티스토리 뷰

🍏/WidgetKit

[WidgetKit] 위젯만들기

eungding 2020. 7. 28. 19:52
반응형

이런 플로우로 되어있는 PR 보여주는 앱을 예제로 하나 만들었습니다.

그리고 위젯을 만들어보겠습니다..!

 

 

 

[ 유의사항 ]

1) Xcode 버전 

 

Xcode 베타 버전(+iOS 베타 버전)에서 버그가 짱 많습니다.

위젯 갤러리에 위젯이 안뜰때도 있었고 

(iOS 14 beta2에서는 해결되었다고 합니다.) 

 

 

https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14-beta-release-notes#Widgets

 

 

 

 

extension으로 돌리면 크래쉬나는 경우도 있었고

(Xcode beta3에서 해결되었다고 합니다.) 

 

 

 

 

Xcode beta릴리즈노트들을 보면 위젯 관련 이슈들이 되게 많아요 😓

이 글을 쓰고 있는 시점에 beta3 까지 나왔는데, 더 있다가 개발하기를 추천드립니다.

지금도 XCode 껐다키면 빌드 성공하고

위젯갤러리에 위젯이 안나오는데 폰 껐다가 다시 켜서 앱 지우고 다시 깔면 나오고... 이런 현상들이 많습니다..하..

 

 

 

2) 위젯은 App Extension 

 

App Extension Programming Guide 를 보면 App과 App Extension의 관계가 나옵니다.

Extension은 App안에 있지만 두개는 각각 다른 container를 가지고 있습니다. 

그래서 기본적으로 둘 사이의 데이터는 공유되지 않고

둘 사이의 데이터를 공유하려면 shared container를 써야합니다. 

 

예를들어 App에서 Userdefaults, Keychain에 데이터를 저장하고

Extension에서 해당 데이터를 얻어오려고 하면 없다고 (nil값으로) 나옵니다. 

하지만 공용으로 같이 쓰겠다고 Userdefaults, Keychain 설정을 해주면

App에서 저장한 데이터를 Extension에서도 쓸 수 있게 됩니다. 

 

 

 

 

 

 

저는 키체인을 쓰고 있어서 제가 사용중인 keychain api wrapper 라이브러리에서 하라는 것을 (Share Keychain items with other apps) 해줬습니다.

 

 

App과 extension 타겟에 Capability > keychain sharing을 추가해주고

 

 

 

 

 

 

 

appIDPrefix+keychainGroup 이름으로 accessGroup을 설정해준 키체인을 써야해요-!

    let keychain: KeychainSwift = {
        let keychain = KeychainSwift()
        let appIDPrefix = "6C4YL9RLK7"
        keychain.accessGroup = "\(appIDPrefix).com.eunjin.GithubPRViewer.keychainGroup"
        return keychain
    }()

 

AppID Prefix는 여기 들어가서 알 수 있어요-!

 

 

 

 

 

그럼 위젯을 만들어볼게요-! 

 

[1] File > Target > Widget Extension 추가하기

 

 

 

 

Siri를 안쓸 거라서 Configuration Intent 체크를 안해주겠습니다. 

 

 

 

 

 

[2] Target Memebership 설정

WidgetExtension에서도 써야하는 파일들의 TargetMembership 설정을 해주세요

 

 

 

 

그리고 저 파일이 import하고 있는 라이브러리들도 WidgetExtension에 설치해야합니다. 

 

 

 

 

저는 SPM으로 넣어서 여기서 + 눌러서 추가했는데,

 

 

 

cocoapod 쓰신다면 podfile에 이런식으로 해주면 됩니다

 

target 'SomeWidgetExtension' do
  pod Alamofire
end

 

참고로 저는 PullRequest모델 파일이랑 GithubFetcher파일 TargetMembership 설정 해줬습니다-!

 

[3] WidgetBundle 구성 (옵셔널)

위젯을 여러개 쓰고 싶다면 WidgetBundle을 이용해주면 됩니다.

 

import WidgetKit
import SwiftUI

@main
struct GithubPRViewerWidgetBundle: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        IssueListWidget()
        PRListWidget()
    }
}

 

하나면 쓰고 싶다면 

위젯에 @main을 붙여주면 되요-!! 

@main
struct PRListWidget: Widget {
    static let kind: String = "PRListWidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: PRListWidget.kind,
                            provider: PRListProvider(),
                            placeholder: PRListPlaceholderView()) { entry in
            PRListEntryView(entry: entry)
        }
        .configurationDisplayName("PR 목록 위젯")
        .description("PR 목록을 볼 수 있습니다.")
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

 

[4] 위젯 만들기

 

 

이 구조로 만들어볼게요! 

 

 

 

이렇게 위젯을 만어주세요

import SwiftUI
import WidgetKit

struct PRListWidget: Widget {
    static let kind: String = "PRListWidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: PRListWidget.kind,
                            provider: PRListProvider(),
                            placeholder: PRListPlaceholderView()) { entry in
            PRListEntryView(entry: entry)
        }
        .configurationDisplayName("PR 목록 위젯")
        .description("PR 목록을 볼 수 있습니다.")
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

 

