Swift Talk # 333

Sticky Headers for Scroll Views

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We implement a sticky modifier that makes views in a scroll stick to the top.

00:06 Today we'll start a new series inspired by a question we saw on Twitter. Seth asked how one should build a scroll view with sticky subviews in SwiftUI and listed a few specific requirements. We want to see both how far we can get with out-of-the-box components and which parts we need to implement ourselves.

00:42 To set things up, we create a simple scroll view, and we add a number of views by using a ForEach:

struct ContentView: View {
    var body: some View {
        ScrollView {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            ForEach(0..<50) { ix in
                Text("Heading \(ix)")
                    .font(.headline)
                    .frame(maxWidth: .infinity)
                    .background(Color.primary.opacity(0.1))
                Text("Hello, world!\nTest 1 2 3")
            }
        }
    }
}

Sticky Modifier

02:46 First, we want to recreate the behavior we know from table section headers, where they stick to the top of the viewport until they're pushed off the screen by the next header. We set up a new helper method in which we can apply this behavior:

extension View {
    func sticky() -> some View {
        self
            .border(Color.red) // to do
    }
}

03:45 We put a longer string in the text views for a more realistic example:

struct ContentView: View {
    var body: some View {
        ScrollView {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)

            ForEach(0..<50) { ix in
                Text("Heading \(ix)")
                    .font(.headline)
                    .frame(maxWidth: .infinity)
                    .background(Color.primary.opacity(0.1))
                    .sticky()
                
                Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce ut turpis tempor, porta diam ut, iaculis leo. Phasellus condimentum euismod enim fringilla vulputate. Suspendisse sed quam mattis, suscipit ipsum vel, volutpat quam. Donec sagittis felis nec nulla viverra, et interdum enim sagittis. Nunc egestas scelerisque enim ac feugiat.")
                    .padding()
            }
        }
    }
}

04:38 Next, we write a Sticky view modifier. There are a number of ways to make a view stick to the top when it would otherwise scroll offscreen, i.e. when its frame's minY position is less than zero in the container's coordinate space. For one, we could offset the view. Another approach would be to duplicate the view and place it in the safe area inset of the scroll view. The advantage of offsetting the view over duplicating it is that we'd be working with a single view, and therefore we don't have to worry about the view's state being reset when we switch between two copies.

06:21 So, let's try offsetting the view to make it sticky. We add an overlay with a geometry reader to measure the view's frame. For this measurement, we refer to the coordinate space of the scroll view's frame:

struct Sticky: ViewModifier {
    func body(content: Content) -> some View {
        content
            .overlay(GeometryReader { proxy in
                let f = proxy.frame(in: .named("container"))
                
            })
    }
}

struct ContentView: View {
    var body: some View {
        ScrollView {
            // ...
        }
        .coordinateSpace(name: "container")
    }
}

07:47 If the minY value of the sticky view's frame is less than zero, it should be offset to stay at the top of the scroll view's frame. We can quickly see if this logic is correct by changing the color of the overlay to red when we think the view should be sticky:

struct Sticky: ViewModifier {
    func body(content: Content) -> some View {
        content
            .offset(y: 0)
            .overlay(GeometryReader { proxy in
                let f = proxy.frame(in: .named("container"))
                f.minY < 0 ? Color.red : .green
            })
    }
}

08:36 We add a state property for the measured frame. We'd normally store the frame through the preference system, but since this value will only be used inside the view modifier itself, we can also assign the frame in the onAppear and onChange(of:perform:) blocks. That way, we don't have to define a preference key:

struct Sticky: ViewModifier {
    @State private var frame: CGRect = .zero

    func body(content: Content) -> some View {
        content
            .offset(y: 0)
            .overlay(GeometryReader { proxy in
                let f = proxy.frame(in: .named("container"))
                Color.clear
                    .onAppear { frame = f }
                    .onChange(of: f) { frame = $0 }
            })
    }
}

To correctly store the latest value from inside the onChange(of:perform:) closure, it's important to use the closure's newValue parameter, and not f.

09:46 We define a computed property that returns true if the view should be sticking to the top:

