🦁

JetpackCompose アニメーションするボタンを作った、そして激ハマりした話

2022/01/14に公開

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

前回こちらの記事でアニメーションボタンを作成しましたが、

こちらの記事の内容だと致命的なバグがあることが判明しました;;;;;;(前回記事は更新済み)

今回はサンプルアプリを交えて何が起こったかを解説したいと思います。

今回やる話

  1. サンプルアプリを交えた解説
  2. 解決方法

サンプルアプリ&サンプルコード

https://github.com/nakatani-takashi/presentation_animation_button

サンプルコードは今回解説に最低限必要な部分だけで割愛してます。

CustomButtonは見た目だけ。

クリックイベントはModifierで渡しています。

class FirstFragment : Fragment() {
    private val t = MutableLiveData("初期値だよ!!")
    private fun postTextUpdate(postText: String) {
        t.postValue(postText)
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View = fragmentComposable(R.id.firstFragment) {

        val postText = t.observeAsState().value
        val interactionSource = remember { MutableInteractionSource() }
        fun route(argText: String) = findNavController().navigate(
            FirstFragmentDirections.actionFirstFragmentToSecondFragment(
                argText
            )
        )

        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.White)
                .padding(16.dp),
            verticalArrangement = Arrangement.SpaceEvenly,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            if (postText != null) {
                TextField(
                    value = postText,
                    onValueChange = {
                        postTextUpdate(it)
                    },
                )
            }
            Button(
                onClick = {
                    route(postText ?: "")
                }
            ) {
                Text(
                    text = "普通のボタン",
                    Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                )
            }
            CustomButton(text = "自作のっぺりボタン", modifier = Modifier
                .clickable {
                    route(postText ?: "")
                }
            )
            CustomButton(text = "自作ボタン", modifier = Modifier
                .clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = rememberRipple(bounded = true),
                ) {
                    route(postText ?: "")
                }
            )
            CustomButton(
                text = "自作アニメーションボタン",
                modifier = Modifier.animationClickable(interactionSource, {
                    route(postText ?: "")
                })
            )
        }
    }
}


テキストフィールドに入力した値を次の画面に映すだけのアプリです。

しかし、

こちらの一番下にある自作アニメーションボタンで遷移すると



どうして??????

解決方法

まず何故値が監視出来ていないかという所ですが、

こちらの記事でもある通りComposeは親が破棄されても

子の持っている値に変更がなければ再Composeされません。

https://qiita.com/yasukotelin/items/fe1a36b3e8d9d21f9126

つまり現状のままだとラムダ内の監視が出来ておらず

値が変更されていないとみなされて再Composeされていない

という訳です。


なのでラムダ内の値を監視するにはどうすればいいかという話になってくるのですが

それはこちらを使います。

https://developer.android.com/jetpack/compose/side-effects?hl=ja#rememberupdatedstate

公式ドキュメントを見ても相変わらずの日本語訳で

逆にわからなくなるやつなのですが

つまりどういうことかというと

公式Widgetであるclickable関数の中を見るとわかりやすいです。

fun Modifier.clickable(
    interactionSource: MutableInteractionSource,
    indication: Indication?,
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit
) = composed(
    factory = {
        val onClickState = rememberUpdatedState(onClick)
        val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) }
        if (enabled) {
            PressedInteractionSourceDisposableEffect(interactionSource, pressedInteraction)
        }
        val isRootInScrollableContainer = isComposeRootInScrollableContainer()
        val isClickableInScrollableContainer = remember { mutableStateOf(true) }
        val delayPressInteraction = rememberUpdatedState {
            isClickableInScrollableContainer.value || isRootInScrollableContainer()
        }
        val gesture = Modifier.pointerInput(interactionSource, enabled) {
            detectTapAndPress(
                onPress = { offset ->
                    if (enabled) {
                        handlePressInteraction(
                            offset,
                            interactionSource,
                            pressedInteraction,
                            delayPressInteraction
                        )
                    }
                },
                onTap = { if (enabled) onClickState.value.invoke() }
            )
        }
        Modifier
            .then(
                remember {
                    object : ModifierLocalConsumer {
                        override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) {
                            with(scope) {
                                isClickableInScrollableContainer.value =
                                    ModifierLocalScrollableContainer.current
                            }
                        }
                    }
                }
            )
            .genericClickableWithoutGesture(
                gestureModifiers = gesture,
                interactionSource = interactionSource,
                indication = indication,
                enabled = enabled,
                onClickLabel = onClickLabel,
                role = role,
                onLongClickLabel = null,
                onLongClick = null,
                onClick = onClick
            )
    },
    inspectorInfo = debugInspectorInfo {
        name = "clickable"
        properties["enabled"] = enabled
        properties["onClickLabel"] = onClickLabel
        properties["role"] = role
        properties["onClick"] = onClick
        properties["indication"] = indication
        properties["interactionSource"] = interactionSource
    }
)

こちらfactory内の早々に

val onClickState = rememberUpdatedState(onClick)

と宣言して

onClick: () -> UnitをrememberUpdatedStateの中に入れています。

そして

val gesture = Modifier.pointerInput(interactionSource, enabled) {
            detectTapAndPress(
                onPress = { offset ->
                    if (enabled) {
                        handlePressInteraction(
                            offset,
                            interactionSource,
                            pressedInteraction,
                            delayPressInteraction
                        )
                    }
                },
                onTap = { if (enabled) onClickState.value.invoke() }
            )
        }

こちらのdetectTapAndPress内でonTap時に

onClickState.value.invoke()としてイベント発火されるようにしていますね。

他も見てみるとrememberUpdatedStateが色々と使われていることがわかります。


つまりは要約すると、

composeは値の変更がなければ再composeされないので

clickable関数に渡されたものは監視できるように

remember関数の中に入れて監視できるようにしたよ!

ということですね。

解決後のクリックアニメーション関数

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

繰り返しにはなってしまいますが、

もらったonClick: () -> Unitを

rememberUpdatedStateに渡して

発火させたいイベント時にonClickState.value.invoke()

しているので、

値が変わっても監視することができるという訳ですね。

参考記事

https://developer.android.com/jetpack/compose/side-effects?hl=ja#rememberupdatedstate

https://qiita.com/yasukotelin/items/fe1a36b3e8d9d21f9126

Discussion