Skip to content

VAndrJ/ObservationTracking

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ObservationTracking

StandWithUkraine Support Ukraine

Language SPM Platform

Swift macros that automatically generate reactive observation patterns using Swift's Observation framework. Provides both basic observation tracking and advanced cancellable observation management.

Overview

ObservationTracking offers four macros:

  • @ObservationTracking: Generates reactive observation for supported top-level assignments, function calls, and if statements
  • @CancellableObservation: Adds observation lifecycle management with cancellation and selective control
  • @StartObservations: Defers a startObservationsIfNeeded() call from an existing method
  • @StopObservations: Defers a stopObservations() call from an existing method

The macros can work together to create observation patterns with minimal boilerplate code.

Requirements

  • Swift 6.0+
  • Xcode 16.0+
  • iOS 17.0+ / macOS 14.0+ / Mac Catalyst 17.0+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+

Required Imports

Macro expansions reference framework symbols directly, so each source file using the macros must import the frameworks needed by the generated code:

import Observation
import ObservationTracking

Installation

Swift Package Manager

Add ObservationTracking to your project through Xcode or by adding it to your Package.swift:

dependencies: [
    .package(url: "https://github.com/VAndrJ/ObservationTracking.git", from: "1.1.2")
]

Or add it through Xcode:

  1. File → Add Package Dependencies
  2. Enter the repository URL: https://github.com/VAndrJ/ObservationTracking.git
  3. Select the version range

Basic Usage

@ObservationTracking Macro

The @ObservationTracking macro automatically generates reactive observation for supported top-level statements in a function:

import ObservationTracking
import Observation

@Observable
class DataModel {
    var count = 0
    var name = "Hello"
    var isActive = false
}

class ViewController {
    @ObservationTracking
    private func bind() {
        label.text = model.name
        countLabel.text = "\(model.count)"
        switchControl.isOn = model.isActive
    }
}

Transformation Scope

@ObservationTracking can be used on instance methods declared in classes, actors, and extensions. Extension support is syntactic: Swift macros cannot prove the extended type is a class or actor, so the compiler validates whether the generated peer methods and [weak self] capture are legal for that extension.

Methods declared directly in a class or actor get generation-based idempotent registration. Calling an annotated method again invalidates its earlier callbacks instead of creating duplicate active registrations. Extension methods remain stateless because Swift extensions cannot add the required stored generation; call those binding methods only once.

The macro transforms these direct top-level statements in the annotated method body:

  • Simple assignments such as property = expression.
  • Function calls containing exactly one syntactically non-literal argument. Other arguments may be literals.
  • if statements containing an assignment or a direct function-call statement in one of their branches. A conditional function call may have any number of arguments because the whole if statement is observed.
@ObservationTracking
func bind() {
    title = model.title              // Tracked
    updateTitle(model.title)         // Tracked when there is one non-literal argument

    if model.isEnabled {             // Tracked as one observation
        subtitle = model.subtitle
    }

    if model.shouldRefresh {         // Calls when true and keeps tracking when false
        refresh()
    }

    if let value = model.detail {    // Tracked as one observation
        detail = value
    }

    subtitle = if model.isEnabled {  // Tracked as an assignment expression
        "Enabled"
    } else {
        "Disabled"
    }

    items.forEach { item in
        lastTitle = item.title       // Not transformed
    }

    defer {
        footer = model.footer        // Not transformed
    }
}

For a supported top-level if, the macro wraps the whole conditional in withObservationTracking, so the condition and whichever branch executes are observed. When the condition is true, the branch runs and observation continues. When it is false, the branch is skipped but the condition remains observed and is evaluated again after its dependencies change. This provides the continuous conditional behavior of SwiftUI's onChange while using the Observation framework directly. The binding also evaluates immediately when the annotated method is called, so an initially true condition runs its branch immediately.

Assignments and function calls inside guard, switch, loops, closures, local functions, defer, and other nested scopes are not transformed independently. Move bindings that must be observed into top-level statements, supported top-level if statements, or helper methods annotated separately with @ObservationTracking.

Generated observers are peer methods, so they cannot capture parameters or local variables from the annotated method. Keep tracked expressions based on instance, global, or static state. Parameterless binding methods are the safest form.

Isolation Control

The @ObservationTracking macro supports an isolation parameter that controls how observation change handlers are generated.

Isolation Options

  • nil or omitted: Infers .mainActor in classes and extensions, or .task in actors.
  • .mainActor: Executes change handlers in Task { @MainActor in ... }.
  • .task: Executes change handlers in an unstructured Task { await ... }. This schedules asynchronous re-observation, but it does not make mutable class state actor-isolated by itself.
  • .synchronous: Executes change handlers directly in the onChange callback without Task wrapping. Use only when synchronous re-observation is safe for your executor and reentrancy model.

Example

For a real-use case, see ExampleScreenView in the Example project.

@CancellableObservation Macro

The @CancellableObservation macro adds advanced observation management to classes:

@CancellableObservation
@MainActor
class WeatherObserver {
    weak var model: WeatherModel?
    var temperature = 0.0
    var humidity = 0.0
    
    @ObservationTracking
    func bind() {
        temperature = model?.temperature ?? 0.0
        humidity = model?.humidity ?? 0.0
    }
    
    // Advanced control methods are automatically generated:
    // - stopObservations()
    // - startObservationsIfNeeded()
    // - observeTemperature()
    // - observeHumidity()
    // - cancelObserveTemperature()
    // - cancelObserveHumidity()
}

Global Observation Management

Control all observations at once:

// Stop all observations
observer.stopObservations()

// Restart observations declared in the same @CancellableObservation class declaration
observer.startObservationsIfNeeded()

Generation-Based Cancellation

The macro uses lightweight monotonic UInt generations to invalidate stale callbacks. Cancelling an observer removes its current generation; restarting it assigns a newer generation, so callbacks from older registrations cannot resume observation.

UIKit Screen Lifecycle

@CancellableObservation(screen: true) is UIKit-style lifecycle glue. It emits override func viewWillAppear(_:) and override func viewDidDisappear(_:) methods that call super, then start or stop observations.

Use screen: true only on UIViewController subclasses or custom screen base classes that already provide overridable viewWillAppear(_:) and viewDidDisappear(_:) methods. Swift macros do not perform full superclass type checking, so applying this option to an arbitrary class can generate invalid override or super calls. If the class already implements these lifecycle methods, the macro adds @StartObservations or @StopObservations to the existing method when possible instead of generating a duplicate method.

Comparison with Regular Approach

Without @ObservationTracking:

class ManualObserver {
    weak var store: SomeStore?
    var localValue1 = 0
    var localValue2 = ""
    
    func startObserving() {
        observeValue1()
        observeValue2()
    }
    
    private func observeValue1() {
        localValue1 = withObservationTracking {
            store?.value1 ?? 0
        } onChange: { [weak self] in
            Task { @MainActor in
                self?.observeValue1()
            }
        }
    }
    
    private func observeValue2() {
        localValue2 = withObservationTracking {
            store?.value2 ?? ""
        } onChange: { [weak self] in
            Task { @MainActor in
                self?.observeValue2()
            }
        }
    }
}

With @ObservationTracking:

class MacroObserver {
    weak var store: SomeStore?
    var localValue1 = 0
    var localValue2 = ""
    
    @ObservationTracking
    func startObserving() {
        localValue1 = store?.value1 ?? 0
        localValue2 = store?.value2 ?? ""
    }
}

Pros and Cons

Pros ✅

  • Reduced Boilerplate: Eliminates repetitive withObservationTracking code
  • Type Safe: Compile-time macro expansion ensures type safety
  • Clean Syntax: Makes intent clear and code more readable
  • UIKit Friendly: Perfect for updating UI elements when data changes
  • Idempotent Registration: Repeated calls to methods declared directly in classes or actors replace earlier registrations instead of multiplying callbacks
  • Advanced Control: Selective observation management with cancellation

Cons ❌

  • Macro Dependency: Requires understanding of Swift macros

What the Macros Generate

@ObservationTracking Expansion

Your code:

@ObservationTracking
private func bind() {
    count = model.value
    name = model.name ?? "default"
}

Generated code:

private func bind() {
    observeCount()
    observeName()
}

private var _observationTrackingGenerationObserveCount: UInt = 0

private func observeCount() {
    _observationTrackingGenerationObserveCount &+= 1
    let generation = _observationTrackingGenerationObserveCount
    count = withObservationTracking {
        model.value
    } onChange: { [weak self] in
        Task { @MainActor in
            guard let self,
                  generation == self._observationTrackingGenerationObserveCount else {
                return
            }
            self.observeCount()
        }
    }
}

private var _observationTrackingGenerationObserveName: UInt = 0

private func observeName() {
    _observationTrackingGenerationObserveName &+= 1
    let generation = _observationTrackingGenerationObserveName
    name = withObservationTracking {
        model.name ?? "default"
    } onChange: { [weak self] in
        Task { @MainActor in
            guard let self,
                  generation == self._observationTrackingGenerationObserveName else {
                return
            }
            self.observeName()
        }
    }
}

Top-level if statements:

@ObservationTracking
private func bind() {
    if model.isEnabled {
        subtitle = model.subtitle
    } else {
        subtitle = ""
    }
}

Generated code:

private func bind() {
    observeSubtitle()
}

private var _observationTrackingGenerationObserveSubtitle: UInt = 0

