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:
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:
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+.
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
}
}
}
onScrollGeometryChanged
which simplifies a lot of this. – Sweeper Commented Jan 15 at 8:00scrollDirectionUpdateHandler
doesn't get called at all. If I replace the whole preference key + geometry reader with aonGeometryChange
, I cannot reproduce "direction gets set multiple times triggering multiple animations" when I scroll slowly. After all, there is no animations in your code. ReplacingscrollDirectionUpdateHandler
with aprint
, all the output looks totally expected to me. So please show a minimal reproducible example. – Sweeper Commented Jan 15 at 8:28