The Evolution of SwiftUI Scroll Control APIs and Highlights from WWDC 2024

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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:

Swift
@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:

Swift
@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:

  1. 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.
  2. API design ambiguity: ScrollViewReader adopts the style of a standard container, which may cause ambiguity in expression. This issue also exists in GeometryReader.
  3. Scope limitation: ScrollViewProxy can only be used directly within the closure of ScrollViewReader. If scroll control is needed outside the closure, the proxy must be passed out, increasing code complexity.
  4. 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:

Swift
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:

  1. The declaration is clearer and more in line with SwiftUI’s declarative style.
  2. There’s no need to pass a Proxy object, simplifying the usage process.
  3. 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:

Swift
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.

Swift
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:

Swift
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:

Swift
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)

image-20240622150128887

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.

Swift
ScrollView {
    // ...
}
.onScrollPhaseChange { _, newPhase in
    if newPhase == .decelerating || newPhase == .idle {
        selection = updateSelection()
    }
}

Here’s the detailed definition of ScrollPhase:

Swift
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:

Swift
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:

Swift
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.

Swift
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.

Swift
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:

  1. Enable scrollTargetLayout on the scrollable content container.
  2. Ensure that child views are explicitly labeled with identifiers using the id modifier, or that the data iterated by ForEach conforms to both Identifiable and Hashable 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:

  1. Better understand SwiftUI’s design philosophy and best practices.
  2. Build more elegant and efficient view extensions in our own projects.
  3. Ensure our code style remains consistent with SwiftUI’s overall ecosystem, improving code readability and maintainability.
  4. 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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed