iOS) CoreData 를 사용하여 Configurable Widget 만들기 (3/3) - Configurable Widget

9 minute read

1. 프로젝트 세팅 2. Widget 만들기 ✅ 3. Configurable Widget 만들기

  • Configurable Widget 을 만들어 보겠습니다.
    • 위젯 편집할 때 정직/동적 선택 목록을 구현.
    • 처음 위젯을 추가할 때 기본이 되는 세팅을 구현.
    • 여러가지 카드(명함이름, 이름을 가진)로 바꿀 수 있게 구현.(메모 위젯처럼 목록에서 선택할 수 있도록)
  • MyCard Widget 은 Intent Configuration.
  • QRCode Widget 은 Static Configuration.
    • 위젯을 통해 앱의 특정 뷰로 이동.

👉 **Configurable Widget 만들기**

iOS) Configurable Widget 만들기

위의 글을 참고해서 Configurable Widget 을 만들어보겠습니다.

  • configurable properties 정의하는 custom intent definition 을 추가.
  • Intents extension 을 구현하고 IntentTimelineProvider 를 사용해서 위젯이 동적 데이터를 반영.

1️⃣ SiriKit Intent Definition File 추가 및 설정

  • custom intent 를 설정하기 위해서 프로젝트에 파일을 추가해줍니다.

1

  • 이때, 사용하려는 타겟(widget extension)에 추가해줍니다.

2

  • Editor > New Intent 를 선택해서 intent 를 추가해줍니다.
  • 코드에서는 SelectMyCardIntent 라는 클래스 이름으로 참조할 수 있습니다.(Attribute InspectorCustom Class 필드에서 확인할 수 있습니다.)
  • Intent is eligible for widgets 체크박스를 체크합니다.(위젯에서 Intent 를 사용하겠다는 의미)

  • configurable setting 이 되는 파라미터를 추가하겠습니다.
  • 우리는 여러가지 명함 목록에서 고르기 위해서 ParameterMyCard 를 추가하였습니다.

4

2️⃣ 우선, 정적인 선택 목록을 생성해보자.

  • 매개변수가 사용자에게 정적인 선택 목록을 제공하는 경우 static enumeration 을 생성해주면 됩니다.
  • 좌측 하단의 + 를 선택해서 New Enum 을 선택하면 static enumeration 을 생성할 수 있습니다.

5

6

  • 이렇게 하면 수동으로 파라미터의 Type pop-up menu 에서 설정해줘야 합니다. 혹은, Type pop-up menu 에서 Add Enum… 을 통해서 바로 생성하고 설정할 수도 있습니다.

7

  • 다음과 같이 static enumeration 을 설정하겠습니다.

8

(🚨 Case 에 한글로 작성하고, 코드의 정의를 따라가면 아래와 같이 enum 이 만들어집니다. Case 에서는 영어로 작성해주고, Display Name 에서 한글로 작성하면 의도한대로 됩니다!)

9

  • 결과

10

3️⃣ 이제, 동적인 선택 목록을 생성해보자.

  • 목록이 다양하거나 동적으로 생성되는 경우, dynamic options 가 있는 type 을 대신 사용할 수 있습니다.
  • Type pop-up menu 에서 Add Type… 을 통해 새로운 type 을 추가 및 설정할 수 있습니다. Card Type 을 추가하겠습니다.

11

  • cardName property 를 새롭게 추가해주고, Type pop-up menu 로부터 String 을 선택해줍니다.

12

  • 동적으로 만들 것이기 때문에 Options are provided dynamically 체크박스를 선택해 줍니다.

4️⃣ 동적으로 선택 목록을 프로젝트에 제공해보자.(Intents extension)

  • 프로젝트에 Intent extension target 을 추가합니다.

14

  • Intents extension 의 이름을 입력하고 Starting PointNone 을 설정합니다.

15

  • 이전에 만든 Intent definition file(SelectMyCardIntents) 을 선택하고, Target Membership 에서 containing app(extension 을 포함하는 앱), widget extension, Intents extension 모두 체크해주면 됩니다.

16

  • 동적인 값을 제공하기 위해서 Intent 을 핸들링해야 하는데 이때 프로토콜을 채택해서 확장할 수 있습니다.

17

  • 프로토콜을 준수하라는 오류 메시지의 fix 를 통해 아래의 stub 함수를 만들어주는데, escaping closure를 사용하는 함수와 async/await 를 사용하는 함수 두 가지로 준수할 수 있습니다.

🚨 트러블 슈팅 - resolveCharacter(for:with:)?

  • 이때 provide[Type]OptionsCollection(for:with:) 외에도 resolve[Type](for:with:) 메서드를 구현하라고 합니다.

18

  • 이는 아래의 Resolvable 를 체크해서 생기는 필수 구현 메서드입니다. 해제해주면 됩니다.

