見出し画像

Jetpack ComposeとUIViewについて

こんばんは。
Androidエンジニアの応為です。
2024年のAdventCalendarでは、Jetpack Composeと、UIViewとの連携について記述したいと思います

Androidが今年2024年5月にKotlin Multi Platformをサポートすると発表しましたね!
KMP推しの私としては今年一番のビッグニュースでした。
KMPはロジックのみkotlinで記述し描画は各プラットフォームで記述することもありましたが、Compose Multi Platformが導入されてから描画もkotlinで記述することが可能になりました。
更に、NavHostがCMPで扱えるようになったことにより、画面遷移すらもkotlinで実装できるようになりました!

ここでComposeで覚えておくといい状態ホイスティングについておさらいしてみましょう。

状態と描画(compose)

Composeは状態を管理し、状態が変わるたびに再描画が行われます。
このときに再描画されることを、recomposeと呼びます。
また、状態と聞くと描画中や通信中、成功失敗といった特定の動作を表すものではなく、ここでの状態とはcomposeを行うために扱う値のことを指します。
例えば単純に数値を表示する画面を表示する場合、以下のクラスが状態として扱います。

    data class NumberState(
        val number: Int
    )

State

上記で定義したNumberStateですが、これだけでは状態としては扱えず、Stateでラップすることで状態として扱うことができるようになります。
また、StateはImmutableなため、MutableStateを使うことで変更可能なStateとして定義できるようになります。
基本的には、rememberとセットで定義することが多いです。
また、Stateは本来、値を取り出すためにState#valueにアクセスをする必要がありますが、byを使って代入することによりvalueのsetter/getterを「=」に移譲することができます。

var state by remember { mutableStateOf(NumberState(0)) }

remember

rememberは、状態の変更に対して状態を記憶しておく事ができる関数です。
今回はTextで1秒ごとに変わる数値を表示したいが、引数がStringのため、Stringの数値を保持したいものとします。
このとき、以下のようにrememberを記述すると、描画はどの様になるでしょう?

data class NumberState(
    val number: Int
)

@Composable
fun DisplayNumber() {
    var state by remember { mutableStateOf(NumberState(0)) }
    val numberStr = remember { state.number.toString() }

    LaunchedEffect(Unit) {
        (1..10).forEach {
            state = NumberState(it)
            delay(100)
        }
    }

    Text(text = numberStr)
}

ここで表示される値は、最初に設定した「0」がずっと覚えられており、Textで表示される値は「0」のままとなります。

keyをうまく使おう!

では、変更に追従するように記述するにはどうすればいいのでしょうか?rememberには、「key」というものがが存在します。
このkeyは指定された値に変更があるともういちどrememberをしなおすという性質を持っています。
そのため、今回の数値の変更に追従して表示するには以下のように記述すると期待した結果が得られます

@Composable
fun DisplayNumber() {
    var state by remember { mutableStateOf(NumberState(0)) }
    // keyにstate.numberを指定することで、state.numberの変更に追従できるようになる
    val numberStr = remember(state.number) {
        state.number.toString()
    }

    LaunchedEffect(Unit) {
        (1..10).forEach {
            state = NumberState(it)
            delay(100)
        }
    }

    Text(text = numberStr)
}

derivedStateOf

さて、rememberで説明した描画では数値が変わるたびに再描画をする内容だったので単純にrememberを使えばよかっただけですが、次はnumberの範囲によって描画を変えたい場合を想定してみましょう。
numberが負の場合は「マイナス」、0の場合は「ゼロ」、正の場合は「プラス」と表示するComposeを書いてみます。

@Composable
fun DisplayNumber() {
    var state by remember { mutableStateOf(NumberState(0)) }

    val str = remember(state.number) {
        when  {
            state.number < 0 -> "マイナス"
            state.number > 0 -> "プラス"
            else -> "ゼロ"
        }
    }

    LaunchedEffect(Unit) {
        (-10..10).forEach {
            state = NumberState(it)
            delay(100)
        }
    }

    Text(text = str)
}

基本的にはrememberの中身がwhenによる条件分岐に切り替わりました。
しかし、これではあまりrememberの意味がありません。
例えば、値が3から5に変わったときは「プラスからプラス」に変更されるため変更に意味がないですよね。
そのためこういったケースの場合は、derivedStateOfを扱うことことで同一結果への変更を覚えておくことができます。

そのため、以下のようにderivedStateOfでラップすることにより値と結果が変わった時にのみstrの値を変更することができます。
また、derivedStateOfも状態管理をする関数のため、返ってくる値はState<T>となります。
また、今回はbyによって移譲をしていないため、条件分岐結果の値を取り出すには 「str.value」に変更する必要があります。

@Composable
fun DisplayNumber() {
    var state by remember { mutableStateOf(NumberState(0)) }

    val str = remember(state.number) {
        derivedStateOf {
            when {
                state.number < 0 -> "マイナス"
                state.number > 0 -> "プラス"
                else -> "ゼロ"
            }
        }
    }

    LaunchedEffect(Unit) {
        (-10..10).forEach {
            state = NumberState(it)
            delay(100)
        }
    }

    Text(text = str.value)
}

CMPでもUIViewがしたい!

冒頭でせっかくCMPについて触れたので、状態ホイスティングについておさらいができたところでCMPでiOSのUIViewを触ってみましょう!
CMPでもiOSの描画ができるようになったとはいえ、CMPだけですべてのViewを賄えるわけではありません。
せっかくなので、上記のおさらいで使った状態をもとにiOSのUILabelを表示したいと思います

