Android 自动循环播放轮播图(Banner)实现

1 阅读12分钟

Record_2025-07-03-15-13-20_3b63b24fd8f885e4d17258d233ca2af0.gif

项目需要一个自动且循环播放的轮播图,忽然想起来原先都是搞个三方库直接展示了,没静下心来搞过这个需求.趁此机会,梳理实现了一下自动循环播放的轮播图

1.需求梳理

下面是要实现的需求

  • 自动播放
  • 循环播放
  • 触摸暂停自动播放
  • 优化自动播放的时候页面切换的速度和插值器(未自定义属性)
  • 圆角/指针/矩形和圆形
  • 指针间距/指针位置

即是要实现一个能自动,循环,且配置了圆形和矩形指针的控件

2.实现路径

整理下要实现的需求,自动,循环,触摸暂停,切换速度,指针样式,这些功能一步步分解实现.然后再结合成控件.

实现组成:

  • ViewPager2(展示内容)
  • 自定义指针(指针)

2.1 自动播放实现

因为 用的是ViewPager2实现的此需求 所以自动播放的实现 定时调用切换Vp2 就可以了

定时器实现多种多样可自己选择实现:

  • Handler
  • Timer
  • 协程+死循环

// 协程作用域,使用 Main 调度器
private val viewJob = SupervisorJob()
private val coroutineScope = CoroutineScope(viewJob + Dispatchers.Main)
// 轮播任务
private var bannerJob: Job? = null


/**
 * 开始自动轮播
 */
fun startAutoScroll() {
    // 如果已经有轮播任务或者数据不足,则不启动
    if (bannerJob != null || (mAdapter?.itemCount ?: 0) <= 1) return
    bannerJob = coroutineScope.launch {
        while (isActive) {
            delay(delayMillis.toLong())
            binding.viewPager.post {
                val currentItem: Int = binding.viewPager.getCurrentItem()
                MyPagerHelper.setCurrentItem(binding.viewPager, currentItem + 1, 800)
            }
        }
    }
}

/**
 * 停止自动轮播
 */
fun stopAutoScroll() {
 
    bannerJob?.cancel()
    bannerJob = null
}

2.2 循环播放

循环播放是通过将条目数无限大 然后再根据具体的条目数算出来展示那条数据实现的


/**
 * 开始自动轮播
 */
fun startAutoScroll() {
    // 如果已经有轮播任务或者数据不足,则不启动
    if (bannerJob != null || (mAdapter?.itemCount ?: 0) <= 1) return
    bannerJob = coroutineScope.launch {
        while (isActive) {
            delay(delayMillis.toLong())
            binding.viewPager.post {
                val currentItem: Int = binding.viewPager.getCurrentItem()
                //切换到指定的条目  binding.viewPager.setCurrentItem(currentItem + 1, true)
                // 处理条目切换 动画
                MyPagerHelper.setCurrentItem(binding.viewPager, currentItem + 1, 800)
            }
        }
    }
}



class BannerAdapter(var mContext: Context,var radius:Int): BaseRvAdapter<BannerItem, ItemBannerBinding>() {
    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int
    ): BaseRvViewHolder<ItemBannerBinding> {
        return BaseRvViewHolder(ItemBannerBinding.inflate(LayoutInflater.from(mContext),parent,false))
    }

    override fun onBindViewHolder(
        holder: BaseRvViewHolder<ItemBannerBinding>,
        position: Int
    ) {
        val realPosition: Int = position % getData().size
        val bean: BannerItem? = getItem(realPosition)
        holder.binding.imageView.shapeAppearanceModel = holder.binding.imageView.shapeAppearanceModel
            .toBuilder()
            .setAllCorners(CornerFamily.ROUNDED, radius.toFloat())
            .build()
        GlideUtil.getInstance().loadImage(mContext,bean?.imageUrl?:"",holder.binding.imageView)
    }

     override fun getItemCount(): Int {
        // 返回极大值,实现无限循环效果
        return if (getData().size > 1) Int.Companion.MAX_VALUE else getData().size
    }


}

2.3 Vp2切换动画速度以及插值器处理

/**
 * 设置当前Item 切换时长
 * @param pager    viewpager2
 * @param item     下一个跳转的item
 * @param duration scroll时长
 */
