LoginSignup
20
9

More than 1 year has passed since last update.

SwiftUIを使ってWidgetを実装

Last updated at Posted at 2021-12-18

はじめに

この記事は、NTTテクノクロス Advent Calendar 2021 19日目です。

NTTテクノクロスの神長です。社内では、モバイル向けアプリ開発の技術支援、ノウハウ記事の執筆、社内研修講師等を担当してます。

また、業務外の活動は2018年から地域の小学生を対象にしたプログラミング教育社内の仲間と技術書典へ書籍の出展等を行ってます。

今回、Widgetを題材にした記事を書こうと考えた理由は、以下の通りです。

  • iOSDC 2021でWidgetに関する良いトークを視聴できたから(参考情報のセクションを参照してください)
  • 社内研修(iOS)の1コンテンツとしてSwiftUIを使ったWidgetの実装を取り上げ、サンプルアプリの開発過程でノウハウ・知見を得ることができたから

これからWidgetを使ったアプリ開発を行う方のお役に立てればと思ってます。

1. Widgetとは

Widgetとは、ニュース、天気アプリ等に利用され、アプリを起動せずとも限られたスペースの中で、アプリの機能の一部をユーザーに提供するものです。

WWDCのセッション:Meet WidgetKitでも、 『Glanceable、Relevant、Personalized』 といったキーワードをあげ、特徴が強調されていました。

  • Glanceable:見てすぐに欲しい情報を手に入れられる
  • Relevant:ユーザーの関心がある情報を適切な時間に表示する
  • Personalized:利用するユーザーに合わせたカスタマイズができる

iOS14からWidgetを3種類のサイズから選び、ホーム画面上に配置ができるようになりました。(下記表は、Apple社のHuman Interface Guidelines WidgetsのDesignより引用してます)

WidgetSize.png

これによりユーザーは、より早く、価値のある/必要な情報を簡単に得られるようになりました。ここでは、Widgetについて以下事項にポイント絞って説明します。

  • 開発環境
  • プロジェクトへの導入方法
  • サンプルアプリを例にした実装方法
  • 記事執筆で参考にした情報

2. 開発環境

この記事は、以下環境を使って執筆を行いました。

  • Xcode:Version 13.1 (13A1030d)
  • Swift:5.5.1
  • iOS:15.0

3. プロジェクトへの導入方法

『CoreDataWidgetSample』 というサンプルアプリのプロジェクトにWidgetを導入する手順を説明します。CoreDataWidgetSampleは、ToolBarからToDoの追加、追加したToDoの表示、削除が行えるアプリです。

CoreDataWidgetSample.png

3-1. Widgetの追加

以下手順でWidgetを追加します。

  • Xcodeでアプリケーションのプロジェクトを開き、[ファイル]>[新規作成]>[ターゲット]を選択してください。
  • Application Extension(アプリケーション拡張)グループから、Widget Extension(ウィジェット拡張)を選択し、Nextをクリックしてください。

Widget1.png

  • 拡張機能の名前を入力してください。(ここでは 『SampleWidget』 としてます)

Widget2.png

  • Finishボタンをクリックしてください。
  • プロジェクトツリー上に 『SampleWidgetExtension(フォルダ)』 が生成されることを確認してください。

Widget3.png

3-2. AppGroupsの設定

Widgetを追加後、App Groupsの設定します。App Groupsは、iOS8から提供された機能です。
同一開発者が開発したアプリ間で共有領域にデータを保存 ※1 することで複数のアプリ間でデータの読み書きが行えるものです。

Widgetの開発では、アプリとWidget間でデータの読み書きが発生するため、この機能を利用します。

※1 UserDefaults、ファイルを利用してアプリ間でのデータ共有ができます。

  • アプリのTARGETS>Singing & Capabilitiesを選択してください。
  • 『+Capability』 を押下し、以下画面を表示して、その中から 『App Groups』 を選択してください。

AppGroups1.png

  • App Groupsの枠内の下部にある+ボタンを押下すると以下の画面が表示されるので、containerの設定を行ってください。(ここでは、group.com.ntttx.CoreDataWidgetSample.SampleWidgetと入力)

AppGroups2.png

  • 以下設定がなされていることを確認してください。

AppGroups3.png

  • この設定をWidgetのTARGETSに対しても行ってください。containerには、 必ずアプリ同じ識別子を設定 してください。

4. サンプルアプリを例にした実装方法

WidgetでアプリがCoreDataに保存しているデータを表示する場合を例に、Widgetのテンプレートソースコード(SampleWidget.swift) ※1 の解説を含め、実装方法を説明します。

WidgetSample1.png

※1 この記事では、Widget Extensionに『SampleWidget』という名称を設定した為、テンプレートのソースコードが『SampleWidget.swift』となっています。

