5

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
Andrew_STOP_RU_WAR_IN_UA
  • 9,318
  • 5
  • 65
  • 101
  • This is old, but may point you in the right direction: https://stackoverflow.com/questions/6747603/how-to-implement-the-two-finger-swipe-gesture-in-cocoa-to-go-back-and-forward – koen Feb 10 '21 at 17:34

4 Answers4

6

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()
  }
}
Thomas O'Dell
  • 504
  • 6
  • 11
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.

Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • 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) – Andrew_STOP_RU_WAR_IN_UA Feb 10 '21 at 14:39
  • Oh, sorry. I'm so used to everybody asking about iOS that I missed that. UI classes are iOS specific. – Duncan C Feb 10 '21 at 16:25
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)
    }
}
Andrew_STOP_RU_WAR_IN_UA
  • 9,318
  • 5
  • 65
  • 101
  • 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. – sas Feb 11 '22 at 16:06
  • this is enough to attach logic in case of usage with signals system – Andrew_STOP_RU_WAR_IN_UA Feb 11 '22 at 16:26
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
    }
} 
RG_
  • 21
  • 4
  • the same solution as mine. But slower :) – Andrew_STOP_RU_WAR_IN_UA Feb 25 '23 at 01:35
  • @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? – RG_ Mar 03 '23 at 16:54