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

7 minute read

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

  • 공유한 데이터를 보여줄 widget 을 구현.
  • 다크모드를 적용.
  • CoreData 데이터 공유. 3. Configurable Widget 만들기

1️⃣ 위젯 UI 구현

  • 우선, 아래와 같은 위젯의 뷰를 정적인 데이터를 채워넣어 구현해보겠습니다.(다크모드도 구현하겠습니다.)

1

struct MyCardEnytryView : View {
    var entry: MyCardProvider.Entry
    // ✅ 다크모드를 판단하기 위한 enviornment 변수.
    @Environment(\.colorScheme) var colorScheme
    
    // TODO: - entry 변수를 사용하여 동적으로 컨텐츠 대응. 지금은 정적으로 대응.
    
    var body: some View {
        ZStack {
            Color.white
            GeometryReader { proxy in
                HStack(spacing: 0) {
                    Image("imgCardWidget")
                        .resizable()
                        // ✅ aspectRatio(width: 너비, height: 높이, contentMode: .fill) 코드에서 지정하는 너비와 높이는 contentMode 에 대한 것이다.
                        // (전체 frame 이 아님. 그래서 이미지의 크기를 위해서는 추후에 frame 에 대한 코드를 작성해주면 된다.)
                        // 현재 aspect ratio 를 그대로 사용하고 싶다면 contentMode 파라미터만 채워주면 된다.
                        .aspectRatio(contentMode: .fill)
                        // ✅ GeometryReader 의 GeometryProxy 사용해서 부모 컨테이너의 높이 값을 사용.
                        .frame(width: proxy.size.height * (92 / 152), height: proxy.size.height)

                     // 👉 다크모드를 다룰 때 다시 살펴보겠습니다.
                     // Color.backgroundColor(for: colorScheme)
                }
            }
            VStack {
                HStack {
                    Text("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("userName")
                        .font(.system(size: 15))
                        // 👉 다크모드를 다룰 때 다시 살펴보겠습니다.
                        // .foregroundColor(.userNameColor(for: colorScheme))
                        .padding(EdgeInsets(top: 0, leading: 10, bottom: 11, trailing: 10))
                        .lineLimit(1)
                }
            }
        }
    }
}

2️⃣ 다크모드를 적용

  • colorScheme 파라미터를 통해서 light, dark appearances 에 해당하는 ColorScheme 를 전달하고, switch 문에서 분기처리 해주었습니다.
  • ColorScheme@frozen 키워드를 사용하지 않기 때문에 언젠가 미래에 case 가 추가될 수 있습니다. 이때 default 로 분기처리하면 추가된 case 에 대해서 안내받을 수 없기 때문에 @unknown 키워드를 사용하여, 경고를 통해 알 수 있도록 하였습니다.
import SwiftUI

extension Color {
    static func backgroundColor(for colorScheme: ColorScheme) -> Color {
        switch colorScheme {
        case .light:
            return Color(red: 255.0 / 255.0, green: 255.0 / 255.0, blue: 255.0 / 255.0)
        case .dark:
            return Color(red: 19.0 / 255.0, green: 20.0 / 255.0, blue: 22.0 / 255.0, opacity: 0.5)
            // ✅ future version 에서 추가적으로 알려지지 않은 case 가 생겼을 때 사용하는 쪽에서 경고를 얻어볼 수 있는 편의 기능을 제공한다.
            // 이때 알려지지 않은 case 에 대한 대응을 할 수 있다.
        @unknown default:
            return Color(red: 255.0 / 255.0, green: 255.0 / 255.0, blue: 255.0 / 255.0)
        }
    }
    
    static func userNameColor(for colorScheme: ColorScheme) -> Color {
        switch colorScheme {
        case .light:
            return Color(red: 19.0 / 255.0, green: 20.0 / 255.0, blue: 22.0 / 255.0)
        case .dark:
            return Color(red: 255.0 / 255.0, green: 255.0 / 255.0, blue: 255.0 / 255.0)
        @unknown default:
            return Color(red: 19.0 / 255.0, green: 20.0 / 255.0, blue: 22.0 / 255.0)
        }
    }
}

// ✅ 위에서 사용한 코드를 다시 살펴보겠습니다.

Color.backgroundColor(for: colorScheme)

Text("userName")
    .foregroundColor(.userNameColor(for: colorScheme))

// ✅ 아래와 같이 삼항 연산자를 활용해서 사용할 수도 있습니다.
// .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))

3️⃣ 공유된 데이터를 사용해봅시다.

