SwiftUI + UIPageViewController, multiple view types - swiftui

I want to use a UIPageViewController in my SwiftUI app, as demonstrated in this tutorial. However, the tutorial passes in identical view types as pages, and I want to pass in any type of view.
struct PageViewController<Page: View>: UIViewControllerRepresentable {
var pages: [Page]
#Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[context.coordinator.controllers[currentPage]], direction: .forward, animated: true)
}
class Coordinator: NSObject, UIPageViewControllerDelegate {
var parent: PageViewController
var controllers = [UIViewController]()
init(_ pageViewController: PageViewController) {
parent = pageViewController
controllers = parent.pages.map { UIHostingController(rootView: $0) }
}
func pageViewController(
_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
}
}
struct PageView<Page: View>: View {
var pages: [Page]
#State var currentPage = 0
var body: some View {
ZStack {
PageViewController(pages: pages, currentPage: $currentPage)
}
}
}
struct PageView_Previews: PreviewProvider {
static var previews: some View {
PageView(pages: [
Color.red,
Color.blue
// I want to add other view types here
])
}
}
If I change the array of pages in the PageView_Previews to something like [Color.red, Text("abc")], I get an error because they're not the same type. How do I get SwiftUI to allow heterogeneous View types here?
I can't use TabView with PageTabViewStyle (lots of bugs), so that's why I need to use this UIKit method.

You can wrap your pages in AnyView:
PageView(pages: [
AnyView(Color.red),
AnyView(Color.blue),
AnyView(Text("Hi"))
])

Related

Can't drag down/dismiss UIColorPickerViewController

