ios - LockingDebouncing a scroll view Preference key observation in SwiftUI - Stack Overflow

admin2025-04-25  2

I have a SwiftUI ScrollView for which I want to perform specific actions on scrolling and specifically when figuring out the scroll direction.

My ScrollView which is SwiftUI is within a UIKit based navigation stack.

My goal is to figure out the scroll direction and accordingly hide the navigation bar and tab bar while scrolling down and do the opposite while scrolling up.

Here is a minimal reproducible example:

In my app delegate, I set up the above navigation stack as follows:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Create the SwiftUI view that provides the window contents.
        
        let tabBarViewController = UITabBarController()
        tabBarViewController.title = "Scroll Hide"
        
        let navigationController = UINavigationController(rootViewController: tabBarViewController)
        
        let contentView = ContentView()
            .didChangeScrollDirection { scrollDirection in
                
                let shouldHide = scrollDirection == .down
                
                UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
                    navigationController.setNavigationBarHidden(shouldHide, animated: true)
                    
                    if let navControllerView = navigationController.view {
                        let tabBarFrame = tabBarViewController.tabBar.frame
                        
                        if shouldHide {
                            tabBarViewController.tabBar.frame.origin.y = navControllerView.frame.maxY + tabBarFrame.height
                        } else {
                            tabBarViewController.tabBar.frame.origin.y = navControllerView.frame.maxY - tabBarFrame.height
                        }
                        
                        navControllerView.layoutIfNeeded()
                    }
                }
            }
        
        let vc = UIHostingController(rootView: contentView)
        vc.title = "Home"
        
        tabBarViewController.viewControllers = [vc]
        
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.rootViewController = navigationController
        self.window = window
        window.makeKeyAndVisible()
        
        return true
    }
}

And this is the View which observes the scroll view scrolls and then tries publishes this to the subscriber above to perform the animation

struct ContentView: View {
    private let minimumDirectionChangeOffset = 16.0

    private var scrollDirectionUpdateHandler: ((ScrollDirection) -> Void)?
    
    @State private var currentDirection = ScrollDirection.none
    @State private var previousScrollOffset = CGFloat.zero
    
    var body: some View {
        ScrollView {
            Rectangle()
                .frame(height: 10000)
                .frame(maxWidth: .infinity)
                .foregroundStyle(.blue)
                .background(scrollDirectionTrackerView)
                .onPreferenceChange(ScrollOffsetKey.self) { updatedOffset in
                    updateScrollDirection(updatedOffset)
                }
        }
        .coordinateSpace(name: "scrollView")
        .navigationTitle("Hello")
    }
    
    private var scrollDirectionTrackerView: some View {
        GeometryReader { proxy in
            Color.clear.preference(key: ScrollOffsetKey.self,
                                   value: -proxy.frame(in: .named("scrollView")).origin.y)
        }
    }
    
    func didChangeScrollDirection(_ handler: @escaping (ScrollDirection) -> Void) -> ContentView {
        var newView = self
        newView.scrollDirectionUpdateHandler = handler
        return newView
    }
    
    private func updateScrollDirection(_ newOffset: CGFloat) {
        let offsetDifference = previousScrollOffset - newOffset
        var updatedDirection = currentDirection
        
        if abs(offsetDifference) > minimumDirectionChangeOffset {
            if offsetDifference > 0 {
                updatedDirection = .up
            } else {
                updatedDirection = .down
            }
            
            previousScrollOffset = newOffset
        }

        // Only publish if we have a different direction
        if updatedDirection != currentDirection {
            print("previous offset: \(previousScrollOffset)")
            print("updated direction: \(updatedDirection)")
            currentDirection = updatedDirection
            scrollDirectionUpdateHandler?(currentDirection)
        }
    }
}

enum ScrollDirection {
    case up
    case down
    case forward
    case backward
    case none
}

private struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue = CGFloat.zero
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}

For the most part this works well.

I want to draw your attention to the lines in the second snippet above where I say // Only publish if we have a different direction

If I perform the scroll fast in either direction, this works well.

However, when I drag really slowly, I feel either a race condition happens with multiple threads accessing this function or something else, but the direction gets set multiple times triggering multiple animations which results in a choppy user experience due to an animation being in progress.

