일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 리액트 네이티브
- react-native-fast-image
- 360도 이미지
- 360도 뷰어
- SwiftUI
- 파노라마 뷰
- requirenativecomponent
- 뷰 정체성
- react
- ios
- 라이브러리 없이
- 3b52.1
- 리액트
- launchscreen
- 스켈레톤 통합
- React-Native
- privacyinfo.plist
- 뷰 생명주기
- 명시적 정체성
- 네이티브
- Android
- 구조적 정체성
- launch screen
- 앱 성능 개선
- ssot
- 360도 이미지 뷰어
- native
- panorama view
- React Native
- data driven construct
- Today
- Total
Neoself의 기술 블로그
WidgetKit 활용해 캘린더 위젯 구현하기 본문
이 글은 제가 개발한 TyTE 앱의 캘린더 위젯 구현 과정을 공유하기 위해 작성한 글입니다. WidgetKit을 처음 사용하고자 하는 개발자에게 도움이 되었으면 합니다.
1. WidgetKit의 핵심 구성요소 이해
struct CalendarWidget: Widget {
let kind: String = "CalendarWidget"
var body: some WidgetConfiguration {
...
}
}
가장 먼저 위젯을 구현하기 위해서는 아래 요소들로 구성된 Widget 프로토콜을 최상단 뷰에 상속해야합니다.
- 필수) WidgetConfiguration을 반환하는 body 속성
- 필수) kind 속성: 위젯의 고유 식별자 역할 / 인스턴스 구분, 상태 관리, 업데이트 처리, 앱 그룹 간 위젯 공유를 위해 사용
- 선택) description 속성: 위젯 갤러리에서 보여질 설명
1.1 WidgetConfiguration
WidgetKit은 두 가지 Configuration 타입을 제공합니다:
1. StaticConfiguration
- 사용자 설정이 필요 없는 단순 위젯
- 주기적인 업데이트나 고정된 데이터 표시에 적합
StaticConfiguration(
kind: kind,
provider: Provider()
) { entry in
CalendarWidgetEntryView(entry: entry)
}
2. IntentConfiguration
- 사용자가 구성 할 수 있는 프로퍼티가 있는 위젯 (ex. 보여지는 정보 성격 변경 등)
IntentConfiguration(
kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()
) { entry in
CalendarWidgetEntryView(entry: entry)
}
1.2 Timeline Provider
Provider는 위젯의 데이터 업데이트 타이밍과 방식을 결정하며, 아래 세 가지 필수 메서드를 구현해야 합니다:
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> CalendarEntry {
// 데이터 로딩 전 실제 위젯의 임시 데이터
}
func getSnapshot(in context: Context, completion: @escaping (CalendarEntry) -> ()) {
// 위젯 갤러리 미리보기용 데이터
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarEntry>) -> ()) {
// 실제 데이터 업데이트 로직
}
}
1. placeholder:
- 위젯이 처음 로드될 때 표시될 임시 데이터 제공
- 실제 데이터 로드 전까지 사용자에게 위젯의 레이아웃과 디자인을 보여줌
- 현재 월의 날짜별 더미 데이터를 생성하여 로딩 중에도 자연스러운 UI 표시
2. getSnapshot:
- 위젯 갤러리에서 보여질 미리보기 데이터 생성
- 실제 데이터와 유사한 형태의 더미 데이터를 제공하여 위젯의 기능과 디자인을 직관적으로 전달
3. getTimeline
- 실제 위젯에 표시될 데이터를 비동기로 가져오고 업데이트하는 핵심 메서드
여기서 저는 Placeholder와 getSnapshot 메서드 차이가 잘 와닿지 않았는데요. getSnapshot와 placeholder 모두 최종적으로 보여야할 위젯 값이 아니라는 공통점이 있지만, placeholder는 입력창에 값이 채워지지 않을때 띄워지는 기본값을 의미하듯 데이터 로드 전까지 실제 위젯에서 사용되는 값인 반면, getSnapshot은 오로지 위젯을 추가하고자 할때 미리보기 사진으로만 사용된다고 이해하면 편합니다.
1.3 TimelineEntry
TimelineEntry는 위젯에서 표시할 데이터의 상태를 나타내는 프로토콜입니다.
public protocol TimelineEntry {
var date: Date { get } // 필수 구현
}
데이터의 타임스탬프 명시 및 위젯 업데이트 시점 설정를 위해 date 프로퍼티를 필수로 정의해주어야 합니다.
// getTimeline 메서드
struct ExampleEntry: TimelineEntry {
let date: Date
let exampleText: String
}
let timeline = Timeline(
entries: [ExampleEntry(date: Date(), exampleText: "hello")],
policy: .never // 업데이트 정책
)
completion(timeline) // Entry 반환
위 코드는 getTimeline 메서드에서 Entry를 반환하는 로직인데요.TimelineEntry 자체를 반환하지 않고, Timeline 객체를 감싸서 반환하는 것을 볼 수 있습니다. 이 Timeline 객체내부 policy 인자를 통해 위젯의 업데이트 시점을 명시해줄 수 있습니다.
업데이트 정책들
- .atEnd: 마지막 entry 이후 자동 업데이트
- .after(date): 지정된 시간 이후 업데이트
- .never: 수동 업데이트만 허용
1.4 WidgetView
위 요소들에 대한 구현이 완료되었을 경우, 앞서 다루었던 entry 데이터를 활용해 실제로 사용자에게 보일 위젯 UI를 구현하면 됩니다. widgetFamily 환경변수를 참조해 위젯 사이즈에 따라 다른 뷰를 보이게끔 구성할 수도 있습니다.
struct CalendarWidgetEntryView: View {
// 1. 위젯 크기 감지
@Environment(\.widgetFamily) var family
// 2. Timeline Provider로부터 데이터 수신
var entry: Provider.Entry
// 3. 크기별 레이아웃 대응
var body: some View {
switch family {
case .systemLarge:
monthlyCalendarView // 월간
case .systemMedium:
weeklyCalendarView // 주간
default:
dailyView // 일간
}
}
}
iOS 환경에서의 SwiftUI와 달리 WidgetUI 구현 시에는 애니메이션이 제한되며, 인터렉션 또한 제한되기 때문에, 이러한 제약사항들을 고려해 뷰 설계가 필요합니다.
2. 실제 캘린더 위젯 구현 코드
2.1 위젯 구현
struct CalendarWidget: Widget {
// 1. kind 속성 (필수)
let kind: String = "CalendarWidget"
// 2. WidgetConfiguration을 반환하는 body (필수)
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: Provider()
) { entry in
CalendarWidgetEntryView(entry: entry)
}
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) // 지원하는 위젯 크기들
}
}
제가 만들고자 했던 위젯은 깃허브 잔디와 같이 사용자의 일별 생산성지수들을 한눈에 볼수있도록 하는 위젯이였습니다.
이는 런타임간에 실시간 뷰 업데이트가 불필요했기 때문에 StaticConfiguration 타입을 채택하였으며, 소, 중, 대 3가지 사이즈에 대한 위젯 레이아웃을 모두 구현하기 위해 supportedFamilies 뷰 수정자 내부에 3개 열거형을 모두 명시하였습니다.
2.2 데이터 모델
CalendarEntry의 경우, 위젯의 세 가지 크기(월간, 주간, 일간)에 필요한 모든 데이터를 포함하도록 설계했습니다:
struct CalendarEntry: TimelineEntry {
let date: Date // 기준이 되는 현재 날짜
let dailyStats: [DailyStat] // 생산성 통계 데이터 배열
let isLoggedIn: Bool // 사용자 로그인 상태
}
struct DailyStat: Codable, Identifiable {
let id: String
...
}
이 구조를 통해 아래 케이스들을 모두 대응하고자 했습니다.
- Large 위젯: dailyStats 전체를 활용한 월간 통계 표시
- Medium 위젯: date 기준 현재 주의 dailyStats 데이터 표시
- Small 위젯: date에 해당하는 단일 dailyStat 표시
또한 isLoggedIn을 통해 인증된 사용자에게만 데이터를 표시하도록 제어하고자 했습니다.
2.3 Timeline Provider 및 reloadTimelines 메서드를 통한 위젯 상태 업데이트 로직 구현
제 위젯의 데이터는 크게 두 시점에서 업데이트가 필요했습니다.
- 로그인/로그아웃 시
- Todo 데이터 변경 시
// UserDefaultsManager
class UserDefaultsManager {
func login() {
isLoggedIn = true
WidgetCenter.shared.reloadTimelines(ofKind: "CalendarWidget")
}
func logout() {
isLoggedIn = false
WidgetCenter.shared.reloadTimelines(ofKind: "CalendarWidget")
}
}
// HomeViewModel
class HomeViewModel: ObservableObject {
private func getDailyStatForDate(_ deadline: String) {
dailyStatService.fetchDailyStat(for: deadline)
.sink { ... } receiveValue: { [weak self] dailyStat in
// 데이터 업데이트 후 위젯 갱신
WidgetCenter.shared.reloadTimelines(ofKind: "CalendarWidget")
}
}
}
WidgetKit는 Widget 타겟 외부에서 위젯을 갱신시키는 reloadTimelines 메서드를 제공합니다. 이를 활용해, 위젯의 데이터갱신이 필요한 시점에만 해당 메서드를 호출해 위젯 업데이트를 트리거하였습니다.
struct Provider: TimelineProvider {
// 위젯 로딩 중 표시할 플레이스홀더 데이터
func placeholder(in context: Context) -> CalendarEntry {
return CalendarEntry(
date: Date(),
dailyStats: /* 실제 데이터 fetch를 통해 가져온 데이터 */,
isLoggedIn: true
)
}
// 위젯 갤러리에서 보여질 미리보기 데이터
func getSnapshot(in context: Context, completion: @escaping (CalendarEntry) -> ()) {
let entry = CalendarEntry(
date: Date(),
dailyStats: /* 더미 데이터 */,
isLoggedIn: true
)
completion(entry)
}
// 실제 위젯 데이터 업데이트 로직
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarEntry>) -> ()) {
// 사용자 인증 관련 정보 유무 파악 후, 없을 경우 isLoggedIn:false인 Entry 조기 반환
Task {
do {
let dailyStats = /* 네트워크 통신이나 영구저장소 접근을 통해 가져온 데이터 */
let timeline = Timeline(
entries: [CalendarEntry(date: Date(), dailyStats: dailyStats, isLoggedIn: true)],
policy: .never
)
completion(timeline)
} catch {
// 네트워크 통신 에러 처리 -> isLoggedIn: false인 Entry 반환
completion(Timeline(
entries: [CalendarEntry(date: Date(), dailyStats: [], isLoggedIn: false)],
policy: .never
))
}
}
}
}
앞서 말씀드렸던 경우를 제외하면, 위젯 자체에서는 정기적인 상태 업데이트가 불필요했습니다. 따라서 Timeline.policy를 .never로 설정해 위젯의 불필요한 업데이트를 최소화하였습니다.
2.4 위젯 뷰 구현
struct CalendarWidgetEntryView : View {
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
var body: some View {
if entry.isLoggedIn {
if family == .systemLarge {
// 큰 사이즈의 위젯 UI
} else if family == .systemMedium {
// 중간 사이즈의 위젯 UI
} else {
// 작은 사이즈의 위젯 UI
}
} else {
// 사용자 인증 안되었을때 표시되는 뷰
}
}
}
마지막으로, 앞서 명시했던 3가지 사이즈의 위젯 뷰 중 하나를 widgetFamily 환경변수값에 따라 조건부 렌더하게끔 body 속성을 구현하였습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
CoreData를 활용한 오프라인 동기화 시스템 구축하기 (1) | 2024.12.30 |
---|---|
전동 킥보드 대여 서비스 iOS 앱 개발기 (0) | 2024.12.25 |
SwiftUI 심층정리 (0) | 2024.12.16 |
Combine에서 Swift Concurrency로의 전환기 (1) | 2024.12.05 |
EnvironmentObject로 인한 SwiftUI 뷰 재구성 이슈와 해결 과정 (1) | 2024.12.02 |