포스트

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)이렇게 수정한다.

CleanShot 2024-12-04 at 15 37 03

초기화면 세팅 완료.

그리고 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를 보면서 하는게 좋다.

이때

CleanShot 2024-12-04 at 15 45 49

Api관련 Docs를 보면 항상 cURL에 대한 내용이 있다.

이건 터미널에서 확인이 가능하다.

한번 확인해보자

owner와 repo만 실제 존재하는걸로 바꿔주면 된다.

나의 계정에서 ForSwiftUI repository에 관한 내용을 적용해보겠다.

CleanShot 2024-12-04 at 15 48 35

이렇게 바로 출력이 되는걸 알 수 있다.

이제 모델링을 해보자

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에 적용을 하자.

CleanShot 2024-12-04 at 15 53 00

가장 심플한건 처음에 만들어지는 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 적용

CleanShot 2024-12-04 at 16 05 08

이렇게 적용이 된다.

마지막활동 계산 함수 만들기

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일이 초과했다면 핑크, 아니면 초록으로 구성했다.

CleanShot 2024-12-04 at 17 05 16 CleanShot 2024-12-04 at 17 04 52

이렇게 된다.

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에 설정을 해준것이다.

CleanShot 2024-12-04 at 17 19 13

사진은 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에 다음과 같이 했다.

위젯을 적용하면 다음과 같이 나온다.

simulator_screenshot_53B95DEE-73B3-442A-B49A-2A46DEAC6E6C

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가 생기니 에러가 발생하고 이제 그부분을 수정해주자.

CleanShot 2024-12-04 at 17 48 28

실제로 필요한 부분만 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()로 바꿔서 작동확인을 해보자

CleanShot 2024-12-04 at 18 08 03

확인 완료

데이터가 nil일때를 대비하여

let entry = RepoEntry(date: .now, repo: repo, avatarImageData: avatarImageData ?? Data())

이렇게 옵셔널 바인딩을 해주자.

CleanShot 2024-12-04 at 18 09 14

실행하면 잘되는걸 알 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.