Mastering SwiftUI Scrolling: Implementing Custom Paging

Published on

Starting with iOS 17, SwiftUI introduced scrollTargetBehavior, enabling developers to control scrolling behavior with greater precision. Whether it’s aligning views at rest or implementing custom paging effects, scrollTargetBehavior offers robust support. More importantly, developers can create custom ScrollTargetBehavior implementations to meet specific needs. This article will walk through a real-world example, step by step, to demonstrate how to use scrollTargetBehavior and ultimately implement a custom scrolling control logic.

Problem: Limitations of Default Paging

A few days ago, a developer raised an issue with scrollTargetBehavior: when using the default paging behavior, scrolling in landscape mode (Landscape) resulted in misalignment, failing to snap to the correct page.

Swift
struct Step0: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(0 ..< 20) { page in
                    Text(page, format: .number)
                        .font(.title)
                        // Set the width of each child view to match the ScrollView container's width
                        .containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0, alignment: .center)
                        .frame(height: 200)
                        .background(.secondary.opacity(0.3))
                        .border(.red, width: 2)
                }
            }
        }
        .border(.blue, width: 2)
        .scrollTargetBehavior(.paging)
    }
}

This issue surprised me, as paging logic should be relatively straightforward. To quickly address the problem, I first tried using the Introspect library to directly enable the isPagingEnabled property of the underlying UIScrollView.

Swift
ScrollView {
    .... 
}
.introspect(.scrollView, on: .iOS(.v17), .iOS(.v18)) {
    $0.isPagingEnabled = true
}

However, the result was identical to using .scrollTargetBehavior(.paging), with the same misalignment issue in landscape mode. This led me to realize that the default paging behavior might not actually rely on ScrollTargetBehavior for its implementation.

I have reported this issue to Apple (FB16486510).

Alternative Solution: Can viewAligned Fix the Problem?

Given that each view in the developer’s code had a width exactly matching the scroll container’s width, I suggested trying viewAligned, a mode that ensures the edges of the view align with the container’s edges at the end of a scroll.

Swift
struct Step1: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(0 ..< 20) { page in
                    Text(page, format: .number)
                        .font(.title)
                        .containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0, alignment: .center)
                        .frame(height: 200)
                        .background(.secondary.opacity(0.3))
                        .border(.red, width: 2)
                }
            }
            // viewAligned requires scrollTargetLayout
            .scrollTargetLayout()
        }
        .border(.blue, width: 2)
        // Limit scrolling to one view at a time
        .scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
    }
}

viewAligned offers several levels of control precision:

  • alwaysByOne: Scroll one view at a time
  • alwaysByFew: Scroll a small number of views
  • never: No limit on the number of views scrolled

However, during testing, I discovered that alwaysByOne does not guarantee scrolling only one view at a time. In the current code, since each child view’s width matches the scroll container’s width, it coincidentally achieves a paging-like effect. But if the child views are narrower, the scrolling distance becomes unpredictable.

Additionally, viewAligned requires the scroll container’s content to consist of multiple child views, making it unsuitable for scenarios like Swift Charts. At this point, implementing a custom paging behavior seemed like the only viable solution.

Custom Paging Implementation

ScrollTargetBehavior empowers developers to customize scrolling behavior. Its declaration is as follows:

Swift
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol ScrollTargetBehavior {
    /// Updates the scroll target position
    func updateTarget(_ target: inout ScrollTarget, context: Self.TargetContext)

    /// The context in which a scroll behavior updates the scroll target.
    typealias TargetContext = ScrollTargetBehaviorContext
}

SwiftUI calls updateTarget at the end of a scroll gesture, allowing developers to adjust the target position.

ScrollTargetBehaviorContext provides the following key information:

  • originalTarget: The target position at the start of the gesture
  • velocity: The velocity vector
  • contentSize: The size of the scrollable content
  • containerSize: The size of the scroll container
  • axis: The scrollable axis

ScrollTarget not only sets the target position but also provides the scroll target calculated by ScrollView based on the gesture.

Next, we will iteratively refine a custom paging implementation through multiple versions.

Version 1: Simple Implementation Based on Velocity

In the first version, we determine the scroll direction based on the velocity vector and adjust the target position by adding or subtracting the scroll container’s width.

Swift
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        // Current ScrollView width
        let scrollViewWidth = context.containerSize.width

        // Adjust the target position based on scroll direction
        if context.velocity.dx > 0 {
            // Scroll right: target position = starting position + ScrollView width
            target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
        } else if context.velocity.dx < 0 {
            // Scroll left: target position = starting position - ScrollView width
            target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
        }
    }
}

extension ScrollTargetBehavior where Self == CustomHorizontalPagingBehavior {
    static var horizontalPaging: CustomHorizontalPagingBehavior { .init() }
}

// Usage
.scrollTargetBehavior(.horizontalPaging)

This implementation seems reasonable but has a clear flaw: if the scroll gesture ends with zero velocity, the paging action will not trigger.

Version 2: Improved Implementation Based on Scroll Distance

In the second version, we determine the scroll direction by calculating the difference between the target position and the starting position. We then decide whether to page based on a preset condition (e.g., scroll distance exceeding 1/3 of the container’s width).