displayName과 description은 위젯갤러리에 이렇게 나오게 됩니다.

 

 

그리고 supportedFamilies를 통해 어떤 사이즈의 위젯을 제공할 지 정할 수 있습니다.

 

[4.1] Provider 만들기 

1) TimelineEntry 만들기

 

 TimelieEntry를 만들어주세요-!!

import WidgetKit
import Foundation

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

 

참고로 PullRequest는 이렇게 생긴 모델이에요 

import Foundation

struct PullRequest: Decodable {
    let url: String
    let state: String
    let title: String
    let user: User
    let createdDate: String
    let updatedDate: String
    
    enum CodingKeys: String, CodingKey {
        case url = "html_url"
        case state
        case title
        case user
        case createdDate = "created_at"
        case updatedDate = "updated_at"
    }
}

struct User: Decodable {
    let name: String
    
    enum CodingKeys: String, CodingKey {
        case name = "login"
    }
}

 

2) TimelineProvider 만들기

import WidgetKit
import Foundation

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)

    }
    
    func timeline(with context: Context, completion: @escaping (Timeline<PRListEntry>) -> ()) {
        let currentDate = Date()
        // 30분마다 refresh 하겠음
        let refreshDate = Calendar.current.date(byAdding: .minute, value: 30, 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)
            }
        }
    }
}


 

자세한 내용은 TimeLineProvider와 WidgetCenter 를 참고해주세요-!!

 

[4.2] ViewContent 만들기

1) Entry를 받는 View 만들기

 

entry를 받아서 뿌려주는 View를 만들어주세요

EnvironmentValues 중, widgetFamily를 이용해서 widgetFamily(위젯사이즈)에 따라서 목록개수를 다르게 해주었습니다.

그리고 Link처리는 밑에 딥링크 설명할때 설명할게요-! 

import SwiftUI
import WidgetKit

struct PRListEntryView: View {
    let entry: PRListEntry
    
    @Environment(\.widgetFamily) var family
    
    var maxCount: Int {
        switch family {
        case .systemMedium:
            return 2
        default:
            return 5
        }
    }
    
    @ViewBuilder
    var body: some View {
        VStack(alignment: .leading) {
            ForEach(0..<min(maxCount, entry.prList.count), id: \.self) { index in
                let pr = entry.prList[index]
                let url = URL(string: "widget://pr?url=\(pr.url)")!
                Link(destination: url) {
                    PRView(pr: pr)
                    Divider()
                }
            }
        }
        .padding(.all, 16)
    }
}

struct PRView: View {
    let pr: PullRequest
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(pr.title)
                .font(.system(size: 18, weight: .semibold))
                .foregroundColor(.black)
            
            Spacer()
                .frame(height: 4)
            
            HStack {
                Text(pr.user.name)
                    .font(.system(size: 14, weight: .regular))
                    .foregroundColor(.gray)
                
                Divider()
                
                Text(pr.createdDate)
                    .font(.system(size: 14, weight: .regular))
                    .foregroundColor(.gray)
            }.frame(height: 20)
        }
    }
}

 

2) PlaceholderView 만들기 

 

덥덥 에서는 isPlaceholder 썼는데

 isPlaceholder가 beta3부터 지원된다는 말이 있다가 

 

 

 

 

 

isPlaceholder deprecated되었다고 redacted 쓰라고 했다가 

 

 

Xcode beta3에서는 이제는 위젯만들때 configuration에 placeholder넣는 이니셜라이저가 deprecated 되었다고 

 

 

 

이렇게 하라고 함.

 

 

그리고 TimeLineProvider에 placeholder 함수가 추가되었다. (필수는 아님. 애플예제 앱에서도 구현 X)

func placeholder(in context: Context) -> Entry

 

문서 의 Display a Placeholder Widget 문단을 보면 

 

위젯킷은 위젯이 처음나올때 placeholder로 위젯뷰를 표시한다.
placeholder를 통해 사용자에게 위젯이 이렇게 생겼구나 알려줄 수 있다.
위젯킷은 placeholder를 보여줄때 placeholder(in:) 을 부른다. 
placeholder를 보여줄때, 위젯킷은  redacted(reason:) 라는 view modifier 를 사용한다. (reason을  placeholder 로 해서. )
위젯킷이 자동으로 placeholder를 보여주는 것을 막으려면 unredacted() 라는 view modifier를 사용해라.

 

라고 되어있다. 

그니까 이제 (xcode beta 3부터)  위젯킷이 알아서 placeholder를 보여주는데,

이게 싫으면 unredacted 쓰라는 것인데?!?!? 

struct PRListWidget: Widget {
    static let kind: String = "PRListWidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: PRListWidget.kind,
                            provider: PRListProvider()) { entry in
            PRListEntryView(entry: entry)
                .unredacted()
        }
        .configurationDisplayName("PR 목록 위젯")
        .description("PR 목록을 볼 수 있습니다.")
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

 

왜 placeholder 안되는지 모르겠다,,

 

 

일단 placeholder 어떻게 생겼는지

궁금하면 이렇게 해서 볼 수 있다-! 

