What wiring code is necessary to wrap NSComboBox with NSViewRepresentable? - swiftui

I am trying to wrap NSComboBox with NSViewRepresentable for use in SwiftUI. I would like to pass in both the list of dropdown options and the text value of the combo box as bindings. I would like the text value binding to update on every keystroke, and on selection of one of the dropdown options. I would also like the text value/selection of the combo box to change if binding is changed externally.
Right now, I am not seeing my binding update on option selection, let alone every keystroke, as demonstrated by the SwiftUI preview at the bottom of the code.
My latest lead from reading old documentation is that maybe in an NSComboBox the selection value and the text value are two different properties and I've written this wrapping as if they are one and the same? Trying to run that down. For my purposes, they will be one and the same, or at least only the text value will matter: it is a form field for arbitrary user string input, that also has some preset strings.
Here is the code. I think this should be paste-able into a Mac-platform playground file:
import AppKit
import SwiftUI
public struct ComboBoxRepresentable: NSViewRepresentable {
private var options: Binding<[String]>
private var text: Binding<String>
public init(options: Binding<[String]>, text: Binding<String>) {
self.options = options
self.text = text
}
public func makeNSView(context: Context) -> NSComboBox {
let comboBox = NSComboBox()
comboBox.delegate = context.coordinator
comboBox.usesDataSource = true
comboBox.dataSource = context.coordinator
return comboBox
}
public func updateNSView(_ comboBox: NSComboBox, context: Context) {
comboBox.stringValue = text.wrappedValue
comboBox.reloadData()
}
}
public extension ComboBoxRepresentable {
final class Coordinator: NSObject {
var options: Binding<[String]>
var text: Binding<String>
init(options: Binding<[String]>, text: Binding<String>) {
self.options = options
self.text = text
}
}
func makeCoordinator() -> Coordinator {
Coordinator(options: options, text: text)
}
}
extension ComboBoxRepresentable.Coordinator: NSComboBoxDelegate {
public func comboBoxSelectionDidChange(_ notification: Notification) {
guard let comboBox = notification.object as? NSComboBox else { return }
text.wrappedValue = comboBox.stringValue
}
}
extension ComboBoxRepresentable.Coordinator: NSComboBoxDataSource {
public func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
guard options.wrappedValue.indices.contains(index) else { return nil }
return options.wrappedValue[index]
}
public func numberOfItems(in comboBox: NSComboBox) -> Int {
options.wrappedValue.count
}
}
#if DEBUG
struct ComboBoxRepresentablePreviewWrapper: View {
#State private var text = "four"
var body: some View {
VStack {
Text("selection: \(text)")
ComboBoxRepresentable(
options: .constant(["one", "two", "three"]),
text: $text
)
}
}
}
struct ComboBoxRepresentable_Previews: PreviewProvider {
#State private var text = ""
static var previews: some View {
ComboBoxRepresentablePreviewWrapper()
.frame(width: 200, height: 100)
}
}
#endif
Thank you in advance if you have any suggestions!

