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:)))
  }
  
}

1 Like

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
Terms of Service

Privacy Policy

Cookie Policy