티스토리 뷰

🍏/WidgetKit

[WidgetKit] TimeLineProvider와 WidgetCenter

사용자 eungding 2020. 7. 28. 18:47
728x90
반응형

위젯의 Configuration은 

UI 담당 ViewContent와

리프레시 로직 담당 Provider가 있습니다.

 

https://developer.apple.com/documentation/widgetkit/

 

코드로 보면 여기 들어가는 provider..! 

 

 

저는 폴더 구조도 저렇게 구성했어요-!

 

 

Provider에 대해서 자세히 알아볼게요 :-)

 

 

[1] TimelineProvider, Timeline, TimelineEntry

 

TimelineProvider는 이름 그대로 Timeline을 제공하여서 위젯킷에게 언제 위젯을 업데이트하고 싶은 지 알려주는 친구입니다.

특정한 시간에 WidgetKit은 Provider의 메소드를 호출하여서 새로운 Timeline을 요구합니다. (이건 아래서 자세하게 설명할게요)

 

그럼 Timeline이란 무엇일까요?!

Timeline은 TimelineEntry 로 이루어진 배열과 refresh 정책을 가지고 있는 객체입니다.

let timeline = Timeline(entries: entries, policy: .after(refreshDate))

 

TimelineEntry는 모델이라고 생각하시면 됩니다-!

각 timelineEntry는 필수값인 date를 가집니다. 그 외에 위젯에 필요한 다른 프로퍼티들을 추가할 수 있습니다.

 

예를들어 TimelineEntry 프로토콜의 필수값인 date를 구현해주고

따로 필요한 health Level을 추가한 모습입니다.

 

 

저도 이렇게 entry를 만들어주었습니다. 

import WidgetKit
import Foundation

struct PRListEntry: TimelineEntry {
    var date = Date()
    let prList: [PullRequest]
}

 

 

[2] TimelineProvider의 snapshot, timeline 메소드

 

WidgetKit은 timeline entries를 Provider의 두가지 메소드를 통해 요청합니다.

 

1. 위젯의 현재 상태를 나타내는 즉각적인 스냅샷 하나 (A single immediate snapshot, representing the widget’s current state.)

2. 현재 상태랑 미래의 상태들을 나타내는 entry 배열 (An array of entries, including the current moment and, if known, any future dates when the widget’s state will change.)

 

 

WidgetKit은 사용자가 위젯을 추가할때 snapshot request(1번)를 합니다. => snapshot(for:with:completion:) 메소드를 부름

그리고 사용자가 위젯을 추가한 다음부터는 timeline request(2번)을 합니다. => timeline(for:with:completion:) 메소드를 부름

 

그럼 1번부터 살펴볼게요-! 

 

1) snapshot(for:with:completion:)

 

snapshot(for:with:completion:) 메소드를 구현해주세요 

 

struct PRListProvider: TimelineProvider {
    typealias Entry = PRListEntry
    
    func snapshot(with context: Context, completion: @escaping (PRListEntry) -> ()) {
        let pr1 = PullRequest(url: "", state: "", title: "버그 픽스 PR 합니다.", user: User(name: "죠르디", imageUrl: ""), createdDate: "2020-07-06", updatedDate: "2020-07-07")
        let pr2 = PullRequest(url: "", state: "", title: "새로운 피쳐 PR 합니다.", user: User(name: "라이언", imageUrl: ""), createdDate: "2020-07-06", updatedDate: "2020-07-07")
        let entry = Entry(prList: [pr1, pr2])
        completion(entry)
    }
    
    ...
 }

 

그러면 위젯갤러리를 열때, 저 메소드가 호출됩니다.

 

 

 

 

데이터를 로드하는 데 시간이 좀 걸리는 경우 (ex. 서버에서 데이터 가져오는 경우) 

샘플데이터를 대신 사용하라고 가이드합니다.

 

