Enrich NSScrollView responsive scrolling API to allow for programatic cancellation

Originator:kubanek.l
Number:rdar://51328335 Date Originated:01.06.2019
Status:Open Resolved:no
Product:macOS + SDK Product Version:10.14.5
Classification: Reproducible:Always
 
In macOS Mavericks, the responsive scrolling architecture for NSScrollView was introduced which allows a smart way for generating overdraws and decouples handling of the incoming scroll events from the main event loop into a separate one. See WWDC 2013 / Session 215.

Because of the different event model, the scroll events no longer go through the scrollWheel(with:) method. In fact, if that method gets overridden in the NSScrollView (or the document view) subclass, the responsive scrolling architecture is disabled and the scrolling falls back to the legacy model.

After the responsive scrolling event loop is entered, it is not possible to access the scroll events. Moreover, it’s not possible to control this event loop in any way. However, in certain situations, it would be helpful to be able to programmatically cancel the scrolling. For example if the command modifier key is pressed, it would be reasonable to perform magnification instead of scrolling as it’s done in the prominent graphics and diagramming applications like Sketch, OmniGraffle or MindNode.

Currently, it’s possible to take a different route for handling the scroll events only for the initial event which arrives in scrollWheel(with:). If the scrolling is in progress and the command key is pressed, there is no way to cancel the scrolling happening on the separate event handling loop in order to handle the upcoming events differently.

Steps:
1. Run the Mac app from attached Xcode project.
2. Move the mouse to the scroll view, press and hold the command key and begin scrolling on a trackpad using two fingers.
3. Without lifting the fingers, release the command key and continue the scroll gesture.
4. Press and hold the command key again and continue the scroll gesture.

Expected:
1. The Mac app is launched.
2. The scroll view is not scrolled but rather the messages for handling the scroll event in a different way are printed.
3. The actual scrolling starts and goes on.
4. The scrolling stops and the messages for handling the scroll event in a different way are printed again.

Actual:
1. ✔︎
2. ✔︎
3. ✔︎
4. The scrolling does not stop and there is no API to achieve it.

If I’m missing an already available API for achieving what I’ve described above, I’d be glad the hear about it. Thanks.

Some further information can be found in my question on Stack Overflow: https://stackoverflow.com/questions/46785553/nsscrollview-magnify-with-cmdscroll-interaction-with-preserved-responsive-scro

---------------------------------------------------------------------------------------------------------------------

The excerpt from the attached code:

internal final class CanvasView: NSView {
    
    // ======================================================= //
    // MARK: - Configuration
    // ======================================================= //
    
    override var acceptsFirstResponder: Bool {
        return true
    }
    
    override static var isCompatibleWithResponsiveScrolling: Bool {
        return true
    }
    
    // ======================================================= //
    // MARK: - Event Handling
    // ======================================================= //
    
    override func scrollWheel(with event: NSEvent) {
        if event.modifierFlags.contains(.command) {
            print("Handling scroll event as magnification (time=\(event.timestamp))")
        } else {
            print("Passing initial scroll event to super to trigger responsive scrolling")
            super.scrollWheel(with: event)
        }
    }
    
    private var _previousFlagsChangedEvent: NSEvent?
    
    override func flagsChanged(with event: NSEvent) {
        let commandWasPressed = _previousFlagsChangedEvent?.modifierFlags.contains(.command) ?? false
        let commandIsPressed = event.modifierFlags.contains(.command)
        
        defer { _previousFlagsChangedEvent = event }
        
        switch (commandWasPressed, commandIsPressed) {
        case (false, true):
            // Transition: scrolling -> magnification
            //
            // The scrolling which is being tracked on the separate loop has to be stopped in order
            // to route the next scroll events through scrollWheel(with:) again.
            print("Pressed CMD")
        case (true, false):
            // Transition: magnification -> scrolling
            //
            // Since the next scroll event goes though scrollWheel(with:) the scrolling is correctly
            // picked up.
            print("Unpressed CMD")
        default:
            break
        }
    }
    
    // ======================================================= //
    // MARK: - Drawing
    // ======================================================= //
    
    override func draw(_ dirtyRect: NSRect) {
        NSColor.white.set()
        dirtyRect.fill()
        
        let phrases = Array(repeating: "Buttery smooth scrolling", count: 500)
        let string = phrases.joined(separator: ". ")
        
        NSString(string: string).draw(
            in: bounds,
            withAttributes: [
                NSAttributedString.Key.foregroundColor: NSColor.lightGray
            ]
        )
    }
    
}

Comments


Please note: Reports posted here will not necessarily be seen by Apple. All problems should be submitted at bugreport.apple.com before they are posted here. Please only post information for Radars that you have filed yourself, and please do not include Apple confidential information in your posts. Thank you!