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) }
}
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.