【Android】ViewPager2+TabLayout实现无限轮播图

需求分析

  • 需要无限循环向右轮播,间隔为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"
/>

这样我们就优雅的实现了一个无限轮播图组件

相关推荐
深海呐3 小时前
Android 最新的AndroidStudio引入依赖失败如何解决?如:Failed to resolve:xxxx
android·failed to res·failed to·failed to resol·failed to reso
解压专家6663 小时前
安卓解压软件推荐:高效处理压缩文件的实用工具
android·智能手机·winrar·7-zip
Rverdoser3 小时前
Android 老项目适配 Compose 混合开发
android
️ 邪神5 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】标题栏
android·flutter·ios·鸿蒙·reatnative
努力遇见美好的生活5 小时前
Mysql每日一题(行程与用户,困难※)
android·数据库·mysql
图王大胜7 小时前
Android Framework AMS(17)APP 异常Crash处理流程解读
android·app·异常处理·ams·crash·binderdied·讣告
ch_s_t8 小时前
电子商务网站之首页设计
android
豆 腐10 小时前
MySQL【四】
android·数据库·笔记·mysql
想取一个与众不同的名字好难12 小时前
android studio导入OpenCv并改造成.kts版本
android·ide·android studio
Jewel10513 小时前
Flutter代码混淆
android·flutter·ios