SwiftUI's New Geometry Modifiers & Best Practices: onGeometryChange, onScrollGeometryChange, and More

SwiftUI has recently introduced a family of geometry observation APIs: onGeometryChange, onScrollGeometryChange, and onScrollPhaseChange. The former is back deployed all the way back to iOS 16 and macOS 13 Ventura. These new modifiers are a pretty big deal in a few ways.

SwiftUI's New Geometry Modifiers & Best Practices: onGeometryChange, onScrollGeometryChange, and More

SwiftUI has recently introduced a family of geometry observation APIs: onGeometryChange, onScrollGeometryChange, and onScrollPhaseChange. The former is back deployed all the way back to iOS 16 and macOS 13 Ventura. These new modifiers are a pretty big deal in a few ways. They provide greater flexibility and support for a growing variety of use cases, and their introduction serves as a general indicator of a revised, more developer-friendly direction for the evolution of SwiftUI.

State of the Art

Until now, if you wanted to observe your view’s size or frame, you’d need to insert a GeometryReader as a background or overlay of your view. This increased the complexity of the graph and, depending on the setup, made layout and hit testing logic more complex and thus slower. You could avoid some of the cost by explicitly disabling hit testing and using PreferenceKey tracking to prevent state invalidations. However, even with optimizations, the cost of observing a view’s geometry was never close to zero.

The philosophy of “you describe the UI, the framework does the rest” can only take you so far. The need for observing views’ geometry changes does not represent that complex of a use case, and is actually required for a lot of fairly common use cases, such as occlusion tracking, manual layout, and scroll effects. The team acknowledging the need for these use cases is really promising for the future evolution of the framework.

New Modifiers

The new iOS 18 and macOS 15 aligned SDKs include the following new APIs:

  • onGeometryChange: Allows observation of general geometry changes for any view. This modifier is back deployed to iOS 16 and macOS 13 aligned targets.
  • onScrollGeometryChange: Specifically designed for observing scroll view geometry changes.
  • onScrollPhaseChange: Provides information about the current phase of a scroll view's gesture.

The new geometry modifiers are fundamentally more efficient than any of the previously (publicly) available approaches, no matter the level of optimizations, since they expose layout information that SwiftUI already manages internally.

The design of the new APIs also encourages “correct” usage. These modifiers utilize a pattern where developers provide two closures:

  • A transform closure that converts a GeometryProxy or ScrollGeometry to a data type you specify.
  • An action closure that's called when the transformed data changes.

This approach allows for more efficient handling of geometry changes by encouraging the reduction of unnecessary updates and graph invalidations. The transform closure acts as a filter, converting raw geometry data into a more meaningful representation for your app's logic.

Performance

While these new APIs offer enhanced capabilities, they should be used judiciously. Observing geometry changes can potentially increase the number of invalidations in your UI code. If every geometry change triggers a state update, it may lead to more frequent re-renders.

By using an Equatable type as the result of this transformation, SwiftUI can efficiently determine when changes occur that are actually meaningful, potentially reducing the number of times the action closure is called and therefore the number of times you have to modify some state.

For example, consider a use case where your goal is to observe when the user scrolls beyond the origin in a ScrollView’s contents in order to implement a custom pull-to-refresh UI.

The less efficient approach would look like this:

ScrollView {
    // ...
}
.onScrollGeometryChange(for: CGPoint.self) { geometry in
    geometry.contentOffset
} action: { _, newOffset in
    self.scrollContentOffset = newOffset
}

This would set the value of some state variable every time the offset changed (which would happen very frequently during an interactive scroll gesture), thus invalidating large portions of your view hierarchy.

However, the API encourages you to think about the data that is actually meaningful to you, and do the relevant transformations inside the modifier’s closure, before setting any state. The more efficient approach would be the following:

ScrollView {
    // ...
}
.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y < geometry.contentMargins.top
} action: { _, isPullingToRefresh in
    self.isPullingToRefresh = isPullingToRefresh
}

As another example, if you're only interested in whether a view is occluded, your transform closure could return a simple boolean. SwiftUI would then only call your action closure when this boolean changes, not on every minor geometry update. This can lead to significant performance improvements compared to reacting to every geometry change.

Compatibility

It's worth noting that the universal onGeometryChange modifier back-deploys to iOS 16 and macOS 13. This indicates that the functionality has been available internally as SPI for a while, but the SwiftUI team was hesitant to release it as part of the public SDK. Given that the efficient use of the API requires some understanding of the internal framework behavior, the hesitation is understandable. The final API design does strike a good balance between providing the much needed flexibility while guiding the developer towards the more efficient usage patterns.

It is interesting to speculate about the revised direction of API design patterns, both in terms of the tiered API surface and framework transparency. Instead of only exposing functionality that obscures close to all implementation details and removes the majority of the need for a developer to understand the inner workings of a framework, the framework can expose more complex systems to allow for deeper customization and control while still keeping the general API surface simple and relatively beginner-friendly.

Use Cases

Finally having access to meaningful information about your views at runtime greatly enhances the variety of use cases you can support in the UI code. Additional potential use cases include:

  • Implementing custom pull-to-refresh mechanisms;
  • Creating parallax effects based on scroll position;
  • Dynamically adjusting layouts based on available space;
  • Disabling effects or animations during an interactive scroll to improve performance;
  • Implementing advanced scroll gesture based navigation systems.

However, before observing geometry, it may be wise to consider alternative solutions, such as using other more targeted functionality the framework provides, or bridging a custom view from AppKit or UIKit. For example, when implementing complex ScrollView behaviors, consider whether ScrollViewReader or scrollPosition(id:anchor:) would provide the functionality you are looking for. When you do decide to adopt these new APIs in your UI code, be mindful of the impact and considerations.

  • Equatable Conformance: The custom data type used in onGeometryChange and onScrollGeometryChange must conform to Equatable. This allows SwiftUI to efficiently determine when meaningful changes occur.
  • Always use the most specific data type for onGeometryChange and onScrollGeometryChange: this will prevent frequent invalidations.
  • Scroll View Specificity: onScrollGeometryChange and onScrollPhaseChange only respond to changes from the outermost scroll view in the view hierarchy. Using these modifiers with nested scroll views may lead to unexpected behavior.