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.21k stars 1.17k forks source link

[iOS] Application restarts when open a Camera #4025

Closed yandroidUA closed 10 months ago

yandroidUA commented 10 months ago

Describe the bug When the user opens a camera through the UIImagePickerController and then cancels or makes a photo the application "reloads" or losses its state. Not sure whether it's a bug of Compose or not, I'm using a following function to obtain an instance of my router:

@Composable
fun <D> rememberNavigationState(
    mapper: ((D) -> KClass<out ViewModel>?)? = null,
    initialConfiguration: TransitionScope.(NavigationState<D>) -> Unit = {},
    initialDestination: D,
    flowFinalizer: FlowFinalizer,
): NavigationState<D> {
    val manager = LocalViewModelManager.current
    return remember {
        NavigationState(
            initialDestination = initialDestination,
            initialConfiguration = initialConfiguration,
            viewModelExtension = mapper?.let { ViewModelExtension(manager = manager, mapping = it) },
            flowFinalizer = flowFinalizer
        )
    }
}

So, from my understanding it should not be reinstantiated. From the logs I see that ComposeUIViewController invokes children composables one the application become active after the camera and with the function above my NavigationState get reinstantiated and I don't know why.

Implementation to open the Camera & Gallery ``` // TODO need to store somewhere globally, otherwise iOS wipes it out and these are never called back private var imagePickerCoordinator: ImagePickerCoordinator? = null private var imagePickerController: UIImagePickerController? = null @Composable internal actual fun ImagePickerResultHandler( source: ImageSource?, onRequestSourceDialog: () -> Unit, onShowPermissionRationale: (Permission) -> Unit, onLoading: (Boolean) -> Unit, onError: (String) -> Unit, onResult: (RuntimeImage) -> Unit ) { val fileManager = LocalFileManager.current val currentController = UIApplication.sharedApplication.keyWindow?.rootViewController ?: LocalUIViewController.current imagePickerCoordinator = remember { ImagePickerCoordinator(onImagePicked = { onResult(RuntimeImage(it)) }, onPickerDismissed = onRequestSourceDialog) } imagePickerController = remember(imagePickerCoordinator) { UIImagePickerController().apply { delegate = imagePickerCoordinator } } LaunchedEffect(source) { when (source) { ImageSource.Camera -> { Napier.d(tag = "IMAGE_PICKER") { "Camera" } imagePickerController?.sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera imagePickerController?.cameraCaptureMode = UIImagePickerControllerCameraCaptureMode.UIImagePickerControllerCameraCaptureModePhoto currentController.showViewController(imagePickerController!!, null) } ImageSource.Gallery -> { imagePickerController?.sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary currentController.showViewController(imagePickerController!!, null) } is ImageSource.RemoteUrl -> { fileManager.getImageFromCacheOrDownload(source.url, force = true) .collect { state -> when (state) { is ApiState.Failed -> onError(state.reason) ApiState.Idle -> {} ApiState.Loading -> onLoading(true) is ApiState.Succeed -> state.data.representation?.image?.let(onResult) ?: onError("") } } } null -> Unit } } } private class ImagePickerCoordinator( private val onImagePicked: (ByteArray) -> Unit, private val onPickerDismissed: () -> Unit ) : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol { override fun imagePickerController( picker: UIImagePickerController, didFinishPickingImage: UIImage, editingInfo: Map? ) { Napier.d(tag = "IMAGE_PICKER") { "imagePickerController" } picker.dismissViewControllerAnimated(true, null) val imageBytes = UIImagePNGRepresentation(didFinishPickingImage)?.toByteArray() imageBytes?.run(onImagePicked) } override fun imagePickerControllerDidCancel(picker: UIImagePickerController) { Napier.d(tag = "IMAGE_PICKER") { "imagePickerControllerDidCancel" } picker.dismissViewControllerAnimated(true, null) onPickerDismissed() } } ```

Affected platforms Select one of the platforms below:

Versions

To Reproduce

Expected behavior Since I'm using remember I'm expecting to retrieve a same instance of the router.

Screenshots

https://github.com/JetBrains/compose-multiplatform/assets/46822605/429cbee8-1611-4e50-a6c5-1e4c3692bcfa

Additional context Obviously, it works fine if I save the NavigationState globally, but should I do that ? From my understanding, it's something that remember is responsible, doesn't it ?

m-sasha commented 10 months ago

Hi, thanks for the report. Could you provide a short but complete reproducer of the problem?

yandroidUA commented 10 months ago

Sure, here is a sample project - archive.

XCode: Version 15.0 (15A240d)

P.S. Please, ignore the GoogleSignIn pod in the app, initially it was a reproducer for the other issue, but it doesn't impact this issue

https://github.com/JetBrains/compose-multiplatform/assets/46822605/740d7e1a-f3de-4b58-a9ad-fbba0b63241d


From the logs:

CONTENT
STATE: com.app.test.NavigationState@1b958020
STATE: com.app.test.NavigationState@1b958020
IMAGE_PICKER: Camera
IMAGE_PICKER: imagePickerControllerDidCancel
CONTENT
STATE: com.app.test.NavigationState@1b95ca20

Same behaviour on the real iOS device


I'm not sure whether it's a Compose problem, but in case it is not I'd be apriciate to receive any advice how I can handle this case

terrakok commented 10 months ago

@yandroidUA it is the known issue with a compose scene lifecycle: https://github.com/JetBrains/compose-multiplatform/issues/3890

I will close this as a duplicate

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.