Blog

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも

ユーザが何かをタップしたときにフィードバックを返すことは、正常に入力できたことをユーザに伝えるだけでなく、心地よい操作感やリッチ感を実現するためにも重要です。

MaterialDesignでは、クリック時に波紋状のRippleエフェクトを表示します。

Jetpack ComposeでもMaterialThemeを利用している場合、クリック可能なコンポーネントはRippleエフェクトが表示されます。

今回はJetpack ComposeでRippleエフェクトについて色々深堀りをしていき、Rippleの色を変えたり、そもそもタップ時のエフェクトを変更する方法についても紹介します。

Rippleエフェクトを表示する

Jetpack ComposeではModifierclickableを指定すると、同時にRippleエフェクトも表示されます。

MaterialThemeの中でないとRippleにならないことに注意してください。

MaterialTheme {
    Text(
        modifier = Modifier
            .clickable(onClick = {})
            .padding(horizontal = 8.dp, vertical = 4.dp),
        text = "Click!"
    )
}

もちろん、ButtonCheckbox 等のMaterialのコンポーネントにもRippleエフェクトが表示されます。

デフォルトのRippleの色定義について

Rippleの色や透明度は RippleThemeというinterfaceで定義されています。

public interface RippleTheme {
    @Composable
    public fun defaultColor(): Color

    @Composable
    public fun rippleAlpha(): RippleAlpha
}

そしてデフォルトの色と透明度は以下のとおりです。

public fun defaultRippleColor(
    contentColor: Color,
    lightTheme: Boolean
): Color {
    val contentLuminance = contentColor.luminance()
    return if (!lightTheme && contentLuminance < 0.5) {
        Color.White
    } else {
        contentColor
    }
}

public fun defaultRippleAlpha(
    contentColor: Color,
    lightTheme: Boolean
): RippleAlpha {
    return when {
        lightTheme -> {
            if (contentColor.luminance() > 0.5) {
                LightThemeHighContrastRippleAlpha
            } else {
                LightThemeLowContrastRippleAlpha
            }
        }
        else -> {
            DarkThemeRippleAlpha
        }
    }
}

引数の contentColorは文字色やアイコン色のことで、lightThemeはdarkテーマかlightテーマかを渡します。

透明度は以下のように定義されています。

private val LightThemeHighContrastRippleAlpha = RippleAlpha(
    pressedAlpha = 0.24f,
    focusedAlpha = 0.24f,
    draggedAlpha = 0.16f,
    hoveredAlpha = 0.08f
)

private val LightThemeLowContrastRippleAlpha = RippleAlpha(
    pressedAlpha = 0.12f,
    focusedAlpha = 0.12f,
    draggedAlpha = 0.08f,
    hoveredAlpha = 0.04f
)

private val DarkThemeRippleAlpha = RippleAlpha(
    pressedAlpha = 0.10f,
    focusedAlpha = 0.12f,
    draggedAlpha = 0.08f,
    hoveredAlpha = 0.04f
)

すなわち、まとめると以下のようになります。

    • ダークテーマ
      • 文字色の輝度が低い場合は白色
      • それ以外の場合は文字色と同じ
    • ライトテーマ
      • 常に文字色と同じ
  • 透明度
    • ダークテーマ
      • 常に10%
    • ライトテーマ
      • 文字色の輝度が高い場合は24%
      • 文字色の輝度が低いときは12%

少し複雑ですが、文字色の輝度によって出し分けすることにより、Rippleや文字を見えやすくする工夫がされています。

この恩恵を最大限に得るためには、文字色を指定する際に直接指定するのではなく、LocalContentColorを上書きすべきでしょう。

MaterialTheme {
    CompositionLocalProvider(
        LocalContentColor provides Color.Red
    ) {
        Text(
            modifier = Modifier
                .clickable(onClick = {}) // 赤色のRippleが出る
                .padding(horizontal = 8.dp, vertical = 4.dp),
            text = "button" // 赤色の文字色
        )
    }
}

文字色と同時にRippleのエフェクト色も変わってることがわかると思います。

Rippleの色を変える

これまでの説明で大体わかってきたと思いますが、RippleThemeを上書きすることでRippleの色、透明度を変えることができます。

例えば、以下のようにdefaultColorをoverrideします。透明度はdefaultRippleAlphaに従うで良いでしょう。

object CustomRippleTheme : RippleTheme {
    @Composable
    override fun defaultColor(): Color {
        return Color.Red
    }

    @Composable
    override fun rippleAlpha(): RippleAlpha {
        return RippleTheme.defaultRippleAlpha(
            contentColor = LocalContentColor.current,
            lightTheme = MaterialTheme.colors.isLight
        )
    }
}

これをCompositionLocalProviderを使って配ります。MaterialThemeに上書きされないよう、MaterialThemeの内側に指定する必要があります。

MaterialTheme {
    CompositionLocalProvider(
        LocalRippleTheme provides CustomRippleTheme
    ) {
        Text(
            modifier = Modifier
                .clickable(onClick = {})
                .padding(horizontal = 8.dp, vertical = 4.dp),
            text = "button"
        )
    }
}

文字色は変えずにRippleだけ赤色にすることができました。

アプリ全体ではなく、一箇所だけRippleの色を変えたい場合はindicationrememberRipple を指定します。 InteractionSourceも同時に指定する必要があることに注意してください。

