轮播图是App首页最常见的UI组件之一,电商首页的促销位、资讯类App的焦点图、社交应用的运营活动位,几乎都离不开它。虽然GitHub上已经有不少成熟的开源库,但作为开发者,理解轮播图的实现原理并亲手封装一个,不仅能解决实际项目需求,对提升组件化思维也大有帮助。本文将基于ViewPager2,从零封装一个支持无限循环、自动播放、指示器联动的通用Banner组件。

一、轮播图的核心需求与整体设计
在动手写代码之前,先梳理清楚一个生产可用的轮播组件需要具备哪些能力。最基础的是数据驱动的页面展示:传入一个数据列表,组件自动渲染每一页;其次是无限循环滑动,用户滑到最后一页还能继续往后滑,形成首尾相接的体验;再次是自动播放与手势交互的结合,无人操作时定时切换,用户触摸时暂停、松手后恢复;最后是可扩展的指示器和暴露页面切换回调,方便业务方定制样式和埋点。
整体设计上,我们采用组合而非继承的思路。自定义一个继承自FrameLayout的BannerView,内部持有ViewPager2,将循环逻辑、定时器逻辑全部收敛在组件内部,对外只暴露简洁的API。这样做的好处是业务方无需关心内部实现,接入成本极低,只需要三行代码就能跑起来一个轮播图。
需要特别说明的是为什么选择ViewPager2而不是旧的ViewPager。ViewPager2基于RecyclerView实现,支持竖直方向滑动、支持DiffUtil数据更新、与Fragment的配合也更灵活,并且官方已停止维护一代版本,新项目没有理由再用旧的。
二、无限循环的实现原理
无限循环是轮播图最核心也最容易出错的部分。业界常见两种方案:第一种是设置一个足够大的整数作为当前页,比如把当前位置设为Integer.MAX_VALUE的一半,再对position % count取模得到真实索引;第二种是Adapter层面增加虚拟页数,返回count * 500这样的虚拟数量,配合取模映射回真实数据。
两种方案各有优劣。取模方案的缺点是滑动到真正的边界时会出现回弹跳变,且getCurrentItem返回的值需要二次换算;而多页缓存方案在数据量较大时初始化会略慢。综合考虑,这里采用方案一的变体:初始位置设为虚拟中间值,同时把offscreenPageLimit设置为1,避免一次性创建过多页面。
class BannerAdapter(private val data: List<String>) :
RecyclerView.Adapter<BannerAdapter.BannerViewHolder>() {
// 虚拟数量放大,配合取模实现伪无限循环
override fun getItemCount(): Int = if (data.size > 1) Int.MAX_VALUE / 2 else data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerViewHolder {
val imageView = AppCompatImageView(parent.context).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
return BannerViewHolder(imageView)
}
override fun onBindViewHolder(holder: BannerViewHolder, position: Int) {
val realPosition = position % data.size // 取模映射回真实索引
holder.imageView.load(data[realPosition]) // 使用Coil加载图片
holder.imageView.setOnClickListener {
onItemClickListener?.invoke(realPosition)
}
}
var onItemClickListener: ((Int) -> Unit)? = null
class BannerViewHolder(val imageView: AppCompatImageView) : RecyclerView.ViewHolder(imageView)
}
这里有个细节值得注意:当数据只有一条时不应启用循环和自动播放,否则单页来回切换的动画会非常怪异,所以getItemCount里做了数据量大于1的判断。另外,调用notifyDataSetChanged刷新数据后,当前虚拟位置可能对不上新数据,需要重新校正到中间位置附近。
三、自动播放与生命周期感知
自动播放的传统做法是Handler加postDelayed,但这种做法有个经典问题:页面不可见时定时器仍在跑,不仅浪费资源,还可能引发内存泄漏。更稳妥的方案是使用LifecycleOwner感知生命周期,在ON_RESUME时启动轮播,在ON_PAUSE时停止。Lifecycle 2.4之后提供的add repeating job或LifecycleEventObserver都能优雅地实现这一点。
class BannerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val handler = Handler(Looper.getMainLooper())
private var intervalMillis = 3000L // 轮播间隔,默认3秒
private var isLooping = false
// 自动播放任务:切到下一页
private val autoPlayTask = object : Runnable {
override fun run() {
viewPager.setCurrentItem(viewPager.currentItem + 1, true)
handler.postDelayed(this, intervalMillis)
}
}
fun startLoop() {
if (isLooping || adapterDataSize <= 1) return
isLooping = true
handler.postDelayed(autoPlayTask, intervalMillis)
}
fun stopLoop() {
isLooping = false
handler.removeCallbacks(autoPlayTask)
}
// 用户按下时暂停,松开后恢复,避免和人手势打架
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> stopLoop()
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> startLoop()
}
return super.dispatchTouchEvent(ev)
}
// 借助生命周期感知,宿主不可见时自动停止轮播
fun bindLifecycle(owner: LifecycleOwner) {
owner.lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_RESUME -> startLoop()
Lifecycle.Event.ON_PAUSE -> stopLoop()
else -> Unit
}
}
})
}
}
除了生命周期,触摸事件的处理也很关键。上面代码重写了dispatchTouchEvent,在ACTION_DOWN时停止定时器,ACTION_UP或ACTION_CANCEL时重新启动。如果不做这个处理,用户正在慢速拖动图片时定时器到点,页面会突然挣脱手指跳到下一页,体验极差。还有一种更彻底的方案是监听ViewPager2的onPageScrollStateChanged,在SCROLL_STATE_DRAGGING时暂停,SETTLING或IDLE时恢复,两种方式任选其一即可。
四、指示器联动与对外API设计
指示器的实现思路比较简单:通过registerOnPageChangeCallback监听页面滑动,把虚拟位置取模后得到真实索引,再刷新指示器选中态。指示器本身可以做成可插拔的接口,默认提供圆点指示器,业务方也可以传入自定义View扩展成数字角标、横线等样式。
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val realPosition = position % data.size
indicatorView.setSelected(realPosition) // 刷新指示器
onPageChangeListener?.invoke(realPosition) // 对外回调真实索引
}
override fun onPageScrolled(
position: Int, positionOffset: Float, positionOffsetPixels: Int
) {
// 可选:根据positionOffset驱动指示器滑动动画
onPageScrolledListener?.invoke(position % data.size, positionOffset)
}
})
对外API的设计原则是简单直接。业务方最常用的入口就是一个setData方法加几个可选配置项,例如切换间隔、是否自动播放、页面间距等。参考业界成熟的Banner库,链式调用是不错的风格,使用方式大致如下:
bannerView.setData(imageUrls)
.setInterval(4000)
.setAutoPlay(true)
.setOnPageChanged { index ->
// 页面切换埋点
}
.setOnItemClickListener { position ->
// 跳转到详情页
}
.start()
最后还要考虑页面切换的视觉效果。ViewPager2通过setPageTransformer支持自定义切换动画,常见的有缩放加深的效果:非当前页缩小到0.85倍并加黑色遮罩,滑动过程中根据transformPage回调的position参数动态计算缩放比和透明度,能做出卡片式的立体轮播效果。需要注意Transformer中如果操作了透明度,要记得处理硬件加速的兼容问题,某些低端机上过度绘制可能引起掉帧,此时可以简化为只做缩放不做遮罩。
五、常见坑点总结
封装过程中有几个容易踩的坑值得记录。第一,ViewPager2内部是RecyclerView,嵌套在ScrollView中时滑动事件冲突,需要通过isUserInputEnabled或者自定义NestedScrollConnection处理;第二,Fragment的View重建时轮播图可能残留旧的Handler回调,务必在onDestroyView中调用stopLoop;第三,取模计算时数据源更新但notifyDataSetChanged未及时调用,会导致索引越界崩溃,刷新数据时要保证原子性;第四,图片加载建议统一交给Coil或Glide处理,并做好占位图和错误图,避免网络图片加载失败时出现白屏。
到这里,一个功能完备的Banner组件就封装完成了。它具备无限循环、自动播放、生命周期感知、可定制指示器四大核心能力,代码总量不到三百行,却覆盖了轮播图开发中绝大多数的坑。理解了这套实现思路之后,再去看那些开源库的源码,你会发现万变不离其宗,无非是在此基础上叠加了更多动画效果和数据类型支持。
Android Banner轮播图ViewPager封装修改时间:2026-09-13 13:48:44