TCA - Wrapping UIViewControllers in SwiftUI Views - Example

This is how to wrap UIViewController within SwiftUI's UIViewControllerRepresentable in TCA, along with Binding

import ContactsUI
import Contacts

struct ContactView: UIViewControllerRepresentable {

  let store: Store<CNContact, ContactAction>
  @ObservedObject var viewStore: ViewStore<CNContact, ContactAction>
  
  init(store: Store<CNContact, ContactAction>) {
    self.store = store
    self.viewStore = ViewStore(self.store)
  }
  
  func makeUIViewController(context: Context) -> CNContactViewController {
    let cvc = CNContactViewController(for: viewStore.state)
    cvc.delegate = context.coordinator
    return cvc
  }
  
  func updateUIViewController(_ uiViewController:CNContactViewController, context: Context) {
    return
  }
    
  class Coordinator: NSObject, CNContactViewControllerDelegate {
    var contact: Binding<CNContact>
    
    init(contact: Binding<CNContact>) {
      self.contact = contact
      // super.init() implicitly called here - Advanced Swift by ObjcIO is Awesome as well
    }
    
    func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) {
      contact.apply { (newVal) -> Void in
        self.contact.wrappedValue = newVal
      }
    }
  }
  
  func makeCoordinator() -> Coordinator {
    return .init(contact: viewStore.binding(send: ContactAction.edited(newValue:) ))
// Using both Get & Set here results in an 2 way unnecessary binding which freezes the screen. Which is why we have the send only overload
//    return .init(contact: viewStore.binding(get: { $0.value }, send: ContactAction.edited(newValue:)))
  }
  
}

2 Likes

Very nice! Thank you for sharing.

What are your thoughts on passing the store directly to the coordinator?

I don't know the exact details of the CNContactViewController's API, however, I'm doing something similar, for a MFMailComposeViewController:

public class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
    
    @Binding var presentation: PresentationMode
    
    let store: Store<MailComposerState, MailComposerAction>
    let viewStore: ViewStore<MailComposerState, MailComposerAction>
    
    init(
        store: Store<MailComposerState, MailComposerAction>,
        presentation: Binding<PresentationMode>)
    {
        self.store = store
        self.viewStore = ViewStore(self.store)
        _presentation = presentation
    }
    
    public func mailComposeController(
        _ controller: MFMailComposeViewController,
        didFinishWith result: MFMailComposeResult,
        error: Error?)
    {
        defer { $presentation.wrappedValue.dismiss() }
        guard error == nil else {
            viewStore.send(.mailComposeResult(.failure(MailComposerError())))
            return
        }
        viewStore.send(.mailComposeResult(.success(result)))
    }
}
1 Like

Thank you

It adopts the Delegate pattern,

For the Target-Action pattern, one would configure the addTarget in the makeUIViewController method like such