4-1. テンプレートのソースコード

テンプレートのソースコードは、プロジェクトにWidget Extensionを追加することで生成されます。
生成されたソースコードには、WidgetKitがインポートされ、Widgetを起動させる為の最低限のソースコードが実装されてます。
ここでは、テンプレートのソースコードに実装されている構造体、関数の役割を説明します。

import WidgetKit
import SwiftUI
import CoreData

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

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

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

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

struct SimpleEntry: TimelineEntry {
    let date: Date
}

struct SampleWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

@main
struct SampleWidget: Widget {
    let kind: String = "SampleWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            SampleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct SampleWidget_Previews: PreviewProvider {
    static var previews: some View {
        SampleWidgetEntryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

4-1-1. Widgetのエントリーポイント

以下ソースコードは、SampleWidgetがプログラムで最初に実行されるエントリーポイントになります。

bodyプロパティでStaticConfiguration ※1 を生成してます。

  • kind : Widgetの識別子
  • provider : Widgetの更新タイミング(TimeLine ※2 )をWidgetKitに提供するプロバイダ

configurationDisplayNameに設定したテキストは『Widgetの名称』、
descriptionに設定したテキストは、Widget Gallary(Widgetの追加画面)に表示される『Widgetの説明』になります。

SampleWidgetEntryViewは、Widgetの画面を定義する構造体です。Widgetの画面に表示する内容を変更する場合、この構造体を編集します。

@main
struct SampleWidget: Widget {
    let kind: String = "SampleWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            SampleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}
struct SampleWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

※1 SampleWidgetはWidgetプロトコルに準拠する意味を持ち、WidgetConfigurationプロトコルのインスタンス(body)を返す必要があります。
WidgetConfigurationの型には、IntentConfigurationStaticConfigurationがあります。今回のサンプルでは、StaticConfigurationで実装を行ってます。

  • IntentConfiguration:ユーザ設定可能なプロパティを持たないWidgetを実装する際に利用する型
  • StaticConfiguration:ユーザーが設定可能なプロパティを持つWidgetを実装する際に利用する型

IntentConfigurationを利用する場合、Widget Extensionをプロジェクトに導入時に 『Include Configuration Intentのチェックボックスをオン』 にする必要があります。

※2 詳細は『TimelineProvider』のセクションを参照してください。

4-1-2. TimelineProvider

TimelineProvider(プロトコル)は、Widgetの更新タイミングを提供します。
そして、TimelineProviderには、以下3つの関数があります。ここでは、その詳細を説明します。

  • placeholder
  • getSnapshot
  • getTimeline
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

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

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

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

4-1-2-1. placeholder

placeholderは、Widgetの初期表示を行う関数です。
テンプレートのソースコードでは、TimelineEntryに準拠したSimpleEntryを呼び出し、現在時刻を表示する実装となってます。

func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}

4-1-2-2. getSnapshot

getSnapshotは、Widgetをホーム画面に追加時、Widget Gallaryでの画面に表示するデータを作成する関数です。
テンプレートのソースコードでは、TimelineEntryに準拠したSimpleEntryを呼び出し、現在時刻を表示する実装となってます。

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

WidgetGallary.png

4-1-2-3. getTimeline

getTimelineは、WidgetKitへタイムラインを提供する関数です。
テンプレートのソースコードでは、現在時刻から1時間毎に4時間後まで、SimpleEntryを呼び出し、配列(entries)に追加。
その後、Timelineインスタンスを生成し、completion(クロージャー)を呼び出すことでWidgetKitにタイムラインを提供する実装となってます。(Keeping a Widget Up To Dateから更新イメージを引用してます)

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []

    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()
    for hourOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = SimpleEntry(date: entryDate)
        entries.append(entry)
    }

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

WidgetUpdate.png

なお、Timelineとは、WidgetのViewをどのように更新するかを決める型です。引数は以下の通りです。

  • entries : タイムラインを構成するデータ(配列)
  • policy : タイムラインの更新ポリシー(ポリシーには、.atEnd.after.neverの3種いずれかを指定できる ※1 )

※1 .atEnd.after.neverの詳細は以下を参照してください。

  • .atEnd :デフォルトの更新ポリシー。タイムラインを構成するデータ全てを表示し終えた時、再度getTimelineを呼び出す。
  • .after(_ date: Date) :タイムラインを構成するデータ全てを表示し終えたかを問わず、Date型の引数で指定した時刻にgetTimelineを呼び出す。
  • .never :タイムラインを構成するデータ全てを表示し終えた後、再度getTimelineを呼び出さないポリシー。

4-1-3. プレビュー

SwiftUIのプレビュー機能は、Widgetでも利用できます。

また、WidgetPreviewContextでWidgetのサイズを指定することができます。サイズは以下3種を指定できます。デフォルトのソースコードでは『.systemSmall』が指定されてます。