그리고 context에는 현재 나온 위젯이 위젯 갤러리의 프리뷰인지 (context.isPreview가 true이면 위젯 갤러리에 위젯이 프리뷰로 나온 상태라고 합니다. false인 케이스는 언제인지 모르겠네요ㅠㅠ) ,표시할 위젯의 크기가 어떻게 되는 지 등등 세부정보가 담겨있습니다.

 

아래 애플 예제 코드에서는 위젯이 위젯갤러리에 나온 프리뷰 상태이고 아직 health level 데이터를 못받아왔을때 0.75라는 샘플 데이터를 사용하고 있습니다.

struct CharacterDetailProvider: TimelineProvider {
    func snapshot(with context: Context, completion: @escaping (Entry) -> ()) {
        let date = Date()
        let entry: CharacterDetailEntry

        if context.isPreview && !hasHealthLevel {
            entry = CharacterDetailEntry(date: date, healthLevel: 0.75)
        } else {
            entry = CharacterDetailEntry(date: date, healthLevel: currentHealthLevel)
        }
        completion(entry)
    }
}

 

저는 snapshot 메소드에서는 API 콜 안하고 샘플데이터만 썼어요-!

 

애플 위젯 예제프로젝트들도 샘플데이터 쓰더라구요-!

Building Widgets Using WidgetKit and SwiftUI

Fruta: Building a Feature-Rich App with SwiftUI  

 

2) timeline(for:with:completion:)

 

timeline(for:with:completion:) 메소드를 구현해주세요 

깃헙에서 풀리퀘들을 받아와서 Timeline을 만들어주는 모습입니다. 

 

struct PRListProvider: TimelineProvider {
    typealias Entry = PRListEntry
    ... 
    
    func timeline(with context: Context, completion: @escaping (Timeline<PRListEntry>) -> ()) {
        let currentDate = Date()
        // 5분마다 refresh 하겠음
        let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
        
        GithubFetcher.getPulls(owner: "eunjin3786", repo: "MyRepo") { result in
            switch result {
            case .success(let pulls):
                let entry = Entry(date: currentDate, prList: pulls)
                let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
                completion(timeline)
            case .failure:
                let entries: [Entry] = []
                let timeline = Timeline(entries: entries, policy: .after(refreshDate))
                completion(timeline)
            }
        }
    }
}


 

그러면 위젯을 처음추가할때 첫번째 타임라인을 요청해서 (timeline메소드를 불러서)

위젯을 refresh합니다. 

 

 

그리고 policy를 after로 넘겨줬기때문에 5분 후에

또 새로운 타임라인을 요청해서 위젯을 refresh하게 됩니다. (policy는 밑에 가서 설명할게요-!) 

근데 여기서 설정해주는 시간에 맞춰서 정확하게 딱 5분 후에 refresh되지 않아요-!!

 

이 블로그 를 보고 알았는데, 

위젯 문서에는 위젯이 업데이트될 시기를 예측할 수 없다고 명시되어있다고 합니다. 

위젯의 업데이트 시간은 사용자에게 표시되는 빈도 등 몇가지 요인에도 영향을 받는다고 합니다. 

블로거님의 경험으로는 5분이라고 주니까 새로 업데이트 되는데에 20분이 걸렸다고 하네요.

 

하긴 엄청 많은 앱의 위젯들을 추가하고 그 위젯들이 다 1초마다 위젯을 업데이트하게 설정해놨다면

iOS가 너무 힘들테니까 왜 이렇게 했는지 이해는 되네요...

 

딱딱 내가 원하는 시간에 업데이트하고 싶으면 WidgetCenter API를 쓸 수 있어요-! 밑에 가서 설명할게요!

 

[3] TimelineReloadPolicy

 

timeline을 만들때, reload policy를 지정해주어야합니다. (디폴트값은 .atEnd)

reload policy는 iOS에게 현재 타임라인을 버리고 새로운 타임라인을 가져오라고 말하는(??) 정책입니다.

 

TimelineReloadPolicy 은 세가지가 있어요

 

 

1) atEnd

 

타임라인 entry들 중, 가장 마지막으로 지정된 시간 이후에 새로운 타임라인 요청하도록 할때 쓰입니다. 

 

동작하는 그림을 볼게요-! 

 

예를들어 health level이 한시간마다 25퍼센트씩 올라가고 100퍼센트가 되면 멈추는 위젯이 있다고 해볼게요

 

위젯킷은 초기화될때, provier에게 timeline을 요청합니다.

provider는 지금은 health level이 25%, 1시간 후는 health level이 50%, 2시간 후는 75%, 3시간 후는 100% 인 

entries로 구성된 timeline을 줍니다. refresh 옵션은 atEnd로 해서요..!

 

그러면 1시간씩 마다 widget 화면을 업데이트하게 되고 

refresh 옵션이 atEnd니까 받은 entries 중 가장 마지막 entry의 date가 되면 위젯킷은 새로운 timeline을 요청합니다. 

 

health level이 100%가 되었으니까 provider는 현재 상태(health level이 100%)로 구성된 timeline을 주고 

더 이상 refresh 안 할 것이니까 refresh 옵션을 .never로 줍니다.

 

 

 

[ 예제 ]

 

Provider의 timeline 메소드를 이렇게 작성해볼게요-! 

struct PRListProvider: TimelineProvider {
    typealias Entry = PRListEntry
    
    ... 
    
    func timeline(with context: Context, completion: @escaping (Timeline<PRListEntry>) -> ()) {
        
        let currentDate = Date()
        let interval = 2
        
        GithubFetcher.getPulls(owner: "eunjin3786", repo: "MyRepo") { result in
            switch result {
            case .success(let pulls):
                let entries = pulls.enumerated().map { (index, pull) -> Entry in
                    let entryDate = Calendar.current.date(byAdding: .second, value: (index + 1) * interval, to: currentDate) ?? currentDate
                    let entry = Entry(date: entryDate, prList: [pull])
                    return entry
                }
                
                let timeline = Timeline(entries: entries, policy: .atEnd)
                completion(timeline)
            case .failure:
                let entries: [Entry] = []
                let timeline = Timeline(entries: entries, policy: .atEnd)
                completion(timeline)
            }
        }
    }
}

 

위젯을 추가할때 첫번째 타임라인이 요청됩니다.

이때 GithubFetcher는 4개의 PR 목록을 줄 것이고  

저는 이렇게 생긴 타임라인을 전달해준 것입니다. (찐초록색은 entry를 말합니다.)

그러면 2초 간격으로 entry가 화면에 업데이트 되는 것을 볼 수 있습니다. 

(참고로 처음 나오는 hello world는 placeholder view 입니다)

 

 

그리고 마지막 entry가 끝나면 새로운 타임라인을 요청합니다.

바로 요청하는 게 아니라 위에서 설명한 것 처럼 시간 delay가 있어요.

 

위젯을 추가한 후, 재빠르게 다섯번째 PR을 만들어줬습니다. 

그 후, 요청되는 새로운 타임라인에는 GithubFetcher는 5개의 PR 목록을 줄 것이고  

 

 

이런 타임라인을 전해주게 됩니다. 

 

그래서 이런 결과가 나오게 됩니다.

(참고로 새로운 타임라인 요청까지 움짤보다 시간이 더 걸리는 데 업로드 용량때문에 조금 짤랐어요-!!)

 

 

 

 

2) after

 

첫번째 timeline 요청 후 (위젯 추가했을 때), next timeline 요청을 얼마 후에 할지 정할 수 있습니다. 

아래 애플 예제 코드는 새로운 타임라인 요청을 150분 후에 합니다.

 

 

동작하는 그림을 볼게요-!!

타임라인을 첫번째로 요청합니다. 

그때 refresh policy를 .after(2시간 후) 로 주었어요

즉 첫번째 타임라인을 요청한후, 2시간 후에 새로운 타임라인을 요청해라!!! 라고 해주는 것이에요

그래서 타임라인 안에 있는 entry들을 보여주고 2시간이 지나면

새로운 타임라인을 요청합니다. 

 

2시간 후, 현재 타임라인을 버리고 새로운 타임라인을 요청하기 때문에 