fun setCurrentItem(pager: ViewPager2, item: Int, duration: Long) {
    val currentItem = pager.currentItem
    // 1. 目标页面与当前页面相同时,直接返回,避免无效动画
    if (item == currentItem) {
        return
    }

    // 2. 处理 ViewPager2 未测量的情况(宽度为 0 时,等待布局完成后再执行)
    val pagePxWidth = pager.width
    if (pagePxWidth <= 0) {
        pager.post { setCurrentItem(pager, item, duration) }
        return
    }

    // 3. 计算需要拖拽的总像素(支持正向/反向滑动)
    val pxToDrag = pagePxWidth * (item - currentItem)

    // 4. 使用局部变量保存 previousValue,避免多实例共享冲突(核心优化)
    var previousValue = 0

    val animator = ValueAnimator.ofInt(0, pxToDrag)
    animator.addUpdateListener { animation ->
        val currentValue = animation.animatedValue as Int
        val currentPxToDrag = (currentValue - previousValue).toFloat()
        // 调用 fakeDragBy 实现滑动(注意负号:模拟用户拖拽方向)
        pager.fakeDragBy(-currentPxToDrag)
        previousValue = currentValue
    }

    animator.addListener(object : Animator.AnimatorListener {
        private var isFakeDragStarted = false

        override fun onAnimationStart(animation: Animator) {
            // 开始假拖拽,标记状态
            pager.beginFakeDrag()
            isFakeDragStarted = true
        }

        override fun onAnimationEnd(animation: Animator) {
            if (isFakeDragStarted) {
                pager.endFakeDrag() // 结束假拖拽
                isFakeDragStarted = false
            }
        }

        override fun onAnimationCancel(animation: Animator) {
            // 2. 动画取消时必须结束假拖拽,避免状态残留
            if (isFakeDragStarted) {
                pager.endFakeDrag()
                isFakeDragStarted = false
            }
        }

        override fun onAnimationRepeat(animation: Animator) {}
    })

    animator.interpolator = AccelerateDecelerateInterpolator()
    animator.duration = duration
    animator.start()
}

2.4 处理滑动时暂停自动切换的逻辑

Vp2 拦截onTouch事件 所以处理触摸滑动 无法直接实现 需要在父布局做拦截分发实现或者直接监听滑动状态 取消自动播放 这里选择后者

   binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
        override fun onPageScrollStateChanged(state: Int) {
            super.onPageScrollStateChanged(state)
            if (state == ViewPager2.SCROLL_STATE_DRAGGING) {
                // 用户开始拖拽,暂停自动播放
                stopAutoScroll()
            } else if (state == ViewPager2.SCROLL_STATE_IDLE) {
                // 滑动结束,恢复自动播放
                startAutoScroll()
            }
        }
        // 处理Vp2切换的时候指针切换 onPageSelect 方法比较慢 在这里处理
        override fun onPageScrolled(
            position: Int, positionOffset: Float, positionOffsetPixels: Int
        ) {
            super.onPageScrolled(position, positionOffset, positionOffsetPixels)

            val indicatorCount = binding.indicatorContainer.childCount
            if (indicatorCount == 0) return

            // 计算当前滑动的两个页面对应的指示器
            val currentPos = position % indicatorCount
            val nextPos = (position + 1) % indicatorCount
            if (indicatorType!=2){
                // 当滑动超过一半时,提前更新指示器状态
                if (positionOffset > 0.5f) {
                    updateIndicatorStatus(nextPos)
                } else {
                    updateIndicatorStatus(currentPos)
                }
            }

        }
    })


2.5 添加指针

设置数据的时候添加指针

/**
 * 设置 Banner 数据
 * @param data Banner 数据列表
 */
fun setBannerData(data: List<BannerItem>) {
    if (data.isEmpty()) return
    mAdapter?.setNewData(data.toMutableList())
    // 计算初始位置,确保可以双向滚动
    val initialPosition = Int.MAX_VALUE / 2 - (Int.MAX_VALUE / 2 % data.size)
    binding.viewPager.setCurrentItem(initialPosition, false)
    if (indicatorType!=2){
        for (i in 0 until data.size) {
            if (i == initialPosition % data.size) {
                curPosition = i
            }
            val indicator = RoundedRectangleIndicatorView(context).apply {
                setDefaultBackgroundColor(indicatorDefaultColor)
                setSelectedBackgroundColor(indicatorSelectedColor)
                setIndicatorWidth(indicatorCustomWidth.toFloat())
                setIndicatorHeight(indicatorCustomHeight.toFloat())
                setCornerRadius(indicatorCornerRadius.toFloat())
                setIndicatorSpacing(indicatorSpacing.toFloat())
                if (indicatorType == 1) {
                    setIndicatorShape(RoundedRectangleIndicatorView.Shape.CIRCLE)
                } else  if (indicatorType == 0){
                    setIndicatorShape(RoundedRectangleIndicatorView.Shape.RECTANGLE)
                }

                // 初始状态:第一个指示器选中
                setSelectedStatus(i == initialPosition % data.size)
            }
            // 设置指示器间距(通过布局参数)
            val lp = FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT
            )
            if (i > 0) lp.leftMargin = indicatorSpacing // 从第二个开始添加左间距
            binding.indicatorContainer.addView(indicator, lp)

        }
    }


    // 如果启用自动轮播且数据数量大于1,则开始轮播
    if (isAutoPlay && data.size > 1) {
        startAutoScroll()
    }
}

