KINTO Tech Blog
Development

SwiftUIをCompose Multiplatformで使用する

Cover Image for SwiftUIをCompose Multiplatformで使用する

はじめに

こんにちは。KINTOテクノロジーズモバイルアプリケーション開発グループのRaselです。私は現在、my route Androidアプリの開発に取り組んでいます。my routeは、外出時に利用するマルチモーダルアプリで、目的地の情報収集、地図上のさまざまな場所の探索、デジタルチケットの購入、予約、乗車料金の支払い処理などを行うことができます。

いまやモバイルアプリは私たちの日常生活に欠かせないものです。我々のようなエンジニアは、AndroidとiOSアプリをそれぞれ別で作成するため、両方のプラットフォームを開発するためにはダブルコストが発生します。これらの開発コストを削減するためにReact Native、Flutterなど、様々なクロスプラットフォームフレームワークが登場しました。

しかし、クロスプラットフォームアプリのパフォーマンスには常に課題があります。ネイティブアプリのようなパフォーマンスではありません。また、プラットフォーム固有の新機能がAndroidやiOSからリリースされると、フレームワーク開発者からサポートを受けなければいけない場合があり、さらに時間がかかります。

そこで Kotlin Multiplatform (KMP) が助けになります。ネイティブアプリ並みのパフォーマンスで、プラットフォーム間で共有するコードを自由に選択できるのです。KMPでは、Androidのネイティブ第一言語であるKotlinでAndroidアプリが開発されていて、完全にネイティブなので、パフォーマンス上の問題はほとんどありません。iOSの部分はKotlin/Native を使用しており、他のフレームワークと比較して、ネイティブアプリとして開発されたものに近いパフォーマンスがあります。

本記事では、SwiftUIコードをCompose Multiplatformと統合する方法を紹介します。

KMP(モバイルプラットフォームではKMMとしても知られています)では、プラットフォーム間で共有するコードの量と、ネイティブアプリに実装するコードを自由に選択でき、プラットフォームのコードとシームレスに統合されます。以前は、ビジネスロジックのみをプラットフォーム間で共有できましたが、今では UI コードも共有できるようになりました。Compose Multiplatformにおいても、 UIコードの共有が可能になりました。下にある以前の記事を読むと、モバイルアプリ開発におけるKotlin MultiplatformとCompose Multiplatformの使用法をよりよく理解できます。

それでは、始めましょう!

概要

我々はUI開発でCompose Multiplatformを使用するKMPを用いてアプリ開発をしています。今回はSwiftUIをCompose Multiplatformに統合する方法を示すため、とてもシンプルなGeminiチャットアプリを使用します。また、チャットでのユーザーのクエリへの返信には、GoogleのGemini Pro APIを使用します。デモすることが目的なので、シンプルにするためにも、テキストメッセージのみが許可されるよう無料版の API を使用します。

Compose と SwiftUI がどのように連携するか

まず、最初に大事なことから。JetbrainのKotlin Multiplatform Wizard を使用してKMPプロジェクトを作成します。このウィザードには、必要になるKMPの基本的なセットアップと、Compose Multiplatformと、いくつかの初期SwiftUIコードが付属しています。
Kotlin MultiplatformWizard

Kotlin Multiplatform Mobile pluginをインストールして、Android Studio IDE を使用し、プロジェクトを作成することもできます。

ComposeとSwiftUIがどのように連携するかをデモしてみます。ComposableコードをiOS に組み込むには、ComposableコードをComposeUIViewController 内にラップする必要があります。ComposeUIViewControllerは UIKit からUIViewControllerの値を返し、その中にComposeコードの組み立てをコンテンツパラメータとして含めることができます。
例:

// MainViewController.kt
fun ComposeEntryPoint(): UIViewController {
    return ComposeUIViewController {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text(text = "Hello from Compose")
        }
    }
}

次に、この関数を iOS 側から呼び出します。そのためには、SwiftUI のComposeコードを表す構造が必要です。以下のコードは、共有モジュールであるUIViewController コードを SwiftUI ビューに変換します。

// ComposeViewControllerRepresentable.swift
struct ComposeViewControllerRepresentable :UIViewControllerRepresentable {
    
    func updateUIViewController(_ uiViewController:UIViewControllerType, context:Context) {}
    
    func makeUIViewController (context:Context)-> some UIViewController {
        return MainViewControllerKt.ComposeEntryPoint()
    }
} 

