Skip to content

Commit cf85e13

Browse files
authored
Merge pull request #3 from MacPaw/feat/SpringMotionPanel-feature
feat: add SpringMotionPanel
2 parents 6e68170 + 1a1da04 commit cf85e13

File tree

1 file changed

+130
-0
lines changed

1 file changed

+130
-0
lines changed

Sources/SpringMotionPanel.swift

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//
2+
// SpringMotionPanel.swift
3+
//
4+
//
5+
// Created by Oryna Semenets on 17.10.2024.
6+
//
7+
8+
#if canImport(AppKit)
9+
10+
import AppKit
11+
12+
open class SpringMotionPanel: NSPanel {
13+
14+
/// Configuration that adjusts the spring physics behind the animation.
15+
public var configuration: SpringConfiguration = .default {
16+
didSet {
17+
motionPhysics = .init(configuration: configuration, timeStep: 0.008)
18+
}
19+
}
20+
21+
// MARK: Private
22+
private var destinationPoint: NSPoint? {
23+
didSet {
24+
startMotion()
25+
}
26+
}
27+
28+
private lazy var motionPhysics = SpringMotionPhysics(configuration: configuration, timeStep: 0.008)
29+
private var currentMotionState: SpringMotionState?
30+
private var displayLink: CVDisplayLink?
31+
private var anchorWindowFrameObservation: NSKeyValueObservation?
32+
}
33+
34+
// MARK: - API
35+
public extension SpringMotionPanel {
36+
37+
/// Moves the window to the specified point with spring animation.
38+
///
39+
/// - Parameter point: a `CGPoint` that defines the window's destination and represents the window's center point.
40+
func move(to point: NSPoint) {
41+
destinationPoint = point
42+
}
43+
44+
/// Starts following the given window with a certain offset from its center.
45+
///
46+
/// - Parameter anchorWindow: an `NSWindow` that this window will follow.
47+
/// - Parameter offsetFromCenter: the difference between this window's center and the anchor window's center.
48+
func pinToWindow(_ anchorWindow: NSWindow, offsetFromCenter: NSPoint) {
49+
unpinFromWindow()
50+
move(to: anchorWindow.frame.center + offsetFromCenter)
51+
anchorWindowFrameObservation = anchorWindow.observe(\.frame, changeHandler: { [weak self] _, _ in
52+
self?.move(to: anchorWindow.frame.center + offsetFromCenter)
53+
})
54+
}
55+
56+
/// Stops following a previously followed window, if any.
57+
func unpinFromWindow() {
58+
anchorWindowFrameObservation?.invalidate()
59+
anchorWindowFrameObservation = nil
60+
}
61+
}
62+
63+
// MARK: - Mouse events
64+
public extension SpringMotionPanel {
65+
66+
override func mouseDown(with event: NSEvent) {
67+
super.mouseDown(with: event)
68+
// Stop window from moving if the user grabbed it.
69+
if isMovableByWindowBackground {
70+
stopMotion()
71+
}
72+
}
73+
74+
override func mouseUp(with event: NSEvent) {
75+
super.mouseUp(with: event)
76+
// Continue movement after the user lets go.
77+
if isMovableByWindowBackground {
78+
startMotion()
79+
}
80+
}
81+
}
82+
83+
// MARK: - Start/stop motion
84+
private extension SpringMotionPanel {
85+
86+
func startMotion() {
87+
guard displayLink == nil,
88+
let screenDescription = NSScreen.main?.deviceDescription,
89+
let screenNumber = screenDescription[.init("NSScreenNumber")] as? NSNumber else { return }
90+
91+
CVDisplayLinkCreateWithCGDisplay(screenNumber.uint32Value, &displayLink)
92+
guard let displayLink else { return }
93+
94+
CVDisplayLinkSetOutputHandler(displayLink) { [weak self] _, inNow, _, _, _ in
95+
DispatchQueue.main.async { [weak self] in
96+
guard let self, let destinationPoint else {
97+
self?.stopMotion()
98+
return
99+
}
100+
101+
let currentState = currentMotionState ?? .init(position: frame.center, velocity: .zero)
102+
let nextState = motionPhysics.calculateNextState(from: currentState, destinationPoint: destinationPoint)
103+
104+
if abs(nextState.velocity.horizontal) < 0.01 && abs(nextState.velocity.vertical) < 0.01 {
105+
stopMotion()
106+
return
107+
}
108+
109+
self.setFrame(.init(x: nextState.position.x - frame.width / 2,
110+
y: nextState.position.y - frame.height / 2,
111+
width: frame.width,
112+
height: frame.height), display: false)
113+
currentMotionState = nextState
114+
}
115+
116+
return kCVReturnSuccess
117+
}
118+
119+
CVDisplayLinkStart(displayLink)
120+
}
121+
122+
func stopMotion() {
123+
guard let link = displayLink else { return }
124+
CVDisplayLinkStop(link)
125+
displayLink = nil
126+
currentMotionState = nil
127+
}
128+
}
129+
130+
#endif

0 commit comments

Comments
 (0)