  • .systemSmall:Smallサイズ
  • .systemMedium:Mediumサイズ
  • .systemLarge:Largeサイズ
struct SampleWidget_Previews: PreviewProvider {
    static var previews: some View {
        SampleWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

WidgetPreview.png

4-2. 実装方法

今回のサンプルでは、ファイル(CoreData)を利用してアプリとWidget間のデータの共有を行ってます。
ここでは、以下ポイントに絞り、説明します。

  • Target Membershipの設定
  • CoreDataのテンプレートファイルの変更
  • WidgetテンプレートファイルからCoreDataを利用する設定

4-2-1. Target Membershipの設定

Widget(SampleWidgetExtension)からCoreDataを利用する為 、.xcdatamodeld(CoreDataのテーブル構造を定義するファイル)とCoreDataのテンプレートファイル(Persistence.swift)の Target MembershipWidget(SampleWidgetExtension) を含める設定を行います。

TargetMembership.png

4-2-2. CoreDataのテンプレートファイルの変更

AppGroupsを利用したデータ共有を行うため、CoreDataのテンプレートファイル(Persistence.swift)のPersistenceController
FileManager.default.containerURL(AppGroupsに設定したID).appendingPathComponent(ファイル名)をAppGroups用のURLを生成します。
生成後、NSPersistentContainerの設定を行います。

struct PersistenceController {
    // 中略
    // AppGroups用のURLを生成
    let appGroupContainerURL = FileManager.default
        .containerURL(forSecurityApplicationGroupIdentifier: "group.com.ntttx.CoreDataWidgetSample.SampleWidget")!
    let storeURL = appGroupContainerURL.appendingPathComponent("Task")

    // NSPersistentContainerの設定
    let container = NSPersistentContainer(name: "CoreDataWidgetSample")
    container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: storeURL)]
    container.loadPersistentStores(completionHandler: { storeDescription, error in
        if let error = error as NSError? {
            // 任意のエラー処理を実装する
        }
    })
}

4-2-3. WidgetテンプレートファイルからCoreDataを利用する設定

Widgetテンプレートファイル(SampleWidget.swift)からCoreDataを利用する場合、先ずWidgetのエントリーポイントに以下定義を行います。

  • NSPersistentContainer ※1 の定義
  • @Environment(.managedObjectContext)にNSManagedObjectContext ※2 の登録処理

※1 NSPersistentContainerは、管理対象オブジェクトモデル(NSManagedObjectModel)、永続ストアコーディネーター(NSPersistentStoreCoordinator)、管理対象オブジェクトコンテキスト(NSManagedObjectContext)を使い、CoreDataスタックの作成と管理を行うもの。

※2 レコードの操作(CoreDataからのデータをフェッチ、作成、変更、削除)を管理するもの。

@main
struct SampleWidget: Widget {
    let kind: String = "SampleWidget"
    // NSPersistentContainerの定義
    let persistenceController = PersistenceController.shared

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(context: PersistenceController.shared.managedObjectContext)) { entry in
            // @Environment(\.managedObjectContext)にNSManagedObjectContextの登録処理
            SampleWidgetEntryView(entry: entry).environment(\.managedObjectContext, PersistenceController.shared.managedObjectContext)


        }
        .configurationDisplayName("Sample Widget")
        .description("This is an sample widget.")
    }
}

続いて、CoreDataを参照する処理(NSFetchRequest)を実装します。ここではgetTimelineで『CoreDataから昇順でソートした検索結果を取得する』例を掲載します。

なお、Widgetではプロパティラッパー(@FetchRequest) ※1 は利用できないので注意が必要です。

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    // 中略
    var task:[Task]?

    // CoreDataから昇順でソートした検索結果を取得("time":タイムスタンプで昇順)
    let request = NSFetchRequest<Task>(entityName: "Task")
    request.sortDescriptors = [NSSortDescriptor(key: "time", ascending: true)]

    do{
        let result = try db.fetch(request)
        task = result
    }
    catch let error as NSError{
        print("fetch failed.\(error.userInfo)")
    }

    // ここから取得したデータを利用し、開発者が行いたい処理を実装する
}

※1 以下定義をすることで、プロパティに検索結果の格納とデータの変更に合わせ検索結果が最新化される仕組み。

@FetchRequest(
    entity: Task.entity(),
    sortDescriptors: [NSSortDescriptor(keyPath: \Task.time, ascending: true)],
    predicate: nil
) private var tasks: FetchedResults<Task>

5. 記事執筆で参考にした情報

本記事を執筆する上で参考にした書籍、サイトを掲載します。

最後に

最後まで読んで頂き、ありがとう御座います。
20日目となる明日の記事も楽しみにして頂ければと思います。

20
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
9