JetpackCompose アニメーションするボタンを作った、そして激ハマりした話
前回こちらの記事でアニメーションボタンを作成しましたが、
こちらの記事の内容だと致命的なバグがあることが判明しました;;;;;;(前回記事は更新済み)
今回はサンプルアプリを交えて何が起こったかを解説したいと思います。
今回やる話
- サンプルアプリを交えた解説
- 解決方法
サンプルアプリ&サンプルコード
サンプルコードは今回解説に最低限必要な部分だけで割愛してます。
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されません。
つまり現状のままだとラムダ内の監視が出来ておらず
値が変更されていないとみなされて再Composeされていない
という訳です。
なのでラムダ内の値を監視するにはどうすればいいかという話になってくるのですが
それはこちらを使います。
公式ドキュメントを見ても相変わらずの日本語訳で
逆にわからなくなるやつなのですが
つまりどういうことかというと
公式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()
しているので、
値が変わっても監視することができるという訳ですね。
参考記事
Discussion