MaterialTheme {
    Text(
        modifier = Modifier
            .clickable(
                onClick = {},
                indication = rememberRipple(color = Color.Red),
                interactionSource = remember { MutableInteractionSource() }
            )
            .padding(horizontal = 8.dp, vertical = 4.dp),
        text = "button"
    )
}

結果は先程と同じく赤色のRippleが表示されます。

Rippleの他のカスタマイズ

rememberRippleにはcolorの他にradiusboundedを指定することができます。 boundedfalseにすることで領域を超えた円形のrippleが表示されます。 クリック可能なアイコン等にはこちらのほうが合うかもしれません。

MaterialTheme {
    Text(
        modifier = Modifier
            .clickable(
                onClick = {},
                indication = rememberRipple(bounded = false),
                interactionSource = remember { MutableInteractionSource() }
            )
            .padding(horizontal = 8.dp, vertical = 4.dp),
        text = "button"
    )
}

クリック時のエフェクトを作る

更にこだわってRippleエフェクト以外のクリック時エフェクトを作成してみます。

以下のようなタップ時にコンポーネントが縮小され、タップを離したときにもとに戻るような Indicator を作成してみます。

タップ等の入力に応じたエフェクトは、Indicationというinterfaceで定義されています。 InteractionSourceはタップなどの入力がFlowで流れてくるので、それに反応してIndicationInstanceを作成することで表現します。

@Stable
interface Indication {
    @Composable
    fun rememberUpdatedInstance(
        interactionSource: InteractionSource
    ): IndicationInstance
}

IndicationInstanceはコンテンツをレイアウトに描画するときの操作を記述できます。

interface IndicationInstance {
    fun ContentDrawScope.drawIndication()
}

Scaleさせるための実装は以下のとおりです。

PressInteraction.Pressが発生したらアニメーションを開始し、PressInteraction.ReleasePressInteraction.Cancelで停止します。 アニメーションはいくつかの方法で行うことができますが、ここではAnimatableを使って行います。 短いタップでもアニメーションを見せるため、minDurationを指定できるようにしています。

class ScaleIndication(
    private val pressedScale: Float,
    private val animationSpec: AnimationSpec<Float>,
    private val minDuration: Long
) : Indication {
    @Composable
    override fun rememberUpdatedInstance(
        interactionSource: InteractionSource
    ): IndicationInstance {
        val instance = remember(pressedScale, animationSpec, minDuration) {
            ScaleIndicationInstance(pressedScale, animationSpec, minDuration)
        }
        LaunchedEffect(interactionSource) {
            interactionSource.interactions.collect {
                when (it) {
                    is PressInteraction.Press -> {
                        instance.start(this)
                    }
                    is PressInteraction.Release,
                    is PressInteraction.Cancel -> {
                        instance.stop()
                    }
                }
            }
        }
        return instance
    }
}

private class ScaleIndicationInstance(
    private val pressedScale: Float,
    private val animationSpec: AnimationSpec<Float>,
    private val minDuration: Long
) : IndicationInstance {
    companion object {
        private const val initialScale = 1.0F
    }

    private var animatable = Animatable(initialScale)
    private var finishEvent = Channel<Unit>(Channel.CONFLATED)

    fun start(scope: CoroutineScope) {
        scope.launch {
            animatable.animateTo(pressedScale, animationSpec)
        }
        scope.launch {
            delay(minDuration)
            finishEvent.receive()
            animatable.animateTo(initialScale, animationSpec)
        }
    }

    fun stop() {
        finishEvent.trySend(Unit)
    }

    override fun ContentDrawScope.drawIndication() {
        val scale = animatable.value
        if (scale != initialScale) {
            drawContext.transform.apply {
                scale(scale)
            }
        }
        drawContent()
    }
}

以下のようなComposable functionを用意しておくと使いやすいと思います。animationSpecdampingRatioを調整したspringを指定することで、例のようにバウンズさせることができます。

@Composable
fun rememberScaleIndication(
    pressedScale: Float = 0.88F,
    animationSpec: AnimationSpec<Float> = spring(
        dampingRatio = Spring.DampingRatioMediumBouncy,
        stiffness = Spring.StiffnessLow,
    ),
    minDuration: Long = 150
): Indication {
    return remember(pressedScale, animationSpec, minDuration) {
        ScaleIndication(pressedScale, animationSpec, minDuration)
    }
}

利用はRippleの変更と同じく、以下のように行います。 LocalIndicatorを上書きしても良いでしょう。

MaterialTheme {
    Text(
        modifier = Modifier
            .clickable(
                onClick = {},
                indication = rememberScaleIndication(),
                interactionSource = remember { MutableInteractionSource() }
            )
            .background(Color.Blue, RoundedCornerShape(50))
            .padding(horizontal = 16.dp, vertical = 8.dp),
        text = "Button",
        fontSize = 20.sp,
        fontWeight = FontWeight.Bold,
        color = Color.White
    )
}

動作するコードはGitHubで確認できます。

今回はわかりやすさのために省略しましたが、実際にはFocusedHoveredのタイミングでもインタラクションを行うべきでしょう。

まとめ

よく使うであろうRipple Effectについて色々見てきました。

前回の記事でも言及しましたが、Jetpack Composeはカスタマイズがしやすく、いろいろな表現を試したくなります。 特にアニメーションはKotlin Coroutinesで書くことが出来るので、かなり書きやすくなったと感じています。

ぜひ皆さんも様々なインタラクションを作成してみてください。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Kotlin Coroutinesで共有リソースを扱う