Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
gradha opened this issue Nov 1, 2023 · 7 comments
Assignees
Labels
bug Something isn't working ios

Comments

@gradha
Copy link

gradha commented Nov 1, 2023

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:

  • iOS

Versions

  • Kotlin version: 1.9.20
  • Compose Multiplatform version: 1.5.10
  • OS architecture (x86 or arm64): arm64

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.

@gradha gradha added bug Something isn't working submitted labels Nov 1, 2023
@eymar
Copy link
Collaborator

eymar commented Nov 2, 2023

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
Copy link

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
Copy link

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
Copy link

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
Copy link

any update?

@kaizeiyimi
Copy link

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
Copy link
Contributor

elijah-semyonov commented Mar 4, 2024

This should have been fixed in JetBrains/compose-multiplatform-core@3083693.
Happened due to wrong reassigning of content that triggered global recomposition on viewWillAppear.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working ios
Projects
None yet
Development

No branches or pull requests

6 participants