Observable Makro in UIKit: Der iOS 26 Guide zu updateProperties()

iOS 26 bringt das @Observable-Makro und die neue Lifecycle-Methode updateProperties() nach UIKit. Der komplette Praxis-Guide mit Codebeispielen, Migration von Combine und Back-Deployment auf iOS 18.

@Observable UIKit iOS 26: Setup-Guide 2026

Ehrlich gesagt: Jahrelang war Datenbindung in UIKit einfach nur anstrengend. NotificationCenter, KVO, Combine – jedes Projekt hatte seinen eigenen Flickenteppich, nur damit Model und UI endlich mal synchron blieben. Mit iOS 26 ändert Apple das Spiel grundlegend: Das @Observable-Makro, bisher der heimliche Star von SwiftUI, hält nun offiziell auch in UIKit Einzug – im Schlepptau hat es die neue Lifecycle-Methode updateProperties().

In diesem Guide zeige ich dir Schritt für Schritt, wie du Observation Tracking in UIKit-Views und -Controllern einsetzt, welche Fallstricke lauern (und es sind ein paar) und wie du das Ganze bis iOS 18 zurück-deployst. Los geht's.

Was ist neu in iOS 26 für UIKit?

Mit der in Swift 5.9 eingeführten Observation-Bibliothek und dem dazugehörigen @Observable-Makro hatte Apple schon 2023 die Grundlage für reaktive Datenflüsse in SwiftUI gelegt. Gut drei Jahre später reicht UIKit nun die Hand: Reads von @Observable-Properties innerhalb bestimmter Update-Methoden werden automatisch getrackt. Ändert sich eine beobachtete Property, plant UIKit die betroffenen Views eigenständig zur Aktualisierung ein – ohne dass du setNeedsLayout(), setNeedsDisplay() oder manuelle Combine-Sinks schreiben musst.

Konkret bringt iOS 26 drei zusammenhängende Neuerungen mit:

  • Automatisches Observation Tracking in layoutSubviews(), draw(_:), tintColorDidChange() und weiteren Update-Methoden.
  • updateProperties() – eine neue Lifecycle-Methode auf UIView und UIViewController, die explizit für Inhaltsaktualisierungen gedacht ist und vor layoutSubviews() läuft.
  • .flushUpdates – eine neue Animations-Option für UIView.animate, die ausstehende Updates vor und nach der Animation automatisch einspielt.

Warum das ein echter Paradigmenwechsel ist

In der klassischen UIKit-Welt besteht Datenbindung aus drei manuellen Schritten: Model-Änderung → Callback (NotificationCenter, Delegate, KVO, Combine) → View-Invalidation per setNeedsLayout/setNeedsDisplay. Jeder dieser Schritte birgt Boilerplate und Fehlerquellen – vom fehlenden removeObserver bis zu inkonsistenten UI-Zuständen nach dem nächsten Merge.

Mit iOS 26 reduzieren sich diese drei Schritte auf einen: Du liest die Property. Den Rest übernimmt UIKit. Das Ergebnis ist eine UIKit-Codebasis, die sich an vielen Stellen wie SwiftUI liest, ohne dass du deinen bestehenden Layer wegwerfen musst. Meiner Erfahrung nach ist das der größte Gewinn – man bekommt den ergonomischen Sprung, ohne das Team in einen Rewrite zu zwingen.

Der neue UIKit-Update-Zyklus in iOS 26

Bevor wir in Code einsteigen, hilft es, den überarbeiteten Update-Zyklus zu kennen. Apple hat die Phasen neu sortiert, um updateProperties() sauber zwischen Trait-Änderungen und Layout einzuordnen:

  1. Trait Update – Dark Mode, Dynamic Type, Size Classes werden propagiert.
  2. updateProperties()neu: Hier setzt du Texte, Farben, Bilder, Badges.
  3. layoutSubviews() – Frames, Constraints, Auto Layout.
  4. Display Passdraw(_:) und Rendering via Core Animation.

Der entscheidende Punkt: updateProperties() löst keinen Layout-Pass aus. Ändert sich nur der Text eines Labels, musst du nicht das halbe View-Subtree neu vermessen lassen. Das spart reale CPU-Zyklen in datenintensiven Listen und Collection-Views – und ja, das merkt man beim Scrollen.

Dein erstes Observable Model in UIKit

Beginnen wir mit einem minimalen Beispiel: einer Badge am Folder-Button eines ViewControllers. Zuerst definierst du ein @Observable-Model. Wichtig – das Makro verlangt eine Klasse, keine Struktur:

import Observation
import UIKit

@Observable
final class MailboxModel {
    var unreadCount: Int = 0
    var isSyncing: Bool = false
    var folderTitle: String = "Posteingang"
}

Anschließend konsumierst du das Model in einem UIViewController. Statt wie früher in viewDidLoad() einen Combine-Sink auf objectWillChange zu registrieren, überschreibst du einfach updateProperties():

final class MailboxViewController: UIViewController {
    let model: MailboxModel
    private let folderButton = UIBarButtonItem()
    private let titleLabel = UILabel()
    private let spinner = UIActivityIndicatorView(style: .medium)

    init(model: MailboxModel) {
        self.model = model
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func updateProperties() {
        super.updateProperties()

        titleLabel.text = model.folderTitle

        if model.unreadCount > 0 {
            folderButton.badge = .count(model.unreadCount)
        } else {
            folderButton.badge = nil
        }

        if model.isSyncing {
            spinner.startAnimating()
        } else {
            spinner.stopAnimating()
        }
    }
}

Das war's. Jede Zuweisung an model.unreadCount, model.isSyncing oder model.folderTitle – egal aus welcher Queue, egal ob direkt oder über Umwege – löst automatisch einen erneuten Aufruf von updateProperties() aus. Du rufst setNeedsUpdateProperties() nie explizit auf, solange du die Properties innerhalb der Methode liest. Klingt fast zu einfach, ist aber genau so gedacht.

setNeedsUpdateProperties() und updatePropertiesIfNeeded()

Analog zu setNeedsLayout()/layoutIfNeeded() bietet UIKit zwei manuelle Steuerungspunkte:

  • setNeedsUpdateProperties() – markiert den View als aktualisierungsbedürftig. Der eigentliche Aufruf erfolgt im nächsten Run-Loop-Zyklus.
  • updatePropertiesIfNeeded() – erzwingt den Update-Pass sofort, falls einer pending ist. Richtig nützlich in Test-Setups oder vor Screenshot-Assertions.
// Externe Quelle hat den State verändert, aber nicht im Observable
folderButton.target = self
folderButton.action = #selector(refreshTapped)

@objc private func refreshTapped() {
    // ausnahmsweise: manueller Trigger
    setNeedsUpdateProperties()
}

Beobachtung in Cells und Custom Views

Der häufigste Praxisfall dürfte die Bindung in UICollectionViewCell oder UITableViewCell sein. Auch hier funktioniert updateProperties() – allerdings mit einer wichtigen Regel: Die Zelle sollte das Model über eine Property halten, nicht nur über ein Closure-Capture.

@Observable
final class MessageViewModel {
    var title: String
    var preview: String
    var isRead: Bool

    init(title: String, preview: String, isRead: Bool) {
        self.title = title
        self.preview = preview
        self.isRead = isRead
    }
}

final class MessageCell: UICollectionViewCell {
    private let titleLabel = UILabel()
    private let previewLabel = UILabel()

    var viewModel: MessageViewModel? {
        didSet { setNeedsUpdateProperties() }
    }

    override func updateProperties() {
        super.updateProperties()
        guard let viewModel else { return }

        titleLabel.text = viewModel.title
        previewLabel.text = viewModel.preview
        titleLabel.font = viewModel.isRead
            ? .preferredFont(forTextStyle: .body)
            : .preferredFont(forTextStyle: .headline)
    }
}

Der didSet-Block stellt sicher, dass ein Zell-Recycling (Stichwort prepareForReuse) nicht zu veraltetem Inhalt führt. Alle weiteren Property-Änderungen am selben MessageViewModel triggert UIKit danach selbständig.

Shared State zwischen UIKit und SwiftUI

Einer der stärksten Punkte des neuen Modells: Derselbe @Observable-Typ funktioniert gleichermaßen in SwiftUI-Views und UIKit-ViewControllern. Du kannst ein Model im UIKit-Parent erzeugen und per UIHostingController an einen SwiftUI-Subtree weiterreichen:

let mailbox = MailboxModel()

// UIKit-Seite
let uiKitVC = MailboxViewController(model: mailbox)

// SwiftUI-Seite
struct MailboxSidebar: View {
    let model: MailboxModel

