I’m working on the Mac version of the Linkage app and I’ve been having some trouble with panning and zooming. When a single finger is slide along the top of the Apple Magic Mouse, the software gets a “scrollWheel” event. It’s a Magic Mouse so there are values for both vertical and horizontal finger movement. If you try the Maps app on the Mac, you will see that both the mouse and the trackpad can be used to pan the map. The trackpad is different, though, because it takes two fingers to trigger the “scrollWheel” event. The event itself is exactly the same regardless of the mouse or the trackpad triggering it.

The problem I have with this is that I want the mouse “wheel” to change the zoom and the trackpad to do a pan Apple does not provide any way to achieve this when processing the event. My first attempt to deal with this was to keep track of changes to the special keys on the keyboard. The command, option, control, function, and shift keys, are all modifier keys. By keeping track of them, I was able to process the two-finger movement on the trackpad while the command key is done, as a pan.

I had tried to write code to track touch events and keep track of touch movement directly with no luck. The way touch events are delivered, I was not getting smooth movement. If a finger was lifted or not detected for even a single event, it would throw off all of the position averaging I was doing. I had panning working but it was complicated and very glitchy – it was unusable.

Today, I realized that if I can keep track of which keys are down and I can process touch events, I can keep track of how many fingers are down and then use that to decide what to do when the “scrollWheel” event shows up. The beauty of this is that there are no touch events when touching the top of the mouse so any fingers down will work. I had to figure out how best to track the count and my first try didn’t work right – I ended up counting three touches when there were only two. Simplifying things always help and the code below shows my final solution. The touch events are handled in the NSView as is the scrollWheel event.

	var touchCount = 0
	
	override func touchesBegan( with event: NSEvent ) {
		getTouchCount( with: event )
	}
	
	override func touchesEnded( with event: NSEvent ) {
		getTouchCount( with: event )
	}
	
	override func touchesCancelled(with event: NSEvent) {
		getTouchCount( with: event )
	}
	
	private func getTouchCount( with event: NSEvent ) {
		touchCount = 0
		for touch in event.allTouches() {
			if !touch.phase.oneOf( .cancelled, .ended ) {
				touchCount += 1
			}
		}
	}

	override func scrollWheel( with event: NSEvent ) {
		if touchCount == 2 {
			let adjustment = 1.2
			dragPreviousPoint = Point()
			onPan( using: Point( event.scrollingDeltaX * adjustment, -event.scrollingDeltaY * adjustment ) )
			return
		}
		
		let point = convert( event.locationInWindow, from: nil )
		mousePoint = Point( cgPoint: point )
		
		zoom( delta: event.scrollingDeltaY )
		if event.phase == .ended {
			markDirtyForPersistentChange()
		}
	}

The scrollWheel event in this code is testing the touch count and then doing a few things needed in order to use the onPan() function that is used for a mouse movement when the right-button is down. That’s why there is a dragPreviousPoint in there. The code also converts to my own type of Point object just because that’s how I do things (CGPoint seems too tightly coupled to the Core Graphics code and I wanted a Point struct that was more global and also a struct instead of a class for faster processing). The markDirty… function is used to mark the document as changed only once for any pan or zoom. I’m still trying to figure out how to deal with that in the best way since the pan and zoom are saved with the document but are not actions that can be undone.

So this is the solution; Just keep track of how many fingers are down and if it’s zero then the event is not coming from the trackpad!

UPDATE!

After I wrote this code and tested it for a while, I discovered that letting up on the trackpad while still swiping will result in a continued pan. This is because the OS uses a “momentum” feature to keep the pan moving. The problem is that there are no fingers down during this time. I experimented a lot and discovered that I can check the momentumPhase value for non-zero to detect momentum. But then it turns out that there is a call or two to the event handler with no momentum and while no fingers are down just before the momentum-related events show up. I solved this with a timer and I don’t “end” the pan unless scrollWheel events have a little gap (0.1 seconds right now) between them. I could probably track this better by detecting momentum-related events going away after they had started up. There is an interesting set of states that could be monitored more accurately if it was important. I’m just interested in having working code right now, not having perfect code. Here’s the new scrollWheel function:

var lastScrollWheelTick = Double( DispatchTime.now().uptimeNanoseconds ) / 1000000000.0

override func scrollWheel( with event: NSEvent ) {
    let tick = Double( DispatchTime.now().uptimeNanoseconds ) / 1000000000.0
    let timeSinceLastEvent = tick - lastScrollWheelTick
    lastScrollWheelTick = tick

    if wasTwoFingers {
        lastPanTick = tick
        // Test for touch count of 2, some momentum, or if thre has not been much time sinc ethe last event, which suggests
        // that this event is a zero-or-one-finger event or thw weird no-fingers-no-momentum event that shows up before the
        // momentum actually starts. An event count might also be useful but might be overkill. The onoy problem with this
        // is that using the mouse top (on Magic Mouse) does panning if the momentun hasn't stopped for long enough, which
        // it won't have.
        if touchCount == 2 || event.momentumPhase.rawValue != 0 || timeSinceLastEvent < 0.1 {
            let adjustment = 1.0
            dragPreviousPoint = Point()
            onPan( using: Point( event.scrollingDeltaX * adjustment, -event.scrollingDeltaY * adjustment ) )
            return
        } else {
            wasTwoFingers = false
        }
    }
    

    let point = convert( event.locationInWindow, from: nil )
    mousePoint = Point( cgPoint: point )
    
    zoom( delta: event.scrollingDeltaY )
}