使用AnchoredDraggable写一个 banner和indicator

material3里面用AnchoredDraggableState替代了SwipeableState。官方迁移文档 (android.com) 主要就是swipeState.progress的fraction改为了progress,from和to可以用targetValue和currentValue代替。 Modifier.swipable()里面需要填的参数大部分转移到AnchoredDraggableState()里面了。

kotlin 复制代码
Modifier  
.swipeable(  
state = swipeAbleState,  
anchors = anchors,  
thresholds = thresholds,  
orientation = Orientation.Horizontal,  
velocityThreshold = velocityThreshold  
)

val state = remember {  
AnchoredDraggableState(  
initialValue = start,  
anchors = DraggableAnchors {  
for (i in 0 until count) {    
i at -i * widthPx  
}  
},  
positionalThreshold = positionalThreshold,  
velocityThreshold = { with(density) { velocityThreshold.toPx() } },  
animationSpec = animation, )  
}

Modifier.anchoredDraggable(  
state = state,  
orientation = orientation,  
enabled = if (count <= 1) false else enabled,  
)

snapTo()是实现绘制的关键,它可以实现没有动画的跳转。假设有三条数据,要求循环滚动。 要在开头和结尾分别插入第三条和第一条数据。这样滑动到第三条数据后,可以继续滑动,第一条数据 也可以向左滑动。初始布局为3 1 2 3 1。 初始位置是1(下标1),向左滑,快滑到头的时候调用snapTo跳转到3的位置(下标4)。从3(下标3)向右滑, 快滑到头的时候调用snapTo跳转到1(下标1)的位置。这样形成循环。

先对原始数据进行加工,默认是头尾各加一。要实现叠加卡片效果的话再根据情况增加数量。

kotlin 复制代码
fun <T> List<T>.loop(loopCount: Int): List<T> {  
val result = ArrayList<T>()  
if (isEmpty()) {  
return result  
}  
result.addAll(this)  
for (i in 0 until loopCount) {  
//头部位置插入  
result.add(i, this[size - 1 - i])  
//末尾添加  
result.add(this[i])  
}  
return result  
  
}

初始化state ,需要指定泛型T。假如动画数据不多,就开始,中间,结束三个状态。可以写成

kotlin 复制代码
enum class DragAnchor{
START,CENTER,END
}
initialValue = DragAnchor.START, 
anchors=DraggableAnchors{
//每个状态对应的位置
START at  a
CENTER at  b
END at  c

}

banner的每个item都相当于一个状态。用计算完的数据遍历。widthPx默认是设备屏幕的宽度。

kotlin 复制代码
val state = remember {  
AnchoredDraggableState(  
initialValue = start,  
anchors = DraggableAnchors {  
for (i in 0 until count) {  
// 每个页面锚点的位置。不设置位置,Box里面是重叠在一起的  
i at -i * widthPx  
}  
},  
positionalThreshold = positionalThreshold,  
velocityThreshold = { with(density) { velocityThreshold.toPx() } },  
animationSpec = animation,  
  
)  
}

绘制内容,Box进行拖动,内部的item根据拖动的距离执行动画。customAnimation(state,i) 可以自定义动画效果,默认是Modifier.graphicsLayer {

translationX = originOffset +state.requireOffset()}。 item内部想做动画的话可以根据state的变化去实现。

kotlin 复制代码
Box(  
modifier = Modifier.anchoredDraggable(  
state = state,  
orientation = orientation,  
enabled = if (count <= 1) false else enabled,  
)  
) {  
for (i in 0 until count) {  
Box(modifier = with(Modifier) { customAnimation(state, i) }  
) {  
content(i - loopCount, list[i], state, widthPx)  
}  
}  
}

然后就是对自动循环和自动播放做判断。循环要处理从初始位置向左滑和末尾位置向右滑。还要根据滑动进度禁止用户用手滑动,不禁止的话会到真正的最后位置,就滑不动了。

