Search code examples
kotlin-multiplatformcompose-multiplatformcompose-ios

Can't use PHPickerViewController delegate with KMM


I'm trying to use the UiKit API PHPickerViewController using KMM and Compose for iOS.

import androidx.compose.runtime.Composable
import androidx.compose.ui.interop.LocalUIViewController
import platform.PhotosUI.PHPickerConfiguration
import platform.PhotosUI.PHPickerViewController
import platform.PhotosUI.PHPickerViewControllerDelegateProtocol
import platform.darwin.NSObject

@Composable
actual fun pickerController() {
    val uiViewController = LocalUIViewController.current
    val configuration = PHPickerConfiguration()
    val pickerController = PHPickerViewController(configuration)
    val pickerDelegate = object : NSObject(), PHPickerViewControllerDelegateProtocol {
        override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
            println("didFinishPicking: $didFinishPicking")
            picker.dismissViewControllerAnimated(flag = false, completion = {})
            uiViewController.dismissModalViewControllerAnimated(false)
        }
    }

    pickerController.setDelegate(pickerDelegate)
    uiViewController.presentViewController(pickerController, animated = false, completion = null)
}

This displays the image picker:

Unfortunately, when clicking on Cancel, the delegate callback is not called, and I get the following message on the console:

[Picker] PHPickerViewControllerDelegate doesn't respond to picker:didFinishPicking:

Is it possible to implement the callback in Kotlin?
What am I missing?


Solution

  • Since pickerDelegate is NSObject, it's lifecycle follows ObjC rules, not KMM memory model.

    So as soon as the execution leaves composable block, this objects gets released - as setDelegate takes it as weak reference.

    You can fix it by storing it using remember.

    Also using your function is dangerous because you're gonna call presentViewController on each recomposition - e.g. if some of your reactive data changes on the calling side.

    You can update it to return an action that will present it, but store delegate and the action itself using remember:

    @Composable
    actual fun rememberOpenPickerAction(): () -> Unit {
        val uiViewController = LocalUIViewController.current
        val pickerDelegate = remember {
            object : NSObject(), PHPickerViewControllerDelegateProtocol {
                override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
                    println("didFinishPicking: $didFinishPicking")
                    picker.dismissViewControllerAnimated(flag = false, completion = {})
                }
            }
        }
    
        return remember {
            {
                val configuration = PHPickerConfiguration()
                val pickerController = PHPickerViewController(configuration)
                pickerController.setDelegate(pickerDelegate)
                uiViewController.presentViewController(pickerController, animated = true, completion = null)
            }
        }
    }
    

    Usage:

    Button(onClick = rememberOpenPickerAction()) {
    
    }