-
[SwiftUI] 5. Animating Views and TransitionsiOS Dev/SwiftUI 2023. 5. 4. 00:47반응형
https://developer.apple.com/tutorials/swiftui/animating-views-and-transitions
Animating Views and Transitions | Apple Developer Documentation
When using SwiftUI, you can individually animate changes to views, or to a view’s state, no matter where the effects are. SwiftUI handles all the complexity of these combined, overlapping, and interruptible animations for you.
developer.apple.com
Section 1. Add Hiking Data to the App
json 데이터를 Resources 폴더에 드래그&드랍 해주자.
Copy items if needed 체크해주어야 원본 파일이 사라져도 사본이 남아 빌드가 가능해진다.
그 다음 Model 그룹 안에 Hike.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> } }
LengthFormatter란?
애플 공식 문서에 따르면 : A formatter that provides localized descriptions of linear distances, such as length and height measurements. 이라고 되어있다. 선형 거리(길이, 높이 등)를 나타낼 수 있는 포멧이라고 해석할 수 있겠다. 어떠한 길이와 단위를 주면 해당 단위에 맞게 표현해줄 수 있다.Range란?
애플 공식 문서에 따르면 : A half-open interval from a lower bound up to, but not including, an upper bound. 이라고 되어있다. 즉 lower bound는 닫혀있고, upper bound는 열려 있는 반열림 구간이다. 우리가 흔히 ..< 연산자를 통해 생성하는 인스턴스가 Range이다. 예를 들어, let underFive = 0.0..<5.0 이라고 생성하면, 0.0은 포함하지만 5.0은 포함하지 않는 구간을 의미한다.그 다음, 아래 파일을 압축 해제하여 나온 폴더를 Resources 폴더 안에 넣어주자.
ModelData.swift 내용에 hikes 변수를 추가해주자.
import Foundation import Combine 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 find \(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)") } }
이제 HikeView.swift의 프리뷰가 잘 동작하는지 살펴보자.
Hikes 폴더 안에 있는 코드에 대해 잠깐 살펴보면,
GraphCapsule.swift에서는 Capsule() 컴포넌트를 사용해서 원하는 길이만큼 캡슐 형태로 그릴 수 있도록 설정해두었다.
HikeGraph.swift 에서는 path에 KeyPath를 사용해서 Hike.Observation에서 원하는 타입에 따라 값이 변할 수 있도록 설정해두었다.
HikeDetail에서는 버튼에 따라 dataToShow 상태를 변화시키면서 graph로 표현시킬 데이터를 정할 수 있다.
HikeView에서는 showDetail 여부에 따라 그래프를 보여줄지 말지를 결정한다.공통적으로 역슬래시(\) 기호를 이용해서 KeyPath에 Key를 전달해주는데, 좀 더 자세히 살펴보자.
var path: KeyPath<Hike.Observation, Range<Double>> // Hike.Observation 내용 struct Observation: Codable, Hashable { var distanceFromStart: Double var elevation: Range<Double> var pace: Range<Double> var heartRate: Range<Double> }
위와 같이 선언되어 있을 때, path의 Key값으로 .elevation이 들어오면, 해당 elevation 값을 읽을 수 있다.
실제로, HikeDetail에서 다음과 같은 코드가 있다.struct HikeDetail: View { let hike: Hike @State var dataToShow = \Hike.Observation.elevation var buttons = [ ("Elevation", \Hike.Observation.elevation), ("Heart Rate", \Hike.Observation.heartRate), ("Pace", \Hike.Observation.pace) ] var body: some View { VStack { HikeGraph(hike: hike, path: dataToShow) .frame(height: 200) ... 후략
중간에 Body 안에 VStack 안에 HikeGraph의 path인자로 dataToShow를 넣어준다.
dataToShow는 초기화로 \Hike.Observation.elevation이 들어가며 이를 통해 WritableKeyPath타입이 된다.잘 보면 \로 시작하는 문장에는 앞부분을 타입이름, 뒷부분을 인스턴스 이름으로 구분할 수 있다.
즉, 여기서는 Hike.Observation까지를 타입이름, elevation을 인스턴스 이름으로 구분할 수 있다.
타입 이름이 명확하면 생략이 가능하다.그렇다면 여기서 문제. 아래 코드는 HikeDetail 코드이다. 여기서 중간에 ForEach(buttons, id: \.0) 이 있는데, 역슬래시 뒤에 타입 이름 부분이 생략된 것이다. 생략하지 말고 전체 부분을 집어넣으려면 무엇을 넣어야 할까?
struct HikeDetail: View { let hike: Hike @State var dataToShow = \Hike.Observation.elevation var buttons = [ ("Elevation", \Hike.Observation.elevation), ("Heart Rate", \Hike.Observation.heartRate), ("Pace", \Hike.Observation.pace) ] var body: some View { VStack { HikeGraph(hike: hike, path: dataToShow) .frame(height: 200) HStack(spacing: 25) { ForEach(buttons, id: \.0) { value in Button { dataToShow = value.1 } label: { Text(value.0) .font(.system(size: 15)) .foregroundColor(value.1 == dataToShow ? .gray : .accentColor) .animation(nil) } } } } } }
ForEach의 id에 들어가야 할 부분은 buttons배열의 각 튜플 중 첫번째 원소이다. 해당 튜플이 몇 번째 원소인지는 알아서 정해줄꺼고, 우리가 명시해야할 것은 각 튜플의 타입이 되는 것이다.
따라서 답은 다음과 같다.
ForEach(buttons, id: \(String, WritableKeyPath<Hike.Observation, Range<Double>>).0) { ...
왜 생략하는지 알 것 같다.
Section 2. Add Animations to Individual Views
HikeView.swift 안에 요소를 수정해볼 것이다.
Lable요소에 자율적으로 animation 요소를 주면서 연습해보자.
/* See LICENSE folder for this sample’s licensing information. Abstract: A view displaying information about a hike, including an elevation graph. */ 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() } } if showDetail { HikeDetail(hike: hike) } } } } struct HikeView_Previews: PreviewProvider { static var previews: some View { VStack { HikeView(hike: ModelData().hikes[0]) .padding() Spacer() } } }
animation에 대해 자세히 알아보자.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) extension View { /// Applies the given animation to this view when the specified value /// changes. /// /// - Parameters: /// - animation: The animation to apply. If `animation` is `nil`, the view /// doesn't animate. /// - value: A value to monitor for changes. /// /// - Returns: A view that applies `animation` to this view whenever `value` /// changes. @inlinable public func animation<V>(_ animation: Animation?, value: V) -> some View where V : Equatable }
사실 value 없는 animation도 있지만 곧 deprecated 될 예정이라 이것만 소개한다.
Equatable을 채택하는 value에 대해 해당 값이 바뀌면 animation을 적용하는 View를 리턴한다. Animation에는 여러가지 이펙트들이 있다. 대표적으로 easeIn, easeOut, easeInOut, spring, 등등이 들어갈 수도 있으며, nil이 들어가면 Animation 효과를 주지 않는다.Section 3. Animate the Effects of State Changes
HikeView.swift를 다음과 같이 바꿔보자.
/* See LICENSE folder for this sample’s licensing information. Abstract: A view displaying information about a hike, including an elevation graph. */ 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.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) } } } } struct HikeView_Previews: PreviewProvider { static var previews: some View { VStack { HikeView(hike: ModelData().hikes[0]) .padding() Spacer() } } }
showDetail을 토글할 때(=버튼 클릭할 때) Animation을 함께 적용한다.
현재 showDetail State에 영향을 받는 뷰는 Label의 rotationEffect와 HikeDetail이다. showDetail의 값은 바로 바뀌지만 뷰들은 서서히 바뀌는 것을 볼 수 있다.Section 4. Customize View Transitions
지금까지 뷰가 나타나고 사라지는 애니메이션을 다뤘다면, 지금은 뷰의 전환 방식을 바꿔볼 차례이다.
HikeView.swift 코드를 다음과 같이 변경한다.
/* See LICENSE folder for this sample’s licensing information. Abstract: A view displaying information about a hike, including an elevation graph. */ import SwiftUI extension AnyTransition { static var moveAndFade: AnyTransition { .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(.moveAndFade) } } } } struct HikeView_Previews: PreviewProvider { static var previews: some View { VStack { HikeView(hike: ModelData().hikes[0]) .padding() Spacer() } } }
.asymmetric은 비대칭이라는 뜻이고, insertion에는 나타날 때, removal에는 사라질 때의 transition을 정의한다.
여기 코드에선 들어올때 trailing으로 서서히 불투명해지면서 들어오고,
사라질 때 크기를 줄이면서 서서히 투명해진다.Section 5. Compose Animations for Complex Effects
HikeGraph.swift 코드를 다음과 같이 바꾼다.
/* See LICENSE folder for this sample’s licensing information. Abstract: The elevation, heart rate, and pace of a hike plotted on a graph. */ import SwiftUI 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 } 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) } } }
역시 애니메이션이 적용되니 눈이 즐겁다. 꼭 한번 실행해서 즐겨보았으면 좋겠다.
Check Your Understanding
정답은? 1 3 2
반응형'iOS Dev > SwiftUI' 카테고리의 다른 글
[SwiftUI] 7. Working with UI Controls (0) 2023.05.06 [SwiftUI] 6. Composing Complex Interfaces (0) 2023.05.04 [SwiftUI] 4. Drawing Paths and Shapes (2) 2023.04.30 [SwiftUI] 3. Handling User Input (0) 2023.03.30 [SwiftUI] 2. Building Lists and Navigation (0) 2023.03.28