swiftuiscrollscrollviewoffsetscrollviewreader

Hide SwiftUI View when ScrollView scrolled down, then show it again if scrolled up, even if not scrolled all the way to the top


I have a SwiftUI view with a ScrollView constructed like this:

ZStack(alignment: .top) {
                Color.black
                
                VStack {
                    HeaderView()
                                            
                    ScrollView(.vertical) {
                        content
                    }
                }
             }

I want to hide the HeaderView() as soon as the user scrolls down on the ScrollView, but then show it when the user scrolls back up (preferably with a bit of an offset), even if the ScrollView isn't scrolled all the way to the top.

This is done on a lot of apps, including Artifact, which does it like this:

enter image description here

Using GeometryReader/ScrollViewReader did not help, or I did not implement it correctly?


Solution

  • If you place a GeometryReader in the background of the scrolled content then it can be used to detect a change in scroll position. An .onChange handler can then be used to toggle the visibility of the header when the direction of scroll changes.

    However, when the content is fully scrolled to the top or to the bottom, the ScrollView may bounce and this may cause the header to be toggled incorrectly. To help resolve this, another GeometryReader can be used to measure the height of the ScrollView. The measurement of the position can then be constrained to the exact height of the content, allowing bounces to be ignored.

    Here is an adaption of your example to show it working:

    @State private var showingHeader = true
    
    var body: some View {
        VStack {
            if showingHeader {
                HeaderView()
                    .transition(
                        .asymmetric(
                            insertion: .push(from: .top),
                            removal: .push(from: .bottom)
                        )
                    )
            }
            GeometryReader { outer in
                let outerHeight = outer.size.height
                ScrollView(.vertical) {
                    content
                        .background {
                            GeometryReader { proxy in
                                let contentHeight = proxy.size.height
                                let minY = max(
                                    min(0, proxy.frame(in: .named("ScrollView")).minY),
                                    outerHeight - contentHeight
                                )
                                Color.clear
                                    .onChange(of: minY) { oldVal, newVal in
                                        if (showingHeader && newVal < oldVal) || !showingHeader && newVal > oldVal {
                                            showingHeader = newVal > oldVal
                                        }
                                    }
                            }
                        }
                }
                .coordinateSpace(name: "ScrollView")
            }
            // Prevent scrolling into the safe area
            .padding(.top, 1)
        }
        .background(.black)
        .animation(.easeInOut, value: showingHeader)
    }
    

    Animation


    EDIT In the OP you said you would prefer it if the header only re-appears after the content has been scrolled a little in the opposite direction. To implement this, it is necessary to detect the turning point and then measure the distance from this point. This requires the following changes to the code above:

    @State private var showingHeader = true
    @State private var turningPoint = CGFloat.zero // ADDED
    let thresholdScrollDistance: CGFloat = 50 // ADDED
    
    .onChange(of: minY) { oldVal, newVal in
        if (showingHeader && newVal > oldVal) || (!showingHeader && newVal < oldVal) {
            turningPoint = newVal
        }
        if (showingHeader && turningPoint > newVal) ||
            (!showingHeader && (newVal - turningPoint) > thresholdScrollDistance) {
            showingHeader = newVal > turningPoint
        }
    }
    

    To use the same effect for hiding the header too, change the second if-statement to:

    if (showingHeader && (turningPoint - newVal) > thresholdScrollDistance) ||
        (!showingHeader && (newVal - turningPoint) > thresholdScrollDistance) {
        showingHeader = newVal > turningPoint
    }