Skip to content

2.2 SwiftUI Animating Views and Transitions 视图和转场动画

developer.apple.com/tutorials/swiftui/animating-views-and-transitions

创建数据模型

  1. 添加hikeData.json文件到项目
  2. 创建数据模型 Hike.swift
swift
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>
    }
}
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文件中增加模型数据导入

swift
import Foundation

//var landmarks: [Landmark] = load("landmarkData.json")

// SwiftUI订阅(subscribes)您的可观察(observable)对象,并在数据变化时更新任何需要刷新的视图。
final class ModelData: ObservableObject {
    // 可观察对象需要发布对其数据的任何更改,以便其订阅者能够接收更改。
    @Published var landmarks: [Landmark] = load("landmarkData.json")
    
    var hikes: [Hike] = load("hikeData.json")
}


func load<T: Decodable>(_ filename: String) -> T {
    let data: Data


    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }


    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }


    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}
import Foundation

//var landmarks: [Landmark] = load("landmarkData.json")

// SwiftUI订阅(subscribes)您的可观察(observable)对象,并在数据变化时更新任何需要刷新的视图。
final class ModelData: ObservableObject {
    // 可观察对象需要发布对其数据的任何更改,以便其订阅者能够接收更改。
    @Published var landmarks: [Landmark] = load("landmarkData.json")
    
    var hikes: [Hike] = load("hikeData.json")
}


func load<T: Decodable>(_ filename: String) -> T {
    let data: Data


    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }


    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }


    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

图形绘制

绘制图形:

|300

Capsule组件说明

SwiftUI 中的 Capsule 可以用来创建圆角矩形,并且可以指定圆角的大小。Capsule 可以设置填充颜色、边框颜色、边框宽度等属性。

swift
import SwiftUI

struct CapsuleDemo: View {
    var body: some View {
        Capsule()
            // 都可以设置颜色,测试发现fill优先级更高
            .fill(.yellow)
            .foregroundColor(.green)
            // 可以看到背景色是整个组件的背景
            //.background(.red)
            // 圆角是整个组件的圆角,所以这个属性不要设置
            //.cornerRadius(200)
            .padding(30)
            .shadow(color:.gray, radius: 10, x: 10, y: 10)
            .overlay {
                Text("Capsule")
                    .foregroundColor(.red)
            }
    }
}

struct CapsuleDemo_Previews: PreviewProvider {
    static var previews: some View {
        CapsuleDemo()
    }
}
import SwiftUI

struct CapsuleDemo: View {
    var body: some View {
        Capsule()
            // 都可以设置颜色,测试发现fill优先级更高
            .fill(.yellow)
            .foregroundColor(.green)
            // 可以看到背景色是整个组件的背景
            //.background(.red)
            // 圆角是整个组件的圆角,所以这个属性不要设置
            //.cornerRadius(200)
            .padding(30)
            .shadow(color:.gray, radius: 10, x: 10, y: 10)
            .overlay {
                Text("Capsule")
                    .foregroundColor(.red)
            }
    }
}

struct CapsuleDemo_Previews: PreviewProvider {
    static var previews: some View {
        CapsuleDemo()
    }
}

创建GraphCapsule充当“柱”图

swift

import SwiftUI

struct GraphCapsule: View, Equatable {
    var index: Int
    var color: Color
    var height: CGFloat
    // 当前数据的范围区间
    var range: Range<Double>
    // 全部范围区间
    var overallRange: Range<Double>

    // 根据数据的范围,和全部范围区间进行比较,计算出占比。最小比例是0.15
    var heightRatio: CGFloat {
        // magnitude自定义函数作用是取区间返回大小 range.upperBound - range.lowerBound
        max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
    }

    // 当前数据,起始点数据的偏移比例
    var offsetRatio: CGFloat {
        CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
    }

    var body: some View {
        Capsule()
            .fill(color)
            .frame(height: height * heightRatio)
            .offset(x: 0, y: height * -offsetRatio)              
    }
}

struct GraphCapsule_Previews: PreviewProvider {
    static var previews: some View {
        GraphCapsule(
            index: 0,
            color: .red,
            height: 150,
            range: 10..<50,
            overallRange: 0..<100)
    }
}

import SwiftUI

struct GraphCapsule: View, Equatable {
    var index: Int
    var color: Color
    var height: CGFloat
    // 当前数据的范围区间
    var range: Range<Double>
    // 全部范围区间
    var overallRange: Range<Double>

    // 根据数据的范围,和全部范围区间进行比较,计算出占比。最小比例是0.15
    var heightRatio: CGFloat {
        // magnitude自定义函数作用是取区间返回大小 range.upperBound - range.lowerBound
        max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
    }