3.核心代码

3.1 自定义属性

<declare-styleable name="AutoBannerViewStyle">
    <!-- 轮播相关 -->
    <attr name="delayTime" format="integer" /> <!-- 轮播间隔(毫秒) -->
    <attr name="bannerCornerSize" format="dimension" /> <!-- 轮播图圆角大小 -->
    <attr name="isAutoPlay" format="boolean" /> <!-- 是否自动轮播 -->
    <!-- 指示器位置:在ViewPager下方(默认)/与ViewPager底部对齐 -->
    <attr name="indicatorPosition" format="enum">
        <enum name="belowViewPager" value="0" /> <!-- 在ViewPager下方 -->
        <enum name="alignViewPagerBottom" value="1" /> <!-- 与ViewPager底部对齐 -->
    </attr>

    <attr name="indicatorGravity" format="enum">
        <enum name="left" value="0x03" />     <!-- Gravity.LEFT -->
        <enum name="center" value="0x01" />   <!-- Gravity.CENTER_HORIZONTAL -->
        <enum name="right" value="0x05" />    <!-- Gravity.RIGHT -->
        <enum name="start" value="0x800003" /> <!-- Gravity.START -->
        <enum name="end" value="0x800005" />   <!-- Gravity.END -->
    </attr>
    <!-- 指示器相关 -->
    <attr name="indicatorMargin" format="dimension" /> <!-- 指示器顶部边距(距离轮播图底部) -->
    <attr name="indicatorMarginSpacing" format="dimension" /> <!-- 指示器之间的间距 -->
    <attr name="indicatorStartSpacing" format="dimension" /> <!-- 指示器距离两边距离 -->

    <attr name="indicatorDefaultColor" format="color" /> <!-- 指示器默认颜色 -->
    <attr name="indicatorSelectedColor" format="color" /> <!-- 指示器选中颜色 -->
    <attr name="indicatorCustomWidth" format="dimension" /> <!-- 指示器宽度 -->
    <attr name="indicatorCustomHeight" format="dimension" /> <!-- 补充:指示器高度(可选) -->
    <attr name="indicatorCornerRadius" format="dimension" /> <!-- 补充:指示器圆角(可选) -->
    <attr name="indicatorType" format="enum">
        <enum name="rectangle" value="0" />
        <enum name="circle" value="1" />
        <enum name="none" value="2" />
    </attr>
</declare-styleable>
<!-- 指针自定义属性 -->
<declare-styleable name="RoundedRectangleControl">
    <attr name="defaultColor" format="color" />
    <attr name="selectedColor" format="color" />
    <attr name="cornerIndicatorRadius" format="dimension" />
    <attr name="isSelected" format="boolean" />
    <attr name="indicatorPadding" format="dimension" />
    <attr name="indicatorSpacing" format="dimension" />
    <attr name="indicatorWidth" format="dimension" />  <!-- 指示器宽度 -->
    <attr name="indicatorHeight" format="dimension" /> <!-- 指示器高度 -->
    <attr name="indicatorShape" format="enum">
        <enum name="rectangle" value="0" />
        <enum name="circle" value="1" />
    </attr>
</declare-styleable>

3.2 自定义BannerView

package com.qianrun.voice.common.view.banner

import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.viewpager2.widget.ViewPager2
import com.blankj.utilcode.util.SizeUtils
import com.qianrun.voice.common.R
import com.qianrun.voice.common.databinding.LayoutAutoBannerBinding
import com.qianrun.voice.common.view.adapter.BannerAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch


/**
 * 自动轮播 Banner 组件
 * 支持自定义轮播间隔、圆角大小、指示器样式等属性
 */
class AutoBannerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    // 使用 ViewBinding 绑定布局
    private val binding: LayoutAutoBannerBinding = LayoutAutoBannerBinding.inflate(LayoutInflater.from(context), this, true)

    // 协程作用域,使用 Main 调度器
    private val viewJob = SupervisorJob()
    private val coroutineScope = CoroutineScope(viewJob + Dispatchers.Main)
    // 轮播任务
    private var bannerJob: Job? = null

    // Banner 适配器
    private var mAdapter: BannerAdapter? = null

    // 轮播配置参数
    private var delayMillis = 3000          // 轮播间隔时间(毫秒)
    private var cornerSize = 20             // 圆角大小(dp)

    private var isAutoPlay = true           // 是否自动轮播

    // 指示器配置参数(从自定义属性获取)
    private var indicatorMarginTop = SizeUtils.dp2px(10f) // 指示器距离轮播图底部的距离(px)
    private var indicatorStartSpacing = SizeUtils.dp2px(5f) // 指示器距离轮播图底部的距离(px)
    private var indicatorSpacing = SizeUtils.dp2px(10f)   // 指示器之间的间距(px)
    private var indicatorDefaultColor = 0xFFE0F2FE.toInt() // 指示器默认颜色
    private var indicatorSelectedColor = 0xFF3B82F6.toInt() // 指示器选中颜色
    private var indicatorCustomWidth = SizeUtils.dp2px(9f)  // 指示器宽度(px)
    private var indicatorCustomHeight = SizeUtils.dp2px(3f) // 指示器高度(px)
    private var indicatorCornerRadius = SizeUtils.dp2px(2f) // 指示器圆角(px)
    private var isAlignViewPagerBottom = false // 是否与ViewPager底部对齐(默认false:在下方)
    private var indicatorGravity = 2 // 指针内容位置
    private var indicatorType = 2 // 指针样式 0 时矩形 1 是圆形 2无指针

    init {
        initAttrs(attrs)
        initView()
    }

    /**
     * 初始化自定义属性
     */
    @SuppressLint("CustomViewStyleable")
    private fun initAttrs(attrs: AttributeSet?) {
        attrs?.let {
            context.obtainStyledAttributes(it, R.styleable.AutoBannerViewStyle).apply {
                // 指针位置
                isAlignViewPagerBottom = getInt(R.styleable.AutoBannerViewStyle_indicatorPosition, 0) == 1
                //指针内容位置
                indicatorGravity = getInt(R.styleable.AutoBannerViewStyle_indicatorGravity, Gravity.CENTER)
                // 指针类型
                indicatorType = getInt(R.styleable.AutoBannerViewStyle_indicatorType, 2)
                // 切换是时间
                delayMillis = getInteger(R.styleable.AutoBannerViewStyle_delayTime, 3000)
                //轮播图圆角
                cornerSize = getDimensionPixelSize(R.styleable.AutoBannerViewStyle_bannerCornerSize, SizeUtils.dp2px(10f))
                //指针轮播图山下距离
                indicatorMarginTop = getDimensionPixelSize(R.styleable.AutoBannerViewStyle_indicatorMargin, SizeUtils.dp2px(10f))
                //距离两边距离
                indicatorStartSpacing = getDimensionPixelSize(R.styleable.AutoBannerViewStyle_indicatorStartSpacing, SizeUtils.dp2px(10f))
                //间距
                indicatorSpacing = getDimensionPixelSize(R.styleable.AutoBannerViewStyle_indicatorMarginSpacing, SizeUtils.dp2px(10f))
                //是否自动播放
                isAutoPlay = getBoolean(R.styleable.AutoBannerViewStyle_isAutoPlay, true)


                // 指示器样式相关
                indicatorDefaultColor = getColor(R.styleable.AutoBannerViewStyle_indicatorDefaultColor, 0xFFE0F2FE.toInt())
                indicatorSelectedColor = getColor(R.styleable.AutoBannerViewStyle_indicatorSelectedColor, 0xFF3B82F6.toInt())
                //指针宽度
                indicatorCustomWidth = getDimensionPixelSize(R.styleable.AutoBannerViewStyle_indicatorCustomWidth, SizeUtils.dp2px(9f))
                // 高度
                indicatorCustomHeight = getDimensionPixelSize(R.styleable.AutoBannerViewStyle_indicatorCustomHeight, SizeUtils.dp2px(3f))

                recycle()
            }
        }
    }

    /**
     * 核心:修改约束实现位置切换
     */
    private fun updateIndicatorPosition(alignBottom: Boolean) {
        // 获取两者的布局参数(约束布局参数)
        val viewPagerLp = binding.viewPager.layoutParams as ConstraintLayout.LayoutParams
        val indicatorLp = binding.indicatorContainer.layoutParams as ConstraintLayout.LayoutParams
        if (alignBottom) {
            // 场景2:与ViewPager底部对齐(在ViewPager内部底部)
            // 1. ViewPager的底部约束到父容器(充满高度)
            viewPagerLp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
            viewPagerLp.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
            viewPagerLp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
            viewPagerLp.bottomMargin = 0

            // 2. 指示器容器的底部也约束到父容器(与ViewPager底部齐平)
            if (indicatorType!=2){
                indicatorLp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
                indicatorLp.bottomMargin = indicatorMarginTop // 可根据需求添加与父容器底部的间距
                }
        } else {
            // 场景1:在ViewPager下方(有间距)
            // 1. ViewPager的底部约束到指示器容器的顶部(ViewPager高度不包含指示器)
            viewPagerLp.bottomToTop = binding.indicatorContainer.id
            viewPagerLp.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
            viewPagerLp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
            viewPagerLp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
            viewPagerLp.bottomMargin = indicatorMarginTop
            viewPagerLp.height = 0
            if (indicatorType!=2){
                // 2. 指示器容器的顶部约束到ViewPager的底部,并添加间距
                indicatorLp.topMargin = indicatorMarginTop // 间距
                indicatorLp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID // 指示器底部贴父容器
                indicatorLp.bottomMargin = 0
            }


        }

        if (indicatorType!=2){
            if (indicatorGravity == Gravity.START || indicatorGravity == Gravity.LEFT) {
                indicatorLp.marginStart = indicatorStartSpacing
                indicatorLp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
                indicatorLp.endToEnd = ConstraintLayout.LayoutParams.UNSET
            } else if (indicatorGravity == Gravity.END || indicatorGravity == Gravity.RIGHT) {
                indicatorLp.marginEnd = indicatorStartSpacing
                indicatorLp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
                indicatorLp.startToStart = ConstraintLayout.LayoutParams.UNSET
            } else {
                indicatorLp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
                indicatorLp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
            }
            binding.indicatorContainer.layoutParams = indicatorLp
        }


        // 应用修改后的约束
        binding.viewPager.layoutParams = viewPagerLp

    }


    /**
     * 初始化视图
     */
    private fun initView() {
        updateIndicatorPosition(isAlignViewPagerBottom)
        mAdapter = BannerAdapter(context, cornerSize)
        binding.viewPager.offscreenPageLimit = 3
        binding.viewPager.adapter = mAdapter
        // 设置初始位置,实现无限轮播效果
        binding.viewPager.setCurrentItem(Int.MAX_VALUE / 2, false)
        binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
            override fun onPageScrollStateChanged(state: Int) {
                super.onPageScrollStateChanged(state)
                if (state == ViewPager2.SCROLL_STATE_DRAGGING) {
                    // 用户开始拖拽,暂停自动播放
                    stopAutoScroll()
                } else if (state == ViewPager2.SCROLL_STATE_IDLE) {
                    // 滑动结束,恢复自动播放
                    startAutoScroll()
                }
            }

            override fun onPageScrolled(
                position: Int, positionOffset: Float, positionOffsetPixels: Int
            ) {
                super.onPageScrolled(position, positionOffset, positionOffsetPixels)

                val indicatorCount = binding.indicatorContainer.childCount
                if (indicatorCount == 0) return

                // 计算当前滑动的两个页面对应的指示器
                val currentPos = position % indicatorCount
                val nextPos = (position + 1) % indicatorCount
                if (indicatorType!=2){
                    // 当滑动超过一半时,提前更新指示器状态
                    if (positionOffset > 0.5f) {
                        updateIndicatorStatus(nextPos)
                    } else {
                        updateIndicatorStatus(currentPos)
                    }
                }

            }
        })

    }

    var curPosition = 0

    // 抽取通用的更新方法
    private fun updateIndicatorStatus(selectPosition: Int) {
        if (selectPosition == curPosition) return // 避免重复更新
        binding.indicatorContainer.post {
            (binding.indicatorContainer.getChildAt(
                curPosition
            ) as? RoundedRectangleIndicatorView)?.setSelectedStatus(false)
            (binding.indicatorContainer.getChildAt(
                selectPosition
            ) as? RoundedRectangleIndicatorView)?.setSelectedStatus(true)
            curPosition = selectPosition
        }
    }


    /**
     * 设置 Banner 数据
     * @param data Banner 数据列表
     */
    fun setBannerData(data: List<BannerItem>) {
        if (data.isEmpty()) return
        mAdapter?.setNewData(data.toMutableList())
        // 计算初始位置,确保可以双向滚动
        val initialPosition = Int.MAX_VALUE / 2 - (Int.MAX_VALUE / 2 % data.size)
        binding.viewPager.setCurrentItem(initialPosition, false)
        if (indicatorType!=2){
            for (i in 0 until data.size) {
                if (i == initialPosition % data.size) {
                    curPosition = i
                }
                val indicator = RoundedRectangleIndicatorView(context).apply {
                    setDefaultBackgroundColor(indicatorDefaultColor)
                    setSelectedBackgroundColor(indicatorSelectedColor)
                    setIndicatorWidth(indicatorCustomWidth.toFloat())
                    setIndicatorHeight(indicatorCustomHeight.toFloat())
                    setCornerRadius(indicatorCornerRadius.toFloat())
                    setIndicatorSpacing(indicatorSpacing.toFloat())
                    if (indicatorType == 1) {
                        setIndicatorShape(RoundedRectangleIndicatorView.Shape.CIRCLE)
                    } else  if (indicatorType == 0){
                        setIndicatorShape(RoundedRectangleIndicatorView.Shape.RECTANGLE)
                    }

                    // 初始状态:第一个指示器选中
                    setSelectedStatus(i == initialPosition % data.size)
                }
                // 设置指示器间距(通过布局参数)
                val lp = FrameLayout.LayoutParams(
                    FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT
                )
                if (i > 0) lp.leftMargin = indicatorSpacing // 从第二个开始添加左间距
                binding.indicatorContainer.addView(indicator, lp)

            }
        }


        // 如果启用自动轮播且数据数量大于1,则开始轮播
        if (isAutoPlay && data.size > 1) {
            startAutoScroll()
        }
    }

    /**
     * 开始自动轮播
     */
    fun startAutoScroll() {
        // 如果已经有轮播任务或者数据不足,则不启动
        if (bannerJob != null || (mAdapter?.itemCount ?: 0) <= 1) return
        bannerJob = coroutineScope.launch {
            while (isActive) {
                delay(delayMillis.toLong())
                binding.viewPager.post {
                    val currentItem: Int = binding.viewPager.getCurrentItem()

                    MyPagerHelper.setCurrentItem(binding.viewPager, currentItem + 1, 800)
                }
            }
        }
    }

    /**
     * 停止自动轮播
     */
    fun stopAutoScroll() {
     
        bannerJob?.cancel()
        bannerJob = null
    }

    /**
     * 释放资源
     */
    fun release() {
        stopAutoScroll()
        coroutineScope.cancel()
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        // 视图附加到窗口时,如果启用了自动轮播,则启动
        if (isAutoPlay && (mAdapter?.itemCount ?: 0) > 1) {
            startAutoScroll()
        }
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        // 视图从窗口分离时停止轮播
        stopAutoScroll()
    }

    override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
        super.onWindowFocusChanged(hasWindowFocus)
        // 窗口获得/失去焦点时控制轮播
        if (hasWindowFocus && isAutoPlay && (mAdapter?.itemCount ?: 0) > 1) {
            startAutoScroll()
        } else {
            stopAutoScroll()
        }
    }
}

