はじめに
こんにちは、コミュニケーションアプリ LINE のAndroidクライアントを開発している森です。
LINEでは現在、Jetpack Composeの導入を進めています。
導入を進めるにあたって、LINEのDesign Systemや独自機能である「着せかえ」機能を実装する必要がありました。
この記事では、LINEがどのようにJetpack ComposeでUIの基盤を作っているのか、その実装方法について紹介します。
ダークテーマ対応とSemantic Color
LINEアプリはダークテーマに対応しているため、Jetpack Composeでも同様にダークテーマを実装する必要がありました。
ダークテーマを実現するため、LINEでは「LINE Design System」の中で「Semantic Color」という概念を定義しています。
LINE Design Systemとは
LINE Design Systemは、多くの国のユーザに一貫した価値や経験を提供するために用意されたガイドラインです。
この中には、デザインの原則や色などの共通リソース、各種コンポーネントが定義されています。
一部はMaterial Designとも大きく異なるため、独自に実装する必要があります。
詳細は以下のWebサイトで公開されていますので、ぜひご覧ください。
designsystem.line.me
Semantic Colorとは
LINE Design Systemの一つとして提供されている「Semantic Color」では、ダークテーマとライトテーマそれぞれに対応した色を一つの名前で定義することができます。
例えば、 「primaryText」
カラーの値は、ライトモードの場合は #111111
、ダークモードの場合は #FFFFFF
となるように設定されています。
出典:https://designsystem.line.me/LDSM/foundation/color/line-semantic-colors-ex-en/
デザイナーはSemantic Colorを指定し、エンジニアはその名前を使うことで自動的に複数のカラーテーマに対応することができます。
Semantic ColorをJetpack Composeで実装する
このSemantic ColorをJetpack Composeで実装する方法について紹介します。
まず、このSemantic ColorをLdsSemanticColorsクラスとして定義します。Lds
はLINE Design Systemの略で、競合を避けるため、Design Systemに関する共通のprefixとして使っています。
@Immutable
data class LdsSemanticColors(
val primaryText: Color,
val primaryBackground: Color,
val primarySurface: Color,
/* ... */
)
この定義したSemantic ColorをCompositionLocalで提供します。CompositionLocal
はUI階層に対して値を暗黙的に伝えるためのツールです。
提供は独自テーマの中で行うと良いでしょう。
公式のドキュメントでも紹介されている方法ですが、MaterialTheme
をラップすることで独自のテーマを定義できます。
今回はAndroid Viewと色の差分を発生させたくなかったため、xmlのcolor resourceを参照するようにしていますが、一部の画面はライトモードのみにする等、他のテーマに差し替えることも可能です。
@Composable
fun LineTheme(
semanticColors: LdsSemanticColors = ldsSemanticColors(),
content: @Composable () -> Unit
) {
MaterialTheme {
CompositionLocalProvider(
LocalLdsSemanticColors provides semanticColors,
content = content
)
}
}
object LineTheme {
val semanticColors: LdsSemanticColors
@Composable
get() = LocalLdsSemanticColors.current
}
@Composable
private fun ldsSemanticColors(): LdsSemanticColors =
LdsSemanticColors(
primaryText = colorResource(colorsR.color.primaryText),
primaryBackground = colorResource(colorsR.color.primaryBackground),
primarySurface = colorResource(colorsR.color.primarySurface),
/* ... */
)
private val LocalLdsSemanticColors: ProvidableCompositionLocal<LdsSemanticColors> =
staticCompositionLocalOf { error("LdsSemanticColors is not provided.") }
色を使う側では、MaterialTheme
と同様に、以下のように利用できます。
@Composable
private fun SampleText() {
Text(
text = "Sample Text",
color = LineTheme.semanticColors.primaryText
)
}
LINEの「着せかえ」を実現する
LINEにはユーザが好きなデザインを設定できる「着せかえ」という機能があります。
着せかえ機能では背景色や文字色が変わるほか、アイコンの画像等も変更することができます。
出典:https://guide.line.me/ja/stickers-emojis-themes/set-themes.html
テキスト色を変更する
各着せかえはJSONで定義されており、要素ごとに色や画像が指定されています。
{
"manifest": /* ... */,
"componentA.tab": {
"arrow.tintcolor": "#777777",
"background.color": {
"normal": "#111111",
"pressed": "#1f1f1f"
},
"text.color": "#ffffff",
"button.tintcolor": {
"normal": "#464f69",
"pressed": "#2d3243"
},
/* ... */
JSONから読み取った着せかえの定義も、Semantic Colorと同様にLineThemeからCompositionLocalを用いて提供します。
ただし、JSONの読み込み部分は複雑であるため、今回の説明では省略します。
また、モジュールの関係でPreviewでは着せかえが正しく動作しない問題がありました。
そのため、Previewの際にはテーマを無効化しています。
ComposableがPreviewで使用されているかどうかは、 LocalInspectionMode.current
を参照することで確認できます。
@Composable
fun LineTheme(
semanticColors: LdsSemanticColors = ldsSemanticColors(),
content: @Composable () -> Unit
) {
MaterialTheme {
CompositionLocalProvider(
LocalLdsSemanticColors provides semanticColors,
LocalThemeElementProvider provides themeElementValueProvider(),
content = content
)
}
}
@Composable
private fun themeElementValueProvider(): ThemeElementProvider =
if (LocalInspectionMode.current) {
NoOpThemeElementProvider
} else {
remember { ThemeElementProviderImpl() }
}
そのままでは少し扱いにくいので、keyから色を取得するためのComposable関数を追加します。
このとき、着せかえが有効になってないときの色も指定できるようにしておきます。
@Composable
fun themeTextColor(
themeElementKeys: Set<ThemeElementKey>,
default: Color = Color.Unspecified
): Color = LocalThemeElementProvider.current
.getTextColor(themeElementKeys)
.takeOrElse { default }
利用側では、以下のようにthemeKeyとデフォルト色を同時に指定して利用します。
@Composable
fun ThemedText() {
Text(
text = "Themed Text",
color = themeTextColor(
themeElementKeys = SAMPLE_KEYS,
default = LineTheme.semanticColors.primaryText
)
)
}
Android Viewでは一度UIを組み立てた後に、後から色や画像を変更することで着せかえ機能を実現していました。
しかし、Jetpack Composeは宣言的UIモデルを採用しているため、UIは常にステートから描画され、後から直接UIを変更することはできません。
そのため、Jetpack Composeでは今までとは異なり、UIを構築する際に宣言的に着せかえの色や画像を指定する必要があります。
画像を変更する
着せかえで提供される画像はAndroidView向けにDrawableに変換しており、これを再利用したかったので、Drawableを描画できるPainterクラスを用意しました。
公式ライブラリから提供されているpainterResource
関数では9-patch画像を描画できませんが、以下のPainterでは描画することができます。
class ThemePainter(private val drawable: Drawable) : Painter() {
override val intrinsicSize: Size = Size(
width = drawable.intrinsicWidth.toFloat(),
height = drawable.intrinsicHeight.toFloat()
)
override fun DrawScope.onDraw() {
val width = size.width.roundToInt()
val height = size.height.roundToInt()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
drawable.setBounds(0, 0, width, height)
drawable.draw(Canvas(bitmap))
drawImage(bitmap.asImageBitmap())
}
}
文字色と同様に、Painterを取得するComposable関数を用意します。
@Composable
fun themeImagePainter(themeElementKeys: Set<ThemeElementKey>): Painter? {
val themeElementProvider = LocalThemeElementProvider.current
return remember(themeElementKeys, themeElementProvider) {
themeElementProvider.getImageDrawable(themeElementKeys)
?.let { ThemePainter(it) }
}
}
画像は着せかえによって画像が変更される場合と塗り色が変わる場合があります。
そのため、Theme対応のImage Composableを用意し、簡単に使えるようにしました。
@Composable
fun ThemeImage(
painter: Painter,
contentDescription: String?,
themeElementKeys: Set<ThemeElementKey>,
modifier: Modifier = Modifier,
/* ... */
tint: Color = Color.Unspecified
) {
val themedPainter = themeImagePainter(themeElementKeys)
val themedTint = if (themedPainter == null) {
themeImageTintColor(themeElementKeys, tint)
} else {
Color.Unspecified
}
Image(
painter = themedPainter ?: painter,
contentDescription = contentDescription,
modifier = modifier,
/* ... */
colorFilter = if (themedTint.isSpecified) {
ColorFilter.tint(themedTint)
} else {
null
}
)
}
背景を変更する
背景はModifierで指定できるようにします。
着せかえによって、背景色が変わるときと、背景画像が設定されるときがあります。
実装は以下の通りです。
背景にPainterを描画したい場合、 drawBehind
を使うことで実現できます。
fun Modifier.themeBackground(
themeElementKeys: Set<ThemeElementKey>,
default: Color = Color.Unspecified
): Modifier = composed {
val painter = themeBackgroundPainter(themeElementKeys)
if (painter != null) {
drawBehind { with(painter) { draw(size = size) } }
} else {
val tint = themeBackgroundColor(themeElementKeys)
.takeOrElse { default }
background(tint)
}
}
クリック時の色を変更する
LINE Design Systemおよび、着せかえが適応されたUIでは、クリック時に背景色の塗りが変わる場合と、文字やアイコンの色が変わる場合があります。
また、ボタンが非活性の場合(disabled)や、項目が選択中(selected)の場合も、同じように背景色や文字色が変化します。
出典:https://designsystem.line.me/LDSM/foundation/color/line-color-guide-ex-en/
AndroidViewでは、Color state list resourceを使ってこれらを実現していました。
しかし、Jetpack Composeではクリック時はリップルエフェクトが表示され、また非活性時の色や選択時の色は個別に指定する必要があります。
そこで、Color state list resourceに似た、新しい仕組みを作ることにしました。
まず最初に、UIの状態を定義します。
ここでは、 Normal
, Pressed
, Selected
, Disabled
の4つの状態を定義しました。
不要なrecomposeを避けるため、Enumを直接使わずStateクラスで保持するようにしています。
@Stable
class ThemeElementState(
val interactionSource: MutableInteractionSource,
pressedState: State<Boolean>,
enabledState: State<Boolean>,
selectedState: State<Boolean>
) {
val value: Value by derivedStateOf {
when {
pressedState.value -> Value.Pressed
!enabledState.value -> Value.Disabled
selectedState.value -> Value.Selected
else -> Value.Normal
}
}
enum class Value {
Normal,
Pressed,
Selected,
Disabled,
}
}
合わせて、先程のStateを作るComposable関数を用意します。
クリックイベントは interactionSource
を経由して取得することができます。
有効か、選択中か、という情報は引数で渡せるようにしました。
Jetpack Composeでクリックイベントは非同期に処理されるため、短時間のクリックではエフェクトが全く表示されないことがありました。
それを避けるため、最低50msはクリック状態になるように調整が入っています。
@Composable
fun rememberElementState(
enabled: Boolean = true,
selected: Boolean = false
): ThemeElementState {
val interactionSource = remember { MutableInteractionSource() }
val pressedState = remember {
// In order to avoid the lack of any effect when clicked, the pressed content is displayed
// for a minimum duration.
interactionSource.isPressedFlow
.guaranteeMinimumPressedTime(MIN_PRESSED_CONTENT_DURATION_MILLIS)
}.collectAsState(false)
val enabledState = rememberUpdatedState(enabled)
val selectedState = rememberUpdatedState(selected)
return remember {
ThemeElementState(
interactionSource = interactionSource,
pressedState = pressedState,
enabledState = enabledState,
selectedState = selectedState
)
}
}
private const val MIN_PRESSED_CONTENT_DURATION_MILLIS = 50L
private val InteractionSource.isPressedFlow: Flow<Boolean>
get() = flow {
val pressInteractions = mutableListOf<PressInteraction.Press>()
interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> pressInteractions.add(interaction)
is PressInteraction.Release -> pressInteractions.remove(interaction.press)
is PressInteraction.Cancel -> pressInteractions.remove(interaction.press)
}
emit(pressInteractions.isNotEmpty())
}
}.distinctUntilChanged()
private fun Flow<Boolean>.guaranteeMinimumPressedTime(minDurationMillis: Long): Flow<Boolean> =
channelFlow {
var lastPressedMillis: Long = 0
collectLatest { isPressed ->
if (isPressed) {
lastPressedMillis = SystemClock.uptimeMillis()
} else {
val pressedDurationMillis = SystemClock.uptimeMillis() - lastPressedMillis
delay(minDurationMillis - pressedDurationMillis)
}
send(isPressed)
}
}
あとは状態ごとに色を定義できるクラスを用意し、状態から色に変換できるようにします。
@Immutable
data class ThemeColorValues(
val normal: Color,
val pressed: Color = normal,
val selected: Color = normal,
val disabled: Color = normal
)
fun ThemeColorValues.getColorForState(state: ThemeElementState): Color =
when (state.value) {
ThemeElementState.Value.Normal -> normal
ThemeElementState.Value.Pressed -> pressed
ThemeElementState.Value.Selected -> selected
ThemeElementState.Value.Disabled -> disabled
}
以下はこれらを使ってクリック時にアイコン色を変えるサンプルです。
同様に背景色を変えたり、複数のコンポーネントの色を同時に変えたりすることができます。
@Composable
fun SampleIconButton(
onClick: () -> Unit,
contentDescription: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val themeElementState = rememberThemeElementState(enabled = enabled)
Box(
modifier = modifier
.size(/* ... */)
.clickable(
onClick = onClick,
role = Role.Button,
enabled = enabled,
interactionSource = themeElementState.interactionSource,
indication = null
)
) {
Icon(
painter = /* ... */
contentDescription = contentDescription,
tint = ThemeColorValues(
normal = LineTheme.semanticColors.primaryInverseFill,
pressed = LineTheme.semanticColors.primaryInverseFillPressed,
disabled = LineTheme.semanticColors.primaryInverseFillDisabled
).getColorForState(themeElementState)
)
}
}
まとめ
今回はJetpack Composeを使ったLINEのUI基盤について紹介しました。
Jetpack ComposeではKotlinの多彩な表現が使えるため、アプリ要件を厳格に表現できるメリットがあると感じています。
また、Jetpack Compose向けにすべてを一から作り直す必要はなく、例えば Drawable
はそのまま使うなど、既存の資産を柔軟に使える点も良かったです。
Jetpack ComposeではUIへの参照を保持したり、UIの現在の値や状態を取得することはできず、厳格にステートとして管理する必要があります。
これにより予期せぬ不具合の発生リスクが低減されるため、基本的にはメリットが大きいと言えます。
しかし、いくつかの点ではAndroid Viewで使っていたアプローチをそのまま使えず、考え方を変える必要がありました。
LINE Design Systemには、ボタンやダイアログを始めとした様々なコンポーネントが定義されています。
今後はこれらのコンポーネントをJetpack Composeで実装し、新しいUIの構築をより簡単に行えるようにすることを目指しています。
出典:https://designsystem.line.me/LDSM/components/
今回紹介した内容が少しでも参考になれば幸いです。