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()