diff --git a/Package.swift b/Package.swift index 09715a9..156f782 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,13 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "SlidingRuler", - platforms: [.iOS(.v13)], + platforms: [.iOS(.v15), + .macOS(.v14), + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( diff --git a/Sources/SlidingRuler/HorizontalPanGesture.swift b/Sources/SlidingRuler/HorizontalPanGesture.swift index 56260b5..d20888a 100644 --- a/Sources/SlidingRuler/HorizontalPanGesture.swift +++ b/Sources/SlidingRuler/HorizontalPanGesture.swift @@ -30,6 +30,7 @@ import SwiftUI import CoreGeometry +#if canImport(UIKit) struct HorizontalDragGestureValue { let state: UIGestureRecognizer.State let translation: CGSize @@ -38,7 +39,7 @@ struct HorizontalDragGestureValue { let location: CGPoint } -protocol HorizontalPanGestureReceiverViewDelegate: class { +protocol HorizontalPanGestureReceiverViewDelegate: AnyObject { func viewTouchedWithoutPan(_ view: UIView) } @@ -140,3 +141,5 @@ extension HorizontalPanGesture.Coordinator: UIPointerInteractionDelegate { .init(shape: .path(Pointers.standard), constrainedAxes: .vertical) } } + +#endif diff --git a/Sources/SlidingRuler/Mechanic.swift b/Sources/SlidingRuler/Mechanic.swift index 9714046..fbf618b 100644 --- a/Sources/SlidingRuler/Mechanic.swift +++ b/Sources/SlidingRuler/Mechanic.swift @@ -26,42 +26,83 @@ // SOFTWARE. // -import UIKit.UIScrollView +#if canImport(UIKit) +import UIKit +typealias ScrollView = UIScrollView +#elseif canImport(AppKit) +import AppKit +typealias ScrollView = NSScrollView +#endif import CoreGraphics -enum Mechanic { +enum Mechanic { + +#if canImport(UIKit) enum Inertia { private static let epsilon: CGFloat = 0.6 /// Velocity at time `t` of the initial velocity `v0` decelerated by the given deceleration rate. - static func velocity(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> CGFloat { + static func velocity(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: ScrollView.DecelerationRate) -> CGFloat { v0 * pow(rate.rawValue, (1000 * CGFloat(t))) } /// Travelled distance at time `t` for the initial velocity `v0` decelerated by the given deceleration rate. - static func distance(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> CGFloat { + static func distance(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: ScrollView.DecelerationRate) -> CGFloat { v0 * (pow(rate.rawValue, 1000 * CGFloat(t)) - 1) / (coef(rate)) } /// Total distance travelled for he initial velocity `v0` decelerated by the given deceleration rate before being completely still. - static func totalDistance(forVelocity v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> CGFloat { + static func totalDistance(forVelocity v0: CGFloat, decelerationRate rate: ScrollView.DecelerationRate) -> CGFloat { distance(atTime: duration(forVelocity: v0, decelerationRate: rate), v0: v0, decelerationRate: rate) } /// Total time ellapsed before the motion become completely still for the initial velocity `v0` decelerated by the given deceleration rate. - static func duration(forVelocity v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> TimeInterval { + static func duration(forVelocity v0: CGFloat, decelerationRate rate: ScrollView.DecelerationRate) -> TimeInterval { TimeInterval((log((-1000 * epsilon * log(rate.rawValue)) / abs(v0))) / coef(rate)) } - static func time(toReachDistance x: CGFloat, forVelocity v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> TimeInterval { + static func time(toReachDistance x: CGFloat, forVelocity v0: CGFloat, decelerationRate rate: ScrollView.DecelerationRate) -> TimeInterval { TimeInterval(log(1 + coef(rate) * x / v0) / coef(rate)) } - static func coef(_ rate: UIScrollView.DecelerationRate) -> CGFloat { + static func coef(_ rate: ScrollView.DecelerationRate) -> CGFloat { 1000 * log(rate.rawValue) } } +#elseif canImport(AppKit) + enum Inertia { + private static let epsilon: CGFloat = 0.6 + + /// Velocity at time `t` of the initial velocity `v0` decelerated by the given deceleration rate. + static func velocity(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: CGFloat) -> CGFloat { + v0 * pow(rate, (1000 * CGFloat(t))) + } + + /// Travelled distance at time `t` for the initial velocity `v0` decelerated by the given deceleration rate. + static func distance(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: CGFloat) -> CGFloat { + v0 * (pow(rate, 1000 * CGFloat(t)) - 1) / (coef(rate)) + } + + /// Total distance travelled for he initial velocity `v0` decelerated by the given deceleration rate before being completely still. + static func totalDistance(forVelocity v0: CGFloat, decelerationRate rate: CGFloat) -> CGFloat { + distance(atTime: duration(forVelocity: v0, decelerationRate: rate), v0: v0, decelerationRate: rate) + } + + /// Total time ellapsed before the motion become completely still for the initial velocity `v0` decelerated by the given deceleration rate. + static func duration(forVelocity v0: CGFloat, decelerationRate rate: CGFloat) -> TimeInterval { + TimeInterval((log((-1000 * epsilon * log(rate)) / abs(v0))) / coef(rate)) + } + + static func time(toReachDistance x: CGFloat, forVelocity v0: CGFloat, decelerationRate rate: CGFloat) -> TimeInterval { + TimeInterval(log(1 + coef(rate) * x / v0) / coef(rate)) + } + + static func coef(_ rate: CGFloat) -> CGFloat { + 1000 * log(rate) + } + } +#endif enum Spring { private static var e: CGFloat { CGFloat(M_E) } diff --git a/Sources/SlidingRuler/Pointers.swift b/Sources/SlidingRuler/Pointers.swift index f8cda36..1536233 100644 --- a/Sources/SlidingRuler/Pointers.swift +++ b/Sources/SlidingRuler/Pointers.swift @@ -27,11 +27,74 @@ // -import UIKit.UIBezierPath +#if canImport(UIKit) +import UIKit +typealias BezierPath = UIBezierPath +#elseif canImport(AppKit) +import AppKit +typealias BezierPath = NSBezierPath +#endif + +#if os(OSX) +import AppKit + +public extension NSBezierPath { + + var cgPath: CGPath { + get { + let path = CGMutablePath() + let points = NSPointArray.allocate(capacity: 3) + + for i in 0 ..< self.elementCount { + let type = self.element(at: i, associatedPoints: points) + switch type { + case .moveTo: + path.move(to: points[0]) + case .lineTo: + path.addLine(to: points[0]) + case .curveTo: + path.addCurve(to: points[2], control1: points[0], control2: points[1]) + case .closePath: + path.closeSubpath() + case .cubicCurveTo: + fatalError("Encountered an unknown element type in NSBezierPath") + case .quadraticCurveTo: + // TODO: Complete + fatalError("Encountered an unknown element type in NSBezierPath") + @unknown default: + fatalError("Encountered an unknown element type in NSBezierPath") + } + } + return path + } + } + + func addLine(to point: NSPoint) { + self.line(to: point) + } + + func addCurve(to point: NSPoint, controlPoint1: NSPoint, controlPoint2: NSPoint) { + self.curve(to: point, controlPoint1: controlPoint1, controlPoint2: controlPoint2) + } + + func addQuadCurve(to point: NSPoint, controlPoint: NSPoint) { + self.curve(to: point, + controlPoint1: NSPoint( + x: (controlPoint.x - self.currentPoint.x) * (2.0 / 3.0) + self.currentPoint.x, + y: (controlPoint.y - self.currentPoint.y) * (2.0 / 3.0) + self.currentPoint.y), + controlPoint2: NSPoint( + x: (controlPoint.x - point.x) * (2.0 / 3.0) + point.x, + y: (controlPoint.y - point.y) * (2.0 / 3.0) + point.y)) + } + + + +} +#endif enum Pointers { - static var standard: UIBezierPath { - let path = UIBezierPath() + static var standard: BezierPath { + let path = BezierPath() path.move(to: CGPoint(x: 18.78348, y: 1.134168)) path.addCurve(to: CGPoint(x: 19, y: 2.051366), controlPoint1: CGPoint(x: 18.925869, y: 1.418949), controlPoint2: CGPoint(x: 19, y: 1.732971)) @@ -65,7 +128,11 @@ enum Pointers { path.addCurve(to: CGPoint(x: 24.5, y: 6), controlPoint1: CGPoint(x: 21, y: 7.567003), controlPoint2: CGPoint(x: 22.567003, y: 6)) path.close() +#if canImport(UIKit) path.apply(.init(translationX: -24.5, y: 0)) +#elseif canImport(AppKit) + path.transform(using: AffineTransform(translationByX: -24.5, byY: 0)) +#endif return path } diff --git a/Sources/SlidingRuler/Ruler/Ruler.swift b/Sources/SlidingRuler/Ruler/Ruler.swift index ebca3aa..d8e911f 100644 --- a/Sources/SlidingRuler/Ruler/Ruler.swift +++ b/Sources/SlidingRuler/Ruler/Ruler.swift @@ -26,18 +26,17 @@ // SOFTWARE. // - import SwiftUI struct Ruler: View, Equatable { @Environment(\.slidingRulerStyle) private var style - + let cells: [RulerCell] let step: CGFloat let markOffset: CGFloat let bounds: ClosedRange let formatter: NumberFormatter? - + var body: some View { HStack(spacing: 0) { ForEach(self.cells) { cell in @@ -46,21 +45,23 @@ struct Ruler: View, Equatable { } .animation(nil) } - + private func configuration(forCell cell: RulerCell) -> SlidingRulerStyleConfiguation { return .init(mark: (cell.mark + markOffset) * step, bounds: bounds, step: step, formatter: formatter) } - + static func ==(lhs: Self, rhs: Self) -> Bool { lhs.step == rhs.step && - lhs.cells.count == rhs.cells.count && - (!StaticSlidingRulerStyleEnvironment.hasMarks || lhs.markOffset == rhs.markOffset) + lhs.cells.count == rhs.cells.count && + (lhs.markOffset == rhs.markOffset) + // causing "Accessing Environment's value outside of being installed on a View" errors when running so commenting out +// (!StaticSlidingRulerStyleEnvironment.hasMarks || lhs.markOffset == rhs.markOffset) } } struct Ruler_Previews: PreviewProvider { static var previews: some View { Ruler(cells: [.init(CGFloat(0))], - step: 1.0, markOffset: 0, bounds: -1...1, formatter: nil) + step: 1.0, markOffset: 0, bounds: -1 ... 1, formatter: nil) } } diff --git a/Sources/SlidingRuler/SlidingRuler.swift b/Sources/SlidingRuler/SlidingRuler.swift index 337325e..6dd8913 100644 --- a/Sources/SlidingRuler/SlidingRuler.swift +++ b/Sources/SlidingRuler/SlidingRuler.swift @@ -30,19 +30,19 @@ import SwiftUI import SmoothOperators -@available(iOS 13.0, *) -public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint { +public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint { + @Environment(\.slidingRulerCellOverflow) private var cellOverflow - + @Environment(\.slidingRulerStyle) private var style @Environment(\.slidingRulerStyle.cellWidth) private var cellWidth @Environment(\.slidingRulerStyle.cursorAlignment) private var verticalCursorAlignment @Environment(\.slidingRulerStyle.fractions) private var fractions @Environment(\.slidingRulerStyle.hasHalf) private var hasHalf - + @Environment(\.layoutDirection) private var layoutDirection - + /// Bound value. @Binding private var controlValue: V /// Possible value range. @@ -57,15 +57,15 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina private let editingChangedCallback: (Bool) -> () /// Number formatter for ruler's marks. private let formatter: NumberFormatter? - + /// Width of the control, retrieved through preference key. @State private var controlWidth: CGFloat? /// Height of the ruller, retrieved through preference key. @State private var rulerHeight: CGFloat? - + /// Cells of the ruler. @State private var cells: [RulerCell] = [.init(CGFloat(0))] - + /// Control state. @State private var state: SlidingRulerState = .idle /// The reference offset set at the start of a drag session. @@ -74,34 +74,52 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina @State private var dragOffset: CGSize = .zero /// Offset of the ruler's displayed marks. @State private var markOffset: CGFloat = .zero - + /// Non-bound value used for rubber release animation. @State private var animatedValue: CGFloat = .zero /// The last value the receiver did set. Used to define if the rendered value was set by the receiver or from another component. @State private var lastValueSet: CGFloat = .zero - + + /// The SwiftUI view that detects the scroll wheel movement. + #if canImport(AppKit) + var scrollView: some View { + RepresentableScrollView() + .onScroll( + action: { event in + horizontalScrollChanged(offset: CGSize(width: event.scrollingDeltaX, height: 0)) + }, + onBegan: { event in + horizontalScrollBegan() + }, + onEnded: { event in + horizontalScrollEnded(event: event) + } + ) + } + #endif + /// VSynch timer that drives animations. @State private var animationTimer: VSynchedTimer? = nil - + private var value: CGFloat { get { CGFloat(controlValue) ?? 0 } nonmutating set { controlValue = V(newValue) } } - + /// Allowed drag offset range. private var dragBounds: ClosedRange { let lower = bounds.upperBound.isInfinite ? -CGFloat.infinity : -bounds.upperBound * cellWidth / step let upper = bounds.lowerBound.isInfinite ? CGFloat.infinity : -bounds.lowerBound * cellWidth / step return .init(uncheckedBounds: (lower, upper)) } - + /// Over-ranged drag rubber should be released. private var isRubberBandNeedingRelease: Bool { !dragBounds.contains(dragOffset.width) } /// Amount of units the ruler can translate in both direction before needing to refresh the cells and reset offset. private var cellWidthOverflow: CGFloat { cellWidth * CGFloat(cellOverflow) } /// Current value clamped to the receiver's value bounds. private var clampedValue: CGFloat { value.clamped(to: bounds) } - + /// Ruler offset used to render the control depending on the state. private var effectiveOffset: CGSize { switch state { @@ -113,7 +131,7 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina return dragOffset } } - + /// Creates a SlidingRuler /// - Parameters: /// - value: A binding connected to the control value. @@ -124,12 +142,12 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina /// - onEditingChanged: A closure executed when a drag session happens. It receives a boolean value set to `true` when the drag session starts and `false` when the value stops changing. Defaults to no action. /// - formatter: A `NumberFormatter` instance the ruler uses to format the ruler's marks. Defaults to `nil`. public init(value: Binding, - in bounds: ClosedRange = -V.infinity...V.infinity, - step: V.Stride = 1, - snap: Mark = .none, - tick: Mark = .none, - onEditingChanged: @escaping (Bool) -> () = { _ in }, - formatter: NumberFormatter? = nil) { + in bounds: ClosedRange = -V.infinity...V.infinity, + step: V.Stride = 1, + snap: Mark = .none, + tick: Mark = .none, + onEditingChanged: @escaping (Bool) -> () = { _ in }, + formatter: NumberFormatter? = nil) { self._controlValue = value self.bounds = .init(uncheckedBounds: (CGFloat(bounds.lowerBound), CGFloat(bounds.upperBound))) self.step = CGFloat(step) @@ -138,14 +156,15 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina self.editingChangedCallback = onEditingChanged self.formatter = formatter } - + // MARK: Rendering + #if canImport(UIKit) public var body: some View { let renderedValue: CGFloat, renderedOffset: CGSize - + (renderedValue, renderedOffset) = renderingValues() - + return FlexibleWidthContainer { ZStack(alignment: .init(horizontal: .center, vertical: self.verticalCursorAlignment)) { Ruler(cells: self.cells, step: self.step, markOffset: self.markOffset, bounds: self.bounds, formatter: self.formatter) @@ -155,6 +174,7 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina self.style.makeCursorBody() } } + .modifier(InfiniteMarkOffsetModifier(renderedValue, step: step)) .propagateWidth(ControlWidthPreferenceKey.self) .onPreferenceChange(MarkOffsetPreferenceKey.self, storeValueIn: $markOffset) @@ -168,11 +188,41 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina prematureEnd: panGestureEndedPrematurely, perform: horizontalDragAction(withValue:)) } - + #else + public var body: some View { + let renderedValue: CGFloat, renderedOffset: CGSize + + (renderedValue, renderedOffset) = renderingValues() + + return FlexibleWidthContainer { + ZStack(alignment: .init(horizontal: .center, vertical: self.verticalCursorAlignment)) { + Ruler(cells: self.cells, step: self.step, markOffset: self.markOffset, bounds: self.bounds, formatter: self.formatter) + .equatable() + .animation(nil) + .modifier(InfiniteOffsetEffect(offset: renderedOffset, maxOffset: self.cellWidthOverflow)) + self.style.makeCursorBody() + } + } + + .modifier(InfiniteMarkOffsetModifier(renderedValue, step: step)) + .propagateWidth(ControlWidthPreferenceKey.self) + .onPreferenceChange(MarkOffsetPreferenceKey.self, storeValueIn: $markOffset) + .onPreferenceChange(ControlWidthPreferenceKey.self, storeValueIn: $controlWidth) { + self.updateCellsIfNeeded() + } + .transaction { + if $0.animation != nil { $0.animation = .easeIn(duration: 0.1) } + } + .overlay(scrollView) + } + #endif + + + private func renderingValues() -> (CGFloat, CGSize) { let value: CGFloat let offset: CGSize - + switch self.state { case .flicking, .springing: if self.value != self.lastValueSet { @@ -197,14 +247,13 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina value = clampedValue ?? 0 offset = self.offset(fromValue: value) } - return (value, offset) } } // MARK: Drag Gesture Management extension SlidingRuler { - + /// Callback handling first touch event. private func firstTouchHappened() { switch state { @@ -217,7 +266,7 @@ extension SlidingRuler { default: break } } - + /// Callback handling gesture premature ending. private func panGestureEndedPrematurely() { switch state { @@ -230,7 +279,8 @@ extension SlidingRuler { break } } - + +#if canImport(UIKit) /// Composite callback passed to the horizontal drag gesture recognizer. private func horizontalDragAction(withValue value: HorizontalDragGestureValue) { switch value.state { @@ -240,7 +290,7 @@ extension SlidingRuler { default: return } } - + /// Callback handling horizontal drag gesture begining. private func horizontalDragBegan(_ value: HorizontalDragGestureValue) { editingChangedCallback(true) @@ -250,7 +300,7 @@ extension SlidingRuler { referenceOffset = dragOffset state = .dragging } - + /// Callback handling horizontal drag gesture updating. private func horizontalDragChanged(_ value: HorizontalDragGestureValue) { let newOffset = self.directionalOffset(value.translation.horizontal + referenceOffset) @@ -263,7 +313,7 @@ extension SlidingRuler { dragOffset = self.applyRubber(to: newOffset) } } - + /// Callback handling horizontal drag gesture ending. private func horizontalDragEnded(_ value: HorizontalDragGestureValue) { if isRubberBandNeedingRelease { @@ -277,7 +327,47 @@ extension SlidingRuler { self.snapIfNeeded() } } + +#elseif canImport(AppKit) + private func horizontalScrollBegan() { + editingChangedCallback(true) + if state != .stoppedSpring { + dragOffset = self.offset(fromValue: clampedValue ?? 0) + } + referenceOffset = dragOffset + state = .dragging + } + + + private func horizontalScrollChanged(offset: CGSize) { + referenceOffset = dragOffset + let newOffset = self.directionalOffset(offset.horizontal + referenceOffset) + let newValue = self.value(fromOffset: newOffset) + + self.tickIfNeeded(dragOffset, newOffset) + + withoutAnimation { + self.setValue(newValue) + dragOffset = self.applyRubber(to: newOffset) + } + } + + private func horizontalScrollEnded(event: NSEvent) { + var velocity = event.scrollingDeltaX + if isRubberBandNeedingRelease { + self.releaseRubberBand() + self.endDragSession() + } else if abs(velocity) > 90 { + self.applyInertia(initialVelocity: velocity) + } else { + state = .idle + self.endDragSession() + self.snapIfNeeded() + } + } +#endif + /// Drag session clean-up. private func endDragSession() { referenceOffset = .zero @@ -287,18 +377,18 @@ extension SlidingRuler { // MARK: Value Management extension SlidingRuler { - + /// Compute the value from the given ruler's offset. private func value(fromOffset offset: CGSize) -> CGFloat { self.directionalValue(-CGFloat(offset.width / cellWidth) * step) } - + /// Compute the ruler's offset from the given value. private func offset(fromValue value: CGFloat) -> CGSize { let width = -value * cellWidth / step return self.directionalOffset(.init(horizontal: width)) } - + /// Sets the value. private func setValue(_ newValue: CGFloat) { let clampedValue = newValue.clamped(to: bounds) @@ -310,50 +400,50 @@ extension SlidingRuler { if lastValueSet != clampedValue { lastValueSet = clampedValue } if value != clampedValue { value = clampedValue } } - + /// Snaps the value to the nearest mark based on the `snap` property. private func snapIfNeeded() { let nearest = self.nearestSnapValue(self.value) guard nearest != value else { return } - + let delta = abs(nearest - value) let fractionalValue = step / CGFloat(fractions) - + guard delta < fractionalValue else { return } - + let animThreshold = step / 200 let animation: Animation? = delta > animThreshold ? .easeOut(duration: 0.1) : nil - + dragOffset = offset(fromValue: nearest) withAnimation(animation) { self.value = nearest } } - + /// Returns the nearest value to snap on based on the `snap` property. private func nearestSnapValue(_ value: CGFloat) -> CGFloat { guard snap != .none else { return value } - + let t: CGFloat - + switch snap { case .unit: t = step case .half: t = step / 2 case .fraction: t = step / CGFloat(fractions) default: fatalError() } - + let lower = (value / t).rounded(.down) * t let upper = (value / t).rounded(.up) * t let deltaDown = abs(value - lower).approximated() let deltaUp = abs(value - upper).approximated() - + return deltaDown < deltaUp ? lower : upper } - + /// Transforms any numerical value based the layout direction. /!\ not properly tested. func directionalValue(_ value: T) -> T { value * (layoutDirection == .rightToLeft ? -1 : 1) } - + /// Transforms an offsetr based on the layout direction. /!\ not properly tested. func directionalOffset(_ offset: CGSize) -> CGSize { let width = self.directionalValue(offset.width) @@ -363,14 +453,14 @@ extension SlidingRuler { // MARK: Control Update extension SlidingRuler { - + /// Adjusts the number of cells as the control size changes. private func updateCellsIfNeeded() { guard let controlWidth = controlWidth else { return } let count = (Int(ceil(controlWidth / cellWidth)) + cellOverflow * 2).nextOdd() if count != cells.count { self.populateCells(count: count) } } - + /// Creates `count` cells for the ruler. private func populateCells(count: Int) { let boundary = count.previousEven() / 2 @@ -378,37 +468,43 @@ extension SlidingRuler { } } +#if canImport(UIKit) extension UIScrollView.DecelerationRate { static var ruler: Self { Self.init(rawValue: 0.9972) } } +#endif // MARK: Mechanic Simulation extension SlidingRuler { - + private func applyInertia(initialVelocity: CGFloat) { func shiftOffset(by distance: CGSize) { let newOffset = directionalOffset(self.referenceOffset + distance) let newValue = self.value(fromOffset: newOffset) - + self.tickIfNeeded(self.dragOffset, newOffset) - + withoutAnimation { self.setValue(newValue) self.dragOffset = newOffset } } - + referenceOffset = dragOffset - + +#if canImport(AppKit) + let rate = 0.9972 +#else let rate = UIScrollView.DecelerationRate.ruler +#endif let totalDistance = Mechanic.Inertia.totalDistance(forVelocity: initialVelocity, decelerationRate: rate) let finalOffset = self.referenceOffset + .init(horizontal: totalDistance) - + state = .flicking - + if dragBounds.contains(finalOffset.width) { let duration = Mechanic.Inertia.duration(forVelocity: initialVelocity, decelerationRate: rate) - + animationTimer = .init(duration: duration, animations: { (progress, interval) in let distance = CGSize(horizontal: Mechanic.Inertia.distance(atTime: progress, v0: initialVelocity, decelerationRate: rate)) shiftOffset(by: distance) @@ -440,13 +536,13 @@ extension SlidingRuler { }) } } - + private func applyInertialRubber(remainingVelocity: CGFloat) { let duration = Mechanic.Spring.duration(forVelocity: abs(remainingVelocity), displacement: 0) let targetOffset = dragOffset.width.nearestBound(of: dragBounds) - + state = .springing - + animationTimer = .init(duration: duration, animations: { (progress, interval) in let delta = Mechanic.Spring.value(atTime: progress, v0: remainingVelocity, displacement: 0) self.dragOffset = .init(horizontal: targetOffset + delta) @@ -457,7 +553,7 @@ extension SlidingRuler { } }) } - + /// Applies rubber effect to an off-range offset. private func applyRubber(to offset: CGSize) -> CGSize { let dragBounds = self.dragBounds @@ -474,15 +570,15 @@ extension SlidingRuler { return .init(horizontal: rubberTx) } - + /// Animates an off-range offset back in place private func releaseRubberBand() { let targetOffset = dragOffset.width.clamped(to: dragBounds) let delta = dragOffset.width - targetOffset let duration = Mechanic.Spring.duration(forVelocity: 0, displacement: abs(delta)) - + state = .springing - + animationTimer = .init(duration: duration, animations: { (progress, interval) in let newDelta = Mechanic.Spring.value(atTime: progress, v0: 0, displacement: delta) self.dragOffset = .init(horizontal: targetOffset + newDelta) @@ -493,13 +589,13 @@ extension SlidingRuler { } }) } - + /// Stops the current animation and cleans the timer. private func cancelCurrentTimer() { animationTimer?.cancel() animationTimer = nil } - + private func cleanTimer() { animationTimer = nil } @@ -510,16 +606,18 @@ extension SlidingRuler { // MARK: Tick Management extension SlidingRuler { private func boundaryMet() { +#if canImport(UIKit) let fg = UIImpactFeedbackGenerator(style: .rigid) fg.impactOccurred(intensity: 0.667) +#endif } - + private func tickIfNeeded(_ offset0: CGSize, _ offset1: CGSize) { let width0 = offset0.width, width1 = offset1.width - + let dragBounds = self.dragBounds guard dragBounds.contains(width0), dragBounds.contains(width1), - !width0.isBound(of: dragBounds), !width1.isBound(of: dragBounds) else { return } + !width0.isBound(of: dragBounds), !width1.isBound(of: dragBounds) else { return } let t: CGFloat switch tick { @@ -537,8 +635,10 @@ extension SlidingRuler { } private func valueTick() { +#if canImport(UIKit) let fg = UIImpactFeedbackGenerator(style: .light) fg.impactOccurred(intensity: 0.5) +#endif } } diff --git a/Sources/SlidingRuler/Styling/CenteredStyle/BlankCenteredStyle.swift b/Sources/SlidingRuler/Styling/CenteredStyle/BlankCenteredStyle.swift index 95776b2..0737c93 100644 --- a/Sources/SlidingRuler/Styling/CenteredStyle/BlankCenteredStyle.swift +++ b/Sources/SlidingRuler/Styling/CenteredStyle/BlankCenteredStyle.swift @@ -1,6 +1,6 @@ // // BlankCenteredStyle.swift -// +// // SlidingRuler // // MIT License @@ -26,12 +26,11 @@ // SOFTWARE. // - import SwiftUI public struct BlankCenteredSlidingRulerStyle: SlidingRulerStyle { public let cursorAlignment: VerticalAlignment = .top - + public init() {} public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { BlankCenteredCellBody(mark: configuration.mark, bounds: configuration.bounds, diff --git a/Sources/SlidingRuler/Styling/CenteredStyle/CenteredScaleView.swift b/Sources/SlidingRuler/Styling/CenteredStyle/CenteredScaleView.swift index e94e061..9b2f3bf 100644 --- a/Sources/SlidingRuler/Styling/CenteredStyle/CenteredScaleView.swift +++ b/Sources/SlidingRuler/Styling/CenteredStyle/CenteredScaleView.swift @@ -32,7 +32,15 @@ import SwiftUI struct CenteredScaleView: ScaleView { struct ScaleShape: Shape { fileprivate var unitMarkSize: CGSize { .init(width: 3.0, height: 27.0)} +#if canImport(UIKit) fileprivate var halfMarkSize: CGSize { .init(width: UIScreen.main.scale == 3 ? 1.8 : 2.0, height: 19.0) } +#elseif canImport(AppKit) + fileprivate var halfMarkSize: CGSize { + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return CGSize(width: scale >= 3 ? 1.8 : 2.0, height: 19.0) + } +#endif + fileprivate var fractionMarkSize: CGSize { .init(width: 1.0, height: 11.0)} func path(in rect: CGRect) -> Path { diff --git a/Sources/SlidingRuler/Styling/CenteredStyle/CenteredStyle.swift b/Sources/SlidingRuler/Styling/CenteredStyle/CenteredStyle.swift index ac5c413..595423a 100644 --- a/Sources/SlidingRuler/Styling/CenteredStyle/CenteredStyle.swift +++ b/Sources/SlidingRuler/Styling/CenteredStyle/CenteredStyle.swift @@ -1,6 +1,6 @@ // // CenteredStyle.swift -// +// // SlidingRulerTestingBoard // // MIT License @@ -26,11 +26,11 @@ // SOFTWARE. // - import SwiftUI public struct CenteredSlindingRulerStyle: SlidingRulerStyle { public var cursorAlignment: VerticalAlignment = .top + public init() {} public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { CenteredCellBody(mark: configuration.mark, diff --git a/Sources/SlidingRuler/Styling/DefaultStyle/BlankStyle.swift b/Sources/SlidingRuler/Styling/DefaultStyle/BlankStyle.swift index 5d6add1..c62e11d 100644 --- a/Sources/SlidingRuler/Styling/DefaultStyle/BlankStyle.swift +++ b/Sources/SlidingRuler/Styling/DefaultStyle/BlankStyle.swift @@ -1,6 +1,6 @@ // // BlankStyle.swift -// +// // SlidingRuler // // MIT License @@ -26,11 +26,11 @@ // SOFTWARE. // - import SwiftUI public struct BlankSlidingRulerStyle: SlidingRulerStyle { public let cursorAlignment: VerticalAlignment = .top + public init() {} public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { BlankCellBody(mark: configuration.mark, diff --git a/Sources/SlidingRuler/Styling/DefaultStyle/DefaultScaleView.swift b/Sources/SlidingRuler/Styling/DefaultStyle/DefaultScaleView.swift index dc65d91..06f6f3a 100644 --- a/Sources/SlidingRuler/Styling/DefaultStyle/DefaultScaleView.swift +++ b/Sources/SlidingRuler/Styling/DefaultStyle/DefaultScaleView.swift @@ -32,7 +32,14 @@ import SwiftUI struct DefaultScaleView: ScaleView { struct ScaleShape: Shape { fileprivate var unitMarkSize: CGSize { .init(width: 3.0, height: 27.0)} +#if canImport(UIKit) fileprivate var halfMarkSize: CGSize { .init(width: UIScreen.main.scale == 3 ? 1.8 : 2.0, height: 19.0) } +#elseif canImport(AppKit) + fileprivate var halfMarkSize: CGSize { + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return CGSize(width: scale >= 3 ? 1.8 : 2.0, height: 19.0) + } +#endif fileprivate var fractionMarkSize: CGSize { .init(width: 1.0, height: 11.0)} func path(in rect: CGRect) -> Path { diff --git a/Sources/SlidingRuler/Styling/NativeCursorBody.swift b/Sources/SlidingRuler/Styling/NativeCursorBody.swift index 6fbf4b5..4d5b10e 100644 --- a/Sources/SlidingRuler/Styling/NativeCursorBody.swift +++ b/Sources/SlidingRuler/Styling/NativeCursorBody.swift @@ -30,9 +30,16 @@ import SwiftUI public struct NativeCursorBody: View { public var body: some View { +#if canImport(UIKit) Capsule() .foregroundColor(.red) .frame(width: UIScreen.main.scale == 3 ? 1.8 : 2, height: 30) +#elseif canImport(AppKit) + Capsule() + .foregroundColor(.red) + .frame(width: NSScreen.main?.backingScaleFactor == 1 ? 1.8 : 2, height: 30) +#endif + } } diff --git a/Sources/SlidingRuler/Styling/Protocols/NativeMarkedRulerCellView.swift b/Sources/SlidingRuler/Styling/Protocols/NativeMarkedRulerCellView.swift index bb92cf8..58f007e 100644 --- a/Sources/SlidingRuler/Styling/Protocols/NativeMarkedRulerCellView.swift +++ b/Sources/SlidingRuler/Styling/Protocols/NativeMarkedRulerCellView.swift @@ -31,9 +31,15 @@ import SwiftUI protocol NativeMarkedRulerCellView: MarkedRulerCellView { } extension NativeMarkedRulerCellView { +#if canImport(UIKit) var markColor: Color { bounds.contains(mark) ? .init(.label) : .init(.tertiaryLabel) } +#elseif canImport(AppKit) + var markColor: Color { + return .primary + } +#endif var displayMark: String { numberFormatter?.string(for: mark) ?? "\(mark.approximated())" } var body: some View { diff --git a/Sources/SlidingRuler/Styling/Protocols/RulerCellView.swift b/Sources/SlidingRuler/Styling/Protocols/RulerCellView.swift index 2ec9801..d76b20e 100644 --- a/Sources/SlidingRuler/Styling/Protocols/RulerCellView.swift +++ b/Sources/SlidingRuler/Styling/Protocols/RulerCellView.swift @@ -56,11 +56,11 @@ extension RulerCellView { ZStack { scale .equatable() - .foregroundColor(.init(.label)) + .foregroundStyle(.primary) .clipShape(maskShape) scale .equatable() - .foregroundColor(.init(.tertiaryLabel)) + .foregroundStyle(.tertiary) } .frame(width: cellWidth) } diff --git a/Sources/SlidingRuler/TrackpadPanGesture.swift b/Sources/SlidingRuler/TrackpadPanGesture.swift new file mode 100644 index 0000000..2f7502d --- /dev/null +++ b/Sources/SlidingRuler/TrackpadPanGesture.swift @@ -0,0 +1,200 @@ +// +// TrackpadPanGesture.swift +// SlidingRulerXcode +// +// Created by Cyril Zakka on 5/31/24. +// + +import SwiftUI + +//struct CaptureVerticalScrollWheelModifier: ViewModifier { +// func body(content: Content) -> some View { +// content +// .background(ScrollWheelHandlerView()) +// } +// +// struct ScrollWheelHandlerView: NSViewRepresentable { +// func makeNSView(context: Context) -> NSView { +// let view = ScrollWheelReceivingView() +// return view +// } +// +// func updateNSView(_ nsView: NSView, context: Context) {} +// } +// +// class ScrollWheelReceivingView: NSView { +// private var scrollVelocity: CGFloat = 0 +// private var decelerationTimer: Timer? +// +// override var acceptsFirstResponder: Bool { true } +// +// override func viewDidMoveToWindow() { +// super.viewDidMoveToWindow() +// window?.makeFirstResponder(self) +// } +// +// override func scrollWheel(with event: NSEvent) { +// var scrollDist = event.deltaX +// var scrollDelta = event.scrollingDeltaX +// +// if event.phase == .began || event.phase == .changed || event.phase.rawValue == 0 { +// // Directly handle scrolling +// handleScroll(with: scrollDist, precise: event.hasPreciseScrollingDeltas) +// +// scrollVelocity = scrollDelta +// } else if event.phase == .ended { +// // Begin decelerating +// decelerationTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] timer in +// guard let self = self else { timer.invalidate(); return } +// self.decelerateScroll() +// } +// } else if event.momentumPhase == .ended { +// // Invalidate the timer if momentum scrolling has ended +// decelerationTimer?.invalidate() +// decelerationTimer = nil +// } +// } +// +// private func handleScroll(with delta: CGFloat, precise: Bool) { +// var scrollDist = delta +// if !precise { +// scrollDist *= 2 +// } +// +// guard let scrollView = self.enclosingScrollView else { return } +// let contentView = scrollView.contentView +// let contentSize = contentView.documentRect.size +// let scrollViewSize = scrollView.bounds.size +// +// let currentPoint = contentView.bounds.origin +// var newX = currentPoint.x - scrollDist +// +// // Calculate the maximum allowable X position (right edge of content) +// let maxX = contentSize.width - scrollViewSize.width +// // Ensure newX does not exceed the bounds +// newX = max(newX, 0) // No less than 0 (left edge) +// newX = min(newX, maxX) // No more than maxX (right edge) +// +// // Scroll to the new X position if it's within the bounds +// scrollView.contentView.scroll(to: NSPoint(x: newX, y: currentPoint.y)) +// scrollView.reflectScrolledClipView(scrollView.contentView) +// } +// +// private func decelerateScroll() { +// if abs(scrollVelocity) < 0.8 { +// decelerationTimer?.invalidate() +// decelerationTimer = nil +// return +// } +// +// handleScroll(with: scrollVelocity, precise: true) +// scrollVelocity *= 0.95 +// } +// } +//} +// +//extension View { +// func captureVerticalScrollWheel() -> some View { +// self.modifier(CaptureVerticalScrollWheelModifier()) +// } +//} + +#if canImport(AppKit) +protocol ScrollViewDelegateProtocol { + /// Informs the receiver that the mouse’s scroll wheel has moved. + func scrollWheel(with event: NSEvent) + func scrollWheelDidBegin(with event: NSEvent) + func scrollWheelDidEnd(with event: NSEvent) + func scrollWheelDidCancel(with event: NSEvent) +} + +class WrappedScrollView: NSView { + + private var scrollVelocity: CGFloat = 0 + private var decelerationTimer: Timer? + + /// 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 } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.makeFirstResponder(self) + } + /// Informs the receiver that the mouse’s scroll wheel has moved. + override func scrollWheel(with event: NSEvent) { + + if event.phase == .mayBegin { + + } else if event.phase == .cancelled { + delegate.scrollWheelDidCancel(with: event) + } else if event.phase == .began { + delegate.scrollWheelDidBegin(with: event) + } else if event.phase == .changed || event.phase == .stationary { + delegate.scrollWheel(with: event) + } else if event.phase == .ended { + delegate.scrollWheelDidEnd(with: event) + } + } + + +} + +struct RepresentableScrollView: NSViewRepresentable, ScrollViewDelegateProtocol { + /// The AppKit view our SwiftUI view manages. + typealias NSViewType = WrappedScrollView + + /// What the SwiftUI content wants us to do when the mouse's scroll wheel is moved. + private var scrollAction: ((NSEvent) -> Void)? + private var scrollActionBegan: ((NSEvent) -> Void)? + private var scrollActionEnded: ((NSEvent) -> Void)? + private var scrollActionCancelled: ((NSEvent) -> Void)? + + /// Creates the view object and configures its initial state. + func makeNSView(context: Context) -> WrappedScrollView { + // Make a scroll view and become its delegate + let view = WrappedScrollView() + 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) { + if let scrollAction = scrollAction { + scrollAction(event) + } + } + + func scrollWheelDidBegin(with event: NSEvent) { + if let scrollAction = scrollActionBegan { + scrollAction(event) + } + } + + func scrollWheelDidCancel(with event: NSEvent) { + if let scrollAction = scrollActionCancelled { + scrollAction(event) + } + } + + func scrollWheelDidEnd(with event: NSEvent) { + if let scrollAction = scrollActionEnded { + scrollAction(event) + } + } + + /// Modifier that allows the content view to set an action in its context. + func onScroll(action: @escaping (NSEvent) -> Void, onBegan: @escaping (NSEvent) -> Void = { _ in }, onEnded: @escaping (NSEvent) -> Void = { _ in }, onCancelled: @escaping (NSEvent) -> Void = { _ in }) -> Self { + var newSelf = self + newSelf.scrollAction = action + newSelf.scrollActionBegan = onBegan + newSelf.scrollActionEnded = onEnded + newSelf.scrollActionCancelled = onCancelled + return newSelf + } +} +#endif diff --git a/Sources/SlidingRuler/VSynchedTimer.swift b/Sources/SlidingRuler/VSynchedTimer.swift index 5a20f2b..de39ce5 100644 --- a/Sources/SlidingRuler/VSynchedTimer.swift +++ b/Sources/SlidingRuler/VSynchedTimer.swift @@ -26,19 +26,26 @@ // SOFTWARE. // +#if canImport(UIKit) import UIKit +#elseif canImport(AppKit) +import AppKit +import CoreVideo +#endif struct VSynchedTimer { typealias Animations = (TimeInterval, TimeInterval) -> () typealias Completion = (Bool) -> () - + private let timer: SynchedTimer init(duration: TimeInterval, animations: @escaping Animations, completion: Completion? = nil) { self.timer = .init(duration, animations, completion) } - - func cancel() { timer.cancel() } + + func cancel() { + timer.cancel() + } } @@ -46,14 +53,18 @@ private final class SynchedTimer { private let duration: TimeInterval private let animationBlock: VSynchedTimer.Animations private let completionBlock: VSynchedTimer.Completion? +#if canImport(UIKit) private weak var displayLink: CADisplayLink? +#elseif canImport(AppKit) + private var displayLink: CVDisplayLink? +#endif private var isRunning: Bool private let startTimeStamp: TimeInterval private var lastTimeStamp: TimeInterval deinit { - self.displayLink?.invalidate() + cancel() } init(_ duration: TimeInterval, _ animations: @escaping VSynchedTimer.Animations, _ completion: VSynchedTimer.Completion? = nil) { @@ -69,28 +80,36 @@ private final class SynchedTimer { func cancel() { guard isRunning else { return } - + isRunning.toggle() +#if canImport(UIKit) displayLink?.invalidate() +#elseif canImport(AppKit) + CVDisplayLinkStop(displayLink!) +#endif self.completionBlock?(false) } - + private func complete() { guard isRunning else { return } - + isRunning.toggle() +#if canImport(UIKit) displayLink?.invalidate() +#elseif canImport(AppKit) + CVDisplayLinkStop(displayLink!) +#endif self.completionBlock?(true) } @objc private func displayLinkTick(_ displayLink: CADisplayLink) { guard isRunning else { return } - + let currentTimeStamp = CACurrentMediaTime() let progress = currentTimeStamp - startTimeStamp let elapsed = currentTimeStamp - lastTimeStamp lastTimeStamp = currentTimeStamp - + if progress < duration { animationBlock(progress, elapsed) } else { @@ -98,10 +117,44 @@ private final class SynchedTimer { } } +#if canImport(UIKit) private func createDisplayLink() -> CADisplayLink { let dl = CADisplayLink(target: self, selector: #selector(displayLinkTick(_:))) dl.add(to: .main, forMode: .common) return dl } +#elseif canImport(AppKit) + private func createDisplayLink() -> CVDisplayLink? { + var cvDisplayLink: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&cvDisplayLink) + guard let displayLink = cvDisplayLink else { return nil } + + CVDisplayLinkSetOutputCallback(displayLink, { (displayLink, inNow, inOutputTime, flagsIn, flagsOut, userInfo) -> CVReturn in + guard let context = userInfo else { return kCVReturnError } + let synchedTimer = Unmanaged.fromOpaque(context).takeUnretainedValue() + synchedTimer.displayLinkTick() + return kCVReturnSuccess + }, Unmanaged.passUnretained(self).toOpaque()) + + CVDisplayLinkStart(displayLink) + return displayLink + } + + @objc private func displayLinkTick() { + guard isRunning else { return } + + let currentTimeStamp = CACurrentMediaTime() + let progress = currentTimeStamp - startTimeStamp + let elapsed = currentTimeStamp - lastTimeStamp + lastTimeStamp = currentTimeStamp + + if progress < duration { + animationBlock(progress, elapsed) + } else { + complete() + } + } +#endif + }