    var body: some View {
        VStack {
            Text(model.folderTitle).font(.headline)
            Text("\(model.unreadCount) ungelesen")
        }
    }
}

let sidebar = UIHostingController(rootView: MailboxSidebar(model: mailbox))

Eine Mutation auf mailbox.unreadCount aktualisiert beide Seiten gleichzeitig – ohne Bridge-Layer, ohne Duplicate-State. Genau für solche gemischten Architekturen (die in der Praxis die Regel und nicht die Ausnahme sind) ist das Gold wert.

Back-Deployment auf iOS 18

Viele Teams können iOS 26 nicht einfach so als Minimum Deployment Target setzen. Apple hat das vorhergesehen: Das automatische Observation Tracking lässt sich bis iOS 18 zurückportieren.

  1. Öffne die Info.plist deines App-Targets.
  2. Füge den Schlüssel UIObservationTrackingEnabled mit dem Wert YES hinzu.
  3. Verschiebe die Update-Logik aus updateProperties() in viewWillLayoutSubviews(), da updateProperties() erst ab iOS 26 existiert.
override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    titleLabel.text = model.folderTitle
    folderButton.badge = model.unreadCount > 0
        ? .count(model.unreadCount)
        : nil
}

Ab iOS 26 solltest du per #available-Check auf updateProperties() umschalten, um den unnötigen Layout-Pass zu vermeiden:

if #available(iOS 26.0, *) {
    // updateProperties() überschreiben
} else {
    // viewWillLayoutSubviews() Fallback
}

Die neue .flushUpdates-Animationsoption

Ein subtiler, aber sehr praktischer Zusatz ist UIView.AnimationOptions.flushUpdates. In älteren iOS-Versionen musstest du vor Animationen manuell layoutIfNeeded() aufrufen, um das gewünschte Ziel-Layout zu erreichen. Mit .flushUpdates übernimmt UIKit das automatisch – sowohl für Layout- als auch für Property-Updates:

UIView.animate(withDuration: 0.3, delay: 0, options: [.flushUpdates]) {
    self.model.unreadCount = 0
    self.containerView.alpha = 0.5
}

Fallstricke und Best Practices

1. Expensive Reads vermeiden

updateProperties() wird potenziell sehr häufig aufgerufen – jede Trait-Änderung, jede Observable-Mutation, jeder manuelle Trigger. Vermeide teure Berechnungen und Datenbank-Zugriffe innerhalb der Methode. Cache Ergebnisse oder verlagere Schwerlast in Background-Tasks und reich nur das fertige Ergebnis ins Observable zurück.

2. Thread-Safety beachten

Das @Observable-Makro macht deine Klasse nicht automatisch Sendable. Mutationen von Hintergrund-Threads können zu inkonsistenten UI-Zuständen führen (ich hab das selbst schon erlebt, und der Bug ist genau so schwer zu finden wie er klingt). Isoliere dein Model entweder auf den MainActor oder synchronisiere Zugriffe konsequent:

@MainActor
@Observable
final class MailboxModel {
    var unreadCount: Int = 0
}

3. Retain-Cycles vermeiden

UIKit hält beobachtete Objekte so lange, wie sie beobachtet werden. Wenn dein ViewController ein @Observable-Model besitzt und das Model wiederum eine Referenz auf den ViewController hält, entsteht ein klassischer Zyklus. Lösung: im Model nur weak-Referenzen oder ein Closure-basiertes Callback-Muster verwenden.

4. Kein Tracking in beliebigen Methoden

Automatisches Tracking funktioniert nur in den von UIKit vorgesehenen Update-Methoden: updateProperties(), layoutSubviews(), draw(_:), tintColorDidChange() und einige weitere. Liest du Observable-Properties in viewDidAppear() oder einer IBAction, triggert das keinen Auto-Update. Nutze in solchen Fällen explizit withObservationTracking.

5. @ObservationIgnored gezielt einsetzen

Nicht jede Property eines Observable-Models soll die UI invalidieren. Technische Felder (Cache-Keys, Analytics-IDs) markierst du explizit:

@Observable
final class MailboxModel {
    var unreadCount: Int = 0

    @ObservationIgnored
    var analyticsSessionId: String = UUID().uuidString
}

Vergleich: Der alte vs. der neue Weg

Vorher – Combine

final class MailboxModel: ObservableObject {
    @Published var unreadCount: Int = 0
}

final class MailboxViewController: UIViewController {
    let model: MailboxModel
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        model.$unreadCount
            .receive(on: DispatchQueue.main)
            .sink { [weak self] count in
                self?.folderButton.badge = count > 0 ? .count(count) : nil
            }
            .store(in: &cancellables)
    }
}

Nachher – iOS 26

