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
}