# Representable Recipes

Nine wrappers for UIKit surfaces that SwiftUI apps still reach for. Every recipe
gives the representable itself, its Coordinator and the delegate methods that
matter, a short SwiftUI call site, and the traps specific to that wrapper. Many
recipes end with a "Prefer native" note: when a SwiftUI equivalent exists for
your deployment target, start there and only wrap when you need what it lacks.

Web content is deliberately absent. On iOS 26 and later the native WebKit for
SwiftUI types replace a wrapped web view; see the `swiftui-webkit` skill. This
file sticks to general representable technique.

All recipes assume a deployment target of iOS 16 or later, so `sizeThatFits`
needs no availability annotation here. Each coordinator keeps a `parent`
reference and each update method starts by refreshing it with
`context.coordinator.parent = self`, so callbacks always write through the
current bindings and closures.

## Contents

1. [Map](#1-mkmapview-wrapper)
2. [Attributed text view](#2-uitextview-wrapper-attributed-text)
3. [Camera preview](#3-avcapturevideopreviewlayer-wrapper)
4. [Photo picker](#4-phpickerviewcontroller-wrapper)
5. [Mail composer](#5-mfmailcomposeviewcontroller-wrapper)
6. [Share sheet](#6-uiactivityviewcontroller-wrapper-share-sheet)
7. [Search bar](#7-uisearchbar-wrapper)
8. [PDF viewer](#8-pdfview-wrapper-pdfkit)
9. [Text message composer](#9-mfmessagecomposeviewcontroller-wrapper)

## 1. MKMapView Wrapper

```swift
import SwiftUI
import MapKit

struct TrailMapView: UIViewRepresentable {
    @Binding var visibleArea: MKCoordinateRegion
    @Binding var baseMap: MKMapType
    var pins: [MKPointAnnotation]
    var onAreaChanged: ((MKCoordinateRegion) -> Void)?

    private static let centerTolerance = 0.0001

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIView(context: Context) -> MKMapView {
        let map = MKMapView(frame: .zero)
        map.showsUserLocation = true
        map.delegate = context.coordinator
        return map
    }

    func updateUIView(_ map: MKMapView, context: Context) {
        context.coordinator.parent = self
        if map.mapType != baseMap { map.mapType = baseMap }

        let shown = map.region.center
        let wanted = visibleArea.center
        if abs(shown.latitude - wanted.latitude) > Self.centerTolerance
            || abs(shown.longitude - wanted.longitude) > Self.centerTolerance {
            map.setRegion(visibleArea, animated: true)
        }

        let onMap = Set(map.annotations.compactMap { item in item as? MKPointAnnotation })
        let requested = Set(pins)
        map.removeAnnotations(Array(onMap.subtracting(requested)))
        map.addAnnotations(Array(requested.subtracting(onMap)))
    }

    @MainActor
    final class Coordinator: NSObject, MKMapViewDelegate {
        init(parent: TrailMapView) { self.parent = parent }
        var parent: TrailMapView

        func mapView(_ map: MKMapView, regionDidChangeAnimated isAnimated: Bool) {
            parent.visibleArea = map.region
            parent.onAreaChanged?(map.region)
        }

        func mapView(_ map: MKMapView, viewFor marker: MKAnnotation) -> MKAnnotationView? {
            guard !(marker is MKUserLocation) else { return nil }
            let reuseID = "trailhead"
            guard let recycled = map.dequeueReusableAnnotationView(withIdentifier: reuseID) else {
                return MKMarkerAnnotationView(annotation: marker, reuseIdentifier: reuseID)
            }
            recycled.annotation = marker
            return recycled
        }
    }
}
```

Call site:

```swift
struct TrailScreen: View {
    @State private var area = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 46.55, longitude: 7.98),
        span: MKCoordinateSpan(latitudeDelta: 0.08, longitudeDelta: 0.08)
    )
    @State private var style = MKMapType.hybrid

    var body: some View {
        TrailMapView(visibleArea: $area, baseMap: $style, pins: [])
            .ignoresSafeArea()
    }
}
```

Pitfalls:

- The region can loop: the delegate writes the binding, update calls
  `setRegion`, which fires the delegate again. The tolerance check on the
  center coordinate is what stops it; do not remove it.
- `MKMapView` copes poorly with the same annotation added twice. Diff with sets
  before adding or removing.
- Prefer native: from iOS 17 the SwiftUI `Map` covers most needs. Keep this
  wrapper for delegate-level control such as custom overlays or clustering.

## 2. UITextView Wrapper (Attributed Text)

```swift
struct RichNoteEditor: UIViewRepresentable {
    @Binding var content: NSAttributedString
    @Binding var isFocused: Bool
    var hint: String

    fileprivate static let hintTag = 999
    private static let verticalInset: CGFloat = 8
    private static let horizontalInset: CGFloat = 4
    private static let minimumHeight: CGFloat = 44

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIView(context: Context) -> UITextView {
        let editor = UITextView(frame: .zero)
        editor.delegate = context.coordinator
        editor.backgroundColor = .clear
        editor.adjustsFontForContentSizeCategory = true
        editor.textContainerInset = UIEdgeInsets(top: Self.verticalInset,
                                                 left: Self.horizontalInset,
                                                 bottom: Self.verticalInset,
                                                 right: Self.horizontalInset)

        let hintLabel = UILabel()
        hintLabel.tag = Self.hintTag
        hintLabel.text = hint
        hintLabel.font = .preferredFont(forTextStyle: .body)
        hintLabel.textColor = .placeholderText
        hintLabel.translatesAutoresizingMaskIntoConstraints = false
        editor.addSubview(hintLabel)
        NSLayoutConstraint.activate([
            hintLabel.leadingAnchor.constraint(equalTo: editor.leadingAnchor, constant: 8),
            hintLabel.topAnchor.constraint(equalTo: editor.topAnchor, constant: 8)
        ])
        return editor
    }

    func updateUIView(_ editor: UITextView, context: Context) {
        context.coordinator.parent = self
        if editor.attributedText != content {
            editor.attributedText = content
        }
        editor.viewWithTag(Self.hintTag)?.isHidden = editor.hasText

        switch (isFocused, editor.isFirstResponder) {
        case (true, false): editor.becomeFirstResponder()
        case (false, true): editor.resignFirstResponder()
        default: break
        }
    }

    func sizeThatFits(_ proposal: ProposedViewSize,
                      uiView editor: UITextView,
                      context: Context) -> CGSize? {
        let targetWidth = proposal.width ?? UIView.layoutFittingExpandedSize.width
        let targetHeight = max(proposal.height ?? Self.minimumHeight, Self.minimumHeight)
        return CGSize(width: targetWidth, height: targetHeight)
    }

    @MainActor
    final class Coordinator: NSObject, UITextViewDelegate {
        init(parent: RichNoteEditor) { self.parent = parent }
        var parent: RichNoteEditor

        func textViewDidChange(_ editor: UITextView) {
            parent.content = editor.attributedText ?? NSAttributedString()
            editor.viewWithTag(RichNoteEditor.hintTag)?.isHidden = editor.hasText
        }

        func textViewDidBeginEditing(_ editor: UITextView) {
            parent.isFocused = true
        }

        func textViewDidEndEditing(_ editor: UITextView) {
            parent.isFocused = false
        }
    }
}
```

Call site: `RichNoteEditor(content: $note, isFocused: $editing, hint: "Add a note").frame(minHeight: 100)`.

Pitfalls:

- Drop the `NSAttributedString` comparison and every keystroke starts a render
  loop.
- Calling `becomeFirstResponder()` unconditionally in update pulls focus away
  from whatever field the user is typing in; only change focus when the binding
  and the actual state disagree.
- Prefer native: on iOS 26 `TextEditor` edits `AttributedString` directly. Wrap
  only when you need `NSAttributedString` or delegate hooks.

## 3. AVCaptureVideoPreviewLayer Wrapper

The preview layer needs a view to live in. Making the layer the view's backing
layer is simpler than adding a sublayer.

```swift
import AVFoundation

final class CapturePreviewUIView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        captureLayer.frame = bounds
    }

    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }

    var captureLayer: AVCaptureVideoPreviewLayer {
        layer as! AVCaptureVideoPreviewLayer
    }
}

struct CapturePreview: UIViewRepresentable {
    let captureSession: AVCaptureSession

    func makeUIView(context: Context) -> CapturePreviewUIView {
        let preview = CapturePreviewUIView(frame: .zero)
        preview.captureLayer.videoGravity = .resizeAspectFill
        preview.captureLayer.session = captureSession
        return preview
    }

    func updateUIView(_ preview: CapturePreviewUIView, context: Context) {
        guard preview.captureLayer.session !== captureSession else { return }
        preview.captureLayer.session = captureSession
    }
}
```

Call site:

```swift
struct ReceiptCameraScreen: View {
    @State private var camera = ReceiptCamera()

    var body: some View {
        CapturePreview(captureSession: camera.session)
            .ignoresSafeArea()
            .task { await camera.start() }
    }
}
```

Notes:

- The `layerClass` override means no extra sublayer, and the layer resizes with
  the view automatically. The forced cast is safe because `layerClass`
  guarantees the type.
- Build and run the `AVCaptureSession` in a separate model object (here
  `ReceiptCamera`). The representable only shows it.
- For rotation, set `captureLayer.connection?.videoRotationAngle`.

## 4. PHPickerViewController Wrapper

```swift
import PhotosUI

struct GalleryPicker: UIViewControllerRepresentable {
    @Environment(\.dismiss) var close
    @Binding var picked: [UIImage]
    var maxSelection: Int

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var options = PHPickerConfiguration(photoLibrary: .shared())
        options.selectionLimit = maxSelection
        options.preferredAssetRepresentationMode = .current
        options.filter = .images
        let sheet = PHPickerViewController(configuration: options)
        sheet.delegate = context.coordinator
        return sheet
    }

    func updateUIViewController(_ sheet: PHPickerViewController, context: Context) {
        context.coordinator.parent = self
    }

    @MainActor
    final class Coordinator: NSObject, PHPickerViewControllerDelegate {
        init(parent: GalleryPicker) { self.parent = parent }
        var parent: GalleryPicker

        func picker(_ sheet: PHPickerViewController, didFinishPicking picks: [PHPickerResult]) {
            parent.close()
            if picks.isEmpty { return }
            let providers = picks.map(\.itemProvider)
            Task { @MainActor in
                var images: [UIImage] = []
                for provider in providers {
                    if let image = await Self.loadImage(provider) { images.append(image) }
                }
                self.parent.picked = images
            }
        }

        private static func loadImage(_ provider: NSItemProvider) async -> UIImage? {
            guard provider.canLoadObject(ofClass: UIImage.self) else { return nil }
            return await withCheckedContinuation { continuation in
                provider.loadObject(ofClass: UIImage.self) { object, _ in
                    continuation.resume(returning: object as? UIImage)
                }
            }
        }
    }
}
```

A `selectionLimit` of 0 means no limit. There is nothing to push in the update
method beyond the parent refresh, because a picker's configuration is fixed
once the controller exists.

Call site:

```swift
.sheet(isPresented: $showingPicker) {
    GalleryPicker(picked: $attachments, maxSelection: 5)
}

// thumbnail grid cell
Image(uiImage: image)
    .resizable()
    .scaledToFill()
    .frame(width: 96, height: 96)
    .clipShape(.rect(cornerRadius: 10))
```

Pitfalls:

- `didFinishPicking` fires for a selection and for a cancel; a cancel delivers
  an empty array. Dismiss in both cases.
- Load images after dismissing so the picker does not stall.
- Prefer native: `PhotosPicker` from PhotosUI (iOS 17 guidance) covers ordinary
  picking. Reach for `PHPickerViewController` when you need your own picker
  presentation or filters the native control lacks.

## 5. MFMailComposeViewController Wrapper

```swift
import MessageUI

struct SupportMailView: UIViewControllerRepresentable {
    @Environment(\.dismiss) var close
    var subject: String
    var recipients: [String]
    var message: String
    var completion: (MFMailComposeResult) -> Void

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIViewController(context: Context) -> MFMailComposeViewController {
        let composer = MFMailComposeViewController()
        composer.setToRecipients(recipients)
        composer.setSubject(subject)
        composer.setMessageBody(message, isHTML: false)
        composer.mailComposeDelegate = context.coordinator
        return composer
    }

    func updateUIViewController(_ composer: MFMailComposeViewController, context: Context) {
        context.coordinator.parent = self
    }

    @MainActor
    final class Coordinator: NSObject, @preconcurrency MFMailComposeViewControllerDelegate {
        init(parent: SupportMailView) { self.parent = parent }
        var parent: SupportMailView

        func mailComposeController(_ composer: MFMailComposeViewController,
                                   didFinishWith outcome: MFMailComposeResult,
                                   error failure: Error?) {
            parent.completion(outcome)
            parent.close()
        }
    }
}
```

Present it only after `MFMailComposeViewController.canSendMail()` returns true:

```swift
Button("Contact support") {
    showingMail = MFMailComposeViewController.canSendMail()
}
.sheet(isPresented: $showingMail) {
    SupportMailView(subject: "Order help", recipients: ["help@example.com"],
                    message: "", completion: { _ in })
}
```

Notes:

- Presenting the composer on a device without a configured mail account
  crashes. Always check `canSendMail()` first.
- Subject, recipients and body are fixed once the composer is on screen, so
  the update method does nothing beyond refreshing `parent`.
- The delegate protocol is `MFMailComposeViewControllerDelegate`. A type named
  `MFMailComposeDelegate` does not exist, and code that uses it will not
  compile.

## 6. UIActivityViewController Wrapper (Share Sheet)

The share sheet is a view controller, so it needs
`UIViewControllerRepresentable`.

```swift
struct ActivitySheet: UIViewControllerRepresentable {
    var items: [Any]
    var extraActivities: [UIActivity]? = nil
    var hiddenTypes: [UIActivity.ActivityType] = []

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let sheet = UIActivityViewController(activityItems: items,
                                             applicationActivities: extraActivities)
        sheet.excludedActivityTypes = hiddenTypes
        return sheet
    }

    func updateUIViewController(_ sheet: UIActivityViewController, context: Context) {}
}
```

Call site:

```swift
.sheet(isPresented: $sharing) {
    ActivitySheet(items: [exportURL], hiddenTypes: [.assignToContact])
        .presentationDetents([.medium])
}
```

Notes:

- Present it with `.sheet`. It does not work as an inline view.
- On iPad, presenting outside a `.sheet` requires setting the
  `popoverPresentationController` source view and rect. `.sheet` takes care of
  that for you.
- Prefer native: from iOS 16 `ShareLink` shares `Transferable` items with no
  wrapper for the simple cases.

## 7. UISearchBar Wrapper

```swift
struct DirectorySearchBar: UIViewRepresentable {
    @Binding var query: String
    var prompt: String
    var onSearch: (String) -> Void
    var onClear: () -> Void

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIView(context: Context) -> UISearchBar {
        let field = UISearchBar(frame: .zero)
        field.placeholder = prompt
        field.searchBarStyle = .minimal
        field.autocapitalizationType = .none
        field.delegate = context.coordinator
        return field
    }

    func updateUIView(_ field: UISearchBar, context: Context) {
        context.coordinator.parent = self
        if field.text != query { field.text = query }
    }

    @MainActor
    final class Coordinator: NSObject, UISearchBarDelegate {
        init(parent: DirectorySearchBar) { self.parent = parent }
        var parent: DirectorySearchBar

        private var pending: Task<Void, Never>?
        private static let quietPeriod: Duration = .milliseconds(300)

        func searchBar(_ field: UISearchBar, textDidChange typed: String) {
            parent.query = typed
            field.setShowsCancelButton(!typed.isEmpty, animated: true)
            pending?.cancel()
            pending = Task { @MainActor [weak self] in
                do {
                    try await Task.sleep(for: Coordinator.quietPeriod)
                } catch {
                    return
                }
                guard !Task.isCancelled else { return }
                self?.parent.onSearch(typed)
            }
        }

        func searchBarSearchButtonClicked(_ field: UISearchBar) {
            pending?.cancel()
            parent.onSearch(field.text ?? "")
            field.resignFirstResponder()
        }

        func searchBarCancelButtonClicked(_ field: UISearchBar) {
            parent.query = ""
            parent.onClear()
            field.resignFirstResponder()
            field.setShowsCancelButton(false, animated: true)
        }
    }
}
```

Notes:

- The debounce is a stored `Task` that is cancelled on each keystroke and
  sleeps 300 ms before searching. No Combine is needed; `Task.sleep` plus a
  cancellation check does the job.
- The search button cancels any pending debounce, searches at once and drops
  the keyboard. The cancel button clears the query, reports it, drops the
  keyboard and hides itself.
- Show and hide the cancel button from the delegate, not from `updateUIView`,
  or the bar jumps during layout.
- Prefer native: `.searchable(text:)` for standard search. Wrap `UISearchBar`
  only when you need exact appearance control or delegate timing.

## 8. PDFView Wrapper (PDFKit)

`PDFView` is a `UIView`, so this is a `UIViewRepresentable`.

```swift
import PDFKit

struct ManualPDFView: UIViewRepresentable {
    let file: PDFDocument?
    @Binding var pageIndex: Int
    var layout: PDFDisplayMode = .singlePageContinuous
    var direction: PDFDisplayDirection = .vertical
    var fitsWidth = true
    var showsPageShadows = true

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIView(context: Context) -> PDFView {
        let viewer = PDFView(frame: .zero)
        viewer.document = file
        viewer.displayMode = layout
        viewer.displayDirection = direction
        viewer.autoScales = fitsWidth
        viewer.pageShadowsEnabled = showsPageShadows

        let center = NotificationCenter.default
        center.addObserver(context.coordinator,
                           selector: #selector(Coordinator.pageDidChange(_:)),
                           name: .PDFViewPageChanged,
                           object: viewer)
        return viewer
    }

    func updateUIView(_ viewer: PDFView, context: Context) {
        context.coordinator.parent = self
        if viewer.document !== file { viewer.document = file }
        if viewer.displayMode != layout { viewer.displayMode = layout }
        if viewer.autoScales != fitsWidth { viewer.autoScales = fitsWidth }

        guard let pdf = viewer.document,
              let target = pdf.page(at: pageIndex),
              viewer.currentPage != target else { return }
        viewer.go(to: target)
    }

    static func dismantleUIView(_ viewer: PDFView, coordinator: Coordinator) {
        NotificationCenter.default.removeObserver(coordinator,
                                                  name: .PDFViewPageChanged,
                                                  object: viewer)
    }

    @MainActor
    final class Coordinator: NSObject {
        init(parent: ManualPDFView) { self.parent = parent }
        var parent: ManualPDFView

        @objc func pageDidChange(_ note: Notification) {
            guard let viewer = note.object as? PDFView,
                  let pdf = viewer.document,
                  let page = viewer.currentPage else { return }
            let position = pdf.index(for: page)
            if position != parent.pageIndex { parent.pageIndex = position }
        }
    }
}
```

`PDFDocument(url:)` and `PDFDocument(data:)` create documents from a file URL
or from bytes. For remote files, load asynchronously:

```swift
struct RemoteManualScreen: View {
    let source: URL
    @State private var loaded: PDFDocument?
    @State private var loadFailed = false
    @State private var page = 0

    var body: some View {
        ZStack {
            if let loaded {
                ManualPDFView(file: loaded, pageIndex: $page)
            } else if loadFailed {
                ContentUnavailableView("Manual unavailable",
                                       systemImage: "doc.questionmark")
            } else {
                ProgressView()
            }
        }
        .task {
            do {
                let (bytes, _) = try await URLSession.shared.data(from: source)
                loaded = PDFDocument(data: bytes)
            } catch {
                loadFailed = true
            }
        }
    }
}
```

`ContentUnavailableView` needs iOS 17; on an iOS 16 target show a `Label` or
`Text` there instead.

Notes:

- `PDFView` has no delegate callback for page changes. Observe
  `.PDFViewPageChanged` instead, and remove the observer in `dismantleUIView`;
  a leftover observer can crash after the view is gone.
- `autoScales` fits the page width. Turn it off when the document should open
  at a zoom factor you choose.
- Building a `PDFDocument` can be expensive. Load it off the main path and
  assign it on the main actor.
- Apple: [PDFView](https://developer.apple.com/documentation/pdfkit/pdfview),
  [PDFKit](https://developer.apple.com/documentation/pdfkit)

## 9. MFMessageComposeViewController Wrapper

This is the text-message counterpart to the mail wrapper in
[recipe 5](#5-mfmailcomposeviewcontroller-wrapper), and it follows the same
structure.

```swift
import MessageUI
import UniformTypeIdentifiers

struct OutgoingAttachment {
    let payload: Data
    let uti: String
    let name: String
}

struct InviteMessageView: UIViewControllerRepresentable {
    @Environment(\.dismiss) var close
    var recipients: [String]
    var text: String
    var attachments: [OutgoingAttachment] = []
    var completion: (MessageComposeResult) -> Void

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIViewController(context: Context) -> MFMessageComposeViewController {
        let composer = MFMessageComposeViewController()
        composer.recipients = recipients
        composer.body = text
        composer.messageComposeDelegate = context.coordinator
        guard MFMessageComposeViewController.canSendAttachments() else { return composer }
        for attachment in attachments {
            composer.addAttachmentData(attachment.payload,
                                       typeIdentifier: attachment.uti,
                                       filename: attachment.name)
        }
        return composer
    }

    func updateUIViewController(_ composer: MFMessageComposeViewController, context: Context) {
        context.coordinator.parent = self
    }

    @MainActor
    final class Coordinator: NSObject, @preconcurrency MFMessageComposeViewControllerDelegate {
        init(parent: InviteMessageView) { self.parent = parent }
        var parent: InviteMessageView

        func messageComposeViewController(_ composer: MFMessageComposeViewController,
                                          didFinishWith outcome: MessageComposeResult) {
            switch outcome {
            case .sent, .cancelled, .failed:
                parent.completion(outcome)
            @unknown default:
                parent.completion(outcome)
            }
            parent.close()
        }
    }
}
```

Attaching a photo: `photo.jpegData(compressionQuality: 0.8)` supplies the
bytes, `UTType.jpeg.identifier` (the string `public.jpeg`) the type, and a name
such as `invite.jpg` the filename.

Notes:

- Check `MFMessageComposeViewController.canSendText()` before presenting; if it
  is false, presenting crashes. Check `canSendAttachments()` before adding
  attachments, because MMS support varies by device and carrier.
- `MFMessageComposeViewControllerDelegate` has one required method,
  `messageComposeViewController(_:didFinishWith:)`.
- As with mail in recipe 5, the fields cannot be edited after presentation.
- The system chooses iMessage when it is available. The app cannot force SMS or
  iMessage.
- `canSendText()` returns false in the Simulator, so test on a device.
- Apple: [MFMessageComposeViewController](https://developer.apple.com/documentation/messageui/mfmessagecomposeviewcontroller),
  [MFMessageComposeViewControllerDelegate](https://developer.apple.com/documentation/messageui/mfmessagecomposeviewcontrollerdelegate)