struct Sticky: ViewModifier {
    @State private var frame: CGRect = .zero

    var isSticking: Bool {
        frame.minY < 0
    }

    // ...
}

10:12 Then, we apply an offset:

struct Sticky: ViewModifier {
    @State private var frame: CGRect = .zero

    var isSticking: Bool {
        frame.minY < 0
    }

    func body(content: Content) -> some View {
        content
            .offset(y: isSticking ? -frame.minY : 0)
            .overlay(GeometryReader { proxy in
                let f = proxy.frame(in: .named("container"))
                Color.clear
                    .onAppear { frame = f }
                    .onChange(of: f) { frame = $0 }
            })
    }
}

10:41 We see that the first header sticks to the top as we scroll up. We also notice that the non-sticky text is displayed over the header instead of scrolling underneath it. And when we keep scrolling, the other sticky headers slide on top of the first one.

11:35 The order of views on the z axis can be changed by applying a zIndex that's different from the default value of 0. Views with a higher index appear on top of other views. We set the index to .infinity if the view is sticking:

struct Sticky: ViewModifier {
    @State private var frame: CGRect = .zero

    var isSticking: Bool {
        frame.minY < 0
    }

    func body(content: Content) -> some View {
        content
            .offset(y: isSticking ? -frame.minY : 0)
            .zIndex(isSticking ? .infinity : 0)
            .overlay(GeometryReader { proxy in
                let f = proxy.frame(in: .named("container"))
                Color.clear
                    .onAppear { frame = f }
                    .onChange(of: f) { frame = $0 }
            })
    }
}

11:55 Now headers appear on top of the other content. The scrolled-up content is still visible in the non-safe area above the sticky header, but that's a different issue. Oftentimes, a navigation bar would cover this area anyway.

Pushing Headers

12:17 We'll need to set up some infrastructure to make the sticky views push each other away: we have to somehow propagate all measured frames up to the scroll view. From there, we pass the frames back down to the Sticky instances so that we can find out which view is currently sticking to the top and which one will be pushing it away.

13:26 Let's create a preference key to propagate an array of measured frames up the view hierarchy:

struct FramePreference: PreferenceKey {
    static var defaultValue: [CGRect] = []

    static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
        value.append(contentsOf: nextValue())
    }
}

13:50 We set the preference in Sticky:

struct Sticky: ViewModifier {
    // ...

    func body(content: Content) -> some View {
        content
            .offset(y: isSticking ? -frame.minY : 0)
            .zIndex(isSticking ? .infinity : 0)
            .overlay(GeometryReader { proxy in
                let f = proxy.frame(in: .named("container"))
                Color.clear
                    .onAppear { frame = f }
                    .onChange(of: f) { frame = $0 }
                    .preference(key: FramePreference.self, value: [frame])
            })
    }
}

14:11 In ContentView, we get the frames array from the preferences, we sort the frames by their minY positions, and we store the array in a state property. We also pull the contents of the scroll view out to a property:

struct ContentView: View {
    @State private var frames: [CGRect] = []

    var body: some View {
        ScrollView {
            contents
        }
        .coordinateSpace(name: "container")
        .onPreferenceChange(FramePreference.self, perform: {
            frames = $0.sorted(by: { $0.minY < $1.minY })
        })
    }

    @ViewBuilder var contents: some View {
        Image(systemName: "globe")
            .imageScale(.large)
            .foregroundColor(.accentColor)
            .padding()

        ForEach(0..<50) { ix in
            Text("Heading \(ix)")
                .font(.title)
                .frame(maxWidth: .infinity)
                .background(.regularMaterial)
                .sticky()

            Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce ut turpis tempor, porta diam ut, iaculis leo. Phasellus condimentum euismod enim fringilla vulputate. Suspendisse sed quam mattis, suscipit ipsum vel, volutpat quam. Donec sagittis felis nec nulla viverra, et interdum enim sagittis. Nunc egestas scelerisque enim ac feugiat. ")
                .padding()
        }
    }
}

