Animation engine for gesturally-driven user interfaces, animations, and interactions on iOS, macOS, and tvOS.
Motion is an animation engine for gesturally-driven user interfaces, animations, and interactions on iOS, macOS, and tvOS, and is powered by SIMD and written fully in Swift. Motion allows for easily creating physically-modeled, interruptible animations (i.e. springs, decays, etc.) that work hand-in-hand with gesture recognizers to make the most fluid and delightful interactions possible.
API Documentation is here
Creating animations in Motion is relatively simple. Simply allocate the animation type that you want with the type that conforms to SIMDRepresentable
, configure it, and call start
to start it. For each frame the animation executes, its onValueChanged
block will be called, and you’ll be given the opportunity to assign that newly animated value to something.
By default, lots of types are already supported out of the box, including:
Float
Double
CGFloat
CGPoint
CGSize
CGRect
SIMD2<Float>
SIMD2<Double>
Calling stop
will freeze it in place, without the need to query the presentationLayer
on CALayer
and set values, or worry about fillMode
, or worry about anything really.
Whenever it’s done, the completion block will be called.
The animations need to be held somewhere as they will stop running if they’re deallocated. Also, due to the nature of how they execute blocks, be careful not to introduce retain cycles by not using a weak self
or unowned
animation inside the animation onValueChanged
or completion
block.
Here’s some examples:
let springAnimation = SpringAnimation<CGRect>()
springAnimation.configure(response: 0.30, damping: 0.64)
springAnimation.toValue = CGRect(x: 0.0, y: 0.0, width: 320.0, height: 320.0)
springAnimation.velocity = CGRect(x: 0.0, y: 0.0, width: -200.0, height: -200.0)
springAnimation.onValueChanged(disableActions: true) { newValue in
view.layer.bounds = newValue
}
springAnimation.completion = { [weak self] in
// all done
self?.animationDone()
}
springAnimation.start()
Note: Some of you may be wondering if it’s a mistake that the stiffness
, damping
, response
, or dampingRatio
setters are private, however this is intentional. It’s incredibly easy to mixup damping
and dampingRatio
, and using one over the other will lead to dramatically different results. In addition, you should only be configuring either stiffness
and damping
or response
and dampingRatio
as they’re both two separate ways of configuring spring constants.
let decayAnimation = DecayAnimation<CGPoint>()
decayAnimation.velocity = CGPoint(x: 0.0, y: 2000.0)
decayAnimation.onValueChanged { newValue in
view.bounds.origin = newValue
}
decayAnimation.completion = {
// all done
}
decayAnimation.start()
let basicAnimation = BasicAnimation<CGFloat>(easingFunction: .easeInOut)
basicAnimation.fromValue = 100.0
basicAnimation.toValue = 200.0
basicAnimation.duration = 0.4
basicAnimation.onValueChanged { newValue in
view.bounds.frame.x = newValue
}
basicAnimation.completion = {
// all done
}
basicAnimation.start()
Note: All of these animations are to run and be interfaced with on the main thread only. There is no support for threading of any kind.
Motion supports SwiftUI out of the box! You can use any Animation
subclass to animate @State
changes manually.
Checkout the example project’s SwiftUI Demo for more info!
Note: Work is planned to support TimelineView
. Stay tuned!
Motion is not designed to be a general-purpose replacement for Core Animation. Core Animation animations are run in a special way, in another process, outside of your app and are designed to be smooth even when the main thread is being heavily used. Motion on the other hand is all run in-process (like a game engine), and using it liberally without considering heavy stack traces, will result in poor performance and dropped frames. Motion itself is not slow (in fact it’s really fast!), but calling methods to change view / layer properties or change layout at 60 FPS (or more) can be really taxing if not done carefully.
tl;dr: Treat Motion animations as you would a UIScrollView
(since scrolling animations behave the same way). If you have too much going on in your UIScrollView
it’ll lag when it scrolls; the same applies to Motion.
Some key tips:
setNeedsDisplay()
and layoutIfNeeded()
.CAKeyframeAnimationEmittable
will help, and that’s outlined later in this guide.Motion is designed out of the box to make interruptible animations much easier. Interruptibility is when you have the ability to interrupt an animation in flight so you can stop, change, or restart it. Normally, with UIView
block-based animations, or Core Animation based animations, this is really difficult to do (need to cancel the animation, figure out its current state on screen, apply that, etc.). UIViewPropertyAnimator
works okay for this, but it relies heavily on “scrubbing” animations, which when working with physically-based animations (i.e. springs), that doesn’t really make a lot of sense, since the physics are what generate the animation dynamically (vs. some predefined easing curve you can scrub).
Motion makes things like this easy, so you have to worry less about syncing up animation state with gestures, and focus more about the interactions themselves.
Here’s an example of how a drag to a spring animation and then catching and redirecting that animation could look like:
Let’s say you have a subview view
inside another view (self
).
// Create a spring animation configured with our constants.
var springAnimation: SpringAnimation<CGPoint>()
springAnimation.configure(response: 0.30, damping: 0.64)
// When you drag on the view and let go, it'll spring away from the center and then rebound back.
// At any point, you can grab the view and do it again.
func didPan(_ gestureRecognizer: UIPanGestureRecognizer) {
switch gestureRecognizer.state: {
case .began:
springAnimation.stop()
case .changed:
view.center = gestureRecognizer.location(in: self)
springAnimation.updateValue(to: view.center)
case .ended:
springAnimation.toValue = self.center
springAnimation.velocity = gestureRecognizer.velocity(in: self)
springAnimation.onValueChanged { newValue in
view.center = newValue
}
springAnimation.start()
}
}
Note: You can try this out in the example project (under the Dragging Demo).
SIMD powers a lot of how Motion works and avoids having to use more “expensive” objects like NSValue
or NSNumber
to animate. SIMD grants the ability to pack multiple values into a single SIMD register and then perform math on all those values simultaneously (Single Instruction Multiple Data). This means you can do neat things like animate a CGRect
to another CGRect
in a single super fast operation (rather than 4 separate operations: x
, y,
, width
, height
). It’s not always the silver bullet, but on average, it’s at least on par, and often faster than the naive implementation.
Motion exposes a protocol called SIMDRepresentable
that allows for easy boxing and unboxing of values:
let point = CGPoint(x: 10.0, y: 10.0)
let simdPoint: SIMD2<CGFloat.NativeType> = point.simdRepresentation()
let pointBoxedAgain = CGPoint(simdPoint)
These conversions are relatively inexpensive, and Motion has been heavily optimized to avoid copying or boxing/unboxing them whenever it can.
For more information on SIMD, check out the docs.
Motion is pretty dang fast (especially on Apple Silicon!), leveraging some manual Swift optimization / specialization as well as SIMD it’s capable of executing 5000 SpringAnimation<SIMD64<Double>>
in ~130ms on an iPhone 12 Pro (that’s 320,000 springs at 0.4 microseconds per spring!!). For smaller types like CGFloat
, it can do the same thing in ~1ms.
Is it as fast as it could be? Faster than some hand-optimized C++ or C implementation? Probably not.
That being said, it’s definitely fast enough for interactions on devices and rarely (if ever) will be the bottleneck. I’m also still no SIMD expert, so if anyone has some tips, I’m sure it can go faster!
tl;dr: SIMD go brrrrrrrrrrrrrrrrrrrrrrr
If you’d like benchmark Motion on your own device, simply run the following from within the Benchmark
folder:
swift run -c release MotionBenchmarkRunner --time-unit ms
If you’d like to run the benchmark on device, just launch the MotionEample-iOS
app in Release
mode with the --benchmark
launch argument.
Motion features some great additions to aid in creating interactions in general.
Rubberbanding is the act of making values appear to be on a rubberband (they stretch and slip based on interaction). UIScrollView
does this when you’re pulling past the contentSize
and by using the rubberband functions in Motion you can re-create this interaction for yourself. See the “ScrollView Demo” inside the example app for more info.
All animations in Motion conform to CAKeyframeAnimationEmittable
and that means that for any animation you configure, you can have it automatically generate a CAKeyframeAnimation
that mirrors what would happen if you were to animate things using start()
. The duration, and everything else is automatically calculated by running the animation from value
to the resolved state. The only difference is onValueChanged
and completion
cannot be used, and you must specify a keypath to animate. There are also some helper methods to make this even easier (like adding any animation to a CALayer
directly).
For example:
let springAnimation = SpringAnimation<CGRect>()
springAnimation.configure(response: 0.30, damping: 0.64)
springAnimation.toValue = CGRect(x: 0.0, y: 0.0, width: 320.0, height: 320.0)
springAnimation.velocity = CGRect(x: 0.0, y: 0.0, width: -200.0, height: -200.0)
let keyframeAnimation = springAnimation.keyframeAnimation()
keyframeAnimation.keyPath = "frame"
layer.add(keyframeAnimation, forKey: "MyAnimation")
// or
layer.add(springAnimation, forKey: "MyAnimation", keyPath: "frame")
Note: If you remove or interrupt the animation and you want it to stay in place on screen, much like all other Core Animation animations, you’ll need to grab the value from the layer’s presentationLayer
and apply that to the layer (as well as worry about fillMode
).
let frame = layer.presentationLayer()?.frame ?? layer.frame
layer.removeAnimation(forKey: "MyAnimation")
CADisableActions {
layer.frame = frame
}
CATransaction
is a really useful API but can easily break things if you forget to pair up CATransaction.begin()
and CATransaction.commit()
calls.
CADisableActions()
can be very helpful to reduce errors created when working with CATransaction
to disable implicit animations:
CADisableActions {
layer.opacity = 0.5
}
// This is the same as calling:
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.opacity = 0.5
CATransaction.commit()
In addition, you can also disable implicit animations in every single onValueChanged
invocation:
let springAnimation = SpringAnimation<CGFloat>(initialValue: 0.5)
springAnimation.onValueChanged(disableActions: true) { newValue in
layer.opacity = newValue
}
springAnimation.start()
Some initial work has been done to generate graphs to visualize a lot of these animations, and you’ll find that in the Graphing package. It’s still heavily a work in progress, but it is pretty neat for visualizing spring / decay functions.
Currently Motion supports Swift Package Manager, CocoaPods, Carthage, being used as an xcframework, and being used manually as an Xcode subproject. Pull requests for other dependency systems / build systems are welcome!
Add the following to your Package.swift
(or add it via Xcode’s GUI):
.package(url: "https://github.com/b3ll/Motion", from: "0.0.3")
A built xcframework is available for each tagged release.
Still working on this… (same with Carthage and Cocoapods). Pull Requests welcome!
There’s an example project available to try animations out and see how they work. Simply open the MotionExample-iOS.xcodeproj
from within the Example
directory.
This library pairs very nicely with Decomposed if you wish to animate CATransform3D
or access specific parts of it for your animations without worrying about the complex matrix math involved (i.e. transform.translation.x
).
Motion is licensed under the BSD 2-clause license.
This project was definitely inspired by the wonderful people I’ve had the experience of working with over the years as well as my appreciation of thoughtfully created and highly-crafted interfaces. In addition, the work done by @timdonnelly for Advance was a large inspiration for getting me started on this. I ended up writing this project as a means to further extend and just learn super in-depth all the mathematics and technical optimizations required to make a high-performance animation / interaction library. The more and more I iterated on it, the more I realized I was sharing the same mindset as I presume he did when writing Advance… brains are weird.
This project has had me really pushing the boundaries of my knowledge in terms of programming, Swift, animation, gestures, etc. and I’m really happy to be sharing it with everyone so that they may be empowered to use it too.
If you have any questions, or want to learn more, feel free to ask me anything!
Feel free to follow me on Mastodon: @b3ll!