相互運用 API

Compose をアプリ内に導入する際、Compose とビューベースの UI を組み合わせることもできます。ここでは、Compose に簡単に移行するための API、推奨事項、ヒントについて説明します。

Android ビューでの Compose

ビューベースの設計を使用している既存のアプリに Compose ベースの UI を追加できます。

完全に Compose をベースとした画面を新たに作成するには、アクティビティで setContent() メソッドを呼び出し、任意のコンポーズ可能な関数を渡します。

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent { // In here, we can call composables!
            MaterialTheme {
                Greeting(name = "compose")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

このコードは、Compose のみのアプリで見られるコードに似ています。

ComposeView の ViewCompositionStrategy

デフォルトでは、ビューがウィンドウからデタッチされるたびに、Compose は Composition を破棄します。ComposeViewAbstractComposeView などの Compose UI の View タイプは、この動作を定義する ViewCompositionStrategy を使用します。

デフォルトでは、Compose は DisposeOnDetachedFromWindow 戦略を使用します。ただし、Compose UI の View タイプが次のものの中で使用される状況では、このデフォルト値が望ましくない場合があります

  • フラグメント。Composition は、Compose UI の View タイプに対するフラグメントのビュー ライフサイクルに従って、状態を保存する必要があります。

  • 遷移。Compose UI の View が遷移の中で使用されると、遷移の終了時ではなく遷移の開始時にウィンドウからデタッチされるため、コンポーザブルはまだ画面に表示されているときに状態を廃棄します。

  • RecyclerView のビューホルダー、または独自のライフサイクルで管理されるカスタム View

いくつかの状況では、AbstractComposeView.disposeComposition を手動で呼び出さない限り、アプリで Composition インスタンスからのメモリリークが徐々に発生することもあります。

Composition が不要になったときに自動的に破棄するには、別の戦略を設定するか、setViewCompositionStrategy メソッドを呼び出して独自の戦略を作成します。たとえば、DisposeOnLifecycleDestroyed 戦略は、lifecycle が破棄されたときに Composition を破棄します。この戦略は、既知の LifecycleOwner と 1 対 1 の関係を共有する Compose UI の View タイプに適しています。LifecycleOwner が不明な場合は、DisposeOnViewTreeLifecycleDestroyed を使用できます。

この API の実際の動作については、フラグメント内の ComposeView セクションをご覧ください。

フラグメント内の ComposeView

Compose UI コンテンツをフラグメントまたは既存の View レイアウトに組み込む場合は、ComposeView を使用し、その setContent() メソッドを呼び出します。ComposeView は Android View です。

ComposeView は、他の View と同じように XML レイアウトに追加できます。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/hello_world"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello Android!" />

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

Kotlin ソースコードでは、XML で定義されたレイアウト リソースからレイアウトをインフレートします。次に、XML ID を使用して ComposeView を取得し、ホスト View に最適な Composition 戦略を設定して、Compose を使用するために setContent() を呼び出します。

class ExampleFragment : Fragment() {

    private var _binding: FragmentExampleBinding? = null
    // This property is only valid between onCreateView and onDestroyView.
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentExampleBinding.inflate(inflater, container, false)
        val view = binding.root
        view.composeView.apply {
            // Dispose the Composition when the view's LifecycleOwner
            // is destroyed
            setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                // In Compose world
                MaterialTheme {
                    Text("Hello Compose!")
                }
            }
        }
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

2 つの若干異なるテキスト要素(一方が他方の上)

図 1. View UI 階層に Compose 要素を追加するコードの出力を示しています。「Hello Android!」のテキストは TextView ウィジェットで表示されます。「Hello Compose!」のテキストは Compose テキスト要素で表示されます。

また、全画面が Compose で作成されている場合、ComposeView をフラグメントに直接含めることもできます。これにより、XML レイアウト ファイル全体の使用を避けることができます。

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            // Dispose the Composition when the view's LifecycleOwner
            // is destroyed
            setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                MaterialTheme {
                    // In Compose world
                    Text("Hello Compose!")
                }
            }
        }
    }
}

同じレイアウトに複数の ComposeView 要素がある場合、savedInstanceState が機能するためには、各要素に一意の ID が必要です。

class ExampleFragment : Fragment() {

  override fun onCreateView(...): View = LinearLayout(...).apply {
      addView(ComposeView(...).apply {
          id = R.id.compose_view_x
          ...
      })
      addView(TextView(...))
      addView(ComposeView(...).apply {
          id = R.id.compose_view_y
          ...
      })
    }
  }
}

ComposeView ID は res/values/ids.xml ファイルで定義されています。

<resources>
    <item name="compose_view_x" type="id" />
    <item name="compose_view_y" type="id" />
</resources>

Compose での Android View

Compose UI に Android View 階層を含めることができます。このアプローチは特に、Compose でまだ利用できない UI 要素(AdViewMapView など)を使用する場合に便利です。このアプローチでは、設計したカスタムビューを再利用することもできます。

