From e5219ff353613b6493bfe5a3333c3bfa2d1e4d57 Mon Sep 17 00:00:00 2001 From: Alexey Bukhtin Date: Wed, 18 Dec 2024 16:38:33 +0100 Subject: [PATCH] Add scroll wheel events for macOS --- Sources/CompactSlider/CompactSlider.swift | 38 ++++++++++--- Sources/CompactSlider/GestureOption.swift | 4 +- .../CompactSlider/ScrollWheelModifier.swift | 56 +++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 Sources/CompactSlider/ScrollWheelModifier.swift diff --git a/Sources/CompactSlider/CompactSlider.swift b/Sources/CompactSlider/CompactSlider.swift index 469398a..25d000c 100644 --- a/Sources/CompactSlider/CompactSlider.swift +++ b/Sources/CompactSlider/CompactSlider.swift @@ -94,6 +94,7 @@ public struct CompactSlider: View @State var lowerProgress: Double = 0 @State var upperProgress: Double = 0 @State private var dragLocationX: CGFloat = 0 + @State private var deltaLocationX: CGFloat = 0 @State var adjustedDragLocationX: (lower: CGFloat, upper: CGFloat) = (0, 0) /// Creates a slider to select a value from a given bounds. @@ -227,6 +228,13 @@ public struct CompactSlider: View isHovering = isEnabled && $0 updateState() } + .onScrollWheel(isEnabled: gestureOptions.contains(.scrollWheel)) { delta in + guard isHovering, abs(delta.x) > abs(delta.y) else { return } + + Task { + deltaLocationX = delta.x + } + } #endif .dragGesture( options: gestureOptions, @@ -274,6 +282,7 @@ public struct CompactSlider: View } .frame(width: proxy.size.width, height: proxy.size.height) .onChange(of: dragLocationX) { onDragLocationXChange($0, size: proxy.size) } + .onChange(of: deltaLocationX) { onDeltaLocationXChange($0, size: proxy.size) } } HStack { valueLabel } @@ -302,29 +311,40 @@ public struct CompactSlider: View private extension CompactSlider { func onDragLocationXChange(_ newValue: CGFloat, size: CGSize) { - guard !bounds.isEmpty else { return } + guard !bounds.isEmpty, size.width > 0 else { return } + + updateProgress(max(0, min(1, newValue / size.width))) + } + + func onDeltaLocationXChange(_ newDelta: CGFloat, size: CGSize) { + guard !bounds.isEmpty, size.width > 0 else { return } - let newProgress = max(0, min(1, newValue / size.width)) + let deltaProgressStep = progressStep * (newDelta.sign == .minus ? -0.5 : 0.5) + let newProgress = max(0, min(1, lowerProgress + newDelta / size.width + deltaProgressStep)) + updateProgress(newProgress) + } + + func updateProgress(_ newValue: Double) { let isProgress2Nearest: Bool // Check which progress is closest and should be in focus. if abs(upperProgress - lowerProgress) < 0.01 { - isProgress2Nearest = newProgress > upperProgress + isProgress2Nearest = newValue > upperProgress } else { - isProgress2Nearest = isRangeValues && abs(lowerProgress - newProgress) > abs(upperProgress - newProgress) + isProgress2Nearest = isRangeValues && abs(lowerProgress - newValue) > abs(upperProgress - newValue) } guard progressStep > 0 else { if isProgress2Nearest { - if upperProgress != newProgress { - upperProgress = newProgress + if upperProgress != newValue { + upperProgress = newValue if upperProgress == 1 { HapticFeedback.vibrate(disabledHapticFeedback) } } - } else if lowerProgress != newProgress { - lowerProgress = newProgress + } else if lowerProgress != newValue { + lowerProgress = newValue if lowerProgress == 0 || lowerProgress == 1 { HapticFeedback.vibrate(disabledHapticFeedback) @@ -334,7 +354,7 @@ private extension CompactSlider { return } - let rounded = (newProgress / progressStep).rounded() * progressStep + let rounded = (newValue / progressStep).rounded() * progressStep if isProgress2Nearest { if rounded != upperProgress { diff --git a/Sources/CompactSlider/GestureOption.swift b/Sources/CompactSlider/GestureOption.swift index 21f7d6a..34d203d 100644 --- a/Sources/CompactSlider/GestureOption.swift +++ b/Sources/CompactSlider/GestureOption.swift @@ -13,6 +13,8 @@ public enum GestureOption: Hashable { case highPriorityGesture /// Enables delay when sliders inside ``ScrollView`` or ``Form``. Enabled by default for iOS. case delayedGesture + /// Enables the scroll wheel. + case scrollWheel } /// A set of drag gesture options: minimum drag distance, delayed touch, and high priority. @@ -22,7 +24,7 @@ extension Set { /// For macOS: minimum drag distance 0. public static var `default`: Self { #if os(macOS) - [.dragGestureMinimumDistance(0)] + [.dragGestureMinimumDistance(0), .scrollWheel] #else [.dragGestureMinimumDistance(1), .delayedGesture] #endif diff --git a/Sources/CompactSlider/ScrollWheelModifier.swift b/Sources/CompactSlider/ScrollWheelModifier.swift new file mode 100644 index 0000000..ca076c1 --- /dev/null +++ b/Sources/CompactSlider/ScrollWheelModifier.swift @@ -0,0 +1,56 @@ +// The MIT License (MIT) +// +// Copyright (c) 2024 Alexey Bukhtin (github.com/buh). +// + +import SwiftUI +#if canImport(AppKit) +import AppKit +import Combine + +struct ScrollWheelModifier: ViewModifier { + let action: (_ delta: CGPoint) -> Void + private var cancellable = Set() + + init(action: @escaping (_ delta: CGPoint) -> Void) { + self.action = action + subscribeForScrollWheelEvents() + } + + func body(content: Content) -> some View { + content + } + + mutating func subscribeForScrollWheelEvents() { + NSApp.publisher(for: \.currentEvent) + .filter { $0?.type == .scrollWheel } + .compactMap { + if let event = $0 { + return CGPoint(x: event.deltaX, y: event.deltaY) + } + + return nil + } + .removeDuplicates() + .sink { [action] in action($0) } + .store(in: &cancellable) + } +} + +extension View { + @ViewBuilder + func onScrollWheel(isEnabled: Bool = true, action: @escaping (_ delta: CGPoint) -> Void) -> some View { + if isEnabled { + modifier(ScrollWheelModifier(action: action)) + } else { + self + } + } +} +#else +extension View { + func onScrollWheel(isEnabled: Bool = false, action: @escaping (_ delta: CGPoint) -> Void) -> some View { + self + } +} +#endif