I am wondering if there is a flaw in my implementation or a more elegant way to handle this:

  • with a debounce / throttling of some sorts
  • some kind of semaphore / lock such that we can lock the function
  • or better math even ?

Some other ideas that come to is a Bool variable to lock the function but was hoping for something more elegant.

I tried this old answer but it did not help me.

Is there a better way to prevent the slow drag from changing the scroll direction multiple times ?

Finally, I need to support iOS 16+.

I have a SwiftUI ScrollView for which I want to perform specific actions on scrolling and specifically when figuring out the scroll direction.

My ScrollView which is SwiftUI is within a UIKit based navigation stack.

My goal is to figure out the scroll direction and accordingly hide the navigation bar and tab bar while scrolling down and do the opposite while scrolling up.

Here is a minimal reproducible example:

In my app delegate, I set up the above navigation stack as follows:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Create the SwiftUI view that provides the window contents.
        
        let tabBarViewController = UITabBarController()
        tabBarViewController.title = "Scroll Hide"
        
        let navigationController = UINavigationController(rootViewController: tabBarViewController)
        
        let contentView = ContentView()
            .didChangeScrollDirection { scrollDirection in
                
                let shouldHide = scrollDirection == .down
                
                UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
                    navigationController.setNavigationBarHidden(shouldHide, animated: true)
                    
                    if let navControllerView = navigationController.view {
                        let tabBarFrame = tabBarViewController.tabBar.frame
                        
                        if shouldHide {
                            tabBarViewController.tabBar.frame.origin.y = navControllerView.frame.maxY + tabBarFrame.height
                        } else {
                            tabBarViewController.tabBar.frame.origin.y = navControllerView.frame.maxY - tabBarFrame.height
                        }
                        
                        navControllerView.layoutIfNeeded()
                    }
                }
            }
        
        let vc = UIHostingController(rootView: contentView)
        vc.title = "Home"
        
        tabBarViewController.viewControllers = [vc]
        
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.rootViewController = navigationController
        self.window = window
        window.makeKeyAndVisible()
        
        return true
    }
}

And this is the View which observes the scroll view scrolls and then tries publishes this to the subscriber above to perform the animation

struct ContentView: View {
    private let minimumDirectionChangeOffset = 16.0

    private var scrollDirectionUpdateHandler: ((ScrollDirection) -> Void)?
    
    @State private var currentDirection = ScrollDirection.none
    @State private var previousScrollOffset = CGFloat.zero
    
    var body: some View {
        ScrollView {
            Rectangle()
                .frame(height: 10000)
                .frame(maxWidth: .infinity)
                .foregroundStyle(.blue)
                .background(scrollDirectionTrackerView)
                .onPreferenceChange(ScrollOffsetKey.self) { updatedOffset in
                    updateScrollDirection(updatedOffset)
                }
        }
        .coordinateSpace(name: "scrollView")
        .navigationTitle("Hello")
    }
    
    private var scrollDirectionTrackerView: some View {
        GeometryReader { proxy in
            Color.clear.preference(key: ScrollOffsetKey.self,
                                   value: -proxy.frame(in: .named("scrollView")).origin.y)
        }
    }
    
    func didChangeScrollDirection(_ handler: @escaping (ScrollDirection) -> Void) -> ContentView {
        var newView = self
        newView.scrollDirectionUpdateHandler = handler
        return newView
    }
    
    private func updateScrollDirection(_ newOffset: CGFloat) {
        let offsetDifference = previousScrollOffset - newOffset
        var updatedDirection = currentDirection
        
        if abs(offsetDifference) > minimumDirectionChangeOffset {
            if offsetDifference > 0 {
                updatedDirection = .up
            } else {
                updatedDirection = .down
            }
            
            previousScrollOffset = newOffset
        }

        // Only publish if we have a different direction
        if updatedDirection != currentDirection {
            print("previous offset: \(previousScrollOffset)")
            print("updated direction: \(updatedDirection)")
            currentDirection = updatedDirection
            scrollDirectionUpdateHandler?(currentDirection)
        }
    }
}

enum ScrollDirection {
    case up
    case down
    case forward
    case backward
    case none
}

private struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue = CGFloat.zero
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}

For the most part this works well.

I want to draw your attention to the lines in the second snippet above where I say // Only publish if we have a different direction

If I perform the scroll fast in either direction, this works well.