I am displaying a UIColorPickerViewController as a sheet using the sheet() method, everything works fine but I can't drag down/dismiss the view anymore.
import Foundation
import SwiftUI
struct ColorPickerView: UIViewControllerRepresentable {
private var selectedColor: UIColor!
init(selectedColor: UIColor) {
self.selectedColor = selectedColor
}
func makeUIViewController(context: Context) -> UIColorPickerViewController {
let colorPicker = UIColorPickerViewController()
colorPicker.selectedColor = self.selectedColor
return colorPicker
}
func updateUIViewController(_ uiViewController: UIColorPickerViewController, context: Context) {
// Silent
}
}
.sheet(isPresented: self.$viewManager.showSheet, onDismiss: {
ColorPickerView()
}
Any idea how to make the drag/down dismiss gesture works?
Thanks!
Ran into the same problem when trying to build a color picker similar to above. What worked was "wrapping" the color picker in a view with a Dismiss button. And also discovered that the bar at the top of the view would allow the picker to now be dragged down and away. Below is my wrapper. (One could add more features such as a title to the bar.)
struct ColorWrapper: View {
var inputColor: UIColor
#Binding var isShowingColorPicker: Bool
#Binding var selectedColor: UIColor?
var body: some View {
VStack {
HStack {
Spacer()
Button("Dismiss", action: {
isShowingColorPicker = false
}).padding()
}
ColorPickerView(inputColor: inputColor, selectedColor: $selectedColor)
}
}
}
And for completeness, here is my version of the color picker:
import SwiftUI
struct ColorPickerView: UIViewControllerRepresentable {
typealias UIViewControllerType = UIColorPickerViewController
var inputColor: UIColor
#Binding var selectedColor: UIColor?
#Environment(\.presentationMode) var isPresented
func makeUIViewController(context: Context) -> UIColorPickerViewController {
let picker = UIColorPickerViewController()
picker.delegate = context.coordinator
picker.supportsAlpha = false
picker.selectedColor = inputColor
return picker
}
func updateUIViewController(_ uiViewController: UIColorPickerViewController, context: Context) {
uiViewController.supportsAlpha = false
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: NSObject, UINavigationControllerDelegate, UIColorPickerViewControllerDelegate {
var parent: ColorPickerView
init(parent: ColorPickerView) {
self.parent = parent
}
func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) {
parent.isPresented.wrappedValue.dismiss()
}
func colorPickerViewController(_ viewController: UIColorPickerViewController, didSelect color: UIColor, continuously: Bool) {
parent.selectedColor = color
// parent.isPresented.wrappedValue.dismiss()
}
}
}

How to dismiss PKToolPicker when view disappears in SwiftUI?

I'm trying to wrap PKCanvasView as a SwiftUI view called CanvasView. I'd like to be able to toggle the whole canvas on top of another view. When the CanvasView appears, I'd like the PKToolPicker to appear. When it disappears, I'd like the PKToolPicker to disappear.
I've found a few similar approaches on here but they only involve showing the picker or toggling the picker with a button; I'd like the picker visibility to be tied to the view visibility.
In the below example you can see that you can toggle the canvas, but once the tool picker is visible, it stays visible.
Here's my CanvasView:
import SwiftUI
import PencilKit
struct CanvasView: UIViewRepresentable {
class Coordinator: NSObject, PKCanvasViewDelegate {
var canvasView: Binding<PKCanvasView>
let onChange: () -> Void
init(canvasView: Binding<PKCanvasView>, onChange: #escaping () -> Void) {
self.canvasView = canvasView
self.onChange = onChange
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
if canvasView.drawing.bounds.isEmpty == false {
onChange()
}
}
}
#Binding var canvasView: PKCanvasView
#Binding var toolPickerIsActive: Bool
private let toolPicker = PKToolPicker()
let onChange: () -> Void
func makeUIView(context: Context) -> PKCanvasView {
canvasView.backgroundColor = .clear
canvasView.isOpaque = true
canvasView.delegate = context.coordinator
showToolPicker()
return canvasView
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {
toolPicker.setVisible(toolPickerIsActive, forFirstResponder: uiView)
}
func showToolPicker() {
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
canvasView.becomeFirstResponder()
}
func makeCoordinator() -> Coordinator {
Coordinator(canvasView: $canvasView, onChange: onChange)
}
}
And an example ContentView:
struct ContentView: View {
#State private var canvasView = PKCanvasView()
#State private var toolPickerIsActive = false
#State private var canvasIsVisible = false
var body: some View {
ZStack {
if canvasIsVisible {
CanvasView(canvasView: $canvasView,
toolPickerIsActive: $toolPickerIsActive,
onChange: canvasDidChange)
.onAppear { toolPickerIsActive = true }
.onDisappear { toolPickerIsActive = false }
}
Button(action: {
canvasIsVisible.toggle()
}, label: {
Text("Toggle canvas view")
})
}
}
private func canvasDidChange() {
// Do something with updated canvas.
}
}
Any guidance would be much appreciated!
In your scenario the CanvasView is destroyed on disappear, so SwiftUI rendering engine just not update it on any state change (as it see that no needs for that).
The possible solution for this use-case is to hide picker on coordinator deinit (because it is destroyed with owner view).
Here is a demo. Tested with Xcode 12.4 / iOS 14.4
struct CanvasView: UIViewRepresentable {
class Coordinator: NSObject, PKCanvasViewDelegate {
var canvasView: Binding<PKCanvasView>
let onChange: () -> Void
private let toolPicker: PKToolPicker
deinit { // << here !!
toolPicker.setVisible(false, forFirstResponder: canvasView.wrappedValue)
toolPicker.removeObserver(canvasView.wrappedValue)
}
init(canvasView: Binding<PKCanvasView>, toolPicker: PKToolPicker, onChange: #escaping () -> Void) {
self.canvasView = canvasView
self.onChange = onChange
self.toolPicker = toolPicker
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
if canvasView.drawing.bounds.isEmpty == false {
onChange()
}
}
}
#Binding var canvasView: PKCanvasView
#Binding var toolPickerIsActive: Bool
private let toolPicker = PKToolPicker()
let onChange: () -> Void
func makeUIView(context: Context) -> PKCanvasView {
canvasView.backgroundColor = .clear
canvasView.isOpaque = true
canvasView.delegate = context.coordinator
showToolPicker()
return canvasView
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {
toolPicker.setVisible(toolPickerIsActive, forFirstResponder: uiView)
}
func showToolPicker() {
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
canvasView.becomeFirstResponder()
}
func makeCoordinator() -> Coordinator {
Coordinator(canvasView: $canvasView, toolPicker: toolPicker, onChange: onChange)
}
}
struct ContentView: View {
#State private var canvasView = PKCanvasView()
#State private var toolPickerIsActive = false
#State private var canvasIsVisible = false
var body: some View {
ZStack {
if canvasIsVisible {
CanvasView(canvasView: $canvasView,
toolPickerIsActive: $toolPickerIsActive,
onChange: canvasDidChange)
.onAppear { toolPickerIsActive = true }
// .onDisappear { toolPickerIsActive = false }
}
Button(action: {
canvasIsVisible.toggle()
}, label: {
Text("Toggle canvas view")
})
}
}
private func canvasDidChange() {
// Do something with updated canvas.
}
}
Note: there might be redesign of ownership, so toolPicker will live only within coordinator, but it does not change idea, and is up to you.

Pass binding from parent to child view conforming to UIViewControllerRepresentable

I need to rely on a PageView view that has a currentPage value, in such a way that the PageView itself has ownership of the value (therefore, #State) but I need to update app state when this value changes.
With TabView, I simply put #Binding $selected in as an argument and can act upon changes to this value outside of the UI layer using a custom Binding<Int> with my own getter and setter. That is the method I'm trying right now to put together a solution.
But my PageView is based on an array of UIHostingControllers and a UIViewControllerRepresentable to integrate UIPageViewController from UIKit (I know that Swift in 5.3 will offer the SwiftUI version through TabView, but I don't want to wait until "September")
PageViewController.swift
// Source: https://stackoverflow.com/questions/58388071/how-to-implement-pageview-in-swiftui
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
#Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
if controllers.count > 0 {
pageViewController.setViewControllers([controllers[currentPage]], direction: .forward, animated: true)
}
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
}
}
PageView.swift
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
var currentPage: Binding<Int>
init(_ views: [Page], currentPage: Binding<Int>) {
self.viewControllers = views.map {
let ui = UIHostingController(rootView: $0)
ui.view.backgroundColor = UIColor.clear
return ui
}
self.currentPage = currentPage
}
var body: some View {
ZStack(alignment: .bottom) {
PageViewController(controllers: viewControllers, currentPage: currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: currentPage)
}.frame(height: 300)
}
}
Above you can see I'm trying passing in a Binding<Int> argument from the parent view, PageViewTest
PageViewTest.swift
struct PageViewTest: View {
var pagesData = ["ONE", "TWO"]
var _currentPage: Int = 0
var currentPage: Binding<Int> {
Binding<Int>(get: {
self._currentPage
}, set: {
// i.e. Update app state
print($0)
})
}
var body: some View {
VStack {
PageView(pagesData.map {
Text($0)
}, currentPage: self.currentPage)
}
}
}
This set up works as far as calling the setter routine specified in PageViewTest, but for some reason the binding is not reflected in the PageControl (from PageView.swift) that conforms to UIViewRepresentable so I don't feel like this is THE solution.
Am I passing around the bindings incorrectly? The PageView should own the state of currentPage, but I want its ancestor view to be able to act on changes to it.
#ObservableObject won't work because I just want to send a primitive. CurrentValueSubject/Passthrough won't fire (presumably because the PageView is being reinitialized over and over):
Alternative PageView.swift
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
var valStr = PassthroughSubject<Int, Never>()
var store = Set<AnyCancellable>()
#State var currentPage: Int = 0 {
didSet {
valStr.send(currentPage)
}
}
init(_ views: [Page], _ cb: #escaping (Int) -> ()) {
self.viewControllers = views.map {
let ui = UIHostingController(rootView: $0)
ui.view.backgroundColor = UIColor.clear
return ui
}
valStr.sink(receiveValue: { value in
cb(value)
}).store(in: &store)
}
var body: some View {
ZStack(alignment: .bottom) {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
}.frame(height: 300)
}
}

