[SwiftUI] 상호 작용 가능한 위젯 만들기

 

이 글에서는 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가지

  1. Widgets are rendered in a separate process: 위젯이 보이면 코드는 실행되지 않음
  2. Changes are driven by timeline entries: 타임라인 엔트리를 업데이트해 위젯 콘텐츠를 바꿀 수 있음 ⇒ 이는 interactive 위젯도 마찬가지
  3. 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

  1. Only Button and Toggle with AppIntent are supported in widgets: App Intent를 사용하는 Button과 Toggle만 Interactive 위젯에서 지원함!!
  2. 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)
    }
}

 

  1. Use invalidatableContent(:) for values that are invalidated by the interaction: 해당 모디파이어는 신중하게 사용할 것. 변경 사항이 생기는 뷰에 전부 annotate할 필요는 없음. 의미 있는 뷰에 해당 모디파이어를 활용
  2. 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