いつの間にやら、Kotlin側のViewModelのFlowをSwiftUIで簡単に監視できるようになってた。
struct ExampleView: View {
let viewModel = SharedViewModel()
var body: some View {
// Observing multiple flows with a "initial content" view closure.
Observing(viewModel.counter, viewModel.toggle) {
ProgressView("Waiting for counters to flows to produce a first value")
} content: { counter, toggle in
Text("Counter: \(counter), Toggle: \(toggle)")
}
}
}
純粋にKMPでKotlinのViewModelをSwiftで使うには
ViewModelを共通で使うってケースを考える。
class CounterViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UIState<CounterState>>(UIState.Loading)
val uiState: StateFlow<UIState<CounterState>> = _uiState.asStateFlow()
init {
loadInitialState()
}
private fun loadInitialState() {
viewModelScope.launch {
_uiState.update { UIState.Loading }
delay(1500) // Loading simulation
_uiState.update {
UIState.Content(
CounterState(
label = "Counter",
count = 0
)
)
}
}
}
...
SwiftからするとViewModelはObservableObject
ではないので、以下のようなAdapter的な層が必要になる。
class CounterViewModelAdapter : ObservableObject {
private let ktViewModel = CounterViewModel()
@Published var swiftUiState: SwiftUIState<CounterState, Error> = .loading
@MainActor
func activate() async {
Task {
for await state in ktViewModel.uiState {
self.swiftUiState = switch onEnum(of: state) {
case .loading:
.loading
case .content(let content):
if let data = content.data {
.content(data)
} else {
.error(NSError(domain: "No data", code: -1))
}
case .error(let error):
.error(NSError(domain: error.message, code: -1))
}
}
}
}
...ktViewModel側と同じ関数の公開
KotlinのFlow
は SKIEによって AsyncSequence
に変換される。
その値を一度取り出して 上記のようにマッピングする必要がある。
また、以下のようなありがち?な、直和な構造もSwiftからは使いにくい。
sealed class UIState<out T> {
object Loading : UIState<Nothing>()
data class Content<T>(val data: T) : UIState<T>()
data class Error(val message: String, val throwable: Throwable? = null) : UIState<Nothing>()
}
KotlinのNothingは、SwiftではKotlinNothingというクラスになる。これはKotlinと違い 全ての型のsub type とはなっていないので、
@Published var uiState: UIState<CounterState> = UIStateLoading()
のような代入は型が合わずコンパイラに弾かられる。
要するに型の共変,反変が 吐き出されたSwiftコードでサポートされていない(し、swiftでもサポートはない?)
enum SwiftUIState<Content, Failure: Error> {
case loading
case content(Content)
case error(Failure)
}
Swift側にも似た構造を作って 適切にマッピングが必要
UI的には SwiftのViewModelを見るような形になる。
struct LegacyContentView: View {
@ObservedObject private var viewModel = CounterViewModelAdapter()
var body: some View {
LegacyCountScreen(
uiState: viewModel.swiftUiState,
onIncrement: { viewModel.increment() },
onDecrement: { viewModel.decrement() },
onReset: { viewModel.reset() },
onRetry: { viewModel.retry() }
).task { await viewModel.activate() }
}
}
// 略
flows in swiftUI
SKIEのflows in swiftUIを有効にすると、上記の例はシンプルにこれだけで済む。
import SwiftUI
import Shared
struct ContentView: View {
private let viewModel = CounterViewModel() // kotlin側のViewModel
var body: some View {
Observing(viewModel.uiState) { uiState in
CounterScreen(
uiState: uiState,
onIncrement: { viewModel.increment() },
onDecrement: { viewModel.decrement() },
onReset: { viewModel.reset() },
onRetry: { viewModel.retry() }
)
}
}
}
kotlin側のViewModelをそのまま使って、ObservedObject的に使える。
前述のUiState
の型が合わない問題も面倒見てくれる。
終わり
Kotlin側のViewModelを Observableにしてくれるライブラリもあるのでこいつを使うというのもある。
https://github.com/rickclephas/KMP-ObservableViewModel
とはいえ、SKIEは Swift Kotlinを統合していれば使っていることが多いと思うので SKIEに入っているのはうれしい。