개요
이 글은 WidgetKit에 대해 정리한 글이다
참고로 위젯은 기본적으로 인터렉션이 불가능하나, AppIntent를 이용하면 가능하다(SwiftUI Button은 action 대신 intent 인자도 가진다!)
AppIntent를 이용한 인터렉티브한 위젯에 대해서는 다음 기회에 알아보고
우선 App Group과 WidgetKit 구성요소들을 알아본다
이 글에서 핵심 키워드들은 다음과 같다
- App Group
- WidgetBundle
- Widget
- WidgetConfiguration
- Provider
- Entry
- EntryView(View)
- Provider
- WidgetConfiguration
- Widget
- WidgetCenter
App Group
App Group을 사용하면 같은 개발자가 만든 앱과 그 앱의 확장 기능들 간에 파일, 사용자 기본 설정, 데이터베이스 등을 공유할 수 있다(동일한 개발자 계정에 의해 서명된 앱들 간에만 작동)
특히 위젯이 네트워크를 통해 데이터를 받아 나타내는 경우가 아니라면, 일반적으로 앱의 데이터를 사용해야 하는데
이때 App Group이 중요한 역할을 한다(앱에 서명하려면 돈을 내라)
App Group은 크게 다음과 같은 특징들이 존재한다
- 데이터 공유 - 사용자는 동일한 데이터를 여러 앱 또는 위젯과 같은 확장 기능에서 사용 가능
- 공유 컨테이너 - App Group을 사용하면 여러 앱이 동일한 파일 시스템 디렉터리에 접근할 수 있는데, 이 디렉터리를 공유 컨테이너라함
- 안전성 - iOS는 각 App Group의 데이터 접근을 강력하게 통제하므로, 데이터는 동일한 App Group에 속하는 앱들 간에만 공유가능
App Group 설정 방법
- App Group - “Signing & Capabilities” → “+Capability” → “App Groups”
- App Group의 식별자는 group.으로 시작해야 한다(예를 들면 group.com.example.myapp과 같은 형태다)
데이터 저장 및 읽기
다음과 같이 App Group 식별자를 이용하여 다음과 같이 UserDefaults와 FileManager를 통해 데이터를 공유하며 처리할 수 있다
UserDefualts(suiteName:)
if let userDefaults = UserDefaults(suiteName: "group.com.example.myapp") {
userDefaults.set("!Some Shared Data!", forKey: "exampleKey")
let value = userDefaults.string(forKey: "exampleKey")
...
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)
if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.myapp") {
let fileURL = containerURL.appendingPathComponent("SharedFile.txt")
...
Widget 구성 요소
여러 위젯 관련 코드들을 찾아보니 다음과 같은 구조로 깔끔하게 코드를 작성하기 편했다
- WidgetBundle
- Widget
- Widget+Entry
- Widget+EntryView
- Widget+Provider
- WidgetBundle - 다수의 위젯 확장을 노출해 주는 요소다
- Widget - 홈스크린, 알림센터에서 표시되는 위젯의 WidgetConfiguration를 포함한다
- WidgetConfiguration - 데이터 전달 방식과 위젯 뷰를 정의하는 부분이다
- provider 인자로 데이터를 전달받는다
- entry를 클로저에서 전달받아 위젯에 사용될 EntryView에 전달한다(EntryView는 위젯으로 나타날 View)
- 참고로, EntryView는 다음 수정자가 설정되어야 올바르게 위젯으로 랜더링된다
- .containerBackground(.clear, for: .widget)
- WidgetConfiguration - 데이터 전달 방식과 위젯 뷰를 정의하는 부분이다
- Widget - 홈스크린, 알림센터에서 표시되는 위젯의 WidgetConfiguration를 포함한다
- Provider - 위젯에게 데이터를 전달해 준다
- Entry - 실제 데이터는 Entry 형식으로 맞춰서 정의한다, 만료날짜는 필요한 경우 설정하면 된다
- Timeline - TimelineProvider에 의해 제공되는 Entry는 데이터 업데이트 방식을 지정할 수 있다
- Entry - 실제 데이터는 Entry 형식으로 맞춰서 정의한다, 만료날짜는 필요한 경우 설정하면 된다
WidgetBundle
WidgetBundle의 body에서는 Widget들을 반환한다
이를 통해 WidgetBundle은 위젯 추가 시, 사용가능한 위젯들을 제공해 주는 역할을 한다
@main
struct TodoWidgetBundle: WidgetBundle {
var body: some Widget {
TodoWidget()
}
}
Widget - StaticConfiguration
Widget의 body에서는 위젯과 관련된 설정인 WidgetConfiguration을 반환한다
WidgetConfiguration 중 하나인 StaticConfiguration에는 데이터를 전달하는 방식인 Provider를 제공해야 한다
또, 이를 통해 전달한 entry를 EntryView에 전달할 수 있다
즉, Widget은 Provider로부터 Entry를 받고, 이를 EntryView에 전달하는 WidgetConfiguration을 반환한다
struct TodoWidget: Widget {
let kind: String = "TodoWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TodoWidgetEntryView(entry: entry)
}
.configurationDisplayName("Todo List")
.description("Display Todo List.")
}
}
Provider - TimelineProvider
Provider는 위젯에 Entry(데이터)를 전달하는 방식을 구현하는 역할이다
struct Provider: TimelineProvider {
/// 위젯이 처음 추가되거나 로딩 중일 때 표시할 임시 데이터를 제공
func placeholder(in context: Context) -> Entry {
Entry(date: Date(), data: .stub)
}
/// 위젯 갤러리에서 미리보기를 표시할 때 사용
func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {
let entry = Entry(date: Date(), data: .stub)
completion(entry)
}
/// 위젯의 실제 데이터와 업데이트 일정을 제공
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let entries = [Entry(date: Date(), data: getData())]
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
private getData() -> [DataType] {
/// 데이터를 불러오는 로직
}
}
Timeline
Timeline은 위젯의 데이터 업데이트 시기와 내용을 관리하는 역할이다
다시말해 위젯이 표시할 일련의 데이터 엔트리와 이 데이터를 언제 업데이트할지를 정의한다
init(
entries: [EntryType],
policy: TimelineReloadPolicy
)
Timeline은 TimelineProvider에서 completion 핸들러의 인자로 사용된다
TimelineReloadPolicy(업데이트 정책)에 대해서는 다음과 같이 3가지 존재한다
- .never
- 새 Timeline을 요청하지 않고 수동으로 위젯을 새로고침(WidgetCenter)
- .atEnd
- 현재 Timeline의 마지막 엔트리가 만료되면 새 Timeline을 요청(Entry의 .expirementDate 기준)
- .after(Date)
- 지정된 날짜 이후에 새 Timeline을 요청
struct Provider: TimelineProvider { /// placeholder, getSnapshot ... func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { let currentDate = Date() do { let data = try fetchData() let entry = Entry(date: currentDate, data: data) /// Timeline을 통해 1시간 뒤로 다음 업데이트를 지정 let nextUpdateDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! let timeline = Timeline(entries: [entry], policy: .after(nextUpdateDate)) completion(timeline) } catch { print("Error fetching data: \(error)") let errorEntry = Entry(date: currentDate, data: .unavailable) /// 오류 발생 시, Timeline을 통해 15분 뒤로 다음 업데이트를 지정 let retryDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)! let timeline = Timeline(entries: [errorEntry], policy: .after(retryDate)) completion(timeline) } } }
Entry - TimelineEntry
Provider를 통해 전달되는 데이터의 실체로, date 프로퍼티가 필요하다
struct Entry: TimelineEntry {
let date: Date
let data: [DataType]
}
WidgetCenter
만약 업데이트 정책을 .never로 한다면, 위젯 쪽에서는 업데이트를 시도하지 않을텐데
이때 WidgetCenter를 이용하면 앱에서 위젯의 업데이트를 수행시킬 수 있다
앱 코드는 다음과 같이 scenePhase 변화 시, SharedStorage 업데이트를 수행하고 Widget을 reload 한다
@main
struct TodoApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .background:
saveTodosToSharedStorage()
reloadWidget()
case .inactive:
saveTodosToSharedStorage()
reloadWidget()
default:
break
}
}
}
private func saveTodosToSharedStorage() {
let todos = TodoManager.shared.todos /// 예시: TodoManager에서 todos를 가져옴
if let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.todowidget") {
sharedDefaults.set(try? JSONEncoder().encode(todos), forKey: "todos")
}
}
private func reloadWidget() {
WidgetCenter.shared.reloadTimelines(ofKind: "TodoWidget")
}
}
위젯 코드에서 Provider는 WidgetCenter의 reloadTimelines를 통해 getTimeline이 수행되게 된다
struct Provider: TimelineProvider {
/// placeholder, getSnapshot ...
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let todos = loadTodosFromSharedStorage()
let entry = Entry(date: Date(), todos: todos)
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
private func loadTodosFromSharedStorage() -> [Todo] {
if let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.todowidget"),
let todoData = sharedDefaults.data(forKey: "todos"),
let todos = try? JSONDecoder().decode([Todo].self, from: todoData) {
return todos
}
return []
}
}
Provider 예시
Provider는 데이터를 불러와 Entry로 매핑하여 위젯에 전달하는 데 사용된다
이때 Provider에서 앱의 데이터를 불러오고 싶다면, App Group을 등록하여 앱과 위젯의 데이터 공유를 가능하게 할 수 있다
아래와 같이 UserDefaults나 FileManager를 사용 시, App Group을 설정하여 공유된 영역으로 데이터를 처리하는 방법을 통해 데이터를 공유할 수 있고,
SwiftData를 통해 데이터베이스를 공유하여 데이터를 공유할 수 있다
UserDefaults, FileManager 사용
import Foundation
/// UserDefualts for AppGroup
extension UserDefaults {
static let appGroup = UserDefaults(suiteName: Shared.appGroupName)!
}
/// FileManager for AppGroup
extension FileManager {
static let appGroupContainerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: Shared.appGroupName
)!
}
import WidgetKit
extension AppGroupWidget {
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> Entry {
.placeholder
}
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
completion(.placeholder)
}
/// Helpers를 통해 데이터를 불러오고, Entry로 래핑하여 위젯에 전달(completion)
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
let entry = Entry(
userDefaults: fromUserDefaults,
file: fromFile
)
completion(.init(entries: [entry], policy: .never))
}
}
}
// MARK: - UserDefualts, FileManager Helpers
extension AppGroupWidget.Provider {
private var fromUserDefaults: String {
UserDefaults.appGroup.string(forKey: Shared.userDefualtKey) ?? ""
}
private var fromFile: String {
let value = FileManager.loadStringFromFile(
filename: Shared.helloFilename
)
return value ?? ""
}
}
SwiftData 사용
extension SwiftDataWidget {
struct Provider: TimelineProvider {
/// SwiftData Container로 부터 ModelContext를 획득
private let modelContext = ModelContext(Self.container)
func placeholder(in context: Context) -> Entry {
.placeholder
}
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
completion(.placeholder)
}
/// Helper를 통해 데이터를 불러오고, Entry로 래핑하여 위젯에 전달(completion)
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
let items = fetch()
if items.isEmpty {
completion(.init(entries: [.empty], policy: .never))
return
}
let entry = Entry(item: items)
completion(.init(entries: [entry], policy: .never))
}
}
}
// MARK: - SwiftData ModelContainer
extension SwiftDataWidget.Provider {
private static let container: ModelContainer = {
do {
return try ModelContainer(for: Item.self)
} catch {
print("Failed to create ModelContainer: \(error)")
fatalError("\(error)")
}
}()
}
// MARK: - SwiftData Helper
extension SwiftDataWidget.Provider {
private func fetch() -> [Item] {
do {
return try modelContext.fetch(FetchDescriptor<Item>())
} catch {
return []
}
}
}
마무리
앱과 관련된 위젯을 만들기 위해, 데이터 공유가 필요한 경우 이를 위해 사용되는 App Group에 대해 정리했다
그리고 실제로 위젯을 만들 때, 핵심이 되는 구성요소들을 정리했고 이를 다시 요약해 보면 다음과 같다
WidgetBundle은 사용 가능한 Widget들을 전부 정의하는 데 사용된다
Widget은 WidgetConfiguration이 필요하며, StaticWidegetConfiguration의 경우 provider로 TimelineProvider를 사용한다
Provider는 위젯에게 데이터를 전달하는 데 사용되며, 데이터는 Entry로 래핑하여 전달한다
이때 TimelineProvider의 경우, TimelineEntry로 데이터를 정의한다
데이터가 업데이트하는 데는 정책이 필요하며, TimelineProvider의 경우 Timeline을 통해 어떻게 데이터 업데이트를 발생시킬지 정의한다
업데이트 정책을 사용하지 않는 경우, 외부에서 WidgetCenter를 통해 위젯 업데이트를 강제할 수 있다
위젯은 기본적으로 인터렉티브 하지 않지만, AppIntent를 이용하면 약간 상호작용 가능하게 할 수 있는데, 이에 대해서는 좀 더 나중에 정리할 것이다
'iOS+' 카테고리의 다른 글
Xcode Cloud와 Sparkle framework (2) | 2024.09.29 |
---|---|
Objective-C (1) | 2024.09.22 |
앱 배포 관련 정리(macOS) (1) | 2024.08.31 |
Cannot preview in this file (0) | 2024.08.18 |
UserNotifications (0) | 2024.08.11 |