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

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

相关推荐
alexhilton1 小时前
务实的模块化:连接模块(wiring modules)的妙用
android·kotlin·android jetpack
ji_shuke2 小时前
opencv-mobile 和 ncnn-android 环境配置
android·前端·javascript·人工智能·opencv
sunnyday04264 小时前
Spring Boot 项目中使用 Dynamic Datasource 实现多数据源管理
android·spring boot·后端
幽络源小助理5 小时前
下载安装AndroidStudio配置Gradle运行第一个kotlin程序
android·开发语言·kotlin
inBuilder低代码平台5 小时前
浅谈安卓Webview从初级到高级应用
android·java·webview
豌豆学姐5 小时前
Sora2 短剧视频创作中如何保持人物一致性?角色创建接口教程
android·java·aigc·php·音视频·uniapp
白熊小北极5 小时前
Android Jetpack Compose折叠屏感知与适配
android
HelloBan5 小时前
setHintTextColor不生效
android
洞窝技术8 小时前
从0到30+:智能家居配网协议融合的实战与思考
android
QING6188 小时前
SupervisorJob子协程异常处理机制 —— 新手指南
android·kotlin·android jetpack