Swift
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        // Current ScrollView width
        let scrollViewWidth = context.containerSize.width
        
        // Scroll distance
        let distance = context.originalTarget.rect.minX - target.rect.minX
        // Adjust the target position based on scroll distance
        // If the scroll distance exceeds 1/3 of the ScrollView width, switch to the next page
        if abs(distance) > scrollViewWidth / 3 {
            if distance > 0 {
                target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
            } else {
                target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
            }
        } else {
            // If the scroll distance is less than 1/3, return to the original position
            target.rect.origin.x = context.originalTarget.rect.minX
        }
    }
}

However, this version still has issues: if scrolling occurs before the previous scroll has fully stopped, the starting position may already be misaligned, leading to inaccurate paging.

Version 3: Robust Paging Control

In the third version, we not only address the previous issues but also handle the following edge cases:

  • Content size smaller than the container size
  • Content size not an exact multiple of the container size
  • Ensuring the stopping position is within valid bounds
Swift
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
  enum Direction {
    case left, right, none
  }

  func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
    let scrollViewWidth = context.containerSize.width
    let contentWidth = context.contentSize.width

    // If the content width is less than or equal to the ScrollView width, align to the leftmost position
    guard contentWidth > scrollViewWidth else {
      target.rect.origin.x = 0
      return
    }

    let originalOffset = context.originalTarget.rect.minX
    let targetOffset = target.rect.minX

    // Determine the scroll direction by comparing the original offset with the target offset
    let direction: Direction = targetOffset > originalOffset ? .left : (targetOffset < originalOffset ? .right : .none)
    guard direction != .none else {
      target.rect.origin.x = originalOffset
      return
    }

    let thresholdRatio: CGFloat = 1 / 3

    // Calculate the remaining content width based on the scroll direction and determine the drag threshold
    let remaining: CGFloat = direction == .left
      ? (contentWidth - context.originalTarget.rect.maxX)
      : (context.originalTarget.rect.minX)

    let threshold = remaining <= scrollViewWidth ? remaining * thresholdRatio : scrollViewWidth * thresholdRatio

    let dragDistance = originalOffset - targetOffset
    var destination: CGFloat = originalOffset

    if abs(dragDistance) > threshold {
      // If the drag distance exceeds the threshold, adjust the target to the previous or next page
      destination = dragDistance > 0 ? originalOffset - scrollViewWidth : originalOffset + scrollViewWidth
    } else {
      // If the drag distance is within the threshold, align based on the scroll direction
      if direction == .right {
        // Scroll right (page left), round up
        destination = ceil(originalOffset / scrollViewWidth) * scrollViewWidth
      } else {
        // Scroll left (page right), round down
        destination = floor(originalOffset / scrollViewWidth) * scrollViewWidth
      }
    }

    // Boundary handling: Ensure the destination is within valid bounds and aligns with pages
    let maxOffset = contentWidth - scrollViewWidth
    let boundedDestination = min(max(destination, 0), maxOffset)

    if boundedDestination >= maxOffset * 0.95 {
      // If near the end, snap to the last possible position
      destination = maxOffset
    } else if boundedDestination <= scrollViewWidth * 0.05 {
      // If near the start, snap to the beginning
      destination = 0
    } else {
      if direction == .right {
        // For right-to-left scrolling, calculate from the right end
        let offsetFromRight = maxOffset - boundedDestination
        let pageFromRight = round(offsetFromRight / scrollViewWidth)
        destination = maxOffset - (pageFromRight * scrollViewWidth)
      } else {
        // For left-to-right scrolling, keep original behavior
        let pageNumber = round(boundedDestination / scrollViewWidth)
        destination = min(pageNumber * scrollViewWidth, maxOffset)
      }
    }

    target.rect.origin.x = destination
  }
}

This version can easily handle cases where the content size is not an exact multiple of the container size.

Swift
struct Step2: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(0 ..< 10) { page in
                    Text(page, format: .number)
                        .font(.title)
                        // Set the width to 1/3 of the ScrollView width
                        .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0, alignment: .center)
                        .frame(height: 200)
                        .background(.secondary.opacity(0.3))
                        .border(.red, width: 2)
                }
            }
        }
        .border(.blue, width: 2)
        .scrollTargetBehavior(.horizontalPaging)
    }
}

I have shared both support horizontal and vertical paging implementations in this Gist. Feel free to check it out.

Beyond Paging

By customizing ScrollTargetBehavior, we not only achieved horizontal paging but can also extend it to support vertical scrolling or more complex scrolling logic. For example, combining scroll velocity (velocity) to implement multi-page scrolling during fast swipes and single-page scrolling during light swipes.

Additionally, scrollTargetBehavior can serve as a tool for dynamic data loading. Compared to using onAppear in lazy views, it allows us to trigger data loading earlier during scrolling, thereby improving the scroll jump issue caused by dynamic data loading in SwiftUI’s lazy containers.

Although onScrollGeometryChange can achieve similar functionality, it is only available on iOS 18 and later, whereas ScrollTargetBehavior has been supported since iOS 17, making it more widely applicable.

Conclusion

This article walked through a real-world example, detailing how to use scrollTargetBehavior to implement custom scrolling logic. From identifying the problem to iteratively refining the solution, we ultimately achieved a stable and flexible paging implementation. I hope this article provides inspiration and guidance for implementing complex scrolling behaviors in SwiftUI.

Weekly Swift & SwiftUI highlights!