Issue #485

The easiest way to show image picker in iOS is to use UIImagePickerController, and we can bridge that to SwiftUI via UIViewControllerRepresentable

First attempt, use Environment

We conform to UIViewControllerRepresentable and make a Coordinator, which is the recommended way to manage the bridging with UIViewController.

There’s some built in environment property we can use, one of those is presentationMode where we can call dismiss to dismiss the modal.

My first attempt looks like below

import SwiftUI
import UIKit

public struct ImagePicker: UIViewControllerRepresentable {
    @Environment(\.presentationMode) private var presentationMode
    @Binding var image: UIImage?

    public func makeCoordinator() -> ImagePicker.Coordinator {
        return ImagePicker.Coordinator(
            presentationMode: presentationMode,
            image: $image
        )
    }

    public func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
        let controller = UIImagePickerController()
        controller.delegate = context.coordinator
        return controller
    }

    public func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
        // No op
    }
}

public extension ImagePicker {
    class Coordinator: NSObject, UINavigationControllerDelegate {
        @Binding var presentationMode: PresentationMode
        @Binding var image: UIImage?

        public init(presentationMode: Binding<PresentationMode>, image: Binding<UIImage?>) {
            self._presentationMode = presentationMode
            self._image = image
        }
    }
}

extension ImagePicker.Coordinator: UIImagePickerControllerDelegate {
    public func imagePickerController(
        _ picker: UIImagePickerController,
        didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
        self.image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage
        presentationMode.dismiss()
    }

    public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        presentationMode.dismiss()
    }
}

Signatures

We need to be aware of the types of these property wrappers

Where we declare environment, presentationMode is of type Binding<PresentationMode>

@Environment(\.presentationMode) private var presentationMode

Given a Binding declaration, for example @Binding var image: UIImage?, image is of type UIImage? but $image is Binding<UIImage?>

public func makeCoordinator() -> ImagePicker.Coordinator {
    return ImagePicker.Coordinator(
        image: $image,
        isPresented: $isPresented
    )
}

When we want to assign to variables in init, we use _image to use mutable Binding<UIImage?> because self.$image gives us immutable Binding<UIImage?>

class Coordinator: NSObject, UINavigationControllerDelegate {
    @Binding var presentationMode: PresentationMode
    @Binding var image: UIImage?

    public init(presentationMode: Binding<PresentationMode>, image: Binding<UIImage?>) {
        self._presentationMode = presentationMode
        self._image = image
    }
}

How to use

To show modal, we use sheet and use a state @State var showImagePicker: Bool = false to control its presentation

Button(action: {
    self.showImagePicker.toggle()
}, label: {
    Text("Choose image")
})
.sheet(isPresented: $showImagePicker, content: {
    ImagePicker(image: self.$image)
})

Environment outside body

If we run the above code, it will crash because of we access environment value presentationMode in makeCoordinator and this is outside body

Fatal error: Reading Environment<Binding> outside View.body

public func makeCoordinator() -> ImagePicker.Coordinator {
    return ImagePicker.Coordinator(
        presentationMode: presentationMode,
        image: $image
    )
}

Second attempt, pass closure

So instead of passing environment presentationMode, we can pass closure, just like in React where we pass functions to child component.

So ImagePicker can just accept a closure called onDone, and the component that uses it can do the dismissal.

Button(action: {
    self.showImagePicker.toggle()
}, label: {
    Text("Choose image")
})
.sheet(isPresented: $showImagePicker, content: {
    ImagePicker(image: self.$image, onDone: {
        self.presentationMode.wrappedValue.dismiss()
    })
})

Unfortunately, although the onDone gets called, the modal is not dismissed.

Use Binding instead of Environment

Maybe there are betters way, but we can use Binding to replace usage of Environment.

We can do that by accepting Binding and change the isPresented state

import SwiftUI
import UIKit

public struct ImagePicker: UIViewControllerRepresentable {
    @Binding var image: UIImage?
    @Binding var isPresented: Bool

    public func makeCoordinator() -> ImagePicker.Coordinator {
        return ImagePicker.Coordinator(
            image: $image,
            isPresented: $isPresented
        )
    }

    public func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
        let controller = UIImagePickerController()
        controller.delegate = context.coordinator
        return controller
    }

    public func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
        // No op
    }
}

public extension ImagePicker {
    class Coordinator: NSObject, UINavigationControllerDelegate {
        @Binding var isPresented: Bool
        @Binding var image: UIImage?

        public init(image: Binding<UIImage?>, isPresented: Binding<Bool>) {
            self._image = image
            self._isPresented = isPresented
        }
    }
}

extension ImagePicker.Coordinator: UIImagePickerControllerDelegate {
    public func imagePickerController(
        _ picker: UIImagePickerController,
        didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
        self.image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage
        isPresented = false
    }

    public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        isPresented = false
    }
}

How to use it

Button(action: {
    self.showImagePicker.toggle()
}, label: {
    Text("Choose image")
})
.sheet(isPresented: $showImagePicker, content: {
    ImagePicker(image: self.$image, isPresented: self.$showImagePicker)
})

Pass ImagePicker to Coordinator

So that we can call parent.presentationMode.wrappedValue.dismiss()