관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - SwiftUI] 튜토리얼 - 7. 애니메이션 (Animation, spring, speed, delay, onTapGesture) 본문

iOS 튜토리얼 (SwiftUI)

[iOS - SwiftUI] 튜토리얼 - 7. 애니메이션 (Animation, spring, speed, delay, onTapGesture)

jake-kim 2022. 7. 9. 23:27

애니메이션에 사용할 모양 정의

  • SwiftUI에는 대표적으로 4가지의 모양을 쉽게 사용이 가능
    • Capsule()
    • Circle()
    • Rectangle()
    • RoundedRectangle(cornerSize:)
  var body: some View {
    Capsule()
      .fill(color)
      .frame(height: 70)
      
    Circle()
      .fill(.red)
      .frame(width: 100, height: 100)
    
    Rectangle()
      .fill(.green)
      .frame(width: 100, height: 100)
    
    RoundedRectangle(cornerSize: .init(width: 12, height: 12))
      .fill(.blue)
      .frame(width: 100, height: 100)
      
  }

Capsule()
Circle()
Rectangle
RoundedRectangle

  • 이 중에서 Capsule을 사용하여 모양을 구현
    • GraphCapsule이라는 struct로 정의
    • 사용하는쪽에서 값을 입력하면 그 값에 따라 모양이 정해지도록 구현
    • range값은 해당 Capsule의 range값을 의미하고, overallRange값은 전체 Capsule들을 한꺼번에 봤을때의 range값
import SwiftUI

struct GraphCapsule: View, Equatable {
  var index: Int
  var color: Color
  var height: CGFloat
  var range: Range<Double>
  var overallRange: Range<Double>
  
  var heightRatio: CGFloat {
    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)
  }
  
  func magnitude(of range: Range<Double>) -> Double {
    range.upperBound - range.lowerBound
  }
}

데이터 준비

  • json 데이터
    • Apple 의 튜토리얼 페이지에서 json 관련 데이터 참고
    • 등산을 갈때의 거리, 고도 등이 나와 있는 값
[
  {
    "name":"Lonesome Ridge Trail",
    "id":1001,
    "distance":4.5,
    "difficulty":3,
    "observations":[
      {
        "elevation":[
          291.65263635636268,
          309.26016677925196
        ],
        "pace":[
          396.08716481908732,
          403.68937873525232
        ],
        "heartRate":[
          117.16351898665887,
          121.95815455919609
        ],
        "distanceFromStart":0
      },
      {
        "elevation":[
          299.24001936628116,
          317.44584350790012
    ...
  • 위 데이터를 처리할 모델도 정의
    • SwiftUI에서의 모델은 항상 Identifiable을 상속받아서 id값도 같이 정해주는 것을 주의 (관련 내용 List 글 참고)
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>
  }
}
  • json to decoding하는 코드와, 로드하는 코드 추가
import Foundation

final class ModelData: ObservableObject {
  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)")
  }
}

애니메이션

  • 잔물결 애니메이션 구현
    • spring으로 스프링처럼 튕기는 애니메이션을 적용
extension Animation {
  // ripple: 잔물결
  static func ripple(index: Int) -> Animation {
    Animation.spring(dampingFraction: 0.5) // dampingFraction 튕기는 정도 0 ~ 1
      .speed(2)
      .delay(0.03 * Double(index))
  }
}
  • 예제 파일
import SwiftUI

struct Example: View {
  var body: some View {
    Text("Hello world")
      .font(.title)
  }
}

struct Example_Previews: PreviewProvider {
  static var previews: some View {
    Example()
  }
}
  • didTap이라는 상태를 두고, 이 상태는 onTapGesture할때 변경하도록 적용
    • VStack도 추가하여, VStack안의 모든 탭을 적용
struct Example: View {
  @State private var didTap = false // <-
  
  var body: some View {
    VStack {
      Text("Hello world")
        .font(.title)
    }
    .onTapGesture {
      didTap.toggle()
    }
  }
}
  • didTap 상태에 따라 rotationEffect를 적용

struct Example: View {
  @State private var didTap = false
  
  var body: some View {
    VStack {
      if didTap { // <-
        Text("Hello world")
          .font(.title)
          .rotationEffect(.degrees(30))
      } else {
        Text("Hello world")
          .font(.title)
      }
    }
    .onTapGesture {
      didTap.toggle()
    }
  }
}
  • 잔물결 (ripple) 애니메이션을 정의한 후 적용

extension Animation {
  // ripple: 잔물결
  static func ripple(index: Int) -> Animation {
    Animation.spring(dampingFraction: 0.5) // dampingFraction 튕기는 정도 0 ~ 1
      .speed(2)
      .delay(0.03 * Double(index))
  }
}

struct Example: View {
  @State private var didTap = false
  
  var body: some View {
    VStack {
      if didTap {
        Text("Hello world")
          .font(.title)
          .rotationEffect(.degrees(30))
      } else {
        Text("Hello world")
          .font(.title)
      }
    }
    .animation(.ripple(index: 1)) // <-
    .onTapGesture {
      didTap.toggle()
    }
  }
}

데이터를 표출할 뷰 구현

 

* 참고

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

Comments