skip to Main Content

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.

2

Answers


  1. Chosen as BEST ANSWER

    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


  2. enter image description here

    If you need solution based on ScrollView:

    1/ find scrollOffset (see example or use ScrollViewWithScrollOffset)

    2/ wrap image with GeometryReader to avoid frame glitches and get normal image size on device screen

    3/ use size from GeometryReader and scrollOffset to set image frame

    Full code:

    struct ContentView: View {
        @State var scrollOffset: CGFloat = 0
        
        private let coordinateSpaceName = "scrollViewSpaceName"
        var body: some View {
            ScrollView {
                VStack {
                    image
                    Color.gray.frame(height: 1000)
                }
                .background( // 1. find scrollOffset
                    GeometryReader { proxy in
                        let offset = proxy.frame(in: .named(coordinateSpaceName)).minY
                        Color.clear.preference(key: ScrollViewWithPullDownOffsetPreferenceKey.self, value: offset)
                    }
                )
            }
            .coordinateSpace(name: coordinateSpaceName)
            .onPreferenceChange(ScrollViewWithPullDownOffsetPreferenceKey.self) { value in
                scrollOffset = value
            }
        }
    
        var image: some View { frame
            GeometryReader { proxy in // 2. get actual size on screen
                Image(systemName: "heart.fill") // 3. use scrollOffset to adjust image 
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding(.horizontal, min(0, -scrollOffset))
                    .frame(width: proxy.size.width,
                           height: proxy.size.height + max(0, scrollOffset))
                    .offset(CGSize(width: 0, height: min(0, -scrollOffset)))
            }
            .aspectRatio(CGSize(width: 375, height: 280), contentMode: .fit)
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search