# Perception backports Swift observation to iOS 13 and macOS 10.15

> Perception is a Point-Free library that reimplements Swift's Observation macros and runtime for older Apple platforms, using a @Perceptible macro, a WithPerceptionTracking wrapper, and a Perceptions async sequence. It is MIT licensed, at version 2.0.12, and comes with a compatibility scheme in its example project that is built against an iOS 18.5 simulator.

**pointfreeco/swift-perception** — Observable tools, backported.

- Repository: https://github.com/pointfreeco/swift-perception
- Stars: 811 · Forks: 88
- Language: Swift
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/pointfreeco-swift-perception

## Observation, four platform versions back

Perception is described in one line as Swift's Observation tools, back-ported, and the second line says it more precisely: the Observation tools back-ported to more platforms.

The specifics are in the Overview. The library back-ports @Observable, withObservationTracking and Observations all the way back to iOS 13, macOS 10.15, tvOS 13 and watchOS 6, which means you can take advantage of all of Swift's observation tools today even if you cannot drop support for older Apple platforms.

That is the whole proposition. Observation arrived in newer Swift releases and is the mechanism that lets a view redraw when the state it reads changes, without a manual invalidation step. Teams with a wide deployment target, a long tail of devices, or a corporate requirement to support older systems, have traditionally had to choose between that model and their reach.

The library was created by Brandon Williams and Stephen Celis, who host the Point-Free video series on advanced Swift language concepts, and the repository carries the usual Point-Free furniture: a Slack badge, a documentation badge pointing at the Swift Package Index, a CI badge, and links to the discussions tab and the community Slack.

It is MIT licensed, and the current version is 2.0.12, published on 2026-08-30, after 2.0.11 in July 2026 and 2.0.10 in March 2026.

## @Perceptible instead of @Observable

Using the backport is almost exactly like using the official tools, with one small exception according to the README, and the exception is the macro name.

To begin, you mark a class as observable with the @Perceptible macro instead of the @Observable macro.

```swift
@Perceptible
class FeatureModel {
  var count = 0
}
```

Everything after that reads like the modern API. The model is held in a view with a regular let property rather than a wrapper, which is the shape the Observation framework settled on.

```swift
struct FeatureView: View {
  let model: FeatureModel

  // ...
}
```

The reason the macro has a different name is not cosmetic in the sense of style, it is functional in the sense of symbol collision. A library cannot ship a macro named Observable and have the compiler pick between that and the standard library's own macro in every file, and the same goes for the property wrappers and the view wrapper further down. Renaming is how the two can coexist in one project, and that is also why the backported names are consistently prefixed.

The result is that a codebase can adopt Perception today, and later remove it by renaming @Perceptible back to @Observable and deleting the wrapper view, which is the migration path the naming implies.

## WithPerceptionTracking, and the warning that catches your mistake

There is one behavioural difference, and the README is honest about it being unfortunate.

In the view's body you must wrap your content using the WithPerceptionTracking view in order for observation to be correctly hooked up.

```swift
struct FeatureView: View {
  let model: FeatureModel

  var body: some View {
    WithPerceptionTracking {
      Form {
        Text("\(model.count)")
        Button("Increment") { model.count += 1 }
      }
    }
  }
}
```

The reason is that the official withObservationTracking API takes a closure and sets up tracking for the duration of that closure, while a SwiftUI body is a computed property that the framework evaluates on its own schedule, so the tracking has to be established by a view wrapper instead.

The cost of forgetting is handled well. The README says that if you forget, you get a runtime warning telling you that observation is not set up correctly.

> Runtime Warning: Perceptible state '\FeatureModel.count' was accessed from a view but is not being tracked.

That is the difference between an hour of debugging and a day. A silent failure here would look like a stale view that never updates, and the warning names the property and the type, so the fix is obvious once you know to look for it.

## Perceptions, the async sequence with a new name

The third piece of the modern API is the Observations async sequence, and its backport is called Perceptions.

It is used to observe changes to perceptible and observable objects over time.

```swift
let counts = Perceptions { model.count }
for await count in counts {
  print("Count changed: \(count)")
}
```

The shape is a familiar one for anyone who has used the Observation framework. The sequence is created from a closure that reads the state you care about, and you consume it with for await, so a change to the observed value produces a new element. That makes it the tool for reacting to state outside a view, for example pushing a value to a background task, mirroring it into a different store, or driving an imperative API from a declarative one.

Because the type is named Perceptions rather than Observations, code that uses both can coexist, and a project can move between them incrementally, which is the same argument as the macro rename and the wrapper view.

The other property wrappers come along too, and they are qualified rather than renamed. SwiftUI's @Bindable has been back-ported to support perceptible objects, and you qualify it with the Perception module.

