SwiftUI: Hiding tabBar and navigationBar on scroll

860 Views Asked by At

I have a complex view that includes a ScrollView and I'm trying to hide both the tabBar and the navigationBar whenever the user starts scrolling, and show them again when the user stops scrolling (kind of like in the Apollo iOS for Reddit app).

However, it doesn't work and I'm sure why. Here's my code:

struct View: View {
  @State var isDragging: Bool = false
  @AppStorage("hideTopBarAndNavBarWhenScrolling") var hideTopBarAndNavBarWhenScrolling: Bool = false

  ScrollView {
    LazyVStack(spacing: 0) {
      ...
    }
  }
  .hideNavBarAndTopBar(isDragging, hideTopBarAndNavBarWhenScrolling)
  .simultaneousGesture(dragGesture)
}

  struct HideNavBarAndTopBarModifier: ViewModifier {
    var isScrollViewDragging: Bool
    var hideTopBarAndNavBarWhenScrolling: Bool

    func body(content: Content) -> some View {
        if hideTopBarAndNavBarWhenScrolling {
            Spacer()
                .frame(height: 1)
                .ignoresSafeArea()
            content
                .toolbar(isScrollViewDragging ? .visible : .hidden, for: .tabBar)
                .toolbar(isScrollViewDragging ? .visible : .hidden, for: .navigationBar)
            Spacer()
                .frame(height: 1)
                .ignoresSafeArea()
        } else {
            content
                .toolbar(.visible, for: .tabBar)
                .toolbar(.visible, for: .navigationBar)
        }
    }
}

extension View {
    func hideNavBarAndTopBar(_ isScrollViewDragging: Bool, _ hideTopBarAndNavBarWhenScrolling: Bool) -> some View {
        self.modifier(HideNavBarAndTopBarModifier(isScrollViewDragging: isScrollViewDragging, hideTopBarAndNavBarWhenScrolling: hideTopBarAndNavBarWhenScrolling))
    }
}
}
2

There are 2 best solutions below

0
Mehmet Karanlık On

I created some demo where you can show or hide toolbars based on scroll direction & scroll range . Numbers are experimental you can find your sweetspot.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            TabView {
                ScrollableView()
            }
            .navigationTitle("Demo")
        }
        
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}



struct ScrollableView: View {
    @State var isHiding : Bool = false
    @State var scrollOffset : CGFloat = 0
    @State var threshHold : CGFloat = 0
    var body: some View {
        ScrollView {
            ZStack {
                LazyVStack {
                    ForEach(0..<300) { _ in
                        ScrollItem()
                    }
                }
                GeometryReader { proxy in
                    Color.clear
                        .changeOverlayOnScroll(
                            proxy: proxy,
                            offsetHolder: $scrollOffset,
                            thresHold: $threshHold,
                            toggle: $isHiding
                        )
                }
            }
        }
        .coordinateSpace(name: "scroll")
        .toolbar(isHiding ? .hidden : .visible, for: .navigationBar)
        .toolbar(isHiding ? .hidden : .visible, for: .tabBar)
        
    }
    
    
 // ScrollChild
struct ScrollItem: View {
        var body: some View {
            Rectangle()
                .fill(Color.red)
                .frame(minHeight: 200)
        }
    }
}





extension View {
    
    func changeOverlayOnScroll(
        proxy : GeometryProxy,
        offsetHolder : Binding<CGFloat>,
        thresHold : Binding<CGFloat>,
        toggle: Binding<Bool>
    ) -> some View {
        self
            .onChange(
                of: proxy.frame(in: .named("scroll")).minY
            ) { newValue in
                // Set current offset
                offsetHolder.wrappedValue = abs(newValue)
                // If current offset is going downward we hide overlay after 200 px.
                if offsetHolder.wrappedValue > thresHold.wrappedValue + 200 {
                    // We set thresh hold to current offset so we can remember on next iterations.
                    thresHold.wrappedValue = offsetHolder.wrappedValue
                    // Hide overlay
                    toggle.wrappedValue = true
                    
                    // If current offset is going upward we show overlay again after 200 px
                }else if offsetHolder.wrappedValue < thresHold.wrappedValue - 200 {
                    // Save current offset to threshhold
                    thresHold.wrappedValue = offsetHolder.wrappedValue
                    // Show overlay
                    toggle.wrappedValue = false
                }
         }
    }
}

0
Sam On

Thanks to Mehmet's answer.

Here's a modified version using a custom modifier. Creating a custom modifier allows us to keep the stored properties in the modifier.

struct ScrollableView: View {
    @State private var isHiding = false
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 10) {
                ForEach(0..<300) { number in
                    Text(number.description)
                        .frame(width: 200, height: 100)
                        .background(RoundedRectangle(cornerRadius: 10)
                            .fill(Color.red)
                            .frame(minHeight: 100)
                        )
                }
            }
            .onScrollingChange(onScrollingDown: {
                isHiding = false
            }, onScrollingUp: {
                isHiding = true
            }, onScrollingStopped: {
                isHiding = false
            })
        }
        .toolbar(isHiding ? .hidden : .visible, for: .navigationBar)
        .toolbar(isHiding ? .hidden : .visible, for: .tabBar)
        .animation(.easeIn, value: isHiding)
    }
}

extension View {
    public func onScrollingChange(
        scrollingChangeThreshold: Double = 200.0,
        scrollingStopThreshold: TimeInterval = 0.5,
        onScrollingDown: @escaping () -> Void,
        onScrollingUp: @escaping () -> Void,
        onScrollingStopped: @escaping () -> Void) -> some View {
            self.modifier(OnScrollingChangeViewModifier(scrollingChangeThreshold: scrollingChangeThreshold, scrollingStopThreshold: scrollingStopThreshold, onScrollingDown: onScrollingDown, onScrollingUp: onScrollingUp, onScrollingStopped: onScrollingStopped))
        }
}

private struct OnScrollingChangeViewModifier: ViewModifier {
    let scrollingChangeThreshold: Double
    let scrollingStopThreshold: TimeInterval
    let onScrollingDown: () -> Void
    let onScrollingUp: () -> Void
    let onScrollingStopped: () -> Void
    
    @State private var scrollingStopTimer: Timer?
    @State private var offsetHolder = 0.0
    @State private var initialOffset: CGFloat?
    
    func body(content: Content) -> some View {
        content.background {
            GeometryReader { proxy in
                Color.clear
                    .onChange(of: proxy.frame(in: .global).minY, initial: true) { oldValue, newValue in
                        
                        // prevent triggering callback when boucing top edge to avoid jumpy animation
                        if initialOffset == nil {
                            initialOffset = oldValue
                        } else if newValue >= initialOffset! {
                            return
                        }
                        
                        let newValue = abs(newValue)
                        
                        if newValue > offsetHolder + scrollingChangeThreshold {
                            // We set thresh hold to current offset so we can remember on next iterations.
                            offsetHolder = newValue
                            
                            // is scrolling down
                            onScrollingDown()
                            
                        } else if newValue < offsetHolder - scrollingChangeThreshold {
                            
                            // Save current offset to threshold
                            offsetHolder = newValue
                            // is scrolling up
                            onScrollingUp()
                        }
                        
                        scrollingStopTimer?.invalidate()
                        scrollingStopTimer = Timer.scheduledTimer(withTimeInterval: scrollingStopThreshold, repeats: false, block: { _ in
                            onScrollingStopped()
                        })
                    }
            }
        }
    }
}