@Observable
final class MailboxModel {
    var unreadCount: Int = 0
}

final class MailboxViewController: UIViewController {
    let model: MailboxModel

    override func updateProperties() {
        super.updateProperties()
        folderButton.badge = model.unreadCount > 0
            ? .count(model.unreadCount)
            : nil
    }
}

Weniger Boilerplate, keine cancellables, kein manuelles Threading, kein explizites Memory-Management für Subscriptions. Ehrlich – genau das, was UIKit seit einem Jahrzehnt gebraucht hat.

Migration in bestehenden Apps – ein realistischer Fahrplan

Für ein Produktionsprojekt empfiehlt sich ein schrittweiser Umstieg (Big-Bang-Migrationen enden erfahrungsgemäß in Tränen):

  1. Einstieg in einem isolierten Feature-Screen wählen. Ideal ist ein neuer Screen ohne Legacy-Abhängigkeiten.
  2. Models umstellen: ObservableObject durch @Observable ersetzen, @Published entfernen. Ein Compiler-Warning-Pass hilft dabei, verbliebene Stellen zu finden.
  3. Combine-Sinks entfernen und Logik nach updateProperties() verschieben.
  4. Info.plist anpassen, falls du iOS 18 als Minimum behältst.
  5. Testen mit updatePropertiesIfNeeded() in Snapshot-Tests, um deterministische UI-Zustände zu erzwingen.

Je nach Projektgröße lassen sich einzelne View-Controller innerhalb weniger Stunden portieren. Ein kompletter Screen mit Collection-View, Header und Detail-Ansicht ist typischerweise an einem Arbeitstag erledigt. Mehr dauert's selten – solange der Model-Layer halbwegs sauber ist.

Häufig gestellte Fragen (FAQ)

Ersetzt @Observable in UIKit vollständig Combine?

Für View-Bindung ja – zumindest in den Fällen, die du bisher mit @Published und sink abgedeckt hast. Combine bleibt aber wertvoll für asynchrone Pipelines mit Operatoren wie debounce, combineLatest oder Netzwerk-Streams. Eine gute Faustregel: Datenfluss ins Model via Combine, Datenfluss vom Model in die UI via @Observable.

Funktioniert updateProperties() auch auf macOS (AppKit)?

Automatisches Observation Tracking kommt in macOS 26 ebenfalls in AppKit an. Die Lifecycle-Methode heißt dort updateProperties() auf NSView/NSViewController, das Verhalten ist analog. Für Cross-Platform-Code kannst du denselben @Observable-Typ auf beiden Plattformen verwenden.

Muss ich meine bestehenden ObservableObject-Klassen migrieren?

Kurzfristig nicht. ObservableObject bleibt funktional. Mittelfristig ist die Migration aber empfehlenswert, weil @Observable präzisere View-Invalidierungen liefert – nur Views, die die tatsächlich geänderte Property lesen, werden aktualisiert. Das reduziert unnötige Redraws in komplexen UIs spürbar.

Kann ich @Observable mit SwiftData kombinieren?

Ja, und das ist einer der nahtlosesten Wege. SwiftData-Models sind bereits observable – Mutationen an einer @Model-Klasse verhalten sich innerhalb von updateProperties() identisch zu @Observable. Du kannst also SwiftData-Entities direkt in UIKit-ViewControllern binden, ohne eine Mapping-Schicht einzuziehen.

Wie debugge ich, warum updateProperties() nicht aufgerufen wird?

Drei häufige Ursachen: (1) Die Property ist mit @ObservationIgnored markiert. (2) Der Property-Read liegt außerhalb einer Update-Methode. (3) Die Mutation passiert auf einem anderen Thread als der MainActor. Ein schneller Check ist withObservationTracking { print(model.unreadCount) } onChange: { ... } – wird das Closure nie aufgerufen, ist das Tracking kaputt, nicht nur UIKit.

Fazit

iOS 26 markiert den Punkt, an dem UIKit endlich dieselbe deklarative Datenbindung bekommt, die SwiftUI seit Jahren prägt. @Observable in Kombination mit updateProperties() reduziert Boilerplate massiv, macht Code lesbarer und eröffnet echte Performance-Gewinne durch granularere Invalidation. Wer heute eine UIKit-Codebasis pflegt, sollte das Feature nicht als Nice-to-have abtun – es ist der direkteste Weg, bestehende Projekte für die nächste Dekade zukunftsfähig zu halten, ohne einen vollständigen SwiftUI-Rewrite zu riskieren.

Über den Autor Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.