이 글에서는 Apple Developer 사이트의 WWDC23 영상을 참고해 위젯에 애니메이션을 적용하는 방법과, Interactive Widget(상호 작용 위젯)을 만드는 법을 알아 볼 예정이다.
영상은 크게 Ainmation과 Interactivity의 두 파트로 나눠져 있다. 해당 영상의 구성에 따라 글을 정리하겠다.
Animation
앱과 위젯의 차이
일반적인 SwiftUI 앱
- withAnimation같은 모디파이어를 사용해서 State의 변경을 통해 애니메이션을 구동
- 일반적인 SwiftUI 앱에서는 @State 변수를 이용해 View를 바꿀 수 있음
위젯
- 위젯에는 State가 없음
- 대신 엔트리로 구성되는 타임라인을 생성: 엔트리는 특정 시간에 렌더링된 각기 다른 뷰와 대응하고, SwiftUI는 엔트리 간의 같거나 다른 부분을 판단하고 바뀐 부분을 애니메이팅함
- 위젯의 background정의: .containerBackground 모디파이어를 사용
.containerBackground(for: .widget) {
Color.cosmicLatte
}
- 새로운 Preview API를 이용해 엔트리가 변경됨에 따라 위젯이 어떻게 애니메이팅 되는지 빠르게 확인 할 수 있음
#Preview(as: WidgetFamily.systemSmall) {
CaffeineTrackerWidget()
} timeline: {
CaffeineLogEntry.log1
CaffeineLogEntry.log2
CaffeineLogEntry.log3
CaffeineLogEntry.log4
}
위젯의 애니메이션 조정
텍스트에 애니메이션 효과주기
- 뷰는 그대로 있고 텍스트(숫자)가 변화됨에 따라 해당 텍스트에만 애니메이션 효과를 주고싶을 때 .contentTransition 모디파이어 사용 ⇒ 중요한 숫자 값이 바뀔 때 강조할 수 있도록 특별히 고안된 것
struct TotalCaffeineView: View {
let totalCaffeine: Measurement<UnitMass>
var body: some View {
VStack(alignment: .leading) {
Text("Total Caffeine")
.font(.caption)
// HERE!!
Text(totalCaffeine.formatted())
.font(.title)
.minimumScaleFactor(0.8)
.contentTransition(.numericText(value: totalCaffeine.value))
}
.foregroundColor(.espresso)
.bold()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
새로운 음료 추가를 강조하는 Transition 추가
- 먼저 id모디파이어를 이용해 View의 identity와 View가 렌더링하는 특정 로그를 연결 ⇒ 이렇게 하면 log가 바뀔 때마다 새로운 View가 있고, 해당 View로 전환해야 한다고 SwiftUI에게 알릴 수 있음
- .transition(.push(from: .bottom)) 모디파이어를 통해 밑에서부터 push해 올라오는 애니메이션을 줄 수 있음
struct LastDrinkView: View {
let log: CaffeineLog
var body: some View {
VStack(alignment: .leading) {
Text(log.drink.name)
.bold()
Text("\(log.date, format: Self.dateFormatStyle) · \(caffeineAmount)")
}
.font(.caption)
// HERE!!
.id(log)
.transition(.push(from: .bottom))
}
var caffeineAmount: String {
log.drink.caffeine.formatted()
}
static var dateFormatStyle = Date.FormatStyle(
date: .omitted, time: .shortened)
}
애니메이션 모디파이어 사용
- -일반적으로 사용하는 .animation 모디파이어의 value에 log값을 넣어주게 되면, 해당 애니메이션을 로그값에 바인딩할 수 있음 ⇒ 애니메이션과 카페인 섭취 상태를 일치하게 함
struct LastDrinkView: View {
let log: CaffeineLog
var body: some View {
VStack(alignment: .leading) {
Text(log.drink.name)
.bold()
Text("\(log.date, format: Self.dateFormatStyle) · \(caffeineAmount)")
}
.font(.caption)
.id(log)
.transition(.push(from: .bottom))
// HERE!!
.animation(.smooth(duration: 1.8), value: log)
}
var caffeineAmount: String {
log.drink.caffeine.formatted()
}
static var dateFormatStyle = Date.FormatStyle(
date: .omitted, time: .shortened)
}
Interactivity
위젯 작동 방식에 대한 아키텍처
위젯 작동 방식
- 위젯을 생성할 때 Widget Extension을 정의: 이는 시스템에서 검색하고, 독립적인 프로세스로 실행함
- 위젯은 사실상 위젯의 모델인 일련의 엔트리를 반환하는 timeline provider를 정의: 위젯이 보이면 시스템은 위젯 extension 프로세스를 시작하고, timeline provider에게 엔트리를 요청
- 이 엔트리는 위젯구성의 일부인 뷰 빌더로 피드백되어 해당 엔트리에 근거해 일련의 뷰를 생성하는 데 사용됨. 이후 시스템은 이런 뷰의 표현을 생성하고, 디스크에 아카이빙함
- 특정 엔트리를 표시할 때가 되면 시스템은 아카이빙된 위젯 representation을 프로세스에서 디코딩하고 렌더링함
- 뷰 코드는 아카이빙 중에만 실행됨. 해당 뷰의 별도 표현은 시스템 프로세스에서 렌더링
- 하지만 데이터가 정적이지 않다면 해당 엔트리를 업데이트 해야 함: 위젯에 표시되는 데이터를 업데이트할 때마다 앱에서 reloadTimelines 함수를 호출하면 됨 ⇒ 이렇게 하면 위의 프로세스를 반복하고 새 엔트리를 재생성하며, 새로운 뷰의 사본을 디스크에 아카이빙함
WidgetCenter.shared.reloadTimelines(ofKind: "LocationForecast")
위젯 작동 방식 아키텍처에서 중요한 3가지
- Widgets are rendered in a separate process: 위젯이 보이면 코드는 실행되지 않음
- Changes are driven by timeline entries: 타임라인 엔트리를 업데이트해 위젯 콘텐츠를 바꿀 수 있음 ⇒ 이는 interactive 위젯도 마찬가지
- Reloads from interactions are guaranteed: 일반적으로 위젯 업데이트는 최선의 노력으로 수행되지만 상호 작용에서 시작된 새로고침은 항상 발생함
Interactivity를 추가하는 법
위젯에 상호작용성을 추가할 때 유의점
- Button과 Toggle처엄 익숙한 컨트롤을 사용해 위젯의 일부를 상호 작용하도록 만들 수 있음
- 다만 위젯은 다른 프로세스에서 랜더링되므로 SwiftUI는 프로세스 공간에서 클로저를 실행하거나 바인딩을 mutate하지 못함!!
- 따라서 위젯 extension으로 실행하고 시스템에서 호출하는 동작을 표현하는 방법이 필요함 ⇒ App Intents
App Intents
- App Intents: 시스템에서 실행 가능한 동작을 코드로 정의하도록 허용하는 프로토콜
- Intent는 여러 파라미터를 입력값으로 정의함
- Intent를 실행하는 비즈니스 로직이 있는 비동기(async)함수 perform도 정의함
- UI에서 App Intent를 바로 실행할 수 있도록 SwiftUI와 App Intent를 모두 import하는 경우, Button과 Toggle에 새로운 이니셜라이저를 적용할 수 있도록 함 ⇒ 컨트롤이 상호 작용 할 때 App Intent를 인수로 받고 해당 Intent를 실행
import SwiftUI
import AppIntents
extension Button {
public init<I: AppIntent>(
intent: I,
@ViewBuilder label: () -> Label
)
}
extension Toggle {
public init<I: AppIntent>(
isOn: Bool, intent: I,
@ViewBuilder label: () -> Label
)
}
AppIntent controls
- Only Button and Toggle with AppIntent are supported in widgets: App Intent를 사용하는 Button과 Toggle만 Interactive 위젯에서 지원함!!
- Work on regular apps: 이러한 이니셜라이저는 앱에서도 잘 작동함 ⇒ App Intent 로직을 위젯과 앱 사이에 공유할 수 있다는 점이 유용한 점
Interactivity 적용
App Intent 정의
- 먼저 새로운 음료를 log할 수 있도록 App Intent를 따르는 유형을 정의
- perform은 비동기 함수: 여기서도 로그 작성 작업을 awaiting할 때 해당 함수를 활용함 ⇒ perform에서 return하자마자 시스템이 위젯 타임라인을 곧바로 새로고침하면서 위젯 콘텐츠를 업데이트할 기회를 제공할 것
- perform에서 return하기 전에 업데이특된 위젯 새로 고침에 필요한 정보가 모두 잘 있는지 꼭 확인할 것
- @Parameter 프로퍼티 래퍼를 사용해 저장된 프로퍼티를 추가하고, 모든 파라미터를 채우는 이니셜라이저를 추가 ⇒ await DrinksLogStore.shared.log(drink: drink)
- @Parameter 프로퍼티를 사용해야(annotate되고 stored된 프로퍼티만) 지속적으로 유지되며 위젯 extension에서 Intent를 수행할 때 사용할 수 있음
import AppIntents
struct LogDrinkIntent: AppIntent {
static var title: LocalizedStringResource = "Log a drink"
static var description = IntentDescription("Log a drink and its caffeine amount.")
@Parameter(title: "Drink", optionsProvider: DrinksOptionsProvider())
var drink: Drink
init() {}
init(drink: Drink) {
self.drink = drink
}
func perform() async throws -> some IntentResult {
await DrinksLogStore.shared.log(drink: drink)
return .result()
}
}
위젯에 버튼 추가
- 버튼이 있는 view(LogDrinkView)를 새로 생성: 이 view에서는 App Intent를 취하는 버튼 이니셜라이저를 사용해 방금 정의한 걸 전달함
- 위젯의 나머지 부분에 해당 view를 추가
struct LogDrinkView: View {
var body: some View {
Button(intent: LogDrinkIntent(drink: .espresso)) {
Label("Espresso", systemImage: "plus")
.font(.caption)
}
.tint(.espresso)
}
}
⇒ +Espresso 버튼을 누르면 바로 마지막으로 마신 Espresso가 로깅 됨
Handling update latency
- App Intent가 작동을 멈추면 위젯이 타임라인을 새로 고침할 것: 이로 인해 동작이 실행되고 UI에 변경 사항이 반영될 때까지 지연시간이 발생할 수 있음
- .invalidatableContent 모디파이어 사용: 예시의 위젯에서는 업데이트된 엔트리가 도착하기 전까지 totalCaffeine값은 업데이트 되지 않을 것임
struct TotalCaffeineView: View {
let totalCaffeine: Measurement<UnitMass>
var body: some View {
VStack(alignment: .leading) {
Text("Total Caffeine")
.font(.caption)
Text(totalCaffeine.formatted())
.font(.title)
.minimumScaleFactor(0.8)
.contentTransition(.numericText(value: totalCaffeine.value))
// HERE!!
.invalidatableContent()
}
.foregroundColor(.espresso)
.bold()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
- Use invalidatableContent(:) for values that are invalidated by the interaction: 해당 모디파이어는 신중하게 사용할 것. 변경 사항이 생기는 뷰에 전부 annotate할 필요는 없음. 의미 있는 뷰에 해당 모디파이어를 활용
- Use Toggle to optimistically update a boolean state: Toggle은 더 나아가 상호작용할 때 프레젠테이션을 최적으로 업데이트 함. 즉, 위젯 extension으로 왕복 이동할 때까지 대기할 필요 X
※ 고유한 토글 스타일을 정의하는 경우 스타일에서 configuration isOn 프로퍼티를 확인하고, appearance를 바꾸는 데 사용해야 함
struct TodoToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
Image(systemName: configuration.isOn ? "largecircle.fill.circle" : "circle")
.foregroundColor (configuration. isOn ? .blue : .gray)
configuration.label
.truncationMode (.tail)
}
}
}
출처
https://developer.apple.com/videos/play/wwdc2023/10028/?time=910
'I'm Tech' 카테고리의 다른 글
[Github] 깃허브를 사용한 협업 개발 (1) | 2024.06.02 |
---|