  • 프로젝트를 만들 때 CoreData 를 사용하겠다고 체크하면 자동으로 AppDelegate 에 코드를 생성해줍니다.
// MARK: - Core Data stack

    lazy var persistentContainer: NSPersistentContainer = {
        /*
         The persistent container for the application. This implementation
         creates and returns a container, having loaded the store for the
         application to it. This property is optional since there are legitimate
         error conditions that could cause the creation of the store to fail.
        */
        let container = NSPersistentContainer(name: "WidgetsWithCoreDataTutorial_iOS")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                 
                /*
                 Typical reasons for an error here include:
                 * The parent directory does not exist, cannot be created, or disallows writing.
                 * The persistent store is not accessible, due to permissions or data protection when the device is locked.
                 * The device is out of space.
                 * The store could not be migrated to the current model version.
                 Check the error message to determine what the actual problem was.
                 */
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

    // MARK: - Core Data Saving support

    func saveContext () {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
  • containing app 에서 데이터를 저장하기 위해 CoreData entitiy 를 CardDetail 로 생성하겠습니다.
    • 이미지를 data 로 변환해서 cardImage attribute 로 저장하겠습니다.
    • widget extension 에서도 사용할 것이기 때문에 target membership 에서 체크해줍니다.

2

👉 CoreData 저장 및 조회

  • widget extension 과 데이터를 공유하기 위해서 AppGroup 을 사용하겠습니다. 사용자 정의 CoreDataManager 클래스를 추가하겠습니다.(이 역시 widget extension 에서도 사용할 것이기 때문에 target membership 에서 체크해줍니다.)
  • 기본적인 저장 및 조회에 대한 기능을 구현하겠습니다.
// ✅ AppDelegate 에 있던 코드를 옮겨 CoreDataManager 라는 클래스를 구현하였습니다.
import CoreData
import UIKit

class CoreDataManager {
    // 👉 앱의 라이프사이클 동안 한번 생성하게되면 중복되지 않고 어디서든 동일한 인스턴스를 호출할 수 있도록 싱글톤 패턴을 적용.
    static let shared = CoreDataManager()
    private init() { }
    
    // ✅ AppGroup 을 활용하여 CoreData 로 저장한 데이터를 공유.
    private let appGroup = "group.WidgetsWithCoreDataTutorial-iOS"
    
    lazy var persistentContainer: NSPersistentContainer = {
        
        // ✅ App Group identifier 와 연결된 container directory 를 반환. 즉, 해당 group 의 공유 directory 의 파일 시스템 내 위치를 지정하는 NSURL 인스턴스를 반환.
        guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { fatalError("Shared file container could not be created.") }
        
        let storeURL = url.appending(path: "WidgetsWithCoreDataTutorial_iOS.sqlite")
        
        // ✅ persistent store 를 생성 및 로드하는데 사용되는 description object.
        let storeDescription = NSPersistentStoreDescription(url: storeURL)

        let container = NSPersistentContainer(name: "WidgetsWithCoreDataTutorial_iOS")
        
        // ✅ 생성된 container 에서 사용하는 persistent store 의 타입을 재정의하려면 NSPersistentStoreDescription 배열로 설정할 수 있다.
        container.persistentStoreDescriptions = [storeDescription]
        
        // ✅ container 가 persistent store 를 로드하고, CoreData stack 을 생성 완료하도록 지시한다.
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()
}

// MARK: - extensions
extension CoreDataManager {
    // ✅ scene 이 foreground 에서 background 로 전환될 때 호출되는 sceneDidEnterBackground(_ scene:) 메서드가 SceneDelegate 에서 구현되어있습니다.
    // 해당 메서드가 호출될 때, CoreData 의 변경사항을 저장하기 위해 saveContext() 메서드를 호출해야 합니다.(AppDelegate 에 있던 코드 그대로 사용하였습니다.)
    func saveContext () {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
    
    // 👉 아래에서 살펴보겠습니다.
    func fetch(entityName: String) -> [NSManagedObject] {
        // ...
    }
}
  • widget extension 에서 데이터를 조회할 수 있도록 메서드를 구현하겠습니다.
func fetch(entityName: String) -> [NSManagedObject] {
        let viewContext = persistentContainer.viewContext

        // ✅ entityName 파라미터에 따라서 해당 entity 를 조회하는 request 생성.
        let fetchReqeust = NSFetchRequest<NSManagedObject>(entityName: entityName)
        
        do {
            // ✅ 조회.
            let fetchResult = try viewContext.fetch(fetchReqeust)
            
            return fetchResult
        } catch {
            let nserror = error as NSError
            fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
        }
    }

👉 적용해보자.

  • ViewController.swift 에서 다음과 같이 CoreData 를 활용하여 데이터를 저장하는 메서드를 구현하였습니다.
import CoreData
import UIKit

class ViewController: UIViewController {

    // MARK: - Components
    @IBOutlet weak var backgroundImageView: UIImageView!
    @IBOutlet weak var cardNameLabel: UILabel!
    @IBOutlet weak var nameLabel: UILabel!
    
    // MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 여러 데이터를 저장하기 위해서 viewDidLoad 에서 다음과 같이 함수를 호출하였습니다.
        save(cardNameLabel.text, userName: nameLabel.text, image: backgroundImageView.image ?? UIImage())
        save("두 번째 카드", userName: "두현규", image: UIImage(named: "imgCardWidget") ?? UIImage())
    }
}

// MARK: - Extension
extension ViewController {
    /* 저장
    1. NSManagedObjectContext를 가져온다.
    2. 저장할 Entity 가져온다.
    3. NSManagedObject 생성한다.
    4. NSManagedObjectContext 저장해준다.
    */
    func save(_ cardName: String?, userName: String?, image: UIImage) {
        // ✅ CoreDataManager 의 persistentContainer 를 가져와서 entity 생성.
        let viewContext = CoreDataManager.shared.persistentContainer.viewContext
        let entity = NSEntityDescription.entity(forEntityName: "CardDetail", in: viewContext)
        
        // ✅ NSManagedObject 생성.
        if let entity {
            let card = NSManagedObject(entity: entity, insertInto: viewContext)
            card.setValue(cardName, forKey: "cardName")
            card.setValue(userName, forKey: "userName")
            if let imageData = image.pngData() {
                card.setValue(imageData, forKey: "cardImage")
            }
            
            // ✅ NSManagedObjectContext 저장.
            do {
                try viewContext.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
}
  • widget 에서 CardDetail entity 를 조회하고 정적으로 반영하겠습니다.(Provider 를 사용하는 동적인 작업은 다음 글에서 진행하겠습니다. 주석처리한 코드를 사용해서 동적으로 반영할 수 있습니다.)
struct MyCardEntry: TimelineEntry {
    let date: Date
    let detail: MyCardDetail
}

struct MyCardEnytryView : View {
    var entry: MyCardProvider.Entry

    // ...
    
    // ✅ CoreData 로 저장한 데이터 조회.
    let cardDetail = CoreDataManager.shared.fetch(entityName: "CardDetail")
    
    var body: some View {

        // ...

        // ✅ cardDetail 배열에서 두 번째 요소를 사용하여 정적으로 위젯에 반영해보겠습니다.
        // value(forKey:) - Returns the value for the property specified by key.
        let cardImage = UIImage(data: cardDetail[1].value(forKey: "cardImage") as? Data ?? Data()) ?? UIImage()
        Image(uiImage: cardImage)
    //  Image(uiImage: entry.detail.cardImage)

        // ...

        let cardName = cardDetail[1].value(forKey: "cardName") as? String ?? ""
        Text(cardName)
    //  Text(entry.detail.cardName)

        // ...

        let userName = cardDetail[1].value(forKey: "userName") as? String ?? ""
        Text(userName)
    //  Text(entry.detail.userName)
    }
}

👉 결과

다음 글에서는 이렇게 불러올 수 있는 데이터를 사용해서 위젯 편집할 때 선택 목록을 구성해보겠습니다.

👉 GitHub

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

참고:

sharing data with a widget

[Sharing Core Data Storage with App Extensions eyupgoymen](https://www.eyupgoymen.com/articles/sharing-core-data-storage-with-extensions/)

Categories:

Updated: