项目需要一个自动且循环播放的轮播图,忽然想起来原先都是搞个三方库直接展示了,没静下心来搞过这个需求.趁此机会,梳理实现了一下自动
且循环播放
的轮播图
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 就可以了.有什么好的思路欢迎一起沟通进步,就这样,结束.