SwiftUI UIPageView compile error and action sheet problem

I have 2 problems in my code.
One is compile error [Cannot convert value of type 'Page1' to expected element type '_'] is shown at ★.
Another is that when backward button is pressed, Page2(blue) disappeared while alert sheet is showing.
(comment out "Page1(page: self.$page)," and then build source)
Expected behavior is that Page2(blue) does not disappear until alert button(Yes) is pressed.
Can anyone tell me how to solve these problems?
import SwiftUI
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
#Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .pageCurl,
navigationOrientation: .vertical)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return nil
}
return parent.controllers[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return nil
}
return parent.controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
#Binding var currentPage: Int
init(_ views: [Page], currentPage: Binding<Int>) {
self._currentPage = currentPage
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}
var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}
struct ContentView: View {
#State var page: Int = 0
var body: some View {
VStack {
PageView([
Page1(page: self.$page), // ★ error
Page2(page: self.$page)// ★ error
], currentPage: $page)
}
}
}
struct Page1: View{
#Binding var page: Int
var body: some View {
ZStack{
Color.red
VStack{
Text("page1")
Button (
action: { self.page += 1 }
){
Image(systemName: "forward")
.accentColor(Color.yellow)
}
}
}
}
}
struct Page2: View{
#State private var showingAlert: Bool = false
#Binding var page: Int
var body: some View {
ZStack{
Color.blue
VStack{
Text("page2")
Button (
action: { self.showingAlert.toggle() }
){
Image(systemName: "backward")
.accentColor(Color.yellow)
}
}
.alert(isPresented: $showingAlert) {
Alert(
title: Text("Confirm"),
message: Text("Back?"),
primaryButton:
.default(Text("Yes"),
action:{ self.page -= 1 }
),
secondaryButton:
.cancel(Text("No"))
)//alert
}//alert
}
}
}
I was able to figure it out using:
[How to create an onboarding screen in SwiftUI #1 - Embedding a UIPageViewController] (https://www.blckbirds.com/post/how-to-create-a-onboarding-screen-in-swiftui-1)

UIViewControllerRepresentable and CNContactPickerViewController

Can't seem to create a UIViewControllerRepresentable that works with CNContactPickerViewController.
Using Xcode 11 beta 4, I've created number of other UIViewControllerRepresentable using other UIViewController and those have worked fine. I've tried changing the features of the CNContactPickerViewController and different implementations of the delegate.
import SwiftUI
import ContactsUI
// Minimal version
struct LookupContactVCR : UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> CNContactPickerViewController {
let contactPickerVC = CNContactPickerViewController()
contactPickerVC.delegate = context.coordinator
return contactPickerVC
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}
class Coordinator: NSObject {}
}
extension LookupContactVCR.Coordinator : CNContactPickerDelegate {
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
print("Chose: \(contact.givenName)")
}
}
#if DEBUG
struct LookupContact_Previews : PreviewProvider {
static var previews: some View {
LookupContactVCR()
}
}
#endif
No error messages. But the screen is always white with nothing rendered.
First of all, please file a [Bug Report][1] for this issue.
[1]: https://bugreport.apple.com
Secondly, there are 2 workarounds for this issue:
You can use ABPeoplePickerNavigationController which is deprecated but still works.
Create a UIViewController which presents CNContactPickerViewController on viewWillAppear and use this newly created view controller with SwiftUI.
1. ABPeoplePickerNavigationController
import SwiftUI
import AddressBookUI
struct PeoplePicker: UIViewControllerRepresentable {
typealias UIViewControllerType = ABPeoplePickerNavigationController
final class Coordinator: NSObject, ABPeoplePickerNavigationControllerDelegate, UINavigationControllerDelegate {
func peoplePickerNavigationController(_ peoplePicker: ABPeoplePickerNavigationController, didSelectPerson person: ABRecord) {
<#selected#>
}
func peoplePickerNavigationControllerDidCancel(_ peoplePicker: ABPeoplePickerNavigationController) {
<#cancelled#>
}
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func makeUIViewController(context: UIViewControllerRepresentableContext<PeoplePicker>) -> PeoplePicker.UIViewControllerType {
let result = UIViewControllerType()
result.delegate = context.coordinator
return result
}
func updateUIViewController(_ uiViewController: PeoplePicker.UIViewControllerType, context: UIViewControllerRepresentableContext<PeoplePicker>) { }
}
2. CNContactPickerViewController
EmbeddedContactPickerViewController
import Foundation
import ContactsUI
import Contacts
protocol EmbeddedContactPickerViewControllerDelegate: AnyObject {
func embeddedContactPickerViewControllerDidCancel(_ viewController: EmbeddedContactPickerViewController)
func embeddedContactPickerViewController(_ viewController: EmbeddedContactPickerViewController, didSelect contact: CNContact)
}
class EmbeddedContactPickerViewController: UIViewController, CNContactPickerDelegate {
weak var delegate: EmbeddedContactPickerViewControllerDelegate?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.open(animated: animated)
}
private func open(animated: Bool) {
let viewController = CNContactPickerViewController()
viewController.delegate = self
self.present(viewController, animated: false)
}
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
self.dismiss(animated: false) {
self.delegate?.embeddedContactPickerViewControllerDidCancel(self)
}
}
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
self.dismiss(animated: false) {
self.delegate?.embeddedContactPickerViewController(self, didSelect: contact)
}
}
}
EmbeddedContactPicker
import SwiftUI
import Contacts
import Combine
struct EmbeddedContactPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = EmbeddedContactPickerViewController
final class Coordinator: NSObject, EmbeddedContactPickerViewControllerDelegate {
func embeddedContactPickerViewController(_ viewController: EmbeddedContactPickerViewController, didSelect contact: CNContact) {
<#selected#>
}
func embeddedContactPickerViewControllerDidCancel(_ viewController: EmbeddedContactPickerViewController) {
<#cancelled#>
}
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func makeUIViewController(context: UIViewControllerRepresentableContext<EmbeddedContactPicker>) -> EmbeddedContactPicker.UIViewControllerType {
let result = EmbeddedContactPicker.UIViewControllerType()
result.delegate = context.coordinator
return result
}
func updateUIViewController(_ uiViewController: EmbeddedContactPicker.UIViewControllerType, context: UIViewControllerRepresentableContext<EmbeddedContactPicker>) { }
}
What I did, is just wrapping it inside a NavigationController. Maybe not as clean as arturigor's answer, but works quite easily.
func makeUIViewController(context: Context) -> some UIViewController {
// needs to be wrapper in another controller. Else isn't displayed
let navController = UINavigationController()
let controller = CNContactPickerViewController()
controller.delegate = delegate
controller.predicateForEnablingContact = enablingPredicate
navController.present(controller, animated: false, completion: nil)
return navController
}
Regarding the questions, how it should be displayed. I Just have it displayed conditionally as a view inside a group
Group {
Text("Sharing is caring")
if showContactPicker {
ContactPicker(contactType: .email)
}
}
import SwiftUI
import Contacts
import ContactsUI
struct SomeView: View {
#State var contact: CNContact?
var body: some View {
VStack {
Text("Selected: \(contact?.givenName ?? "")")
ContactPickerButton(contact: $contact) {
Label("Select Contact", systemImage: "person.crop.circle.fill")
.fixedSize()
}
.fixedSize()
.buttonStyle(.borderedProminent)
}
}
}
struct SomeView_Previews: PreviewProvider {
static var previews: some View {
SomeView()
}
}
public struct ContactPickerButton<Label: View>: UIViewControllerRepresentable {
public class Coordinator: NSObject, CNContactPickerDelegate {
var onCancel: () -> Void
var viewController: UIViewController = .init()
var picker = CNContactPickerViewController()
#Binding var contact: CNContact?
// Possible take a binding
public init<Label: View>(contact: Binding<CNContact?>, onCancel: #escaping () -> Void, #ViewBuilder content: #escaping () -> Label) {
self._contact = contact
self.onCancel = onCancel
super.init()
let button = Button<Label>(action: showContactPicker, label: content)
let hostingController: UIHostingController<Button<Label>> = UIHostingController(rootView: button)
hostingController.view?.sizeToFit()
(hostingController.view?.frame).map {
hostingController.view!.widthAnchor.constraint(equalToConstant: $0.width).isActive = true
hostingController.view!.heightAnchor.constraint(equalToConstant: $0.height).isActive = true
viewController.preferredContentSize = $0.size
}
hostingController.willMove(toParent: viewController)
viewController.addChild(hostingController)
viewController.view.addSubview(hostingController.view)
hostingController.view.anchor(to: viewController.view)
picker.delegate = self
}
func showContactPicker() {
viewController.present(picker, animated: true)
}
public func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
onCancel()
}
public func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
self.contact = contact
}
func makeUIViewController() -> UIViewController {
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<ContactPickerButton>) {
}
}
#Binding var contact: CNContact?
#ViewBuilder
var content: () -> Label
var onCancel: () -> Void
public static func defaultContent() -> SwiftUI.Label<Text, Image> {
SwiftUI.Label("Select Contact", systemImage: "person.crop.circle.fill")
}
public init(contact: Binding<CNContact?>, onCancel: #escaping () -> () = {}, #ViewBuilder content: #escaping () -> Label) {
self._contact = contact
self.onCancel = onCancel
self.content = content
}
public func makeCoordinator() -> Coordinator {
.init(contact: $contact, onCancel: onCancel, content: content)
}
public func makeUIViewController(context: Context) -> UIViewController {
context.coordinator.makeUIViewController()
}
public func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
context.coordinator.updateUIViewController(uiViewController, context: context)
}
}
fileprivate extension UIView {
func anchor(to other: UIView) {
self.translatesAutoresizingMaskIntoConstraints = false
self.topAnchor.constraint(equalTo: other.topAnchor).isActive = true
self.bottomAnchor.constraint(equalTo: other.bottomAnchor).isActive = true
self.leadingAnchor.constraint(equalTo: other.leadingAnchor).isActive = true
self.trailingAnchor.constraint(equalTo: other.trailingAnchor).isActive = true
}
}
The #youjin solution have an issue when you use it inside a Sheet with navigationView.
For example, first I present an .sheet view, inside this sheet view I have and NavigationView as child, then, inside all this, I present the Contact Picker. For this scenario when Contact Picker dismiss, also dismiss my sheet view parent.
I added an #Environment(\.presentationMode) variable and I dismissed using the Coordinator approach. Look my solution here:
import SwiftUI
import ContactsUI
/**
Presents a CNContactPickerViewController view modally.
- Parameters:
- showPicker: Binding variable for presenting / dismissing the picker VC
- onSelectContact: Use this callback for single contact selection
- onSelectContacts: Use this callback for multiple contact selections
*/
public struct ContactPicker: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
#Binding var showPicker: Bool
#State private var viewModel = ContactPickerViewModel()
public var onSelectContact: ((_: CNContact) -> Void)?
public var onSelectContacts: ((_: [CNContact]) -> Void)?
public var onCancel: (() -> Void)?
public init(showPicker: Binding<Bool>, onSelectContact: ((_: CNContact) -> Void)? = nil, onSelectContacts: ((_: [CNContact]) -> Void)? = nil, onCancel: (() -> Void)? = nil) {
self._showPicker = showPicker
self.onSelectContact = onSelectContact
self.onSelectContacts = onSelectContacts
self.onCancel = onCancel
}
public func makeUIViewController(context: UIViewControllerRepresentableContext<ContactPicker>) -> ContactPicker.UIViewControllerType {
let dummy = _DummyViewController()
viewModel.dummy = dummy
return dummy
}
public func updateUIViewController(_ uiViewController: _DummyViewController, context: UIViewControllerRepresentableContext<ContactPicker>) {
guard viewModel.dummy != nil else {
return
}
// able to present when
// 1. no current presented view
// 2. current presented view is being dismissed
let ableToPresent = viewModel.dummy.presentedViewController == nil || viewModel.dummy.presentedViewController?.isBeingDismissed == true
// able to dismiss when
// 1. cncpvc is presented
let ableToDismiss = viewModel.vc != nil
if showPicker && viewModel.vc == nil && ableToPresent {
let pickerVC = CNContactPickerViewController()
pickerVC.delegate = context.coordinator
viewModel.vc = pickerVC
viewModel.dummy.present(pickerVC, animated: true)
} else if !showPicker && ableToDismiss {
// viewModel.dummy.dismiss(animated: true)
self.viewModel.vc = nil
}
}
public func makeCoordinator() -> CNContactPickerDelegate {
if self.onSelectContacts != nil {
return MultipleSelectionCoordinator(self)
} else {
return SingleSelectionCoordinator(self)
}
}
public final class SingleSelectionCoordinator: NSObject, CNContactPickerDelegate {
var parent : ContactPicker
init(_ parent: ContactPicker){
self.parent = parent
}
public func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
parent.showPicker = false
parent.onCancel?()
}
public func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
parent.showPicker = false
parent.onSelectContact?(contact)
}
}
public final class MultipleSelectionCoordinator: NSObject, CNContactPickerDelegate {
var parent : ContactPicker
init(_ parent: ContactPicker){
self.parent = parent
}
public func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
parent.showPicker = false
parent.onCancel?()
parent.presentationMode.wrappedValue.dismiss()
}
public func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) {
parent.showPicker = false
parent.onSelectContacts?(contacts)
parent.presentationMode.wrappedValue.dismiss()
}
}
}
class ContactPickerViewModel {
var dummy: _DummyViewController!
var vc: CNContactPickerViewController?
}
//Don't use it any more 😐
//public protocol Coordinator: CNContactPickerDelegate {}
public class _DummyViewController: UIViewController {}
UPDATE
We only replace the Coordinator protocol by the CNContactPickerDelegate, and this way we avoid the error that Xcode show us.
"Inheritance from non-protocol, non-class type 'ContactPicker.Coordinator' (aka 'any Coordinator')."
A similar workaround
Please see below for a similar workaround that perhaps offers more flexibility around the delegate and event handling.
import SwiftUI
import ContactsUI
/// `UIViewRepresentable` to port `CNContactPickerViewController` for use with SwiftUI.
struct ContactPicker: UIViewControllerRepresentable {
#Binding var delegate: ContactPickerDelegate
public var displayedPropertyKeys: [String]?
// Sadly, we need to present the `CNContactPickerViewController` from another `UIViewController`.
// This is due to a confirmed bug -- see https://openradar.appspot.com/7103187.
class Presenter: UIViewController {}
public var presenter = Presenter()
typealias UIViewControllerType = Presenter
func makeUIViewController(context: Context) -> UIViewControllerType {
let picker = CNContactPickerViewController()
picker.delegate = delegate
picker.displayedPropertyKeys = displayedPropertyKeys
presenter.present(picker, animated: true)
return presenter
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
if !delegate.showPicker {
presenter.dismiss(animated: true)
}
}
}
/// Delegate required by `ContactPicker` to handle `CNContactPickerViewController` events.
/// Extend `ContactPickerDelegate` and implement/override its methods to provide custom functionality as required.
/// Listen/subscribe to `showPicker` in a `View` or `UIViewController`, e.g. to control whether `CNContactPickerViewController` is presented.
class ContactPickerDelegate: NSObject, CNContactPickerDelegate, ObservableObject {
#Published var showPicker: Bool = false
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
self.showPicker = false
}
}
Example of usage in a SwiftUI View
import SwiftUI
import ContactsUI
struct ContactPickerView: View {
#ObservedObject var delegate = Delegate()
var body: some View {
VStack {
Text("Hi")
Button(action: {
delegate.showPicker = true
}, label: {
Text("Pick contact")
})
.sheet(isPresented: $delegate.showPicker, onDismiss: {
delegate.showPicker = false
}) {
ContactPicker(delegate: .constant(delegate))
}
if let contact = delegate.contact {
Text("Selected: \(contact.givenName)")
}
}
}
/// Provides `CNContactPickerDelegate` functionality tailored to this view's requirements.
class Delegate: ContactPickerDelegate {
#Published var contact: CNContact? = nil
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
print(contact.givenName)
self.contact = contact
self.showPicker = false
}
}
}
struct ContactPickerView_Previews: PreviewProvider {
static var previews: some View {
ContactPickerView()
}
}
Remarks
Unfortunately, this workaround suffers from the same issue where a blank white/gray screen (the additional UIViewController) is shown temporarily after the picker is dismissed.