3시간 후에 나와야하는 entry는 등장을 못하고 버려졌어요. 

(이게 atEnd와 큰 차이 점입니다!!!!!! atEnd는 마지막 entry까지 다 나온 후 refresh되는 것이 보장되지만 after는 시간맞춰서 칼같이 짤라요) 

 

새로운 타임라인에서도  .after(2시간 후) 를 policy로 주었네요

그래서 또 두시간 지나면 새로운 타임라인을 요청합니다. 

 

2시간 후 또 새로운 타임라인을 요청하게 되고

이때는 never를 policy로 줘서 이제 refresh 안되게 해주네요

 

 

 

[ 예제 ]

 

아래 코드로 실험해보고 싶었는데,

위에서 말한 것 처럼 iOS가 5초 지나고 칼같이 바로 새로운 타임라인을 요청해주는게 아니라서 실험을 못했습니다. 

(timeline에 디버깅 포인트 걸어보면 처음 타임라인 요청때 걸리고 한~~참후에 또 걸리는 것을 알 수 있어요) 

 

"정확한 시간이 아니라 iOS에게 희망시간(??) 정도를 알려준다. 위젯이 업데이트될 시기를 예측할 수 없다" 다시 한번 기억-!

 

struct PRListProvider: TimelineProvider {
    typealias Entry = PRListEntry
    
    ... 
    
    func timeline(with context: Context, completion: @escaping (Timeline<PRListEntry>) -> ()) {
        
        let currentDate = Date()
        let refreshDate = Calendar.current.date(byAdding: .second, value: 5, to: currentDate)!
        let interval = 2
        
        GithubFetcher.getPulls(owner: "eunjin3786", repo: "MyRepo") { result in
            switch result {
            case .success(let pulls):
                let entries = pulls.enumerated().map { (index, pull) -> Entry in
                    let entryDate = Calendar.current.date(byAdding: .second, value: (index + 1) * interval, to: currentDate) ?? currentDate
                    let entry = Entry(date: entryDate, prList: [pull])
                    return entry
                }
                
                let timeline = Timeline(entries: entries, policy: .after(refreshDate))
                completion(timeline)
            case .failure:
                let entries: [Entry] = []
                let timeline = Timeline(entries: entries, policy: .after(refreshDate))
                completion(timeline)
            }
        }
    }
}

 

 

3) never

 

새로운 타임라인을 요청하지 않습니다.

미래의 이벤트를 예측하지 못할 경우 이 옵션을 사용해서

위젯킷한테 새로운 타임라인을 요청하지 말라고 말할 수 있습니다.

이런 경우는 새로운 타임라인이 가능해질때,  WidgetCenter 의 reloadTimelines(ofKind:) 를 이용하여 직접 refresh 해줄 수 있습니다. 

 

 

또한 안바뀌는 static content를 위젯으로 보여주고 싶다면 이 옵션을 사용하면 되겠죠?!? (근데 HIG에서 바뀌는 위젯이 좋다고 함)

 

 

[ 예제 ]

 

5초마다 refresh해주고 풀리퀘스트 카운트가 7개 이상되면 더 이상 refresh안하도록 해줘볼게요-!! (말이 안되지만 실험이니까-!!)

struct PRListProvider: TimelineProvider {
    typealias Entry = PRListEntry

    ...
    
    func timeline(with context: Context, completion: @escaping (Timeline<PRListEntry>) -> ()) {
        let currentDate = Date()
        // 5초마다 refresh 하겠음
        let refreshDate = Calendar.current.date(byAdding: .second, value: 5, to: currentDate)!
        
        GithubFetcher.getPulls(owner: "eunjin3786", repo: "MyRepo") { result in
            switch result {
            case .success(let pulls):
                let entry = Entry(date: currentDate, prList: pulls)
                if pulls.count >= 7 {
                    let timeline = Timeline(entries: [entry], policy: .never)
                    completion(timeline)
                } else {
                    let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
                    completion(timeline)
                }
            case .failure:
                let entries: [Entry] = []
                let timeline = Timeline(entries: entries, policy: .after(refreshDate))
                completion(timeline)
            }
        }
    }
}

 

