代码下载
使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节。在这篇中,会给跟踪用户徒步的图表视图添加动画,使用animation(_:)修改器给一个视图添加动画效果非常容易。
下载起步项目并跟着本篇教程一步步实践,或者查看本篇完成状态时的工程代码去学习,项目文件。
添加 Hiking 数据到应用程序
在添加动画之前,需要一些东西来做动画。在本节中,将导入和建模 Hiking 数据,然后添加一些预构建的视图,以便在图中静态地显示该数据。
1、从下载文件的“Resources”文件夹将 hikeData.json 文件拖动放入项目。在单击 Finish 之前,请确保选择 “Copy items if need”。
2、新建 Hike.swift 文件,与Landmark结构体一样,Hike结构体也遵守Codable协议,并且具有与相应数据文件中的键匹配的属性:
import Foundationstruct Hike: Codable, Hashable, Identifiable {var id: Intvar name: Stringvar distance: Doublevar difficulty: Intvar observations: [Observation]static var formatter = LengthFormatter()var distanceText: String {Hike.formatter.string(fromValue: distance, unit: .kilometer)}struct Observation: Codable, Hashable {var distanceFromStart: Doublevar 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 = falsevar 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 = falsevar 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 = falsevar 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 = falsevar 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 = falsevar 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: Hikevar path: KeyPath<Hike.Observation, Range<Double>>var color: Color {switch path {case \.elevation:return .graycase \.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.observationslet 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 inHStack(alignment: .bottom, spacing: proxy.size.width / 120) {ForEach(Array(data.enumerated()), id: \.offset) { index, observation inGraphCapsule(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: Hikevar path: KeyPath<Hike.Observation, Range<Double>>var color: Color {switch path {case \.elevation:return .graycase \.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.observationslet 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 inHStack(alignment: .bottom, spacing: proxy.size.width / 120) {ForEach(Array(data.enumerated()), id: \.offset) { index, observation inGraphCapsule(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)效果是怎么作用在视图转场中的。