    // 当前数据,起始点数据的偏移比例
    var offsetRatio: CGFloat {
        CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
    }

    var body: some View {
        Capsule()
            .fill(color)
            .frame(height: height * heightRatio)
            .offset(x: 0, y: height * -offsetRatio)              
    }
}

struct GraphCapsule_Previews: PreviewProvider {
    static var previews: some View {
        GraphCapsule(
            index: 0,
            color: .red,
            height: 150,
            range: 10..<50,
            overallRange: 0..<100)
    }
}

创建柱状图

创建HikeGraph.swift页面:

swift
import SwiftUI
struct HikeGraph: View {
    var hike: Hike
    // Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
    // 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
    // 例如: 这里代表 Hike.Observation 这个类型如何访问到  Range<Double>
    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
        // lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
        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
                    )
                }
                .offset(x: 0, y: proxy.size.height * heightRatio)
            }
        }
    }
}

func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    where C.Element == Range<Double> {
    guard !ranges.isEmpty else { return 0..<0 }
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    let high = ranges.lazy.map { $0.upperBound }.max()!
    return low..<high
}

func magnitude(of range: Range<Double>) -> Double {
    range.upperBound - range.lowerBound
}

struct HikeGraph_Previews: PreviewProvider {
    static var hike = ModelData().hikes[0]

    static var previews: some View {
        Group {
            HikeGraph(hike: hike, path: \.elevation)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.heartRate)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.pace)
                .frame(height: 200)
        }
    }
}
import SwiftUI
struct HikeGraph: View {
    var hike: Hike
    // Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
    // 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
    // 例如: 这里代表 Hike.Observation 这个类型如何访问到  Range<Double>
    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
        // lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
        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
                    )
                }
                .offset(x: 0, y: proxy.size.height * heightRatio)
            }
        }
    }
}

func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    where C.Element == Range<Double> {
    guard !ranges.isEmpty else { return 0..<0 }
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    let high = ranges.lazy.map { $0.upperBound }.max()!
    return low..<high
}

func magnitude(of range: Range<Double>) -> Double {
    range.upperBound - range.lowerBound
}

struct HikeGraph_Previews: PreviewProvider {
    static var hike = ModelData().hikes[0]

    static var previews: some View {
        Group {
            HikeGraph(hike: hike, path: \.elevation)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.heartRate)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.pace)
                .frame(height: 200)
        }
    }
}

|300

尝试为单个视图增加动画

我们可以在equatable view上,使用使用修饰器 animation(_:), 可以对视图的可动画属性的修改产生动画。 view的color、opacity、rotation、size以及其他属性都是可以产生动画的。如果view不是equatable的,我们可以使用animation(_:)修饰器在指定值发生变化时启动动画。

swift
import SwiftUI
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()
                        // 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
                        // 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
                        .animation(.easeInOut, value: showDetail)
                }
            }
            
            if showDetail {
                HikeDetail(hike: hike)
            }
            Spacer()
        }
    }
    
    struct HikeView_Previews: PreviewProvider {
        static var previews: some View {
            HikeView(hike: ModelData().hikes[0])
        }
    }
}
import SwiftUI
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()
                        // 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
                        // 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
                        .animation(.easeInOut, value: showDetail)
                }
            }
            
            if showDetail {
                HikeDetail(hike: hike)
            }
            Spacer()
        }
    }
    
    struct HikeView_Previews: PreviewProvider {
        static var previews: some View {
            HikeView(hike: ModelData().hikes[0])
        }
    }
}

效果演示如图:

额外的,使用.animation(nil, value: showDetail)将会取消掉上文中的旋转动画效果。

swift
import SwiftUI

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, value: showDetail)
                        // 根据动画状态,调整大小
                        .scaleEffect(showDetail ? 1.5 : 1)
                        .padding()
                        // 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
                        // 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
                        //.animation(.easeInOut, value: showDetail)
                        .animation(.spring(), value: showDetail)
                }
            }
            
            if showDetail {
                HikeDetail(hike: hike)
            }
            Spacer()
        }
    }    
}

struct HikeView_Previews: PreviewProvider {
	static var previews: some View {
		HikeView(hike: ModelData().hikes[0])
	}
}
import SwiftUI

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, value: showDetail)
                        // 根据动画状态,调整大小
                        .scaleEffect(showDetail ? 1.5 : 1)
                        .padding()
                        // 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
                        // 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
                        //.animation(.easeInOut, value: showDetail)
                        .animation(.spring(), value: showDetail)
                }
            }
            
            if showDetail {
                HikeDetail(hike: hike)
            }
            Spacer()
        }
    }    
}

struct HikeView_Previews: PreviewProvider {
	static var previews: some View {
		HikeView(hike: ModelData().hikes[0])
	}
}

