需求分析
- 需要无限循环向右轮播,间隔为3S
- 支持手动向左或向右滑动
- 支持点击跳转目标页面
- 支持关闭
ViewPager2
ViewPager2是ViewPager的升级版本并且在功能和性能上有所提升,ViewPager2主要用在APP中进行页面切换。
ViewPager2本身支持左右滑动,且兼容嵌套滑动容器,这两点为我们的轮播图减少了不少工作量。
如何使用
ViewPager2内部实现依靠的是RecyclerView,所以它的使用方法和RecyclerView是一模一样的。都需要定义Adapter、ViewHolder。
xml
<!--layout_banner.xml-->
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dp_14"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginBottom="@dimen/dp_8"
>
<com.google.android.material.tabs.TabLayout
android:id="@+id/indicatorLayout"
android:layout_width="match_parent"
android:layout_height="@dimen/dp_8"
android:layout_gravity="center"
app:tabBackground="@drawable/circle_red_white_selector"
app:tabGravity="center"
app:tabPadding="0dp"
app:tabMinWidth="0dp"
app:tabMaxWidth="@dimen/dp_8"
app:tabIndicatorHeight="0dp"
/>
</FrameLayout>
</merge>
上面的xml代码,定义了我们轮播图的骨架,由于关闭按钮比较简单,这里省略了。
xml
<!--layout_banner_item.xml-->
<?xml version="1.0" encoding="utf-8"?>
<ImageView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="@dimen/dp_64"
android:scaleType="fitXY"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />
上面的代码定义了每一个轮播图的Item布局,在我们这个需求中只需要展示图片即可。
kotlin
// PagerViewHolder.kt
class PagerViewHolder(
private val bind: LayoutBannerItemBinding,
private val onClickListener:((bean: BannerData)->Unit)?=null
) : RecyclerView.ViewHolder(bind.root) {
/**
* @param bean BannerData 渲染每个轮播图Item的数据源
*/
fun render(bean: BannerData) {
// todo 替换图片加载
bind.imageView.setBackgroundColor(Color.BLUE)
bind.imageView.setOnClickListener {
onClickListener?.invoke(bean)
}
}
}
以上是ViewHolder的定义
kotlin
// BannerAdapter.kt
class BannerAdapter(val list: MutableList<BannerData>) :
RecyclerView.Adapter<PagerViewHolder>() {
private var onClickListener:((bannerData: BannerData)->Unit)?=null
fun setOnClickListener(onClickListener:((bannerData: BannerData)->Unit)?=null){
this.onClickListener=onClickListener
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PagerViewHolder {
return PagerViewHolder(
LayoutBannerItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
){
onClickListener?.invoke(it)
}
}
override fun getItemCount() = list.size
override fun onBindViewHolder(holder: PagerViewHolder, position: Int) {
holder.render(list[position])
}
}
以上是Adapter的定义,到这里ViewPager已经可以正常显示了,这些流程也是和RecyclerView的使用一模一样了。
如何无限循环
到这里ViewPager虽然可以正常显示了,但是在使用的时候我们会发现,虽然可以左右滑,但是滑到最后一张的时候,不能继续左滑,或者在第一张的时候,不可以右滑。
解决这个问题,需要在数据源的首位插入2张图片,详细见下图:
当我们滑动到最右边的3时,也就是理论上的最后一张,此时继续左滑就可以滑动到最右边的1,营造一种进入循环的假象。此时需要调用viewPager的setCurrentItem手动把当前的Item移动到左边的1,如下图所示:
同样的道理,如果当前处于最左边的1时,此时向右滑,会滑动到最左边的3,这时候需要再次调用setCurrentItem移动到最右边的3
对上面的理论进行代码实践:
kotlin
//BannerAdapter.kt
class BannerAdapter {
// ...
/**
* @param data List<AdBean> : 更新的全量数据
* 图片大于一张时,需要在首尾插入图片达到无限轮播的目的
*/
fun updateAllList(data: List<BannerData>) {
list.clear()
if (data.isNotEmpty()) {
if (data.size > 1) {
list.add(data[data.size - 1])
}
list.addAll(data)
if (data.size > 1) {
list.add(data[0])
}
}
notifyDataSetChanged()
}
// ...
}
首先是为Adapter新增一个更新数据的方法,这个方法当图片大于一张的时候,会为首尾插入需要的图片。下一步就是要监听viewPager的页面变化,这里是ViewPager2.OnPageChangeCallback
kotlin
val pageChangeCallback = object : ViewPager2.OnPageChangeCallback(){
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
// todo 当前ViewPager切换时,同时计算tab应该如何切换
}
override fun onPageScrollStateChanged(state: Int) {
super.onPageScrollStateChanged(state)
// 当滑动到最右边时,需要移动到index1的位置
val total = (binding.viewPager.adapter?.itemCount ?: 0)
if (state == ViewPager2.SCROLL_STATE_IDLE && total > 1 && binding.viewPager.currentItem == total - 1) {
binding.viewPager.setCurrentItem(1, false)
}
// 当滑动到最左边的时候,需要移动到index = total-2的位置
if (state == ViewPager2.SCROLL_STATE_IDLE && total > 1 && binding.viewPager.currentItem == 0) {
binding.viewPager.setCurrentItem(total - 2, false)
}
}
}
在onPageScrollStateChanged
处理每次滑动完以后的事件,并移动到相应的位置。其中SCROLL_STATE_IDLE是等page稳定以后在执行,这样移动page会非常无感。
如何自动滑动
想让轮播图自己动起来比较简单,只需要启动定时器或者Handler就可以达到这个目的,这部分的代码放到后面一起展示。
TabLayout实现指示器
TabLayout主要是用于实现选项卡式布局,经常与ViewPager搭配使用,其中TabLayout还提供setupWithViewPager(ViewPager)
方法用于自动与ViewPager绑定。
我们在layout_banner.xml文件中已经定义了TabLayout在布局中的代码了。接下来就是如何绑定,如果正常使用ViewPager只需要调用setupWithViewPager就可以自动绑定了,但是由于我们要达到无限循环的效果,在ViewPager首尾添加了重复数据,所以我们如果直接用setupWithViewPager那么TabLayout就会多两个Tab。
kotlin
// 初始化下方圆点指示器
binding.indicatorLayout.removeAllTabs()
for (i in 0 until data.size) {
val tab = binding.indicatorLayout.newTab()
// 禁用tab点击事件
tab.view.isEnabled = false
binding.indicatorLayout.addTab(tab)
}
以上代码手动添加了正确数量的Tab,除此之外,还需要考虑当ViewPager滑动时TabLayout需要跟随切换Tab
kotlin
val pageChangeCallback = object : ViewPager2.OnPageChangeCallback(){
private var lastPage=-1
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
// 当前ViewPager切换时,同时计算tab应该如何切换
val total = (binding.viewPager.adapter?.itemCount ?: 0)
val curPosition = if (total > 1 && position >= 1 && position <= total - 2) {
position - 1
} else {
null
}
if(curPosition != null && lastPage!=curPosition) {
listener?.invoke(curPosition)
binding.indicatorLayout.getTabAt(curPosition)?.select()
lastPage=curPosition
}
}
override fun onPageScrollStateChanged(state: Int) {
super.onPageScrollStateChanged(state)
// ...同上
}
}
如下图所示:我们可以知道tab的坐标和viewPager的坐标关系是y = position - 1
的关系,再过滤掉边界条件就可以了。
从上面我们可以看到还记录了lastPage这个属性,这是为什么呢?
这是因为我们实现无限轮播用的"障眼法"每次到边界时,都会回调两次相同的positon,这样会导致我们统计上有偏差,所以需要做一个过滤。
封装成组件
以上已经完全实现了所有需求,最后我们把以上部分都封装到一个View中,达到复用的目的
kotlin
// Banner.kt
class Banner @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {
private var binding: LayoutBannerBinding
private var bannerAdapter:BannerAdapter
// 每个页面之间的延迟时间(毫秒)
private val DELAY_TIME_MS: Long = 3000
private val viewPagerHandler = Handler(Looper.getMainLooper())
private var viewPagerRunnable:Runnable
private var listener:((currentPage:Int)->Unit)?=null
init {
binding=LayoutBannerBinding.inflate(LayoutInflater.from(context),this)
bannerAdapter= BannerAdapter(mutableListOf())
binding.viewPager.adapter=bannerAdapter
val pageChangeCallback = object : ViewPager2.OnPageChangeCallback(){
override fun onPageSelected(position: Int) {
// 参上
}
override fun onPageScrollStateChanged(state: Int) {
// 参上
}
}
binding.viewPager.registerOnPageChangeCallback(pageChangeCallback)
viewPagerRunnable = object : Runnable {
override fun run() {
binding.viewPager.apply {
if (currentItem + 1 < (adapter?.itemCount ?: 0)) {
setCurrentItem(currentItem + 1, true)
}
}
viewPagerHandler.postDelayed(this, DELAY_TIME_MS)
}
}
}
/**
* 设置item点击处理
* @param onClickListener Function1<[@kotlin.ParameterName] BannerData, Unit>?
*/
fun setOnClickListener(onClickListener:((bannerData: BannerData)->Unit)?=null){
bannerAdapter.setOnClickListener(onClickListener)
}
/**
* 设置页面切换时的监听
* @param listener Function1<[@kotlin.ParameterName] Int, Unit>
*/
fun setPageChangedListener(listener:(currentPage:Int)->Unit){
this.listener=listener
}
/**
* @param data List<BannerData> : 更新的全量数据
* 用于更新广告数据
*/
fun updateList(data: List<BannerData>) {
bannerAdapter.updateAllList(data)
// 超过一张才需要轮播
if (data.size > 1) {
// 默认展示第一张图
binding.viewPager.setCurrentItem(1, false)
// 初始化下方圆点指示器
binding.indicatorLayout.removeAllTabs()
for (i in 0 until data.size) {
val tab = binding.indicatorLayout.newTab()
tab.view.isEnabled = false
binding.indicatorLayout.addTab(tab)
}
} else {
binding.indicatorLayout.removeAllTabs()
}
}
/**
* 设置视图是否自动滚动轮播
* @param autoScroll Boolean 是否自动滚动
*/
fun autoScroll(autoScroll:Boolean){
viewPagerHandler.removeCallbacks(viewPagerRunnable)
if(autoScroll) {
viewPagerHandler.postDelayed(viewPagerRunnable, DELAY_TIME_MS)
}
}
}
对于使用者只需要
ini
<Banner
android:id="@+id/banner"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
这样我们就优雅的实现了一个无限轮播图组件