Skip to content

Interactive spring animations for macOS/iOS

License

Notifications You must be signed in to change notification settings

MacPaw/CocoaSprings

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CocoaSprings

Contents

About

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:

About

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.

Installation

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")
]

Usage

SpringConfiguration

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 is 7.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 is 0.5.

Set the configuration on any component to adjust its physics:

let layer = SpringMotionLayer()
layer.configuration = SpringConfiguration(angularFrequency: 10, dampingRatio: 0.7)

SpringMotionLayer

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))
    }
}

Layer Example

SpringMotionView

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))
    }
}

View Example

SpringMotionWindow

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))
        }
    }
}

View Example

Demo

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.

View Example

License

CocoaSprings is available under the MIT license.

See the LICENSE file for more info.