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

489 Views Asked by At

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?

1

There are 1 best solutions below

2
Benzy Neez On BEST ANSWER

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
}