Skip to content

Blog


Programmatic Scrolling with SwiftUI ScrollView

July 21, 2022

|
Zoltan Lippai

Zoltan Lippai

SwiftUI is Apple’s new UI building framework released in 2019 as part of the iOS13 update. Compared to the old UIKit, SwiftUI is a declarative, functional framework, allowing developers to build user interfaces much faster, while offering a very useful toolkit to debug and test the result through interactive previews and built-in support from Xcode.

DoorDash’s journey with SwiftUI required figuring out how to programmatically scroll ScrollViews starting with iOS13 to pave the way for more complicated functionalities down the road, including such things as adding snapping-to-content behavior.

ScrollViews are common in any modern UI. They are part of many different use cases and user interfaces, allowing users to experience much more content than would normally fit on a mobile device’s screen or in the given viewport of a web browser.

ScrollViews, as shown in Figure 1, are tightly integrated into our designs to allow us to focus the users’ attention on a particular item. We can use them to highlight position or progress, align content with the viewport, and enforce snapping behavior among myriad other benefits.

Figure 1: The ScrollView is the area below the fixed height view on a mobile app and can, via scrolling,  provide access to more content than would otherwise fit on the page. 

With our new SwiftUI app, we sought to add many of these features to our app experience. Unfortunately, the first release of SwiftUI in 2019, bundled with iOS13, lacked any means to programmatically move ScrollViews, which could only be accomplished following the next release for iOS14 a year later.

Building programmatic scrolling

We first will explore the scrollview implementation options before diving into how to implement the programmatic scrolling behavior.

Programmatic scrolling and its benefits

Programmatic scrolling refers to the possibility of instructing the ScrollView to move to a particular position (referred to as content offset) or to a target view.

Programmatic scrolling was introduced in the first version of iOS (iPhoneOS 2.0) within UIKit. There are publicly available APIs to accomplish it: all UIScrollViews and their subclasses come with the methods setContentOffset(:animated:) and scrollRectToVisible(:animated:).

Because of this UIKit legacy, there are countless user experiences already in production offering programmatic scrolling; it has been a core feature for many user flows. As a result, it was only natural to require any new UI framework to enable the same UX.

To review a few examples, our UX design specifies scenarios where a ScrollView is positioned around some of its subviews, such as scrolling to incomplete sections on a form before the user can submit it:

Figure 2. This form scrolls to the first incomplete section 
before letting the user save their options

Similarly, when two elements’ scroll positions are linked and scroll together, such as a main list and a horizontal carousel:

Figure 3. The horizontal carousel on the top and the main scroll offset of the menu 
are linked: scrolling the main menu updates the carousel and tapping
 any of the labels on the carousel scrolls the main menu.

SwiftUI 1.0 ScrollViews lacked any kind of support for accomplishing programmatic scrolling in general: there weren’t any APIs that would allow someone to instruct the ScrollView to move itself to a particular position. 

Without out-of-the-box-support, it seemed most expedient to keep the implementation based on UIKit and not take on the extra challenge of using SwiftUI. However, DoorDash’s philosophy has been to investigate new possibilities to move toward our end goal and try to bridge any gaps we encounter along the way. This project was no exception.

Before we show the detailed steps of our investigation, we would like to note that SwiftUI 2.0 shipped with added programmatic support for iOS14, including Apple’s introduction of a ScrollViewReader and a ScrollViewProxy

The ScrollView reader is a transparent container that exposes a proxy argument to its content. The content’s code blocks can use this proxy to send messages to the ScrollView to move its offset to a particular position.

However, ScrollViewReader and its proxy are not backward-compatible with iOS13/SwiftUI 1.0.

This improvement nonetheless gave us inspiration for our own API design, including how the end result should look and which syntax feels most natural when building our user interface with SwiftUI.

Building programmatic ScrollViews with SwiftUI

To build something like this on our own required the following steps:

  1. Exposing a reference to the underlying UIKit UIScrollView. This reference enables us to use the UIKit APIs to programmatically scroll the content.
  2. Building SwiftUI-based components that make use of the above reference so that  developers can use these to instruct the ScrollView to scroll.
  3. Wrapping the solution in a convenient and easy-to-understand API to hide its complexity.

For reference, here is a quick code example using Apple’s solution. This is an example of a simple view declaration demonstrating the use of a ScrollViewReader. The reader’s content itself is given a parameter of the type ScrollViewProxy, which exposes the API to scroll the ScrollView to any designated child view using its assigned identifier:

import SwiftUI

struct ContentView: View {
    enum ScrollPosition: Hashable {
        case image(index: Int)
    }
    