func makeVC(context) -> VC {
  let button = UIButton()
  button.addTarget(context.coordinator, #selector(handleTap), for: .touchUpInside)
  return button
}
@objc func handleTap(_ button: UIButton) {
// send actions to viewStore here
}

I learnt this from SwiftUI by Tutorials book from RW. The Coordinator helps in communication between UIViewController & SwiftUI.View

Regarding your approach, you have 2 binding variables, presentation and MailComposerAction
Notice, you aren't reading the MailComposerState object here, because Coordinator helps with communication

How about having a Property in your State of type MFMailComposeResult, Error or Result<MFMailComposeResult, Error> and setting it, and then triggerring from an outer body?

If your method works, without memory leaks, well and good

maybe @stephencelis can help

1 Like

Both do seem to work, but I ended up liking your way best, and use a binding, and not pass the store to the coordinator at all.

Thanks :slight_smile:

I'll leave it here for reference:

enum MailError: Error, Equatable {
  case queueingEmail
}

struct MailComposerState: Equatable {
  var subject: String = ""
  var messageBody: String = ""
  var toRecipients: [String] = []
  var result: Result<MFMailComposeResult, MailError>? = nil
}

enum MailComposerAction: Equatable {
  case mailComposeResult(Result<MFMailComposeResult, MailError>?)
}

struct MailComposerEnvironment {}

let mailComposerReducer = Reducer<MailComposerState, MailComposerAction, MailComposerEnvironment>
{ state, action, _ in
  switch action {
  case let .mailComposeResult(result):
    state.result = result
    return .none
  }
}

struct MailComposerView: UIViewControllerRepresentable {
  
  @Environment(\.presentationMode) private var presentationMode
  
  let store: Store<MailComposerState, MailComposerAction>
  let viewStore: ViewStore<MailComposerState, MailComposerAction>
  
  init(store: Store<MailComposerState, MailComposerAction>) {
    self.store = store
    self.viewStore = ViewStore(self.store)
  }
  
  func makeCoordinator() -> Coordinator {
    return Coordinator(
      presentation: presentationMode,
      result: viewStore.binding(
        get: { $0.result },
        send: MailComposerAction.mailComposeResult
      )
    )
  }
  
  func makeUIViewController(
    context: UIViewControllerRepresentableContext<MailComposerView>)
    -> MFMailComposeViewController
  {
    let vc = MFMailComposeViewController(with: viewStore.state)
    vc.mailComposeDelegate = context.coordinator
    return vc
  }
  
  func updateUIViewController(
    _ uiViewController: MFMailComposeViewController,
    context: UIViewControllerRepresentableContext<MailComposerView>)
  {
    
  }
}

class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
  
  let presentation: Binding<PresentationMode>
  let result: Binding<Result<MFMailComposeResult, MailError>?>
  
  init(
    presentation: Binding<PresentationMode>,
    result: Binding<Result<MFMailComposeResult, MailError>?>)
  {
    self.presentation = presentation
    self.result = result
  }
  
  func mailComposeController(
    _ controller: MFMailComposeViewController,
    didFinishWith result: MFMailComposeResult,
    error: Error?)
  {
    defer { presentation.wrappedValue.dismiss() }
    guard error == nil else {
      self.result.wrappedValue = .failure(.queueingEmail)
      return
    }
    self.result.wrappedValue = .success(result)
  }
}

extension MFMailComposeViewController {
  convenience init(with state: MailComposerState) {
    self.init()
    setSubject(state.subject)
    setMessageBody(state.messageBody, isHTML: false)
    setToRecipients(state.toRecipients)
  }
}

And to call it:

var body: some View {
  WithViewStore(self.store) { viewStore in
    NavigationView {
      List {
        Button(action: {
          viewStore.send(.mailButtonTapped)
        }) {
          Text("Mail")
        }.disabled(viewStore.isMailButtonDisabled)
      }
      .sheet(
        isPresented: viewStore.binding(
          get: { $0.isMailComposerSheetPresented },
          send: MoreAction.setMailComposerSheet)
      ) {
        IfLetStore(self.store.scope(
          state: { $0.mailState },
          action: SomeAction.mailView ),
          then: MailComposerView.init(store:)
        )
      }
    }
  }
}
1 Like

That's cool!

One edit I'd like to propose in your mailComposeController delegate function

func mailComposeController(_, result: MFMailComposeResult, error: Error?) {
  defer { presentation.wrappedValue.dismiss() }  
  let swiftResult: Result<MFMailComposeResult, Error> = error != nil ? .failure(error!) : .success(result)
  self.result.wrappedValue = swiftResult
}

This target-action pattern lives in the Coordinator

The reason I didn't use that exact signature is because my State requires an Equatable error. So I have a:

struct QueueingMailError: Error, Equatable {}

About that Button's target action handling, I don't see a use case for me there :thinking: The mail composer after being presented is out of my hands, in terms of interaction. All I care then is knowing when it was dismissed, and what was the result of that dismissal.

Edit:

Technically, Error could even be ignored altogether. If it's not nil, MFMailComposeResult will show failed anyway.

Perhaps forwarding along the actual error, even as a string, for debugging reasons:

let swiftResult: Result<MFMailComposeResult, MailError> = error != nil ?
  .failure(.queueingEmail(error!.localizedDescription)) : .success(result)
2 Likes

I stumbled across this wanting to make something similar. Here's what I ended up with using the new "Dependencies" structure along with the Reducer Protocol. Note that some of the delegate callbacks are commented out. Apparently, you should only register with the one callback you want to receive in order for it to fire. Mine is set up to just get an address from the contact, but could be trivially modified to get an entire contact record(s) or some property(ies).

import ComposableArchitecture
import Contacts
import ContactsUI
import SwiftUI
import Dependencies
#if canImport(UIKit)
import UIKit
#endif

public enum ContactPickerError: Error {
  case cancelled
}

public struct ContactPicker: ReducerProtocol {
  public enum ContactResult: Equatable {
    case contact(CNContact)
    case contacts([CNContact])
    case property(CNContactProperty)
    case properties([CNContactProperty])
  }
  
  public struct State: Equatable {
    public init(
      result: Result<ContactPicker.ContactResult, ContactPickerError>? = nil
    ) {
      self.result = result
    }
    
    @BindableState var result: Result<ContactResult, ContactPickerError>? = nil
  }
  
  public enum Action: Equatable, BindableAction {
    case binding(BindingAction<State>)
  }
  
  public var body: some ReducerProtocol<State, Action> {
    BindingReducer()
  }
}

struct ContactPickerView: UIViewControllerRepresentable {
  @Environment(\.presentationMode) private var presentationMode

  func makeCoordinator() -> Coordinator {
    .init(
      presentation: presentationMode,
      result: viewStore.binding(\.$result)
    )
  }
  
  let store: StoreOf<ContactPicker>
  let viewStore: ViewStoreOf<ContactPicker>
  
  init(store: StoreOf<ContactPicker>) {
    self.store = store
    self.viewStore = ViewStore(store)
  }
  
  func makeUIViewController(
    context: Context
  ) -> CNContactPickerViewController {
    let cvc = CNContactPickerViewController()
    cvc.displayedPropertyKeys = [CNContactPostalAddressesKey]
    cvc.predicateForSelectionOfContact = NSPredicate(
      format: "postalAddresses.@count <= 1"
    )
    cvc.modalPresentationStyle = .currentContext
    cvc.delegate = context.coordinator
    return cvc
  }
    
  func updateUIViewController(
    _ uiViewController: CNContactPickerViewController,
    context: Context)
  {}
  
  class Coordinator: NSObject, CNContactPickerDelegate {
    var presentation: Binding<PresentationMode>
    var result: Binding<Result<ContactPicker.ContactResult, ContactPickerError>?>
    
    init(
      presentation: Binding<PresentationMode>,
      result: Binding<Result<ContactPicker.ContactResult, ContactPickerError>?>)
    {
      self.presentation = presentation
      self.result = result
    }
    
//    func contactPicker(
//      _ picker: CNContactPickerViewController,
//      didSelectContactProperties contactProperties: [CNContactProperty]
//    ) {
//      self.result.wrappedValue = .success(.properties(contactProperties))
//    }
    
//    func contactPicker(
//      _ picker: CNContactPickerViewController,
//      didSelect contact: CNContact
//    ) {
//      self.result.wrappedValue = .success(.contact(contact))
//    }

    func contactPicker(
      _ picker: CNContactPickerViewController,
      didSelect contactProperty: CNContactProperty
    ) {
      self.result.wrappedValue = .success(.property(contactProperty))
    }

//    func contactPicker(
//      _ picker: CNContactPickerViewController,
//      didSelect contacts: [CNContact]
//    ) {
//      self.result.wrappedValue = .success(.contacts(contacts))
//    }

    func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
      self.result.wrappedValue = .failure(.cancelled)
    }
  }
}