代码下载
使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节。在这篇中,会给跟踪用户徒步的图表视图添加动画,使用animation(_:)修改器给一个视图添加动画效果非常容易。
下载起步项目并跟着本篇教程一步步实践,或者查看本篇完成状态时的工程代码去学习,项目文件。
添加 Hiking 数据到应用程序
在添加动画之前,需要一些东西来做动画。在本节中,将导入和建模 Hiking 数据,然后添加一些预构建的视图,以便在图中静态地显示该数据。
1、从下载文件的“Resources”文件夹将 hikeData.json 文件拖动放入项目。在单击 Finish 之前,请确保选择 “Copy items if need”。
2、新建 Hike.swift 文件,与Landmark结构体一样,Hike结构体也遵守Codable协议,并且具有与相应数据文件中的键匹配的属性:
import Foundation
struct Hike: Codable, Hashable, Identifiable {
var id: Int
var name: String
var distance: Double
var difficulty: Int
var observations: [Observation]
static var formatter = LengthFormatter()
var distanceText: String {
Hike.formatter
.string(fromValue: distance, unit: .kilometer)
}
struct Observation: Codable, Hashable {
var distanceFromStart: Double
var elevation: Range<Double>
var pace: Range<Double>
var heartRate: Range<Double>
}
}
3、新建 ModelData.swift 文件:
import Foundation
@Observable
class ModelData {
var hikes: [Hike] = load("hikeData.json")
}
func load<T: Decodable>(_ filename: String) -> T {
guard let path = Bundle.main.url(forResource: filename, withExtension: nil),
let data = try? Data(contentsOf: path),
let result = try? JSONDecoder().decode(T.self, from: data) else {
fatalError("数据加载失败!")
}
return result
}
4、将已下载文件的Resources文件夹中的hike文件夹拖到项目中。在单击Finish之前,请确保选择“Copy items if need”和“Create groups”。熟悉这些新的视图,它们一起工作来显示加载到模型中的 hike 数据。
5、在HikeView.swift中,打开实时预览,体验一下图表的打开和隐藏,此时的状态改变时是没有添加动画效果的。在本篇的实践中,保持实时预览一直打开,每一步修改的效果就可以实时的看到。
给每个视图单独添加动画
在视图上使用animation(_:)修改器时,SwiftUI会在视图的任何可进行动画的属性发生改变时产生对应的动画效果。视图的颜色、不透明度、旋转角度、大小及一些其它属性都是可进行动画的。
1、在HikeView.swift中,给显示/隐藏切换的箭头按钮添加旋转动画,会发现现在按钮点击时的旋转有一个动画过渡的效果了。当视图从隐藏到展示时,让切换按钮变大1.5倍,把动画的类型从easeInOut改为spring()。SwiftUI包含一些预设或可自定义的动画类型,像弹簧(spring)动画和类型液体(fluid)动画类型。可以调整动画开始前的等待时长、动画的速度也可以指定让动画循环重复的进行:
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
showDetail.toggle()
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}
if showDetail {
HikeDetail(hike: hike)
}
}
}
}
2、如果只想让按钮具有缩放动画而不进行旋转动画,可以在scaleEffect前面添加animation(nil)来实现。可以在这里做一些实验,如果把其它的一些动画效果结合在一起,会怎么样:
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
showDetail.toggle()
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}
if showDetail {
HikeDetail(hike: hike)
}
}
}
}
3、进行下一节之前,把本节中添加的animation(_:)修改器都去掉。
把视图的状态改态转化成动画效果
已经学会了给单个视图添加动画的方法,现在可以学习怎么在视图的状态发生改变时添加动画效果。当用户点击按钮时会切换showDetail状态的值,在视图变化过程中添加动画效果。
1、把showDetail.toggle()包裹在withAnimation函数调用块中。showDetail的改变影响了视图HikeDetail和详情切换按钮,在显示/隐藏详情的过程中都有了过滤动画效果。
2、放慢动画速度,可以观察SwiftUI动画在被中断下是怎么运作的。给withAnimation传入一个时长4秒的基本动画参数.easeInOut(duration:4),可以指定动画过程时长,给withAnimation传入的动画参数与.animation(_:)修改器可用参数一致。
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
withAnimation(.easeInOut(duration: 4)) {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
}
}
}
}
3、在动画过程进行中点击按钮切换视图状态,查看对应的动画被中断时的效果。进行下一节之前,把动画时长参数(.easeInOut(duration: 4))去掉,让动画不再缓慢进行。
定制视图转场动画
默值情况下,视图离屏和入屏时的动画效果是渐隐/渐现, 这个默认的转场效果可以使用transition(_:)修改器进行定制。
1、给HikeView视图添加transition(_:)修改器,并定制转场参数为.slide,转场动画为滑入/滑出:
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
withAnimation {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
.transition(.slide)
}
}
}
}
2、可以把滑入/滑出这种转场动画封装起来,方便其它视图复用同样的转场效果:
extension AnyTransition {
static var moveAndFade: AnyTransition {
.slide
}
}
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
withAnimation {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
.transition(AnyTransition.moveAndFade)
}
}
}
}
3、在moveAndFade转场效果的定义中使用move(edge:),让滑入/滑出从屏幕的同一边进行:
extension AnyTransition {
static var moveAndFade: AnyTransition {
.move(edge: .trailing)
}
}
4、使用asymmetric(insertion:removal:)修改器来定制视图显示/消失时的转场动画效果:
extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = self.move(edge: .trailing)
.combined(with: .opacity)
let removal = self.scale
.combined(with: .opacity)
return self.asymmetric(insertion: insertion, removal: removal)
}
}
组合复杂的动画效果
点击图表下面的三个按钮,会在三个不同的数据集间进行切换并展示。本节中会使用组合动画,让图表在不同数据集间切换时的转换动画流畅自然。
1、把showDetail的默认值改为true,并把HikeView的预览模式视图固定在画布上。这样可以在编辑其它文件时,依然看到动画效果的变化。
2、在HikeGraph.swift中定义了一个新的波动动画,并把它与滑入/滑出动画一起应用到图表视图上:
extension Animation {
static func ripple() -> Animation {
Animation.default
}
}
struct HikeGraph: View {
var hike: Hike
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
GraphCapsule(
index: index,
color: color,
height: proxy.size.height,
range: observation[keyPath: path],
overallRange: overallRange
).animation(.ripple())
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
3、把动画切换为弹簧动画(spring),并设置弹簧阻尼系数为0.5,动画过程中产生了逐渐回弹效果:
extension Animation {
static func ripple() -> Animation {
Animation.spring(dampingFraction: 0.5)
}
}
4、加速弹簧动画的执行速度,缩短切换图表的时间:
extension Animation {
static func ripple() -> Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
}
}
5、以当条形在图表中的位置为参数,添加延迟效果,图表中的每个条形会顺序动起来:
extension Animation {
static func ripple(index: Int) -> Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(Double(index)*0.03)
}
}
struct HikeGraph: View {
var hike: Hike
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
GraphCapsule(
index: index,
color: color,
height: proxy.size.height,
range: observation[keyPath: path],
overallRange: overallRange
).animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
观察一下自定义波动(rippling)效果是怎么作用在视图转场中的。