kotlin 复制代码
从末尾向右滑动  
val rightState by remember {  
derivedStateOf {  
state.targetValue == count - loopCount && state.progress > 0.99f  
}  
}  
SideEffect {  
if (rightState) {  
enabled = false  
coroutineScope.launch {  
state.snapTo(start)  
}  
} else {  
enabled = true  
}  
}

自动播放只需要判断滑向的位置在state.anchors里面就可以。

kotlin 复制代码
val autoState by remember {  
derivedStateOf {  
state.targetValue + 1 in 0 until state.anchors.size  
}  
}  
LaunchedEffect(state.targetValue) {  
delay(duration)  
if (autoState && !state.isAnimationRunning) {  
coroutineScope.launch {  
state.animateTo(state.targetValue + 1)  
}  
}  
}

indicator指示器,在banner里把数量,state,头尾各自添加的数量传递给indicatorState就可以了。

kotlin 复制代码
val total by  
remember {  
derivedStateOf {  
if (count > 2 * loopCount) {  
count - 2 * loopCount  
} else {  
count  
}  
}  
}  
indicatorState.pagerState = state  
indicatorState.loopCount = loopCount  
indicatorState.total = total

自定义一个IndicatorState,用AnchoredDraggableState计算数据的时候要注意减去循环列表添加的数量。 再把indicatorState传给Indicator,在Indicator里面根据当前位置,位移进度绘制。

带indicator使用必须先初始化indicatorState,不然会出错。

kotlin 复制代码
val state = rememberIndicatorState()  
Banner(data = banner, loop = true, autoSwipe = true,indicatorState=state) { index, item, state, width ->  
BannerItem(banner = item, index, onShowSnackbar, onBannerClick)  
}  
  
Indicator(  
modifier = Modifier.align(  
Alignment.BottomCenter  
),  
state=state,  
)

最后就是在compose里能用remember就用remember,有计算或者高频变化但只需要其中的一小部分结果。用remember{ derivedStateOf{}},可以大大减少重组 。没加前看layout inspector里的重组次数就是在跑 风火轮。。。加了之后滑动一次才重组两次。就是连续快速滑动一个循环会顿一下滑不过去,暂时没想明白。 第一次写,有没有好心人来测试下复杂动画,我写不出来 。完整代码如下。

参考学习

How to Implement Swipe-to-Action using AnchoredDraggable in Jetpack Compose | by Radhika S | Canopas

michaellee123/Pager: A pager for banner in Jetpack Compose, it has linear or stack two styles. (github.com)

Compose:从重组谈谈页面性能优化思路,狠狠优化一笔 - 掘金 (juejin.cn)

Jetpack Compose - Effect与协程 (十五) - 掘金 (juejin.cn)

Jetpack Compose 优化之调试重组和性能监控 - 掘金 (juejin.cn)

kotlin 复制代码
class IndicatorState(  
var total: Int = 0,  
internal var loopCount: Int  
) {  
lateinit var pagerState: AnchoredDraggableState<Int>  
  
val current: Int  
get() {  
var current = pagerState.targetValue - loopCount  
when {  
current < 0 -> current = total - 1  
  
current > total - 1 -> current = 0  
}  
return current  
}  
  
  
val from: Int get() = pagerState.currentValue - loopCount  
val to: Int get() = pagerState.targetValue - loopCount  
val fraction: Float get() = pagerState.progress  
  
}  
  
@Composable  
fun rememberIndicatorState(  
total: Int = 0,  
loopCount: Int = 1,  
): IndicatorState {  
return remember {  
IndicatorState(total, loopCount)  
}  
}  
  
