Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI Nested Scrollviews problem on macOS

(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.

Apple Music App Scrolling

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.

iOS scrolling works

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

enter image description here

macOS scrolling problems

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()
    }
}
like image 522
Denise Avatar asked Nov 19 '20 22:11

Denise


Video Answer


1 Answers

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:

  1. Make a 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.
  2. Insert this 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.

like image 101
siejkowski Avatar answered Sep 19 '22 08:09

siejkowski