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の使用法をよりよく理解できます。
- Kotlin Multiplatform Mobile (KMM)を使ったモバイルアプリ開発
- Kotlin Multiplatform Mobile(KMM)および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 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 は次のようになります:
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)
})
})
}
出力は次のようになります:
この方法では、共有の合成可能なコードに、好きなだけ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)
}
}
false
を displayTextField
に渡したので、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 |
---|---|
ここでは、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!
関連記事 | Related Posts
We are hiring!
【iOSエンジニア】モバイルアプリ開発G/東京
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【iOSエンジニア】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。