diff --git a/CHANGELOG.md b/CHANGELOG.md index 44be48b33..f02d63edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Please refer to [2.0.0-alpha10 – Migration guide](2.0.0-alpha10.md) - [#654](https://github.com/bumble-tech/appyx/pull/654) - Renamings - [#657](https://github.com/bumble-tech/appyx/pull/657) - Rename ParentNode & Node to Node and LeafNode - [#644](https://github.com/bumble-tech/appyx/pull/644) – Refactor AppyxComponent and application of draggable modifier +- [#660](https://github.com/bumble-tech/appyx/pull/660) - Make IosNodeHost to receive a Lifecycle class to fix iOS lifecycle problems ### Fixed diff --git a/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/integration/IosNodeHost.kt b/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/integration/IosNodeHost.kt index 6f88232d7..5a7e2a75a 100644 --- a/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/integration/IosNodeHost.kt +++ b/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/integration/IosNodeHost.kt @@ -7,11 +7,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.bumble.appyx.navigation.lifecycle.Lifecycle import com.bumble.appyx.navigation.node.Node import com.bumble.appyx.navigation.platform.LocalOnBackPressedDispatcherOwner import com.bumble.appyx.navigation.platform.OnBackPressedDispatcher import com.bumble.appyx.navigation.platform.OnBackPressedDispatcherOwner -import com.bumble.appyx.navigation.platform.PlatformLifecycleRegistry import com.bumble.appyx.utils.customisations.NodeCustomisationDirectory import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl import kotlinx.cinterop.ExperimentalForeignApi @@ -27,12 +27,10 @@ fun > IosNodeHost( onBackPressedEvents: Flow, modifier: Modifier = Modifier, integrationPoint: IntegrationPoint, + lifecycle: Lifecycle, customisations: NodeCustomisationDirectory = remember { NodeCustomisationDirectoryImpl(null) }, factory: NodeFactory, ) { - val platformLifecycleRegistry = remember { - PlatformLifecycleRegistry() - } val mainScreen = UIScreen.mainScreen val screenBounds = mainScreen.bounds @@ -58,7 +56,7 @@ fun > IosNodeHost( CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides onBackPressedDispatcherOwner) { NodeHost( - lifecycle = platformLifecycleRegistry, + lifecycle = lifecycle, integrationPoint = integrationPoint, modifier = modifier, customisations = customisations, diff --git a/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/platform/LifecycleHelper.kt b/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/platform/LifecycleHelper.kt new file mode 100644 index 000000000..695092289 --- /dev/null +++ b/appyx-navigation/common/src/iosMain/kotlin/com/bumble/appyx/navigation/platform/LifecycleHelper.kt @@ -0,0 +1,23 @@ +package com.bumble.appyx.navigation.platform + +import com.bumble.appyx.navigation.lifecycle.Lifecycle + +class LifecycleHelper { + val lifecycle: PlatformLifecycleRegistry = PlatformLifecycleRegistry() + + fun created() { + lifecycle.setCurrentState(Lifecycle.State.CREATED) + } + + fun resumed() { + lifecycle.setCurrentState(Lifecycle.State.RESUMED) + } + + fun started() { + lifecycle.setCurrentState(Lifecycle.State.STARTED) + } + + fun destroyed() { + lifecycle.setCurrentState(Lifecycle.State.DESTROYED) + } +} diff --git a/demos/appyx-navigation/ios/build.gradle.kts b/demos/appyx-navigation/ios/build.gradle.kts index 09b3f3212..18192502c 100644 --- a/demos/appyx-navigation/ios/build.gradle.kts +++ b/demos/appyx-navigation/ios/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { framework { baseName = "ios" isStatic = true + export(project(":appyx-navigation:appyx-navigation")) } license = "Apache License, Version 2.0" authors = "https://github.com/bumble-tech/" @@ -36,6 +37,7 @@ kotlin { iosSimulatorArm64Main.dependsOn(this) dependencies { implementation(project(":demos:appyx-navigation:common")) + api(project(":appyx-navigation:appyx-navigation")) api(compose.runtime) api(compose.foundation) api(compose.material) diff --git a/demos/appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt b/demos/appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt index e63a1ef35..134a67fb4 100644 --- a/demos/appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt +++ b/demos/appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.demos.navigation.node.root.RootNode import com.bumble.appyx.demos.navigation.ui.AppyxSampleAppTheme import com.bumble.appyx.navigation.integration.IosNodeHost import com.bumble.appyx.navigation.integration.MainIntegrationPoint +import com.bumble.appyx.navigation.platform.LifecycleHelper import kotlinx.coroutines.flow.flowOf import platform.Foundation.NSURL @@ -19,7 +20,7 @@ private val integrationPoint = MainIntegrationPoint() private val navigator = Navigator() @Suppress("FunctionNaming") -fun MainViewController() = ComposeUIViewController { +fun MainViewController(lifecycleHelper: LifecycleHelper) = ComposeUIViewController { AppyxSampleAppTheme { Scaffold( modifier = Modifier @@ -33,7 +34,8 @@ fun MainViewController() = ComposeUIViewController { IosNodeHost( modifier = Modifier, onBackPressedEvents = flowOf(), - integrationPoint = integrationPoint + integrationPoint = integrationPoint, + lifecycle = lifecycleHelper.lifecycle, ) { nodeContext -> RootNode( nodeContext = nodeContext, diff --git a/demos/appyx-navigation/iosApp/iosApp/ContentView.swift b/demos/appyx-navigation/iosApp/iosApp/ContentView.swift index 4fff14055..aedc9903c 100644 --- a/demos/appyx-navigation/iosApp/iosApp/ContentView.swift +++ b/demos/appyx-navigation/iosApp/iosApp/ContentView.swift @@ -3,16 +3,21 @@ import SwiftUI import ios struct ComposeView: UIViewControllerRepresentable { + + var lifecycleHelper: LifecycleHelper + func makeUIViewController(context: Context) -> UIViewController { - Main_iosKt.MainViewController() + Main_iosKt.MainViewController(lifecycleHelper: lifecycleHelper) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } struct ContentView: View { + var lifecycleHelper: LifecycleHelper + var body: some View { - ComposeView() + ComposeView(lifecycleHelper: lifecycleHelper) .ignoresSafeArea(.all) } } diff --git a/demos/appyx-navigation/iosApp/iosApp/iOSApp.swift b/demos/appyx-navigation/iosApp/iosApp/iOSApp.swift index 66023831a..86526af08 100644 --- a/demos/appyx-navigation/iosApp/iosApp/iOSApp.swift +++ b/demos/appyx-navigation/iosApp/iosApp/iOSApp.swift @@ -1,17 +1,52 @@ import SwiftUI import ios +import Foundation +import UIKit @main struct iOSApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate: AppDelegate + + @Environment(\.scenePhase) var scenePhase + var body: some Scene { WindowGroup { ZStack { Color.white.ignoresSafeArea(.all) // status bar color - ContentView() + ContentView(lifecycleHelper: appDelegate.lifecycleHolder.lifecycleHelper) } .onOpenURL { incomingURL in Main_iosKt.handleDeepLinks(url: incomingURL) } + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: appDelegate.lifecycleHolder.lifecycleHelper.created() + case .inactive: appDelegate.lifecycleHolder.lifecycleHelper.created() + case .active: appDelegate.lifecycleHolder.lifecycleHelper.resumed() + @unknown default: break + } + } } } } + +class AppDelegate: NSObject, UIApplicationDelegate { + let lifecycleHolder: LifecycleHolder = LifecycleHolder() +} + +class LifecycleHolder { + + let lifecycleHelper: LifecycleHelper + + init() { + lifecycleHelper = LifecycleHelper() + lifecycleHelper.created() + } + + deinit { + // Destroy the root component before it is deallocated + lifecycleHelper.destroyed() + } +} diff --git a/demos/sandbox-appyx-navigation/ios/build.gradle.kts b/demos/sandbox-appyx-navigation/ios/build.gradle.kts index 5541981b3..e84330c2f 100644 --- a/demos/sandbox-appyx-navigation/ios/build.gradle.kts +++ b/demos/sandbox-appyx-navigation/ios/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { framework { baseName = "ios" isStatic = true + export(project(":appyx-navigation:appyx-navigation")) } } @@ -34,6 +35,7 @@ kotlin { iosSimulatorArm64Main.dependsOn(this) dependencies { implementation(project(":demos:sandbox-appyx-navigation:common")) + api(project(":appyx-navigation:appyx-navigation")) api(compose.runtime) api(compose.foundation) api(compose.material) diff --git a/demos/sandbox-appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt b/demos/sandbox-appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt index 455f80b97..5a9d65cc9 100644 --- a/demos/sandbox-appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt +++ b/demos/sandbox-appyx-navigation/ios/src/iosMain/kotlin/main.ios.kt @@ -19,6 +19,7 @@ import com.bumble.appyx.demos.sandbox.navigation.node.container.MainNavNode import com.bumble.appyx.demos.sandbox.navigation.ui.AppyxSampleAppTheme import com.bumble.appyx.navigation.integration.IosNodeHost import com.bumble.appyx.navigation.integration.MainIntegrationPoint +import com.bumble.appyx.navigation.platform.LifecycleHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -29,7 +30,7 @@ val backEvents: Channel = Channel() private val integrationPoint = MainIntegrationPoint() @Suppress("FunctionNaming") -fun MainViewController() = ComposeUIViewController { +fun MainViewController(lifecycleHelper: LifecycleHelper) = ComposeUIViewController { AppyxSampleAppTheme { val coroutineScope = rememberCoroutineScope() @@ -45,7 +46,8 @@ fun MainViewController() = ComposeUIViewController { IosNodeHost( modifier = Modifier, onBackPressedEvents = backEvents.receiveAsFlow(), - integrationPoint = remember { integrationPoint } + lifecycle = lifecycleHelper.lifecycle, + integrationPoint = remember { integrationPoint }, ) { nodeContext -> MainNavNode( nodeContext = nodeContext, diff --git a/demos/sandbox-appyx-navigation/iosApp/iosApp/ContentView.swift b/demos/sandbox-appyx-navigation/iosApp/iosApp/ContentView.swift index 4fff14055..aedc9903c 100644 --- a/demos/sandbox-appyx-navigation/iosApp/iosApp/ContentView.swift +++ b/demos/sandbox-appyx-navigation/iosApp/iosApp/ContentView.swift @@ -3,16 +3,21 @@ import SwiftUI import ios struct ComposeView: UIViewControllerRepresentable { + + var lifecycleHelper: LifecycleHelper + func makeUIViewController(context: Context) -> UIViewController { - Main_iosKt.MainViewController() + Main_iosKt.MainViewController(lifecycleHelper: lifecycleHelper) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } struct ContentView: View { + var lifecycleHelper: LifecycleHelper + var body: some View { - ComposeView() + ComposeView(lifecycleHelper: lifecycleHelper) .ignoresSafeArea(.all) } } diff --git a/demos/sandbox-appyx-navigation/iosApp/iosApp/iOSApp.swift b/demos/sandbox-appyx-navigation/iosApp/iosApp/iOSApp.swift index 1f23b442a..f2ce4003b 100644 --- a/demos/sandbox-appyx-navigation/iosApp/iosApp/iOSApp.swift +++ b/demos/sandbox-appyx-navigation/iosApp/iosApp/iOSApp.swift @@ -1,13 +1,50 @@ import SwiftUI +import ios +import Foundation +import UIKit @main struct iOSApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate: AppDelegate + + @Environment(\.scenePhase) var scenePhase + var body: some Scene { WindowGroup { ZStack { Color.white.ignoresSafeArea(.all) // status bar color - ContentView() - }.preferredColorScheme(.light) + ContentView(lifecycleHelper: appDelegate.lifecycleHolder.lifecycleHelper) + } + .preferredColorScheme(.light) + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: appDelegate.lifecycleHolder.lifecycleHelper.created() + case .inactive: appDelegate.lifecycleHolder.lifecycleHelper.created() + case .active: appDelegate.lifecycleHolder.lifecycleHelper.resumed() + @unknown default: break + } + } } } -} \ No newline at end of file +} + +class AppDelegate: NSObject, UIApplicationDelegate { + let lifecycleHolder: LifecycleHolder = LifecycleHolder() +} + +class LifecycleHolder { + + let lifecycleHelper: LifecycleHelper + + init() { + lifecycleHelper = LifecycleHelper() + lifecycleHelper.created() + } + + deinit { + // Destroy the root component before it is deallocated + lifecycleHelper.destroyed() + } +} diff --git a/documentation/navigation/multiplatform.md b/documentation/navigation/multiplatform.md index 0122d8fe5..a4a5882db 100644 --- a/documentation/navigation/multiplatform.md +++ b/documentation/navigation/multiplatform.md @@ -180,15 +180,18 @@ fun main() { ### iOS +For a complete example on how to pass a `LifecycleHelper` class to `MainViewController` please refer to our [multiplatform starter kit](https://github.com/bumble-tech/appyx-starter-kit/tree/a7331be581a6727597eab35744fe1bcd26f3fa87/iosApp/iosApp) + ```kotlin val backEvents: Channel = Channel() -fun MainViewController() = ComposeUIViewController { +fun MainViewController(lifecycleHelper: LifecycleHelper) = ComposeUIViewController { YourAppTheme { IosNodeHost( modifier = Modifier, + lifecycle = lifecycleHelper.lifecycle, // See back handling section in the docs below! - onBackPressedEvents = backEvents.receiveAsFlow() + onBackPressedEvents = backEvents.receiveAsFlow(), ) { RootNode( nodeContext = it