그러면 PR할때마다 특정 시간 이후 계속 위젯이 refresh되다가 

여덟번째 PR을 하면 아무리 기다려도 refresh가 안되는 것을 볼 수 있습니다-!!

 

 

 

[ Widget Center ]

WidgetCenter를 이용해서 전체 위젯을 다 refresh하거나 

아니면 위젯의 kind를 넘겨줘서 특정 위젯만 refresh할 수 있습니다. 

 

WidgetCenter 함수들을 이용하면 즉각적으로 위젯을 refresh 시킬 수 있어요 

 WidgetCenter.shared.reloadAllTimelines()
 WidgetCenter.shared.reloadTimelines(ofKind: "PRListWidget")

 

예를 들어 login, logout할때 위젯을 업데이트 시켜줘야되서 저는 이렇게 해줬습니다. 

func login() {
    ...
    WidgetCenter.shared.reloadAllTimelines()
}

func logout() {
    ...
    WidgetCenter.shared.reloadAllTimelines()
}

 

 

그럼 로그아웃할때 timeline을 즉각적으로 새로 요청하고 되고

이때 로그아웃할때 accessToken이 지워져서 api call 결과가 fail이 됩니다.

그래서 저런 뷰가 나오게 되어요-! 

(실제 서비스에는 error code로 authorization error인지를 판별해서 저런 뷰를 보여주면 더 좋겠죠..?!?)

 

 

struct PRListProvider: TimelineProvider {
    typealias Entry = PRListEntry
    
    ... 

    func timeline(with context: Context, completion: @escaping (Timeline<PRListEntry>) -> ()) {
        let currentDate = Date()
        // 5초마다 refresh 하겠음
        let refreshDate = Calendar.current.date(byAdding: .second, value: 5, to: currentDate)!
        
        GithubFetcher.getPulls(owner: "eunjin3786", repo: "MyRepo") { result in
            switch result {
            case .success(let pulls):
                let entry = Entry(date: currentDate, prList: pulls)
                let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
                completion(timeline)
            case .failure:
                let entry = Entry(prList: [PullRequest(url: "", state: "", title: "데이터를 가져올 수 없습니다.", user: User(name: "또르르", imageUrl: ""), createdDate: "로그인을 안한 것 아닐까요..?", updatedDate: "")])
                let entries: [Entry] = [entry]
                let timeline = Timeline(entries: entries, policy: .after(refreshDate))
                completion(timeline)
            }
        }
    }
}


 

 

 

그리고 다시 로그인 하면 reloadAllTimelines로 인해 새로운 타임라인을 요청하게 되고

로그인하면 accessToken이 있으니까 api call이 성공해서 PR 목록을 잘 보여주게 됩니다. 

 

 

 

[ Configurable Widget ]

 

[WidgetKit] IntentConfiguration으로 Edit Widget 기능을 추가하기 를 해보니까

사용자 인풋을 받아서 바로바로 업데이트 되어야하는 Configurable Widget인 경우

입력을 하고 홈으로 돌아올때 timeline 요청을 새로 해주는 것 같습니다.

문서에는 해당 내용을 못찾았지만, refresh 정책과 상관없이 바로바로 위젯을 refresh 시켜주는 것을 볼 수 있기때문입니다.

실제로 timeline 함수에 디버깅 포인트 걸어보면 인풋입력하고 홈 돌아올때마다 계속 디버깅 걸립니다.

(Xcode 버그때문에 widget extension에 디버깅 포인트 안잡힐때가 많아서 한번 더 확인하고 싶었는데, 이제 안잡히네요ㅠㅠ흑)

 

 

 

 

 

 

 

Reference

developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date

 

Apple Developer Documentation

 

developer.apple.com

 

developer.apple.com/documentation/widgetkit/widgetcenter

 

Apple Developer Documentation

 

developer.apple.com

 

728x90
반응형
댓글
댓글쓰기 폼