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 @Observable tracking feature can be back-deployed to iOS 18 — see Back-Deploying to iOS 18.

Contents

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:

StyleTransparencyAdaptivityUse Case
.regularMediumFull — adapts to any backgroundToolbars, controls, overlays
.clearHighLimited — needs a dimming layerMedia-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 .regular unless you are specifically layering glass over rich visual content like movie posters or artwork. The .clear variant 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 to containerView directly. 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 SystemProperty System
setNeedsLayout()setNeedsUpdateProperties()
layoutIfNeeded()updatePropertiesIfNeeded()
layoutSubviews()updateProperties()

Tip: Keep property assignments in updateProperties() and frame calculations in layoutSubviews(). 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.plist key 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 raw UIGlassEffect when 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 and layoutSubviews() 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)

ScenarioRecommendation
New custom floating controlUse UIGlassEffect with isInteractive = true.
Standard action buttonUse .glass() or .prominentGlass() config.
Full-screen video playerConsider .clear glass or opt out entirely.
UIKit view binding to modelUse @Observable + updateProperties().
Supporting iOS 17 minimumBack-deploy tracking to iOS 18, Combine for 17.
Glass surfaces in a scroll viewDo not use glass per-cell. Use it on overlays.
Migrating ObservableObjectStart 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

  • UIGlassEffect brings Liquid Glass to custom UIKit views through the familiar UIVisualEffectView pattern. Two styles — .regular and .clear — cover most use cases.
  • UIGlassContainerEffect coordinates merging, splitting, and uniform adaptation across sibling glass surfaces.
  • updateProperties() is the new UIKit lifecycle method for property binding. It runs before layoutSubviews(), tracks @Observable references automatically, and invalidates independently from layout.
  • Automatic observation tracking also works in layoutSubviews(), draw(_:), and updateConstraints() — each triggering the correct invalidation method.
  • Back-deploy observation tracking to iOS 18 with the UIObservationTrackingEnabled Info.plist key. 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.