【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"
/>

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

相关推荐
千里马学框架6 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台6 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone7 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc7 小时前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo9 小时前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077009 小时前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼10 小时前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone11 小时前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen11 小时前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone11 小时前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui