【React Native】React Native で Jetpack Compose を使ってみる
こんにちは!アルダグラムでエンジニアをしている渡邊です!
先日 React Native の Native UI Components で Android のネイティブのビューを表示する という記事を投稿しましたが、今回はその応用として Jetpack Compose を導入してみました!
試しに弊社のアプリの KANNA に組み込んでみましたが問題が発生したため、その際の対応も含めて共有できればと思っています。
※ もし今回のソースコードを使用される際はあくまで自己責任でお願いします
Jetpack Compose とは
Jetpack Compose とは Android で UI を構築するためのライブラリです。 React と同様宣言的 UI が特徴となっており、UI が簡潔かつ直感的に記述できるようになります。
@Composable
fun JetpackCompose() {
Card {
var expanded by remember { mutableStateOf(false) }
Column(Modifier.clickable { expanded = !expanded }) {
Image(painterResource(R.drawable.jetpack_compose))
AnimatedVisibility(expanded) {
Text(
text = "Jetpack Compose",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
上記は Jetpack Compose のドキュメントに記載されているサンプルになりますが、React を使ったことがある方であれば何となく読むこともできるのではないでしょうか。
Jetpack Compose を使うための手順
React Native で Jetpack Compose を導入するための手順について見ていきます。
- Kotlin の導入
- Jetpack Compose の導入
- Jetpack Compose での UI 作成
Kotlin の導入
Jetpack Compose を使うには Kotlin が必須になります。 そのため Kotlin を導入していない場合は、まず Kotlin を導入する必要があります。
buildscript {
dependencies {
...
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10") // <- この行を追加
}
}
apply plugin: "com.android.application"
apply plugin: "kotlin-android" // <- この行を追加
後述する Compose コンパイラのバージョンによって、使用可能な Kotlin のバージョンは固定されます。
Compose コンパイラのバージョンと対応する Kotlin のバージョンの表が こちらのドキュメント に記載されているので参考にしてください。
Jetpack Compose の導入
続いて Jetpack Compose の導入になります。 まずは android/app/build.gradle
に以下を追加します。
android {
...
buildFeatures {
compose true
}
composeOptions {
// Compose コンパイラのバージョン
kotlinCompilerExtensionVersion = "1.4.3"
}
}
dependencies {
// Compose のバージョン管理は BOM を使うと便利
// https://developer.android.com/jetpack/compose/bom/bom
def composeBom = platform('androidx.compose:compose-bom:2023.01.00')
implementation composeBom
// Material Design 2
implementation 'androidx.compose.material:material'
// Android Studio Preview support
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
}
上記で導入しているライブラリは最低限のものになっています。 Compose で使えるアイコンのライブラリなど、他にも導入したい場合は こちらのドキュメント を参考にしていただくのがよいかと思います。
また注意点として上記に記載した Compose のバージョンを使う場合、compileSdkVersion が33以降である必要があります。 設定自体は android/app/build.gradle
にありますが、React Native アプリを初期作成した時には android/build.gradle
に変数定義されているのでそちらを更新する必要があるかもしれません。
buildscript {
ext {
...
compileSdkVersion = 33 // <- 33 に更新する
targetSdkVersion = 31
...
}
...
}
これで Jetpack Compose の導入は完了になります。
Jetpack Compose での UI 作成
続いて Jetpack Compose で UI を作成し、それを表示してみたいと思います。 Android のネイティブのビューを表示するためには Native UI Components の機能を使う必要があります。 こちらについては先日投稿した React Native の Native UI Components で Android のネイティブのビューを表示する の記事を参考にしていただけたらと思います。
まずは Android 側で Compose を使うための View を作成します。
class MyComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
@Composable
override fun Content() {
// このメソッド内に Compose のコードが書ける
Text(text = "Hello, Jetpack Compose!")
}
}
AbstractComposeView
を継承した View を用意し、Content
メソッドをオーバーライドします。
Compose で UI のコードを書く際に @Composable
アノテーションが付いたメソッドは @Composable
アノテーションが付いたメソッド内でしか呼び出せないというルールがあります。
Content
メソッドは @Composable
アノテーションが付いているので、このメソッド内に UI のコードを書いていく形になります。
続いて SimpleViewManager
を継承した MyComposeViewManager
を作ります。
class MyComposeViewManager : SimpleViewManager<MyComposeView>() {
override fun getName(): String {
// React Native 側で View を参照する際の名前
return "MyComposeView"
}
override fun createViewInstance(context: ThemedReactContext): MyComposeView {
return MyComposeView(context)
}
}
createViewInstance
メソッドで先ほど作成した MyComposeView
を返すようにします。
続いて作成した MyComposeViewManager
を登録します。
class MyPackage : ReactPackage {
override fun createNativeModules(context: ReactApplicationContext): List<NativeModule> {
return emptyList()
}
override fun createViewManagers(context: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(MyComposeViewManager())
}
}
public class MainApplication extends Application implements ReactApplication {
private final ReactNativeHost mReactNativeHost =
new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new MyPackage()); // <- この行を追加
return packages;
}
...
これで Android 側の設定は完了したので、続いて React Native 側になります。
まず MyComposeView をコンポーネントとして使えるように定義します。
import { requireNativeComponent, ViewStyle } from 'react-native'
type MyComposeViewProps = Readonly<{
style: ViewStyle
}>
export const MyComposeView =
requireNativeComponent<MyComposeViewProps>('MyComposeView')
そして定義した MyComposeView を表示するようにすれば完成です!
import { StyleSheet } from 'react-native'
import { MyComposeView } from './MyComposeView'
const App = () => {
return (
<MyComposeView style={styles.compose} />
)
}
const styles = StyleSheet.create({
compose: {
flex: 1
}
});
export default App
これで React Native で Compose を使って UI を描画することができました。
React Native から変数を渡して Compose の表示を更新する
もし React Native で UI の表示に必要な状態を管理している場合、React Native から Android のネイティブに対して状態を渡して表示を更新させる必要があります。
例として以下のように React Native 側でボタンを表示し、ボタンをタップするとカウンターが1つずつ増えていく場合を考えます。
const App = () => {
const [count, setCount] = useState(0)
return (
<>
<MyComposeView style={styles.compose} />
<Button
title='Increment'
onPress={() => setCount(prevCount => prevCount + 1)}
/>
</>
)
}
このカウントの値を Compose で表示させてみたいと思います。 React Native 側でコンポーネントの props として設定された値を受け取るために、まずは先ほど作成した MyComposeViewManager
を変更します。
class MyComposeViewManager : SimpleViewManager<MyComposeView>() {
...
@ReactProp(name = "count")
fun setCount(view: MyComposeView, count: Int) {
view.setCount(count)
}
}
props を受け取るために上記のように @ReactProp
アノテーションを付与したセッターメソッドを用意します。 今回数値を受け取るので引数の型は Int にしています。
MyComposeView
に setCount
メソッドを用意し、そこに値を渡しています。 続いて MyComposeView
を変更します。
class MyComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
private val count: MutableState<Int> = mutableStateOf(0) // ①
@Composable
override fun Content() {
// ② で値が更新されると UI が再レンダリングされ表示が更新される
Text(text = "count = ${count.value}")
}
fun setCount(count: Int) {
this.count.value = count // ②
}
}
ここで重要なのは ① の部分です。 Compose では状態が変わると UI が再レンダリングされるようになっており、この状態は State の変数で管理されます。
State は読み取り専用ですが、MutableState は値の更新が可能になっており、mutableStateOf というメソッドで変数の作成を行います。
なので②の部分で MutableState の値が更新されると、Content
メソッド内で count
を参照しているため UI が再レンダリングされて表示が更新されるようになります。
ちなみにこの MutableState はいくつか書き方があり、以下のように書くこともできます。
(詳細は こちらのドキュメント を参照)
// Kotlin の Delegated Propeties の機能を使っている
// 利用する際には以下のインポート文が必要
// import androidx.compose.runtime.getValue
// import androidx.compose.runtime.setValue
private var count: Int by mutableStateOf(0)
// Kotlin の Destructuring Declarations の機能を使っている
private val (count, setCount) = mutableStateOf(0)
続いて React Native 側の変更です。 MyComponentView
の props に count を設定できるように型定義します。
type MyComposeViewProps = Readonly<{
count: number // <- この行を追加
style: ViewStyle
}>
最後に MyComponentView
の props に count を渡すようにすれば完成です。
const App = () => {
const [count, setCount] = useState(0)
return (
<>
<MyComposeView
count={count}
style={styles.compose}
/>
<Button
title='Increment'
onPress={() => setCount(prevCount => prevCount + 1)}
/>
</>
)
}
発生した問題
最初弊社のアプリに試しに組み込む際に、前述の MyComposeViewManager
で設定するビューを ComposeView を使って試しました。
すると Compose を使った画面の表示時に以下のような例外が発生しました。
java.lang.IllegalStateException: Cannot locate windowRecomposer; View androidx.compose.ui.platform.ComposeView{65a64d6 V.E...... ......ID 0,0-0,0 #395} is not attached to a window
at androidx.compose.ui.platform.WindowRecomposer_androidKt.getWindowRecomposer(WindowRecomposer.android.kt:294)
at androidx.compose.ui.platform.AbstractComposeView.resolveParentCompositionContext(ComposeView.android.kt:240)
at androidx.compose.ui.platform.AbstractComposeView.ensureCompositionCreated(ComposeView.android.kt:247)
at androidx.compose.ui.platform.AbstractComposeView.onMeasure(ComposeView.android.kt:284)
at android.view.View.measure(View.java:26358)
at com.facebook.react.uimanager.NativeViewHierarchyManager.updateLayout(NativeViewHierarchyManager.java:189)
at com.swmansion.reanimated.layoutReanimation.ReanimatedNativeHierarchyManager.updateLayout(ReanimatedNativeHierarchyManager.java:274)
at com.facebook.react.uimanager.UIViewOperationQueue$UpdateLayoutOperation.execute(UIViewOperationQueue.java:169)
at com.facebook.react.uimanager.UIViewOperationQueue$1.run(UIViewOperationQueue.java:915)
at com.facebook.react.uimanager.UIViewOperationQueue.flushPendingBatches(UIViewOperationQueue.java:1026)
at com.facebook.react.uimanager.UIViewOperationQueue.access$2600(UIViewOperationQueue.java:47)
at com.facebook.react.uimanager.UIViewOperationQueue$DispatchUIFrameCallback.doFrameGuarded(UIViewOperationQueue.java:1086)
at com.facebook.react.uimanager.GuardedFrameCallback.doFrame(GuardedFrameCallback.java:29)
at com.facebook.react.modules.core.ReactChoreographer$ReactChoreographerDispatcher.doFrame(ReactChoreographer.java:175)
at com.facebook.react.modules.core.ChoreographerCompat$FrameCallback$1.doFrame(ChoreographerCompat.java:85)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1229)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1239)
at android.view.Choreographer.doCallbacks(Choreographer.java:899)
at android.view.Choreographer.doFrame(Choreographer.java:827)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1214)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7898)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
どうやら ComposeView が Window にアタッチされていなく、例外が発生しているようでした。
これは https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-1009532706 でコメントされている問題と同じで、原因についてもこちらに記載されています。
どうやら ComposeView では onMeasure() のタイミングで Window にアタッチされている必要がありますが、React Native では NativeViewHierarchyManager
というクラス内でビューが Window にアタッチされていない状態でビューの measure メソッドを呼んでいるため、Window にアタッチされていない状態でも onMeasure() が呼び出されてしまっているようです。
対応策
上記の例外は ComposeView が CompositionContext というインスタンスを参照できないために発生しているようです。
https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-1010436438 のコメントを参考に、以下のような対策を行いました。
-
AbstractComposeView
を継承したスタブのビューを実装し、ビューを見えない状態にして ReactRootView にあらかじめ addView しておく -
AbstractAppComposeView
を作成し、Compose での画面表示にはこれを継承したビューを使う
補足
例外が発生する原因は ComposeView が CompositionContext というインスタンスを参照できないために発生しています。
CompositionContext のインスタンスは ComposeView がウィンドウにアタッチされる時に作られますが、先祖のビューですでに作られているものがあればそれが利用されるようになっています。
なので 1 であらかじめ addView しておくことにより CompositionContext のインスタンスを作る目的でこの処理を行なっています
1 については、まず以下のようなビューを作成します。
class StubComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
init {
isVisible = false
}
@Composable
override fun Content() {
}
}
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new ReactActivityDelegateWrapper(this,
new ReactActivityDelegate(this, getMainComponentName()) {
@Override
protected ReactRootView createRootView() {
final ReactRootView view = super.createRootView();
view.addView(new StubComposeView(MainActivity.this)); // StubComposeView を addView する
return view;
}
}
);
}
2 については、まず AbstractAppComposeView
を作成します。
abstract class AbstractAppComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
init {
// [WORKAROUND]
// https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-1009532706
// ViewManager の createViewInstance() メソッドで ComposeView のインスタンスを返すと、
// 上記に記載されているものと同じエラーが表示されてしまう.
val compositionContext = findViewTreeCompositionContext()
?: context.findActivity()?.findViewById<ViewGroup>(android.R.id.content)
?.children
?.mapNotNull { it.findViewTreeCompositionContext() }
?.firstOrNull()
setParentCompositionContext(compositionContext)
}
}
そして Compose を使う画面ではこのビューを継承したクラスを作成して、Compose のコードを書いていきます。
class MyComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractAppComposeView(context, attrs, defStyleAttr) {
@Composable
override fun Content() {
...
}
}
これで Compose の画面を表示しても例外が発生しなくなります。
この対策を行うことで特に追加で問題は発生していませんが、正しい対策かどうかは不明のため参考にする際にはあくまで自己責任でお願いします。
以上が行った対策になります。
最後に
ちなみに先ほどの例外については正直発生条件がよくわかりませんでした。
簡単なサンプルプロジェクトを作ってみて試しても例外が発生しなかったので、React Native で使っているライブラリなども関係しているかもしれませんが、どなたかの参考になれば幸いです!
株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら herp.careers/v1/aldagram0508/
Discussion