【自定义View】洋葱数学同款Banner的进化-BannerView

开箱即用的源码地址

洋葱数学同款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

  1. 数月前UI大佬要从新定义Banner样式,其中指示器由原点变为了小横杆,而且位于Banner的正下方必定距离。当时业务紧迫,用了一天多的时间匆忙基于ViewPager封装了出来,此时指示器耦合在Banner中。
  2. 以后UI大佬为了挤出一点页面空间,要求在某条件下,指示器小横杆要放进Banner内部,距底部必定距离。又是业务紧迫,用临时代码解决了需求。
  3. 终于业务侧腾出了时间,我赶忙把BannerView重构了,其中解耦了指示器、处理了临时代码、整理归置代码等。
  4. 然后没多久,UI大佬发话说,要从新定义Banner样式...其中页面切换方式由常见ViewPager样式改到了要『从右向左匀速移动的效果』,而且要『手指滑动时要跟ViewPager同样的切换手感』,而且要『一屏多显示的画廊效果』,而且要『页面之间要有间距』。
  5. 因而我就开启了一次踩坑与成长之旅,其中滋味,『真香』!

看下最终手感的效果图: ide

踩坑过程:布局

  1. 毕竟以前基于ViewPager的Banner已经封装好了,并且考虑到『手指滑动时要跟ViewPager同样的切换手感』,我第一反应就是基于以前的代码去作扩展,这样就巧妙的避开了处理切换手感的问题。
  2. 专一解决『从右向左匀速移动的效果』,因而我经过反射的方式用线性Scroller实例替换了ViewPager的mScroller实例,由于以每五秒定时切换一页,且mScroller执行一次动画设置为五秒,这样看起来就实现了『从右向左匀速移动的效果』...
  3. 固然我还经过手势事件DOWN UP等不断切换mScroller的实例,以保持手指切换时的手感...
  4. 以上虽然看起来解决了动效问题。对!它只是看起来解决了问题,但当把这样子的Banner嵌入到列表中时(RecyclerView),性能问题、卡顿Bug就来了!
  5. 而且ViewPager很差处理画廊效果(我不喜欢clipChildren的方式)

因而:post

  1. 因而我痛定思痛决心重写底层实现。
  2. 因而我带着最不想处理的『切换手感问题』去查解决方案。
  3. 因而我打开『小飞机』、打开『Chrome』、输入『RecyclerView实现ViewPager』。
  4. 因而我领会到玉皇大帝给你关上一扇门就会给你打开一扇窗。这扇窗即是PagerSnapHelper

成长总结:性能

  1. 以上是一种极其失败的解决方案!
  2. 不要采用『看起来能解决』的解决方案!
  3. 当你以为实现方案就不常规、不畅、不合理的时候,大几率这方案不可用!
  4. 当你须要一个解决方案的时候,不妨先跟同事聊聊、google一下试试,说不定有意外收获!

思考分析

NOTE动画

  1. 这篇文章咱们专一于BannerView的封装与实现,关于更底层的PagerSnapHelper的原理部分不在范围内,但在我拜读的文章中贴出了一份连接,你们可自行食用。

前路漫漫,咱们先梳理下需求:ui

  1. 要支持两种滚动模式,间隔切换、平滑滚动
  2. 要支持设置视图区域圆角
  3. 要支持设置条目视图圆角(ItemView)(该需求本次未作实现,下文会自动忽略该需求)
  4. 要支持无限循环滚动
  5. 要支持根据BannerView的宽的比值设置ItemView的宽
  6. 要支持设置ItemView之间的间距
  7. 要支持设置滚动间隔,匀速模式要支持设置滚动一页的时间
  8. 要支持设置匀速模式下,手指滑动后,页面停留的时长
  9. 要支持设置ItemView与父WrapperView的对齐方式(决定了itemViewMargin的留白位置)
  10. 要支持设置默认是否开启滚动
  11. 要支持设置数据源为空时的默认View
  12. 要支持数据源只有1张banner时,禁止滚动
  13. 要暴露API控制Banner的自动滚动与暂停
  14. 要支持设置指示器(Indicator),且能灵活控制指示器位置,且与BannerView解耦

🤩这么多需求,不要怕,咱们根据需求来理一遍核心技术点:

  1. 平滑滚动模式可使用RecyclerView+PagerSnapHelper实现,间隔滚动模式能够继续使用ViewPager实现,也可使用前者方式实现。(本文统一使用RecyclerView+PagerSnapHelper方式,不过代码中也留出了接口,可用ViewPager作实现)
  2. 设置圆角仍是采用Xfermode作裁剪合成便可。(该方式在以前的文章ShadowLayout中使用过,故本文再也不赘述)
  3. 需求[4]将adpter中getItemCount()返回Int.MAX_VALUE,再在绑定View时候,用当前的position与真实count求余数,做为真实的position去绑定数据,便可实现。
  4. 需求[4]到[13],都没有技术复杂度,但有业务复杂度,作常规实现便可。
  5. 需求[14]可定义Indicator涉及的接口作代码解耦,并将BannerView继承RelativeLayout,这样Indicator做为子View在xml中可灵活控制位置。

这样一来,实现咱们想要的BannerView只是耐心+时间的问题了。如下,我会挑本次实现中重要的几点来作说明,以下:

  1. RecyclerView+PagerSnapHelper实现的PagerRecyclerView
  2. 生成PagerView实例的工厂PagerViewFactory
  3. Indicator的解耦实现

PagerRecyclerView

看名字便知这是一个用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较为关键的点,其它部分均为业务逻辑的处理与实现,你们可打开源码自行食用。

PagerViewFactory

这里采用了工厂方法模式来建立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
}
复制代码

Indicator的解耦实现

解耦的惯用套路就是抽象方法定义接口。因此咱们定义了两个接口,一个是指示器实例需实现的接口,一个是指示器依赖的外部实现。因此使用这两个接口,能够自定义实现想要的样式。

/** * 指示器实例需实现的接口 */
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哈哈~

我的能力有限,若有不正之处欢迎你们批评指出,我会虚心接受并第一时间修改,以不误导你们

拜读的文章

个人其它文章

相关文章
相关标签/搜索