🦔

JetpackCompose リップルエフェクトとアニメーションを併用したクリックイベントを実装する方法

2021/12/30に公開

https://zenn.dev/apple_nktn/articles/b7b886ed4a1b4d

こちらにて現在のコードに至った経緯とエラーケースを書いてますので

よければこちらもこちらも見てみて下さい。

やりたい事

  • クリック、ロングクリック時にアニメーションとクリックイベント
  • ホールドしてカーソルを外すとアニメーションを停止、クリックイベントを起こさない

まぁ良くあるボタンイベントである。

だけどJetpackComposeでこれを実装しようと思うと

本当によくわからなくて困り果てていました;;;;;;;;

理由

  • 現状クリックイベントではクリック、ロングクリックには対応できるが、ボタンを離した時・ホールドしてカーソルを外した時などの細かい出し分けには対応していない
  • 細かい出し分けを行うにはpointerInteropFilterを使ってMotionEventによる出し分けをする必要がある
  • しかしpointerInteropFilterを使うとリップルエフェクトを実装する方法がない
  • 空のclickableを作ってそこにリップルエフェクトを定義してpointerInteropFilterを付けpointerInteropFilterのBooleanをfalseにすると、リップルエフェクトは付くがスケールしたアニメーションにリップルエフェクトが乗らなかったり、pointerInteropFilterのBooleanをfalseになっているので最初のクリックイベントで監視が終了してしまって出し分けに対応できなかったりする

結論&サンプルコード

fun Modifier.animationClickable(
    interactionSource: MutableInteractionSource,
    onClick: () -> Unit,
    enabled: Boolean = true
): Modifier = composed {

    val onClickState = rememberUpdatedState(onClick)
    var playedAnimation by remember {
        mutableStateOf(false)
    }
    var f by remember {
        mutableStateOf(1.0f)
    }
    val scale by
    animateFloatAsState(
        targetValue = if (playedAnimation) 0.9f else f,
        animationSpec = repeatable(
            iterations = 2,
            animation = tween(durationMillis = 300),
        ),
    )

    scale(scale)
        .pointerInput(interactionSource, enabled) {
            if (enabled) {
                forEachGesture {
                    coroutineScope {
                        awaitPointerEventScope {
                            val down = awaitFirstDown(requireUnconsumed = false)
                            val downPress = PressInteraction.Press(down.position)
                            val holdButtonJob = launch {
                                interactionSource.emit(downPress)
                                f = 0.9f
                                playedAnimation = true
                            }
                            val up = waitForUpOrCancellation()
                            holdButtonJob.cancel()
                            launch {
                                when (up) {
                                    null -> {
                                        interactionSource.emit(PressInteraction.Cancel(downPress))
                                    }
                                    else -> {
                                        interactionSource.emit(PressInteraction.Release(downPress))
                                        onClickState.value.invoke()
                                    }
                                }
                                f = 1.0f
                                playedAnimation = false
                            }
                        }
                    }
                }
            }
        }
        .indication(interactionSource, rememberRipple())
}

解説

pointerInteropFilterではなくpointerInputを使う。

pointerInputだとkeyを渡すことができるので

そこにinteractionSourceを渡し、

各アクションをinteractionSourceにemitすることと

pointerInputに.indication()を付けることで

リップルエフェクトを実装することができる。

リップルエフェクトがスケールしないのはModifierに記述する順序で動作が変わるためである。

アニメーションが起こる前にscaleを付けてあげることで追従することができる。

参考記事

https://developer.android.com/reference/kotlin/androidx/compose/foundation/interaction/InteractionSource

https://medium.com/frndapp/a-quick-guide-to-the-animate-asstate-api-in-jetpack-compose-animation-ab1ba3b6379f

https://qiita.com/yasukotelin/items/03f326a81a77d5eb4d5c

https://stackoverflow.com/questions/66251718/scaling-button-animation-in-jetpack-compose

https://kaleidot.net/jetpack-compose-では-modifier-を記述する順序で動作が変わる-ce0eb05bbefb

https://proandroiddev.com/jetpack-compose-interactionsources-the-ripple-effect-and-you-f451b60fcd37

Discussion