WidgetKit (5)
WidgetBundle 만들기
WidgetBundle관련 Docs는 여기
간단하게 정리하면 여러개의 위젯을 제공하기 위함이다.
파일을 하나 만들어 주고
1
2
3
4
5
6
@main
struct RepoWatcherWidgets: WidgetBundle {
var body: some Widget {
RepoWatcherWidget()
}
}
다음과 같이 작성해준다.
이때 한번도 사용하지 않은 @main
이 등장
@main
이란?
- 프로그램 진입점 정의
- @main은 Swift 프로그램이 시작될 때 호출되는 진입점을 나타낸다.
- 해당 속성이 부여된 타입은 프로그램의 실행 흐름을 제어한다.
- 사용 조건
- @main은 반드시 하나의 타입에만 선언할 수 있다.
즉 이렇게 Main을 하게되면 이젠 위젯이 실행되면 Bundle에서 시작이 될것이다.
이때 이전버전과는 달리 에러가 발생하는데 이유가
1
2
3
4
5
6
7
8
@main
struct RepoWatcherWidgetBundle: WidgetBundle {
var body: some Widget {
CompactRepoWidget()
RepoWatcherWidgetControl()
RepoWatcherWidgetLiveActivity()
}
}
위젯을 만들면서 번들이 자연스럽게 생기기 때문이다.
우린 새롭게 번들을 만들었으니 여기에 있는 main을 지워주자. 그 이유는 위에 2. 사용조건에 명시
그리고 현재 만들었던 관련 위젯들의 이름을 전부 바꿔주었다.
앞에 Compact를 붙어주었다.
ContributorWidget 만들기
기존 CompactRepoWidget에 있는 내용을 가져와서 적용을 해준게 다라서 내용은 패스한다.
이때 만약 preview를 적용하려는데 어려움이 있다면 저번글 에서도 언급한 코드를 사용하자.
그렇게 했는데 이런 에러가 뜬다면?
preview 코드에
1
2
3
4
5
6
7
struct ContributorWidget_Previews: PreviewProvider {
static var previews: some View {
ContributorEntryView(entry: ContributorEntry(date: Date()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.containerBackground(.fill.tertiary, for: .widget) // added
}
}
해당 내용을 추가해주도록 하자.
Bundle에 새로 만든 위젯 추가
1
2
3
4
5
6
7
@main
struct RepoWatcherWidgets: WidgetBundle {
var body: some Widget {
CompactRepoWidget()
ContributorWidget()
}
}
뭐 심플하다.
실행해보면
새롭게 추가한 위젯이 이렇게 나오는걸 알 수있다.
아래에 있는 add widget 버튼의 색을 변경하고 싶다면?
위 순서대로 하게되면
이렇게 바뀌게 된다.
Entry에 Repository 추가
Entry에 repository를 하나 추가해준다.
1
2
3
4
struct ContributorEntry: TimelineEntry {
var date: Date
let repo: Repository
}
이후 관련 에러는 추가를 알아서 하자 어차피 missing 이라서 Repo에 관한 MockData를 추가해 주면 된다.
UI Design
이런식으로 디자인을 할 것이다.
이미 위에는 RepoMediumView로 완성이 된 상태이므로, 아래만 새롭게 디자인을 해주면 된다.
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
32
33
34
35
var body: some View {
VStack {
RepoMediumView(repo: entry.repo)
VStack {
HStack {
Text("Top Contributors")
.font(.caption).bold()
.foregroundStyle(.secondary)
Spacer()
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2),
alignment: .leading,
spacing: 20) {
ForEach(0..<4) { i in
HStack {
Circle()
.frame(width: 44, height: 44)
VStack(alignment: .leading) {
Text("Sean Allen")
.font(.caption)
.minimumScaleFactor(0.7)
Text("42")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
}
.padding()
}
}
우선 이렇게 초기 디자인을 해준다
디자인의 결과는 다음과 같다.
LazyVGrid?
이때 한가지 궁금할 수 있는 점 LazyVGrid와 그 내부에 있는건 무엇일까?
ForEach야 사진을 보면 contributors를 표현하는 원과 텍스트 같은걸 4개씩 만들어 준거라고 하더라도
위에있는건 무엇을 의미할까?
LazyVGrid의 정의는 다음과 같다.
수직 그리드 레이아웃을 구현하는 뷰이다. 필요할때 아이템을 생성한다.
즉 이전에 UIKit을 사용할때 lazy var를 사용했던것과 같은 맥락
이전에 작성한글을 참고하자.
Parameters
그리고 LazyVGrid의 파라미터들은 다음과 같다
- columns
- An array of grid items to size and position each row of the grid.
- alignment
- The alignment of the grid within its parent view.
- spacing
- The spacing between the grid and the next item in its parent view.
- pinnedViews
- Views to pin to the bounds of a parent scroll view.
- content
- The content of the grid.
다시 돌아와서
columns: Array(repeating: GridItem(.flexible()), count: 2
위의 코드는 동일한 크기와 속성을 가진 열을 2개 생성하여 그리드 레이아웃을 구성하는 것이다. 각 열은 .flexible()로 정의되며, 이는 화면 크기에 따라 열의 크기가 동적으로 조정된다.
그러면 ForEach에서 5개가되면 어떻게 UI가 구성이 될까?
이렇게 아래에 생긴다.
그러면 count가 3이라면?
이렇게 옆으로 하나의 열이 더 생기면서 거기에 배치가 되었다.
실제로는 4개일때
1
2
***
*
이런 상태였다가
5개가 되면서
1
2
***
**
가 된것이 더 정확하긴 하다.
Circle을 Image로 대체
1
2
3
4
Image(uiImage: UIImage(named: "avatar")!)
.resizable()
.frame(width: 44, height: 44)
.clipShape(Circle())
언급은 패스
ContributorMediumView 만들기
이제 이렇게 만든 View를 새롭게 만든 View파일에 옮겨준다.
내용은 생략~!
이후
1
2
3
4
5
6
7
8
9
10
struct ContributorEntryView : View {
var entry: ContributorEntry
var body: some View {
VStack {
RepoMediumView(repo: entry.repo)
ContributorMediumView()
}
}
}
새롭게 만든 View파일을 적용해주면 끝.
Contributor Modeling
이제 Contributor 모델링을 해보자.
그전에 curl을 통해 contributor에 필요한게 어떤게 있는지 json 응답을 확인하도록 한다.
그렇게 확인을 하다보면
"contributors_url": "https://api.github.com/repos/
이런 부분은 발견할 수 있게된다.
해당 주소를 다시 curl을 통해 확인을 하면 여러 정보가 나온다.
각 유져가 있고 그 유져에 따라 얼마나 contribution을 했는지도 나오게 된다.
아래는 내가 파이널프로젝트에 했던 내용이고 그걸 가져왔다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"login": "Haroldfromk",
"id": 97341336,
"node_id": "U_kgDOBc1PmA",
"avatar_url": "https://avatars.githubusercontent.com/u/97341336?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/Haroldfromk",
"html_url": "https://github.com/Haroldfromk",
"followers_url": "https://api.github.com/users/Haroldfromk/followers",
"following_url": "https://api.github.com/users/Haroldfromk/following{/other_user}",
"gists_url": "https://api.github.com/users/Haroldfromk/gists{/gist_id}",
"starred_url": "https://api.github.com/users/Haroldfromk/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/Haroldfromk/subscriptions",
"organizations_url": "https://api.github.com/users/Haroldfromk/orgs",
"repos_url": "https://api.github.com/users/Haroldfromk/repos",
"events_url": "https://api.github.com/users/Haroldfromk/events{/privacy}",
"received_events_url": "https://api.github.com/users/Haroldfromk/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false,
"contributions": 246
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Contributor {
let login: String
let avatarUrl: String
let contributions: Int
var avataData: Data
}
extension Contributor {
struct CodingData: Decodable {
let login: String
let avatarUrl: String
let contributions: Int
var contributor: Contributor {
Contributor(login: login,
avatarUrl: avatarUrl,
contributions: contributions,
avataData: Data())
}
}
}
이렇게 모델링을 해주었다.
Repository에도 Contributors를 추가해준다.
1
2
3
4
struct Repository {
// 생략...
var contributors: [Contributor] = [] // new
}
위와 같이 해서 바로 빈배열로 초기화를 해주었다.
이렇게 Repository에 담아주는이유는 하나의 모델에서 사용하게되면 그만큼 변수를 더 만들 필요가 없기도 하기때문이다.
View에 Contributor 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2),
alignment: .leading,
spacing: 20) {
ForEach(repo.contributors) { contributor in
HStack {
Image(uiImage: UIImage(data: contributor.avataData) ?? UIImage(named: "avatar")!)
.resizable()
.frame(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(contributor.login)
.font(.caption)
.minimumScaleFactor(0.7)
Text("\(contributor.contributions)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
이렇게 view에 적용하려하니 contributor가 identifiable 프로토콜을 채택해야한다고 한다.
그래서 수정을 해준다.
1
2
3
4
5
6
7
struct Contributor: Identifiable {
let id = UUID()
let login: String
let avatarUrl: String
let contributions: Int
var avataData: Data
}
이때 필요한게 바로 id
preview를 보면 아무것도 안나온다
왜냐 contributor에 어떤 데이터도 담겨있지 않기 때문.
MockData에 추가하기
1
2
3
4
contributors: [Contributor(login: "Sean Allen", avatarUrl: "", contributions: 42, avataData: Data()),
Contributor(login: "Michael Jordan", avatarUrl: "", contributions: 25, avataData: Data()),
Contributor(login: "Steph Curry", avatarUrl: "", contributions: 30, avataData: Data()),
Contributor(login: "Lebron James", avatarUrl: "", contributions: 12, avataData: Data())]
이런식으로 새롭게 추가만 해주면 된다.
확인 완료
NetworkManager에 Contributor 함수 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func getContributors(atUrl urlString: String) async throws -> [Contributor] {
guard let url = URL(string: urlString) else {
throw NetworkError.invalidRepoURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw NetworkError.invalidResponse
}
do {
let codingData = try decoder.decode([Contributor.CodingData].self, from: data)
let contributors = codingData.map { $0.contributor }
return contributors
} catch {
throw NetworkError.invalidRepoData
}
}
기존에서 크게 달라지는건 없다.
한가지 map을 사용했다는 점.
그렇다면 기존 방식과의 차이는 뭘까?
getRepo vs getContributors
- getRepo:
- JSON 데이터가 단일 객체로 반환되기 때문에, Repository.CodingData로 디코딩.
- 디코딩한 CodingData를 repo 프로퍼티를 통해 Repository 객체로 변환.
1 2
let codingData = try decoder.decode(Repository.CodingData.self, from: data) return codingData.repo
- getContributors:
- JSON 데이터가 배열로 반환되기 때문에, [Contributor.CodingData] 타입으로 디코딩.
- 디코딩된 각 CodingData를 Contributor로 변환하기 위해 map을 사용.
1 2 3
let codingData = try decoder.decode([Contributor.CodingData].self, from: data) let contributors = codingData.map { $0.contributor } return contributors
그리고 배열을 씌운채로 디코딩을 하는건 애초에 json 결과가 []이렇게 배열에 감싸진 채로 값을 리턴하기 때문이다.
contributor.CodingData는 JSON 구조와 동일하지만, Contributor는 앱에서 사용할 데이터 모델이기에 해주는 것.
Widget에 적용
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
32
33
34
35
36
func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<ContributorEntry>) -> Void) {
Task {
let nextUpdate = Date().addingTimeInterval(43200)
do {
//Get Repo
let repoToShow = RepoURL.tpk
var repo = try await NetworkManager.shared.getRepo(atUrl: repoToShow)
let avatarImageData = await NetworkManager.shared.downloadImageData(from: repo.owner.avatarUrl)
repo.avatarData = avatarImageData ?? Data()
//Get Contributors
let contributors = try await NetworkManager.shared.getContributors(atUrl: repoToShow + "/contributors")
// Filter to jsut the top 4
var topFour = Array(contributors.prefix(4))
// Download top four avatars
for i in topFour.indices {
let avatarData = await NetworkManager.shared.downloadImageData(from: topFour[i].avatarUrl)
topFour[i].avataData = avatarData ?? Data()
}
repo.contributors = topFour
// Create Entry & Timeline
let entry = ContributorEntry(date: .now, repo: repo)
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) // update every 12hours
completion(timeline)
} catch {
print("❌ Error - \(error.localizedDescription)")
}
}
}
여기서 prefix를 한 이유? var topFour = Array(contributors.prefix(4))
curl을 통해 조회하면 애초에 contributor가 많은순으로 정렬이 된채로 json으로 값을 보내기 때문이다.
그래서 앞에서 4개를 잘라준것.
이렇게 미리보기에도 나온다.
이때
1
2
3
4
func getSnapshot(in context: Context, completion: @escaping @Sendable (ContributorEntry) -> Void) {
let entry = ContributorEntry(date: .now, repo: MockData.repoOne)
completion(entry)
}
repoTwo를 repoOne으로 바꿔 주었다.
실행하고 위젯을 추가하면? 이렇게 나온다.
contributor 수가 적을땐
1
2
3
4
5
6
LazyVGrid(){
//생략
}
if repo.contributors.count < 3 {
Spacer().frame(height: 20)
}
이렇게 추가를 해주면 된다.
iOS17 문제 해결
위에서도 언급한 해당 에러가 발생하는 가장 큰 이유는 containerBackground
가 없어서 그렇다.
이부분을 view에 적용해주면 해결이
1
2
3
4
5
6
7
8
9
10
11
12
//struct Static_Widget_Previews: PreviewProvider {
// static var previews: some View {
// RepoMediumView(repo: MockData.repoOne)
// .previewContext(WidgetPreviewContext(family: .systemMedium))
// }
//}
#Preview(as: .systemMedium) {
CompactRepoWidget()
} timeline: {
CompactRepoEntry(date: .now, repo: MockData.repoOne, bottomRepo: nil)
}
이렇게 바꿔주자.
이외에도 여러가지가 있지만 이전처럼 text에 애니메이션 추가라 내용은 패스…
iOS18 적용
이전과 같이 tint기능을 적용한다.
.widgetAccentable()
이전엔 각 Component에 했는데
Stack 전체에도 가능하다.
그리고 이미지의 경우
.widgetAccentedRenderingMode(.accented)
이걸 사용하는데 역시 iOS18 이후에만가능
만약 17 사용자도 있다면?
1
2
3
4
5
6
7
8
9
10
if #available(iOS 18.0, *) {
Image(uiImage: UIImage(data: repo.avatarData) ?? UIImage(named: "avatar")!)
.resizable()
.widgetAccentedRenderingMode(.accentedDesaturated)
.frame(width: 50, height: 50)
.clipShape(Circle())
} else {
Image(uiImage: UIImage(data: repo.avatarData) ?? UIImage(named: "avatar")!)
.resizable()
.frame(width: 50, height: 50)
이렇게 버전에따른 UI조건을 다르게 해줘야 한다.
이렇게 이미지도 적용이 되는걸 확인 아래는 해당부분을 적용하지 않음.
관련 Docs 여기에.
1
2
3
4
5
Image(uiImage: UIImage(data: contributor.avataData) ?? UIImage(named: "avatar")!)
.resizable()
.widgetAccentedRenderingMode(.desaturated)
.frame(width: 44, height: 44)
.clipShape(Circle())
Contributor이미지는 desaturated를 적용했다
그럼 이렇게 tint의 영향을 받지않고 흑백모드로 전환이 된다.