洋葱数学同款BannerViewjava
支持XML
自定义属性:git
bv_viewHeight
:Banner视图区域的高度,小于等于0时为该布局的高度bv_viewCornerRadius
:视图区域圆角的半径bv_itemViewWidthRatio
:根据该布局宽度的百分比设置ItemView的宽度bv_itemViewMargin
:设置ItemView之间的间距bv_intervalInMillis
:Banner轮换时间(在SMOOTH模式下为Banner从右匀速到左的时间)bv_pageHoldInMillis
:手指滑动后,页面停留的时长(只在SMOOTH模式下生效)bv_scrollMode
:设置Banner滚动模式
INTERVAL
:间隔切换模式SMOOTH
:匀速滚动模式bv_itemViewAlign
:ItemView与父WrapperView的对齐方式(决定了itemViewMargin的留白位置)
CENTER_HORIZONTAL
:水平居中ALIGN_PARENT_LEFT
:居左对齐ALIGN_PARENT_RIGHT
:居右对齐暴露的API
有:github
setBannerViewImpl(impl: IBannerView)
:设置Banner必须的实现类startAutoScroll()
:开始自动滚动(页面数量小于1时不会滚动)stopAutoScroll()
:中止自动滚动/** * 定义页面切换回调 */
interface OnPageChangeListener {
fun onPageSelected(position: Int)
}
interface IBannerViewBase {
fun getCount(): Int
fun getItemView(context: Context): View
fun onBindView(itemView: View, position: Int)
}
/** * BannerView依赖的外部实现 */
interface IBannerView : OnPageChangeListener, IBannerViewBase {
/** * 当count为0时的默认view */
fun getDefaultView(context: Context): View? {
return null
}
/** * 默认关闭自动滚动 */
fun isDefaultAutoScroll(): Boolean {
return false
}
override fun onPageSelected(position: Int) {}
}
复制代码
关于我接手我司Banner控件后的故事大概是这样:app
看下最终手感的效果图: ide
踩坑过程:布局
线性Scroller实例
替换了ViewPager的mScroller
实例,由于以每五秒定时切换一页,且mScroller
执行一次动画设置为五秒,这样看起来就实现了『从右向左匀速移动的效果』...DOWN
UP
等不断切换mScroller
的实例,以保持手指切换时的手感...RecyclerView
),性能问题、卡顿Bug就来了!ViewPager
很差处理画廊效果(我不喜欢clipChildren的方式)因而:post
成长总结:性能
NOTE:动画
PagerSnapHelper
的原理部分不在范围内,但在我拜读的文章中贴出了一份连接,你们可自行食用。前路漫漫,咱们先梳理下需求:ui
🤩这么多需求,不要怕,咱们根据需求来理一遍核心技术点:
平滑滚动模式
可使用RecyclerView+PagerSnapHelper
实现,间隔滚动模式
能够继续使用ViewPager实现,也可使用前者方式实现。(本文统一使用RecyclerView+PagerSnapHelper
方式,不过代码中也留出了接口,可用ViewPager作实现)Xfermode
作裁剪合成便可。(该方式在以前的文章ShadowLayout中使用过,故本文再也不赘述)这样一来,实现咱们想要的BannerView只是耐心+时间的问题了。如下,我会挑本次实现中重要的几点来作说明,以下:
看名字便知这是一个用RecyclerView实现ViewPager功能的类,因此继承自RecyclerView。
它做为BannerView的核心功能实现类,为了与上层解耦(也就是方便切换为其它实现,好比用ViewPager作实现)因此定义接口IPagerViewInstance
。
/** * PagerView功能实例需实现的接口 */
interface IPagerViewInstance {
/** * 设置自动滚动 * @param intervalInMillis: Int 在INTERVAL模式下为页面切换间隔 在SMOOTH模式下为滚动一页所需时间 */
fun startAutoScroll(intervalInMillis: Int)
/** * 中止自动滚动 */
fun stopAutoScroll()
/** * 获取当前Item的位置(List的索引) */
fun getCurrentPosition(): Int
/** * 获取当前真实的Item的位置(List的索引) */
fun getRealCurrentPosition(realCount: Int): Int
/** * 设置平滑模式是否开启,不然为间隔切换模式 */
fun setSmoothMode(enabled: Boolean)
/** * 设置页面停留时长 */
fun setPageHoldInMillis(pageHoldInMillis: Int)
/** * 设置页面切换回调 */
fun setOnPageChangeListener(listener: OnPageChangeListener)
/** * 通知数据刷新 */
fun notifyDataSetChanged()
}
复制代码
关于PagerSnapHelper
的使用极其简单,只需建立出实例,attachToRecyclerView一下,便可让RecyclerView摇身一变成为ViewPager同样。(这里实在让人惊叹!!咱们都应该追求这种API的极致设计)
/** * 滑动到具体位置帮助器 */
private var mSnapHelper: PagerSnapHelper = PagerSnapHelper()
... 省略代码
init {
mSnapHelper.attachToRecyclerView(this)
... 省略代码
}
复制代码
关于间隔切换模式
匀速滚动模式
的实现主要是在startTimer()
方法中,二者的区别在于Timer的间隔时间不一样、回调中执行的方法不一样。其中匀速模式的Timer间隔时间须要使用外部设置的滚动一屏的时间
、一屏的宽度
、每次scrollBy的距离
计算而来。
/** * 开始定时器 */
private fun startTimer() {
mTimer?.cancel()
if (mWidth > 0 && mFlagStartTimer && context != null && context is Activity) {
mTimer = timer(initialDelay = mDelayedTime, period = mPeriodTime) {
if (mScrollState == SCROLL_STATE_IDLE) {
(context as Activity).runOnUiThread {
if (mSmoothMode) {
scrollBy(DEFAULT_PERIOD_SCROLL_PIXEL, 0)
triggerOnPageSelected()
} else {
smoothScrollToPosition(++mOldPosition)
mPageChangeListener?.onPageSelected(mOldPosition)
}
}
}
}
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mWidth = (w - paddingLeft - paddingRight).toFloat()
mHeight = (h - paddingTop - paddingBottom).toFloat()
//计算匀速滚动的时间间隔
if (mSmoothMode) {
mPeriodTime = (mSmoothSpeed / (mWidth / DEFAULT_PERIOD_SCROLL_PIXEL)).toLong()
}
if (mTimer == null) {
startTimer()
}
}
复制代码
页面选中是根据PagerSnapHelper
中提供的findSnapView
方法,先找到Snap(就是当前的目标View),再找它的位置,固然还需用一个变量记录一下,防止屡次触发回调。
/** * 触发OnPageSelected回调 */
private fun triggerOnPageSelected() {
val layoutManager = getLinearLayoutManager()
val view = mSnapHelper.findSnapView(layoutManager)
if (view != null) {
val position = layoutManager.getPosition(view)
//防止同一位置屡次触发
if (position != mOldPosition) {
mOldPosition = position
mPageChangeListener?.onPageSelected(position)
}
}
}
复制代码
还有一个值得说道的点是初始化时须要矫正Snap的位置,由于PagerSnapHelper手指滑动的时候才工做让RecyclerView滑动出ViewPager的感受,因此初始化时不矫正会发现选中的页面不居中显示,仍是一个RecyclerView的样子。那如何矫正呢?这里去看了PagerSnapHelper实现,搬过来,稍加修改便可。
/** * 矫正首次初始化时SnapView的位置 */
private fun correctSnapViewPosition() {
val layoutManager = getLinearLayoutManager()
val snapView = mSnapHelper.findSnapView(layoutManager)
if (snapView != null) {
val snapDistance = mSnapHelper.calculateDistanceToFinalSnap(layoutManager, snapView)
if (snapDistance != null) {
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
//咱们把源码的smoothScrollBy改成scrollBy,这样视觉上觉察不出矫正过程
scrollBy(snapDistance[0], snapDistance[1])
}
//首次触发回调
triggerOnPageSelected()
}
}
}
/** * 这是源码 */
void snapToTargetExistingView() {
if (this.mRecyclerView != null) {
LayoutManager layoutManager = this.mRecyclerView.getLayoutManager();
if (layoutManager != null) {
View snapView = this.findSnapView(layoutManager);
if (snapView != null) {
int[] snapDistance = this.calculateDistanceToFinalSnap(layoutManager, snapView);
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
this.mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}
}
}
}
复制代码
以上是我认为PagerRecyclerView
较为关键的点,其它部分均为业务逻辑的处理与实现,你们可打开源码自行食用。
这里采用了工厂方法模式来建立Banner底层的核心实现。
首先定义了BannerView实例接口,它将做为工厂实例的构造方法参数,用于区分建立底层实现。
interface IBannerViewBase {
fun getCount(): Int
fun getItemView(context: Context): View
fun onBindView(itemView: View, position: Int)
}
/** * 定义BannerView实例接口 */
interface IBannerViewInstance : IBannerViewBase {
fun getContext(): Context
fun isSmoothMode(): Boolean
fun getItemViewWidth(): Int
fun getItemViewMargin(): Int
fun getItemViewAlign(): Int
}
复制代码
工厂有个getPagerView()
的方法,来建立Banner核心实现
/** * 工厂根据参数建立对应PagerView实例 */
override fun getPagerView(): IPagerViewInstance {
return if (bannerView.isSmoothMode()) {
casePagerRecycler(true)
} else {
if (intervalUseViewPager) {
//这里能够根据须要用ViewPager作底层实现
throw IllegalStateException("这里未使用ViewPager作底层实现")
} else {
casePagerRecycler(false)
}
}
}
复制代码
这里就是建立了以前写好的PagerRecyclerView
,其实就是建立配置使用一个RecyclerView的过程。
/** * 处理PagerRecyclerView */
private fun casePagerRecycler(isSmoothMode: Boolean): IPagerViewInstance {
val recyclerView = PagerRecyclerView(bannerView.getContext())
recyclerView.layoutManager = LinearLayoutManager(bannerView.getContext(), LinearLayoutManager.HORIZONTAL, false)
recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemCount(): Int {
return Int.MAX_VALUE
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (!isActivityDestroyed(holder.itemView.context)) {
val realPos = position % bannerView.getCount()
bannerView.onBindView(holder.itemView.findViewById(R.id.id_real_item_view), realPos)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val itemWrapper = LayoutInflater.from(parent.context).inflate(
R.layout.layout_banner_item_wrapper,
parent,
false
) as RelativeLayout
//处理ItemViewWrapper的宽
itemWrapper.layoutParams.width = bannerView.getItemViewWidth() + bannerView.getItemViewMargin()
//外部实际的ItemView
val itemView = bannerView.getItemView(parent.context)
itemView.id = R.id.id_real_item_view
val ivParams = RelativeLayout.LayoutParams(
bannerView.getItemViewWidth(),
ViewGroup.LayoutParams.MATCH_PARENT
)
ivParams.addRule(bannerView.getItemViewAlign())
//添加ItemView到Wrapper
itemWrapper.addView(itemView, ivParams)
return object : RecyclerView.ViewHolder(itemWrapper) {}
}
}
//初始化位置
recyclerView.scrollToPosition(bannerView.getCount() * 100)
recyclerView.setSmoothMode(isSmoothMode)
return recyclerView
}
复制代码
解耦的惯用套路就是抽象方法定义接口。因此咱们定义了两个接口,一个是指示器实例需实现的接口,一个是指示器依赖的外部实现。因此使用这两个接口,能够自定义实现想要的样式。
/** * 指示器实例需实现的接口 */
interface IIndicatorInstance {
/** * 设置外部实现 */
fun setIndicator(impl: IIndicator)
/** * 从新布局 */
fun doRequestLayout()
/** * 从新绘制 */
fun doInvalidate()
}
/** * 指示器依赖的外部实现 */
interface IIndicator {
/** * 获取adapter总数目 */
fun getCount(): Int
/** * 获取当前选中页面的索引 */
fun getCurrentIndex(): Int
}
复制代码
对于咱们此次实现的CrossBarIndicator
,它就是一个常规的自定义View,这里已没有什么好说的啦。重点要说的是需求中有一条且能灵活控制指示器位置,如何实现呢?需求分析时说了,咱们的BannerView是一个RelativeLayout,Indicator做为其子View能够很方便的控制其位置。
而后,看下BannerView中的关键代码:
override fun onFinishInflate() {
super.onFinishInflate()
findIndicator()
}
/** * 在子View中找到指示器 */
private fun findIndicator() {
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child is IIndicatorInstance) {
//布局填充完毕时,找到子View中的Indicator,并保存下来
mIndicator = child
return
}
}
}
/** * 初始化view */
private fun initView() {
if (mBannerViewImpl != null && mWidth > 0) {
val bvImpl = mBannerViewImpl!!
removeAllViews()
... 省略代码
//初始化指示器
if (mIndicator != null) {
mIndicator?.setIndicator(object : IIndicator {
override fun getCount(): Int {
return bvImpl.getCount()
}
override fun getCurrentIndex(): Int {
return mPagerViewInstance.getRealCurrentPosition(bvImpl.getCount())
}
})
//把指示器再添加回去
addView(mIndicator as View)
}
}
}
复制代码
到这里总体要说的就完结了,整个BannerView的实现细节、逻辑仍是不少的,不过复杂度倒没那么高,建议食用源码,若有疑问欢迎留言~ O(∩_∩)O哈哈~
我的能力有限,若有不正之处欢迎你们批评指出,我会虚心接受并第一时间修改,以不误导你们。