SwiftUI: Two-finger swipe ( scroll ) gesture
Asked Answered
K

6

6

I'm interested in 2-finger swipe ( scroll ) gesture.

Not two-finger drag, but 2-finger swipe (without press). Like used in Safari to scroll up and down.

As I see noone of basic gestures will work for this: TapGesture - is not; LongPressGesture - not; DragGesture - not; MagnificationGesture - not; RotationGesture - not;

Have anyone some ideas how to do this?

I need at least direction to look at.


  • This is MacOS project
  • And by the way I cannot use UI classes in my project, I cannot re-made project to catalist
Karafuto answered 10/2, 2021 at 13:16 Comment(1)
This is old, but may point you in the right direction: #6748103Stagg
K
2
import Combine

@main
struct MyApp: App {
    @State var subs = Set<AnyCancellable>() // Cancel onDisappear

    @SceneBuilder
    var body: some Scene {
        WindowGroup {
            SomeWindowView()
                /////////////
                // HERE!!!!!
                /////////////
                .onAppear { trackScrollWheel() }
        }
    }
}

/////////////
// HERE!!!!!
/////////////
extension MyApp {
    func trackScrollWheel() {
        NSApp.publisher(for: \.currentEvent)
            .filter { event in event?.type == .scrollWheel }
            .throttle(for: .milliseconds(200),
                      scheduler: DispatchQueue.main,
                      latest: true)
            .sink {
                if let event = $0 {
                    if event.deltaX > 0 { print("right") }
                    if event.deltaX < 0 { print("left") }
                    if event.deltaY > 0 { print("down") }
                    if event.deltaY < 0 { print("up") }
                }
            }
            .store(in: &subs)
    }
}
Karafuto answered 10/12, 2021 at 11:32 Comment(2)
This works only sporadically for me, which might be related to my attempt to attach this to a List. Every once in a while I'd get a printout but not reliably enough to attach logic to it, sadly.Familiarize
this is enough to attach logic in case of usage with signals systemKarafuto
D
8

With due respect to @duncan-c 's answer, the more effective way is to use the NSResponder's scrollWheel(with: NSEvent) mechanism to track two-finger scrolling (one finger on the Apple Mouse).

However it's only available under NSView, so you need to integrate it into SwiftUI using NSRepresentableView.

Here is a complete set of working code that scrolls the main image using the scroll wheel. The code uses delegates and callbacks to pass the scroll event back up the chain into SwiftUI:

//
//  ContentView.swift
//  ScrollTest
//
//  Created by TR Solutions on 6/9/21.
//

import SwiftUI

/// How the view passes events back to the representable view.
protocol ScrollViewDelegateProtocol {
  /// Informs the receiver that the mouse’s scroll wheel has moved.
  func scrollWheel(with event: NSEvent);
}

/// The AppKit view that captures scroll wheel events
class ScrollView: NSView {
  /// Connection to the SwiftUI view that serves as the interface to our AppKit view.
  var delegate: ScrollViewDelegateProtocol!
  /// Let the responder chain know we will respond to events.
  override var acceptsFirstResponder: Bool { true }
  /// Informs the receiver that the mouse’s scroll wheel has moved.
  override func scrollWheel(with event: NSEvent) {
    // pass the event on to the delegate
    delegate.scrollWheel(with: event)
  }
}

/// The SwiftUI view that serves as the interface to our AppKit view.
struct RepresentableScrollView: NSViewRepresentable, ScrollViewDelegateProtocol {
  /// The AppKit view our SwiftUI view manages.
  typealias NSViewType = ScrollView
  
  /// What the SwiftUI content wants us to do when the mouse's scroll wheel is moved.
  private var scrollAction: ((NSEvent) -> Void)?
  
  /// Creates the view object and configures its initial state.
  func makeNSView(context: Context) -> ScrollView {
    // Make a scroll view and become its delegate
    let view = ScrollView()
    view.delegate = self;
    return view
  }
  
  /// Updates the state of the specified view with new information from SwiftUI.
  func updateNSView(_ nsView: NSViewType, context: Context) {
  }
  
  /// Informs the representable view  that the mouse’s scroll wheel has moved.
  func scrollWheel(with event: NSEvent) {
    // Do whatever the content view wants
    // us to do when the scroll wheel moved
    if let scrollAction = scrollAction {
      scrollAction(event)
    }
  }

  /// Modifier that allows the content view to set an action in its context.
  func onScroll(_ action: @escaping (NSEvent) -> Void) -> Self {
    var newSelf = self
    newSelf.scrollAction = action
    return newSelf
  }
}

/// Our SwiftUI content view that we want to be able to scroll.
struct ContentView: View {
  /// The scroll offset -- when this value changes the view will be redrawn.
  @State var offset: CGSize = CGSize(width: 0.0, height: 0.0)
  /// The SwiftUI view that detects the scroll wheel movement.
  var scrollView: some View {
    // A view that will update the offset state variable
    // when the scroll wheel moves
    RepresentableScrollView()
      .onScroll { event in
        offset = CGSize(width: offset.width + event.deltaX, height: offset.height + event.deltaY)
      }
  }
  /// The body of our view.
  var body: some View {
    // What we want to be able to scroll using offset(),
    // overlaid (must be on top or it can't get the scroll event!)
    // with the view that tracks the scroll wheel.
    Image(systemName:"applelogo")
      .scaleEffect(20.0)
      .frame(width: 200, height: 200, alignment: .center)
      .offset(offset)
      .overlay(scrollView)
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}
Dunghill answered 6/9, 2021 at 3:16 Comment(0)
G
2

Edit: Correcting my answer cover Mac OS

Scrolling up and down is a NSPanGestureRecognizer. That has a numberOfTouchesRequired property that lets you make it respond to 2 fingers if desired.

Mac OS does not have a swipe gesture recognizer.

The standard UISwipeGestureRecognizer does exactly what you want. Just set numberOfTouchesRequired to 2.

...Although I'm not sure mobile Safari uses swipe gestures. It might be a 2-finger drag with some special coding.

Granddaddy answered 10/2, 2021 at 13:35 Comment(2)
tag "macos". And there at the moment no UI classes, only NS. Google don't show me nothing about NSSwipeGestureRecognizer =( (more correctly is that is impossible to connect UI classes to my project)Karafuto
Oh, sorry. I'm so used to everybody asking about iOS that I missed that. UI classes are iOS specific.Granddaddy
K
2
import Combine

@main
struct MyApp: App {
    @State var subs = Set<AnyCancellable>() // Cancel onDisappear

