WidgetKit (13)
Concurrency & Tinted 적용 (iOS 18)
이전에 MainActor를 사용 하면서 뜬 Warning이 있다.
바로 이것.
이전에 에러가 떴던 이유는 CoreData를 사용할때
1
2
3
4
@MainActor func fetchDays() -> [Day] {
// 중간 생략..
return try! container.mainContext.fetch(descriptor)
}
바로 여기서 mainContext를 사용했기 때문
그렇기에 해당 경고가 떴던 것.
지금은 그게 아니기에 @MainActor
를 전부 지워준다.
1
2
3
4
5
6
7
8
func getSnapshot(in context: Context, completion: @escaping (CalendarEntry) -> ()) {
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
}
func fetchDays() -> [Day] {
}
tinted는 .widgetAccentable()
이것만 적용해주면 되기에 생략.
Custom Modifier 만들기
새롭게 파일을 만들고 다음과 같이한다.
이건 widgetAccentable을 적용할지말지에 대해서 Custom Modifier를 만드는 과정
1
2
3
4
5
6
7
8
9
10
11
struct DidStudyAccent: ViewModifier {
let didStudy: Bool
func body(content: Content) -> some View {
if didStudy {
content.widgetAccentable()
} else {
content
}
}
}
이때 버전에 맞게 적용을 한다고 하면
1
2
3
4
5
6
7
8
9
10
11
func body(content: Content) -> some View {
if didStudy {
if #available(iOS 16, *) {
content.widgetAccentable()
} else {
content
}
} else {
content
}
}
이렇게 하면 된다.
그리고 extension을 사용하여
1
2
3
4
5
extension View {
public func didStudyAccentable(_ didStudy: Bool) -> some View {
modifier(DidStudyAccent(didStudy: didStudy))
}
}
이건 modifer를 리턴하는데 true / false에 따라 widgetAccentable 여부를 결정
그리고
1
2
3
4
5
6
7
8
9
10
11
Text(day.date.formatted(.dateTime.day()))
.font(.caption2)
.bold()
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
.background {
Circle()
.foregroundStyle(.orange.opacity(day.didStudy ? 0.3 : 0.0))
.scaleEffect(1.5)
}
.didStudyAccentable(day.didStudy)
이렇게 적용해준다.
그러면 날짜에서 공부여부에 따라 다르게 표시된다.
Control Widget (iOS 18)
Control Widget의 내용은 여기
Docs에서는
The configuration and content of a control widget to display in system spaces such as Control Center, the Lock Screen, and the Action Button.
제어 센터, 잠금 화면, 작업 버튼 등의 시스템 공간에 표시할 제어 위젯의 구성과 콘텐츠.
로 정의한다.
참고글도 읽어보면 좋을듯.
SwiftCalControlWidget 만들기
SwiftCalControl이라는 파일을 만들고 위젯을 담당하는 struct 를 그대로 복사해서 가져온다.
아래의 코드다.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct SwiftCalWidget: Widget {
let kind: String = "SwiftCalWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
SwiftCalWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Swift Study Calendar")
.description("Track days you study Swift with streaks.")
.supportedFamilies([.systemMedium])
}
}
우선 위의 코드를 좀 수정을 해야한다.
1
2
3
4
5
6
StaticControlConfiguration(kind: kind) {
ControlWidgetToggle(<#T##title: StringProtocol##StringProtocol#>,
isOn: <#T##Bool#>,
action: <#T##SetValueIntent#>,
valueLabel: <#T##(Bool) -> View#>)
}
이렇게 4개를 입력 해줘야 한다.
마지막 valueLabel은 Closure형태로 전환.
Day Extension
Day Extenstion에
1
2
3
var startOfDay: Date {
Calendar.current.dateInterval(of: .day, for: self)!.start
}
하루의 시작을 알려주는 녀석이다.
Persistence에 오늘 추가.
그리고 오늘을 나타내는 걸 Persistence에 만들어준다.
1
2
3
4
5
6
7
8
static var currentDay: Day? {
let context = ModelContext(Persistence.container)
let today = Date()
let predicate = #Predicate<Day> { $0.date == today.startOfDay }
let descriptor = FetchDescriptor(predicate: predicate)
return try? context.fetch(descriptor).first
}
AppIntent 추가
이후, ControlStudyIntent를 만들어준다.
내용은 ToggleStudyIntent와 유사.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ControlToggleStudyIntent: SetValueIntent {
static var title: LocalizedStringResource = "Control Toggle Studied"
@Parameter(title: "Did Study")
var value: Bool
func perform() async throws -> some IntentResult {
let context = ModelContext(Persistence.container)
let today = Date()
let predicate = #Predicate<Day> { $0.date == today.startOfDay }
let descriptor = FetchDescriptor(predicate: predicate)
guard let day = try! context.fetch(descriptor).first else {
return .result()
}
day.didStudy = value
try! context.save()
return .result()
}
}
SwiftCalControlWidget에 내용 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct SwiftCalControl: ControlWidget {
let kind: String = "SwiftCalControl"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: kind) {
ControlWidgetToggle("Study Swift",
isOn: Persistence.currentDay?.didStudy ?? false,
action: ControlToggleStudyIntent()) { isOn in
Label(isOn ? "Studied Swift" : "Study Swift", systemImage: isOn ? "checkmark.circle" : "swift")
}
.tint(.orange)
}
.displayName("Swift Study Today")
.description("Mark that you studied Swift today.")
}
}
그동안에 만든 것들을 적용할 차례이다.
- title
- isOn
- isOn은 Bool 값이 들어간다.
- true / false에 따라 작동
- 여기서는 우리는 그낭 공부를 했는지 안했는지에 대해 적용을 한다. (위젯과 동일)
- action
- isOn의 작동에 따라 영향을 주는 행동
- SetValueIntent 프로토콜을 따라야함
- valueLabel
- Closure의 형태로 전환해서 사용하며
- UIDesign 역할
WidgetBundle에 ControlWidget 추가
이후 Bundle에 추가
1
2
3
4
5
6
7
@main
struct SwiftCalWidgetBundle: WidgetBundle {
var body: some Widget {
SwiftCalWidget()
SwiftCalControl()
}
}
이후 실행을 하면
이렇게 나오지만
아직 widget과 연동은 되어있지 않다.
Widget 상호 연동
AppIntents 파일로 가서
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import WidgetKit
struct ToggleStudyIntent: AppIntent {
func perform() async throws -> some IntentResult {
ControlCenter.shared.reloadControls(ofKind: "SwiftCalControl") // new
return .result()
}
}
struct ControlToggleStudyIntent: SetValueIntent {
func perform() async throws -> some IntentResult {
WidgetCenter.shared.reloadTimelines(ofKind: "SwiftCalWidget") // new
return .result()
}
}
값이 변할때마다 위젯과 컨트롤 센터가 서로가 연동을 하게 해당 코드를 추가해주자.
이때 kind에 들어가는이름을 각자 맞는것을 해줘야 한다는것. 그리고 서로가 반대를 리로드 해줘야한다. Toggle은 Widget인데 여기다가 WidgetControl을 하면 안된다는것.
그리고 ControlCenter에도 반영되게 추가해준다.
1
2
3
4
5
6
7
8
9
.onTapGesture {
if day.date.dayInt <= Date().dayInt {
day.didStudy.toggle()
WidgetCenter.shared.reloadTimelines(ofKind: "SwiftCalWidget")
ControlCenter.shared.reloadControls(ofKind: "SwiftCalControl") // new
} else {
print("Can't study in the future!")
}
}
지난글 버그 해결
사실 버그도 아니다…
1
2
3
4
5
6
7
8
9
10
.onTapGesture {
if day.date.dayInt <= Date().dayInt {
day.didStudy.toggle()
try? context.save() // new
WidgetCenter.shared.reloadTimelines(ofKind: "SwiftCalWidget")
ControlCenter.shared.reloadControls(ofKind: "SwiftCalControl")
} else {
print("Can't study in the future!")
}
}
여기가 빠져있어서 지난글에서 버그처럼 안되는것처럼 강의에서 표현을 했다.
SwiftData에 저장을 안해서 생긴 문제
이렇게 서로가 연동이 잘된다.