    var body: some View {
        ScrollView {
            ScrollViewReader { proxy in
                VStack {
                    ForEach(0..<10, content: logoImage)
                    
                    Button {
                        withAnimation {
                            proxy.scrollTo(
                                ScrollPosition.image(index: 0),
                                anchor: .top
                            )
                        }
                    } label: {
                        Text("Scroll to top!")
                    }
                    .buttonStyle(.redButton)
                }
            }
        }
    }
    
    func logoImage(at index: Int) -> some View {
        Image("brand-logo")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding()
            .border(Color.red.opacity(0.5))
            .padding()
            .id(ScrollPosition.image(index: index))
    }
}

We wanted to ensure developers could use a familiar syntax, thus it became our goal to mimic this construction.

Building the components for programmatic scrolling

To implement any kind of custom programmatic API, we had to instruct the UI framework to do what we wanted it to do.

Behind the scenes, SwiftUI components use the old UIKit views as building blocks; in particular ScrollViews are using an underlying UIScrollView. If we could safely walk through the UIKit view hierarchy that SwiftUI automatically generated for us to find this component, we could use the old UIKit methods to perform the programmatic scrolling and accomplish our original goal. Assuming we can accomplish this, this should be our path forward.

Sidenote: There are third-party libraries that claim to hook into UIScrollView, but in our experience they do not work reliably across different versions of SwiftUI. That’s why we implemented our own means to locate this reference.

To find a particular superview — in our case the containing ScrollView — in the UIKit view tree, we need to insert a transparent view which does not actually display anything to the user, but rather is used to investigate the view hierarchy. To this end, we need a UIViewRepresentable and a UIView instance as its view type.

A short note for the sake of bikeshedding about type names in this example: We call the replacement for Apple’s ScrollViewReaderScrollReader’ and for ScrollViewProxy we use a protocol called ScrollProxyProtocol and the type of the object we use to implement it as __ScrollProxy.

struct ScrollViewBackgroundReader: UIViewRepresentable {

    let setProxy: (ScrollProxyProtocol) -> ()
    
    func makeCoordinator() -> Coordinator {
        let coordinator = Coordinator()
        setProxy(coordinator)
        return coordinator
    }

    func makeUIView(context: Context) -> UIView {
        UIView()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) { }
}

Going forward, the Coordinator we added as part of this UIViewRepresentable will be doing the heavy lifting for us, including implementing the necessary steps to programmatically scroll the UIScrollView. We pass a closure from the ScrollReader to the background view, so the __ScrollProxy implementation can delegate requests to the Coordinator. You can see its implementation below.

We can add this reader view as a background to the content of our own ScrollReader:

struct ScrollReader<ScrollViewContent: View>: View {
    private let content: (ScrollProxyProtocol) -> Content
    private let proxy = __ScrollProxy()

    init(@ViewBuilder content: @escaping (ScrollProxyProtocol) -> ScrollViewContent) {
        self.content = content
    }
    
    var body: some View {
        content(proxy)
            .background(
                ScrollViewBackgroundReader(setProxy: { proxy.other = $0 })
            )
    }
}

Throughout this example, we use the .background() modifier to add our reader views. This allows us to add them as part of the view hierarchy. Furthermore, .background() components share the geometry (position and size) of the receiver, which makes it useful to find the coordinates of various views later on when we need to translate the content’s position to CGPoint coordinates.

Next, we define our ScrollProxyProtocol:

protocol ScrollProxyProtocol {
    /// Scrolls to a child view with the specified identifier.
    ///
    /// - Parameter identifier: The unique scroll identifier of the child view.
    /// - Parameter anchor: The unit point anchor to describe which edge of the child view
    /// should be snap to.
    func scroll(to identifier: AnyHashable, anchor: UnitPoint)
    /// Scrolls to a child view with the specified identifier and adjusted by the offset position.
    ///
    /// - Parameter identifier: The unique scroll identifier of the child view.
    /// - Parameter anchor: The unit point anchor to describe which edge of the child view
    /// should be snap to.
    /// - Parameter offset: Extra offset on top of the identified view's position.
    func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint)
}

We shall implement this protocol above with the proxy object (i.e. the __ScrollProxy private type) and separately with the Coordinator of the reader. In this design, the proxy object will delegate the scroll requests to the Coordinator behind the scenes. The Coordinator’s reference is passed to the proxy object using the setProxy(_:) closure used above.

struct ScrollReader: View {
    ...
    private final class __ScrollProxy: ScrollProxyProtocol {
        var other: ScrollProxyProtocol? // This is set to the Coordinator's instance
                
