开箱即用的自定义Banner

自定义Banner用法明细

支持XML自定义属性:

  • 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有:

  • setBannerViewImpl(impl: IBannerView):设置Banner必须的实现类
  • startAutoScroll():开始自动滚动(页面数量小于1时不会滚动)
  • stopAutoScroll():停止自动滚动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 定义页面切换回调
*/
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) {}
}

源码地址:

https://github.com/drawf/SourceSet/tree/master/app/src/main/java/me/erwa/sourceset/view/banner

思考分析

NOTE:这篇文章我们专注于BannerView的封装与实现,关于更底层的PagerSnapHelper的原理部分不在范围内,但在文末我拜读的文章中贴出了一份链接,大家可自行食用。

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

  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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 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的极致设计)

1
2
3
4
5
6
7
8
9
/**
* 滑动到具体位置帮助器
*/
private var mSnapHelper: PagerSnapHelper = PagerSnapHelper()
... 省略代码
init {
mSnapHelper.attachToRecyclerView(this)
... 省略代码
}

关于间隔切换模式 匀速滚动模式的实现主要是在startTimer()方法中,两者的区别在于Timer的间隔时间不同、回调中执行的方法不同。其中匀速模式的Timer间隔时间需要使用外部设置的滚动一屏的时间、一屏的宽度、每次scrollBy的距离计算而来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 开始定时器
*/
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),再找它的位置,当然还需用一个变量记录一下,防止多次触发回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 触发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实现,搬过来,稍加修改即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 矫正首次初始化时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实例接口,它将作为工厂实例的构造方法参数,用于区分创建底层实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 工厂根据参数创建对应PagerView实例
*/
override fun getPagerView(): IPagerViewInstance {
return if (bannerView.isSmoothMode()) {
casePagerRecycler(true)
} else {
if (intervalUseViewPager) {
//这里可以根据需要用ViewPager做底层实现
throw IllegalStateException("这里未使用ViewPager做底层实现")
} else {
casePagerRecycler(false)
}
}
}

这里就是创建了之前写好的PagerRecyclerView,其实就是创建配置使用一个RecyclerView的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 处理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的解耦实现

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 指示器实例需实现的接口
*/
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中的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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哈哈~

个人能力有限,如有不正之处欢迎大家批评指出,我会虚心接受并第一时间修改,以不误导大家。

拜读的文章

SnapHelper硬核讲解:

https://juejin.im/post/5cbe78ae5188250a6b183872

坚持原创技术分享,您的支持将鼓励我继续创作!