Files
stackdex_neu/.claude/skills/axiom-uikit-bridging/SKILL.md
Matthias a60a76b797 Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
2026-04-19 21:11:32 +02:00

773 lines
31 KiB
Markdown

---
name: axiom-uikit-bridging
description: Use when wrapping UIKit views/controllers in SwiftUI, embedding SwiftUI in UIKit, or debugging UIKit-SwiftUI interop issues. Covers UIViewRepresentable, UIViewControllerRepresentable, UIHostingController, UIHostingConfiguration, coordinators, lifecycle, state binding, memory management.
license: MIT
---
# UIKit-SwiftUI Bridging
Systematic guidance for bridging UIKit and SwiftUI. Most production iOS apps need both — this skill teaches the bridging patterns themselves, not the domain-specific views being bridged.
## Decision Framework
```dot
digraph bridge {
start [label="What are you bridging?" shape=diamond];
start -> "UIViewRepresentable" [label="UIView subclass → SwiftUI"];
start -> "UIViewControllerRepresentable" [label="UIViewController → SwiftUI"];
start -> "UIGestureRecognizerRepresentable" [label="UIGestureRecognizer → SwiftUI\n(iOS 18+)"];
start -> "UIHostingController" [label="SwiftUI view → UIKit"];
start -> "UIHostingConfiguration" [label="SwiftUI in UIKit cell\n(iOS 16+)"];
"UIViewRepresentable" [shape=box];
"UIViewControllerRepresentable" [shape=box];
"UIGestureRecognizerRepresentable" [shape=box];
"UIHostingController" [shape=box];
"UIHostingConfiguration" [shape=box];
}
```
**Quick rules:**
- Wrapping a `UIView``UIViewRepresentable` (Part 1)
- Wrapping a `UIViewController``UIViewControllerRepresentable` (Part 2)
- Wrapping a `UIGestureRecognizer` subclass → `UIGestureRecognizerRepresentable` (Part 2b, iOS 18+)
- Embedding SwiftUI in UIKit navigation → `UIHostingController` (Part 3)
- SwiftUI in UICollectionView/UITableView cells → `UIHostingConfiguration` (Part 3)
- Sharing state between UIKit and SwiftUI → `@Observable` shared model (Part 4)
---
# Part 1: UIViewRepresentable — Wrapping UIViews
Use when you have a `UIView` subclass (MKMapView, WKWebView, custom drawing views) and need it in SwiftUI.
> For comprehensive MapKit patterns and the SwiftUI Map vs MKMapView decision, see `axiom-mapkit`.
## Lifecycle
```
makeUIView(context:) → Called ONCE. Create and configure the view.
updateUIView(_:context:) → Called on EVERY SwiftUI state change. Patch, don't recreate.
dismantleUIView(_:coordinator:) → Called when removed from hierarchy. Clean up observers/timers.
```
**Critical**: `updateUIView` is called frequently. Guard against unnecessary work:
```swift
struct MapView: UIViewRepresentable {
let region: MKCoordinateRegion
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
return map
}
func updateUIView(_ map: MKMapView, context: Context) {
// Guard: only update if region actually changed
if map.region.center.latitude != region.center.latitude
|| map.region.center.longitude != region.center.longitude {
map.setRegion(region, animated: true)
}
}
static func dismantleUIView(_ map: MKMapView, coordinator: Coordinator) {
map.removeAnnotations(map.annotations)
}
}
```
## State Synchronization
State flows in two directions across the bridge:
**SwiftUI → UIKit**: Via `updateUIView`. SwiftUI state changes trigger this method.
**UIKit → SwiftUI**: Via the Coordinator, using `@Binding` on the parent struct.
```swift
struct SearchField: UIViewRepresentable {
@Binding var text: String
@Binding var isEditing: Bool
func makeUIView(context: Context) -> UISearchBar {
let bar = UISearchBar()
bar.delegate = context.coordinator
return bar
}
func updateUIView(_ bar: UISearchBar, context: Context) {
bar.text = text // SwiftUI UIKit
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchField
init(_ parent: SearchField) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText // UIKit SwiftUI
}
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
parent.isEditing = true // UIKit SwiftUI
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
parent.isEditing = false
}
}
}
```
## Layout Property Warning
SwiftUI owns the layout of representable views. **Never modify `center`, `bounds`, `frame`, or `transform`** on the wrapped UIView — this is undefined behavior per Apple documentation. SwiftUI sets these properties during its layout pass. If you need custom sizing, override `intrinsicContentSize` on the UIView or use `sizeThatFits(_:)`.
## Coordinator Pattern
The Coordinator is a reference type (`class`) that:
1. Acts as the delegate/data source for the UIKit view
2. Holds a reference to the parent `UIViewRepresentable` struct
3. Bridges UIKit callbacks back to SwiftUI `@Binding` properties
`makeCoordinator()` is **optional** — omit it when the UIKit view needs no delegate callbacks or UIKit→SwiftUI communication (e.g., a static display-only view).
**Why not closures?** Closures capture `self` and create retain cycles. The Coordinator pattern gives you a stable reference type that SwiftUI manages.
```swift
// Closure-based: retain cycle risk, no delegate protocol support
func makeUIView(context: Context) -> UITextField {
let field = UITextField()
field.addTarget(self, action: #selector(textChanged), for: .editingChanged) // Won't compile self is a struct
return field
}
// Coordinator: clean lifecycle, delegate support
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UITextFieldDelegate {
var parent: SearchField
init(_ parent: SearchField) { self.parent = parent }
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
}
```
## Sizing
UIViewRepresentable views participate in SwiftUI layout. Control sizing with:
```swift
// If the UIView has intrinsicContentSize, SwiftUI respects it
// For views without intrinsic size (MKMapView, WKWebView), set a frame:
MapView(region: region)
.frame(height: 300)
// For views that should size to fit their content:
WrappedLabel(text: "Hello")
.fixedSize() // Uses intrinsicContentSize
```
Override `sizeThatFits(_:)` for custom size proposals:
```swift
struct WrappedLabel: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.numberOfLines = 0
return label
}
func updateUIView(_ label: UILabel, context: Context) {
label.text = text
}
// Custom size proposal SwiftUI calls this during layout
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? {
let width = proposal.width ?? UIView.layoutFittingCompressedSize.width
return uiView.systemLayoutSizeFitting(
CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
}
}
```
## Scroll-Tracking Navigation Bars (iOS 15+)
When wrapping a UIScrollView subclass, tell the navigation bar which scroll view to track for large title collapse:
```swift
func makeUIView(context: Context) -> UITableView {
let table = UITableView()
return table
}
func updateUIView(_ table: UITableView, context: Context) {
// Tell the nearest navigation controller to track this scroll view
// for inline/large title transitions
if let navController = sequence(first: table as UIResponder, next: \.next)
.compactMap({ $0 as? UINavigationController }).first {
navController.navigationBar.setContentScrollView(table, forEdge: .top)
}
}
```
Without this, navigation bar large titles won't collapse when scrolling a wrapped UIScrollView.
## Animation Bridging
Use `context.transaction.animation` to bridge SwiftUI animations into UIKit:
```swift
func updateUIView(_ uiView: UIView, context: Context) {
if context.transaction.animation != nil {
UIView.animate(withDuration: 0.3) {
uiView.alpha = isVisible ? 1 : 0
}
} else {
uiView.alpha = isVisible ? 1 : 0
}
}
```
**iOS 18+ animation unification**: SwiftUI animations can be applied directly to UIKit views via `UIView.animate(_:)`. However, be aware of incompatibilities:
- SwiftUI animations are **NOT backed by CAAnimation** — they use a different rendering path
- **Incompatible with** `UIViewPropertyAnimator` and `UIView` keyframe animations
- **Velocity retargeting**: Re-targeted SwiftUI animations carry forward velocity from interrupted animations, creating fluid transitions
For comprehensive animation bridging patterns, see `/skill axiom-swiftui-animation-ref` Part 10.
---
# Part 2: UIViewControllerRepresentable — Wrapping UIViewControllers
Use when wrapping a full `UIViewController` — pickers, mail compose, Safari, camera, or any controller that manages its own view hierarchy.
## Lifecycle
```
makeUIViewController(context:) → Called ONCE. Create and configure.
updateUIViewController(_:context:) → Called on SwiftUI state changes.
dismantleUIViewController(_:coordinator:) → Cleanup.
```
## Canonical Example: PHPickerViewController
```swift
struct PhotoPicker: UIViewControllerRepresentable {
@Binding var selectedImages: [UIImage]
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.selectionLimit = 5
config.filter = .images
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: PHPickerViewController, context: Context) {
// PHPicker doesn't support updates after creation
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, PHPickerViewControllerDelegate {
var parent: PhotoPicker
init(_ parent: PhotoPicker) { self.parent = parent }
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
parent.selectedImages = []
for result in results {
result.itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
if let image = image as? UIImage {
DispatchQueue.main.async {
self.parent.selectedImages.append(image)
}
}
}
}
parent.dismiss()
}
}
}
```
## When the Controller Presents Its Own UI
Some controllers (UIImagePickerController, MFMailComposeViewController, SFSafariViewController) present their own full-screen UI. Handle dismissal through the coordinator:
```swift
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
var parent: MailComposer
func mailComposeController(_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult, error: Error?) {
parent.dismiss() // Let SwiftUI handle the dismissal
}
}
```
**Don't** call `controller.dismiss(animated:)` directly from the coordinator — let SwiftUI's `@Environment(\.dismiss)` or the binding that controls presentation handle it.
## Presentation Context
The wrapped controller doesn't automatically inherit SwiftUI's navigation context. If you need the controller to push onto a navigation stack, you need UIViewControllerRepresentable inside a NavigationStack, and the controller needs access to the navigation controller:
```swift
// This won't push the controller has no navigationController
struct WrappedVC: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> MyViewController {
let vc = MyViewController()
vc.navigationController?.pushViewController(otherVC, animated: true) // nil
return vc
}
}
// Present modally instead, or use UIHostingController in a UIKit navigation flow
.sheet(isPresented: $showPicker) {
PhotoPicker(selectedImages: $images)
}
```
---
# Part 2b: UIGestureRecognizerRepresentable (iOS 18+)
Use when you need a UIKit gesture recognizer in SwiftUI — for gestures that SwiftUI's native gesture API doesn't support (custom subclasses, precise UIKit gesture state machine, hit testing control).
**Pre-iOS 18 fallback**: Attach the gesture recognizer to a transparent `UIView` wrapped with `UIViewRepresentable`, using the Coordinator as the target/action receiver (see Part 1 Coordinator Pattern). You lose `CoordinateSpaceConverter` but can use the recognizer's `location(in:)` directly.
## Lifecycle
```
makeUIGestureRecognizer(context:) → Called ONCE. Create the recognizer.
handleUIGestureRecognizerAction(_:context:) → Called when the gesture is recognized.
updateUIGestureRecognizer(_:context:) → Called on SwiftUI state changes.
makeCoordinator(converter:) → Optional. Create coordinator for state.
```
**No manual target/action** — the system manages action target installation. Implement `handleUIGestureRecognizerAction` instead.
## Canonical Example: Long Press with Location
```swift
struct LongPressGesture: UIGestureRecognizerRepresentable {
@Binding var pressLocation: CGPoint?
func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
let recognizer = UILongPressGestureRecognizer()
recognizer.minimumPressDuration = 0.5
return recognizer
}
func handleUIGestureRecognizerAction(
_ recognizer: UILongPressGestureRecognizer, context: Context
) {
switch recognizer.state {
case .began:
// localLocation converts UIKit coordinates to SwiftUI coordinate space
pressLocation = context.converter.localLocation
case .ended, .cancelled:
pressLocation = nil
default:
break
}
}
}
// Usage
struct ContentView: View {
@State private var pressLocation: CGPoint?
var body: some View {
Rectangle()
.gesture(LongPressGesture(pressLocation: $pressLocation))
}
}
```
## CoordinateSpaceConverter
The `context.converter` bridges UIKit gesture coordinates into SwiftUI coordinate spaces:
| Property/Method | Description |
|-----------------|-------------|
| `localLocation` | Gesture position in the attached SwiftUI view's space |
| `localTranslation` | Gesture movement in local space |
| `localVelocity` | Gesture velocity in local space |
| `location(in:)` | Transform location to an ancestor coordinate space |
| `translation(in:)` | Transform translation to an ancestor space |
| `velocity(in:)` | Transform velocity to an ancestor space |
## When to Use This vs SwiftUI Gestures
| Need | Use |
|------|-----|
| Standard tap, drag, long press, rotation, magnification | SwiftUI native gestures |
| Custom `UIGestureRecognizer` subclass | `UIGestureRecognizerRepresentable` |
| Precise control over gesture state machine (`.possible`, `.began`, `.changed`, etc.) | `UIGestureRecognizerRepresentable` |
| Gesture that requires `delegate` methods for failure requirements or simultaneous recognition | `UIGestureRecognizerRepresentable` with a Coordinator |
| Coordinate space conversion between UIKit and SwiftUI | `UIGestureRecognizerRepresentable` (converter is built-in) |
---
# Part 3: UIHostingController — SwiftUI Inside UIKit
Use when embedding SwiftUI views in an existing UIKit navigation hierarchy.
## Basic Embedding
```swift
// Push onto UIKit navigation stack
let profileView = ProfileView(user: user)
let hostingController = UIHostingController(rootView: profileView)
navigationController?.pushViewController(hostingController, animated: true)
// Present modally
let settingsView = SettingsView()
let hostingController = UIHostingController(rootView: settingsView)
hostingController.modalPresentationStyle = .pageSheet
present(hostingController, animated: true)
```
## Child View Controller Embedding
When embedding as a child VC (e.g., a SwiftUI card inside a UIKit layout):
```swift
let swiftUIView = StatusCard(status: currentStatus)
let hostingController = UIHostingController(rootView: swiftUIView)
hostingController.sizingOptions = .intrinsicContentSize // iOS 16+
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: headerView.bottomAnchor)
])
hostingController.didMove(toParent: self)
```
**`sizingOptions: .intrinsicContentSize`** (iOS 16+) makes the hosting controller report its SwiftUI content size to Auto Layout. Without this, the hosting controller's view has no intrinsic size and relies entirely on constraints.
**`sizingOptions` cases** (iOS 16+, `OptionSet`):
- `.intrinsicContentSize` — auto-invalidates intrinsic content size when SwiftUI content changes
- `.preferredContentSize` — tracks content's ideal size in the controller's `preferredContentSize`
## Explicit Size Queries
Use `sizeThatFits(in:)` to calculate the SwiftUI content's preferred size for Auto Layout integration:
```swift
let hostingController = UIHostingController(rootView: CompactCard(item: item))
// Query preferred size for a given width constraint
let fittingSize = hostingController.sizeThatFits(in: CGSize(width: 320, height: .infinity))
// Returns the optimal CGSize for the SwiftUI content
```
This is useful when you need the hosting controller's size before adding it to the view hierarchy, or when embedding in contexts where `sizingOptions` alone isn't sufficient (e.g., manually sizing popover content).
## Environment Bridging
Standard system environment values (`colorScheme`, `sizeCategory`, `locale`) bridge automatically through the UIKit trait system. Custom `@Environment` keys from a parent SwiftUI view do NOT — unless you use `UITraitBridgedEnvironmentKey`.
**Option 1: Inject explicitly** (simplest, works on all versions):
```swift
let view = DetailView(store: appStore, theme: currentTheme)
let hostingController = UIHostingController(rootView: view)
```
**Option 2: UITraitBridgedEnvironmentKey** (iOS 17+, bidirectional bridging):
Bridge custom environment values between UIKit traits and SwiftUI environment:
```swift
// 1. Define a UIKit trait
struct FeatureOneTrait: UITraitDefinition {
static let defaultValue = false
}
extension UIMutableTraits {
var featureOne: Bool {
get { self[FeatureOneTrait.self] }
set { self[FeatureOneTrait.self] = newValue }
}
}
// 2. Define a SwiftUI EnvironmentKey
struct FeatureOneKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var featureOne: Bool {
get { self[FeatureOneKey.self] }
set { self[FeatureOneKey.self] = newValue }
}
}
// 3. Bridge them
extension FeatureOneKey: UITraitBridgedEnvironmentKey {
static func read(from traitCollection: UITraitCollection) -> Bool {
traitCollection[FeatureOneTrait.self]
}
static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
mutableTraits.featureOne = value
}
}
```
Now `@Environment(\.featureOne)` automatically syncs in both directions — UIKit `traitOverrides` update SwiftUI views, and SwiftUI `.environment(\.featureOne, true)` updates UIKit views.
To push values from UIKit into hosted SwiftUI content:
```swift
// In any UIKit view controller flows down to UIHostingController children
viewController.traitOverrides.featureOne = true
```
## UIHostingConfiguration (iOS 16+)
Use SwiftUI views as UICollectionView or UITableView cells:
```swift
cell.contentConfiguration = UIHostingConfiguration {
HStack {
Image(systemName: item.icon)
.foregroundStyle(.tint)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.margins(.all, EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.minSize(width: nil, height: 44) // Minimum tap target height
.background(.quaternarySystemFill) // ShapeStyle background
```
**Cell clipping?** UIHostingConfiguration cells self-size. If cells are clipped, the collection view layout likely uses fixed `itemSize` — switch to `estimated` dimensions in your compositional layout so cells can grow to fit the SwiftUI content.
#### Advantages over full UIHostingController
- No child view controller management
- Automatic cell sizing
- Self-sizing invalidation on state change
- Compatible with diffable data sources
#### When to use UIHostingConfiguration vs UIHostingController
| Scenario | Use |
|----------|-----|
| Cell content in UICollectionView/UITableView | UIHostingConfiguration |
| Full screen or navigation destination | UIHostingController |
| Child VC in a layout | UIHostingController |
| Overlay or decoration | UIHostingConfiguration in a supplementary view |
## Scroll-Tracking for Navigation Bars
When a UIHostingController contains a scroll view and is pushed onto a UINavigationController, large title collapse may not work. Use `setContentScrollView`:
```swift
let hostingController = UIHostingController(rootView: ScrollableListView())
// After pushing, tell the nav bar to track the scroll view
if let scrollView = hostingController.view.subviews.compactMap({ $0 as? UIScrollView }).first {
navigationController?.navigationBar.setContentScrollView(scrollView, forEdge: .top)
}
```
This is a common issue when embedding SwiftUI `List` or `ScrollView` in UIKit navigation.
## Keyboard Handling in Hybrid Layouts
When mixing UIKit and SwiftUI, keyboard avoidance may not work automatically. Use `UIKeyboardLayoutGuide` (iOS 15+) for constraint-based keyboard tracking in UIKit layouts that contain SwiftUI content:
```swift
// Constrain the hosting controller's view above the keyboard
hostingController.view.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true
```
---
# Part 4: Shared State with @Observable
When UIKit and SwiftUI coexist in the same app, you need a shared model layer. `@Observable` (iOS 17+) works naturally in both frameworks without Combine.
## @Observable as the Shared Model Layer
```swift
@Observable
class AppState {
var userName: String = ""
var isLoggedIn: Bool = false
var itemCount: Int = 0
}
```
**SwiftUI side** — standard property wrappers:
```swift
struct ProfileView: View {
@State var appState: AppState // or @Environment, @Bindable
var body: some View {
Text("Welcome, \(appState.userName)")
Text("\(appState.itemCount) items")
}
}
```
**Why UIKit needs explicit observation**: SwiftUI's rendering engine automatically participates in the Observation framework — when a view's `body` accesses an `@Observable` property, SwiftUI registers that access and re-renders when it changes. UIKit is imperative and has no equivalent re-evaluation mechanism, so you must opt in explicitly.
**UIKit side (pre-iOS 26)** — manual observation with `withObservationTracking()`:
```swift
class DashboardViewController: UIViewController {
let appState: AppState
override func viewDidLoad() {
super.viewDidLoad()
observeState()
}
private func observeState() {
withObservationTracking {
// Properties accessed here are tracked
titleLabel.text = appState.userName
countLabel.text = "\(appState.itemCount) items"
} onChange: {
// Fires ONCE on the thread that mutated the property must re-register
// Always dispatch to main: onChange can fire on ANY thread
DispatchQueue.main.async { [weak self] in
self?.observeState()
}
}
}
}
```
**UIKit side (iOS 26+)** — automatic observation tracking:
UIKit automatically tracks `@Observable` property access in designated lifecycle methods. Properties read in these methods trigger automatic UI updates when they change:
| Method | Class | What it updates |
|--------|-------|----------------|
| `updateProperties()` | UIView, UIViewController | Content and styling |
| `layoutSubviews()` | UIView | Geometry and positioning |
| `viewWillLayoutSubviews()` | UIViewController | Pre-layout |
| `draw(_:)` | UIView | Custom drawing |
```swift
class DashboardViewController: UIViewController {
let appState: AppState
// iOS 26+: Properties accessed here are auto-tracked
override func updateProperties() {
super.updateProperties()
titleLabel.text = appState.userName
countLabel.text = "\(appState.itemCount) items"
}
}
```
**Info.plist requirement**: In iOS 18, add `UIObservationTrackingEnabled = true` to your Info.plist to enable automatic observation tracking. iOS 26+ enables it by default.
## iOS 16 Fallback: ObservableObject + Combine
If targeting iOS 16 (before `@Observable`), use `ObservableObject` with `@Published` and observe via Combine on the UIKit side:
```swift
class AppState: ObservableObject {
@Published var userName: String = ""
@Published var itemCount: Int = 0
}
// UIKit side observe with Combine sink
class DashboardViewController: UIViewController {
let appState: AppState
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
appState.$userName
.receive(on: DispatchQueue.main)
.sink { [weak self] name in
self?.titleLabel.text = name
}
.store(in: &cancellables)
}
}
```
## Migration Note
`@Observable` replaces `ObservableObject` + `@Published` without requiring Combine. For hybrid apps:
- Replace `ObservableObject` classes with `@Observable`
- Remove `@Published` property wrappers (observation is automatic)
- SwiftUI views keep working — `@State` and `@Environment` support `@Observable` directly
- UIKit views gain observation through `withObservationTracking()` (iOS 17+) or automatic tracking (iOS 26+)
---
# Part 5: Common Gotchas
| Gotcha | Symptom | Fix |
|--------|---------|-----|
| Coordinator retains parent | Memory leak, views never deallocate | Coordinator stores `var parent: X` (not `let`). SwiftUI updates the parent reference on each `updateUIView` call. Don't add extra strong references. |
| updateUIView called excessively | UIKit view flickers, resets scroll position, drops user input | Guard with equality checks. Compare old vs new values before applying changes. |
| Environment doesn't cross bridge | Custom environment values are nil/default | Use `UITraitBridgedEnvironmentKey` (iOS 17+) for bidirectional bridging, or inject dependencies through initializer. System traits (color scheme, size category) bridge automatically. |
| Large title won't collapse | Navigation bar stays expanded when scrolling wrapped UIScrollView | Call `setContentScrollView(_:forEdge:)` on the navigation bar. |
| UIHostingController sizing wrong | View is zero-sized or jumps after layout | Use `sizingOptions: .intrinsicContentSize` (iOS 16+). For earlier versions, call `hostingController.view.invalidateIntrinsicContentSize()` after root view changes. |
| Mixed navigation stacks | Unpredictable back button behavior, lost state | Don't mix UINavigationController and NavigationStack in the same flow. Migrate entire navigation subtrees. |
| makeUIView called multiple times | View recreated unexpectedly | Ensure the `UIViewRepresentable` struct's identity is stable. Avoid putting it inside a conditional that changes identity. |
| Coordinator not receiving callbacks | Delegate methods never fire | Set `delegate = context.coordinator` in `makeUIView`, not `updateUIView`. Verify protocol conformance. |
| Layout properties modified on representable view | View jumps, disappears, or has inconsistent layout | Never modify `center`, `bounds`, `frame`, or `transform` on the wrapped UIView — SwiftUI owns these. |
| Keyboard hides content in hybrid layout | Text field or content hidden behind keyboard | Use `UIKeyboardLayoutGuide` (iOS 15+) constraints in UIKit, or ensure SwiftUI's keyboard avoidance isn't disabled. |
| @Observable not updating UIKit views | UIKit views show stale data after model changes | Use `withObservationTracking()` (iOS 17+) or enable `UIObservationTrackingEnabled` in Info.plist (iOS 18). iOS 26+ auto-tracks in `updateProperties()`. |
---
# Part 6: Anti-Patterns
| Pattern | Problem | Fix |
|---------|---------|-----|
| "I'll use UIViewRepresentable for the whole screen" | UIViewControllerRepresentable exists for controllers that manage their own view hierarchy, handle rotation, and participate in the responder chain | Use UIViewControllerRepresentable for UIViewControllers. UIViewRepresentable is for bare UIViews. |
| "I don't need a coordinator, I'll use closures" | Closures capture the struct value (not reference), become stale on updates, and can't conform to delegate protocols | Use the Coordinator. It's a stable reference type that SwiftUI keeps alive and updates. |
| "I'll rebuild the UIKit view every update" | `makeUIView` runs once. Recreating the view in `updateUIView` causes flickering, lost state, and performance issues. | Create in `makeUIView`. Patch properties in `updateUIView`. |
| "SwiftUI environment will just work across the bridge" | Custom `@Environment` values don't cross UIKit boundaries | Use `UITraitBridgedEnvironmentKey` (iOS 17+) for bridging, or inject explicitly through initializers. System trait-based values bridge automatically. |
| "I'll dismiss the UIKit controller directly" | Calling `dismiss(animated:)` from coordinator bypasses SwiftUI's presentation state, leaving bindings out of sync | Use `@Environment(\.dismiss)` or the `@Binding var isPresented` to let SwiftUI handle dismissal. |
| "I'll skip dismantleUIView, it'll clean up automatically" | Timers, observers, and KVO registrations on the UIView leak | Implement `dismantleUIView` (static method) for any cleanup that `deinit` alone won't handle. |
---
## Resources
**WWDC**: 2019-231, 2022-10072, 2023-10149, 2024-10118, 2024-10145, 2025-243, 2025-256
**Docs**: /swiftui/uiviewrepresentable, /swiftui/uiviewcontrollerrepresentable, /swiftui/uigesturerecognizerrepresentable, /uikit/uihostingcontroller, /uikit/uihostingconfiguration, /swiftui/uitraitbridgedenvironmentkey, /observation, /uikit/updating-views-automatically-with-observation-tracking
**Skills**: app-composition, swiftui-animation-ref, camera-capture, transferable-ref, swift-concurrency