💫

入門 Jetpack Compose Animation part3

に公開

はじめに

前回は、Value-based animationsを読んで学習した内容をまとめました。
今回は、Advanced animation exampleを読んで学習した内容をまとめます。

ジェスチャーとアニメーション

タッチイベントとアニメーションを組み合わせる場合は注意することが増えます。
ユーザーインタラクションはアニメーションよりも優先され、タッチイベントが開始されたら、たとえアニメーションの途中であっても中断して新しいイベントに反応する必要があります。

ジェスチャー


ジェスチャーサンプル: タップで青い円が移動する様子

@Composable
fun Gesture(modifier: Modifier = Modifier) {
    // you can use Animatable to change Offset
    val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
    val scope = rememberCoroutineScope()
    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // using high level api
                detectTapGestures {
                    scope.launch {
                        // cancel after new tap event comming
                        offset.animateTo(it)
                    }
                }

                // using low level api
                coroutineScope {
                    // cancel after new tap event comming
                    while (true) {
                        awaitPointerEventScope {
                            val position = awaitFirstDown().position
                            launch {
                                offset.animateTo(position)
                            }
                        }
                    }
                }
            }
    ) {
        Box(
            modifier = Modifier
                .offset { offset.value.toIntOffset() }
                .size(100.dp)
                .background(Color.Blue, shape = CircleShape)
        )
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

自作SwipeToDismiss


スワイプで要素を画面外にDismissするサンプル

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        val decay = splineBasedDecay<Float>(this)
        coroutineScope {
            while (true) {
                val velocityTracker = VelocityTracker()
                // 1
                offsetX.stop()
                awaitPointerEventScope {
                    val pointerId = awaitFirstDown().id
                    horizontalDrag(pointerId) { change ->
                        launch {
                            // 2
                            offsetX.snapTo(offsetX.value + change.positionChange().x)
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position,
                        )
                    }
                }
                // スワイプの速度を計測
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat(),
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // 3
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity,
                        )
                    } else {
                        // 4
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
@Composable
fun TodoList(modifier: Modifier = Modifier) {
    val todos = remember {
        mutableStateListOf(
            "Take out the trash",
            "Do the dishes",
            "Write a blog post",
        )
    }

    LazyColumn(
        modifier = modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(32.dp)
    ) {
        items(todos, { it }) { text ->
            Box(
                modifier = Modifier
                    .swipeToDismiss(
                        onDismissed = {
                            todos.remove(text)
                        }
                    )
                    .padding(horizontal = 16.dp)
                    .background(
                        color = MaterialTheme.colorScheme.secondaryContainer,
                        shape = RoundedCornerShape(8.dp),
                    )
                    .padding(16.dp)
                    .height(100.dp)
                    .fillMaxWidth()
            ) {
                Text(
                    text = text,
                    color = MaterialTheme.colorScheme.onSecondaryContainer,
                )
            }
        }
    }
}
余談

offsetX.updateBoundsはアニメーション範囲を制限し、画面外への過度な移動を防ぎます。
animateDecayは慣性や物理的挙動を模倣した減衰アニメーションを提供します。

参考文献

https://developer.android.com/develop/ui/compose/animation/advanced?hl=en
https://github.com/mikanIchinose/android-compose-animation-sample

GitHubで編集を提案

Discussion