WidgetKit (3)
UI Design
이부분은 생략
다만 한가지 특이점이라면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
HStack {
StatLabel(value: 999, systemImageName: "star.fill")
StatLabel(value: 99, systemImageName: "tuningfork")
StatLabel(value: 999, systemImageName: "exclamationmark.triangle.fill")
}
fileprivate struct StatLabel: View {
let value: Int
let systemImageName: String
var body: some View {
Label {
Text("\(value)")
.font(.footnote)
} icon: {
Image(systemName: systemImageName)
.foregroundStyle(.green)
}
.fontWeight(.medium)
}
}
StatLabel에 대해 fileprivate가 걸렸는데, 이렇게 되면 해당 struct를 가지고 있는 파일 안에서만 사용이 가능하다.
즉 무작정 사용이 안되기때문에 코드 혼선을 방지할수있음.
그리고 강의에선 HStack의 padding을 Default로 하였으나, 그렇게 하니 조금 안맞는 부분이 있어 .padding(7)
이렇게 수정한다.
초기화면 세팅 완료.
그리고 preview의 경우 현재와 이전버전이 다른데
위젯의 사이즈를 변경하고 싶다면
1
2
3
4
5
6
#Preview(as: .systemMedium) {
RepoWatcherWidget()
} timeline: {
SimpleEntry(date: .now, emoji: "😀")
SimpleEntry(date: .now, emoji: "🤩")
}
여기서 as 뒤에 원하는 사이즈를 정하자
처음에 프로젝트를 만들면 systemSmall
이다.
Api 모델링
GitHub Api를 사용할것이다
우선 Docs를 보면서 하는게 좋다.
이때
Api관련 Docs를 보면 항상 cURL에 대한 내용이 있다.
이건 터미널에서 확인이 가능하다.
한번 확인해보자
owner와 repo만 실제 존재하는걸로 바꿔주면 된다.
나의 계정에서 ForSwiftUI repository에 관한 내용을 적용해보겠다.
이렇게 바로 출력이 되는걸 알 수 있다.
이제 모델링을 해보자
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Repository: Decodable {
let name: String
let owner: Owner
let hasIssues: Bool
let forks: Int
let watchers: Int
let openIssues: Int
let pushedAt: String
}
struct Owner: Decodable {
let avatarUrl: String
}
Entry 적용
이제 모델링을 한것을 Entry에 적용을 하자.
가장 심플한건 처음에 만들어지는 SimpleEntry를 Refactor를 사용하여 이름을 변경해주는것이다.
그리고 repo를 추가.
1
2
3
4
struct RepoEntry: TimelineEntry {
let date: Date
let repo: Repository // added
}
이때 여러 에러가 발생
placeholder에 DummyData를 주기 위해
1
2
3
4
5
6
7
8
9
struct Repository: Decodable {
static let placeholder = Repository(name: "Your Repo",
owner: Owner(avatarUrl: ""),
hasIssues: true, forks: 65,
watchers: 123,
openIssues: 55,
pushedAt: "2024-12-04T05:22:15Z"
)
}
placeholder용 변수를 하나 만들어주자.
이제 이걸 적용하면
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
37
38
39
40
41
42
43
44
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> RepoEntry {
RepoEntry(date: Date(), repo: Repository.placeholder)
}
func getSnapshot(in context: Context, completion: @escaping (RepoEntry) -> ()) {
let entry = RepoEntry(date: Date(), repo: Repository.placeholder)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [RepoEntry] = []
// 중간부분 삭제
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
VStack(alignment: .leading) {
HStack {
Circle()
.frame(width: 50, height: 50)
Text(entry.repo.name)
.font(.title2)
.fontWeight(.semibold)
.minimumScaleFactor(0.6)
.lineLimit(1)
}
.padding(.bottom, 6)
HStack {
StatLabel(value: entry.repo.watchers, systemImageName: "star.fill")
StatLabel(value: entry.repo.forks, systemImageName: "tuningfork")
StatLabel(value: entry.repo.openIssues, systemImageName: "exclamationmark.triangle.fill")
}
#Preview(as: .systemMedium) {
RepoWatcherWidget()
} timeline: {
RepoEntry(date: .now, repo: Repository.placeholder)
}
첫번째: provider에 repo를 추가 그리고 timeline의 중간 for loop내용 삭제
두번째: value값을 999같은 임의의 숫자가 아닌 entry에서 가져오게 수정
세번째: preview에서 timeline을 수정하여 dummy 적용
이렇게 적용이 된다.
마지막활동 계산 함수 만들기
terminal의 결과를 보면
1
2
3
"created_at": "2024-10-15T20:10:29Z",
"updated_at": "2024-12-04T05:22:15Z",
"pushed_at": "2024-12-04T05:22:11Z",
이렇게 언제 push가 되었는지를 알 수 있다.
이걸 통해서 마지막으로 활동한 일자를 계산하는 함수를 만들고 적용을 해보도록 한다.
1
2
3
4
5
6
func calculateDaysSinceLastActivity(from dateString: String) -> Int {
let formatter = ISO8601DateFormatter()
let lastActivityDate = formatter.date(from: dateString) ?? .now
let daysSinceLastActivity = Calendar.current.dateComponents([.day], from: lastActivityDate, to: .now).day ?? 0
return daysSinceLastActivity
}
이때 다른건 특이사항이 없지만
바로 새로운 formatter가 나타나다.
ISO8601DateFormatter Docs를 읽어보자
2024-10-15T20:10:29Z 이렇게 출력되는 형식이 바로 ISO8601 형식이다.
여기 간략한 설명이 있으니 참고.
다시돌아와서 현재 github에서 쓰는 시간의 형식은 ISO8601이다.
그래서 해당 formatter를 사용.
하지만 함수안에서 formatter를 사용하는것 보다는 또 사용할수 있으므로 view에 선언을 하고
날짜를 계산한 값을 computed property를 사용해 만들어주자
1
2
3
4
let formatter = ISO8601DateFormatter()
var daysSinceLastAcitivity: Int {
calculateDaysSinceLastActivity(from: entry.repo.pushedAt)
}
그리고 그걸 날짜를 알려주는 Text에 적용
1
2
3
4
5
6
7
8
VStack {
Text("\(daysSinceLastAcitivity)")
.bold()
.font(.system(size: 70))
.frame(width: 90)
.minimumScaleFactor(0.6)
.lineLimit(1)
.foregroundStyle(daysSinceLastAcitivity > 50 ? .pink : .green)
foregroundStyle에 삼항 연산자를 통해 업데이트하고 50일이 초과했다면 핑크, 아니면 초록으로 구성했다.
이렇게 된다.
Dummy를 바꾸려면
struct Repository
여기서 바꾸면 된다
이미 만들어 뒀으니까.
NetworkManager 만들기
이제 Api를 호출하는걸 해볼것이다.
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
37
38
39
40
41
42
class NetworkManager {
static let shared = NetworkManager()
let decoder = JSONDecoder()
private init() {
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
}
func getRepo(atUrl urlString: String) async throws -> Repository {
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 {
return try decoder.decode(Repository.self, from: data)
} catch {
throw NetworkError.invalidRepoData
}
}
}
enum NetworkError: Error {
case invalidRepoURL
case invalidResponse
case invalidRepoData
}
enum RepoURL {
static let swiftUIStudy = "https://api.github.com/repos/haroldfromk/ForSwiftUI"
static let swiftAlgorithms = "https://api.github.com/repos/TheAlgorithms/Swift"
static let google = "https://api.github.com/repos/google/GoogleSignIn-iOS"
}
여기서 특이점은 바로 init을 할때 decoder에 설정을 해준것이다.
사진은 convertFromSnakeCase
의 일부를 가져왔는데
위와 같이 디코딩을 할때 변환을 자연스럽게 해준다는 것이다.
날짜 관련해서 디코딩은 iso8601으로 한다는것.
현재 호출되어서 가져오는 json의 avatar_url
의 경우 CamelCase의 형식을 따르지 않는다.
그리고 우리는
1
2
3
struct Owner: Decodable {
let avatarUrl: String
}
그냥 이렇게 썼다.
이전이었으면 CodingKey를 사용해서 해결했다.
1
2
3
4
5
6
7
8
struct Owner: Decodable {
let avatarUrl: String
enum CodingKeys: String, CodingKey {
case avatarUrl = "avatar_url"
}
}
하지만 지금은 그렇게 하지 않았다.
디코더에서 설정을 해주면 하지않아도 된다는것.
TimeLine에 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
let nextUpdate = Date().addingTimeInterval(43200) // 12 hours in seconds
do {
let repo = try await NetworkManager.shared.getRepo(atUrl: RepoURL.swiftUIStudy)
let entry = RepoEntry(date: .now, repo: repo)
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) // update every 12hours
completion(timeline)
}
catch {
print("❌ Error - \(error.localizedDescription)")
}
}
}
이때 timeLine 변수를 좀 바꿔주었는데, 12시간마다 업데이트를 하기위해 위와 같이 해준다.
이때 안에 들어가는건 초단위 이므로 nextUpdate에 다음과 같이 했다.
위젯을 적용하면 다음과 같이 나온다.
Owner의 Avatar 가져오기
NewworkManager
1
2
3
4
5
6
7
8
9
10
11
12
func downloadImageData(from urlString: String) async -> Data? {
guard let url = URL(string: urlString) else {
return nil
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
return data
} catch {
return nil
}
}
이렇게 가져오는 함수를 만들어준다.
설명은 패스
Timeline
이후 TimeLine에 추가를 해주고,
1
2
3
4
5
6
7
do {
let repo = try await NetworkManager.shared.getRepo(atUrl: RepoURL.google)
let avatarImageData = await NetworkManager.shared.downloadImageData(from: repo.owner.avatarUrl) // new
let entry = RepoEntry(date: .now, repo: repo)
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) // update every 12hours
completion(timeline)
}
Entry
이제 Entry에도 적용을 해주자
1
2
3
4
5
struct RepoEntry: TimelineEntry {
let date: Date
let repo: Repository
let avatarImageData: Data
}
그러면 새로운 parameter가 생기니 에러가 발생하고 이제 그부분을 수정해주자.
실제로 필요한 부분만 let entry = RepoEntry(date: .now, repo: repo, avatarImageData: avatarImageData!)
이렇게 값을 넣어주고
나머지는 미리보기 용이기에
RepoEntry(date: Date(), repo: Repository.placeholder, avatarImageData: Data())
이런식으로 Data()
로 대체한다.
가져온 Image 적용하기
AsyncImage는 Widget에서는 사용이 안된다.
그래서 일반적인 Image를 사용해야한다.
Circle로 했던부분을 이제 Image를 사용하여 대체한다.
1
2
3
4
Image(uiImage: UIImage(data: entry.avatarImageData) ?? UIImage(named: "avatar")!)
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
placeholder 작동확인
Timeline의 let entry = RepoEntry(date: .now, repo: repo, avatarImageData: avatarImageData!)
여기에서 이미지 데이터를 아까 전에 적용했던것처럼 Data()로 바꿔서 작동확인을 해보자
확인 완료
데이터가 nil일때를 대비하여
let entry = RepoEntry(date: .now, repo: repo, avatarImageData: avatarImageData ?? Data())
이렇게 옵셔널 바인딩을 해주자.
실행하면 잘되는걸 알 수 있다.