CocoaSprings is a lightweight Swift package that simulates damped spring physics for basic AppKit & UIKit components. The package provides subclasses of CALayer
, UIView
, NSView
, and NSWindow
, which can be moved around the UI repeatedly without breaking the continuity of their motion. This allows for the implementation of fluid, interactive animations similar to this one:
The math behind the animations is based on this excellent blog post by Ryan Juckett. In it, he explains the algorithm he uses for moving third-person cameras in video games, provided that a camera's motion should be smooth and continuous despite the possible abrupt changes in the player's movement. This is the exact effect we wanted to achieve for animating UI elements inside our apps, so we applied the same algorithm to move layers, views, and windows on the 2D plane of the user interface.
CocoaSprings is available through SPM. Just add this repository as a dependency to your package or project.
dependencies: [
.package(url: "https://github.com/MacPaw/CocoaSprings.git", branch: "main")
]
The physics of CocoaSprings components is configured via the SpringConfiguration
struct. It has two properties:
angularFrequency
controls how fast an object moves towards its destination. The higher the value, the faster an object moves. The default value is7.5
.dampingRatio
controls how fast the spring motion decays. The lower the value, the less velocity is lost upon each oscillation. The value must range from 0 to 1; the default is0.5
.
Set the configuration on any component to adjust its physics:
let layer = SpringMotionLayer()
layer.configuration = SpringConfiguration(angularFrequency: 10, dampingRatio: 0.7)
SpringMotionLayer
is a CALayer
subclass available for both iOS & macOS.
Add it as a sublayer to the desired parent layer and call the move(to:)
method to update the layer's position with spring animation.
Below is an example of a macOS NSView
subclass that hosts a SpringMotionLayer
and moves it to whichever point inside it gets clicked:
import AppKit
import CocoaSprings
final class ClickableView: NSView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private lazy var rootLayer = CALayer()
private lazy var springMotionLayer: SpringMotionLayer = {
let layer = SpringMotionLayer()
layer.backgroundColor = NSColor.systemRed.cgColor
layer.cornerRadius = 10
layer.frame.size = .init(width: 20, height: 20)
return layer
}()
private func setup() {
layer = rootLayer
wantsLayer = true
rootLayer.addSublayer(springMotionLayer)
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
springMotionLayer.move(to: convert(event.locationInWindow, from: nil))
}
}
SpringMotionView
is an NSView
/UIView
subclass, depending on the platform you're building for.
Due to the fact that views are usually positioned using constraints, the client is responsible for updating the view's position during animation. On each frame of movement the view executes its onUpdatePosition
closure, you must set it to update the relevant view's constraints.
Consider an example iOS snippet below, assume we've set up SpringMotionView
and its constraints via Interface Builder:
import UIKit
import CocoaSprings
final class ViewController: UIViewController {
@IBOutlet weak var springMotionView: SpringMotionView!
@IBOutlet weak var springMotionViewTopConstraint: NSLayoutConstraint!
@IBOutlet weak var springMotionViewLeftConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
// Here we set up how the view should update its position
// depending on the provided `point` parameter.
springMotionView.onPositionUpdate = { [weak self] point in
guard let self else { return }
let size = self.springMotionView.frame.size
self.springMotionViewLeftConstraint.constant = point.x - size.width / 2
self.springMotionViewTopConstraint.constant = point.y - size.height / 2
}
}
// UITapGestureRecognizer action
@IBAction func handleTap(_ sender: UITapGestureRecognizer) {
// Start moving to the tapped location
springMotionView.move(to: sender.location(in: view))
}
}
SpringMotionWindow
is an NSWindow
subclass, available for macOS only.
- Call
move(to:)
to start animating position to a point on screen. - Call
pinToWindow(_:offsetFromCenter:)
to make the window follow any other window on screen with spring animation (example below). - Call
unpinFromWindow()
to stop following a previously followed window.
import AppKit
import CocoaSprings
final class ViewController: NSViewController {
override func viewDidAppear() {
super.viewDidAppear()
let springMotionWindow = SpringMotionWindow()
springMotionWindow.contentView = NSView()
springMotionWindow.contentView?.wantsLayer = true
springMotionWindow.contentView?.layer?.backgroundColor = NSColor.systemRed.cgColor
springMotionWindow.contentView?.layer?.cornerRadius = 10
springMotionWindow.setFrame(.init(origin: .zero, size: .init(width: 100, height: 100)), display: true)
springMotionWindow.makeKeyAndOrderFront(nil)
springMotionWindow.level = .mainMenu
springMotionWindow.styleMask = [.borderless, .fullSizeContentView]
springMotionWindow.backgroundColor = .clear
springMotionWindow.isMovableByWindowBackground = true
if let mainWindow = view.window {
springMotionWindow.pinToWindow(mainWindow, offsetFromCenter: .init(x: 300, y: 100))
}
}
}
For your convenience, most of the features described above are implemented in a demo project in this repository. Please refer to it for package usage examples.
CocoaSprings is available under the MIT license.
See the LICENSE file for more info.