デザインシステム(Design System)はプロダクト開発において重要な役割を担うようになっています。オープンソースのデザインシステムを採用する例や、自社のデザインシステムを構築する例は多くみられます。筆者も株式会社メルカリのデザインシステムプロジェクトのエンジニアとして、Jetpack Compose を使ったデザインシステムの実装をしました。今回は、その実装方法をご紹介します。
はじめに
Material Design というデザインシステムがすでに存在しているのに、「なぜ別で構築する必要があるのだろうか?」という疑問を持つ方もいらっしゃるのではないでしょうか。プロダクトデザインの世界には、Material Design のスペックがフィットしないケースが多々あります。複数のプラットフォーム(Android、iOS、Webなど)を同時にサポートしたいときや、特別なユーザー体験を提供したいときなど、Material Design をある程度カスタマイズし、拡張することが必要になります。
必要な知識 :
注意 :
- 本記事はデザインシステムそのものに関する説明ではありません。
- 本記事にあるソースコードは Jetpack Compose 1.0.0-beta01 と Android Studio Arctic Fox Canary 13 で構築したものです。
- DLS は Design (Language) System の略称で、コードのコンフリクトを避けるために、プレフィックスとして使われています。
デザイン
技術的観点に集中するために、とてもシンプルなデザインシステムを用意しました。プロダクトレベルのデザインシステムとの違いは規模感(スタイルの数と種類)だけで、基本の考え方は変わりません。
ご覧のとおり、多くのデザインシステムにおいてよく使われている 3 種類のスタイルを用意しました。これから実装していきます。
Style(Token)
まず使用する色を定義します。これらをまとめたカラーパレットを用意することで、テーマやブランドに応じて配色を切り替えられるようにします。
object DlsColors
{ val primary = Color(0xFF3366FF)
val background = Color(0xFFFFFFFF)
val backgroundReverse = Color(0xFF192038)
val basic = Color(0xFF8F9BB3)
val disable = basic.copy(alpha = 0.24f)
val text = Color(0xFF192038)
val textReverse = Color(0xFFFFFFFF)
val success = Color(0xFF00E096)
val link = Color(0xFF0095FF)
val warning = Color(0xFFFFAA00)
val error = Color(0xFFFF3D71)}
次にカラーパレットのインターフェースを定義します。materialColors
は MaterialTheme の色をオーバーライドするために用意しました。
interface DlsColorPalette {
val primary: Color
val background: Color
val basic: Color
val disable: Color
val text: Color
val success: Color
val link: Color
val warning: Color
val error: Color
val materialColors: Colors}
定義したカラーパレットを実装することで、実際の配色を定義します。次の dlsLightColorPalette
はデザインシステムのカラーパレットとカスタマイズされた MaterialTheme カラーを返します。
fun dlsLightColorPalette(): DlsColorPalette = object : DlsColorPalette {
override val primary: Color = DlsColors.primary
override val background: Color = DlsColors.background
override val basic: Color = DlsColors.basic
override val disable: Color = DlsColors.disable
override val text: Color = DlsColors.text
override val success: Color = DlsColors.success
override val link: Color = DlsColors.link
override val warning: Color = DlsColors.warning
override val error: Color = DlsColors.error
override val materialColors: ColorPalette = lightColorPalette(
primary = DlsColors.primary
)}
MaterialTheme のカラーをオーバーライドすることは必須ではありません。オーバーライドすることによってアプリの全体にわたる色の指定ができ、UI 実装時に色を指定するコードを減らすことができます。
同様にダークモード用のカラーパレットも用意します。
fun dlsDarkColorPalette(): DlsColorPalette = object : DlsColorPalette {
override val primary: Color = DlsColors.primary
override val background: Color = DlsColors.backgroundReverse
override val basic: Color = DlsColors.basic
override val disable: Color = DlsColors.disable
override val text: Color = DlsColors.textReverse
override val success: Color = DlsColors.success
override val link: Color = DlsColors.link
override val warning: Color = DlsColors.warning
override val error: Color = DlsColors.error
override val materialColors: Colors = darkColors(
primary = DlsColors.primary
)}
dlsDarkColorPalette
は dlsLightColorPalette
と基本同じですが、background と text の色が入れ変わっています。なお、プロダクトレベルのデザインシステムでは、カラーテーマごとに違うパレットを用意するといった構築方法も利用されます。
サイズ指定
サイズの定義はとてもシンプルです。デバイス画面のサイズによってサイズを変更するといった複数のバリエーションを持つ必要がある場合は、カラーパレットと同様の方法で対応できます。
data class DlsSize internal constructor(
val smaller: Dp = 4.dp,
val small: Dp = 8.dp,
val medium: Dp = 16.dp,
val large: Dp = 32.dp,
val larger: Dp = 64.dp)
Typography
タイポグラフィもサイズの定義と同様、シンプルに定義します。テーマに応じてフォントを使い分ける必要がある場合、また言語によって使用するフォントを切り替えたい場合など、複数のフォントバリアントのサポートが必要な場合があります。その場合は、配色と同様に実装することで対応できます。
@Immutable
data class DlsTypography internal constructor(
val headline1: TextStyle = TextStyle(
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp
),
......
val button: TextStyle = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 16.sp
),
val materialTypography: Typography = Typography(
body1 = paragraph1
))
materialTypography は、materialColors と同様、MaterialTheme のフォントをオーバーライドするために用意しました。
テーマ(ブランド)
MaterialTheme を利用することで、Material Design に対応した UI を作成できます。しかしサンプルとして作成しているデザインシステムは Material Design をベースにしたものではないため、独自の テーマ(ブランド) を追加する必要があります。
@Composable
fun DlsTheme(
colors: DlsColorPalette = dlsLightColorPalette(),
typography: DlsTypography = DlsTypography(),
children: @Composable() () -> Unit) {
CompositionLocalProvider(
LocalDlsColors provides colors,
LocalDlsTypography provides typography,
) {
MaterialTheme(
colors = colors.materialColors,
typography = typography.materialTypography
) {
children()
}
}}
object DlsTheme {
val colors: DlsColorPalette
@Composable
@ReadOnlyComposable
get() = LocalDlsColors.current
val typography: DlsTypography
@Composable
@ReadOnlyComposable
get() = LocalDlsTypography.current
val sizes: DlsSize
@Composable
@ReadOnlyComposable
get() = DlsSize()}
internal val LocalDlsColors = staticCompositionLocalOf { dlsLightColorPalette() }
internal val LocalDlsTypography = staticCompositionLocalOf { DlsTypography() }
コンポーザブル関数 DlsTheme
はテーマ(ブランド)によって変化する可能性のあるスタイルを外から受け取れます。サンプルとして作成している デザインシステムの場合、 配色と、タイポグラフィ を受け取れます。DlsTheme
は MaterialTheme をラップしています。Material Design の機能とスタイルをデザインのベースとして流用することで、デザインシステムの実装コストを大幅に減らすことを目的としています。
CompositionLocalProvider
と staticCompositionLocalOf
について少し説明します。
CompositionLocalProvider
でプロバイドしたスタイルバリュー(サンプルでは Color とTypography)は CompositionLocalProvider
の下の階層のどこでも CompositionLocal.current
(LocalDlsColors.current
と LocalDlsTypography.current
)でアクセスすることができます。そのバリューを変更すると CompositionLocal.current
も自動的に更新されます。CompositionLocal
についての詳しい説明は長くなるため、本ブログ記事では割愛します。
デモ
デザインシステムのスタイルとテーマが完成したので、使い方を説明します。
DlsTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = DlsTheme.colors.background
) {
Text(
text = "TEXT",
color = DlsTheme.colors.success,
style = DlsTheme.typography.paragraph1
)
}}
まず、デザインシステムを適用したい画面を DlsTheme
でラップします。必要なスタイルは DlsTheme
からアクセスできます。MaterialTheme の使い方は変わりません。
ダークモードなどの違うテーマをサポートするときは、DlsTheme
にスタイルを渡すことで実現できます。
val isDarkState = mutableStateOf(false)
setContent {
DlsTheme(
colors = if (isDarkState.value) dlsDarkColorPalette() else dlsLightColorPalette()
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = DlsTheme.colors.background
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (isDarkState.value) "Is Dark" else "Is Light",
color = DlsTheme.colors.text,
style = DlsTheme.typography.paragraph1
)
Spacer(modifier = Modifier.height(DlsTheme.sizes.medium))
Button(
onClick = {
isDarkState.value = !isDarkState.value
}
) {
Text(
text = text,
style = DlsTheme.typography.button
)
}
}
}
}}
コンポーネント
Jetpack Compose を使うとデザインシステムコンポーネントを簡単に実装できます。デザインシステムを実装する上で優れていると感じた点を紹介します。
1. API レベル制限がない
Android エンジニアであれば、一行のコードで問題解決できると思ったのに、最小 API レベルがあわず、利用したかった機能が使えなかった、という体験をしたことがあるでしょう。
このようなことは Jetpack Compose には存在しません。Jetpack Compose は SDK ではなく Android X のライブラリとして提供されています。API レベルを気にすることなく、新しい機能を使うことができます。
2. ほぼ Kotlin 100%
これは Kotlin 愛好者たちにとって素晴らしいニュースだと思います。
上のような Avatar コンポーネントを実装する場合を考えてみましょう。
従来の Android UI システムで実装した場合のソースコード :
<style name="Dls.Component.Avatar">
<item name="android:background">@drawable/shape_avatar_background</item>
<item name="android:adjustViewBounds">true</item>
</style>
<style name="Dls.Component.Avatar.Large">
<item name="android:layout_width">?attr/dlsSizeLarge</item>
<item name="android:layout_height">?attr/dlsSizeLarge</item>
<item name="android:minWidth">?attr/dlsSizeLarge</item>
<item name="android:minHeight">?attr/dlsSizeLarge</item>
<item name="android:maxWidth">?attr/dlsSizeLarge</item>
<item name="android:maxHeight">?attr/dlsSizeLarge</item>
</style>
<style name="Dls.Component.Avatar.Medium">
<item name="android:layout_width">?attr/dlsSizeMedium</item>
<item name="android:layout_height">?attr/dlsSizeMedium</item>
<item name="android:minWidth">?attr/dlsSizeMedium</item>
<item name="android:minHeight">?attr/dlsSizeMedium</item>
<item name="android:maxWidth">?attr/dlsSizeMedium</item>
<item name="android:maxHeight">?attr/dlsSizeMedium</item>
</style>
<style name="Dls.Component.Avatar.Small">
<item name="android:layout_width">?attr/dlsSizeSmall</item>
<item name="android:layout_height">?attr/dlsSizeSmall</item>
<item name="android:minWidth">?attr/dlsSizeSmall</item>
<item name="android:minHeight">?attr/dlsSizeSmall</item>
<item name="android:maxWidth">?attr/dlsSizeSmall</item>
<item name="android:maxHeight">?attr/dlsSizeSmall</item>
</style>
- デフォルト Size バリアント用の attribute 定義(必要な場合)
<!-- attrs.xml -->
<attr name="dlsStyleAvatarMedium" format="reference" />
<!-- themes.xml -->
<item name="dlsStyleAvatarMedium">@style/Dls.Component.Avatar.Medium</item>
class Avatar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.dlsStyleAvatarMedium) : ImageView(context, attrs, defStyleAttr) {
init {
// make the image be clipped by circle background
clipToOutline = true
}}
このように、従来の Android UI システムを利用した方法ではデザインシステムのスタイルを適用するために XML と Kotlin の両方を使わなければなりませんでした。また、XML で定義したどのようなスタイルが実際に適用されるのか、プレビューで確認するのは難しく、実際にビルドしてみなければならないことがほとんどでした。
次に Jetpack Compose でデザインシステムを適用する場合を見てみましょう。
Jetpack Compose で実装した場合のソースコード :
object Avatar {
sealed class SizeVariant {
@get:Composable
abstract val value: Dp
object Small : SizeVariant() {
override val value: Dp
@Composable
get() = DlsTheme.sizes.small
}
object Medium : SizeVariant() {
override val value: Dp
@Composable
get() = DlsTheme.sizes.medium
}
object Large : SizeVariant() {
override val value: Dp
@Composable
get() = DlsTheme.sizes.large
}
}}
@Composable
fun Avatar(
painter: Painter,
sizeVariant: Avatar.SizeVariant = Avatar.SizeVariant.Medium
onClick: (() -> Unit)? = null) {
val clickable = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
Box(
modifier = Modifier
.size(sizeVariant.value)
.clip(CircleShape)
.then(clickable)
) {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}}
このように Jetpack Compose では UI の実装に XML と Kotlin を併用する必要はほとんどありません。もちろん、Jetpack Compose でも XML リソースファイルを使う必要があるケース(画面のオリエンテーションのハンドリングなど)が存在しますが、基本的に Kotlin のみでコンポーネントの実装ができると言えるでしょう。
3. 可視性制御
従来の Android UI システムでは XML ファイルに定義されているリソース(dimen、style など)はプロジェクトでグローバルに参照することができ、別 module にある同名の定義に上書きされる可能性があります。
prefix(dls_
、internal_
など)をつけて重複を回避する解決法がありますが、根本的な解決策ではありません。
<attr name="dlsInternalStyleRow" format="reference" />
これもまた Kotlin のおかげで解決できます。Jetpack Compose は Kotlin のコードであるため、Kotlin の可視性修飾子を使ってデザインシステム内部の定義を Private にするなど、簡単に可視性を制御できます。
private const val LINE_HEIGHT_MULTIPLIER = 1.4
@Composable
internal fun ClearIcon(
onClick: () -> Unit,
modifier: Modifier = Modifier) {
IconButton(
onClick = onClick,
modifier = modifier
) {
Icon(
painter = DlsIcons.deleteFilled,
modifier = Modifier.size(DlsTheme.sizing.measure20),
tint = DlsTheme.colors.iconDisabled
)
}}
4. 全ては Composable
これはデザインシステムを実装するにあたって強い機能だと思います。
例えば、従来の Android UI システムでは、サブビューをパラメータとして受けとって表示できるコンポーネントを作る場合にどれくらい大変か想像してみてください。これはデザインシステムコンポーネントのデザインによくある設計です。
Jetpack Compose を使えば、Composable 関数を使ってコンポーネントを構築することが一般的です。
@Composable
fun ListItem(
title: String,
// accept a Composable body
body: @Composable RowScope.() -> Unit) {
Row(
Modifier.padding(DlsTheme.sizes.medium)
) {
Text(
text = title,
modifier = Modifier.padding(end = DlsTheme.sizes.small),
color = DlsTheme.colors.text,
style = DlsTheme.typography.headline3
)
body()
}}
// usage example
ListItem(title = "Title") {
Text(
text = "Custom Body",
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.border(1.dp, color = DlsTheme.colors.success),
color = DlsTheme.colors.success,
textAlign = TextAlign.Center,
style = DlsTheme.typography.headline3
)}
コンポーネントのタイトルの右側に表示したいサブビューを body パラメータで受け取ることができます。
まとめ
ご覧のとおり、デザインシステムと Jetpack Compose は相性が良いので、もしデザインシステムに対応する Android アプリを構築する場合には、ぜひ Jetpack Compose に移行することをお勧めします。
Source Code
https://github.com/howiezuo/design-system-compose