/**  
* @param content 自定义指示器内容  
*/  
@Composable  
fun Indicator(  
modifier: Modifier = Modifier,  
state: IndicatorState,  
orientation: Orientation = Orientation.Horizontal,  
content: @Composable (state: IndicatorState) -> Unit = { indicatorState ->  
for (i in 0 until indicatorState.total) {  
val select = indicatorState.current == i  
Spacer(  
modifier = Modifier  
.size(if (select) 18.dp else 6.dp, 6.dp)  
.background(  
if (select) Color.White else Color.Gray,  
CircleShape  
)  
)  
if (i < indicatorState.total - 1) {  
Spacer(modifier = Modifier.width(6.dp))  
}  
}  
  
},  
) {  
if (orientation == Orientation.Horizontal) {  
Row(modifier = modifier) {  
content(state)  
  
}  
} else {  
Column(modifier = modifier) {  
content(state)  
}  
}  
  
  
}  
  
  
/**  
* @param density dp转换为px的密度单位。默认为当前设备的密度。  
*  
* @param positionalThreshold 松手后会执行动画的位置阈值。默认位移一半。  
*  
* @param velocityThreshold 松手后的滑动速度阈值,超速后可以忽略位置阈值执行动画。  
*  
* @param loop 是否循环。  
*  
* @param loopCount 首尾分别添加的数量。默认从1开始。0的时候不能循环滑动。  
*  
* @param autoSwipe 是否自动滑动。  
*  
* @param orientation 滑动方向。  
*  
* @param duration 间隔时间。  
*  
* @param makeLoop 自定义列表插入数据方法。  
*  
* @param widthPx 根据[orientation]为宽度或者高度,默认全屏宽度或者高度  
*  
* @param animation 拖动屏幕的动画效果  
*  
* @param customAnimation 自定义item绘制动画效果  
*  
* @param data list数据  
*  
* @param content 自定义item  
*/  
@Composable  
fun <T> Banner(  
modifier: Modifier = Modifier,  
density: Density = LocalDensity.current,  
positionalThreshold: (Float) -> Float = { it * 0.5f },  
velocityThreshold: Dp = 125.dp,  
loop: Boolean = true,  
@IntRange(from = 1)  
loopCount: Int = 1,  
autoSwipe: Boolean = true,  
indicatorState: IndicatorState = rememberIndicatorState(),  
indicatorEnable: Boolean = true,  
orientation: Orientation = Orientation.Horizontal,  
duration: Long = 3000L,  
makeLoop: List<T>.(Int) -> List<T> = { loop(it) },  
widthPx: Float = if (orientation == Orientation.Horizontal) LocalContext.current.resources.displayMetrics.widthPixels.toFloat()  
else LocalContext.current.resources.displayMetrics.heightPixels.toFloat(),  
animation: AnimationSpec<Float> = tween(),  
customAnimation: Modifier.(AnchoredDraggableState<Int>, index: Int) -> Modifier = { state, index ->  
val originOffset = index * widthPx.roundToInt()  
graphicsLayer {  
when (orientation) {  
Orientation.Vertical -> {  
translationY = originOffset +state.requireOffset()  
}  
  
Orientation.Horizontal -> {  
translationX = originOffset +state.requireOffset()  
  
}  
}  
}  
  
},  
data: List<T>,  
content: @Composable (index: Int, item: T, state: AnchoredDraggableState<Int>, width: Float) -> Unit,  
) {  
  
if (data.isEmpty()) {  
Box(modifier = modifier) {  
return  
}  
}  
var loopEnable by remember {  
mutableStateOf(loop)  
}  
var autoSwipeEnable by remember {  
mutableStateOf(autoSwipe)  
}  
/**  
* 循环滚动就给列表头尾各添加一条数据。  
* 如果只有一条数据禁止滑动和循环。  
*/  
if (data.size == 1) {  
loopEnable = false  
autoSwipeEnable = false  
}  
val list by remember {  
derivedStateOf {  
if (loopEnable) {  
data.makeLoop(loopCount)  
} else {  
data  
}  
}  
}  
val coroutineScope = rememberCoroutineScope()  
  
val count by remember {  
mutableIntStateOf(list.size)  
}  
  
var enabled by remember {  
mutableStateOf(true)  
}  
  
/**  
* 初始化位置  
*/  
val start by remember {  
  
derivedStateOf {  
//只有一条数据时的情况  
if (loopEnable) minOf(count - 1, loopCount) else 0  
  
}  
}  
  
  
val state = remember {  
AnchoredDraggableState(  
initialValue = start,  
anchors = DraggableAnchors {  
for (i in 0 until count) {  
// 每个页面锚点需要向左滑动的宽度。  
i at -i * widthPx  
}  
},  
positionalThreshold = positionalThreshold,  
velocityThreshold = { with(density) { velocityThreshold.toPx() } },  
animationSpec = animation,  
  
)  
}  
//indicator指示器  
if (indicatorEnable) {  
val total by  
remember {  
derivedStateOf {  
if (count > 2 * loopCount) {  
count - 2 * loopCount  
} else {  
count  
}  
}  
}  
indicatorState.pagerState = state  
indicatorState.loopCount = loopCount  
indicatorState.total = total  
}  
  
Box(  
modifier = Modifier.anchoredDraggable(  
state = state,  
orientation = orientation,  
enabled = if (count <= 1) false else enabled,  
)  
) {  
for (i in 0 until count) {  
Box(modifier = with(Modifier) { customAnimation(state, i) }  
) {  
content(i - loopCount, list[i], state, widthPx)  
}  
}  
}  
  
  
if (loopEnable) {  
// 从末尾向右滑动  
val rightState by remember {  
derivedStateOf {  
state.targetValue == count - loopCount && state.progress > 0.99f  
}  
}  
SideEffect {  
if (rightState) {  
enabled = false  
coroutineScope.launch {  
state.snapTo(start)  
}  
} else {  
enabled = true  
}  
}  
//从初始位置向左滑动  
val leftState by remember {  
derivedStateOf {  
state.targetValue == start - 1 && state.progress > 0.99f  
}  
}  
SideEffect {  
if (leftState) {  
enabled = false  
coroutineScope.launch {  
if (count - 1 - loopCount in 0 until state.anchors.size) {  
state.snapTo(count - 1 - loopCount)  
}  
}  
} else {  
enabled = true  
}  
}  
  
  
}  
  
if (autoSwipeEnable) {  
val autoState by remember {  
derivedStateOf {  
state.targetValue + 1 in 0 until state.anchors.size  
}  
}  
LaunchedEffect(state.targetValue) {  
delay(duration)  
if (autoState && !state.isAnimationRunning) {  
coroutineScope.launch {  
state.animateTo(state.targetValue + 1)  
}  
}  
}  
}  
}  
  
/**  
* 开头插入最后一个,末尾加入第一个  
* 默认插入一条。  
* 要实现叠加效果的banner再添加多个。  
*  
*/  
fun <T> List<T>.loop(loopCount: Int): List<T> {  
val result = ArrayList<T>()  
if (isEmpty()) {  
return result  
}  
result.addAll(this)  
for (i in 0 until loopCount) {  
//头部位置插入  
result.add(i, this[size - 1 - i])  
//末尾添加  
result.add(this[i])  
}  
return result  
  
}
相关推荐
雨白16 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
刘龙超2 天前
如何应对 Android 面试官 -> 玩转 JetPack LiveData
android jetpack
Wgllss12 天前
Kotlin+协程+FLow+Channel+Compose 实现一个直播多个弹幕效果
android·架构·android jetpack
_一条咸鱼_12 天前
Android Gson注解驱动的转换规则原理(9)
android·面试·android jetpack
_一条咸鱼_12 天前
Android Runtime大对象分配与处理流程原理深度剖析(59)
android·面试·android jetpack
Wgllss13 天前
Kotlin+协程+FLow+Channel,实现生产消费者模式3种案例
android·架构·android jetpack
Wgllss13 天前
6种Kotlin中单例模式写法,特点及应用场景指南
android·架构·android jetpack
_一条咸鱼_13 天前
Android Runtime内存分配与对象生命周期深度解析(57)
android·面试·android jetpack
webbin13 天前
Compose 两种 `derivedStateOf` 写法比较
android jetpack
_一条咸鱼_14 天前
Android Runtime并发标记与三色标记法实现原理(55)
android·面试·android jetpack