Provide ways to limit animations/motion people find jarring in your apps. This repo demonstrates accessible and inclusive iOS animations/motion with practical examples and best practices.
Why use animations? | Example |
---|---|
Delight and playfulness (Duolingo) | |
State change: Hamburger to close icon | |
Draw user’s attention | |
Guidance: Replace telling with showing |
Types of Animations | Example |
---|---|
Programmatically initiated: Loading | |
User initiated: Gestural-based |
Implicit animation: .animation:
import SwiftUI
struct Implicit: View {
@State private var starting = false
@State private var ending = false
@State private var rotating = false
var body: some View {
VStack {
Circle()
.trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
.animation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true), value: starting)
.animation(.easeInOut(duration: 1).delay(1).repeatForever(autoreverses: true), value: ending)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(rotating ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: rotating)
.accessibilityLabel("Loading Animation")
.onAppear {
starting.toggle()
rotating.toggle()
ending.toggle()
}
Image(.bmcLogo)
} //
}
}
#Preview {
Implicit()
}
Explicit animation: withAnimation():
import SwiftUI
struct Explicit: View {
@State private var starting = false
@State private var ending = false
@State private var rotating = false
var body: some View {
VStack {
Circle()
.trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 50, height: 50)
.rotationEffect(.degrees(rotating ? 360 : 0))
.accessibilityLabel("Loading Animation")
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotating.toggle()
}
withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
starting.toggle()
}
withAnimation(.easeInOut(duration: 1).delay(1).repeatForever(autoreverses: true)) {
ending.toggle()
}
}
Image(.bmcLogo)
} //
}
}
#Preview {
Explicit()
}
The implicit and explicit code samples above result in the same animation
–
What animations/motion may be distractive? | Example |
---|---|
Rain, clouds, slowly moving stars, thunder | |
Parallax: Multi-speed & multi-direction | |
Zooming and scaling animations: App icon throwing animation on iOS | |
Spinning or rotating effects | |
Bouncy & swoopy effects | |
Bouncing & wave-like movement | |
Animating depth changes: Z-axis layers and multi-axis. Card flip animation | |
Multi-sliding animations: Moving in the opposite direction to the user’s scroll direction | |
Intense Animations: Glitching and flicking effects. Example: HoloVista | |
Blinking animation: Can cause epileptic episodes |
NavigationLink {
PreJoinScreen()
} label: {
VStack(alignment: .leading) {
HStack {
Image(systemName: "person.circle.fill")
.font(.title2)
Spacer()
Image(systemName: "ellipsis")
.rotationEffect(.degrees(90))
}
Spacer()
Text("New Meeting")
}
.padding()
.frame(width: 160, height: 160)
.background(.ultraThinMaterial)
.cornerRadius(20)
}
Reduce Motion Off
Reduce Motion On
//
// ReduceMotionAnimationNil.swift
// Hamburger to Close
//
import SwiftUI
struct ReduceMotionAnimationSubtleFeel: View {
@State private var isRotating = false
@State private var isHidden = false
// Reduce Motion On
let subtleFeel = Animation.snappy
// Reduce Motion OFF
let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
// Detect and respond to reduce motion
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack(spacing: 14){
Rectangle() // top
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
Rectangle() // middle
.frame(width: 64, height: 10)
.cornerRadius(4)
.scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
.opacity(isHidden ? 0 : 1)
Rectangle() // bottom
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Menu and close icon transition")
.onTapGesture {
withAnimation(reduceMotion ? subtleFeel : bouncyFeel) {
isRotating.toggle()
isHidden.toggle()
}
}
}
}
#Preview {
ReduceMotionAnimationSubtleFeel()
.preferredColorScheme(.dark)
}
//
// ReduceMotionAnimationNil.swift
// Hamburger to Close
//
import SwiftUI
struct ReduceMotionAnimationNil: View {
@State private var isRotating = false
@State private var isHidden = false
// Reduce Motion On
let subtleFeel = Animation.snappy
// Reduce Motion OFF
let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
// Detect and respond to reduce motion
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack(spacing: 14){
Rectangle() // top
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
Rectangle() // middle
.frame(width: 64, height: 10)
.cornerRadius(4)
.scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
.opacity(isHidden ? 0 : 1)
Rectangle() // bottom
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Menu and close icon transition")
.onTapGesture {
withAnimation(reduceMotion ? nil : bouncyFeel) {
isRotating.toggle()
isHidden.toggle()
}
}
}
}
#Preview {
ReduceMotionAnimationSubtleFeel()
.preferredColorScheme(.dark)
}
//
// ReduceMotionAnimationNil.swift
// Hamburger to Close
//
import SwiftUI
struct ReduceMotionAnimationSubtleFeel: View {
@State private var isRotating = false
@State private var isHidden = false
// Reduce Motion On
let subtleFeel = Animation.snappy
// Reduce Motion OFF
let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
// Detect and respond to reduce motion
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack(spacing: 14){
Rectangle() // top
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
Rectangle() // middle
.frame(width: 64, height: 10)
.cornerRadius(4)
.scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
.opacity(isHidden ? 0 : 1)
Rectangle() // bottom
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Menu and close icon transition")
.onTapGesture {
withAnimation(reduceMotion ? subtleFeel : bouncyFeel) {
isRotating.toggle()
isHidden.toggle()
}
}
}
}
#Preview {
ReduceMotionAnimationSubtleFeel()
.preferredColorScheme(.dark)
}
//
// ReduceMotionDurationZero.swift
// Hamburger to Close
//
import SwiftUI
struct ReduceMotionDurationZero: View {
@State private var isRotating = false
@State private var isHidden = false
// Reduce Motion On
let durationZero = Animation.snappy(duration: 0)
// Reduce Motion OFF
let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
// Detect and respond to reduce motion
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack(spacing: 14){
Rectangle() // top
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
Rectangle() // middle
.frame(width: 64, height: 10)
.cornerRadius(4)
.scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
.opacity(isHidden ? 0 : 1)
Rectangle() // bottom
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Menu and close icon transition")
.onTapGesture {
withAnimation(reduceMotion ? durationZero : bouncyFeel) {
isRotating.toggle()
isHidden.toggle()
}
}
}
}
#Preview {
ReduceMotionDurationZero()
.preferredColorScheme(.dark)
}
Circle()
.trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 50, height: 50)
.rotationEffect(.degrees(rotating ? 360 : 0))
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotating.toggle()
}
withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
starting.toggle()
}
}
Checkout the sound version on Vimeo
Circle()
.trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 50, height: 50)
.rotationEffect(.degrees(rotating ? 360 : 0))
.accessibilityLabel("Loading Animation")
//.accessibilityAddTraits()
.accessibilityValue("Animation")
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotating.toggle()
}
withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
starting.toggle()
}
}
Checkout the sound version on Vimeo
Silent Mode On: Emulate the absence of sound
Example: Reporting an incoming or outgoing call