开发人员的无障碍和包容性 iOS/SwiftUI 动画/运动备忘单
对某些应用程序用户来说,动画和运动可能会令人眩晕。让我们通过这些实用示例并遵循本仓库中概述的运动无障碍指南来解决这个问题。你发现有什么遗漏吗?请在 Twitter 上联系 @amos_gyamfi 和 @stefanjblos。
我们拥有的资源
为什么使用动画? | 示例 |
---|---|
愉悦和趣味性(多邻国) | |
状态变化:汉堡菜单变为关闭图标 | |
吸引用户注意力 | |
指导:用展示代替讲解 |
使用以下方式构建动画:
- 符号效果、相位、关键帧、弹簧:
动画类型 | 示例 |
---|---|
程序启动:加载 | |
用户启动:基于手势 |
如何添加动画
隐式动画:.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("加载动画")
.onAppear {
starting.toggle()
rotating.toggle()
ending.toggle()
}
Image(.bmcLogo)
} //
}
}
#Preview {
Implicit()
}
显式动画: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("加载动画")
.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()
}
上面的隐式和显式代码示例产生相同的动画效果
运动方式
- 标准缓动:默认、线性、easeIn、easeOut、easeInOut
- 时间曲线(自定义):easeInOutBack
- 弹簧:弹性、平滑、快速。访问 Purposeful SwiftUI Animation 了解更多。
--
哪些动画/运动可能会分散注意力?
- 频繁的粒子动画: 雨、云、缓慢移动的星星、雷电
- 视差:多速度和多方向(可能导致不匹配)
- UIMotionEffect:创造深度感知
- 使用起来很有趣但可能令人眩晕
- 可能导致晕动病
- 持续的背景和前景效果:星星和云
哪些动画/运动可能会分散注意力? | 示例 |
---|---|
雨、云、缓慢移动的星星、雷电 | |
视差:多速度和多方向 | |
缩放和缩放动画:iOS 上的应用图标投掷动画 | |
旋转或旋转效果 | |
弹跳和波动效果 | |
弹跳和波浪状运动 | |
深度变化动画:Z 轴层和多轴。卡片翻转动画 | |
多滑动动画:与用户滚动方向相反的移动 | |
强烈动画:故障和闪烁效果。示例:HoloVista | |
闪烁动画:可能导致癫痫发作 |
指南 1:暂停、播放、隐藏
- 自动播放 GIF 和视频:显示播放/暂停按钮
- 背景动画: 隐藏按钮
- 动画插图:播放/暂停控制(循环超过 5 次)
指南 2:1 秒内闪烁/闪现不超过 3 次
- 视觉障碍人士:分散注意力且无用
- 闪烁:可能导致癫痫发作
- 优秀示例:iOS 屏幕录制动画(灵动岛)
- 替代闪烁:使用各种 SF Symbol 动画传达信息
指南 3:用户启动的动画
- 提供禁用动画的方法
- 优秀示例:Telegram 中的动画弹跳反应
减少动画:通用设置
- 为动画提供不明显或减少的行为
- 不意味着移除所有动画
- GIF 和视频:使用图像切换技术
- App Store: 水平卡片滚动动画
减少动画:通用设置
- 设置应用:限制所有应用中的动画/运动
- 缩放和放大动画:启动 iOS 应用图标的投掷动画
减少动画:偏好交叉淡入淡出(iOS 14)
- 什么?:SwiftUI NavigationLink -> 交叉淡入淡出过渡
- 推送转场与滑入/滑出动画:UI 出现/消失
NavigationLink {
PreJoinScreen()
} label: {
VStack(alignment: .leading) {
HStack {
Image(systemName: "person.circle.fill")
.font(.title2)
Spacer()
Image(systemName: "ellipsis")
.rotationEffect(.degrees(90))
}
Spacer()
Text("新会议")
}
.padding()
.frame(width: 160, height: 160)
.background(.ultraThinMaterial)
.cornerRadius(20)
}
减少动画:偏好交叉淡入淡出(iOS 14)
- 何时使用:当没有合适的替代动画时
减少动画:偏好交叉淡入淡出(iOS 14)
- 启用时:用微妙的淡入淡出替代滑动过渡
- 使用 NavigationLink 可免费获得此效果
- 示例:设置应用
减少动画:每个应用的设置
- 设置应用:为特定应用移除某些动画
- App Store:自动播放动画图像和视频预览
- 示例:下载 Headspace
![Headspace 动画](https://yellow-cdn.vecligh // // ReduceMotionAnimationNil.swift // 汉堡包到关闭
import SwiftUI
struct ReduceMotionAnimationSubtleFeel: View {
@State private var isRotating = false
@State private var isHidden = false
// 减少动画开启
let subtleFeel = Animation.snappy
// 减少动画关闭
let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
// 检测并响应减少动画
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack(spacing: 14){
Rectangle() // 顶部
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
Rectangle() // 中间
.frame(width: 64, height: 10)
.cornerRadius(4)
.scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
.opacity(isHidden ? 0 : 1)
Rectangle() // 底部
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("菜单和关闭图标过渡")
.onTapGesture {
withAnimation(reduceMotion ? subtleFeel : bouncyFeel) {
isRotating.toggle()
isHidden.toggle()
}
}
}
}
#Preview { ReduceMotionAnimationSubtleFeel() .preferredColorScheme(.dark) }
![提供替代的减少行为](https://yellow-cdn.veclightyear.com/2b54e442/5435a947-2fc4-47cb-860d-f9d439727978.gif)
---
## 采用减少动画
- **将动画持续时间**设置为0
```swift
//
// ReduceMotionDurationZero.swift
// 汉堡包到关闭
//
import SwiftUI
struct ReduceMotionDurationZero: View {
@State private var isRotating = false
@State private var isHidden = false
// 减少动画开启
let durationZero = Animation.snappy(duration: 0)
// 减少动画关闭
let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
// 检测并响应减少动画
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack(spacing: 14){
Rectangle() // 顶部
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
Rectangle() // 中间
.frame(width: 64, height: 10)
.cornerRadius(4)
.scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
.opacity(isHidden ? 0 : 1)
Rectangle() // 底部
.frame(width: 64, height: 10)
.cornerRadius(4)
.rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("菜单和关闭图标过渡")
.onTapGesture {
withAnimation(reduceMotion ? durationZero : bouncyFeel) {
isRotating.toggle()
isHidden.toggle()
}
}
}
}
#Preview {
ReduceMotionDurationZero()
.preferredColorScheme(.dark)
}
动画描述和VoiceOver
- 测试动画:向Siri说"打开VoiceOver。"
- 隐藏装饰性动画
VoiceOver:没有标签的动画
- 用滑动手势导航
- VoiceOver跳过动画
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()
}
}
在Vimeo查看带声音的版本
VoiceOver:带标签的动画
- 为有意义的动画添加标签
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("加载动画")
//.accessibilityAddTraits()
.accessibilityValue("动画")
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotating.toggle()
}
withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
starting.toggle()
}
}
在Vimeo查看带声音的版本
为动画添加触觉反馈
- 模仿物理触摸和拖动:
- 示例:缝合
为动画添加触觉反馈
-
**静音模式开启:**模拟声音缺失
-
示例:报告来电或去电
遵循基本的无障碍指南
- 屏幕闪烁可能导致头痛和癫痫发作
- 提供类似的视觉效果而不需要动作
- 过度的动作可能导致不适、头晕
- 示例:视差效果,滑动动画
总结
- 谨慎使用动作
- 优先使用NavigationLink:避免自定义滑动转场
- **减少动画:**提供选项来限制****动画和动作效果
- **是否启用了VoiceOver?**考虑如何清晰地转译动画
- 花时间考虑什么可能导致头晕/不适
- 使用微妙的动作效果
- 这个动画会引起不适吗?
- 对动作敏感的人如何享受我的应用?
- 如果用户开启了减少动画设置会怎样?
- 弹性/弹跳效果会显得不合适吗?