```swift
struct FeatureView: View {
  @Perception.Bindable var model: FeatureModel

  // ...
}
```

## Environment support, and the compatibility scheme

The last piece is environment. SwiftUI's @Environment property wrapper and the environment view modifier's support for observation have also been back-ported to support perceptible objects, using the exact same APIs.

```swift
struct FeatureView: View {
  @Environment(Settings.self) var settings

  // ...
}

// In some parent view:
.environment(settings)
```

No wrapper and no new spelling, which is the point. A settings object injected through the environment behaves the same way it does with the official macro, so the code a team writes against Perception reads like the code it would write against the framework itself.

The repository is a normal Swift package. There is a Package.swift and a separate Package@swift-6.0.swift, which is the manifest versioning mechanism for declaring a different package configuration per Swift language mode, along with a Package.resolved, a .spi.yml for the Swift Package Index, Sources/ and Tests/ directories, and an Example/ folder holding an Xcode project with a Compatibility scheme and its own package manifest.

The Makefile is where the backport gets tested rather than assumed. The test-compatibility target builds that Compatibility scheme with xcodebuild, skipping macro validation, and it first runs a warm-simulator target that boots an iOS 18.5 iPhone Pro simulator with simctl and opens the Simulator app. The format target runs swift format over the sources, skipping the Documentation.docc directories.

Running the example on a current SDK is how the maintainers check that the backport still behaves like the thing it copies.

## Where the backport is the wrong choice

Three situations rule it out.

The first is a project that can use the real thing. If your deployment target is a recent OS version, the official @Observable, withObservationTracking and Observations exist, they are maintained by the Swift toolchain itself, and they will keep evolving with the language. A backport of a moving API is a maintenance liability, because every new observation feature in a future Swift release has to be re-implemented or left out.

The second is a project that does not need observation at all, or that has already solved the problem. A small app with a handful of views is fine with plain state and bindings, and a project that has an existing reactive layer, whether Combine or something else, will find two reactive systems harder to reason about than one.

The third is a team that has not internalised the wrapper requirement. The WithPerceptionTracking wrapper is the sharp edge of this library, and the runtime warning catches it only in development, only in the view that reads the state, and only if the build keeps the warning visible. In a large codebase, a systematic omission of the wrapper produces a set of views that quietly stop updating, and the cost of finding those is much higher than the cost of adding the wrapper in the first place.

For a team with a real deployment floor, though, Perception is a small, MIT licensed library that makes the modern model available now, with the sharp edge clearly documented and a compatibility scheme that proves it still works.

## Conclusion

Perception fits a team that wants Swift's observation model today but still supports older Apple platforms, since it provides the same API shape with the same runtime behaviour rather than a home grown alternative. It is the wrong choice for a project that has already moved to the modern Observation framework and can drop the old deployment targets, because there the original tools are better supported. Before adopting it, read the runtime warning in the README, since forgetting to wrap a view in WithPerceptionTracking is the mistake that will cost you the most time, and check the compatibility scheme in the example project, which is what the maintainers use to verify the backport against a current SDK.

## FAQ

### What does the Perception library backport?

It back-ports @Observable, withObservationTracking and Observations back to iOS 13, macOS 10.15, tvOS 13 and watchOS 6, and also back-ports SwiftUI's @Bindable and @Environment support for observable objects.

### How do I use Perception instead of the built-in Observation tools?

Replace the @Observable macro with @Perceptible, wrap the view's content in the WithPerceptionTracking view, and use Perceptions in place of Observations. The README says the tools work almost exactly as the official ones, with that one exception.

### What happens if I forget WithPerceptionTracking?

You get a runtime warning telling you that perceptible state was accessed from a view but is not being tracked, for example that Perceptible state '\FeatureModel.count' was accessed from a view but is not being tracked, so the view would not update.

### How is the Perceptions async sequence used?

Create it from a closure that reads the state you want to watch and then consume it with for await, for example let counts = Perceptions { model.count } followed by a for await loop that prints each new value.

### How do the maintainers verify that the backport still works?

The repository includes an Example project with a Compatibility scheme, and the Makefile builds it with xcodebuild and the skipMacroValidation flag against a booted iOS 18.5 iPhone Pro simulator. The package also ships a separate Package@swift-6.0.swift manifest alongside Package.swift.

## Sources

- [Issues](https://github.com/pointfreeco/swift-perception/issues)
- [License: MIT](https://github.com/pointfreeco/swift-perception/blob/main/LICENSE)
- [pointfreeco/swift-perception on GitHub](https://github.com/pointfreeco/swift-perception)
- [README](https://github.com/pointfreeco/swift-perception/blob/main/README.md)
- [Releases](https://github.com/pointfreeco/swift-perception/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/pointfreeco-swift-perception
