この記事は、 Google Developer Expert (GDE) @yanzm さんに寄稿いただいたゲスト記事です。この「エキスパートに学ぶシリーズ」では、まだ Jetpack Compose を使ったことがないデベロッパー向けに既存アプリで段階的に導入を進める方法や導入するメリットについて GDE の方々よりご紹介いただきます。前回は既存アプリに Jetpack Compose を導入する手順について紹介しました。今回は XML レイアウトの中に Jetpack Compose による UI を表示するための View を追加し、段階的に Jetpack Compose に置き換えていく方法を紹介します。
まず、カンファレンスのセッション情報を表示するためのレイアウトを例に、XML で定義されたレイアウトを Jetpack Compose へ置き換えて行きます。
レイアウトは次の様に ConstraintLayout を使って構成されています。その中には、セッションタイトル、スピーカーの画像、スピーカーの名前、スピーカーの肩書きを表示する 3 つの TextView と 1 つの ImageView が含まれます。
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/session_title" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:layout_marginEnd="16dp" android:textAppearance="@style/TextAppearance.AppCompat.Headline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Jetpack Compose で Material Design 3" /> <ImageView android:id="@+id/speaker_image" android:layout_width="72dp" android:layout_height="72dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:layout_marginBottom="16dp" android:background="#cccccc" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/session_title" /> <TextView android:id="@+id/speaker_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:textAppearance="@style/TextAppearance.AppCompat.Body1" app:layout_constraintBottom_toTopOf="@id/speaker_title" app:layout_constraintStart_toEndOf="@id/speaker_image" app:layout_constraintTop_toTopOf="@id/speaker_image" app:layout_constraintVertical_chainStyle="packed" tools:text="Yuki Anzai" /> <TextView android:id="@+id/speaker_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:textAppearance="@style/TextAppearance.AppCompat.Caption" app:layout_constraintBottom_toBottomOf="@id/speaker_image" app:layout_constraintStart_toStartOf="@id/speaker_name" app:layout_constraintTop_toBottomOf="@id/speaker_name" tools:text="Android GDE" /></androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/session_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Jetpack Compose で Material Design 3" />
<ImageView
android:id="@+id/speaker_image"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_marginBottom="16dp"
android:background="#cccccc"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/session_title" />
android:id="@+id/speaker_name"
android:layout_width="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@id/speaker_title"
app:layout_constraintStart_toEndOf="@id/speaker_image"
app:layout_constraintTop_toTopOf="@id/speaker_image"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Yuki Anzai" />
android:id="@+id/speaker_title"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
app:layout_constraintBottom_toBottomOf="@id/speaker_image"
app:layout_constraintStart_toStartOf="@id/speaker_name"
app:layout_constraintTop_toBottomOf="@id/speaker_name"
tools:text="Android GDE" />
</androidx.constraintlayout.widget.ConstraintLayout>
このレイアウトを持つアプリは MDC-Android (英語) の マテリアル デザイン 3 テーマをベースとするアプリのテーマを View システムで使っています。
そのため Jetpack Compose 用の マテリアル デザイン 3 コンポーネントライブラリに加えて、既存 View システムで使用しているテーマを Jetpack Compose で再利用するための MDC-Android Compose Theme Adapter (英語) のマテリアル デザイン 3 用ライブラリを追加します。
dependencies { … implementation "androidx.compose.material3:material3:1.0.0" implementation "com.google.android.material:compose-theme-adapter-3:1.0.21"}
dependencies {
…
implementation "androidx.compose.material3:material3:1.0.0"
implementation "com.google.android.material:compose-theme-adapter-3:1.0.21"
}
XML レイアウトの中に Jetpack Compose による UI を表示するには、 ComposeView という View を利用します。
XML レイアウトの中に ComposeView を追加して android:id を指定します。
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout …> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/session_title" … /> …</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout …>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
app:layout_constraintTop_toTopOf="parent" />
… />
ComposeView には setContent メソッドが用意されています。このメソッドのラムダブロック内に Jetpack Compose を利用したコードを記述して UI を実装していきます 。
class SessionDetailActivity : AppCompatActivity() { private val binding by lazy { ActivitySessionDetailBinding.inflate(layoutInflater) } private val viewModel: SessionDetailViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) … binding.composeView.setContent { // この中に Jetpack Compose のコードを書いていく Mdc3Theme { Text("Hello, Compose!") } } }}
class SessionDetailActivity : AppCompatActivity() {
private val binding by lazy {
ActivitySessionDetailBinding.inflate(layoutInflater)
private val viewModel: SessionDetailViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.composeView.setContent {
// この中に Jetpack Compose のコードを書いていく
Mdc3Theme {
Text("Hello, Compose!")
XML レイアウトの置き換え先になるコンポーズ可能関数を SessionDetail という名前で用意します。以下、 SessionDetail Composable の様な記述は、 SessionDetail という名前のコンポーズ可能関数を意味します。 SessionDetail Composable に表示させるセッションの情報は、SessionData 型のオブジェクトを引数でとるようにします。
Jetpack Compose では Preview アノテーションを使って Composable のプレビューを表示することができるので、 SessionDetail Composable のプレビューも用意しておきます。
@Composableprivate fun SessionDetail(data: SessionData) {}@Preview@Composableprivate fun SessionDetailPreview() { Mdc3Theme { Surface { SessionDetail( data = SessionData( … ) ) } }}
@Composable
private fun SessionDetail(data: SessionData) {
@Preview
private fun SessionDetailPreview() {
Surface {
SessionDetail(
data = SessionData(
)
SessionData は SessionDetailViewModel が保持しており、Flow<SessionData> 型で公開されています。
class SessionDetailViewModel : ViewModel() { val sessionDataFlow: Flow<SessionData> = …}
class SessionDetailViewModel : ViewModel() {
val sessionDataFlow: Flow<SessionData> = …
Jetpack Compose には Flow を State に変換する collectAsState メソッドが用意されています。このメソッドを使って Flow<SessionData> を State<SessionData?> に変換します。State の値に value プロパティでアクセスし、 SessionDetail Composable に渡します。
SessionDetailViewModel ではなく SessionDetail を渡すようにすることで、 SessionDetail Composable を stateless(状態を持たない)な Composable にできます。stateless な Composable はテストがしやすくプレビューも簡単にできます。
class SessionDetailActivity : AppCompatActivity() { … private val viewModel: SessionDetailViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) … binding.composeView.setContent { // この中に Jetpack Compose のコードを書いていく Mdc3Theme { val state: State<SessionData?> = viewModel.sessionDataFlow .collectAsState(null) val data: SessionData? = state.value if (data != null) { SessionDetail(data = data) } } } }}
val state: State<SessionData?> = viewModel.sessionDataFlow
.collectAsState(null)
val data: SessionData? = state.value
if (data != null) {
SessionDetail(data = data)
セッションタイトルを表示する Text Composable を SessionDetail Composable に追加します。セッションタイトルを表示していた TextView は削除するのではなく android:visibility を invisible にします。
スピーカーの画像を表示する ImageView がこの TextView を参照しているため、削除すると ImageView のレイアウトでエラーがおこり、参照先を変更しなければなりません。また android:visibility が gone の場合だと TextView のエリア分だけ ImageView が上にずれてしまいます。
@Composableprivate fun SessionDetail(data: SessionData) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Text( text = data.sessionTitle, style = MaterialTheme.typography.headlineSmall, ) }}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = data.sessionTitle,
style = MaterialTheme.typography.headlineSmall,
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout …> <androidx.compose.ui.platform.ComposeView … /> <TextView android:id="@+id/session_title" … android:visibility="invisible" … /> …</androidx.constraintlayout.widget.ConstraintLayout>
android:visibility="invisible"
インターネット上にある画像を Compose で表示する例として、ここでは Coil ライブラリ (英語) を使います。
dependencies { … implementation "io.coil-kt:coil-compose:2.2.2"}
implementation "io.coil-kt:coil-compose:2.2.2"
Coil ライブラリ の AsyncImage Composable を使ってスピーカーの画像を表示します。
ConstraintLayout 内の ImageView は android:visibility を invisible にしておきます。
@Composableprivate fun SessionDetail(data: SessionData) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Text( text = data.sessionTitle, style = MaterialTheme.typography.headlineSmall, ) Row( modifier = Modifier.padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( model = data.speakerImageUrl, contentDescription = "speaker image", modifier = Modifier.size(72.dp) ) } }}
Row(
modifier = Modifier.padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
AsyncImage(
model = data.speakerImageUrl,
contentDescription = "speaker image",
modifier = Modifier.size(72.dp)
スピーカーの名前と肩書きも同じように Text Composable で表示します。
@Composableprivate fun SessionDetail(data: SessionData) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Text( text = data.sessionTitle, style = MaterialTheme.typography.headlineSmall, ) Row( modifier = Modifier.padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( model = data.speakerImageUrl, contentDescription = "speaker image", modifier = Modifier.size(72.dp) ) Column( modifier = Modifier.padding(start = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = data.speakerName, style = MaterialTheme.typography.bodyMedium, ) Text( text = data.speakerTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } }}
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
text = data.speakerName,
style = MaterialTheme.typography.bodyMedium,
text = data.speakerTitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
ConstraintLayout 内の全ての View を SessionDetail Composable に置き換えたので、 XML レイアウトおよび View Binding をやめて Activity の setContent を使うようにします。
class SessionDetailActivity : AppCompatActivity() { private val viewModel: SessionDetailViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Mdc3Theme { val state: State<SessionData?> = viewModel.sessionDataFlow .collectAsState(null) val data: SessionData? = state.value if (data != null) { SessionDetail(data = data) } } } }}
setContent {
XML レイアウトを段階的に Jetpack Compose に置き換えていく方法を紹介しました。ComposeView を使うことで View 階層の中に Jetpack Compose による UI を簡単に組み込むことができます。
表示内容の変わらない View ならデータの更新について考える必要がないため、 Jetpack Compose への初めての置き換えに最適です。ぜひ挑戦してみてください。