At WWDC 2024, Apple once again introduced a series of remarkable new APIs for SwiftUI’s ScrollView
component. These new features not only enhanced developers’ ability to control scrolling behaviors but also reflected the ongoing evolution of the SwiftUI framework’s design philosophy. This article will explore these latest scroll control APIs and review the development of all significant APIs related to scroll control since the inception of SwiftUI. Through this micro view, we will reveal the changes in SwiftUI’s design style over the past few years and the underlying macro design trends.
2019: Initial Exploration of Declarative Scroll Control
SwiftUI made a stunning debut at WWDC 2019, immediately causing a stir among developers in the Apple ecosystem. This new framework, seamlessly integrated with the Swift language, brought unprecedented simplicity and expressiveness to developers. Compared to other declarative frameworks, SwiftUI’s code was more concise and clear, offering a refreshing experience.
However, although the first version already supported the creation of scrollable containers through methods like List
and ScrollView + LazyVStack
, Apple did not provide effective scroll control APIs. This omission caused numerous inconveniences for developers. As developers gradually delved deeper into SwiftUI’s underlying implementation, they found workarounds by manipulating the underlying UIKit components to achieve scroll control. Nevertheless, developers eagerly anticipated Apple to introduce a set of scroll control systems more aligned with the declarative programming paradigm.
In fact, Apple did make attempts in this direction in the first version of SwiftUI, but possibly due to unsatisfactory implementation results, they ultimately chose to hide these attempts. From iOS 13 up to the current iOS 18, we can still find traces of these early attempts in SwiftUI’s interface
files, including _ScrollView
.
In the _ScrollView
component, developers could exert fine-grained control over the scroll container through _ScrollViewConfig
:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct _ScrollView<Provider> : SwiftUICore.View where Provider : SwiftUI._ScrollableContentProvider {
public var contentProvider: Provider
public var config: SwiftUI._ScrollViewConfig
public init(contentProvider: Provider, config: SwiftUI._ScrollViewConfig = _ScrollViewConfig())
}
public struct _ScrollViewConfig {
public static let decelerationRateNormal: Swift.Double
public static let decelerationRateFast: Swift.Double
public enum ContentOffset {
case initially(CoreFoundation.CGPoint)
case binding(SwiftUICore.Binding<CoreFoundation.CGPoint>)
}
public var contentOffset: SwiftUI._ScrollViewConfig.ContentOffset
public var contentInsets: SwiftUICore.EdgeInsets
public var decelerationRate: Swift.Double
public var alwaysBounceVertical: Swift.Bool
public var alwaysBounceHorizontal: Swift.Bool
public var gestureProvider: any SwiftUI._ScrollViewGestureProvider
public var stopDraggingImmediately: Swift.Bool
public var isScrollEnabled: Swift.Bool
public var showsHorizontalIndicator: Swift.Bool
public var showsVerticalIndicator: Swift.Bool
public var indicatorInsets: SwiftUICore.EdgeInsets
public init()
}
public protocol _ScrollViewGestureProvider {
func scrollableDirections(proxy: SwiftUI._ScrollViewProxy) -> SwiftUI._EventDirections
func gestureMask(proxy: SwiftUI._ScrollViewProxy) -> SwiftUICore.GestureMask
}
extension SwiftUI._ScrollViewGestureProvider {
public func defaultScrollableDirections(proxy: SwiftUI._ScrollViewProxy) -> SwiftUI._EventDirections
public func defaultGestureMask(proxy: SwiftUI._ScrollViewProxy) -> SwiftUICore.GestureMask
public func scrollableDirections(proxy: SwiftUI._ScrollViewProxy) -> SwiftUI._EventDirections
public func gestureMask(proxy: SwiftUI._ScrollViewProxy) -> SwiftUICore.GestureMask
}
public struct _ScrollViewProxy : Swift.Equatable {
public func setContentOffset(_ newOffset: CoreFoundation.CGPoint, animated: Swift.Bool, completion: ((Swift.Bool) -> Swift.Void)? = nil)
public func scrollRectToVisible(_ rect: CoreFoundation.CGRect, animated: Swift.Bool, completion: ((Swift.Bool) -> Swift.Void)? = nil)
public func contentOffsetOfNextPage(_ directions: SwiftUI._EventDirections) -> CoreFoundation.CGPoint
}
From this code, we can see Apple’s initial ambition — to provide comprehensive control capabilities for ScrollView
. However, upon closer examination of these implementations, we find that they significantly deviate from SwiftUI’s overall design philosophy, failing to fully embody the characteristics of declarative programming. This may be the main reason why Apple ultimately decided not to make the _ScrollView
API public.
It’s worth mentioning that besides
_ScrollView
, the first version of SwiftUI also included another unpublished API:_PagingView
. Both these APIs share similar characteristics: they are powerful in functionality but incongruent with the style of other SwiftUI APIs.
These traces of early attempts demonstrate Apple’s struggle to balance feature richness and API design elegance. This stage can be seen as the starting point for the evolution of SwiftUI’s scroll control APIs, laying the foundation for subsequent developments while also highlighting the challenges of implementing complex interaction controls in a declarative framework.
2020: Identifier-Based Scroll Control - The Birth of ScrollViewReader
At WWDC 2020, Apple introduced the ScrollViewReader
container for SwiftUI, marking a significant breakthrough in SwiftUI’s scroll control capabilities. This new API provided identifier-based scroll control functionality, offering developers a declarative-style method to manage scrolling behavior.
Here’s a typical example of using ScrollViewReader
:
@Namespace var topID
@Namespace var bottomID
var body: some View {
ScrollViewReader { proxy in
ScrollView {
Button("Scroll to Bottom") {
withAnimation {
proxy.scrollTo(bottomID)
}
}
.id(topID)
VStack(spacing: 0) {
ForEach(0..<100) { i in
color(fraction: Double(i) / 100)
.frame(height: 32)
}
}
Button("Top") {
withAnimation {
proxy.scrollTo(topID)
}
}
.id(bottomID)
}
}
}
The design of ScrollViewReader
cleverly leverages SwiftUI’s view identification mechanism. For data in ForEach
that conforms to both Identifiable
and Hashable
protocols, SwiftUI automatically uses it as the identifier for child views, which can be directly used for scroll control. For views outside ForEach
, developers need to explicitly set identifiers using the id
modifier. This design not only simplified the implementation of scroll control but also deepened developers’ understanding of SwiftUI’s view identification mechanism.
However, as SwiftUI’s first API specifically for scroll control, ScrollViewReader
also has some limitations:
- Limited precise control: It lacks the ability to precisely control based on the global scrollable content. To achieve more accurate positioning, developers often need to add extra identifiers to the scrollable content.
- API design ambiguity:
ScrollViewReader
adopts the style of a standard container, which may cause ambiguity in expression. This issue also exists inGeometryReader
. - Scope limitation:
ScrollViewProxy
can only be used directly within the closure ofScrollViewReader
. If scroll control is needed outside the closure, the proxy must be passed out, increasing code complexity. - Limited functionality: It can only control scroll position but doesn’t allow developers to sense the current scroll position or scroll state.
Despite these issues, ScrollViewReader
remains the only scroll control API that supports List
, ensuring its important position in the SwiftUI ecosystem. Its introduction not only filled the gap in SwiftUI’s scroll control capabilities but also provided valuable experience and insights for subsequent API designs.
2023: Comprehensive Feature Explosion and Maturation of API Design
In the two years following the release of ScrollViewReader
, progress in SwiftUI’s scroll control functionality was relatively slow, with only minor improvements such as scrollDisabled
. However, WWDC 2023 marked a significant breakthrough in this area, with Apple introducing a plethora of new scroll-related APIs, many of which directly involved scroll control.
For a comprehensive understanding of all new scroll-related APIs introduced at WWDC 2023, please read ”Deep Dive into the New Features of ScrollView in SwiftUI 5“.
Drawing from the lessons learned with ScrollViewReader
, Apple introduced a more elegant scrollPosition
view modifier when designing new scroll control tools:
struct ScrollPositionDemo: View {
@State var rows = (0 ..< 100).map { _ in Row(id: UUID()) }
@State var positionID: UUID?
var body: some View {
ScrollView {
ForEach(rows) { row in
row
}
}
.scrollPosition(id: $positionID, anchor: .top)
Button("Top") {
positionID = rows.first?.id
}
Button("Bottom") {
positionID = rows.last?.id
}
}
}
struct Row: View, Identifiable, Hashable {
let id: UUID
var body: some View {
Rectangle()
.foregroundStyle(.red.gradient)
.frame(height: 100)
.overlay(
Text("\(id)")
)
}
}
Compared to ScrollViewReader
, the design of scrollPosition
is more concise and clear:
- The declaration is clearer and more in line with SwiftUI’s declarative style.
- There’s no need to pass a
Proxy
object, simplifying the usage process. - Scroll position control has been upgraded from unidirectional adjustment to bidirectional awareness, aligning better with the reactive programming paradigm.
Unfortunately, from this version onwards, the new scroll control APIs no longer support List
, which to some extent limits their application range. This also hints at the possibility that Apple might change the underlying implementation of List
or integrate List
’s unique features into LazyVStack
in the future.
Additionally, the newly added defaultScrollAnchor
greatly simplified the process of setting the initial position of scroll views. Previously, developers typically needed to explicitly manipulate ScrollViewProxy
in onAppear
or task
.
Although the API design in 2023 was more reasonable, it still didn’t provide scroll control capabilities based on the scrollable content as a whole, leaving room for improvement in this area.
Besides the core scroll control APIs, WWDC 2023 also brought a series of new features for child views within scroll containers:
- scrollTargetBehavior: Allows customization of
ScrollView
’s scrolling behavior, including options like paging and child view alignment. - NamedCoordinateSpace.scrollView: Enables child views to conveniently obtain their current position within the scroll view.
- scrollTransition: Allows child views to quickly adjust their appearance based on their state relative to the scroll container, using an enumeration approach.
- scrollTargetLayout: Allows developers to control the activation of scroll state awareness, finding a balance between feature richness and performance optimization.
This version of SwiftUI not only introduced new features but also demonstrated significant progress in API design style. Many new modifiers that maintain context integrity were added, such as visualEffect
, scrollTransition
, new versions of animation
and transaction
:
CellView()
.scrollTransition(.animated) { content, phase in
content
.scaleEffect(phase != .identity ? 0.6 : 1)
.opacity(phase != .identity ? 0.3 : 1)
}
ContentRow()
.visualEffect { content, geometryProxy in
content.offset(x: geometryProxy.frame(in: .global).origin.y)
}
Rectangle()
.transaction {
$0.animation = .none
} body: {
$0.scaleEffect(scale ? 1.5 : 1)
}
This style of API is more specific in description, provides richer parameter information, and is more convenient to use. The new APIs introduced in WWDC 2024 also extensively use this approach.
It can be said that by 2023, SwiftUI’s API style had gradually matured.
2024: Breakthrough Scroll Control Capabilities
Despite Apple’s significant enhancement of scroll-related API functionalities in 2023, they once again exceeded developers’ expectations at WWDC 2024, bringing a series of innovations and breakthroughs to scroll control. The highlight of this update is that scroll control is no longer limited to traditional identifier-based methods. Instead, it introduces a new control logic that operates on the scrollable view content as a whole, providing developers with more intuitive and powerful tools.
Newly Upgraded scrollPosition
The scrollPosition
functionality introduced last year has been significantly enhanced and expanded in this version. The new ScrollPosition
struct in this version cleverly integrates multiple scroll control capabilities, providing a more unified and powerful interface.
struct ScrollPositionDemo: View {
@State var rows = (0 ..< 100).map { _ in Row(id: UUID()) }
@State var scrollPosition = ScrollPosition()
var body: some View {
ScrollView {
LazyVStack {
ForEach(rows) { row in
row
}
}
}
.scrollPosition($scrollPosition)
Button("Content Top") {
// Scroll to the top position of the scroll container content
scrollPosition.scrollTo(edge: .top)
}
Button("First Row") {
// Scroll to the first child view
scrollPosition.scrollTo(id: rows.first!.id)
}
Button("Top Position") {
// Scroll to Y-axis position 0 of the scroll container content
scrollPosition.scrollTo(y: 0)
}
}
}
A major highlight of this new API is its concise and intuitive usage. The advantages of the new API are particularly evident when developers only need to operate on the scroll container’s content as a whole, without requiring precise control at the child view level. In this case, we no longer need to ensure that the data iterated by ForEach
conforms to both Identifiable
and Hashable
protocols, nor do we need to use the id
modifier to explicitly annotate identifiers. This design greatly simplifies the code structure and improves development efficiency. For example, the following code demonstrates how to concisely implement the functionality of scrolling the container to the top:
ScrollView {
LazyVStack {
// No need to worry about iteration data type or use id to add identifiers
ForEach(0..<100) { row in
Text("\(row)")
}
}
}
.scrollPosition($scrollPosition)
Button("Content Top") {
// Scroll to the top position of the scroll container content
scrollPosition.scrollTo(edge: .top)
}
Button("Top Position") {
// Scroll to Y-axis position 0 of the scroll container content
scrollPosition.scrollTo(y: 0)
}
Enhanced Capabilities of defaultScrollAnchor
At WWDC 2024, the defaultScrollAnchor
functionality was further enhanced. This improvement not only expanded its ability to define initial scroll positions but also introduced a new feature: dynamically adjusting view alignment in specific scenarios. This functionality allows developers to precisely control view alignment behavior when the content size is smaller than the scroll container, or when the content and container sizes change.
The following code demonstrates the practical application of this new feature:
ScrollView {
Rectangle()
.foregroundColor(.orange)
.frame(width: 200, height: 100)
}
.scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .alignment) // Align to bottom when content size is smaller than scroll container
.frame(height: 400)
.border(.blue)
Although developers cannot directly obtain the current state of the scroll container from
ScrollPosition
, Apple has provided a set of new APIs that are more aligned with ergonomic principles. These new tools not only simplify the development process but also help developers more intuitively perceive and control scrolling behavior.
onScrollPhaseChange
onScrollPhaseChange
introduces an enum-based scroll state description (ScrollPhase
), providing developers with unprecedented scroll state awareness capabilities. This precise state description is even difficult to obtain so conveniently in UIKit APIs.
ScrollView {
// ...
}
.onScrollPhaseChange { _, newPhase in
if newPhase == .decelerating || newPhase == .idle {
selection = updateSelection()
}
}
Here’s the detailed definition of ScrollPhase
:
public enum ScrollPhase : Equatable {
case idle
case tracking
case interacting
case decelerating
case animating
public var isScrolling: Bool { get }
}
This detailed state division allows developers to more precisely control and respond to scrolling behaviors, thereby creating a more fluid and intuitive user experience.
onScrollGeometryChange
onScrollGeometryChange
opens a new window for developers, enabling us to respond in real-time to geometric information of the scrollable content as a whole during the scrolling process. Its design philosophy is highly consistent with onGeometryChange
, but it focuses on the specific needs of scroll views. The ScrollGeometry
struct provides rich information:
public struct ScrollGeometry : Equatable, Sendable {
public var contentOffset: CGPoint
public var contentSize: CGSize
public var contentInsets: EdgeInsets
public var containerSize: CGSize
public var visibleRect: CGRect { get }
public var bounds: CGRect { get }
}
The following code demonstrates how to use the new APIs (onScrollPhaseChange
+ onScrollGeometryChange
) to detect the scrolling direction of a scroll container:
struct ScrollDirection: View {
@State var direction = Direction.none
var body: some View {
Text(direction.rawValue)
ScrollView {
ForEach(0 ..< 100) {
Text("\($0)")
}
}
.onScrollPhaseChange { _, phase in
if phase == .idle {
direction = .none
}
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { oldY, newY in
if oldY < newY {
direction = .up
} else {
direction = .down
}
}
}
}
enum Direction: String {
case up
case down
case none
}
The MapKit framework also provides the
onScrollGeometryChange
functionality, allowing developers to easily respond to position changes in map containers. This cross-framework consistency in design significantly reduces the learning curve and improves development efficiency.
onScrollVisibilityChange
onScrollVisibilityChange
provides a unique trigger timing for child views, distinct from onAppear
. When a child view enters the visible area of the scroll view, this modifier uses a closure to report whether the child view has reached a preset visibility threshold. The following example code demonstrates how to use this feature: when the visible area of a child view in the scroll container exceeds 30%, it adds a prominent red border to it.
struct OnScrollVisibilityChangeDemo: View {
@State var visibilityID: Int?
var body: some View {
ScrollView {
ForEach(0 ..< 100) { i in
Rectangle()
.foregroundStyle(.green.gradient)
.frame(height: 100)
.border(visibilityID == i ? .red : .clear, width: 8)
.overlay(
Text("\(i)")
)
.onScrollVisibilityChange(threshold: 0.3) { visibility in
if visibility {
visibilityID = i
}
}
}
}
}
}
onScrollVisibilityChange
not only provides an ideal trigger timing for non-lazy containers, but its flexibility is also demonstrated in the threshold
parameter. The cleverness of this parameter lies in its acceptance of both positive and negative values, allowing developers to precisely adjust its trigger timing relative to onAppear
in lazy containers.
onScrollTargetVisibilityChange
onScrollTargetVisibilityChange
is functionally similar to onScrollVisibilityChange
, but it acts directly on the outer layer of ScrollView
. This modifier can identify and report the identifiers of all child views that are currently within the visible area of the scroll container and meet a preset threshold.
The following code example demonstrates how to use this feature to dynamically change the appearance of child views: when the visible area of a child view exceeds 90%, its background color changes from green to red.
struct OnScrollVisibilityChangeDemo: View {
@State var ids = [Int]()
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Rectangle()
.foregroundStyle(ids.contains(where: { $0 == i }) ? .red : .green)
.frame(height: 200)
.overlay(
Text("\(i)")
)
.id(i)
}
}
.scrollTargetLayout()
}
.onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.9) { ids in
self.ids = ids
}
}
}
To successfully use onScrollTargetVisibilityChange
, two conditions need to be met:
- Enable
scrollTargetLayout
on the scrollable content container. - Ensure that child views are explicitly labeled with identifiers using the
id
modifier, or that the data iterated byForEach
conforms to bothIdentifiable
andHashable
protocols.
Design Features of New APIs in WWDC 2024
These new APIs introduced at WWDC 2024 continue and deepen the design philosophy of WWDC 2023, further expanding the family of scenario-responsive modifiers prefixed with on
. Notably, in the dual-closure version of responsive modifiers, Apple cleverly introduced processing logic specifically for data type conversion. This design not only improved the code’s specificity and clarity but also provided developers with greater flexibility.
These precise response mechanisms greatly reduced developers’ reliance on the onChange
modifier, making view code more concise and clear. As the SwiftUI API design style matures, we have reason to expect to see more similar modifiers in future versions, such as onNavigatorPhaseChange
, onTabViewGeometryChange
, etc. This trend will not only further enhance SwiftUI’s expressiveness but also provide developers with more precise and intuitive control methods.
Summary
This article discussed the evolution of SwiftUI scroll control APIs since the framework’s inception. We not only reviewed the important APIs at each stage but also analyzed and compared their design philosophies and implementation methods.
Looking back at this evolution process, we can clearly see Apple’s continuous innovation and optimization in API design. From initial experimental attempts to gradually forming a unified, elegant declarative style.
By learning and drawing inspiration from the design ideas of these official APIs, we can:
- Better understand SwiftUI’s design philosophy and best practices.
- Build more elegant and efficient view extensions in our own projects.
- Ensure our code style remains consistent with SwiftUI’s overall ecosystem, improving code readability and maintainability.
- Foresee future API development trends, making our projects more forward-looking and adaptable.
In conclusion, by exploring the evolution of SwiftUI scroll control APIs, we can not only become more proficient in using these tools but also improve our API design capabilities on a more macro level, thereby creating more modern, unified applications that better align with the spirit of SwiftUI.