使用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  
  
}
相关推荐
帅次12 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
IAM四十二3 天前
Jetpack Compose State 你用对了吗?
android·android jetpack·composer
Wgllss4 天前
那些大厂架构师是怎样封装网络请求的?
android·架构·android jetpack
x02421 天前
Android Room(SQLite) too many SQL variables异常
sqlite·安卓·android jetpack·1024程序员节
alexhilton24 天前
深入理解观察者模式
android·kotlin·android jetpack
Wgllss24 天前
花式高阶:插件化之Dex文件的高阶用法,极少人知道的秘密
android·性能优化·android jetpack
上官阳阳1 个月前
使用Compose创造有趣的动画:使用Compose共享元素
android·android jetpack
沐言人生1 个月前
Android10 Framework—Init进程-15.属性变化控制Service
android·android studio·android jetpack
IAM四十二1 个月前
Android Jetpack Core
android·android studio·android jetpack
王能1 个月前
Kotlin真·全平台——Kotlin Compose Multiplatform Mobile(kotlin跨平台方案、KMP、KMM)
android·ios·kotlin·web·android jetpack·kmp·kmm