※KMPでiOSの設定は割愛し、Compose-UIViewの部分だけを記述します。

CMP上でUIViewを呼び出すには「UIKitView」を扱います。
このUIKitViewは、ComposableからUIViewを呼び出すことが可能です。

まずは、SampleTextというComsable関数を作成して呼び出す部分だけを先に作成しましょう。

@Composable
@Preview
fun App() {
    var state by remember { mutableStateOf(NumberState(0)) }
    val str = remember(state.number) {
        derivedStateOf {
            when {
                state.number < 0 -> "マイナス"
                state.number > 0 -> "プラス"
                else -> "ゼロ"
            }
        }
    }

    LaunchedEffect(Unit) {
        (-10..10).forEach {
            state = NumberState(it)
            delay(1000)
        }
    }

    MaterialTheme {
        SampleText(text = str.value)
    }
}

@Composable
expect fun SampleText(text: String)

data class NumberState(
    val number: Int
)

まずは上記のように、commonMainにて呼び出し部分を作成します。
また、SampleTextはexpect装飾詞をつけています。
このexpectは、commonMainで定義だけをしておき、iosMainやandroidMainといった対応するプラットフォームのパッケージで実装をします。

Kotlinのみで実装する場合

まずはkotlinだけでこのSampleTextを実装してみましょう。
今回はiOSの実装をしたいため、iosMainで実装を行います。
実は、UILableやUITextといった基本的なUIVIewはCMP上でKotlin側でラップしてくれているため、Swiftを記述しなくても扱うことができます!
また、各expect関数の実装は、actual装飾詞をつけて中身を実装していきます。

@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun SampleText(text: String) {
    UIKitView(
        modifier = Modifier.size(180.dp),
        factory = {
            UILabel().also { label ->
                label.text = text
            }
        },
        update = { label ->
            label.text = text
        },
    )
}

基本的に凝ったことはしていないですが、UIKitViewのfactoryにてUILablelを生成しています。
しかし、factoryはUILabel生成時のみしか呼ばれないため、factoryのみではtextの変更が反映されません。
そこで変更時に反映をしてくれるのがupdateの部分です。
これは、状態の変更があった場合に指定のUIVIewに対して変更を伝播したいときに記述するクロージャとなっています。

Swiftを使って実装する場合

ではkotlinでラップされていないUIViewはどうすればいいでしょうか?
このケースではSwift及びkotlin両方に手を入れていきます。

まずは、kotlin側の呼び出し部分を変更します。
ここでは、TextDelegateを生成して、commonMainにてApp()の引数とSampleTextの引数に追加しています。

@Composable
@Preview
fun App(textDelegateGenerator: () -> TextDelegate) { // 追加
    var state by remember { mutableStateOf(NumberState(0)) }
    val str = remember(state.number) {
        derivedStateOf {
            when {
                state.number < 0 -> "マイナス"
                state.number > 0 -> "プラス"
                else -> "ゼロ"
            }
        }
    }

    LaunchedEffect(Unit) {
        (-10..10).forEach {
            state = NumberState(it)
            delay(1000)
        }
    }

    MaterialTheme {
        SampleText(
            text = str.value,
            textDelegateGenerator = textDelegateGenerator // 追加
        )
    }
}

@Composable
expect fun SampleText(
    text: String,
    textDelegateGenerator: () -> TextDelegate
)

data class NumberState(
    val number: Int
)

// 追加
// UIViewにimplementsさせたい
interface TextDelegate {
    fun setText(text: String)
}


次に、iosMain上でSampleTextを変更します。
ここでは、UILabelは使わずに、先程作ったTextDelegateを扱ってUIViewを操作します。
ここではfactoryにてtextDelegateGeneratorをUIViewとしてキャストして返却し、updateにてsetTextを呼び出すようにします。
こうすることでSwift側のUIViewに変更を伝播します。

@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun SampleText(
    text: String,
    textDelegateGenerator: () -> TextDelegate
) {
    UIKitView(
        modifier = Modifier.size(180.dp),
        factory = {
            textDelegateGenerator().let { generator ->
                generator.setText(text)
                generator as UIView
            }
        },
        update = { view ->
            (view as? TextDelegate)?.setText(text)
        },
    )
}


最後に、こちらがSwift上のコードです。
一部もともと生成したコードが混在していますが、今回の変更としては、MyTextViewクラスの生成と、textDelegateGeneratorにてMyTextViewのインスタンスを生成している箇所が変更点となります。
実は、KMPではkotlinで書いたソースコードをSwiftで扱うことができます!
そのためkotlin側で定義したTextDelegateをSwift上でdelegateとして扱いimplementsすることができます!

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        MainViewControllerKt.MainViewController(
            viewControllerGenerator: { () -> UIViewController in
                return UIHostingController(rootView: MyView())
            },
            textDelegateGenerator: { () in MyTextView() } // 追加
        )
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    }
}

struct ContentView: View {
    var body: some View {
        ComposeView()
            .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
    }
}

// 追加
class MyTextView: UILabel, TextDelegate {
    func setText(text: String) {
        self.text = text
    }
}

上記のように、CMPからUIViewを扱うこともできました!
CMPを使えばJetpackComposeをベースにiOSアプリを作成でき、必要に応じて各プラットフォームで定義できるため両OSのアプリを比較的簡単にできるようになりました。
また、androidxパッケージのライブラリも順次KMPで扱えるように対応が進んでおり、KMPが今よりも更に使いやすくなることが期待されます。

最後までお付き合いいただき、ありがとうございました! このアドベントカレンダーが、皆様の開発のヒントになれば幸いです。

皆様、良いお年をお迎えください!