struct PRListWidget: Widget {
    static let kind: String = "PRListWidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: PRListWidget.kind,
                            provider: PRListProvider()) { entry in
            PRListEntryView(entry: entry)
                .redacted(reason: .placeholder)
        }
        .configurationDisplayName("PR 목록 위젯")
        .description("PR 목록을 볼 수 있습니다.")
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

 

 

혹시 몰라 이렇게도 해봤는지 동작 X

struct PRListWidget: Widget {
    static let kind: String = "PRListWidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: PRListWidget.kind,
                            provider: PRListProvider()) { entry in
            PRListEntryView(entry: entry)
                .redacted(reason: entry.prList.isEmpty ? .placeholder : [])
        }
        .configurationDisplayName("PR 목록 위젯")
        .description("PR 목록을 볼 수 있습니다.")
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

 

 

참고로 애플에서 공개한 위젯 예제 프로젝트 두개 모두 현재 placeholder가 제대로 안나오고 있다..😞


Building Widgets Using WidgetKit and SwiftUI 

Fruta: Building a Feature-Rich App with SwiftUI  

 

[5] 딥링크

 

만약에 위젯 전체를 눌러서 딥링킹하고 싶으면 

widgetURL을 쓰면 됩니다-!

 

struct PREntryView: View {
    let entry: PREntry
    
    var body: some View {
        VStack(alignment: .leading) {
           ...
        }
        .padding(.all, 16)
        .widgetURL(URL(string: "widget://pr?url=\(entry.pr.url)")!)
    }
}

 

하지만 리스트를 각각 눌러서 상세페이지로 가고 싶다면

 

 

 

 

Link를 쓰세요

 

struct PRListEntryView: View {
   ... 
    var body: some View {
        VStack(alignment: .leading) {
            ForEach(0..<min(maxCount, entry.prList.count), id: \.self) { index in
                let pr = entry.prList[index]
                let url = URL(string: "widget://pr?url=\(pr.url)")!
                Link(destination: url) {
                    PRView(pr: pr)
                    Divider()
                }
            }
        }
        .padding(.all, 16)
    }
}

 

저 URL은 appDelegate 또는 sceneDeleagate의 openURL 메소드로 들어온답니다.

여기서 이동처리를 해주면 되요-!

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    if let url = URLContexts.first?.url {
        if url.absoluteString.starts(with: "widget://pr") {
            guard let urlComponents = URLComponents(string: url.absoluteString) else { return }
            guard let pullRequestUrl = urlComponents.queryItems?.first(where: { $0.name == "url" })?.value else { return }
            let prDetailViewController = PRDetailViewController(urlString: pullRequestUrl)
            (self.window?.rootViewController as? UINavigationController)?.pushViewController(prDetailViewController, animated: true)
        } 
}

 

 

[6] 안되는 것 

1) 앱을 열지 않는 버튼 안됨

 

위젯에 버튼을 추가할 수 없습니다. 

버튼을 추가해도 클릭이벤트를 못받습니다. 
새로고침 버튼을 추가했는데 클릭이벤트를 못받고
위젯기본 클릭 이벤트인 앱을 여는 동작을 하는 것을 볼 수 있어요

 

struct PRListEntryView: View {
    let entry: PRListEntry
    
    @Environment(\.widgetFamily) var family
    
    var maxCount: Int {
        switch family {
        case .systemMedium:
            return 2
        default:
            return 5
        }
    }
    
    @ViewBuilder
    var body: some View {
        VStack(alignment: .leading) {
            ForEach(0..<min(maxCount, entry.prList.count), id: \.self) { index in
                let pr = entry.prList[index]
                let url = URL(string: "widget://pr?url=\(pr.url)")!
                Link(destination: url) {
                    PRView(pr: pr)
                    Divider()
                }
            }
            
            // 버튼 넣어도 클릭이벤트 못받는 것 보여주기 위해 추가
            // WWDC에서 그렇게 한 이유 나옴.
            Button(action: {
                WidgetCenter.shared.reloadTimelines(ofKind: PRListWidget.kind)
            }, label: {
                Text("새로고침")
                    .font(.system(size: 12, weight: .regular))
                    .foregroundColor(.gray)
            })
        }
        .padding(.all, 16)
    }
}

 
그이유는 덥덥에서 봤는데...! 곧 추가하겠습니다!-!

 


버튼을 두고 싶다면 앱을 열고 어떤 액션을 하는 용도로 사용하세요

ex) 애플 지도 앱에서 검색버튼누르면 앱열고 검색하고..

 

 

앱을 열지 않고 위젯에서만 어떤 액션을 하는 버튼은 만들수없습니다-!

 

 

2) UIViewRePresentable 안됨

 

UIKit 뷰를 스유로 쓰려고 UIViewRepresentable 채택하는 것을 만들어서 

위젯에 보여주게 하면 

struct SomeViewSwiftUI: UIViewRepresentable {
  typealias UIViewType = SomeView
     ...
}

 

이렇게 금지 표시로 뜸.. 

 

 "UIViewRepresentable is not supported in widgets." 이라고 함 (developer.apple.com/forums/thread/652042)

반응형
댓글