UIViewControllerRepresentable and CNContactPickerViewController

8.6k Views Asked by At

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.

5

There are 5 best solutions below

8
On BEST ANSWER

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:

  1. You can use ABPeoplePickerNavigationController which is deprecated but still works.
  2. 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>) { }

}
4
On

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')."

1
On

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)
    }
}
0
On

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.

0
On
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
    }
}