iOS Development · SwiftUI · UIKit

Bridging UIKit into SwiftUI with UIViewRepresentable

A PKAddPassButton case study — wrapping Apple's Wallet button for use in a SwiftUI hierarchy

← Back to blog

Why UIViewRepresentable Exists

SwiftUI covers the vast majority of UI components you'll need in a modern iOS app. But Apple's own frameworks — PassKit, MapKit, WebKit, and others — still expose views exclusively through UIKit. When you need one of those views inside a SwiftUI hierarchy, UIViewRepresentable is the bridge.

SwiftUI and UIKit have fundamentally different mental models. SwiftUI describes what the UI should look like given the current state. UIKit manages views as long-lived objects that you mutate imperatively. UIViewRepresentable lets you wrap a UIKit view so SwiftUI can treat it as a first-class view — creating it, updating it, and tearing it down in sync with the SwiftUI lifecycle.

The Protocol

protocol UIViewRepresentable {
    associatedtype UIViewType: UIView
    func makeUIView(context: Context) -> UIViewType
    func updateUIView(_ uiView: UIViewType, context: Context)
    func makeCoordinator() -> Coordinator  // optional
}

Three responsibilities:

  • makeUIView — called once to create the UIKit view. Do your one-time setup here: allocate, configure, wire up targets.
  • updateUIView — called every time SwiftUI re-renders the wrapping struct. Sync any state from SwiftUI into the UIKit view here. If nothing needs syncing, leave it empty.
  • makeCoordinator — returns a helper object that acts as the UIKit delegate or target/action receiver, bridging UIKit callbacks back into SwiftUI.

The Coordinator Pattern

The coordinator is the piece that trips people up most. UIKit's event system is delegate- and target/action-based — it needs an NSObject to call methods on. Your SwiftUI struct can't fill that role (it's a value type and gets recreated constantly). The coordinator is a stable reference type that lives alongside the view and acts as the UIKit-facing receiver.

SwiftUI struct (recreated on state change) │ └── Coordinator (stable NSObject, lives for view's lifetime) │ └── Calls back into SwiftUI via closures/bindings

Wrapping PKAddPassButton

PKAddPassButton is a UIKit-only button from PassKit that renders Apple's standard "Add to Wallet" styling — not something you can replicate with a SwiftUI Button. Here's how to wrap it:

import SwiftUI
import PassKit
struct AddToWalletButton: UIViewRepresentable {
    let onTap: () -> Void
    func makeUIView(context: Context) -> PKAddPassButton {
        let button = PKAddPassButton(addPassButtonStyle: .black)
        button.addTarget(
            context.coordinator,
            action: #selector(Coordinator.tapped),
            for: .touchUpInside
        )
        return button
    }
    func updateUIView(_ uiView: PKAddPassButton, context: Context) {}
    func makeCoordinator() -> Coordinator {
        Coordinator(onTap: onTap)
    }
    class Coordinator: NSObject {
        let onTap: () -> Void
        init(onTap: @escaping () -> Void) {
            self.onTap = onTap
        }
        @objc func tapped() {
            onTap()
        }
    }
}

Breaking down each part:

makeUIView — creates the PKAddPassButton with a style (.black renders the standard black wallet button Apple recommends). Wires up the tap using UIKit's target/action pattern, pointing at the coordinator.

updateUIView — empty here because the button has no dynamic state. If you wanted to change the style based on SwiftUI state, you'd update uiView here.

makeCoordinator — returns the Coordinator, passing in the onTap closure. SwiftUI calls this before makeUIView, so context.coordinator is already available when you wire the target.

Coordinator — a plain NSObject subclass. The @objc attribute on tapped() is required for UIKit's target/action system to see the method via the Objective-C runtime.

Using It in a SwiftUI View

struct CredentialView: View {
    var onAddToWallet: () -> Void
    var body: some View {
        VStack {
            // ... other content
            AddToWalletButton(onTap: onAddToWallet)
                .frame(width: 127, height: 40)
        }
    }
}

The .frame modifier controls the size — PKAddPassButton scales to fill whatever frame you give it. Apple's recommended dimensions are 127×40pt for the standard button.

Key Rules to Remember

1. makeUIView is called once — updateUIView is called many times.
Heavy setup (delegates, subviews, targets) belongs in makeUIView. updateUIView should be fast and idempotent — it's called on every SwiftUI render cycle.

2. Never store the UIView in a property on the struct.
The struct is recreated on every render. Store state in @State, bindings, or the coordinator instead.

3. The coordinator owns UIKit callbacks.
Any delegate conformance or target/action method that UIKit needs to call belongs on the coordinator, not the struct.

4. updateUIView receives the same UIView instance every time.
SwiftUI reuses the UIKit view — it doesn't tear it down and recreate it on every render. That's why you mutate uiView directly in updateUIView rather than creating a new one.

When to Reach for UIViewRepresentable

Use it when you need:

  • A UIKit view with no SwiftUI equivalent (PassKit, MapKit, WKWebView, ARKit)
  • Fine-grained UIKit behaviour that SwiftUI doesn't expose (specific scroll view delegate callbacks, custom drawing)
  • Third-party UIKit SDKs

For everything else, prefer native SwiftUI components. UIViewRepresentable carries a small overhead and adds conceptual complexity — it's the right tool for bridging, not a general-purpose escape hatch.

Wrapping Up

UIViewRepresentable follows a clear contract: create once in makeUIView, sync state in updateUIView, and delegate UIKit callbacks through the coordinator. Once you internalise that pattern, bridging any UIKit view into SwiftUI becomes mechanical.

PKAddPassButton is a clean example because it has no dynamic state to sync — the implementation reduces to just the creation and the callback bridge, which makes the structure easy to see.