From 2ce087ae6ee82027722219931644876ad83afdd8 Mon Sep 17 00:00:00 2001 From: Weixuan Fu Date: Fri, 31 Mar 2023 02:30:01 -0700 Subject: [PATCH 1/9] bug fixings: support custom style, fix runtime error --- Sources/SlidingRuler/Ruler/Ruler.swift | 17 +++++++++-------- .../CenteredStyle/BlankCenteredStyle.swift | 5 ++--- .../Styling/CenteredStyle/CenteredStyle.swift | 4 ++-- .../Styling/DefaultStyle/BlankStyle.swift | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) 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/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/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, From a9d47407eea0c4a0834671bd47111d730043d3fc Mon Sep 17 00:00:00 2001 From: Cyril Zakka <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 00:15:01 -0700 Subject: [PATCH 2/9] AppKit updates --- .../SlidingRuler/HorizontalPanGesture.swift | 106 +++++++++++++++++- Sources/SlidingRuler/Mechanic.swift | 57 ++++++++-- Sources/SlidingRuler/Pointers.swift | 73 +++++++++++- Sources/SlidingRuler/SlidingRuler.swift | 15 ++- .../CenteredStyle/CenteredScaleView.swift | 8 ++ .../DefaultStyle/DefaultScaleView.swift | 7 ++ .../Styling/NativeCursorBody.swift | 7 ++ .../Protocols/NativeMarkedRulerCellView.swift | 6 + .../Styling/Protocols/RulerCellView.swift | 4 +- Sources/SlidingRuler/VSynchedTimer.swift | 71 ++++++++++-- 10 files changed, 329 insertions(+), 25 deletions(-) diff --git a/Sources/SlidingRuler/HorizontalPanGesture.swift b/Sources/SlidingRuler/HorizontalPanGesture.swift index 56260b5..16d460b 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,106 @@ extension HorizontalPanGesture.Coordinator: UIPointerInteractionDelegate { .init(shape: .path(Pointers.standard), constrainedAxes: .vertical) } } + + +#elseif canImport(AppKit) +struct HorizontalDragGestureValue { + let state: NSGestureRecognizer.State + let translation: CGSize + let velocity: CGFloat + let startLocation: CGPoint + let location: CGPoint +} + +protocol HorizontalPanGestureReceiverViewDelegate: AnyObject { + func viewTouchedWithoutPan(_ view: NSView) +} + +class HorizontalPanGestureReceiverView: NSView { + weak var delegate: HorizontalPanGestureReceiverViewDelegate? + + override func touchesEnded(with event: NSEvent) { + super.touchesEnded(with: event) + delegate?.viewTouchedWithoutPan(self) + } +} + +extension View { + func onHorizontalDragGesture(initialTouch: @escaping () -> () = { }, + prematureEnd: @escaping () -> () = { }, + perform action: @escaping (HorizontalDragGestureValue) -> ()) -> some View { + self.overlay(HorizontalPanGesture(beginTouch: initialTouch, prematureEnd: prematureEnd, action: action)) + } +} + +private struct HorizontalPanGesture: NSViewRepresentable { + typealias Action = (HorizontalDragGestureValue) -> () + + class Coordinator: NSObject, NSGestureRecognizerDelegate, HorizontalPanGestureReceiverViewDelegate { + private let beginTouch: () -> () + private let prematureEnd: () -> () + private let action: Action + weak var view: NSView? + + init(_ beginTouch: @escaping () -> () = { }, _ prematureEnd: @escaping () -> () = { }, _ action: @escaping Action) { + self.beginTouch = beginTouch + self.prematureEnd = prematureEnd + self.action = action + } + + @objc func panGestureHandler(_ gesture: NSPanGestureRecognizer) { + print("PanGesture handler called") + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + let location = gesture.location(in: view) + let startLocation = location - translation + + let value = HorizontalDragGestureValue(state: gesture.state, + translation: .init(horizontal: translation.x), + velocity: velocity.x, + startLocation: startLocation, + location: location) + self.action(value) + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: NSGestureRecognizer) -> Bool { + guard let pgr = gestureRecognizer as? NSPanGestureRecognizer else { return false } + let velocity = pgr.velocity(in: view) + return abs(velocity.x) > abs(velocity.y) + } + + func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer, shouldReceive touch: NSTouch) -> Bool { + beginTouch() + return true + } + + func viewTouchedWithoutPan(_ view: NSView) { + prematureEnd() + } + } + + @Environment(\.slidingRulerStyle) private var style + + let beginTouch: () -> () + let prematureEnd: () -> () + let action: Action + + func makeCoordinator() -> Coordinator { + .init(beginTouch, prematureEnd, action) + } + + func makeNSView(context: Context) -> some NSView { + let view = HorizontalPanGestureReceiverView(frame: .init(size: .init(square: 42))) + let pgr = NSPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.panGestureHandler(_:))) + view.delegate = context.coordinator + pgr.delegate = context.coordinator + view.addGestureRecognizer(pgr) + context.coordinator.view = view + + return view + } + + func updateNSView(_ nsView: NSViewType, context: Context) { } +} + +#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/SlidingRuler.swift b/Sources/SlidingRuler/SlidingRuler.swift index 337325e..ef3c346 100644 --- a/Sources/SlidingRuler/SlidingRuler.swift +++ b/Sources/SlidingRuler/SlidingRuler.swift @@ -30,7 +30,7 @@ import SwiftUI import SmoothOperators -@available(iOS 13.0, *) + public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint { @Environment(\.slidingRulerCellOverflow) private var cellOverflow @@ -155,6 +155,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) @@ -197,7 +198,6 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina value = clampedValue ?? 0 offset = self.offset(fromValue: value) } - return (value, offset) } } @@ -233,6 +233,7 @@ extension SlidingRuler { /// Composite callback passed to the horizontal drag gesture recognizer. private func horizontalDragAction(withValue value: HorizontalDragGestureValue) { + print(value) switch value.state { case .began: horizontalDragBegan(value) case .changed: horizontalDragChanged(value) @@ -378,9 +379,11 @@ extension SlidingRuler { } } +#if canImport(UIKit) extension UIScrollView.DecelerationRate { static var ruler: Self { Self.init(rawValue: 0.9972) } } +#endif // MARK: Mechanic Simulation extension SlidingRuler { @@ -400,7 +403,11 @@ extension SlidingRuler { 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) @@ -510,8 +517,10 @@ 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) { @@ -537,8 +546,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/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/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/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 + } From 0c48937aac724f0c14ae0b191c3d367273052585 Mon Sep 17 00:00:00 2001 From: Cyril Zakka <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 14:31:35 -0700 Subject: [PATCH 3/9] macOS scroll now works but ugly --- .../SlidingRuler/HorizontalPanGesture.swift | 201 ++++++++-------- Sources/SlidingRuler/SlidingRuler.swift | 214 +++++++++++------- Sources/SlidingRuler/TrackpadPanGesture.swift | 98 ++++++++ 3 files changed, 337 insertions(+), 176 deletions(-) create mode 100644 Sources/SlidingRuler/TrackpadPanGesture.swift diff --git a/Sources/SlidingRuler/HorizontalPanGesture.swift b/Sources/SlidingRuler/HorizontalPanGesture.swift index 16d460b..61d0a68 100644 --- a/Sources/SlidingRuler/HorizontalPanGesture.swift +++ b/Sources/SlidingRuler/HorizontalPanGesture.swift @@ -143,104 +143,107 @@ extension HorizontalPanGesture.Coordinator: UIPointerInteractionDelegate { } -#elseif canImport(AppKit) -struct HorizontalDragGestureValue { - let state: NSGestureRecognizer.State - let translation: CGSize - let velocity: CGFloat - let startLocation: CGPoint - let location: CGPoint -} - -protocol HorizontalPanGestureReceiverViewDelegate: AnyObject { - func viewTouchedWithoutPan(_ view: NSView) -} - -class HorizontalPanGestureReceiverView: NSView { - weak var delegate: HorizontalPanGestureReceiverViewDelegate? - - override func touchesEnded(with event: NSEvent) { - super.touchesEnded(with: event) - delegate?.viewTouchedWithoutPan(self) - } -} - -extension View { - func onHorizontalDragGesture(initialTouch: @escaping () -> () = { }, - prematureEnd: @escaping () -> () = { }, - perform action: @escaping (HorizontalDragGestureValue) -> ()) -> some View { - self.overlay(HorizontalPanGesture(beginTouch: initialTouch, prematureEnd: prematureEnd, action: action)) - } -} - -private struct HorizontalPanGesture: NSViewRepresentable { - typealias Action = (HorizontalDragGestureValue) -> () - - class Coordinator: NSObject, NSGestureRecognizerDelegate, HorizontalPanGestureReceiverViewDelegate { - private let beginTouch: () -> () - private let prematureEnd: () -> () - private let action: Action - weak var view: NSView? - - init(_ beginTouch: @escaping () -> () = { }, _ prematureEnd: @escaping () -> () = { }, _ action: @escaping Action) { - self.beginTouch = beginTouch - self.prematureEnd = prematureEnd - self.action = action - } - - @objc func panGestureHandler(_ gesture: NSPanGestureRecognizer) { - print("PanGesture handler called") - let translation = gesture.translation(in: view) - let velocity = gesture.velocity(in: view) - let location = gesture.location(in: view) - let startLocation = location - translation - - let value = HorizontalDragGestureValue(state: gesture.state, - translation: .init(horizontal: translation.x), - velocity: velocity.x, - startLocation: startLocation, - location: location) - self.action(value) - } - - func gestureRecognizerShouldBegin(_ gestureRecognizer: NSGestureRecognizer) -> Bool { - guard let pgr = gestureRecognizer as? NSPanGestureRecognizer else { return false } - let velocity = pgr.velocity(in: view) - return abs(velocity.x) > abs(velocity.y) - } - - func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer, shouldReceive touch: NSTouch) -> Bool { - beginTouch() - return true - } - - func viewTouchedWithoutPan(_ view: NSView) { - prematureEnd() - } - } - - @Environment(\.slidingRulerStyle) private var style - - let beginTouch: () -> () - let prematureEnd: () -> () - let action: Action - - func makeCoordinator() -> Coordinator { - .init(beginTouch, prematureEnd, action) - } - - func makeNSView(context: Context) -> some NSView { - let view = HorizontalPanGestureReceiverView(frame: .init(size: .init(square: 42))) - let pgr = NSPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.panGestureHandler(_:))) - view.delegate = context.coordinator - pgr.delegate = context.coordinator - view.addGestureRecognizer(pgr) - context.coordinator.view = view - - return view - } - - func updateNSView(_ nsView: NSViewType, context: Context) { } -} +//#elseif canImport(AppKit) +//struct HorizontalDragGestureValue { +// let state: NSGestureRecognizer.State +// let translation: CGSize +// let velocity: CGFloat +// let startLocation: CGPoint +// let location: CGPoint +//} +// +//protocol HorizontalPanGestureReceiverViewDelegate: AnyObject { +// func viewTouchedWithoutPan(_ view: NSView) +//} +// +//class HorizontalPanGestureReceiverView: NSView { +// weak var delegate: HorizontalPanGestureReceiverViewDelegate? +// +// override func mouseUp(with event: NSEvent) { +// super.mouseUp(with: event) +// print("Mouse up") +// delegate?.viewTouchedWithoutPan(self) +// } +//} +// +//extension View { +// func onHorizontalDragGesture(initialTouch: @escaping () -> () = { }, +// prematureEnd: @escaping () -> () = { }, +// perform action: @escaping (HorizontalDragGestureValue) -> ()) -> some View { +// self.overlay(HorizontalPanGesture(beginTouch: initialTouch, prematureEnd: prematureEnd, action: action)) +// } +//} +// +//private struct HorizontalPanGesture: NSViewRepresentable { +// typealias Action = (HorizontalDragGestureValue) -> () +// +// class Coordinator: NSObject, NSGestureRecognizerDelegate, HorizontalPanGestureReceiverViewDelegate { +// private let beginTouch: () -> () +// private let prematureEnd: () -> () +// private let action: Action +// weak var view: NSView? +// +// init(_ beginTouch: @escaping () -> () = { }, _ prematureEnd: @escaping () -> () = { }, _ action: @escaping Action) { +// self.beginTouch = beginTouch +// self.prematureEnd = prematureEnd +// self.action = action +// } +// +// @objc func panGestureHandler(_ gesture: NSPanGestureRecognizer) { +// let translation = gesture.translation(in: view) +// let velocity = gesture.velocity(in: view) +// let location = gesture.location(in: view) +// let startLocation = location - translation +// +// let value = HorizontalDragGestureValue(state: gesture.state, +// translation: .init(horizontal: translation.x), +// velocity: velocity.x, +// startLocation: startLocation, +// location: location) +// self.action(value) +// } +// +// func gestureRecognizerShouldBegin(_ gestureRecognizer: NSGestureRecognizer) -> Bool { +// guard let pgr = gestureRecognizer as? NSPanGestureRecognizer else { return false } +// let velocity = pgr.velocity(in: view) +// let translation = pgr.translation(in: view) +// print(translation, velocity) // Why is this always 0? +// return abs(velocity.x) >= abs(velocity.y) +// } +// +// func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer, shouldReceive touch: NSTouch) -> Bool { +// beginTouch() +// return true +// } +// +// func viewTouchedWithoutPan(_ view: NSView) { +// prematureEnd() +// } +// } +// +// @Environment(\.slidingRulerStyle) private var style +// +// let beginTouch: () -> () +// let prematureEnd: () -> () +// let action: Action +// +// func makeCoordinator() -> Coordinator { +// .init(beginTouch, prematureEnd, action) +// } +// +// func makeNSView(context: Context) -> NSView { +// let view = HorizontalPanGestureReceiverView(frame: .init(size: .init(square: 42))) +// let pgr = NSPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.panGestureHandler(_:))) +// view.delegate = context.coordinator +// view.wantsLayer = true +// view.layer?.backgroundColor = CGColor(red: 0.3, green: 0.6, blue: 0.7, alpha: 0.5) +// pgr.delegate = context.coordinator +// view.addGestureRecognizer(pgr) +// context.coordinator.view = view +// return view +// } +// +// func updateNSView(_ nsView: NSView, context: Context) { } +//} #endif diff --git a/Sources/SlidingRuler/SlidingRuler.swift b/Sources/SlidingRuler/SlidingRuler.swift index ef3c346..64fa4a9 100644 --- a/Sources/SlidingRuler/SlidingRuler.swift +++ b/Sources/SlidingRuler/SlidingRuler.swift @@ -32,17 +32,17 @@ import SmoothOperators 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. + 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() + } + ) + } + /// 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,24 +156,24 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina self.editingChangedCallback = onEditingChanged self.formatter = formatter } - + // MARK: Rendering 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) +// .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) @@ -165,15 +183,16 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina .transaction { if $0.animation != nil { $0.animation = .easeIn(duration: 0.1) } } - .onHorizontalDragGesture(initialTouch: firstTouchHappened, - prematureEnd: panGestureEndedPrematurely, - perform: horizontalDragAction(withValue:)) + .overlay(scrollView) + // .onHorizontalDragGesture(initialTouch: firstTouchHappened, + // prematureEnd: panGestureEndedPrematurely, + // perform: horizontalDragAction(withValue:)) } - + private func renderingValues() -> (CGFloat, CGSize) { let value: CGFloat let offset: CGSize - + switch self.state { case .flicking, .springing: if self.value != self.lastValueSet { @@ -204,7 +223,7 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina // MARK: Drag Gesture Management extension SlidingRuler { - + /// Callback handling first touch event. private func firstTouchHappened() { switch state { @@ -217,7 +236,7 @@ extension SlidingRuler { default: break } } - + /// Callback handling gesture premature ending. private func panGestureEndedPrematurely() { switch state { @@ -230,10 +249,10 @@ extension SlidingRuler { break } } - + +#if canImport(UIKit) /// Composite callback passed to the horizontal drag gesture recognizer. private func horizontalDragAction(withValue value: HorizontalDragGestureValue) { - print(value) switch value.state { case .began: horizontalDragBegan(value) case .changed: horizontalDragChanged(value) @@ -241,7 +260,7 @@ extension SlidingRuler { default: return } } - + /// Callback handling horizontal drag gesture begining. private func horizontalDragBegan(_ value: HorizontalDragGestureValue) { editingChangedCallback(true) @@ -251,7 +270,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) @@ -264,7 +283,7 @@ extension SlidingRuler { dragOffset = self.applyRubber(to: newOffset) } } - + /// Callback handling horizontal drag gesture ending. private func horizontalDragEnded(_ value: HorizontalDragGestureValue) { if isRubberBandNeedingRelease { @@ -278,7 +297,48 @@ 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) + print(newValue) + + self.tickIfNeeded(dragOffset, newOffset) + + withoutAnimation { + self.setValue(newValue) + dragOffset = self.applyRubber(to: newOffset) + } + } + + private func horizontalScrollEnded() { + var velocity = 70.0 // TODO: Calculate somehow + 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 @@ -288,18 +348,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) @@ -311,50 +371,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) @@ -364,14 +424,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 @@ -387,35 +447,35 @@ extension UIScrollView.DecelerationRate { // 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) + +#if canImport(AppKit) let rate = 0.9972 - #else +#else let rate = UIScrollView.DecelerationRate.ruler - #endif +#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) @@ -447,13 +507,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) @@ -464,7 +524,7 @@ extension SlidingRuler { } }) } - + /// Applies rubber effect to an off-range offset. private func applyRubber(to offset: CGSize) -> CGSize { let dragBounds = self.dragBounds @@ -481,15 +541,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) @@ -500,13 +560,13 @@ extension SlidingRuler { } }) } - + /// Stops the current animation and cleans the timer. private func cancelCurrentTimer() { animationTimer?.cancel() animationTimer = nil } - + private func cleanTimer() { animationTimer = nil } @@ -522,13 +582,13 @@ extension SlidingRuler { 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 { diff --git a/Sources/SlidingRuler/TrackpadPanGesture.swift b/Sources/SlidingRuler/TrackpadPanGesture.swift new file mode 100644 index 0000000..07097a6 --- /dev/null +++ b/Sources/SlidingRuler/TrackpadPanGesture.swift @@ -0,0 +1,98 @@ +// +// TrackpadPanGesture.swift +// SlidingRulerXcode +// +// Created by Cyril Zakka on 5/31/24. +// + +import SwiftUI + +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 { + /// 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 } + /// 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 + } +} From 5c13a0f064fea84636e293a735de6817e0541adb Mon Sep 17 00:00:00 2001 From: Cyril Zakka <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 14:50:11 -0700 Subject: [PATCH 4/9] Scrolling now smoother. Still no inertia. --- .../SlidingRuler/HorizontalPanGesture.swift | 104 ------------------ Sources/SlidingRuler/SlidingRuler.swift | 49 +++++++-- Sources/SlidingRuler/TrackpadPanGesture.swift | 102 +++++++++++++++++ 3 files changed, 141 insertions(+), 114 deletions(-) diff --git a/Sources/SlidingRuler/HorizontalPanGesture.swift b/Sources/SlidingRuler/HorizontalPanGesture.swift index 61d0a68..d20888a 100644 --- a/Sources/SlidingRuler/HorizontalPanGesture.swift +++ b/Sources/SlidingRuler/HorizontalPanGesture.swift @@ -142,108 +142,4 @@ extension HorizontalPanGesture.Coordinator: UIPointerInteractionDelegate { } } - -//#elseif canImport(AppKit) -//struct HorizontalDragGestureValue { -// let state: NSGestureRecognizer.State -// let translation: CGSize -// let velocity: CGFloat -// let startLocation: CGPoint -// let location: CGPoint -//} -// -//protocol HorizontalPanGestureReceiverViewDelegate: AnyObject { -// func viewTouchedWithoutPan(_ view: NSView) -//} -// -//class HorizontalPanGestureReceiverView: NSView { -// weak var delegate: HorizontalPanGestureReceiverViewDelegate? -// -// override func mouseUp(with event: NSEvent) { -// super.mouseUp(with: event) -// print("Mouse up") -// delegate?.viewTouchedWithoutPan(self) -// } -//} -// -//extension View { -// func onHorizontalDragGesture(initialTouch: @escaping () -> () = { }, -// prematureEnd: @escaping () -> () = { }, -// perform action: @escaping (HorizontalDragGestureValue) -> ()) -> some View { -// self.overlay(HorizontalPanGesture(beginTouch: initialTouch, prematureEnd: prematureEnd, action: action)) -// } -//} -// -//private struct HorizontalPanGesture: NSViewRepresentable { -// typealias Action = (HorizontalDragGestureValue) -> () -// -// class Coordinator: NSObject, NSGestureRecognizerDelegate, HorizontalPanGestureReceiverViewDelegate { -// private let beginTouch: () -> () -// private let prematureEnd: () -> () -// private let action: Action -// weak var view: NSView? -// -// init(_ beginTouch: @escaping () -> () = { }, _ prematureEnd: @escaping () -> () = { }, _ action: @escaping Action) { -// self.beginTouch = beginTouch -// self.prematureEnd = prematureEnd -// self.action = action -// } -// -// @objc func panGestureHandler(_ gesture: NSPanGestureRecognizer) { -// let translation = gesture.translation(in: view) -// let velocity = gesture.velocity(in: view) -// let location = gesture.location(in: view) -// let startLocation = location - translation -// -// let value = HorizontalDragGestureValue(state: gesture.state, -// translation: .init(horizontal: translation.x), -// velocity: velocity.x, -// startLocation: startLocation, -// location: location) -// self.action(value) -// } -// -// func gestureRecognizerShouldBegin(_ gestureRecognizer: NSGestureRecognizer) -> Bool { -// guard let pgr = gestureRecognizer as? NSPanGestureRecognizer else { return false } -// let velocity = pgr.velocity(in: view) -// let translation = pgr.translation(in: view) -// print(translation, velocity) // Why is this always 0? -// return abs(velocity.x) >= abs(velocity.y) -// } -// -// func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer, shouldReceive touch: NSTouch) -> Bool { -// beginTouch() -// return true -// } -// -// func viewTouchedWithoutPan(_ view: NSView) { -// prematureEnd() -// } -// } -// -// @Environment(\.slidingRulerStyle) private var style -// -// let beginTouch: () -> () -// let prematureEnd: () -> () -// let action: Action -// -// func makeCoordinator() -> Coordinator { -// .init(beginTouch, prematureEnd, action) -// } -// -// func makeNSView(context: Context) -> NSView { -// let view = HorizontalPanGestureReceiverView(frame: .init(size: .init(square: 42))) -// let pgr = NSPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.panGestureHandler(_:))) -// view.delegate = context.coordinator -// view.wantsLayer = true -// view.layer?.backgroundColor = CGColor(red: 0.3, green: 0.6, blue: 0.7, alpha: 0.5) -// pgr.delegate = context.coordinator -// view.addGestureRecognizer(pgr) -// context.coordinator.view = view -// return view -// } -// -// func updateNSView(_ nsView: NSView, context: Context) { } -//} - #endif diff --git a/Sources/SlidingRuler/SlidingRuler.swift b/Sources/SlidingRuler/SlidingRuler.swift index 64fa4a9..6dd8913 100644 --- a/Sources/SlidingRuler/SlidingRuler.swift +++ b/Sources/SlidingRuler/SlidingRuler.swift @@ -81,6 +81,7 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina @State private var lastValueSet: CGFloat = .zero /// The SwiftUI view that detects the scroll wheel movement. + #if canImport(AppKit) var scrollView: some View { RepresentableScrollView() .onScroll( @@ -88,15 +89,14 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina horizontalScrollChanged(offset: CGSize(width: event.scrollingDeltaX, height: 0)) }, onBegan: { event in - horizontalScrollBegan() }, onEnded: { event in - - horizontalScrollEnded() + horizontalScrollEnded(event: event) } ) } + #endif /// VSynch timer that drives animations. @State private var animationTimer: VSynchedTimer? = nil @@ -159,6 +159,7 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina // MARK: Rendering + #if canImport(UIKit) public var body: some View { let renderedValue: CGFloat, renderedOffset: CGSize @@ -168,7 +169,36 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina 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) + .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) } + } + .onHorizontalDragGesture(initialTouch: firstTouchHappened, + 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() } @@ -184,10 +214,10 @@ public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: Bina if $0.animation != nil { $0.animation = .easeIn(duration: 0.1) } } .overlay(scrollView) - // .onHorizontalDragGesture(initialTouch: firstTouchHappened, - // prematureEnd: panGestureEndedPrematurely, - // perform: horizontalDragAction(withValue:)) } + #endif + + private func renderingValues() -> (CGFloat, CGSize) { let value: CGFloat @@ -313,7 +343,6 @@ extension SlidingRuler { referenceOffset = dragOffset let newOffset = self.directionalOffset(offset.horizontal + referenceOffset) let newValue = self.value(fromOffset: newOffset) - print(newValue) self.tickIfNeeded(dragOffset, newOffset) @@ -324,8 +353,8 @@ extension SlidingRuler { } - private func horizontalScrollEnded() { - var velocity = 70.0 // TODO: Calculate somehow + private func horizontalScrollEnded(event: NSEvent) { + var velocity = event.scrollingDeltaX if isRubberBandNeedingRelease { self.releaseRubberBand() self.endDragSession() diff --git a/Sources/SlidingRuler/TrackpadPanGesture.swift b/Sources/SlidingRuler/TrackpadPanGesture.swift index 07097a6..2f7502d 100644 --- a/Sources/SlidingRuler/TrackpadPanGesture.swift +++ b/Sources/SlidingRuler/TrackpadPanGesture.swift @@ -7,6 +7,99 @@ 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) @@ -16,10 +109,18 @@ protocol ScrollViewDelegateProtocol { } 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) { @@ -96,3 +197,4 @@ struct RepresentableScrollView: NSViewRepresentable, ScrollViewDelegateProtocol return newSelf } } +#endif From d2359ea7d432ca5bf4d34244bef0b59adf353f4b Mon Sep 17 00:00:00 2001 From: "Cyril Zakka, MD" <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 15:01:53 -0700 Subject: [PATCH 5/9] Update Package.swift --- Package.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 09715a9..0a7dfdd 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,9 @@ import PackageDescription let package = Package( name: "SlidingRuler", - platforms: [.iOS(.v13)], + platforms: [.iOS(.v13), + .macOS(.v10_15), + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( From 6a32b7fcea4cfe41bab905c6369669fb97ed3f37 Mon Sep 17 00:00:00 2001 From: "Cyril Zakka, MD" <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 15:04:43 -0700 Subject: [PATCH 6/9] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 0a7dfdd..15c3da1 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "SlidingRuler", platforms: [.iOS(.v13), - .macOS(.v10_15), + .macOS(.v14_0), ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. From 830133d455558bb9d8339ac78be6e31fcb0ab41a Mon Sep 17 00:00:00 2001 From: "Cyril Zakka, MD" <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 15:06:20 -0700 Subject: [PATCH 7/9] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 15c3da1..fa813f2 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "SlidingRuler", platforms: [.iOS(.v13), - .macOS(.v14_0), + .macOS(.v14), ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. From f427c4e1547aee6601231170380fd4e3a398ef7b Mon Sep 17 00:00:00 2001 From: "Cyril Zakka, MD" <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 15:08:35 -0700 Subject: [PATCH 8/9] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index fa813f2..13da4b4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// 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 From a57705b186e3f2627fbecee74fd3e7cf28f0db8b Mon Sep 17 00:00:00 2001 From: "Cyril Zakka, MD" <1841186+cyrilzakka@users.noreply.github.com> Date: Fri, 31 May 2024 15:30:39 -0700 Subject: [PATCH 9/9] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 13da4b4..156f782 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "SlidingRuler", - platforms: [.iOS(.v13), + platforms: [.iOS(.v15), .macOS(.v14), ], products: [