이어서 진행해보겠습니다.

provideMyCardOptionsCollection(for:with:) 메서드를 구현하기 위해서 containing app 에서 구조체를 생성하겠습니다. 그렇기 때문에 타겟은 다음과 같이 설정합니다.

20

  • MyCardDetail.swift 를 사용해서 provideMyCardOptionsCollection(for:with:) 구현해보겠습니다.
import UIKit

struct MyCardDetail {
    let cardName: String
    let userName: String
    let cardImage: UIImage
    
    static let availableMyCards = [
        MyCardDetail(cardName: "첫번째 카드", userName: "첫현규", cardImage: UIImage(named: "background") ?? UIImage()),
        MyCardDetail(cardName: "두번째 카드", userName: "두현규", cardImage: UIImage(named: "imgCardWidget") ?? UIImage())
    ]
}
  • IntentHandler.swift
import Intents

class IntentHandler: INExtension, SelectMyCardIntentHandling  {
    func provideMyCardOptionsCollection(for intent: SelectMyCardIntent, with completion: @escaping (INObjectCollection<Card>?, Error?) -> Void) {

        // ✅ intent defintion file 에서 만든 custom type 인 Card 를 사용.
        let myCards: [Card] = MyCardDetail.availableMyCards.map { card in

            // ✅ 구분자가 되는 identifier 로 card.cardName 프로퍼티 사용.
            let myCard = Card(identifier: card.cardName, display: card.cardName)

            // ✅ 위의 convenience initializer 말고도 새로 추가한 attribute 에 대해서 초기화.
            myCard.cardName = card.cardName
            return myCard
        }
        
        // ✅ completion handler 로 넘겨줄 INObjectCollection 생성.
        let collection = INObjectCollection(items: myCards)

        completion(collection, nil)
    }
    
    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.
        
        return self
    }
}
  • intent definition file 에서 설정한 intent 의 parameter 의 Display Name 이 좌측에 보여집니다.
  • IntentHandler 에서 MyCardDetail.availableMyCards 로 설정한 completion handler 를 통해 우측에 동적인 목록이 보여집니다.

21

🚨 트러블 슈팅 - 기본값으로 첫번째 카드가 선택되도록 하고싶어요!

  • 기본값이 선택되지 않아서 아래와 같이 비어있게 되는데 선택되도록 해보겠습니다.

22

  • dynamic options 에서 parameter 의 기본값을 default[Type](for:) (설정한 Custom Type 이 들어감)메서드로 설정할 수 있습니다.
func defaultMyCard(for intent: SelectMyCardIntent) -> Card? {
    let defaultCard = MyCardDetail.availableMyCards[0]
    let card = Card(identifier: defaultCard.cardName, display: defaultCard.cardName)
    card.cardName = defaultCard.cardName
        
    return card
}

결과

  • 기본값을 설정하였기 때문에 “첫번째 카드” 로 표시되고, 선택 목록 중에 체크되어 있는 것을 확인할 수 있습니다.

결과 화면 중에 위젯에 데이터가 반영되는 과정을 다음 단계에서 진행해보겠습니다.


5️⃣ IntentHandler 수정

  • 공유된 데이터를 Configurable Widget 에 반영해보겠습니다.
    • IntentHandler 에서 데이터 모델의 static property 를 사용하던 로직에서 CoreData 를 사용하여 조회하여 동적 선택 목록을 구현해보겠습니다.
    • IntentTimelineProvider 를 수정해서 widget entry 를 통해 데이터를 전달해보겠습니다.
  • IntentHandler.swift 에서 CoreDataManager 클래스를 사용하기 위해서 CoreDataManagerTarget Membership 도 수정해줍니다.

24

  • IntentHandler.swift 에서 AppGroup 을 활용한 CoreDataManager 를 사용하기 때문에 다음과 같이 Capability 를 추가해야한다.

25

  • 기존에 static property 인 MyCardDetail.availableMyCards 을 사용하던 코드에서 CoreData 를 활용한 코드로 변경하였습니다.
import Intents
  
class IntentHandler: INExtension {
    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.
        
        return self
    }
}

extension IntentHandler: SelectMyCardIntentHandling {
    func provideMyCardOptionsCollection(for intent: SelectMyCardIntent, with completion: @escaping (INObjectCollection<Card>?, Error?) -> Void) {

     // let myCards: [Card] = MyCardDetail.availableMyCards.map { card in
        // ✅ CoreData 조회.
        let cardDetail = CoreDataManager.shared.fetch(entityName: "CardDetail")
        let myCards: [Card] = cardDetail.map { card in
            let cardName = card.value(forKey: "cardName") as? String ?? ""
            let myCard = Card(identifier: cardName, display: cardName)
            
            myCard.cardName = cardName
            return myCard
        }
        
        let collection = INObjectCollection(items: myCards)

        completion(collection, nil)
    }
    