15:47 We'd like to keep working with previews, but it makes quickly printing some debugging information to the console impossible. Instead, we can display the measured frames in the view, so we build up a string of the frames' minY and height values, and we pass it to a text view overlay:

struct ContentView: View {
    @State private var frames: [CGRect] = []
    
    var body: some View {
        ScrollView {
            contents
        }
        .coordinateSpace(name: "container")
        .onPreferenceChange(FramePreference.self, perform: {
            frames = $0.sorted(by: { $0.minY < $1.minY })
        })
        .overlay(alignment: .center) {
            let str = frames.map {
                "\(Int($0.minY)) - \(Int($0.height))"
            }.joined(separator: "\n")
            Text(str)
                .foregroundColor(.white)
                .background(.black)
        }
    }

    // ...
}

18:04 Now it's apparent that the first sticky view starts sticking to the top when its minY becomes negative. As we keep scrolling and the second header approaches the first one, we see that the second header should start to push the first one off the screen when its minY is equal to the height of the first header.

18:36 Inside the Sticky modifier, we should now be able to determine two things. First, we need to check whether this view is the current sticky header. Second, we want to see if there's a next header that's pushing this view up.

19:30 The most straightforward way to get the needed information into the Sticky modifier is to pass all measured frames into it. This isn't the prettiest API, but we can improve our code in a later phase:

struct Sticky: ViewModifier {
    var stickyRects: [CGRect]
    @State private var frame: CGRect = .zero

    // ...
}

extension View {
    func sticky(_ stickyRects: [CGRect]) -> some View {
        modifier(Sticky(stickyRects: stickyRects))
    }
}

struct ContentView: View {
    @State private var frames: [CGRect] = []
    
    // ...

    @ViewBuilder var contents: some View {
        // ...

        ForEach(0..<50) { ix in
            Text("Heading \(ix)")
                .font(.title)
                .frame(maxWidth: .infinity)
                .background(.regularMaterial)
                .sticky(frames)

            // ...
        }
    }
}

20:43 Now each sticky view can use the frames to calculate its own offset. Before making this calculation, we make sure the view should be sticking; otherwise, we can return 0. If the view is sticking, the default offset is equal to the view's minY position, just like before:

struct Sticky: ViewModifier {
    var stickyRects: [CGRect]
    @State private var frame: CGRect = .zero

    var isSticking: Bool {
        frame.minY < 0
    }

    var offset: CGFloat {
        guard isSticking else { return 0 }
        var o = -frame.minY

        return o
    }

    func body(content: Content) -> some View {
        content
            .offset(y: offset)
            // ...
    }
}

21:55 If there's a next header that's moving into the currently sticking header's frame, we need to adjust the offset so that the view appears to be pushed up. We can find this next header by looking for the first frame whose minY is greater than the current view's minY and less than the current view's height. The amount by which the view needs to pushed up is the difference between its height and the next header's minY position:

struct Sticky: ViewModifier {
    var stickyRects: [CGRect]
    @State private var frame: CGRect = .zero

    var isSticking: Bool {
        frame.minY < 0
    }

    var offset: CGFloat {
        guard isSticking else { return 0 }
        var o = -frame.minY
        if let idx = stickyRects.firstIndex(where: { $0.minY > frame.minY && $0.minY < frame.height }) {
            let other = stickyRects[idx]
            o -= frame.height - other.minY
        }
        return o
    }

    func body(content: Content) -> some View {
        content
            .offset(y: offset)
            // ...
    }
}

23:56 That's it. The top image view scrolls away like normal, and the headers stick to the top until they're pushed up by a next header. By simply offsetting views to make them stick to the top, we aren't breaking any of the scroll view's behaviors, which means the sticky headers cooperate well with the scroll view's bouncing at the ends and with the elastic effect happening when we try scrolling beyond the edges of the contents.

25:09 This concludes the first part of building our rich scroll view. Besides adding sticky views, there are some other requirements we should still look into. And we can also improve our implementation a bit by abstracting some things away. Let's take a look at that next time.

Resources

  • Sample Code

    Written in Swift 5.7

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

101 Episodes · 36h03min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes