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

UIImagePickerController Causing iOS Application Restart in Compose Multiplatform (iOS) #4334

Closed Mukuljangir372 closed 8 months ago

Mukuljangir372 commented 9 months ago

Describe the bug We're using UIImagePickerController to open the native camera using UiViewController in compose multiplatform. It is causing the iOS Application to get restart when click on cancel or capture in camera screen in iOS device.

What is the issue UIImagePickerController causing the iOS app restart.

Affected platforms

Versions

To Reproduce

  1. Call MediaPicker composable
  2. open the gallery
  3. click on cancel or press back in iOS app after opening camera
  4. Observe the app restart

Expected behavior iOS App should not restart automatically.

Code Snippets:

// entry view called from iOS Swift
fun AppView(): UIViewController {
    return ComposeUIViewController { App() }
}
// shared compose app
@Composable
fun App() {
    ScogoThemeUi {
        PreComposeApp {
            KoinContext {
                AppNavigationGraph.Graph()
            }
        }
    }
}
// Navigation Logic
internal object AppNavigationGraph {
    @Composable
    fun Graph(
        root: NavScreen.Root = NavScreen.Root.Launcher,
        navigator: Navigator = rememberNavigator()
    ) {
        NavHost(
            navigator = navigator,
            initialRoute = NavScreen.Launcher.Splash.createRoute(root)
        ) {
            scene(route = NavScreen.Launcher.Splash.createRoute(root)) {
                SplashScreen(
                    toLogin = {
                        val options = NavOptions(popUpTo = PopUpTo.First())
                        navigator.navigate(NavScreen.Launcher.Login.createRoute(root), options)
                    },
                    toDashboard = {
                        val options = NavOptions(popUpTo = PopUpTo.First())
                        navigator.navigate(NavScreen.Launcher.Dashboard.createRoute(root), options)
                    }
                )
            }

            scene(route = NavScreen.Launcher.Login.createRoute(root)) {
                LoginScreen(
                    toDashboard = {
                        val options = NavOptions(popUpTo = PopUpTo.First())
                        navigator.navigate(NavScreen.Launcher.Dashboard.createRoute(root), options)
                    }
                )
            }

            scene(route = NavScreen.Launcher.Dashboard.createRoute(root)) {
                DashboardScreen()
            }
        }
    }
}
@Immutable
internal class IosMediaManager : MediaManager {
    private val imagePickerController = UIImagePickerController()

    private lateinit var mediaContext: PlatformMediaContext

    override fun register(context: PlatformMediaContext) {
        mediaContext = context
    }

    override fun takePhoto(result: MediaResultListener) {
        with(imagePickerController) {
            sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera
            setCameraCaptureMode(UIImagePickerControllerCameraCaptureMode.UIImagePickerControllerCameraCaptureModePhoto)
            setAllowsEditing(true)
        }
        presentController(result)
    }

    override fun openGallery(result: MediaResultListener) {
        with(imagePickerController) {
            sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary
            setAllowsEditing(true)
        }
        presentController(result)
    }

    private fun presentController(result: MediaResultListener) {
        mediaContext.rootController.presentViewController(imagePickerController, true) {
            imagePickerController.delegate = getImagePickerDelegate { bytes ->
                result.onResult(listOf(bytes.toFile()))
            }
        }
    }

    @OptIn(ExperimentalForeignApi::class)
    private fun getImagePickerDelegate(
        onImagePicked: (ByteArray) -> Unit
    ): UINavigationControllerDelegateProtocol {
        return object :
            NSObject(),
            UIImagePickerControllerDelegateProtocol,
            UINavigationControllerDelegateProtocol {
            override fun imagePickerController(
                picker: UIImagePickerController,
                didFinishPickingImage: UIImage,
                editingInfo: Map<Any?, *>?
            ) {
                val imageNsData = UIImageJPEGRepresentation(didFinishPickingImage, 1.0) ?: return
                val bytes = ByteArray(imageNsData.length.toInt())
                memcpy(bytes.refTo(0), imageNsData.bytes, imageNsData.length)

                onImagePicked(bytes)

                picker.dismissViewControllerAnimated(true, null)
            }

            override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
                picker.dismissViewControllerAnimated(true, null)
            }
        }
    }
}
// called from a chat screen
@Composable
fun MediaPicker(
    mediaManager: MediaManager = getKoin().get(),
    permissionManager: PermissionManager = getKoin().get(),
    context: PlatformMediaContext = getPlatformMediaContext(),
    picker: @Composable (MediaManager) -> Unit,
    onPermissionDenied: () -> Unit
) {
    var permissionGranted by rememberSaveable {
        mutableStateOf(permissionManager.isGranted(Permission.STORAGE, Permission.CAMERA))
    }

    LaunchedEffect(permissionGranted) {
        if (!permissionGranted) {
            val granted = permissionManager.request(Permission.STORAGE, Permission.CAMERA).granted()
            if (granted) {
                permissionGranted = true
            } else {
                onPermissionDenied()
            }
        }
    }

    if (permissionGranted) {
        mediaManager.register(context)
        picker(mediaManager)
    }
}
@Immutable
actual class PlatformMediaContext(val rootController: UIViewController)

@Composable
actual fun getPlatformMediaContext(): PlatformMediaContext {
    return PlatformMediaContext(LocalUIViewController.current)
}
dima-avdeev-jb commented 9 months ago

@Mukuljangir372 Thanks for this Issue. Can you please provide a minimal reproducible sample project on GitHub?

Mukuljangir372 commented 8 months ago

@dima-avdeev-jb Thank you for your reply!

Here, Application root UiViewController got replaced by UiImagePickerController UiViewController that is returned by LocalUiViewController resulting causing the iOS app restart as previous UIViewController was replaced by UIImagePickerController. So to fix this issue, We need to add UiViewController on top of UiViewController so that previous view don't get replaced by new one. To achieve this behaviour, we need to add this.

imagePickerController.modalPresentationStyle = .overCurrentContext  //add this
rootController.present(imagePickerController, animated: true, completion: nil)
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.