    func defaultMyCard(for intent: SelectMyCardIntent) -> Card? {

     // let defaultCard = MyCardDetail.availableMyCards[0]
        // ✅ CoreData 조회.
        let cardDetail = CoreDataManager.shared.fetch(entityName: "CardDetail")
        let defaultCardName = cardDetail[0].value(forKey: "cardName") as? String ?? ""        

        let card = Card(identifier: defaultCardName, display: defaultCardName)
        card.cardName = defaultCardName
        
        return card
    }
}

6️⃣ IntentTimelineProvider 과 위젯 수정

  • core data 로 조회된 명함의 세부사항을 알려주는 MyCardDetail 데이터 모델을 widget 에서 사용할 것이기 때문에 다음과 같이 Target Membership 에서 WidgetsExtension 도 체크해줍니다.

26

  • MyCardWidget.swift 에서 CoreData 를 통해 데이터를 조회하고, Provider 를 수정하여 cardName 이 동일한 CardDetail 을 결정하여 widget view 에서 보여주겠습니다. 그럼 최종적으로 다음과 같이 작성할 수 있습니다.
import WidgetKit
import SwiftUI
import Intents

// MARK: - Provider

// ✅ Configurable widget 을 만들기 위해서 IntentTimelineProvider 채택.
struct MyCardProvider: IntentTimelineProvider {
    
    // 👉 WidgetKit 이 처음으로 위젯을 표시할 때 placeholder 로 렌더링합니다.
    // placeholder view 는 사용자에게 위젯의 일반적인 표현을 표시하여 정보를 제공합니다.
    func placeholder(in context: Context) -> MyCardEntry {

        // ✅ CoreData 조회.
        // 가장 첫 번째 정보(대표가 되는 card)를 보여주겠습니다.
        let cardDetail = CoreDataManager.shared.fetch(entityName: "CardDetail")
        let myCardDetail = MyCardDetail(cardName: cardDetail[0].value(forKey: "cardName") as? String ?? "",
                                        userName: cardDetail[0].value(forKey: "userName") as? String ?? "",
                                        cardImage: UIImage(data: cardDetail[0].value(forKey: "cardImage") as? Data ?? Data()) ?? UIImage())
        return MyCardEntry(date: Date(), detail: myCardDetail)
    }
    
    // 👉 위젯을 추가할때와 같이 일시적인 상황에서 표시하기 위해서 snapshot 을 요청한다.
    // 위젯의 현재 상태를 가져오거나 계산하는데 몇 초가 걸릴 수 있는 경우에는 샘플 데이터를 제공하여 가능한 빨리 completion handler 를 호출해야 합니다.
    func getSnapshot(for configuration: SelectMyCardIntent, in context: Context, completion: @escaping (MyCardEntry) -> ()) {

        // ✅ CoreData 조회.
        // 가장 첫 번째 정보(대표가 되는 card)를 보여주겠습니다.
        let cardDetail = CoreDataManager.shared.fetch(entityName: "CardDetail")
        let myCardDetail = MyCardDetail(cardName: cardDetail[0].value(forKey: "cardName") as? String ?? "",
                                        userName: cardDetail[0].value(forKey: "userName") as? String ?? "",
                                        cardImage: UIImage(data: cardDetail[0].value(forKey: "cardImage") as? Data ?? Data()) ?? UIImage())
        let entry = MyCardEntry(date: Date(), detail: myCardDetail)
        completion(entry)
    }
    
    // 👉 위젯을 업데이트하기 위해서 현재 시간 및 선택적으로 미래 시간에 대한 timline 배열을 제공합니다.
    func getTimeline(for configuration: SelectMyCardIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [MyCardEntry] = []
        
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            
            // ✅ CoreData 조회.
            let cardDetail = CoreDataManager.shared.fetch(entityName: "CardDetail")
            
            // ✅ SelectMyCardIntent 에서 전달되는 cardName 을 cardDetail 과 대조해서 동일한 카드를 결정.
            let myCardName = configuration.MyCard?.cardName
            cardDetail.forEach { card in
                guard let cardName = cardDetail[0].value(forKey: "cardName") as? String else { return }
                
                if myCardName == cardName {
                    let myCardDetail = MyCardDetail(cardName: cardName,
                                                    userName: cardDetail[0].value(forKey: "userName") as? String ?? "",
                                                    cardImage: UIImage(data: cardDetail[0].value(forKey: "cardImage") as? Data ?? Data()) ?? UIImage())
                    let entry = MyCardEntry(date: entryDate, detail: myCardDetail)
                    entries.append(entry)
                }
            }
        }
        
        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

// MARK: - TimelineEntry
struct MyCardEntry: TimelineEntry {
    let date: Date
    let detail: MyCardDetail
}

// MARK: - View
struct MyCardEnytryView : View {
    var entry: MyCardProvider.Entry
    @Environment(\.colorScheme) var colorScheme
    
