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.
This commit is contained in:
772
.claude/skills/axiom-uikit-bridging/SKILL.md
Normal file
772
.claude/skills/axiom-uikit-bridging/SKILL.md
Normal file
@@ -0,0 +1,772 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user