Skip to content

Commit c33d0ac

Browse files
authored
Merge pull request #75 from android/mlykotom/ios-viewmodel-scoping
ViewModel Scoping with store in Swift. The failing CI action is fixed in the following PRs
2 parents 92a9de0 + e860db9 commit c33d0ac

File tree

17 files changed

+270
-134
lines changed

17 files changed

+270
-134
lines changed

.github/workflows/Fruitties.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
uses: actions/checkout@v4
2222

2323
- name: Validate Gradle Wrapper
24-
uses: gradle/actions/wrapper-validation@v3
24+
uses: gradle/wrapper-validation-action@v3
2525

2626
- name: Set up JDK 17
2727
uses: actions/setup-java@v4

Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import androidx.compose.material3.TopAppBarDefaults
4141
import androidx.compose.runtime.Composable
4242
import androidx.compose.runtime.collectAsState
4343
import androidx.compose.runtime.getValue
44-
import androidx.compose.runtime.remember
4544
import androidx.compose.ui.Alignment
4645
import androidx.compose.ui.Modifier
4746
import androidx.compose.ui.platform.LocalContext
@@ -51,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
5150
import com.example.fruitties.android.R
5251
import com.example.fruitties.android.di.App
5352
import com.example.fruitties.viewmodel.CartViewModel
53+
import com.example.fruitties.viewmodel.creationExtras
5454

5555
@OptIn(ExperimentalMaterial3Api::class)
5656
@Composable
@@ -61,13 +61,10 @@ fun CartScreen(onNavBarBack: () -> Unit) {
6161
// Here we put the KMP-compatible AppContainer into the extras
6262
// so it can be passed to the ViewModel factory.
6363
val app = LocalContext.current.applicationContext as App
64-
val extras = remember(app) {
65-
val container = app.container
66-
CartViewModel.creationExtras(container)
67-
}
64+
6865
val viewModel: CartViewModel = viewModel(
6966
factory = CartViewModel.Factory,
70-
extras = extras,
67+
extras = creationExtras(app.container),
7168
)
7269

7370
val cartState by viewModel.cartUiState.collectAsState()

Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import androidx.compose.material3.TopAppBarColors
4040
import androidx.compose.runtime.Composable
4141
import androidx.compose.runtime.collectAsState
4242
import androidx.compose.runtime.getValue
43-
import androidx.compose.runtime.remember
4443
import androidx.compose.ui.Alignment
4544
import androidx.compose.ui.Modifier
4645
import androidx.compose.ui.platform.LocalContext
@@ -53,6 +52,7 @@ import com.example.fruitties.android.R
5352
import com.example.fruitties.android.di.App
5453
import com.example.fruitties.model.Fruittie
5554
import com.example.fruitties.viewmodel.MainViewModel
55+
import com.example.fruitties.viewmodel.creationExtras
5656

5757
@OptIn(ExperimentalMaterial3Api::class)
5858
@Composable
@@ -63,13 +63,9 @@ fun ListScreen(onClickViewCart: () -> Unit = {}) {
6363
// Here we put the KMP-compatible AppContainer into the extras
6464
// so it can be passed to the ViewModel factory.
6565
val app = LocalContext.current.applicationContext as App
66-
val extras = remember(app) {
67-
val container = app.container
68-
MainViewModel.creationExtras(container)
69-
}
7066
val viewModel: MainViewModel = viewModel(
7167
factory = MainViewModel.Factory,
72-
extras = extras,
68+
extras = creationExtras(app.container),
7369
)
7470

7571
val uiState by viewModel.homeUiState.collectAsState()

Fruitties/gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ skie = "0.10.2"
3434
sqlite = "2.5.1"
3535
spotless = "7.0.4"
3636
okio = "3.12.0"
37+
kermit = "2.0.4"
3738
runner = "1.6.2"
3839
core = "1.6.1"
3940
junit = "1.2.1"
@@ -74,6 +75,7 @@ androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runt
7475
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
7576
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
7677
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
78+
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
7779

7880
[plugins]
7981
androidApplication = { id = "com.android.application", version.ref = "agp" }

Fruitties/iosApp/iosApp.xcodeproj/project.pbxproj

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
archiveVersion = 1;
44
classes = {
55
};
6-
objectVersion = 54;
6+
objectVersion = 70;
77
objects = {
88

99
/* Begin PBXBuildFile section */
1010
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
1111
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
1212
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
13-
2E8773602BC85C2400BF7C40 /* CartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E87735F2BC85C2400BF7C40 /* CartView.swift */; };
14-
7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
13+
2DBC08C52E0C3291000309C8 /* ViewModelStoreOwnerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBC08C42E0C3291000309C8 /* ViewModelStoreOwnerProvider.swift */; };
14+
2DBC08C72E0C32CE000309C8 /* ObservableValueWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBC08C62E0C32CE000309C8 /* ObservableValueWrapper.swift */; };
15+
2DBC08C92E0C96A4000309C8 /* IOSViewModelStoreOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBC08C82E0C96A4000309C8 /* IOSViewModelStoreOwner.swift */; };
1516
/* End PBXBuildFile section */
1617