3.3 指针View

package com.qianrun.voice.common.view.banner

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.core.content.withStyledAttributes
import com.fasterxml.jackson.annotation.JsonFormat.Shape
import com.qianrun.voice.common.R

class RoundedRectangleIndicatorView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // 默认属性值
    private var defaultBackgroundColor = Color.parseColor("#E0F2FE")
    private var selectedBackgroundColor = Color.parseColor("#3B82F6")
    private var cornerRadius = 8f
    private var isSelectedState = false
    private var indicatorPadding = 0f
    private var indicatorSpacing = 8f

    // 新增:宽高相关属性
    private var indicatorWidth = 24f  // 指示器默认宽度
    private var indicatorHeight = 8f  // 指示器默认高度

    // 画笔
    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.FILL
    }

    // 绘制区域
    private val rect = RectF()

    // 点击监听器
    private var onStateChangeListener: ((Boolean) -> Unit)? = null
    private var indicatorShape = Shape.RECTANGLE // 默认矩形
    // 新增:形状枚举
    enum class Shape {
        RECTANGLE, CIRCLE
    }
    init {
        // 从XML属性中获取配置(包括宽高)
        context.withStyledAttributes(attrs, R.styleable.RoundedRectangleControl) {
            // 原有属性...
            defaultBackgroundColor = getColor(
                R.styleable.RoundedRectangleControl_defaultColor,
                defaultBackgroundColor
            )
            selectedBackgroundColor = getColor(
                R.styleable.RoundedRectangleControl_selectedColor,
                selectedBackgroundColor
            )
            cornerRadius = getDimension(
                R.styleable.RoundedRectangleControl_cornerIndicatorRadius,
                cornerRadius
            )
            isSelectedState = getBoolean(
                R.styleable.RoundedRectangleControl_isSelected,
                isSelectedState
            )
            indicatorPadding = getDimension(
                R.styleable.RoundedRectangleControl_indicatorPadding,
                indicatorPadding
            )
            indicatorSpacing = getDimension(
                R.styleable.RoundedRectangleControl_indicatorSpacing,
                indicatorSpacing
            )

            // 新增:从XML获取宽高属性
            indicatorWidth = getDimension(
                R.styleable.RoundedRectangleControl_indicatorWidth,
                indicatorWidth
            )
            indicatorHeight = getDimension(
                R.styleable.RoundedRectangleControl_indicatorHeight,
                indicatorHeight
            )
            // 新增:获取形状属性
            indicatorShape = when (getInt(R.styleable.RoundedRectangleControl_indicatorShape, 0)) {
                1 -> Shape.CIRCLE
                else -> Shape.RECTANGLE}
        }

        isClickable = true
    }

    /**
     * 测量控件尺寸
     * 优先使用XML中设置的尺寸,若无则使用默认宽高
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 计算测量后的宽高(考虑父容器限制)
        val measuredWidth = measureDimension(indicatorWidth.toInt(), widthMeasureSpec)
        val measuredHeight = measureDimension(indicatorHeight.toInt(), heightMeasureSpec)

        // 如果是圆形,确保宽高相等(取较大值)
        if (indicatorShape == Shape.CIRCLE) {
            val size = maxOf(measuredWidth, measuredHeight)
            setMeasuredDimension(size, size)
        } else {
            setMeasuredDimension(measuredWidth, measuredHeight)
        }
    }

    /**
     * 辅助计算测量尺寸
     * @param defaultSize 控件默认尺寸
     * @param measureSpec 父容器传来的尺寸限制
     */
    private fun measureDimension(defaultSize: Int, measureSpec: Int): Int {
        var result = defaultSize
        val specMode = MeasureSpec.getMode(measureSpec)
        val specSize = MeasureSpec.getSize(measureSpec)

        when (specMode) {
            // 父容器未限制尺寸,使用默认值
            MeasureSpec.UNSPECIFIED -> result = defaultSize
            // 父容器强制限制尺寸,使用限制值
            MeasureSpec.EXACTLY -> result = specSize
            // 父容器建议尺寸,取默认值与建议值中的较小者
            MeasureSpec.AT_MOST -> result = minOf(defaultSize, specSize)
        }
        return result
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 绘制区域(考虑内边距)
        // 根据形状选择绘制方式
        when (indicatorShape) {
            Shape.RECTANGLE -> drawRectangle(canvas)
            Shape.CIRCLE -> drawCircle(canvas)
        }
    }

    /**
     * 绘制圆角矩形
     */
    private fun drawRectangle(canvas: Canvas) {
        // 绘制区域(考虑内边距)
        rect.set(
            indicatorPadding,
            indicatorPadding,
            width.toFloat() - indicatorPadding,
            height.toFloat() - indicatorPadding
        )

        // 根据选中状态设置背景色
        backgroundPaint.color = if (isSelectedState) selectedBackgroundColor else defaultBackgroundColor
        // 绘制圆角矩形
        canvas.drawRoundRect(rect, cornerRadius, cornerRadius, backgroundPaint)
    }

    // 新增:设置形状
    fun setIndicatorShape(shape: Shape) {
        if (indicatorShape != shape) {
            indicatorShape = shape
            requestLayout()  // 可能需要重新调整尺寸
            invalidate()     // 重新绘制
        }
    }

    /**
     * 绘制圆形
     */
    private fun drawCircle(canvas: Canvas) {
        // 计算圆心和半径(考虑内边距)
        val centerX = width / 2f
        val centerY = height / 2f
        val radius = minOf(width, height) / 2f - indicatorPadding

        // 根据选中状态设置背景色
        backgroundPaint.color = if (isSelectedState) selectedBackgroundColor else defaultBackgroundColor
        // 绘制圆形
        canvas.drawCircle(centerX, centerY, radius, backgroundPaint)
    }

    // 触摸事件处理(保持不变)
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_UP -> {
                toggleState()
                performClick()
                return true
            }
        }
        return super.onTouchEvent(event)
    }

    override fun performClick(): Boolean {
        super.performClick()
        return true
    }

    // 新增:动态设置指示器宽度
    fun setIndicatorWidth(width: Float) {
        if (indicatorWidth != width) {
            indicatorWidth = width
            // 触发重新测量和绘制
            requestLayout()  // 重新计算尺寸
            invalidate()     // 重新绘制
        }
    }

    // 新增:动态设置指示器高度
    fun setIndicatorHeight(height: Float) {
        if (indicatorHeight != height) {
            indicatorHeight = height
            requestLayout()
            invalidate()
        }
    }

    // 原有方法(保持不变)
    fun toggleState() {
        isSelectedState = !isSelectedState
        invalidate()
        onStateChangeListener?.invoke(isSelectedState)
    }

    fun setSelectedStatus(selected: Boolean) {
        if (isSelectedState != selected) {
            isSelectedState = selected
            invalidate()
            onStateChangeListener?.invoke(isSelectedState)
        }
    }

    fun isSelectedStatus(): Boolean = isSelectedState

    fun setOnStateChangeListener(listener: (Boolean) -> Unit) {
        onStateChangeListener = listener
    }

    fun setDefaultBackgroundColor(color: Int) {
        defaultBackgroundColor = color
        if (!isSelectedState) invalidate()
    }

    fun setSelectedBackgroundColor(color: Int) {
        selectedBackgroundColor = color
        if (isSelectedState) invalidate()
    }

    fun setCornerRadius(radius: Float) {
        cornerRadius = radius
        invalidate()
    }

    fun setIndicatorPadding(padding: Float) {
        indicatorPadding = padding
        invalidate()
    }

    fun setIndicatorSpacing(spacing: Float) {
        indicatorSpacing = spacing
        parent?.requestLayout()
    }

    fun getIndicatorSpacing(): Float = indicatorSpacing
}

