본문 바로가기
iOS/SwiftUI Tutorial

SwiftUI Tutorial 6 - Animating views and transitions

by 6cess 2024. 7. 8.

1. Add hiking data to the app

제공하는 json 데이터 파일을 프로젝트에 다운 받은 다음 데이터 양식에 맞게 구조체를 구현한다.

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>
    }
}

 이번에는 튜토리얼을 위해 이미 구현해놓은 view들을 제공한다. 프로젝트로 다운 받는다.

 

2. Add animations to individual views

우선 애니메이션을 적용할 view 가 Equatable 이 아닐 경우 animation(_:value:) 메서드로 애니메이션을 조작할 수 있다.

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()
                        .animation(.spring(), value: showDetail)
                }
            }

            if showDetail {
                HikeDetail(hike: hike)
            }
        }
    }
}

 

이펙트를 따로 주고 싶다면 중간 중간 추가로 animation(_:value:)메서드를 호출한다.

 

3. Animate the effects of state changes

다음으로는 state의 value을 변경해서 애니메이션을 작동시키는 방법을 사용해보자.

withAnimation을 state가 변화하는 코드를 감싸보자. 그러면 state의 변화로 인해 영향을 받는 views 들이 transition을 갖고 변화한다.

Button {
    withAnimation(.easeInOut(duration: 4)) {
        showDetail.toggle()
    }
}

4. Customize view transitions

각각의 view에 transition 설정을 transition(_:) modifier을 통해 커스터마이징할 수 있다.

if showDetail {
    HikeDetail(hike: hike)
        .transition(.slide)
}

 

Extension으로 Transition을 커스터마이징해보자.

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

5. Compose animations for complex effects

Animation을 좀 더 복잡하게 설정해보자.

extension Animation {
    static func ripple(index: Int) -> Animation {
        Animation.spring(dampingFraction: 0.5)
            .speed(2)
            .delay(0.03 * Double(index))
    }
}

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)
            }
        }
    }
}

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
}

#Preview {
    let hike = ModelData().hikes[0]
    return 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)
    }
}