    @SceneBuilder
    var body: some Scene {
        WindowGroup {
            SomeWindowView()
                /////////////
                // HERE!!!!!
                /////////////
                .onAppear { trackScrollWheel() }
        }
    }
}

/////////////
// HERE!!!!!
/////////////
extension MyApp {
    func trackScrollWheel() {
        NSApp.publisher(for: \.currentEvent)
            .filter { event in event?.type == .scrollWheel }
            .throttle(for: .milliseconds(200),
                      scheduler: DispatchQueue.main,
                      latest: true)
            .sink {
                if let event = $0 {
                    if event.deltaX > 0 { print("right") }
                    if event.deltaX < 0 { print("left") }
                    if event.deltaY > 0 { print("down") }
                    if event.deltaY < 0 { print("up") }
                }
            }
            .store(in: &subs)
    }
}
Karafuto answered 10/12, 2021 at 11:32 Comment(2)
This works only sporadically for me, which might be related to my attempt to attach this to a List. Every once in a while I'd get a printout but not reliably enough to attach logic to it, sadly.Familiarize
this is enough to attach logic in case of usage with signals systemKarafuto
E
2

Since I like using view modifiers, I'll show a practical example here:

struct ScrollWheelModifier: ViewModifier {
    
    enum Direction {
        case up, down, left, right
    }

    @State private var subs = Set<AnyCancellable>() // Cancel onDisappear

    var action: (Direction) -> Void
    
    func body(content: Content) -> some View {
        content
            .onAppear { trackScrollWheel() }
    }
    
    func trackScrollWheel() {
        NSApp.publisher(for: \.currentEvent)
            .filter { event in event?.type == .scrollWheel }
            .throttle(for: .milliseconds(200),
                      scheduler: DispatchQueue.main,
                      latest: true)
            .sink {
                if let event = $0 {
                    if event.deltaX > 0 {
                        action(.right)
                    }
                    
                    if event.deltaX < 0 {
                        action(.left)
                    }
                    
                    if event.deltaY > 0 {
                        action(.down)
                    }
                    
                    if event.deltaY < 0 {
                        action(.up)
                    }
                }
            }
            .store(in: &subs)
    }
}

extension View {
    func onScrollWheelUp(action: @escaping (ScrollWheelModifier.Direction) -> Void) -> some View {
        modifier(ScrollWheelModifier(action: action) )
    }
}

Example:

var body: some View {
     VStack {
        Text("Hello World!")
     }
     .onScrollWheelUp { direction in
            switch direction {
            case .up:
                print("up")
            case .down:
                print("down")
            case .left:
                print("left")
            case .right:
                print("right")
            }
        }
}
Exequies answered 12/12, 2023 at 16:36 Comment(0)
D
1

Okay, I spent some time and now have a view modifier on Github that may solve this problem. It’s uses @RG_’s solution which I find to be cleaner. I did have to add some code to clean up the monitoring or there is a memory leak.

SwipeModifier code can be found here

The result is that you can add the modifier to any view like the following:

VStack {
    Image(systemName: "globe")
        .imageScale(.large)
        .foregroundStyle(.tint)
    Text("Hello, world!")
}
.frame(width:600, height: 400)
.onSwipe { event in
    switch event.direction {
        case .up:
            print("up")
        case .down:
            print("down")
        case .left:
            print("left")
        case .right:
            print("right")
        default:
            print("nothing")
    }
}

I’ve profiled it for memory leaks (that took the most time to resolve) and it all looks good.

I also pass the modifiers in so you can handle option-scrollWheel events, etc.

Note that this passes the event up the chain, so if you have a scroll list, it will also respond. Just an FYI if things start acting wacky.

Dial answered 22/8, 2024 at 21:38 Comment(0)
L
0

If your view already happens to implement the NSViewRepresentable protocol, you can handle scroll events by adding just the following in its onAppear method:

MyRepresentableView()
.onAppear {
    NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { event in
        print("dx = \(event.deltaX)  dy = \(event.deltaY)")
        return event
    }
} 
Lactescent answered 25/2, 2023 at 0:42 Comment(3)
the same solution as mine. But slower :)Karafuto
@Andrew___Pls_Support_UA I actually find your version to be slower, unless I reduce your throttle time from 200 to e.g. 2 ms, in which case it appears to be just as responsive as mine. I'm going by the apparent time between performing the scroll gesture (on a Magic Trackpad, with Mac Mini M2) and first detecting it in code. In either case, the response appears to be instantaneous. In my application I don't care about how quickly the subsequent events generated by any single scroll gesture are received. How were you measuring the speed?Lactescent
I use @Lactescent solution along with onContinuousHover { phase in so swipes are contained where I want them (Lists, for example, do this on their own). This gets pretty messy pretty fast.Dial

© 2022 - 2025 — McMap. All rights reserved.