ビュー要素または階層を含めるには、AndroidView コンポーザブルを使用します。AndroidView には、View を返すラムダが渡されます。AndroidView には、ビューがインフレートされるときに呼び出される update コールバックも用意されています。AndroidView は、コールバック内で読み込まれた State が変更されるたびに、再コンポーズを行います。

@Composable
fun CustomView() {
    val selectedItem = remember { mutableStateOf(0) }

    // Adds view to Compose
    AndroidView(
        modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
        factory = { context ->
            // Creates custom view
            CustomView(context).apply {
                // Sets up listeners for View -> Compose communication
                myView.setOnClickListener {
                    selectedItem.value = 1
                }
            }
        },
        update = { view ->
            // View's been inflated or state read in this block has been updated
            // Add logic here if necessary

            // As selectedItem is read here, AndroidView will recompose
            // whenever the state changes
            // Example of Compose -> View communication
            view.coordinator.selectedItem = selectedItem.value
        }
    )
}

@Composable
fun ContentExample() {
    Column(Modifier.fillMaxSize()) {
        Text("Look at this CustomView!")
        CustomView()
    }
}

XML レイアウトを埋め込むには、androidx.compose.ui:ui-viewbinding ライブラリで提供される AndroidViewBinding API を使用します。そのためには、プロジェクトでビュー バインディングを有効にする必要があります。

AndroidView は、他の多くの組み込みコンポーザブルと同様に、Modifier パラメータを受け取ります。これは親コンポーザブルでの位置を設定したりするために使用できます。

@Composable
fun AndroidViewBindingExample() {
    AndroidViewBinding(ExampleLayoutBinding::inflate) {
        exampleView.setBackgroundColor(Color.GRAY)
    }
}

Compose から Android フレームワークを呼び出す

Compose は、Android フレームワーク クラスと密接に結びついています。たとえば、ActivityFragment などの Android View クラスでホストされているため、Context、システム リソース、ServiceBroadcastReceiver などの Android フレームワーク クラスを使用することが必要な場合もあります。

システム リソースについて詳しくは、Compose のリソースのドキュメントをご覧ください。

コンポジション ローカル

CompositionLocal クラスを使用すると、コンポーズ可能な関数を通じて暗黙的にデータを渡すことができます。通常、UI ツリーの特定のノードに値が設定されます。その値は、コンポーズ可能な関数のパラメータとして CompositionLocal を宣言しなくても、コンポーズ可能な子孫で使用できます。

CompositionLocal は、Compose の Android フレームワーク タイプ(ContextConfiguration または Compose コードがホストされている View など)の値を対応する LocalContextLocalConfigurationLocalView に伝播するために使用されます。 IDE のオートコンプリートで検出しやすいように CompositionLocal クラスの先頭に Local が付いています。

CompositionLocal の現在の値にアクセスするには、current プロパティを使用します。たとえば、下記のコードは LocalContext.current を呼び出して、Compose UI ツリーの該当する部分で利用可能な Context によりカスタムビューを作成します。

@Composable
fun rememberCustomView(): CustomView {
    val context = LocalContext.current
    return remember { CustomView(context).apply { /*...*/ } }
}

より詳しい例については、このドキュメントの最後にある事例紹介: BroadcastReceivers をご覧ください。

その他の操作

必要な操作のためのユーティリティが定義されていない場合は、一般的な Compose ガイドラインに従い、データは下に流れ、イベントは上に流れるようにすることをおすすめします(詳しくは Compose の思想をご覧ください)。たとえば、このコンポーザブルは別のアクティビティを起動します。

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // get data from savedInstanceState
        setContent {
            MaterialTheme {
                ExampleComposable(data, onButtonClick = {
                    startActivity(/*...*/)
                })
            }
        }
    }
}

@Composable
fun ExampleComposable(data: DataExample, onButtonClick: () -> Unit) {
    Button(onClick = onButtonClick) {
        Text(data.title)
    }
}

事例紹介: BroadcastReceiver

Compose で移行または実装する機能の現実的な例として、CompositionLocal副作用を紹介します。ここでは、BroadcastReceiver をコンポーズ可能な関数から登録する必要があります。

このソリューションでは、LocalContext(現在のコンテキストを使用するため)、rememberUpdatedState および DisposableEffect の副作用を利用します。

@Composable
fun SystemBroadcastReceiver(
    systemAction: String,
    onSystemEvent: (intent: Intent?) -> Unit
) {
    // Grab the current context in this part of the UI tree
    val context = LocalContext.current

    // Safely use the latest onSystemEvent lambda passed to the function
    val currentOnSystemEvent by rememberUpdatedState(onSystemEvent)

    // If either context or systemAction changes, unregister and register again
    DisposableEffect(context, systemAction) {
        val intentFilter = IntentFilter(systemAction)
        val broadcast = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                onSystemEvent(intent)
            }
        }

        context.registerReceiver(broadcast, intentFilter)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            context.unregisterReceiver(broadcast)
        }
    }
}

@Composable
fun HomeScreen() {

    SystemBroadcastReceiver(Intent.ACTION_BATTERY_CHANGED) { batteryStatus ->
        val isCharging = /* Get from batteryStatus ... */ true
        /* Do something if the device is charging */
    }

    /* Rest of the HomeScreen */
}