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
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:
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.
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:
Acts as the delegate/data source for the UIKit view
Holds a reference to the parent
UIViewRepresentable
struct
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.
// ❌ 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:
// 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:
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:
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:
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
// 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:
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:
// ❌ 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
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
// 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):
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:
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
) 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):
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:
// 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:
// 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:
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
:
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:
// 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
@Observable
class
AppState
{
var
userName
:
String
=
""
var
isLoggedIn
:
Bool
=
false
var
itemCount
:
Int
=
0
}
SwiftUI side
— standard property wrappers:
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()
:
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
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:
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