使用AnchoredDraggable写一个 banner和indicator

1,147 阅读6分钟

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

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)的位置。这样形成循环。

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

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。假如动画数据不多,就开始,中间,结束三个状态。可以写成

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

}

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

    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,)
    }

绘制内容,Box进行拖动,内部的item根据拖动的距离执行动画。customAnimation(state,i) 可以自定义动画效果,默认是Modifier.graphicsLayer {translationX = originOffset +state.requireOffset()}。 item内部想做动画的话可以根据state的变化去实现。

 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, count, widthPx) }
            ) {
                content(i - loopCount, list[i], state, widthPx)
            }
        }
    }

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

从末尾向右滑动  
     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
            }
        }

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

    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就可以了。

 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,不然会出错。

  val state = rememberIndicatorState()
        Banner(data = banner, loop = true, autoSwipe = true,indicatorState=state,) { 
        index, item, dragState, width ->
            BannerItem(banner = item, index, onShowSnackbar, onBannerClick)
        }

        Indicator(
            modifier = Modifier.align(
                Alignment.BottomCenter
            ),
          state=state,
        )

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

屏.png

参考学习

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)


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: @Composable Modifier.(AnchoredDraggableState<Int>, index: Int, count: Int, widthPx: Float) -> Modifier = { state, index, count, width ->
        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, count, widthPx) }
            ) {
                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

}