3.4 xml adapter

layout_auto_banner.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <LinearLayout
        android:id="@+id/indicatorContainer"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

item_banner.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"

    android:layout_height="match_parent">

    <com.google.android.material.imageview.ShapeableImageView
        android:id="@+id/imageView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop" />
</FrameLayout>

BannerAdapter

package com.qianrun.voice.common.view.adapter

import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.google.android.material.shape.CornerFamily
import com.qianrun.voice.basic.adapter.BaseRvAdapter
import com.qianrun.voice.basic.adapter.holder.BaseRvViewHolder
import com.qianrun.voice.common.databinding.ItemBannerBinding
import com.qianrun.voice.common.glide.GlideUtil
import com.qianrun.voice.common.view.banner.BannerItem


/**
 *
 *@Author: wkq
 *
 *@Time: 2025/7/2 10:45
 *
 *@Desc:
 */
class BannerAdapter(var mContext: Context,var radius:Int): BaseRvAdapter<BannerItem, ItemBannerBinding>() {
    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int
    ): BaseRvViewHolder<ItemBannerBinding> {
        return BaseRvViewHolder(ItemBannerBinding.inflate(LayoutInflater.from(mContext),parent,false))
    }

    override fun onBindViewHolder(
        holder: BaseRvViewHolder<ItemBannerBinding>,
        position: Int
    ) {
        val realPosition: Int = position % getData().size
        val bean: BannerItem? = getItem(realPosition)
        holder.binding.imageView.shapeAppearanceModel = holder.binding.imageView.shapeAppearanceModel
            .toBuilder()
            .setAllCorners(CornerFamily.ROUNDED, radius.toFloat())
            .build()
        GlideUtil.getInstance().loadImage(mContext,bean?.imageUrl?:"",holder.binding.imageView)
    }

     override fun getItemCount(): Int {
        // 返回极大值,实现无限循环效果
        return if (getData().size > 1) Int.Companion.MAX_VALUE else getData().size
    }


}

4.总结

简单的实现了自动,循环播放的Banner,未处理定制Banner图片展示样式的处理.有需要,Banner样式以及指针样式可以自己定制修改 在添加指针和数据的地方传入特定的View 就可以了.有什么好的思路欢迎一起沟通进步,就这样,结束.