(If any Apple folks see this - I filed a feedback: FB8910881)
I am trying to build a an SwiftUI app with a layout similar to the Apple Music app - Artist Page or like in the SwiftUI tutorial from Apple (Landmarks app). Inside the app you can scroll vertically over list items and horizontally over all the rows.
In my app I have a list of rows inside a ScrollView
(vertical). The rows itself scrolls vertical and contain a ScrollView
and an LazyHGrid
. It works on iOS but not on macOS.
On iOS I can scroll vertically through the list and horizontally through the row-items - in the Simulator you click and drag to scroll inside the app - works like a charm.
On macOS however I can only scroll vertically if I am not over a row... I scroll via the magic mouse up/down/left/right. The row's Scrollview seems to catch the vertical scrolling... In the Apple Music app you can scroll up and down and left and right if you over the Artist Playlist row.
Xcode 12.2 and Xcode 12.3 beta, macOS BigSur 11.0.1
Here is my code:
import SwiftUI
struct ScrollViewProblem: View {
var body: some View {
ScrollView {
HStack {
Text("Scrolling Problem")
.font(.largeTitle)
Spacer()
}.padding()
ScrollRow()
ScrollRow()
ScrollRow()
ScrollRow()
}
}
}
struct ScrollRow: View {
let row = [
GridItem(.flexible(minimum: 200))
]
var body: some View {
VStack {
HStack {
Text("Row")
.font(.largeTitle)
Spacer()
}
ScrollView(.horizontal) {
LazyHGrid(rows: row, spacing: 20) {
ForEach(1..<7) { index in
Rectangle()
.fill(Color.blue)
.frame(width: 200, height: 200)
}
}
}
}
.padding()
}
}
struct ScrollViewProblem_Previews: PreviewProvider {
static var previews: some View {
ScrollViewProblem()
}
}
I've found a workaround for the bug, although I hope it will soon be solved by Apple.
To my best understanding the underlying issue is that the mouse wheel scroll events are not forwarded from the inner horizontal scroll view to the outer vertical scroll view.
What makes it so frustrating is that there is the AppKit
method exactly for that scenario: specifies whether the scroll events should be forwarded up the responder chain. It's called wantsForwardedScrollEvents
and it's documented here in the Apple documentation. Even though the documentation says it's for "elastic scrolling", this stack overflow answer shows how it's equally useful for passing the events up the responder chain for all scrolling events.
So what we need is:
NSView
that overrides the wantsForwardedScrollEvents
and returns true
for the vertical scrolling axis so that the vertical scrolling events are passed from the inner horizontal scroll to the outer vertical scroll.NSView
into the SwiftUI view hierarchy between the horizontal scroll and the vertical scroll.The second point is a little tricky, but thankfully the truly amazing SwiftUI Lab blog has a wonderful tutorial on how to do exactly that. Seriously, I cannot count how many times this blog has saved my sanity when working with SwiftUI.
So the final code can look like that:
// we need this workaround only for macOS
#if os(macOS)
// this is the NSView that implements proper `wantsForwardedScrollEvents` method
final class VerticalScrollingFixHostingView<Content>: NSHostingView<Content> where Content: View {
override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool {
return axis == .vertical
}
}
// this is the SwiftUI wrapper for our NSView
struct VerticalScrollingFixViewRepresentable<Content>: NSViewRepresentable where Content: View {
let content: Content
func makeNSView(context: Context) -> NSHostingView<Content> {
return VerticalScrollingFixHostingView<Content>(rootView: content)
}
func updateNSView(_ nsView: NSHostingView<Content>, context: Context) {}
}
// this is the SwiftUI wrapper that makes it easy to insert the view
// into the existing SwiftUI view builders structure
struct VerticalScrollingFixWrapper<Content>: View where Content : View {
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
VerticalScrollingFixViewRepresentable(content: self.content())
}
}
#endif
Having that structure ready, we can use it in your sample like this:
import SwiftUI
// just a helper to make using nicer by keeping #if os(macOS) in one place
extension View {
@ViewBuilder func workaroundForVerticalScrollingBugInMacOS() -> some View {
#if os(macOS)
VerticalScrollingFixWrapper { self }
#else
self
#endif
}
}
struct ScrollViewProblem: View {
var body: some View {
ScrollView {
HStack {
Text("Scrolling Problem")
.font(.largeTitle)
Spacer()
}.padding()
ScrollRow().workaroundForVerticalScrollingBugInMacOS()
ScrollRow().workaroundForVerticalScrollingBugInMacOS()
ScrollRow().workaroundForVerticalScrollingBugInMacOS()
ScrollRow().workaroundForVerticalScrollingBugInMacOS()
}
}
}
Verified on macOS 11.0.1 and Xcode 12.2.
[updated based on comment by @marshallino16]
You might encounter one SwiftUI issue after you've added the NSView
into your view hierarchy. The inner views stop responding to the change in the state of the outer view.
Using the terms from sample code here: if there is a @State
variable in ScrollViewProblem
that is passed down to ScrollRow
, the ScrollRow
doesn't always recompute on that @State variable change.
This issue is mentioned in the "An Unpleasant Surprise" section of SwiftUI Lab blogpost.
Unfortunately, I've not found a direct workaround to make the @State
propagation work again, so currently the best working solution I'm aware of (and been using myself) is to not pass the @State
variable from the ScrollViewProblem
to ScrollRow
.
This means that whatever information you need to pass to ScrollRow
, you must encapsulate it in the external object (view model, view store, whatever your architecture uses). Then you can pass this object to the ScrollRow
and let the ScrollRow
keep it and use it as either @State
or @StateObject
variable.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With