Search code examples
iosswiftswiftuiscrollview

SwiftUI: How would I make stretchable (flexible) sticky header for ScrollView?


Well, honestly, I did it, because I needed it, and only then looked around and did not find anything on SO native in SwiftUI, so wanted to share. Thus this is just a self-answered question.

Initially I needed sticky stretchable sticky header for lazy content dependent only on ScrollView.

Later (after I got my solution) I found this one on Medium, but I don't like it (and would not recommend at least as-is), because:

  1. overcomplicated (many unneeded code, many unneeded calculations)
  2. depends (and joins) with safe area only, so limited applicability
  3. based on offset (I don't like to use offset, because of its inconsistency with layout, etc.)
  4. it is not sticky and to make it sticky it is needed even more code

So, actually all this text was just to fulfil SO question requirements - who knows me here knows that I don't like to type many text, it is better to type code 😀, in short - my approach is below in answer, maybe someone find it useful.

Initial code which SwiftUI gives us for free

ScrollView {
    LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) {
        Section {
            ForEach(0...100) {
                Text("Item \($0)")
                    .frame(maxWidth: .infinity, minHeight: 60)
            }
        } header: {
           Image("picture").resizable().scaledToFill()
               .frame(height: 200)
        }
    }
}

Header is sticky by scrolling up, but not when down (dragged with content), and it is not stretchable.


Solution

  • iOS 15.5 (initial)

    demo

    Ok, we need to solve two problems:

    1. make top of header pinned to top of ScrollView on drag down
    2. stretch header on drag down to make header content (image in majority of cases) scale to fill

    A possible approach to solve this:

    1. ScrollView now manages content offsets privately (UIKit variants are out of topics here), so to pin to top using overlay
        ScrollView {
            // ...
        }
        .overlay(
            // >> any header
            Image("picture").resizable().scaledToFill()
            // << header end
                .frame(height: imageHeight)  // will calculate below
                .clipped()
    
    1. Use Section default header (as placeholder) to calculate current distance from ScrollView top

       Section(...) {
         // ...
       } header: {
           // here is only caculable part
           GeometryReader {
               // detect current position of header bottom edge
               Color.clear.preference(key: ViewOffsetKey.self,
                   value: $0.frame(in: .named("area")).maxY)
           }
           .frame(height: headerHeight)
           .onPreferenceChange(ViewOffsetKey.self) {
               // prevent image negative height if header is not pinned 
               // for simplicity (can be optional, etc.)
               imageHeight = $0 < 0 ? 0.001 : $0
           }
       }
      

    That's actually it, everything else is just for demo part.

    Tested with Xcode 13.4 / iOS 15.5

    Test module is here