状态变化的动画效果

使用withAnimation包裹住showDetail.toggle()。 (测试发现HikeDetail出现的时候没有带动画,这里没有深究)

swift
import SwiftUI
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影响的视图(按钮和详情)都增加上了动画效果
                    // withAnimation {
                    // 为了效果更明显,这里增加了4s时间
                    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)
            }
            Spacer()
        }
    }
}


struct HikeView_Previews: PreviewProvider {
    static var previews: some View {
        HikeView(hike: ModelData().hikes[0])
    }
}
import SwiftUI
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影响的视图(按钮和详情)都增加上了动画效果
                    // withAnimation {
                    // 为了效果更明显,这里增加了4s时间
                    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)
            }
            Spacer()
        }
    }
}


struct HikeView_Previews: PreviewProvider {
    static var previews: some View {
        HikeView(hike: ModelData().hikes[0])
    }
}

自定义转场动画

swift
import SwiftUI

extension AnyTransition {
    static var moveAndFade: AnyTransition {
		// AnyTransition.slide
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .scale.combined(with: .opacity)
        )
    }
}

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)
                    // 自定义的动画
                    .transition(.moveAndFade)
            }
            Spacer()
        }
    }
}

struct HikeView_Previews: PreviewProvider {
    static var previews: some View {
        HikeView(hike: ModelData().hikes[0])
    }
}
import SwiftUI

extension AnyTransition {
    static var moveAndFade: AnyTransition {
		// AnyTransition.slide
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .scale.combined(with: .opacity)
        )
    }
}

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)
                    // 自定义的动画
                    .transition(.moveAndFade)
            }
            Spacer()
        }
    }
}

struct HikeView_Previews: PreviewProvider {
    static var previews: some View {
        HikeView(hike: ModelData().hikes[0])
    }
}

组合复杂动画

当您点击柱形图下方的按钮时,图形将在三组不同的数据之间切换。在本节中,您将使用合成动画为组成图形的胶囊提供动态的波纹过渡。

swift
import SwiftUI

extension Animation {
    static func ripple(index: Int) -> Animation {
        // 默认动画
        // Animation.default
        // 弹性动画
        //Animation.spring(dampingFraction: 0.5)
        // 调整弹性动画参数
        Animation.spring(dampingFraction: 0.5)
            // 稍稍加快动画速度,缩短每个条移动到新位置的时间。
            .speed(2)
            // 根据胶囊在图形上的位置为每个动画添加延迟
            .delay(0.03 * Double(index))
    }
}

struct HikeGraph: View {
    var hike: Hike
    // Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
    // 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
    // 例如: 这里代表 Hike.Observation 这个类型如何访问到  Range<Double>
    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
        // lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
        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)
            }
        }
    }
}

func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    where C.Element == Range<Double> {
    guard !ranges.isEmpty else { return 0..<0 }
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    let high = ranges.lazy.map { $0.upperBound }.max()!
    return low..<high
}

func magnitude(of range: Range<Double>) -> Double {
    range.upperBound - range.lowerBound
}

struct HikeGraph_Previews: PreviewProvider {
    static var hike = ModelData().hikes[0]

    static var previews: some View {
        Group {
            HikeGraph(hike: hike, path: \.elevation)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.heartRate)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.pace)
                .frame(height: 200)
        }
    }
}
import SwiftUI

extension Animation {
    static func ripple(index: Int) -> Animation {
        // 默认动画
        // Animation.default
        // 弹性动画
        //Animation.spring(dampingFraction: 0.5)
        // 调整弹性动画参数
        Animation.spring(dampingFraction: 0.5)
            // 稍稍加快动画速度,缩短每个条移动到新位置的时间。
            .speed(2)
            // 根据胶囊在图形上的位置为每个动画添加延迟
            .delay(0.03 * Double(index))
    }
}

struct HikeGraph: View {
    var hike: Hike
    // Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
    // 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
    // 例如: 这里代表 Hike.Observation 这个类型如何访问到  Range<Double>
    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
        // lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
        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)
            }
        }
    }
}

func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    where C.Element == Range<Double> {
    guard !ranges.isEmpty else { return 0..<0 }
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    let high = ranges.lazy.map { $0.upperBound }.max()!
    return low..<high
}

func magnitude(of range: Range<Double>) -> Double {
    range.upperBound - range.lowerBound
}

struct HikeGraph_Previews: PreviewProvider {
    static var hike = ModelData().hikes[0]

    static var previews: some View {
        Group {
            HikeGraph(hike: hike, path: \.elevation)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.heartRate)
                .frame(height: 200)
            HikeGraph(hike: hike, path: \.pace)
                .frame(height: 200)
        }
    }
}

|300