private func observeSubtitle() {
    _observationTrackingGenerationObserveSubtitle &+= 1
    let generation = _observationTrackingGenerationObserveSubtitle
    withObservationTracking {
        if model.isEnabled {
            subtitle = model.subtitle
        } else {
            subtitle = ""
        }
    } onChange: { [weak self] in
        Task { @MainActor in
            guard let self,
                  generation == self._observationTrackingGenerationObserveSubtitle else {
                return
            }
            self.observeSubtitle()
        }
    }
}

Conditional function calls:

@ObservationTracking
private func bind() {
    if viewModel.isSomethingEnabled {
        someFunction()
    }
}

Generated code:

private func bind() {
    observeSomeFunction()
}

private var _observationTrackingGenerationObserveSomeFunction: UInt = 0

private func observeSomeFunction() {
    _observationTrackingGenerationObserveSomeFunction &+= 1
    let generation = _observationTrackingGenerationObserveSomeFunction
    withObservationTracking {
        if viewModel.isSomethingEnabled {
            someFunction()
        }
    } onChange: { [weak self] in
        Task { @MainActor in
            guard let self,
                  generation == self._observationTrackingGenerationObserveSomeFunction else {
                return
            }
            self.observeSomeFunction()
        }
    }
}

The generated observer always reads isSomethingEnabled. It calls someFunction() only while the value is true, and remains registered while the value is false so a later change can trigger another evaluation.

Isolation Examples

With .task isolation:

@ObservationTracking(isolation: .task)
private func processData() {
    result = model.computation
}

Generated code:

private func processData() {
    observeResult()
}

private var _observationTrackingGenerationObserveResult: UInt = 0

private func observeResult() {
    _observationTrackingGenerationObserveResult &+= 1
    let generation = _observationTrackingGenerationObserveResult
    result = withObservationTracking {
        model.computation
    } onChange: { [weak self] in
        Task {
            guard let self,
                  await generation == self._observationTrackingGenerationObserveResult else {
                return
            }
            await self.observeResult()
        }
    }
}

With .synchronous isolation:

@ObservationTracking(isolation: .synchronous)
private func updateCache() {
    cached = model.data
}

Generated code:

private func updateCache() {
    observeCached()
}

private var _observationTrackingGenerationObserveCached: UInt = 0

private func observeCached() {
    _observationTrackingGenerationObserveCached &+= 1
    let generation = _observationTrackingGenerationObserveCached
    cached = withObservationTracking {
        model.data
    } onChange: { [weak self] in
        guard let self,
              generation == self._observationTrackingGenerationObserveCached else {
            return
        }
        self.observeCached()
    }
}

@CancellableObservation Expansion

Your code:

@CancellableObservation
class Observer {
    @ObservationTracking
    func bind() {
        value = model.property
    }
}

Generated infrastructure:

class Observer {
    func bind() {
        observeValue()
    }

    func observeValue() {
        guard isObservingEnabled else { return }

        observationGeneration &+= 1
        let generation = observationGeneration
        observationTokens["observeValue"] = generation
        value = withObservationTracking {
            model.property
        } onChange: { [weak self] in
            Task { @MainActor in
                guard let self,
                      generation == self.observationTokens["observeValue"] else {
                    return
                }
                self.observeValue()
            }
        }
    }

    func cancelObserveValue() {
        observationTokens.removeValue(forKey: "observeValue")
    }

    // Infrastructure
    private var observationTokens: [String: UInt] = [:]
    private var observationGeneration: UInt = 0
    private var isObservingEnabled = true

    func stopObservations() {
        isObservingEnabled = false
        observationTokens.removeAll()
    }

    func startObservationsIfNeeded() {
        guard !isObservingEnabled || observationTokens.isEmpty else { return }
        isObservingEnabled = true
        bind()  // Calls @ObservationTracking functions in this class declaration
    }
}

How It Works

  1. Observation Detection: The macro scans direct top-level statements in the function body for supported assignments (property = expression), supported function calls, and top-level if statements containing assignments or function calls
  2. Method Generation: Creates individual observer methods for each detected top-level statement
  3. Reactive Wrapping: Wraps assignment right-hand side expressions, supported function call arguments, or whole supported if statements with withObservationTracking
  4. Idempotent Re-observation: Assigns each registration a generation and lets only the latest callback re-execute its observer method
  5. Function Transformation: Replaces detected top-level assignments, function calls, and supported if statements in the original function with calls to observer methods
  6. Cancellation Management: Adds generation-based cancellation and control infrastructure
  7. Automatic Restart: startObservationsIfNeeded() automatically calls @ObservationTracking functions declared in the same @CancellableObservation class declaration

License

This project is licensed under the MIT License - see the LICENSE file for details.

Related

Inspired by the need for cleaner observation code in UIKit.

About

A macro for automatic observation tracking using Swift's Observation framework.

Resources

License

Stars

4 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages