WidgetKit을 정복하자!
https://bangul-domato.tistory.com/60
위젯킷이란 무엇인가? 바로 앱에 진입하지 않고도 앱 콘텐츠를 사용할 수 있도록 하고, 앱의 컨텐츠를 한 눈에 알아볼 수 있도록 하며, 앱의 범위를 확장하는 데 도움을 줄 수 있는 FrameWork를 의미한다! 이번 NC2에서는 위젯킷을 활용하여 하나의 어플을 만드는 것이 학습 목표였다. 2주도 안되는 짧은 시간이었지만 정말 많은 배움을 얻었다고 생각한다. 특히 목적으로 삼은 MVP를 모두 완성시켰으며, 이번에는 스스로 생각해도 GPT를 정말 필요한 순간에만 잘 사용했다 싶어서 여러모로 만족이 큰 챌린지였다.
이번 챌린지에서는 오늘의 운세를 확인할 수 있는 앱을 만들었다. 위젯을 통해 홈 화면에서도 유저의 십이간지에 해당하는 운세를 확인할 수 있고, 위젯을 한 번 더 클릭하면 십이간지에 해당하는 이미지를 볼 수 있다. 앱의 메인 뷰에서는 십이간지를 선택할 수 있는 버튼과 오늘의 운세를 확인할 수 있는 헤더 뷰를 구현하였다. 운세는 Gemini API를 활용하여 헤더 뷰를 누를 때마다 새로운 운세가 나타날 수 있도록 하였다.
https://github.com/DeveloperAcademy-POSTECH/2024-NC2-M2-WidgetKit
1. App Group과 UserDefault
App Group이란 서로 공유하고 있는 여러 앱들 사이의 접근을 가능하게 해주는 기능이다. 이번 프로젝트에서는 UserDefault에 저장된 데이터를 메인 앱과 Widget Extension에 공유하는 용도로 주로 사용되었다.
App Group은 프로젝트 > Signing & Capabilities > Capability 에서 설정 가능하다. 기본적으로 'group. ~ ' 형식으로 지정한다. 이렇게 AppGroup을 설정해주는 이유는 메인 앱과 Widget이 사실상 별도의 앱이기 때문이다.
App Extension Programming Guide에서 알 수 있듯이, Widget과 메인 앱은 다른 container를 가지고 있으므로 둘 사이의 데이터 공유를 위해선 별도의 shared container가 필요하다. 그러므로 UserDefault를 활용하여 두 앱 사이의 데이터를 공유하고자 했다. UserDefault는 앱의 간단한 데이터를 저장하고 검색하는 데 사용하는 클래스로, 이를 활용해여 사용자가 앱을 종료하더라도 다시 시작하면 데이터를 유지할 수 있다. 이 UserDefault를 @AppStorage라는 프로퍼티 래퍼를 활용해 간편하게 연결하고, 데이터 변경시 뷰가 자동으로 업데이트 되도록 할 수 있다.
//2024_NC2_M2_WidgetKitApp
@main
struct _024_NC2_M2_WidgetKitApp: App {
var body: some Scene {
WindowGroup {
if let defaults = UserDefaults(suiteName: "group.com.~") {
MainView()
.defaultAppStorage(defaults)
} else {
Text("Failed to load UserDefaults")
}
}
}
}
// MainView
struct MainView: View {
...
@AppStorage("selectedImage") var selectedImage: String = ""
@AppStorage("selectedImage_Kor") var selectedImage_Kor: String = ""
...
}
//TodayFortune(Widget Extension)
struct Provider: AppIntentTimelineProvider {
...
private func getSelectedImage() -> String {
let defaults = UserDefaults(suiteName: "group.com.~")
return defaults?.string(forKey: "selectedImage") ?? "placeholder"
}
private func getSelectedImage_Kor() -> String {
let defaults = UserDefaults(suiteName: "group.com.~")
return defaults?.string(forKey: "selectedImage_Kor") ?? "placeholder"
}
...
}
AppStorage 를 사용하여 저장한 변수값을 UserDefault를 활용해 Widget Extension으로 넘겨주는 코드이다. UserDefault의 인수 suiteName 에는 생성한 App Group 주소를, return 값에는 forkey를 통해 서로 같은 변수임을 확인하고 데이터를 공유하는 형태임을 알 수 있다. @main에서 defaultAppStorage 모디파이어를 사용하여 UserDefaults의 객체를 MainView에 전달하고, 이를 통해 'MainView' 와 하위 뷰들이 기본 앱 저장소로 UserDefaults를 사용할 수 있게 된다.
2. App Intent
이번에 꼭 공부하고 싶었던 부분 중 하나는 App Intent 이다. 인터렉티브 위젯을 생성하기 위해선 App Intent가 반드시 필요하다. App Intent는 앱의 특정 작업이나 기능을 쉽게 호출하고 실행할 수 있도록 하는 기능으로 주로 Siri, Spotlight, Shortcuts와 같은 기능과 결합하여 사용할 수 있다. Interactive Widget에서 App Intent는 필수적인 요소이다. 위젯에서 button을 활용하여 Interact를 수행할 때, App Intent는 iOS가 필요할 때 해당 작업을 수행할 수 있도록 메인 앱의 작업을 시스템에 노출시킨다.
struct ToggleFortuneIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Fortune Display"
// intent가 호출될 때, 수행하는 메서드
func perform() async throws -> some IntentResult {
let defaults = UserDefaults(suiteName: "group.com. ~ ")
// 위젯 내에서 Fortune을 표시하는 토글 Intent
let isShowingFortune = defaults?.bool(forKey: "isShowingFortune") ?? true
defaults?.set(!isShowingFortune, forKey: "isShowingFortune")
WidgetCenter.shared.reloadTimelines(ofKind: "TodayFortune")
return .result()
}
}
// 위젯의 실제 View 정의
struct TodayFortuneEntryView : View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var widgetFamily
var body: some View {
switch widgetFamily {
case .systemSmall:
ZStack {
ContainerRelativeShape()
.fill(.white)
// Button에 action 대신 intent 인수를 넣음
Button(intent: ToggleFortuneIntent()){
VStack {
if !entry.isShowingFortune {
...
}
Button(_label:, intent:) 을 활용하여 Widget에 Interactivity를 구현하고자 했다. AppIntent 를 호출될 때 func perform() 이 실행된다. ToggleFortuneIntent() 함수를 호출하여 위젯에서 운세가 보이는 것을 토글 방식으로 구현하고자 했다. (지금에서야 다시 알게된 사실은,, Button Intent 방식 뿐만 아니라 Toggle Intent 방식도 Interactive Widget에서 제공하고 있었다는 점이다...! 걍 그걸로 할걸..!)
3. API
struct APIKey {
static var value: String {
guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "GeminiAPIKey") as? String else {
fatalError("API Key not found")
}
return apiKey
}
}
class APIModel: ObservableObject {
@Published var storyText: String = "Loading..."
private let model: GenerativeModel
init() {
self.model = GenerativeModel(name: "gemini-1.5-flash", apiKey: APIKey.value)
}
func fetchGemini(prompt: String) async {
do {
let response = try await model.generateContent(prompt)
if let text = response.text {
DispatchQueue.main.async {
self.storyText = text
}
} else {
DispatchQueue.main.async {
self.storyText = "Failed to load Gemini."
}
}
} catch {
DispatchQueue.main.async {
self.storyText = "Error: \(error.localizedDescription)"
}
}
}
}
//HeaderView
struct HeaderView: View {
...
var body: some View {
//prompt 변수를 활용해 Gemini에게 어떤 내용을 생성할 지 알려줌
let prompt = "\(selectedImage) 띠의 오늘의 운세를 한 줄로 알려줘"
// 오늘의 운세 설명 부분
Button(action: {
Task {
await apiModel.fetchGemini(prompt: prompt)
self.geminiFortune = apiModel.storyText
WidgetCenter.shared.reloadAllTimelines()
}
}, label: {
...
}
API를 사용하긴 했지만, 사실 설명할 수는 없다..ㅠ 다른 러너분에게 코드를 건네 받아서 적절하게 수정하여 사용하였기 때문이다. 어느정도 이해하고자 했으나 동기와 비동기 관련 내용은 아직 내게 어려운 지라 더 공부해보아야 할 것 같다. 다만, 이번에 task, await 라는 키워드에 대해 아주! 조금 찍먹하게 되어 그정도만 정리해두고자 한다.
메인 앱의 HeaderView에서 운세를 받고 나서 위젯으로 이동할 때 생겼던 문제는 HeaderView에 나타난 운세가 위젯으로 바로 적용되지 않는다는 점이었다. 'WidgetCenter.shared.reloadAllTimelines()' 코드가 잘못된 줄 알고 이리저리 옮겨 봤으나 그것도 문제는 아니었다. 원인조차 몰라 헤매고 있을 때, 다른 러너분께서 네트워크 문제일 수도 있다라고 설명하며 도움을 주셨다. API를 통해 받아온 운세가 변수에 저장되기도 전에 위젯을 reload하고 있기 때문에 이전 운세가 계속 나타나는 문제를 겪고 있다는 것이었다. 코드 중 task는 비동기 작업 단위를 표현하는 키워드인데, 이 사이에 await를 넣어 Gemini를 fetch하는 것을 지연시키고 변수에 저장될 수 있는 시간을 벌어준 다음 reload 하게 한다면 문제를 겪지 않을 것이라 말씀해주셨다. 감사하게도 코드를 수정해주신 이후 문제가 해결되었다. 이 부분은 기록만 남겨두고 나중에 더 공부해서 추가적으로 어떤 원리로 해결된 건지 작성해두어야 겠다.
4. Widget code
위젯을 생성하게 되면 기본적으로 제공하는 코드가 있다. 그 코드들이 어떤 역할을 하는지 간단하게만 짚어보고자 한다.
// Provider: 타임라인을 관리하는 클래스. AppIntentTimelineProvider를 사용하여 사용자 인터페이스의 상태를 변경할 수 있음
struct Provider: AppIntentTimelineProvider {
// placeholder: 위젯의 기본 모양을 정의
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), imageName: getSelectedImage(), selectedImage_Kor: getSelectedImage_Kor(), geminiFortune: getGeminiFortune(), isShowingFortune: isShowingFortune())
}
// snapshot: 위젯의 스냅샷을 생성
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: Date(), configuration: configuration, imageName: getSelectedImage(), selectedImage_Kor: getSelectedImage_Kor(), geminiFortune: getGeminiFortune(), isShowingFortune: isShowingFortune())
}
// timeline: 타임라인을 생성하여 위젯의 상태를 업데이트
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
// 현재 시간부터 시작하여 시간 단위로 5개의 항목을 생성
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration, imageName: getSelectedImage(), selectedImage_Kor: getSelectedImage_Kor(), geminiFortune: getGeminiFortune(), isShowingFortune: isShowingFortune())
entries.append(entry)
}
// 타임라인을 반환. .atEnd 정책은 타임라인이 끝날 때 위젯을 갱신.
return Timeline(entries: entries, policy: .atEnd)
}
...
}
// SimpleEntry: 타임라인 항목을 정의
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
...
}
// 위젯 자체를 정의
struct TodayFortune: Widget {
let kind: String = "TodayFortune"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
TodayFortuneEntryView(entry: entry)
}
.contentMarginsDisabled()
}
}
기술 블로그라기엔 내가 가진 지식 자체가 적어서 무언가 알려주긴 어렵지만..ㅎㅎ.. 그래도 이번 기술을 사용하면서 익힌 지식을 열심히 정리한 것 같아 뿌듯하다. 부족한 내용은 추후 채워나가기로 하며 이번 NC2 정말 고생했다~!
Cognitive task analysis (0) | 2024.07.01 |
---|---|
[BR4] AppleAcademy BR4 회고록 17주차 (0) | 2024.07.01 |
[NC2] AppleAcademy NC2 회고록 15주차 (1) | 2024.06.16 |
[BR3] AppleAcademy BR3 회고록 14주차 (0) | 2024.06.12 |
[MC2] AppleAcademy MC2 회고록 13주차 : It's Pilling Time (1) | 2024.06.10 |
댓글 영역