1718
/* Begin PBXCopyFilesBuildPhase section */
@@ -31,12 +32,17 @@
3132
058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3233
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
3334
2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
34-
2E87735F2BC85C2400BF7C40 /* CartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartView.swift; sourceTree = "<group>"; };
35+
2DBC08C42E0C3291000309C8 /* ViewModelStoreOwnerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStoreOwnerProvider.swift; sourceTree = "<group>"; };
36+
2DBC08C62E0C32CE000309C8 /* ObservableValueWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableValueWrapper.swift; sourceTree = "<group>"; };
37+
2DBC08C82E0C96A4000309C8 /* IOSViewModelStoreOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSViewModelStoreOwner.swift; sourceTree = "<group>"; };
3538
7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
36-
7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
3739
7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
3840
/* End PBXFileReference section */
3941

42+
/* Begin PBXFileSystemSynchronizedRootGroup section */
43+
2DBC08CA2E0CA0A9000309C8 /* ui */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ui; sourceTree = "<group>"; };
44+
/* End PBXFileSystemSynchronizedRootGroup section */
45+
4046
/* Begin PBXFrameworksBuildPhase section */
4147
7555FF78242A565900829871 /* Frameworks */ = {
4248
isa = PBXFrameworksBuildPhase;
@@ -76,12 +82,14 @@
7682
7555FF7D242A565900829871 /* iosApp */ = {
7783
isa = PBXGroup;
7884
children = (
85+
2DBC08CA2E0CA0A9000309C8 /* ui */,
7986
058557BA273AAA24004C7B11 /* Assets.xcassets */,
80-
7555FF82242A565900829871 /* ContentView.swift */,
8187
7555FF8C242A565B00829871 /* Info.plist */,
8288
2152FB032600AC8F00CF470E /* iOSApp.swift */,
8389
058557D7273AAEEB004C7B11 /* Preview Content */,
84-
2E87735F2BC85C2400BF7C40 /* CartView.swift */,
90+
2DBC08C42E0C3291000309C8 /* ViewModelStoreOwnerProvider.swift */,
91+
2DBC08C82E0C96A4000309C8 /* IOSViewModelStoreOwner.swift */,
92+
2DBC08C62E0C32CE000309C8 /* ObservableValueWrapper.swift */,
8593
);
8694
path = iosApp;
8795
sourceTree = "<group>";
@@ -110,6 +118,9 @@
110118
);
111119
dependencies = (
112120
);
121+
fileSystemSynchronizedGroups = (
122+
2DBC08CA2E0CA0A9000309C8 /* ui */,
123+
);
113124
name = iosApp;
114125
productName = iosApp;
115126
productReference = 7555FF7B242A565900829871 /* iosApp.app */;
@@ -187,9 +198,10 @@
187198
isa = PBXSourcesBuildPhase;
188199
buildActionMask = 2147483647;
189200
files = (
190-
2E8773602BC85C2400BF7C40 /* CartView.swift in Sources */,
191201
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
192-
7555FF83242A565900829871 /* ContentView.swift in Sources */,
202+
2DBC08C92E0C96A4000309C8 /* IOSViewModelStoreOwner.swift in Sources */,
203+
2DBC08C72E0C32CE000309C8 /* ObservableValueWrapper.swift in Sources */,
204+
2DBC08C52E0C3291000309C8 /* ViewModelStoreOwnerProvider.swift in Sources */,
193205
);
194206
runOnlyForDeploymentPostprocessing = 0;
195207
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import SwiftUI
2+
import shared
3+
4+
/// A ViewModelStoreOwner specifically for iOS.
5+
/// This is used with from iOS with Kotlin Multiplatform (KMP).
6+
class IOSViewModelStoreOwner: ObservableObject, SwiftViewModelStoreOwner {
7+
8+
var viewModelStore: Lifecycle_viewmodelViewModelStore =
9+
Lifecycle_viewmodelViewModelStore()
10+
11+
/// This function allows retrieving the androidx ViewModel from the store.
12+
func viewModel<T: Lifecycle_viewmodelViewModel>(
13+
key: String? = nil,
14+
factory: Lifecycle_viewmodelViewModelProviderFactory,
15+
extras: Lifecycle_viewmodelCreationExtras? = nil
16+
) -> T {
17+
do {
18+
return try viewModelStore.getViewModel(
19+
modelClass: T.self,
20+
factory: factory,
21+
key: key,
22+
extras: extras
23+
) as! T
24+
} catch {
25+
fatalError("Failed to create ViewModel of type \(T.self)")
26+
}
27+
}
28+
29+
func clear() {
30+
viewModelStore.clear()
31+
}
32+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Combine
2+
import SwiftUI
3+
import shared
4+
5+
/// A generic wrapper that makes any `Value` type observable by SwiftUI.
6+
///
7+
/// Use this to wrap non-`ObservableObject` types when their changes need to update SwiftUI views.
8+
class ObservableValueWrapper<Value>: ObservableObject {
9+
10+
/// The wrapped value. Changes trigger SwiftUI view updates.
11+
@Published var value: Value
12+
13+
/// Initializes the wrapper with an initial value.
14+
init(value: Value) {
15+
self.value = value
16+
}
17+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import SwiftUI
2+
import shared
3+
4+
/// A SwiftUI `View` that provides a `ViewModelStoreOwner` to its content.
5+
///
6+
/// Manages the lifecycle of `ViewModel` instances, scoping them to this view hierarchy.
7+
/// Clears the associated `ViewModelStore` when the provider disappears.
8+
struct ViewModelStoreOwnerProvider<Content: View>: View {
9+
@StateObject private var viewModelStoreOwner = IOSViewModelStoreOwner()
10+
11+
private let content: Content
12+
13+
/// Initializes the provider with its content, creating a new `IOSViewModelStoreOwner`.
14+
init(@ViewBuilder content: () -> Content) {
15+
self.content = content()
16+
}
17+
18+
var body: some View {
19+
content
20+
.environmentObject(viewModelStoreOwner)
21+
.onDisappear {
22+
viewModelStoreOwner.clear()
23+
}
24+
}
25+
}
26+
27+

Fruitties/iosApp/iosApp/iOSApp.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@ import shared
1919

2020
@main
2121
struct iOSApp: App {
22-
let appContainer = AppContainer(factory: Factory())
22+
/// The application's dependency container, wrapped for SwiftUI observation.
23+
let appContainer: ObservableValueWrapper<AppContainer>
24+
25+
init() {
26+
self.appContainer = ObservableValueWrapper<AppContainer>(
27+
value: AppContainer(factory: Factory())
28+
)
29+
}
30+
2331
var body: some Scene {
2432
WindowGroup {
25-
let iosViewModelOwner = IOSViewModelStoreOwner(appContainer: appContainer)
26-
ContentView(mainViewModel: iosViewModelOwner.getMainViewModel(),
27-
cartViewModel: iosViewModelOwner.getCartViewModel())
33+
/// Provides the root `ViewModelStoreOwner` to the environment, making it accessible to all child views.
34+
/// Nested `ViewModelStoreOwnerProvider` instances can create additional, scoped ViewModel stores.
35+
ViewModelStoreOwnerProvider {
36+
ContentView()
37+
}
38+
.environmentObject(appContainer)
2839
}
2940
}
3041
}

Fruitties/iosApp/iosApp/CartView.swift renamed to Fruitties/iosApp/iosApp/ui/CartView.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@ import SwiftUI
1919
import shared
2020

2121
struct CartView : View {
22-
let cartViewModel: CartViewModel
22+
/// Injects the `IOSViewModelStoreOwner` from the environment, which manages the lifecycle of `ViewModel` instances.
23+
@EnvironmentObject var viewModelStoreOwner: IOSViewModelStoreOwner
2324

24-
// The ViewModel exposes a StateFlow that we access in SwiftUI with SKIE Observing.
25-
// https://skie.touchlab.co/features/flows-in-swiftui
25+
/// Injects the `AppContainer` from the environment, providing access to application-wide dependencies.
26+
@EnvironmentObject var appContainer: ObservableValueWrapper<AppContainer>
2627

2728
var body: some View {
28-
// https://skie.touchlab.co/features/flows-in-swiftui
29+
/// Retrieves the `CartViewModel` instance using the `viewModelStoreOwner`.
30+
/// The `CartViewModel.Factory` and `creationExtras` are provided to enable dependency injection
31+
/// and proper initialization of the ViewModel with its required `AppContainer`.
32+
let cartViewModel: CartViewModel = viewModelStoreOwner.viewModel(
33+
factory: CartViewModel.companion.Factory,
34+
extras: creationExtras(appContainer: appContainer.value)
35+
)
36+
37+
/// Observes the `cartUiState` `StateFlow` from the `CartViewModel` using SKIE's `Observing` utility.
38+
/// This allows SwiftUI to react to changes in the cart's UI state.
39+
/// For more details, refer to: https://skie.touchlab.co/features/flows-in-swiftui
2940
Observing(cartViewModel.cartUiState) { cartUIState in
3041
VStack {
3142
HStack {
@@ -44,7 +55,9 @@ struct CartDetailsView: View {
4455
let cartViewModel: CartViewModel
4556

4657
var body: some View {
47-
// https://skie.touchlab.co/features/flows-in-swiftui
58+
/// Observes the `cartUiState` `StateFlow` from the `CartViewModel` using SKIE's `Observing` utility.
59+
/// This allows SwiftUI to react to changes in the cart's UI state.
60+
/// For more details, refer to: https://skie.touchlab.co/features/flows-in-swiftui
4861
Observing(self.cartViewModel.cartUiState) { cartUIState in
4962
ScrollView {
5063
LazyVStack {

0 commit comments

Comments
 (0)