UIKit in iOS 26: Liquid Glass Variants and `@Observable` Integration
You have a UIKit codebase that ships to millions of users. You are not rewriting it in SwiftUI. And yet iOS 26 just
landed two changes that make your UIKit views feel as modern as anything SwiftUI offers: Liquid Glass effects through
UIGlassEffect, and automatic @Observable tracking baked directly into the view update cycle. No Combine
subscriptions, no manual setNeedsLayout calls, no UIViewRepresentable wrappers.
This post covers both features in depth — from the UIGlassEffect API surface and UIGlassContainerEffect merging
behavior, to the new updateProperties() lifecycle method and back-deployment options. It does not cover the
SwiftUI-side glassEffect(_:in:) modifier — that lives in our
Liquid Glass Design System post.
Note: All code in this post requires Xcode 26 and an iOS 26 deployment target unless otherwise noted. The
@Observabletracking feature can be back-deployed to iOS 18 — see Back-Deploying to iOS 18.
Contents
- The Problem
- Liquid Glass in UIKit
- Automatic @Observable Tracking
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
If you maintained a UIKit app through 2024, you lived with two pain points. First, achieving the frosted-glass aesthetic
that SwiftUI apps got for free meant stacking UIBlurEffect with UIVibrancyEffect, manually managing corner radii,
and hoping the result looked close enough to the system components. Second, keeping your views in sync with model
changes required either KVO, Combine pipelines, or delegation — all of which added boilerplate that SwiftUI developers
never had to think about.
Consider this typical view controller from a Pixar film catalog app. It observes a model with Combine, subscribes to changes, and manually triggers layout:
import Combine
import UIKit
final class FilmDetailViewController: UIViewController {
private var cancellables: Set<AnyCancellable> = []
private let filmModel: FilmDetailModel // ObservableObject
private let titleLabel = UILabel()
private let taglineLabel = UILabel()
private let backdropView = UIVisualEffectView(
effect: UIBlurEffect(style: .systemMaterial)
)
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
bindModel()
}
private func bindModel() {
filmModel.$title
.receive(on: DispatchQueue.main)
.sink { [weak self] title in
self?.titleLabel.text = title
}
.store(in: &cancellables)
filmModel.$tagline
.receive(on: DispatchQueue.main)
.sink { [weak self] tagline in
self?.taglineLabel.text = tagline
}
.store(in: &cancellables)
}
}
That is 15 lines of reactive plumbing just to keep two labels in sync. And the blur effect? It looks adequate, but it will not match the new Liquid Glass visual language that system controls adopt automatically in iOS 26. Your custom surfaces will feel dated on day one.
iOS 26 solves both problems in a single release. Let’s start with the visual side.
Liquid Glass in UIKit
Apple’s most significant design change since iOS 7 centers on Liquid Glass — a translucent, depth-aware material that
refracts content beneath it. System bars, tab bars, and navigation bars adopt it automatically. For custom views, UIKit
introduces UIGlassEffect — a UIVisualEffect
subclass you apply through the familiar UIVisualEffectView workflow.
Apple Docs:
UIGlassEffect— UIKit
Creating a Glass Surface
The simplest path from blur to glass is a three-line swap. Create a UIGlassEffect, wrap it in a UIVisualEffectView,
and animate it in:
let glassView = UIVisualEffectView()
view.addSubview(glassView)
let glassEffect = UIGlassEffect()
UIView.animate {
glassView.effect = glassEffect // Materializes with animation
}
Setting the effect inside an animation block gives you the materialization transition — the glass fades in with the
characteristic refraction animation. To remove it, set the effect to nil in another animation block and the glass
dematerializes.
Content placed inside contentView automatically gets vibrancy treatment. Text colors determine the rendering mode:
.label renders as primary vibrant text, .secondaryLabel as secondary:
let filmTitleLabel = UILabel()
filmTitleLabel.text = "Toy Story 5"
filmTitleLabel.textColor = .label // Primary vibrant rendering
let studioLabel = UILabel()
studioLabel.text = "Pixar Animation Studios"
studioLabel.textColor = .secondaryLabel // Secondary vibrant rendering
glassView.contentView.addSubview(filmTitleLabel)
glassView.contentView.addSubview(studioLabel)
Glass Styles
UIGlassEffect ships with two styles, defined on UIGlassEffect.Style:
| Style | Transparency | Adaptivity | Use Case |
|---|---|---|---|
.regular | Medium | Full — adapts to any background | Toolbars, controls, overlays |
.clear | High | Limited — needs a dimming layer | Media-rich backgrounds |
You choose the style at initialization:
// Default — adapts to any content
let regularGlass = UIGlassEffect(style: .regular)
// High transparency — ideal over poster images
let clearGlass = UIGlassEffect(style: .clear)
Tip: Prefer
.regularunless you are specifically layering glass over rich visual content like movie posters or artwork. The.clearvariant requires careful contrast management to keep text legible.
Customizing Glass Appearance
Three properties let you tailor the glass to your design:
Tint color applies a stained-glass effect. Pass any UIColor:
let glassEffect = UIGlassEffect()
glassEffect.tintColor = .systemBlue
UIView.animate {
glassView.effect = glassEffect
}
Interactive mode makes the glass respond to touch — expanding slightly with a highlight when tapped. Essential for controls:
let glassEffect = UIGlassEffect()
glassEffect.isInteractive = true
glassView.effect = glassEffect
Corner configuration controls the shape. By default, glass renders as a capsule. You have two options:
// Fixed corner radius
UIView.animate {
glassView.cornerConfiguration = .fixed(12)
}
// Container-relative — adapts concentricity to parent
UIView.animate {
glassView.cornerConfiguration = .containerRelative()
}
The .containerRelative() option is particularly useful: as the glass view moves closer to its container’s corner, the
inner corner radius automatically decreases to maintain concentricity — a detail that would require nontrivial geometry
math to replicate manually.
UIGlassContainerEffect: Merging and Splitting
When multiple glass surfaces exist in close proximity, they should merge like water droplets rather than overlap with
harsh edges. UIGlassContainerEffect
coordinates this behavior:
// Create the container
let containerEffect = UIGlassContainerEffect()
let containerView = UIVisualEffectView(effect: containerEffect)
view.addSubview(containerView)
// Create individual glass elements
let glassEffect = UIGlassEffect()
let playButton = UIVisualEffectView(effect: glassEffect)
let skipButton = UIVisualEffectView(effect: glassEffect)
// Add to the container's contentView — not directly to the view
containerView.contentView.addSubview(playButton)
containerView.contentView.addSubview(skipButton)
The container also enforces uniform adaptation. Without it, two adjacent glass views might adapt differently based on
their individual backgrounds. With UIGlassContainerEffect, all children share a consistent appearance.
The spacing property controls how close views must be before they start merging:
let containerEffect = UIGlassContainerEffect()
containerEffect.spacing = 20 // Points before views begin merging
Animate frames and the merging happens automatically:
// Merge: animate both views to the same frame
UIView.animate {
playButton.frame = mergedFrame
skipButton.frame = mergedFrame
}
// Split: animate them apart
UIView.animate {
playButton.frame = leftFrame
skipButton.frame = rightFrame
}
Warning: Always add glass children to
containerView.contentView, not tocontainerViewdirectly. Adding them as siblings of the container view bypasses the merging and uniform-adaptation behavior entirely.
Button Configurations with Glass
For standard buttons, UIKit provides two new UIButton.Configuration factory methods that handle glass automatically:
// Standard glass button
var playConfig = UIButton.Configuration.glass()
playConfig.title = "Play Trailer"
playConfig.image = UIImage(systemName: "play.fill")
let playButton = UIButton(configuration: playConfig)
// Prominent glass — tinted with the app's accent color
var rateConfig = UIButton.Configuration.prominentGlass()
rateConfig.title = "Rate Film"
let rateButton = UIButton(configuration: rateConfig)
These configurations give you interactive glass behavior, vibrancy, and proper hit-testing without touching
UIGlassEffect directly. Prefer them for action buttons.
Automatic @Observable Tracking
The second major feature is arguably more impactful for day-to-day code. iOS 26 embeds Swift’s
Observation framework directly into UIKit’s update cycle. When
you reference an @Observable model’s property inside layoutSubviews(), updateConstraints(), draw(_:), or the new
updateProperties() method, UIKit automatically registers the dependency and invalidates the view when the property
changes.
No Combine subscriptions. No delegation. No withObservationTracking boilerplate.
The updateProperties() Lifecycle Method
iOS 26 adds updateProperties() to both
UIView and UIViewController. It runs during the top-down layout pass, after traits update but before
layoutSubviews(). Think of it as the property-setting counterpart to layout:
@Observable
final class FilmDetailModel {
var title: String = "Finding Nemo"
var tagline: String =
"There are 3.7 trillion fish in the ocean. They're looking for one."
var badgeCount: Int?
}
final class FilmDetailViewController: UIViewController {
var model: FilmDetailModel
private let titleLabel = UILabel()
private let taglineLabel = UILabel()
init(model: FilmDetailModel) {
self.model = model
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateProperties() {
super.updateProperties()
titleLabel.text = model.title // Tracked automatically
taglineLabel.text = model.tagline // Tracked automatically
}
}
That is the entire data-binding layer. No cancellables, no sink, no store(in:). When model.title changes, UIKit
schedules an updateProperties() pass and re-runs only the code that read the changed property.
The invalidation methods mirror the existing layout system:
| Layout System | Property System |
|---|---|
setNeedsLayout() | setNeedsUpdateProperties() |
layoutIfNeeded() | updatePropertiesIfNeeded() |
layoutSubviews() | updateProperties() |
Tip: Keep property assignments in
updateProperties()and frame calculations inlayoutSubviews(). This separation lets UIKit invalidate them independently — a property change that does not affect geometry skips the layout pass entirely, and vice versa.
Tracking in layoutSubviews and draw(_:)
The automatic tracking is not limited to updateProperties(). Any override that UIKit calls during its update cycle
participates:
final class FilmPosterView: UIView {
var model: FilmPosterModel // @Observable
override func layoutSubviews() {
super.layoutSubviews()
// Reading model.posterSize registers a tracking dependency.
// When posterSize changes, UIKit calls setNeedsLayout().
let posterFrame = CGRect(origin: .zero, size: model.posterSize)
posterImageView.frame = posterFrame
}
override func draw(_ rect: CGRect) {
super.draw(rect)
// Reading model.accentColor registers a dependency.
// When accentColor changes, UIKit calls setNeedsDisplay().
model.accentColor.setFill()
UIBezierPath(roundedRect: rect, cornerRadius: 8).fill()
}
}
The right invalidation method is called automatically based on which override read the property. A property read in
layoutSubviews() triggers setNeedsLayout(). A property read in draw(_:) triggers setNeedsDisplay(). A property
read in updateProperties() triggers setNeedsUpdateProperties().
Back-Deploying to iOS 18
Here is the detail that changes your planning calculus: automatic observation tracking in UIKit can be back-deployed to
iOS 18. Add a single key to your Info.plist:
<key>UIObservationTrackingEnabled</key>
<true/>
On iOS 26, this key is ignored — tracking is enabled by default. On iOS 18 through iOS 25, the key opts you in. This
means you can start migrating from Combine-based bindings to @Observable today, even if your minimum deployment target
is iOS 18.
Warning: On iOS 17 and earlier, the
Info.plistkey has no effect. If you still support iOS 17, you will need a fallback path — either conditional compilation with#available(iOS 18, *)or continuing to use Combine for those versions.
Advanced Usage
Combining Glass with @Observable
The two features compose naturally. Here is a custom player control bar that uses Liquid Glass for its visual surface
and @Observable for its data bindings:
@Observable
final class PlaybackModel {
var filmTitle: String = "Up"
var isPlaying: Bool = false
var progress: Float = 0.0
var elapsedTime: String = "0:00"
}
@available(iOS 26, *)
final class MiniPlayerView: UIView {
var model: PlaybackModel
private let glassView = UIVisualEffectView()
private let titleLabel = UILabel()
private let playPauseButton = UIButton(configuration: .glass())
private let progressView = UIProgressView()
init(model: PlaybackModel) {
self.model = model
super.init(frame: .zero)
setupGlass()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupGlass() {
let effect = UIGlassEffect()
effect.isInteractive = false
effect.tintColor = .systemIndigo
UIView.animate {
self.glassView.effect = effect
}
glassView.contentView.addSubview(titleLabel)
glassView.contentView.addSubview(playPauseButton)
glassView.contentView.addSubview(progressView)
addSubview(glassView)
}
override func updateProperties() {
super.updateProperties()
titleLabel.text = model.filmTitle
progressView.progress = model.progress
let icon = model.isPlaying ? "pause.fill" : "play.fill"
playPauseButton.configuration?.image = UIImage(
systemName: icon
)
}
override func layoutSubviews() {
super.layoutSubviews()
glassView.frame = bounds
// Layout titleLabel, playPauseButton, progressView...
// Simplified for clarity
}
}
When model.isPlaying flips, only updateProperties() re-runs — the layout pass is skipped because no geometry-related
properties changed.
Glass Merging Animations
The merging behavior of UIGlassContainerEffect shines in contexts like media controls or segmented actions. Here is a
pattern for merging multiple controls into a single glass surface during a transition — think of a Pixar film browsing
interface that collapses filter pills into a single search bar:
@available(iOS 26, *)
func collapseFilters(
filters: [UIVisualEffectView],
into searchBar: UIVisualEffectView,
in container: UIVisualEffectView
) {
let targetFrame = searchBar.frame
UIView.animate(withDuration: 0.4) {
for filter in filters {
filter.frame = targetFrame
filter.alpha = 0.0
}
searchBar.alpha = 1.0
} completion: { _ in
filters.forEach { $0.removeFromSuperview() }
}
}
Because all views share the same UIGlassContainerEffect parent, the glass surfaces merge smoothly as their frames
converge.
Opting Out of Automatic Glass Adoption
System bars adopt Liquid Glass automatically in iOS 26. If your app’s custom chrome conflicts with the glass aesthetic — perhaps you render full-bleed video content — you can opt out per-bar:
// Opt out of glass on navigation bar
navigationController?.navigationBar
.preferredGlassEffectIntensity = 0
// Opt out on a tab bar
tabBarController?.tabBar
.preferredGlassEffectIntensity = 0
Warning: Opting out should be a deliberate design choice, not a default. Users will expect the Liquid Glass look across iOS 26, and inconsistency will feel like a bug rather than a feature.
Performance Considerations
Glass Rendering Cost
Liquid Glass is GPU-composited. Each UIVisualEffectView with a glass effect adds a compositing pass. In isolation this
is cheap — Apple optimizes for it at the system level. But stacking multiple glass layers in a scrolling context can
push frame times above budget.
Guidelines from WWDC25 session Build a UIKit app with the new design:
- Limit glass to interactive elements. Use it for controls, toolbars, and floating overlays — not for every card in a collection view.
- Avoid glass-on-glass. Two overlapping glass surfaces create double compositing with diminishing visual returns.
- Prefer system controls.
UIButton.Configuration.glass()is optimized internally. Only reach for rawUIGlassEffectwhen you need custom geometry.
Observation Tracking Overhead
The automatic tracking in updateProperties() and layoutSubviews() uses the same withObservationTracking machinery
as SwiftUI. The overhead per tracked property is negligible — a dictionary lookup to register the dependency and a
willSet notification to trigger invalidation.
However, tracking is per-invocation. If your layoutSubviews() reads 50 properties from 10 different @Observable
models, all 50 become dependencies. A change to any one of them invalidates the entire method. This is identical to how
SwiftUI’s body works — fine-grained at the property level, not at the expression level.
To keep invalidation targeted:
- Use
updateProperties()for property assignments andlayoutSubviews()for geometry. A change to a label’s text should not trigger a layout pass. - If a single view consumes many models, consider whether it should be split into smaller views, each tracking a focused subset.
Apple Docs:
UIView.updateProperties()— UIKit
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| New custom floating control | Use UIGlassEffect with isInteractive = true. |
| Standard action button | Use .glass() or .prominentGlass() config. |
| Full-screen video player | Consider .clear glass or opt out entirely. |
| UIKit view binding to model | Use @Observable + updateProperties(). |
| Supporting iOS 17 minimum | Back-deploy tracking to iOS 18, Combine for 17. |
| Glass surfaces in a scroll view | Do not use glass per-cell. Use it on overlays. |
| Migrating ObservableObject | Start with updateProperties(), one VC at a time. |
What About Combine?
Combine is not deprecated. It still works, and Apple ships new Combine publishers with each SDK. But for the specific
use case of “model property changed, update UI,” @Observable tracking is strictly superior in UIKit — less code, no
retain-cycle risk from closures, and automatic invalidation granularity. Reserve Combine for streams, timers, and
complex operator chains where its reactive model adds genuine value.
Summary
UIGlassEffectbrings Liquid Glass to custom UIKit views through the familiarUIVisualEffectViewpattern. Two styles —.regularand.clear— cover most use cases.UIGlassContainerEffectcoordinates merging, splitting, and uniform adaptation across sibling glass surfaces.updateProperties()is the new UIKit lifecycle method for property binding. It runs beforelayoutSubviews(), tracks@Observablereferences automatically, and invalidates independently from layout.- Automatic observation tracking also works in
layoutSubviews(),draw(_:), andupdateConstraints()— each triggering the correct invalidation method. - Back-deploy observation tracking to iOS 18 with the
UIObservationTrackingEnabledInfo.plistkey. No code changes required beyond adopting@Observable.
If your UIKit codebase has held off on SwiftUI because the migration cost was not justified, iOS 26 just moved the goalposts. You get SwiftUI’s two most compelling features — declarative data binding and the system design language — without leaving UIKit. For an in-depth look at UIKit’s scene lifecycle changes that pair with these features, see UIKit Scene Lifecycle.