public struct ComboBoxRepresentable: NSViewRepresentable {
//If the options change the parent should be an #State or another source of truth if they don't change just remove the #Binding
#Binding private var options: [String]
#Binding private var text: String
public init(options: Binding<[String]>, text: Binding<String>) {
self._options = options
self._text = text
}
public func makeNSView(context: Context) -> NSComboBox {
let comboBox = NSComboBox()
comboBox.delegate = context.coordinator
comboBox.usesDataSource = true
comboBox.dataSource = context.coordinator
comboBox.stringValue = text
comboBox.reloadData()
return comboBox
}
public func updateNSView(_ comboBox: NSComboBox, context: Context) {
//You don't need anything here the delegate updates text and the combobox is already updated
}
}
public extension ComboBoxRepresentable {
final class Coordinator: NSObject {
//This is a much simpler init and injects the new values directly int he View vs losing properties in a class updates can be unreliable
var parent: ComboBoxRepresentable
init(_ parent: ComboBoxRepresentable) {
self.parent = parent
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
extension ComboBoxRepresentable.Coordinator: NSComboBoxDelegate {
public func comboBoxSelectionDidChange(_ notification: Notification) {
guard let comboBox = notification.object as? NSComboBox else { return }
//It is a known issue that this has to be ran async for it to have the current value
//https://stackoverflow.com/questions/5265260/comboboxselectiondidchange-gives-me-previously-selected-value
DispatchQueue.main.async {
self.parent.text = comboBox.stringValue
}
}
}
extension ComboBoxRepresentable.Coordinator: NSComboBoxDataSource {
public func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
guard parent.options.indices.contains(index) else { return nil }
return parent.options[index]
}
public func numberOfItems(in comboBox: NSComboBox) -> Int {
parent.options.count
}
}
#if DEBUG
struct ComboBoxRepresentablePreviewWrapper: View {
#State private var text = "four"
//If they dont update remove the #Binding
#State private var options = ["one", "two", "three"]
var body: some View {
VStack {
Text("selection: \(text)")
ComboBoxRepresentable(
options: $options,
text: $text
)
}
}
}
struct ComboBoxRepresentable_Previews: PreviewProvider {
#State private var text = ""
static var previews: some View {
ComboBoxRepresentablePreviewWrapper()
.frame(width: 200, height: 100)
}
}
#endif

Okay, I think I have come to a solution that satisfies the requirements I laid out in the question:
public struct ComboBoxRepresentable: NSViewRepresentable {
private let title: String
private var text: Binding<String>
private var options: Binding<[String]>
private var onEditingChanged: (Bool) -> Void
public init(
_ title: String,
text: Binding<String>,
options: Binding<[String]>,
onEditingChanged: #escaping (Bool) -> Void = { _ in }
) {
self.title = title
self.text = text
self.options = options
self.onEditingChanged = onEditingChanged
}
public func makeNSView(context: Context) -> NSComboBox {
let comboBox = NSComboBox()
comboBox.delegate = context.coordinator
comboBox.usesDataSource = true
comboBox.dataSource = context.coordinator
comboBox.placeholderString = title
comboBox.completes = true
return comboBox
}
public func updateNSView(_ comboBox: NSComboBox, context: Context) {
comboBox.stringValue = text.wrappedValue
comboBox.reloadData()
}
}
public extension ComboBoxRepresentable {
final class Coordinator: NSObject {
private var parent: ComboBoxRepresentable
init(parent: ComboBoxRepresentable) {
self.parent = parent
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
}
extension ComboBoxRepresentable.Coordinator: NSComboBoxDelegate {
public func comboBoxSelectionDidChange(_ notification: Notification) {
guard let comboBox = notification.object as? NSComboBox,
parent.options.wrappedValue.indices.contains(comboBox.indexOfSelectedItem) else { return }
parent.text.wrappedValue = parent.options.wrappedValue[comboBox.indexOfSelectedItem]
}
public func controlTextDidChange(_ notification: Notification) {
guard let comboBox = notification.object as? NSComboBox else { return }
parent.text.wrappedValue = comboBox.stringValue
}
public func controlTextDidBeginEditing(_ notification: Notification) {
parent.onEditingChanged(true)
}
public func controlTextDidEndEditing(_ notification: Notification) {
parent.onEditingChanged(false)
}
}
extension ComboBoxRepresentable.Coordinator: NSComboBoxDataSource {
public func comboBox(_ comboBox: NSComboBox, completedString string: String) -> String? {
parent.options.wrappedValue.first { $0.hasPrefix(string) }
}
public func comboBox(_ comboBox: NSComboBox, indexOfItemWithStringValue string: String) -> Int {
guard let index = parent.options.wrappedValue.firstIndex(of: string) else { return NSNotFound }
return index
}
public func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
guard parent.options.wrappedValue.indices.contains(index) else { return nil }
return parent.options.wrappedValue[index]
}
public func numberOfItems(in comboBox: NSComboBox) -> Int {
parent.options.wrappedValue.count
}
}
On the point about updating the bound value as the user types, to get that you have implement the parent NSTextField delegate method controlTextDidChange.
And then in comboBoxSelectionDidChange, you need to update the bound value from the bound options using the combo box's newly-selected index.

Related

Setting up .onCommit{} in UITextFieldViewRepresentable SwiftUI

I bridge UIKit with SwiftUI as follows:
struct UITextFieldViewRepresentable: UIViewRepresentable {
#Binding var language: String
#Binding var text: String
init(language: Binding<String>, text: Binding<String>) {
self._language = language
self._text = text
}
func makeUIView(context: Context) -> UITextField {
let textField = getTextField()
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
// Change the language in the wordTextField here.
if let wordTextField = uiView as? WordTextField {
wordTextField.language = self.language
}
}
private func getTextField() -> UITextField {
let textField = WordTextField(frame: .zero)
textField.language = self.language
textField.textAlignment = .center
textField.font = UIFont.systemFont(ofSize: 15, weight: .regular)
return textField
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
init(text: Binding<String>) {
self._text = text
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
}
class WordTextField: UITextField {
var language: String? {
didSet {
if self.isFirstResponder{
self.resignFirstResponder()
self.becomeFirstResponder()
}
}
}
override var textInputMode: UITextInputMode? {
if let language = self.language {
print("text input mode: \(language)")
for inputMode in UITextInputMode.activeInputModes {
if let inputModeLanguage = inputMode.primaryLanguage, inputModeLanguage == language {
return inputMode
}
}
}
return super.textInputMode
}
}
}
And call it as follows:
UITextFieldViewRepresentable(language: $keyboardLanguage, text: $foreignThing)
This works fine in some parts of my app. In other parts, I need a text field which calls a method when the user taps the enter key after entering text. It's written like this:
TextField("", text: Binding<String>(
get: { self.userAnswer },
set: {
self.userAnswer = $0
self.enableHint()
}), onCommit: {
if self.userAnswer.isEmpty {
answerPlaceholder = NSMutableAttributedString(string: "Tap here to answer...")
} else {
answerDisabled = true
checkAnswer()
}
})
I tried implementing the above with UITextFieldViewRepresenatable as follows:
UITextFieldViewRepresentable(language: $keyboardLanguage, text: Binding<String>(
get: { self.userAnswer },
set: {
self.userAnswer = $0
self.enableHint()
}), onCommit: {
if self.userAnswer.isEmpty {
answerPlaceholder = NSMutableAttributedString(string: "Tap here to answer...")
} else {
answerDisabled = true
checkAnswer()
}
})
I'm getting 'compiler couldn't type check this expression in reasonable time' error. I think I've narrowed it down to not implementing .onCommit:{} in my UITextFieldViewRepresentable()
If this is the problem, then I'd like to know how .onCommit:{} can be implemented in UITextFieldViewRepresentable().
There are a few mistakes in the UIViewRepresentable implementation:
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
This text binding will be out of date when the View is recreated, so change it to:
func makeCoordinator() -> Coordinator {
return Coordinator()
}
You can store the textField inside the coordinator, make it a lazy, set delegate self, then return it from the make func, eg..
func makeUIView(context: Context) -> UITextField {
context.coordinator.textField // lazy property that sets delegate self.
}
In update, you need to make use of the new value of the binding, e.g.
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
// Change the language in the wordTextField here.
if let wordTextField = uiView as? WordTextField {
wordTextField.language = self.language
}
// use the new binding
context.coordinator.textDidChange = { newText in
text = newText
}
}
class Coordinator: NSObject, UITextFieldDelegate {
lazy var textField: UITextField = {
let textField = UITextField()
textField.delegate = self
return textField
}()
var textDidChange: ((String) -> Void)?
func textFieldDidChangeSelection(_ textField: UITextField) {
textDidChange?(textField.text)
}
}
You'll see something similar in PaymentButton.swift in Apple's Fruta sample.
Perhaps a simpler way would be this (but I've not tested it yet):
// update
context.coordinator.textBinding = _text
// Coordinator
var textBinding: Binding<String>?
// textViewDidChange
textBinding?.wrappedValue = textField.text

SwiftUI: In SplitView, how can I detect if the master view is visible?

When SwiftUI creates a SplitView, it adds a toolbar button that hides/shows the Master view. How can I detect this change so that I can resize the font in the detail screen and use all the space optimally?
I've tried using .onChange with geometry but can't seem to get that to work.
If you're using iOS 16 you can use NavigationSplitView with NavigationSplitViewVisibility
Example:
struct MySplitView: View {
#State private var columnVisibility: NavigationSplitViewVisibility = .all
var bothAreShown: Bool { columnVisibility != .detailOnly }
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Text("Master Column")
} detail: {
Text("Detail Column")
Text(bothAreShown ? "Both are shown" : "Just detail shown")
}
}
}
After thinkering for a while on this I got to this solution:
struct ContentView: View {
#State var isOpen = true
var body: some View {
NavigationView {
VStack{
Text("Primary")
.onUIKitAppear {
isOpen.toggle()
}
.onAppear{
print("hello")
isOpen.toggle()
}
.onDisappear{
isOpen.toggle()
print("hello: bye")
}
.navigationTitle("options")
}
Text("Secondary").font(isOpen ? .body : .title)
}.navigationViewStyle(.columns)
}
}
The onUIKitAppear is a custom extension suggested by apple to be only executed once the view has been presented to the user https://developer.apple.com/forums/thread/655338?page=2
struct UIKitAppear: UIViewControllerRepresentable {
let action: () -> Void
func makeUIViewController(context: Context) -> UIAppearViewController {
let vc = UIAppearViewController()
vc.delegate = context.coordinator
return vc
}
func makeCoordinator() -> Coordinator {
Coordinator(action: self.action)
}
func updateUIViewController(_ controller: UIAppearViewController, context: Context) {}
class Coordinator: ActionRepresentable {
var action: () -> Void
init(action: #escaping () -> Void) {
self.action = action
}
func remoteAction() {
action()
}
}
}
protocol ActionRepresentable: AnyObject {
func remoteAction()
}
class UIAppearViewController: UIViewController {
weak var delegate: ActionRepresentable?
var savedView: UIView?
override func viewDidLoad() {
self.savedView = UILabel()
if let _view = self.savedView {
view.addSubview(_view)
}
}
override func viewDidAppear(_ animated: Bool) {
delegate?.remoteAction()
}
override func viewDidDisappear(_ animated: Bool) {
view.removeFromSuperview()
savedView?.removeFromSuperview()
}
}
public extension View {
func onUIKitAppear(_ perform: #escaping () -> Void) -> some View {
self.background(UIKitAppear(action: perform))
}
}

UISearchController how to activate from SwiftUI

I found an integration of the uiSearchController in SwiftUI, but I don't know how to let it become active?
I found this:
I want that the searchBar becomes active when changing an Bool in the SwiftUI View with a #State for example.
If I add a Binding to the view modifier and set the isActive property of the searchController in
ViewControllerResolver { viewController in
viewController.navigationItem.searchController = self.searchBar.searchController
viewController.navigationItem.hidesSearchBarWhenScrolling = false
}
then is doesn't become active.
Im not really familiar with UIKit, perhaps anybody knows how to correctly activate the searchbar that one can start typing for a search.
class SearchBar: NSObject, ObservableObject {
#Published var text: String = ""
let searchController: UISearchController = UISearchController(searchResultsController: nil)
override init() {
super.init()
self.searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchResultsUpdater = self
}
}
extension SearchBar: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
// Publish search bar text changes.
if let searchBarText = searchController.searchBar.text {
self.text = searchBarText
}
}
}
final class ViewControllerResolver: UIViewControllerRepresentable {
let onResolve: (UIViewController) -> Void
init(onResolve: #escaping (UIViewController) -> Void) {
self.onResolve = onResolve
}
func makeUIViewController(context: Context) -> ParentResolverViewController {
ParentResolverViewController(onResolve: onResolve)
}
func updateUIViewController(_ uiViewController: ParentResolverViewController, context: Context) {
}
}
class ParentResolverViewController: UIViewController {
let onResolve: (UIViewController) -> Void
init(onResolve: #escaping (UIViewController) -> Void) {
self.onResolve = onResolve
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("Use init(onResolve:) to instantiate ParentResolverViewController.")
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent {
onResolve(parent)
}
}
}
struct SearchBarModifier: ViewModifier {
let searchBar: SearchBar
func body(content: Content) -> some View {
content
.overlay(
ViewControllerResolver { viewController in
viewController.navigationItem.searchController = self.searchBar.searchController
viewController.navigationItem.hidesSearchBarWhenScrolling = false
}
.frame(width: 0, height: 0)
)
}
}
extension View {
func add(_ searchBar: SearchBar) -> some View {
return self.modifier(SearchBarModifier(searchBar: searchBar))
}
}
To activate a UISearchBar (which is what you're using), just do:
searchController.searchBar.becomeFirstResponder()
(from this answer)
Now all we need to do is reference searchController.searchBar from the SwiftUI view. First, add a function to your SearchBar class.
class SearchBar: NSObject, ObservableObject {
#Published var text: String = ""
let searchController: UISearchController = UISearchController(searchResultsController: nil)
override init() {
super.init()
self.searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchResultsUpdater = self
}
/// add this function
func activate() {
searchController.searchBar.becomeFirstResponder()
}
}
Then, just call it. I think this is better than setting a #State, but if you require that, let me know and I'll edit my answer.
struct ContentView: View {
#StateObject var searchBar = SearchBar()
var body: some View {
NavigationView {
Button(action: {
searchBar.activate() /// activate the search bar
}) {
Text("Activate search bar")
}
.modifier(SearchBarModifier(searchBar: searchBar))
.navigationTitle("Navigation View")
}
}
}
Result:

Custom UITextField wrapped in UIViewRepresentable interfering with ObservableObject in NavigationView

Context
I have created a UIViewRepresentable to wrap a UITextField so that:
it can be set it to become the first responder when the view loads.
the next textfield can be set to become the first responder when enter is pressed
Problem
When used inside a NavigationView, unless the keyboard is dismissed from previous views, the view doesn't observe the value in their ObservedObject.
Question
Why is this happening? What can I do to fix this behaviour?
Screenshots
Keyboard from root view not dismissed:
Keyboard from root view dismissed:
Code
Here is the said UIViewRepresentable
struct SimplifiedFocusableTextField: UIViewRepresentable {
#Binding var text: String
private var isResponder: Binding<Bool>?
private var placeholder: String
private var tag: Int
public init(
_ placeholder: String = "",
text: Binding<String>,
isResponder: Binding<Bool>? = nil,
tag: Int = 0
) {
self._text = text
self.placeholder = placeholder
self.isResponder = isResponder
self.tag = tag
}
func makeUIView(context: UIViewRepresentableContext<SimplifiedFocusableTextField>) -> UITextField {
// create textfield
let textField = UITextField()
// set delegate
textField.delegate = context.coordinator
// configure textfield
textField.placeholder = placeholder
textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.tag = self.tag
// return
return textField
}
func makeCoordinator() -> SimplifiedFocusableTextField.Coordinator {
return Coordinator(text: $text, isResponder: self.isResponder)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<SimplifiedFocusableTextField>) {
// update text
uiView.text = text
// set first responder ONCE
if self.isResponder?.wrappedValue == true && !uiView.isFirstResponder && !context.coordinator.didBecomeFirstResponder{
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
private var isResponder: Binding<Bool>?
var didBecomeFirstResponder = false
init(text: Binding<String>, isResponder: Binding<Bool>?) {
_text = text
self.isResponder = isResponder
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = true
}
}
func textFieldDidEndEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = false
}
}
}
}
And to reproduce, here is the contentView:
struct ContentView: View {
var body: some View {
return NavigationView { FieldView(tag: 0) }
}
}
and here's the view with the field and its view model
struct FieldView: View {
#ObservedObject private var viewModel = FieldViewModel()
#State private var focus = false
var tag: Int
var body: some View {
return VStack {
// listen to viewModel's value
Text(viewModel.value)
// text field
SimplifiedFocusableTextField("placeholder", text: self.$viewModel.value, isResponder: $focus, tag: self.tag)
// push to stack
NavigationLink(destination: FieldView(tag: self.tag + 1)) {
Text("Continue")
}
// dummy for tapping to dismiss keyboard
Color.green
}
.onAppear {
self.focus = true
}.dismissKeyboardOnTap()
}
}
public extension View {
func dismissKeyboardOnTap() -> some View {
modifier(DismissKeyboardOnTap())
}
}
public struct DismissKeyboardOnTap: ViewModifier {
public func body(content: Content) -> some View {
return content.gesture(tapGesture)
}
private var tapGesture: some Gesture {
TapGesture().onEnded(endEditing)
}
private func endEditing() {
UIApplication.shared.connectedScenes
.filter {$0.activationState == .foregroundActive}
.map {$0 as? UIWindowScene}
.compactMap({$0})
.first?.windows
.filter {$0.isKeyWindow}
.first?.endEditing(true)
}
}
class FieldViewModel: ObservableObject {
var subscriptions = Set<AnyCancellable>()
// diplays
#Published var value = ""
}
It looks like SwiftUI rendering engine again over-optimized...
Here is fixed part - just make destination unique forcefully using .id. Tested with Xcode 11.4 / iOS 13.4
NavigationLink(destination: FieldView(tag: self.tag + 1).id(UUID())) {
Text("Continue")
}

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.