        func scroll(to identifier: AnyHashable, anchor: UnitPoint) {
            other?.scroll(to: identifier, anchor: anchor)
        }
        
        func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint) {
            other?.scroll(to: identifier, anchor: anchor, offset: offset)
        }
    }
}

Our Coordinator’s implementation of the proxy protocol will look like this code sample below — with TODO placeholders for now:

final class Coordinator: MyScrollViewProxy {
    ...
                
    private func locateTargetOffset(with identifier: AnyHashable, anchor: UnitPoint) -> (view: UIView, offset: CGPoint)? { 
        // TODO: locate target views with the given identifier
    }
        
    // MARK: ScrollProxyProtocol implementation
        
    func scroll(to identifier: AnyHashable, anchor: UnitPoint) {
        // TODO: locate the view with the identifier, scroll its parent scrollview to the view’s position
    }
                
    func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint) {
        // TODO: locate the view with the identifier, scroll its parent scrollview to the view’s position
    }
...

As a next step, we need to locate the target content offset based on the view identifier and the UnitPoint anchor before locating the correct UIScrollView instance.

These two tasks are related; once we have found the correct destination view, we can find the first of its parents, which is also a UIScrollView. To simplify this step, we have added a computed property on UIView:

extension UIView {
    var enclosingScrollView: UIScrollView? {
         sequence(first: self, next: { $0.superview })
            .first(where: { $0 is UIScrollView }) as? UIScrollView
    }
}

But we still need to identify and locate the target view. In their own solution, Apple is using the SwiftUI .id() API to uniquely identify views. This mechanism is used in their programmatic scrolling solution as well.

We cannot use the results of this API because it is private and hidden from us. What we can do is implement something similar.

Annotating target views

Here we use the .background() modifier again to annotate potential scroll-to targets with a unique identifier while using the background reader view above to locate views with these unique identifiers. To do so, we need to complete the following tasks:

  • Add a SwiftUI API to annotate views
  • Add a lookup mechanism to find these views later when we need to scroll programmatically
  • Convert the placements of these views to CGPoint content offset coordinates

For the first step, we need to place one more UIViewRepresentable in the view hierarchy using the .background() modifier:

struct ScrollAnchorView: UIViewRepresentable {
    let id: AnyHashable
    
    func makeUIView(context: Context) -> ScrollAnchorBackgroundView {
        let view = ScrollAnchorBackgroundView()
        view.id = id
        return view
    }
    
    func updateUIView(_ uiView: ScrollAnchorBackgroundView, context: Context) { }
    
    final class ScrollAnchorBackgroundView: UIView {
        var id: AnyHashable!
    }
}

We then add a convenience method to use the above:

extension View {
    /// Marks the given view as a potential scroll-to target for programmatic scrolling.
    ///
    /// - Parameter id: An arbitrary unique identifier. Use this id in the scrollview reader's proxy
    /// methods to scroll to this view.
    func scrollAnchor(_ id: AnyHashable) -> some View {
        background(ScrollAnchorView(id: id))
    }
}

We made sure the UIViewRepresentable view and its UIView share the same unique ID because the ID’s value is specified in the SwiftUI domain. We will, however, need to locate the UIView with the same ID in the UIKit hierarchy.

We can use the following methods to locate the unique UIView in the view hierarchy with the given identifier using a recursive lookup:

extension UIView {   
    func scrollAnchorView(with id: AnyHashable) -> UIView? {
        for subview in subviews {
            if let anchor = subview.asAnchor(with: id) ?? subview.scrollAnchorView(with: id) {
                return anchor
            }
        }
        return nil
    }

    private func asAnchor(with identifier: AnyHashable) -> UIView? {
        guard let anchor = self as? ScrollAnchorView.ScrollAnchorBackgroundView, anchor.id == identifier else {
            return nil
        }
        return anchor
    }
}

We can use these methods in our Coordinator’s locateTargetOffset function. Immediately afterward, we can locate the parent UIScrollView instance as well:

func locateTargetOffset(with identifier: AnyHashable, anchor: UnitPoint) -> (view: UIView, offset: CGPoint)? {
    guard let targetView = backgroundReaderView.window?.scrollAnchorView(with: identifier) else { return nil }
    guard let scrollView = targetView.enclosingScrollView else { return nil }
    self.scrollView = scrollView
    return (targetView, scrollView.convert(targetView.frame, from: targetView.superview).point(at: anchor, within: scrollView.bounds))
}

This method is called from the ScrollProxyProtocol implementation within our Coordinator:

func scroll(to identifier: AnyHashable, anchor: UnitPoint) {
    guard let target = locateTargetOffset(with: identifier, anchor: anchor) else { return 
    scrollView?.setContentOffset(target.offset, animated: true)
}
                
func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint) {
    guard let target = locateTargetOffset(with: identifier, anchor: anchor) else { return }
    scrollView?.setContentOffset(target.offset + offset, animated: true)
}

We have overloaded the + operator to add two CGPoints to simplify the syntax in the last step.
The following code snippet is used to convert the UIViews’ bounds and the UnitPoint anchors into content offset coordinates:

extension CGRect {
    func point(at anchor: UnitPoint, within container: CGRect) -> CGPoint {
        CGPoint(
            x: minX + anchor.x * (width - container.width),
            y: minY + anchor.y * (height - container.height)
        )
    }
}

At this point, we have completed everything for the first iteration of a programmatically scrollable solution:

  • Added a SwiftUI API for our ScrollReader, which in turn is used to publish the proxy object to allow programmatic scrolling
  • Located the target view and its parent ScrollView and converted the positioning of the view to the input parameters that the UIScrollView APIs expect
  • Connected the proxy object with the Coordinator’s concrete implementation

You can find the solution as a Swift project here.

Wrapping up programmatic scrolling

The construction outlined above lets us replicate the behavior of the SwiftUI ScrollViewReader without iOS or SwiftUI version restrictions. Because we have full access to the underlying UIScrollView, we can use this solution to add extra bits of functionality beyond this initial result, something we will explore in subsequent posts.

But even this initial result has an extra bit of functionality: It allows scrolling to a view at an anchor with a local offset applied for more fine-grained control to position our ScrollView.

Now we can replicate the very first example with our own solution:

struct MyContentView: View {
    enum ScrollPosition: Hashable {
        case image(index: Int)
    }
    
    var body: some View {
        ScrollView {
            ScrollReader { proxy in
                VStack {
                    ForEach(0..<10, content: logoImage)
                    
                    // as before - scrolling to an image
                    Button {
                        withAnimation {
                            proxy.scroll(to: ScrollPosition.image(index: 0), anchor: .top)
                        }
                    } label: {
                        Text("Scroll to the first image!") 
                    }
                    
                }
                .buttonStyle(.redButton)
            }
        }
    }
    
    func logoImage(at index: Int) -> some View {
        Image("brand-logo")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding()
            .border(Color.red.opacity(0.5))
            .padding()
            .scrollAnchor(ScrollPosition.image(index: index))
    }
}

Note that we have replaced the .id() in the logoImage function with our own .scrollAnchor() modifier.

And it works as expected:

Figure 4. Scrolling programmatically using our own solution. The first button tap scrolls to the origin coordinate of the first logo image, the second tap scrolls the content 50 points above the top position of the content.

The shortcoming of this iteration is the lack of support for SwiftUI animations. There is no easy way to translate SwiftUI Animation specifications using the UIKit scroll APIs. We will explore a solution to this problem in a later post.

Conclusion

Building programmatic scrolling in SwiftUI required several iterations to achieve success as we moved through our SwiftUI learning curve. In this current form, however, it is now relatively easy to implement and can be used for simple use cases across the board and even for production features.

But this version is still not the final result. We have managed to take our solution further, adding support for SwiftUI animations, scroll snapping behavior for pagination, and other fine-grained content snapping behavior, and support for adjusting the deceleration rate.

Overall, these steps improved the SwiftUI ScrollView to allow us to use it for production features while paving the way for our migration to rewrite our consumer app using the SwiftUI framework without compromises.

In even more complex use cases, we have implemented a collaboration between our modal bottom sheet component and a ScrollView as its content, translating ScrollView drag gestures into gestures that instead move the sheet itself when appropriate.

One more obvious choice of improvement is fine-turning animation support in this solution. In SwiftUI animations can be specified in a very intuitive and straightforward way, and are more powerful than the choices offered in UIKit: the latter is especially true in the case of UIScrollView APIs where we can only pick if we want animation or not at all.

In subsequent posts, we will explain adding support for snapping behavior and deceleration rate, as well as how to change this iteration to enable SwiftUI animations. We'll revisit this problem in a later post.

Related Jobs

Location
San Francisco, CA; Sunnyvale, CA
Department
Engineering
Location
Seattle, WA
Department
Engineering
Job ID: 6069119
Location
San Francisco, CA; Sunnyvale, CA; Seattle, WA
Department
Engineering
Location
Seattle, WA; San Francisco, CA; Sunnyvale, CA
Department
Engineering
Location
Seattle, WA; San Francisco, CA; Sunnyvale, CA
Department
Engineering