ここで、MainViewControllerKt.ComposeEntryPoint()の名前を詳しく見てみましょう。これが Kotlin から生成されたコードになります。そのため、共有モジュール内のファイル名とコードによって異なる場合があります。共有モジュール内のファイル名が Main.ios.ktで、UIViewController returning function nameが ComposeEntryPoint()の場合、Main_iosKt.ComposeEntryPoint()のように呼び出す必要があります。そのため、コードによって異なります。

次に、この ComposeViewControllerRepresentable をコードContentView() の内部からインスタンス化します。これで準備は完了です。

// ContentView.swift 
struct ContentView:View {
    var body: some View {
        composeViewControllerRepresentable ()
            .ignoresSafeArea (.all)
    }
}

コードを見てわかるように、このComposeコードは SwiftUI 内のどこでも使用でき、SwiftUI 内で好きなようにサイズを制御できます。UI は次のようになります:
Hello from Swift

SwiftUI のコードをCompose内に統合したい場合は、UIView でラップする必要があります。SwiftUIのコードをKotlinで直接記述することはできないため、Swiftで記述してKotlin関数に渡す必要があります。これを実装するために、 関数ComposeEntryPoint()に、 UIView タイプの引数を追加してみましょう。

// MainViewController.kt
fun ComposeEntryPoint(createUIView: () -> UIView): UIViewController {
    return ComposeUIViewController {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            UIKitView(
                factory = createUIView,
                modifier = Modifier.fillMaxWidth().height(500.dp),
            )
        }
    }
}

そして CreateUIView を以下のような Swift コードへ渡します。

// ComposeViewControllerRepresentable.swift
struct ComposeViewControllerRepresentable : UIViewControllerRepresentable {
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
    
    func makeUIViewController(context: Context) -> some UIViewController {
        return MainViewControllerKt.ComposeEntryPoint(createUIView: { () -> UIView in
            UIView()
        })
    }
}

さて、他のViewを追加したい場合は、以下のように親ラッパー UIView を作成してください:

// ComposeViewControllerRepresentable.swift
private class SwiftUIInUIView<Content: View>: UIView {
    init(content: Content) {
        super.init(frame: CGRect())
        let hostingController = UIHostingController(rootView: content)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        addSubview(hostingController.view)
        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: topAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

次に、それをComposeViewControllerRepresentable に追加し、必要に応じてViewを追加します。

// ComposeViewControllerRepresentable.swift
func makeUIViewController(context: Context) -> some UIViewController {
    return MainViewControllerKt.ComposeEntryPoint(createUIView: { () -> UIView in
            SwiftUIInUIView(content: VStack {
                Text("Hello from SwiftUI")
                Image(systemName: "moon.stars")
                    .resizable()
                    .frame(width: 200, height: 200)
            })
    })
}

出力は次のようになります:
Hello from Swift with Image

この方法では、共有の合成可能なコードに、好きなだけSwiftUIコードを追加できます。

また、UIKit コードをCompose内に統合したい場合、中間コードを自分で作成する必要はありません。Compose Multiplatformが提供するComposable関数UIKitView () を使用して、その中にUIKitコードを直接追加できます。

// MainViewController.kt
UIKitView(
  modifier = Modifier.fillMaxWidth().height(350.dp),
  factory = { MKMapView() }
)

このコードは iOS ネイティブのマップ画面をCompose内に統合します。

Gemni Chatアプリの実装

それでは、ComposeコードをSwiftUI内に統合して、Gemini Chat アプリの実装を進めましょう。Jetpack Compose の LazyColumn を使用して、基本的なチャット UI を実装します。Compose Multiplatform内にSwiftUIを統合することが主な目的なので、Composeやデータ、ロジック等、他の部分の実装についてはここでは割愛します。Gemini Pro APIを実装するため、我々はKtorネットワーキングライブラリを利用しました。Ktorの実装についての詳細は、Creating a cross-platform mobile application のページをご覧ください。

このプロジェクトでは、Compose Multiplatformで全てのUIを実装しました。Compose MultiplatformのTextFieldではiOS側でパフォーマンスに問題があるので、iOSアプリの入力フィールドにのみSwiftUIを使用します。

ComposeEntryPoint()関数の中にComposeコードを入れてみましょう。これらのコードには、TopAppBarを含むチャットUIとメッセージのリストが含まれています。これには、Androidアプリで使用される入力フィールドの条件付き実装もあります。

// MainViewController.kt
fun ComposeEntryPoint(): UIViewController =
    ComposeUIViewController {
        Column(
            Modifier
                .fillMaxSize()
                .windowInsetsPadding(WindowInsets.systemBars),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            ChatApp(displayTextField = false)
        }
    }

falsedisplayTextFieldに渡したので、iOS バージョンのアプリでは Compose 入力フィールドがアクティブになりません。そして、Android側のTextFieldにはパフォーマンスの問題がないため、Android 実装側からComposable関数をこの ChatApp ()のComposable関数を呼び出すと、displayTextFieldの値は true で返ってきます。(これはAndroid のネイティブ UI コンポーネントです。)

それでは、Swift コードに戻ってSwiftUIで入力フィールドを実装します。

// TextInputView.swift
struct TextInputView: View {
    @Binding var inputText: String
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack {
            Spacer()
            HStack {
                TextField("メッセージを入力する...", text: $inputText, axis: .vertical)
                    .focused($isFocused)
                    .lineLimit(3)
                if (!inputText.isEmpty) {
                    Button {
                        sendMessage(inputText)
                        isFocused = false
                        inputText = ""
                    } label: {
                        Image(systemName: "arrow.up.circle.fill")
                            .tint(Color(red: 0.671, green: 0.365, blue: 0.792))
                    }
                }
            }
            .padding(15)
            .background(RoundedRectangle(cornerRadius: 200).fill(.white).opacity(0.95))
            .padding(15)
        }
    }
}

そして、 ContentView 構造体に戻り、以下のように修正します:

// ContentView.swift
struct ContentView: View {
    @State private var inputText = ""
    
    var body: some View {
        ZStack {
            Color("TopGradient")
                .ignoresSafeArea()
            ComposeViewControllerRepresentable()
            TextInputView(inputText: $inputText)
        }
        .onTapGesture {
            // Hide keyboard on tap outside of TextField
            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
        }
    }
}

ここでは ZStack を追加し、その中にTopGradientカラーと、Modifier ignoresSafeArea ()を追加して、ステータスバーの色が他の UI の色と一致するようにしました。

次に、共有されたCompose コードのラッパー ComposeViewControllerRepresentable を追加し、メインのチャットUIを実装しました。そして、TextInputView() というSwiftUIビューも追加しました。これにより、iOSアプリのユーザーにもiOSネイティブコードでスムーズなパフォーマンスが提供することができます。最終的なUIは次のようになります。

Gemini Chat iOS Gemini Chat Android
Gemini Chat iOS Gemini Chat Android

ここでは、ChatAppのUIコード全体がKMPのCompose MultiplatformでAndroidとiOSの両方に共有され、iOSの入力フィールドのみがSwiftUIにネイティブに統合されています。

このプロジェクトの完全なソースコードは、GitHub で公開リポジトリとして公開されています。

GitHubリポジトリ:Compose MultiplatformにおけるSwiftUI

さいごに

このように、Kotlin MultiplatformとCompose Multiplatformを使うことで、クロスプラットフォームアプリでのパフォーマンスの問題を解決しながら、ユーザーにネイティブのような操作感と外観を提供できます。また、プラットフォーム間でコードを好きなだけ共有できるため、開発コストも削減できます。Compose Multiplatformでは、デスクトップアプリとコードを共有することもできます。ですから、単一のコードベースをデスクトップアプリだけでなくモバイルプラットフォームでも使用できます。さらに、プラットフォーム間でのコードベース共有を促進するため、Webサポートも進行中です。Kotlin Multiplatform (KMP) のもう1つの大きな利点は、コードを無駄にすることなく、いつでもネイティブ開発に切り替えることができる点です。AS-ISのKMPコード はAndroidネイティブのため、Androidアプリではそのまま利用でき、iOSアプリを別途切り離して開発することができます。また、KMPにすでに実装したものと同じSwiftUIコードを再利用することも可能です。このフレームワークは、高性能のアプリケーションを提供するだけでなく、共有するコードの割合を自由に変更したり、ネイティブ開発にいつでも切り替えたりできます。

本記事はここまでとしますが、KINTOテクノロジーズのテックブログでは今後もおもしろい記事を発信していきます!Happy Coding!

Facebook

関連記事 | Related Posts

We are hiring!

【iOS/Androidエンジニア】モバイルアプリ開発G/東京・大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【モバイルアプリUI/UXデザイナー(リードクラス)】my route開発G/東京

my route開発グループについてmy route開発グループでは、グループ会社であるトヨタファイナンシャルサービス株式会社の事業メンバーと共に、my routeの開発・運用に取り組んでおります。現在はシステムの安定化を図りながら、サービス品質の向上とビジネスの伸長を目指しています。