ユーザが何かをタップしたときにフィードバックを返すことは、正常に入力できたことをユーザに伝えるだけでなく、心地よい操作感やリッチ感を実現するためにも重要です。
MaterialDesignでは、クリック時に波紋状のRippleエフェクトを表示します。
Jetpack ComposeでもMaterialThemeを利用している場合、クリック可能なコンポーネントはRippleエフェクトが表示されます。
今回はJetpack ComposeでRippleエフェクトについて色々深堀りをしていき、Rippleの色を変えたり、そもそもタップ時のエフェクトを変更する方法についても紹介します。
Jetpack ComposeではModifierのclickableを指定すると、同時にRippleエフェクトも表示されます。
MaterialTheme
の中でないとRippleにならないことに注意してください。
MaterialTheme {
Text(
modifier = Modifier
.clickable(onClick = {})
.padding(horizontal = 8.dp, vertical = 4.dp),
text = "Click!"
)
}
もちろん、Buttonや Checkbox 等のMaterialのコンポーネントにも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
)
すなわち、まとめると以下のようになります。
少し複雑ですが、文字色の輝度によって出し分けすることにより、Rippleや文字を見えやすくする工夫がされています。
この恩恵を最大限に得るためには、文字色を指定する際に直接指定するのではなく、LocalContentColorを上書きすべきでしょう。
MaterialTheme {
CompositionLocalProvider(
LocalContentColor provides Color.Red
) {
Text(
modifier = Modifier
.clickable(onClick = {}) // 赤色のRippleが出る
.padding(horizontal = 8.dp, vertical = 4.dp),
text = "button" // 赤色の文字色
)
}
}
文字色と同時に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の色を変えたい場合はindication
にrememberRipple を指定します。
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が表示されます。
rememberRipple
にはcolor
の他にradius
とbounded
を指定することができます。
bounded
をfalse
にすることで領域を超えた円形の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.Release
かPressInteraction.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を用意しておくと使いやすいと思います。animationSpec
にdampingRatio
を調整した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で確認できます。
今回はわかりやすさのために省略しましたが、実際にはFocused
やHovered
のタイミングでもインタラクションを行うべきでしょう。
よく使うであろうRipple Effectについて色々見てきました。
前回の記事でも言及しましたが、Jetpack Composeはカスタマイズがしやすく、いろいろな表現を試したくなります。 特にアニメーションはKotlin Coroutinesで書くことが出来るので、かなり書きやすくなったと感じています。
ぜひ皆さんも様々なインタラクションを作成してみてください。