    var body: some View {
        ZStack {
            Color.white
            GeometryReader { proxy in
                HStack(spacing: 0) {
                    // ✅ MyCardEntry timeline entry 을 사용해서 동적으로 위젯에서 표시할 수 있다.
                    Image(uiImage: entry.detail.cardImage)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: proxy.size.height * (92 / 152), height: proxy.size.height)
                        .clipped()
                    Color.backgroundColor(for: colorScheme)
                }
            }
            VStack {
                HStack {
                    // ✅
                    Text(entry.detail.cardName)
                        .font(.system(size: 15))
                        .foregroundColor(.init(white: 1.0, opacity: 0.8))
                        .padding(EdgeInsets(top: 12, leading: 10, bottom: 0, trailing: 0))
                        .lineLimit(1)
                    Spacer()
                    Image("logoNada")
                        .padding(EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 10))
                }
                Spacer()
                HStack {
                    Spacer()
                    // ✅
                    Text(entry.detail.userName)
                        .font(.system(size: 15))
                        .foregroundColor(.userNameColor(for: colorScheme))
                        .padding(EdgeInsets(top: 0, leading: 10, bottom: 11, trailing: 10))
                        .lineLimit(1)
                        // .foregroundColor(colorScheme == .light ? Color(red: 19.0 / 255.0, green: 20.0 / 255.0, blue: 22.0 / 255.0) : Color(red: 255.0 / 255.0, green: 255.0 / 255.0, blue: 255.0 / 255.0))
                }
            }
        }
    }
}

// MARK: - Widget
struct MyCardWidget: Widget {
    let kind: String = "MyCardWidget"
    
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind,
                            intent: SelectMyCardIntent.self,
                            provider: MyCardProvider()) { entry in
            MyCardEnytryView(entry: entry)
        }
        .configurationDisplayName("명함 위젯")
        .description("명함 이미지를 보여주고,\n내 명함으로 빠르게 접근합니다.")
        .supportedFamilies([.systemSmall])
    }
}

// MARK: - Preivews
struct MyCardWidget_Previews: PreviewProvider {
    static var previews: some View {
        MyCardEnytryView(entry: MyCardEntry(date: Date(), detail: MyCardDetail.availableMyCards[0]))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
} 

👉 QR Code 위젯은 static configuration

  • QR Code 위젯은 위젯 편집이 필요하지 않은 static configuration 입니다. 이는 위젯을 처음에 만들 때 Include Configuration Intent 를 체크하지 않으면 자동으로 코드를 만들어줍니다.

27

  • Configuration Intent 를 포함하지 않는 코드가 어떻게 차이가 나는지 잠깐 살펴보겠습니다.
import WidgetKit
import SwiftUI
// ✅import Intents

// ✅struct Provider: IntentTimelineProvider {
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
//    ✅SimpleEntry(date: Date(), configuration: ConfigurationIntent())
        SimpleEntry(date: Date())
    }

// ✅func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
//     ✅let entry = SimpleEntry(date: Date(), configuration: configuration)
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

// ✅func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
//        ✅let entry = SimpleEntry(date: entryDate, configuration: configuration)
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
//  ✅let configuration: ConfigurationIntent
}

struct QRCodeEnytryView : View {
// 👉 이미지로 뷰를 채웠습니다.
    Image("widgetQr")
            .resizable()
            .scaledToFill()
}

struct QRCodeWidget: Widget {
    let kind: String = "QRCodeWidget"
    
    var body: some WidgetConfiguration {
//    ✅IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            QRCodeEnytryView(entry: entry)
        }
        // ...
    }
}

struct QRCodeWidget_Previews: PreviewProvider {
    static var previews: some View {
//    ✅QRCodeEnytryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
        QRCodeEnytryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

QRCodeWidget 은 이미지를 사용하여서 뷰를 구현하였습니다.(static configuration 이기 때문에 위젯 편집이 없습니다.)

28

추가적으로 위젯을 통해 앱의 특정 뷰로 이동하도록 구현하겠습니다. 아래의 글을 작성하였습니다.

iOS) 위젯으로 앱의 특정 뷰로 이동(widgetURL)

👉 GitHub

https://github.com/hyun99999/WidgetsWithCoreDataTutorial-iOS

참고:

Add configuration and intelligence to your widgets - WWDC20 - Videos - Apple Developer

[WidgetKit] IntentConfiguration으로 Edit Widget 기능을 추가하기

iOS) Configurable Property 가 있는 Widget 만들기

Categories:

Updated: