JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
16.27k stars 1.18k forks source link

Navigation between iOS compose view controllers triggers LaunchedEffect and DisposableEffect when it should not #3890

Closed gradha closed 8 months ago

gradha commented 1 year ago

Describe the bug

After implementing a very basic and rudimentary navigation between screens that calls under the hood startActivity on Android and pushViewController on iOS, I started to notice unexpected behaviour, which might be related to #3889. On Android LaunchedEffect and DisposableEffect execute once per activity lifetime, but on iOS they would be called every time the UIViewController left the screen, even if the navigation was deeper into further view controllers, not back.

Affected platforms Select one of the platforms below:

Versions

To Reproduce

The pull request at this fork of the ios compose template contains the whole code. A ProxyNavigator interface is created and passed on to the compose screens, so that interaction in those screens can call back code implemented by the native platform:

interface ProxyNavigator {
    fun openNext()
}

The implementation of this interface on Android is fairly trivial:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val navigator = object : ProxyNavigator {
            override fun openNext() {
                open()
            }
        }

        setContent {
            MainView(navigator)
        }
    }

    private fun open() {
        startActivity(Intent(this, FirstActivity::class.java))
    }
}

On iOS it is slightly more awkward, it adds a view controller weak variable to capture the result of calling ComposeUIViewController:

class MainNavigator: ProxyNavigator {
    weak var vc: UIViewController?

    func openNext() {
        let firstNavigator = FirstNavigator()
        let result = Main_iosKt.FirstViewController(proxyNavigator: firstNavigator)
        firstNavigator.vc = result
        vc?.navigationController?.pushViewController(result, animated: true)
    }
}

In both platforms the example app starts with basic root view, which then will open the first screen, and this in turn will add the second screen.

fun FirstScreen(proxyNavigator: ProxyNavigator) {
    MaterialTheme {
        Column(
            Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
        ) {
            Button(onClick = { proxyNavigator.openNext() }) {
                Text("Open second screen")
            }

            LaunchedEffect(Unit) {
                println("Launching First screen effect")
            }

            DisposableEffect(Unit) {
                println("Entry point of First screen disposable effect")
                onDispose { println("Disposing First Screeen!!!!! Bye bye!!!") }
            }
        }
    }
}

As can be seen, the compose button will call proxyNavigator.openNext(), which under the hood will either start another activity on android or push a new view controller on iOS. The second screen is just a dummy to highlight the effect.

When running the android app, the root screen opens the first screen, and the LaunchedEffect triggers once. The entry point of the DisposableEffect also triggers once. Now you can open the second screen and go back to the first as many times as you want, and no other side effects will trigger. Only when going back from the first screen to the root one will the onDispose trigger happen.

On the other hand, on iOS if you navigate from root -> First -> second, when entering second screen you will see that the dispose effects are triggered through logs. Going back and forth between the first and second screens will trigger all the launch and disposable side effects.

Expected behavior On iOS the launch side effect should trigger once when entering the screen, but it triggers when you navigate back from the second to the first view controllers. Same thing with the disposable side effects, they should happen once, but they happen every time the user leaves the screen from the first to the second and back.

Additional context I haven't tested this on SwiftUI, but I'm trying to integrate compose views into an old iOS architecture that uses UIViewController. The need for the Proxy navigation hack is really ugly because there's no way to access the opaque UIViewController created by compose multiplatform. This is making navigation quite messy with all the circular references and extra attributes.

Even ComposeUIViewControllerConfiguration could alleviate these hacks if the interface implemented passing as first parameter the view controller:

interface WishComposeUIViewControllerDelegate {
    fun viewDidLoad(vc: UIViewController) = Unit
    fun viewWillAppear(vc: UIViewController, animated: Boolean) = Unit
    fun viewDidAppear(vc: UIViewController, animated: Boolean) = Unit
    fun viewWillDisappear(vc: UIViewController, animated: Boolean) = Unit
    fun viewDidDisappear(vc: UIViewController, animated: Boolean) = Unit
}

With this kind of interface I would not need to store the UIViewController in extra variables, or rather could capture it inside viewDidLoad to later use it for navigation.

eymar commented 1 year ago

Hi @gradha ! Thank you for reporting the problem. The lifecycle in Compose for iOS is indeed different from that on Android. We see that it causes different issues and we plan to approach this problem relatively soon. Unfortunately, there is no workaround for now, afaik. We'll post an update here as soon as we have any news.

MikePT28 commented 1 year ago

Hi! We also are facing this issue. For now we found a workaround that seems to work. We have yet to find any side effects.

By following the steps to use a UIKit/SwiftUI View/ViewControllers within compose we sidestep leaving the context.

This is not an ideal solution and a fix would be greatly appreciated. But this might allow others to continue working while they wait.

Link to docs on how to achieve this: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-ios-ui-integration.html#use-uikit-inside-compose-multiplatform

❤️

andriiyan commented 10 months ago

Any updates regarding this issue ?

It's quite a critical bug, for example, it prevents using a Camera through the KMP implementation - which is a crucial part of features for some applications

kaizeiyimi commented 9 months ago

the easiest way to reproduce is using UITabBarController:

final class RootTabBarController: UITabBarController {
    override func loadView() {
        super.loadView()
            let composePage = PagesKt.settingsPage()
            let nativePage = UIViewController()
            viewControllers = [composePage, nativePage]
    }
}

not only DisposableEffect is triggered, but also all remember states are lost. the whole page is reset to initial.

kaizeiyimi commented 9 months ago

any update?

kaizeiyimi commented 8 months ago

the easiest way to reproduce is using UITabBarController:

final class RootTabBarController: UITabBarController {
    override func loadView() {
        super.loadView()
            let composePage = PagesKt.settingsPage()
            let nativePage = UIViewController()
            viewControllers = [composePage, nativePage]
    }
}

not only DisposableEffect is triggered, but also all remember states are lost. the whole page is reset to initial.

1.6.2 seems to solved this problem!

elijah-semyonov commented 8 months ago

This should have been fixed in https://github.com/JetBrains/compose-multiplatform-core/commit/3083693a90f550c86ff84153bea0ab77892635c2. Happened due to wrong reassigning of content that triggered global recomposition on viewWillAppear.

okushnikov commented 2 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.