Jetpack Composeの大きな特徴として、再利用可能なコンポーネントを作りやすくなったことがあるでしょう。
細かい単位でComposable関数に切り出すことで、同じUIを共通化できるだけでなく、今後の仕様追加/変更の際にもスムーズにコンポーネントを再利用できるでしょう。
一方で、再利用可能なコンポーネントの実現にはいくつか注意すべき点があります。 今回は公式から述べられている注意事項や、自分が書いていて注意していることについて紹介します。
再利用の話に入る前に、再利用しない選択肢について書かせてください。
同じコードを何度も再利用することができれば、変更時に一箇所だけ変更すれば良くなりますし、何より楽です。 一方で、共通化することにより予期していなかった問題が発生することはよくあります。
一般的にUIの変更は多く、コンポーネントの切り出しの難易度は高いです。 とりあえず共通化しておいたものに、それぞれ別の対応が必要になり、いつの間にか一つのコンポーネントが肥大化していることはないでしょうか。
Jetpack Composeの場合であっても、1つの関数内に大量のwhen
やif
が存在する場合、それは責務を持ちすぎている可能性があります。
一見似ているものであっても、それが一時的なものであったり、異なる目的が存在する場合には、分けて扱ったほうが良いでしょう。
凝集度を意識しながらコードを書くことは、Jetpack Composeでも非常に重要です。
凝集度等の詳しい説明は以前こちらで書いています。
React等の宣言的UIでよく言われていることですが、コンポーネント内でマージンやサイズを決めるのではなく、外から設定できるようにすることで、汎用的に扱うことができます。
Jetpack Composeでは、Modifier
を引数として受け付けるようにしましょう。
渡したModifier
はコンポーネント内の一番外側にわたすように気をつけてください。
Modifier
は最初のデフォルト引数を持つ引数として、modifier
という名前で定義するのが推奨されているようです。参考
@Composable
fun Sample(
text: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
) {
Text(text = text)
}
}
@Composable
fun Sample(
text: String
) {
Box(
modifier = Modifier
.width(320.dp)
.padding(16.dp)
) {
Text(text = text)
}
}
PaddingValues
で渡す1番を実践していくと、背景があるコンポーネントやスクロール可能なコンポーネントのパディングは、Modifier
だけでは制御できないことがわかります。
Modifier
は順番が大事で、背景(background
)がある場合、背景より前に設定したpadding
はいわゆるマージンとして扱われ、背景の後に設定したpadding
はいわゆるパディングとして扱われます。
Modifier
.padding(16.dp) // いわゆるマージン
.background(Color.Red)
.padding(16.dp) // いわゆるパディング
背景色等はコンポーネント内で決めつつ、背景の内側のパディングを外側から操作したい場合、PaddingValues を別途渡してあげるのが良いでしょう。
@Composable
fun Sample(
text: String,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp)
) {
Box(
modifier = modifier
.background(Color.Red)
.padding(contentPadding)
) {
Text(text = text)
}
}
これはLazyColumn等でも使われている方法になります。
@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
...
Composable
を渡すボタンのラベル等、何かを表示したい場合に、できるだけ引数をString
などの実態ではなく、Composable
を渡すようにしておくと、汎用的に利用することができます。
例えば、以下のボタンの例だと、一部の利用箇所でのみ文字色を変えたり、アイコン付きのボタンにすることが容易になります。
@Composable
fun SampleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
modifier = modifier.clickable(onClick = onClick)
) {
content()
}
}
@Composable
fun SampleButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.clickable(onClick = onClick)
) {
Text(text = text)
}
}
LocalContentColor
やProvideTextStyle
で子Composableを制御するComposable
を渡すようにしていくと、今度は利用側のコードが多く書かないといけなくなり、また場合によってそれらが重複しているケースも出てくるでしょう。
CompositionLocalを使うことで、それらを緩和することができます。例えば、文字色やアイコン色等は LocalContentColor
を上書きすることで固定することができます。
@Composable
fun SampleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalContentColor provides Color.Red // 赤色に固定
) {
Box(
modifier = modifier.clickable(onClick = onClick)
) {
content()
}
}
}
このコンポーネントに対して、文字色を指定せずに利用した場合は文字色が赤色になり、明示的に指定した場合はその色になります。
SampleButton(onClick = { /* ... */ }) {
Text(
text = "Hello, World" // 赤色
)
}
SampleButton(onClick = { /* ... */ }) {
Text(
color = Color.Blue,
text = "Hello, World"// 青色
)
}
文字サイズやFontWeight
を指定する場合はProvideTextStyleを使うことができます。
@Composable
fun SampleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
ProvideTextStyle(
TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
) {
Box(
modifier = modifier.clickable(onClick = onClick)
) {
content()
}
}
}
これらは material
のpackageで提供されているため、BasicText等には影響しないことに注意してください。
他の要素も独自にCompositionLocal
を配布することで共通化をすることが可能ですが、暗黙的な値の受け渡しになるので、乱用は注意したほうが良さそうです。
引数で渡されたComposableにデータを渡す方法として、CompositionLocal
を使う他に、明示的な引数を渡す方法があります。
@Composable
fun SampleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (contentPadding: PaddingValues) -> Unit
) {
Box(
modifier = modifier.clickable(onClick = onClick)
) {
content(PaddingValues(16.dp))
}
}
以下のように使うことができます。
SampleButton(onClick = { /* ... */ }) { contentPadding ->
Text(
modifier = Modifier
.background(Color.Red)
.padding(contentPadding),
text = "Hello, World"
)
}
Stateだけでなく、Composableを渡すことも可能です。
fun SampleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (innerContent: @Composable () -> Unit) -> Unit
....
利用イメージは以下のようになります。 あまりやりすぎると理解が難しくなるので注意が必要そうです。
SampleButton(onClick = { /* ... */ }) { innerContent ->
Box(modifier = Modifier.background(Color.Red)) {
innerContent()
}
}
BasicTextFieldのdecorationBox
ではこの方法でフォームを装飾できるようになっています。
@Composable
fun BasicTextField(
value: String?,
onValueChange: ((String) -> Unit)?,
/* ... */
decorationBox: (@Composable (@Composable innerTextField: () -> Unit) -> Unit)? = @Composable { innerTextField -> innerTextField() }
): Unit
引数として渡す方法の他に、BoxのBoxScopeのように、Scopeとして渡す方法も考えられます。
@Composable
fun SampleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable SampleButtonScope.() -> Unit
) {
Box(
modifier = modifier.clickable(onClick = onClick)
) {
val scope = remember { SampleButtonScope() }
scope.content()
}
}
class SampleButtonScope {
fun Modifier.customWidth(): Modifier {
return this.width(120.dp)
}
}
このように、利用することができます。
その中だけで利用できるModifier
を定義する場合はこちらが良いかもしれません。
SampleButton(onClick = { /* ... */ }) {
Text(
modifier = Modifier.customWidth(),
text = "Hello, World"
)
}
Jetpack ComposeにはState Hoistingの考え方があり、出来る限り状態を外部で制御できるようにすることが推奨されています。 状態を外部から制御することができれば、そのUIは容易に再利用できるようになります。
例えば、テキストの入力できるTextFieldなども、現在の状態を表すvalue
と変更を通知するonValueChange
のパラメータを受け取ります。
onValueChange
のイベントに応じてvalue
を更新しない限り、表示は変更されません。
@Composable
fun TextField(
value: String?,
onValueChange: ((String) -> Unit)?,
modifier: Modifier? = Modifier,
/* ... */
): Unit
TextField
を内部で使うような独自コンポーネントも、同様に状態を外部で制御できるようにします。
内部で状態を保持すると、常にそれらの同期が必要になり、バグが混入する可能性が増加します。
@Composable
fun EmailTextField(
email: String,
onEmailChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = email,
onValueChange = onEmailChange,
modifier = modifier,
label = { Text("Email") }
)
}
@Composable
fun EmailTextField(
onEmailChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
var email by remember { mutableStateOf("") }
TextField(
value = email,
onValueChange = {
email = it
onEmailChange(it)
},
modifier = modifier,
label = { Text("Email") }
)
}
Jetpack Composeにおいて、スクロール位置はScrollStateにて管理を行います。
スクロール可能なコンポーネントを作成する場合、ScrollState
を引数として受け取ることにより、外部からスクロール位置を監視したり、制御することが可能になります。
デフォルト引数で rememberScrollState
を指定しておくことで、制御や監視の必要がない場合は、指定せずに使うこともできます。
その他のStateも、出来る限り外部から管理可能な形にすることが良いでしょう。
@Composable
fun EmailTextField(
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState()
) {
Column(
modifier = modifier.verticalScroll(scrollState)
) {
/* ... */
}
}
@Composable
@Composable
fun EmailTextField(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.verticalScroll(rememberScrollState())
) {
/* ... */
}
}
複雑なコンポーネントを作成する場合、独自でStateを用意するのが良いでしょう。
例えば、開閉が出来るアコーディオンメニューのUIを考えます。
以下のようなStateを作成し、それを引数に受け取るようにします。
@Stable
class AccordionState {
var expanded by mutableStateOf(false)
private set
fun toggle() {
if (expanded) {
collapsed()
} else {
expand()
}
}
fun expand() {
expanded = true
}
fun collapsed() {
expanded = false
}
}
@Composable
fun Accordion(
modifier: Modifier = Modifier,
state: AccordionState = remember { AccordionState() }
) {
Column(
modifier = modifier
) {
Text(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = { state.toggle() }),
text = "Header”
)
if (state. expanded) {
Text(text = "Content")
}
}
}
state class内で扱う状態も、Stateを使い、常にComposeに通知されるされるようにします。
また、Stable
アノテーションを付けておくことで、不要なrecomposeを抑制することができます。
ScrollState
と同様にデフォルト引数を指定しておくことで、呼び出し側で制御しない場合は意識せずに使うことができます。
画面回転時等に状態を維持したい場合はSaverを実装し、rememberSaveableを使う必要があります。
Jetpack ComposeでUIを再利用するための注意事項について紹介してきました。
再利用可能にするために、汎用的にコンポーネントを作成することは重要ですが、それによって不必要な考慮をし過ぎないように気をつけてください。(YAGNIの原則)
今回の記事も参考にしつつ、ぜひJetpack Composeで効率的なUI構築を実現してほしいと考えています。