However, when I drag really slowly, I feel either a race condition happens with multiple threads accessing this function or something else, but the direction gets set multiple times triggering multiple animations which results in a choppy user experience due to an animation being in progress.

I am wondering if there is a flaw in my implementation or a more elegant way to handle this:

  • with a debounce / throttling of some sorts
  • some kind of semaphore / lock such that we can lock the function
  • or better math even ?

Some other ideas that come to is a Bool variable to lock the function but was hoping for something more elegant.

I tried this old answer but it did not help me.

Is there a better way to prevent the slow drag from changing the scroll direction multiple times ?

Finally, I need to support iOS 16+.

Share Improve this question edited Jan 15 at 11:51 Shawn Frank asked Jan 15 at 7:34 Shawn FrankShawn Frank 5,2332 gold badges20 silver badges46 bronze badges 9
  • What iOS version do you need to support? iOS 18 has onScrollGeometryChanged which simplifies a lot of this. – Sweeper Commented Jan 15 at 8:00
  • I've just updated my question, it is iOS 16+. – Shawn Frank Commented Jan 15 at 8:02
  • I cannot reproduce with basic assumptions. scrollDirectionUpdateHandler doesn't get called at all. If I replace the whole preference key + geometry reader with a onGeometryChange, I cannot reproduce "direction gets set multiple times triggering multiple animations" when I scroll slowly. After all, there is no animations in your code. Replacing scrollDirectionUpdateHandler with a print, all the output looks totally expected to me. So please show a minimal reproducible example. – Sweeper Commented Jan 15 at 8:28
  • please see my updates @Sweeper - I have added all the required code and some examples. – Shawn Frank Commented Jan 15 at 10:46
  • Ah so the animation is hiding the nav bar. I think this is the problem - hiding the navigation bar changes the scroll offset as a side effect, making your code think that it is scrolling up and hence is changing the scroll direction. – Sweeper Commented Jan 15 at 10:56
 |  Show 4 more comments

1 Answer 1

Reset to default 0

Another approach is you can use UIScrollView and UIScrollViewDelegate like this.

import SwiftUI
import UIKit

enum ScrollDirection {
    case up, down, forward, backward, none
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

class ScrollViewDelegate: NSObject, UIScrollViewDelegate {
    var onScroll: (ScrollDirection) -> Void
    private var lastContentOffset: CGFloat = 0
    
    init(onScroll: @escaping (ScrollDirection) -> Void) {
        self.onScroll = onScroll
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let currentOffset = scrollView.contentOffset.y
        print("Current offset: \(currentOffset), Last offset: \(lastContentOffset)")
        
        if currentOffset > lastContentOffset {
            print("Scrolling down")
            onScroll(.down)
        } else if currentOffset < lastContentOffset {
            print("Scrolling up")
            onScroll(.up)
        }
        
        lastContentOffset = currentOffset
    }
}

struct UIScrollViewWrapper<Content: View>: UIViewRepresentable {
    let content: Content
    let onScroll: (ScrollDirection) -> Void
    
    init(@ViewBuilder content: () -> Content, onScroll: @escaping (ScrollDirection) -> Void) {
        self.content = content()
        self.onScroll = onScroll
    }
    
    func makeCoordinator() -> ScrollViewDelegate {
        ScrollViewDelegate(onScroll: onScroll)
    }
    
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.delegate = context.coordinator
        
        // Embed SwiftUI content
        let hostingController = UIHostingController(rootView: content)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        
        scrollView.addSubview(hostingController.view)
        
        NSLayoutConstraint.activate([
            hostingController.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: scrollView.topAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            hostingController.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
        ])
        
        return scrollView
    }
    
    func updateUIView(_ uiView: UIScrollView, context: Context) { }
} 

and you can use it like this

UIScrollViewWrapper {
    LazyVStack(spacing: 10) {
        ForEach(0..<40) { index in
            Text("Item \(index)")
                .font(.system(.body, design: .rounded))
                .frame(maxWidth: .infinity)
                .padding()
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color.blue.opacity(0.1))
                )
                .shadow(radius: 2)
        }
    }
    .padding()
} onScroll: { direction in
    print("Scroll direction: \(direction)")
    DispatchQueue.main.async {
        withAnimation {
            lastDirection = direction
        }
    }
}
转载请注明原文地址:http://anycun.com/QandA/1745595141a90939.html