Atoll/DynamicIsland/extensions/PanGesture.swift
2025-12-24 12:51:03 +05:30

146 lines
5.3 KiB
Swift

//
// PanGesture.swift
// DynamicIsland
//
// Created by Richard Kunkli on 21/08/2024.
//
import AppKit
import SwiftUI
enum PanDirection {
case left, right, up, down
var isHorizontal: Bool { self == .left || self == .right }
var sign: CGFloat { (self == .right || self == .down) ? 1 : -1 }
func signed(from translation: CGSize) -> CGFloat { (isHorizontal ? translation.width : translation.height) * sign }
func signed(deltaX: CGFloat, deltaY: CGFloat) -> CGFloat { (isHorizontal ? deltaX : deltaY) * sign }
}
extension View {
func panGesture(direction: PanDirection, threshold: CGFloat = 4, action: @escaping (CGFloat, NSEvent.Phase) -> Void) -> some View {
self
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let s = direction.signed(from: value.translation)
guard s > 0, s.magnitude >= threshold else { return }
action(s.magnitude, .changed)
}
.onEnded { _ in action(0, .ended) }
)
.background(ScrollMonitor(direction: direction, threshold: threshold, action: action))
}
}
private struct ScrollMonitor: NSViewRepresentable {
let direction: PanDirection
let threshold: CGFloat
let action: (CGFloat, NSEvent.Phase) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
context.coordinator.installMonitor(on: view)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.removeMonitor() }
func makeCoordinator() -> Coordinator {
Coordinator(direction: direction, threshold: threshold, action: action)
}
@MainActor final class Coordinator: NSObject {
private let direction: PanDirection
private let threshold: CGFloat
private let action: (CGFloat, NSEvent.Phase) -> Void
private var monitor: Any?
private var globalMonitor: Any?
private var accumulated: CGFloat = 0
private var active = false
private let noiseThreshold: CGFloat = 0.2
private weak var observedView: NSView?
private let hoverInset: CGFloat = 14
private var lastEventTimestamp: TimeInterval = 0
init(direction: PanDirection, threshold: CGFloat, action: @escaping (CGFloat, NSEvent.Phase) -> Void) {
self.direction = direction
self.threshold = threshold
self.action = action
}
func installMonitor(on view: NSView) {
removeMonitor()
observedView = view
monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in
guard let self else { return event }
self.handleScroll(event)
return event
}
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in
self?.handleScroll(event)
}
}
func removeMonitor() {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
self.monitor = nil
}
if let globalMonitor = globalMonitor {
NSEvent.removeMonitor(globalMonitor)
self.globalMonitor = nil
}
accumulated = 0
active = false
observedView = nil
lastEventTimestamp = 0
}
private func handleScroll(_ event: NSEvent) {
guard lastEventTimestamp != event.timestamp else { return }
lastEventTimestamp = event.timestamp
guard isCursorNearObservedView(using: event) else { return }
if event.phase == .ended || event.momentumPhase == .ended {
if active {
action(accumulated.magnitude, .ended)
} else {
action(0, .ended)
}
active = false
accumulated = 0
return
}
let s = direction.signed(deltaX: event.scrollingDeltaX, deltaY: event.scrollingDeltaY)
guard s.magnitude > noiseThreshold else { return }
accumulated = s > 0 ? accumulated + s : 0
if !active && accumulated >= threshold {
active = true
action(accumulated.magnitude, .began)
} else if active {
action(accumulated.magnitude, .changed)
}
}
private func isCursorNearObservedView(using event: NSEvent) -> Bool {
guard let view = observedView, let window = view.window else { return false }
let screenPoint: NSPoint
if let eventWindow = event.window {
let rect = NSRect(origin: event.locationInWindow, size: .zero)
screenPoint = eventWindow.convertToScreen(rect).origin
} else {
screenPoint = NSEvent.mouseLocation
}
let windowPoint = window.convertPoint(fromScreen: screenPoint)
let localPoint = view.convert(windowPoint, from: nil)
let hitArea = view.bounds.insetBy(dx: -hoverInset, dy: -hoverInset)
return hitArea.contains(localPoint)
}
}
}