• トップ
  • ブログ一覧
  • 【iOS14】WidgetKitを使ってみた!
  • 【iOS14】WidgetKitを使ってみた!

    広告メディア事業部広告メディア事業部
    2020.11.24

    IT技術

    WidgetKitの実装方法をまとめてみた

    小林先生

    (株)ライトコードの小林(こばやし)です!

    iOS14からホーム画面にWidget機能が追加されましたね。

    筆者自身気になってはいたものの実装したことがありませんでした!

    そこで、この機会に色々触ってみつつ実装方法をまとめてみました。

    開発環境

    開発環境は以下のとおりです。

    MacOS10.15.4
    Swift5.3
    Xcode12.0.1

    Widget/WidgetKitとは

    Widgetとは、iOS14から追加された機能であり、ホーム画面にアプリに関する情報を一目でわかる形で表示することができるものです。

    iOSホーム画面

    Widget 自体は、SwiftUI でできた View であり、Timeline という機能によっては時間の経過とともに View が更新されていきます。

    また、WidgetKit とはそれらの機能を実現するためのフレームワークで、これを用いて Widget を構築していきます。

    Widgetの元となるアプリを実装する

    それでは早速、Widget を実装していきましょう!

    …と言いたいところですが、その前に Widget の元となるアプリが必要になるので、まずは事前準備として ToDo を表示するだけの簡単なアプリを作っていきます。

    プロジェクト作成

    Xcode のメニューバーから

    File > New > Project...

    を開いて、App を選択します。

    プロジェクト作成

    プロジェクト名を入力し、Interface は SwiftUI、Life Cycle は SwiftUI App で作成します。

    プロジェクト名入力

    Preview を起動すると「Hello, World!」と表示される View が確認できます。

    アプリ実装

    次に、ToDo を表示するためのアプリの実装を行っていきます。

    ディレクトリ構造は下記のようになっています。

    ディレクトリ構造画面

    今回の記事は Widget の実装がメインとなるので、このアプリの実装の説明は省略し、ソースコードのみを載せる形にします。

    1import SwiftUI
    2
    3@main
    4struct ToDoManagerApp: App {
    5    var body: some Scene {
    6        WindowGroup {
    7            ContentView()
    8        }
    9    }
    10}
    1import SwiftUI
    2
    3struct ContentView: View {
    4
    5    @State var todo1Active: Bool = false
    6    @State var todo2Active: Bool = false
    7    @State var todo3Active: Bool = false
    8
    9    var body: some View {
    10        NavigationView {
    11            List {
    12                NavigationLink(
    13                    destination: DetailView(todo: .todo_1), isActive: $todo1Active) {
    14                    TableRow(todo: .todo_1)
    15                }
    16                NavigationLink(
    17                    destination: DetailView(todo: .todo_2), isActive: $todo2Active) {
    18                    TableRow(todo: .todo_2)
    19                }
    20                NavigationLink(
    21                    destination: DetailView(todo: .todo_3), isActive: $todo3Active) {
    22                    TableRow(todo: .todo_3)
    23                }
    24            }
    25            .navigationBarTitle("ToDo List")
    26            .onOpenURL(perform: { (url) in
    27                todo1Active = url == ToDo.todo_1.url
    28                todo2Active = url == ToDo.todo_2.url
    29                todo3Active = url == ToDo.todo_3.url
    30            })
    31        }
    32    }
    33}
    34
    35struct TableRow: View {
    36    let todo: ToDo
    37    var body: some View {
    38        HStack {
    39            UserIcon(todo: todo)
    40            Overview(todo: todo)
    41            .padding()
    42        }
    43    }
    44}
    1import SwiftUI
    2
    3struct DetailView: View {
    4    let todo: ToDo
    5    var body: some View {
    6        VStack {
    7            HStack {
    8                UserIcon(todo: todo)
    9                Overview(todo: todo)
    10                .padding()
    11            }
    12            Text("担当者: \(todo.user.name)").padding()
    13            Text("説明: \(todo.bio)").padding()
    14        }
    15    }
    16}
    1import Foundation
    2
    3struct ToDo: Identifiable {
    4    var id: Int
    5    let title: String
    6    let user: User
    7    let deadline: String
    8    let url: URL
    9    let bio: String
    10
    11    static let todo_1 = ToDo(
    12        id: 1,
    13        title: "素材を集める",
    14        user: User.wizard,
    15        deadline: "2020/10/8",
    16        url: URL(string: "todo:///todo_1")!,
    17        bio: "武器を作るための素材を集めます。"
    18    )
    19
    20    static let todo_2 = ToDo(
    21        id: 2,
    22        title: "武器を作る",
    23        user: User.fighter,
    24        deadline: "2020/10/10",
    25        url: URL(string: "todo:///todo_2")!,
    26        bio: "敵のボスを倒すために武器を作ります。"
    27    )
    28
    29    static let todo_3 = ToDo(
    30        id: 3,
    31        title: "敵のボスを倒す",
    32        user: User.hero,
    33        deadline: "2020/10/15",
    34        url: URL(string: "todo:///todo_3")!,
    35        bio: "近くの村を襲った敵のボスを倒しにいきます。"
    36    )
    37
    38    static let availableToDoList = [todo_1, todo_2, todo_3]
    39}
    1import Foundation
    2
    3struct User: Identifiable {
    4    let id: String
    5    let name: String
    6    let icon: String
    7
    8    static let hero = User(
    9        id: "hero",
    10        name: "勇者",
    11        icon: "🦸‍♂️"
    12    )
    13
    14    static let fighter = User(
    15        id: "fighter",
    16        name: "格闘家",
    17        icon: "👨‍🎤"
    18    )
    19
    20    static let wizard = User(
    21        id: "wizard",
    22        name: "魔法使い",
    23        icon: "🧙"
    24    )
    25}
    1import SwiftUI
    2
    3struct UserIcon: View {
    4    var todo: ToDo
    5    var body: some View {
    6        ZStack {
    7            Circle().fill(Color.green)
    8                .frame(maxWidth: 50, maxHeight: 50)
    9            Text(todo.user.icon)
    10                .font(.largeTitle)
    11                .multilineTextAlignment(.center)
    12        }
    13    }
    14}
    1import SwiftUI
    2
    3struct Overview: View {
    4    var todo: ToDo
    5    var body: some View {
    6        VStack(alignment: .leading) {
    7            Text(todo.title)
    8                .font(.title)
    9                .fontWeight(.bold)
    10                .minimumScaleFactor(0.25)
    11            Text("締め切り: \(todo.deadline)")
    12                .minimumScaleFactor(0.5)
    13        }
    14    }
    15}
    1import SwiftUI
    2
    3struct ToDoView: View {
    4    var todo: ToDo
    5    var body: some View {
    6        HStack {
    7            VStack(alignment: .leading) {
    8                HStack {
    9                    UserIcon(todo: todo)
    10                    VStack {
    11                        Text("締め切り")
    12                            .minimumScaleFactor(0.1)
    13                            .lineLimit(1)
    14                        Text(todo.deadline)
    15                            .minimumScaleFactor(0.1)
    16                            .lineLimit(1)
    17                    }
    18                }
    19                Text(todo.title)
    20                    .font(.title)
    21                    .fontWeight(.bold)
    22                    .minimumScaleFactor(0.1)
    23                    .lineLimit(1)
    24            }
    25        }
    26        .padding()
    27    }
    28}
    1import SwiftUI
    2
    3struct AllToDoListView: View {
    4    var body: some View {
    5        VStack(alignment: .leading, spacing: 48, content: {
    6            ForEach(
    7                ToDo.availableToDoList.sorted { $0.deadline.toDate() < $1.deadline.toDate() }) { todo in
    8                Link(destination: todo.url) {
    9                    HStack {
    10                        VStack(alignment: .leading) {
    11                            HStack {
    12                                UserIcon(todo: todo)
    13                                VStack {
    14                                    Text("締め切り")
    15                                        .foregroundColor(.black)
    16                                    Text(todo.deadline)
    17                                        .minimumScaleFactor(0.1)
    18                                        .lineLimit(1)
    19                                        .foregroundColor(.black)
    20                                }
    21                            }
    22                        }
    23                        Text(todo.title)
    24                            .font(.title)
    25                            .fontWeight(.bold)
    26                            .foregroundColor(.black)
    27                            .minimumScaleFactor(0.1)
    28                            .lineLimit(1)
    29                    }
    30                }
    31            }
    32        })
    33    }
    34}
    1import Foundation
    2
    3extension String {
    4    func toDate() -> Date {
    5        let dateFormatter = DateFormatter()
    6        dateFormatter.dateFormat = "yyyy/MM/dd"
    7        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    8        dateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
    9        guard let date = dateFormatter.date(from: self) else { return Date()}
    10        return date
    11    }
    12}

    Build&Run すると、下記のようなアプリが立ち上がります。

    作成したToDo List画面

    それでは、このアプリを使って Widget を作っていきましょう!

    Widgetを実装する

    アプリの準備が完了したので、Widget を実装していきます。

    Widgetを追加する

    まずは Widget を追加しましょう。

    メニューバーから

    File > New > Target...

    を開いて「widget」を検索します。

    widget追加画面

    ProductName 等を入力して、Finish を押すとプロジェクト配下に新しく Widget用ディレクトリが追加されます。

    ProductName 等の入力画面

    ToDoManagerWidget.swift を開いて Preview を見てみると現在時刻を確認できる Widget が表示されます。

    Preview画面

    また、作成した WidgetExtension を下記画像のように Model と View の Target Membership に追加します。Target Membership に追加

    Widgetを実装する

    それではアプリ用の Widget を実装していきましょう!

    まずは、configurationDisplayNamedescription を設定します。

    1@main
    2struct ToDoManagerWidget: Widget {
    3    let kind: String = "ToDoManagerWidget"
    4
    5    var body: some WidgetConfiguration {
    6        StaticConfiguration(kind: kind, provider: Provider()) { entry in
    7            ToDoManagerWidgetEntryView(entry: entry)
    8        }
    9        // Widget設定画面でのタイトル
    10        .configurationDisplayName("ToDoの詳細")
    11        // Widget設定画面での説明文
    12        .description("ToDoの詳細を確認することができます")
    13    }
    14}

    これらで設定した値は、Widget選択時に表示されるタイトルと説明文になります。

    Widgetを実装画面

    次は、Widget に表示する View 周りを実装していきます。

    SimpleEntity に対し、View に渡したいデータ項目(今回の場合はToDo)を追加します。

    1struct SimpleEntry: TimelineEntry {
    2    let date: Date
    3    // 追加
    4    let todo: ToDo
    5}

    SimpleEntity を使用している箇所で引数不足のエラーが出てくるので、それぞれ引数を追加します。

    1struct Provider: TimelineProvider {
    2    func placeholder(in context: Context) -> SimpleEntry {
    3        // 引数追加
    4        SimpleEntry(date: Date(), todo: .todo_1)
    5    }
    6
    7    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    8        // 引数追加
    9        let entry = SimpleEntry(date: Date(), todo: .todo_1)
    10        completion(entry)
    11    }
    12
    13    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    14        // 引数追加
    15        let entries: [SimpleEntry] = [SimpleEntry(date: Date(), todo: .todo_1)]
    16
    17        let timeline = Timeline(entries: entries, policy: .atEnd)
    18        completion(timeline)
    19    }
    20}

    続いて、EntryView で表示したい View を body に追加します。

    1struct ToDoManagerWidgetEntryView : View {
    2    var entry: Provider.Entry
    3
    4    var body: some View {
    5        // 事前に作成したToDoViewを設定
    6        ToDoView(todo: entry.todo)
    7    }
    8}

    この状態で一度実行してみましょう!

    ToDoの詳細画面

    いい感じですね!

    Widgetのサポートサイズを設定する

    サポートできるサイズにはsystemSmallsystemMediumsystemLarge の3つがあります。

    また、systemSmallはタップ領域が一つだけですが、systemMediumsystemLargeは複数設置できたりします。

    デフォルトではすべてのサイズがサポートされている状態ですが、下記画像のようにsystemLargeサイズだと余白が多くもったいない感じがします。

    Widgetのサポートサイズを設定する

    別の View を使って表示したいのでここではsystemSmallsystemMediumのみをサポートするように設定します。

    まずは、supportFamilies の設定を追加します。

    1@main
    2struct ToDoManagerWidget: Widget {
    3    let kind: String = "ToDoManagerWidget"
    4
    5    var body: some WidgetConfiguration {
    6        StaticConfiguration(kind: kind, provider: Provider()) { entry in
    7            ToDoManagerWidgetEntryView(entry: entry)
    8        }
    9        .configurationDisplayName("ToDoの詳細")
    10        .description("ToDoの詳細を確認することができます")
    11        // サポートするサイズを設定
    12        .supportedFamilies([.systemSmall, .systemMedium])
    13    }
    14}

    次に、WidgetKit が提供している widgetFamily の環境変数を利用して、systemSmallでの表示とsystemMediumでの表示を少し変えてみます。

    1struct ToDoManagerWidgetEntryView : View {
    2    var entry: Provider.Entry
    3
    4    @Environment(\.widgetFamily) var family
    5
    6    @ViewBuilder
    7    var body: some View {
    8        switch family {
    9        case .systemSmall:
    10            ZStack {
    11                ToDoView(todo: entry.todo)
    12            }
    13            .widgetURL(entry.todo.url)
    14        default:
    15            ZStack {
    16                HStack(alignment: .center) {
    17                    ToDoView(todo: entry.todo)
    18                    Text(entry.todo.bio)
    19                        .padding()
    20                }
    21                .padding()
    22                .widgetURL(entry.todo.url)
    23            }
    24        }
    25    }

    これで Build&Run してみると…

    サイズ変更後の画面

    systemMediumサイズもいい感じになりました!

    Widgetに表示するデータを変更できるようにする

    これまでは表示しているデータは魔法使いの ToDo である「素材を集める」しか表示してませんでしたが、他のユーザーのToDoにも表示切り替えができるようにしたいですね。

    選択できるようにするには IntentDefinition を追加する必要があります。

    まずは、File > New > File... で Intent を検索して、「SiriKit Intent Definition File」を選択し追加します。

    Widgetに表示するデータを変更できるようにする

    作成した intentdefinitiontarget ファイルの Target Membership に Widget が含まれていることを確認します。

    作成した intentdefinitiontarget ファイルの Target Membership に Widget が含まれていることを確認

    次に、intentdefinitiontarget ファイルを開き左下の+ボタンを押して New Enum で Enum を追加し、下記のように編集します。

    intentdefinitiontarget ファイルを開き左下の+ボタンを押して New Enum で Enum を追加し、下記のように編集

    同様に+ボタンを押して New Intent で Intent を追加し、下記のように編集します。

    同様に+ボタンを押して New Intent で Intent を追加し、下記のように編集

    続いて、Widget を編集していきます。

    まず、StaticConfiguration をIntentConfiguration に変更し、intent の引数に先程作成した UserSelectionIntent を設定します。

    1@main
    2struct ToDoManagerWidget: Widget {
    3    let kind: String = "ToDoManagerWidget"
    4
    5    var body: some WidgetConfiguration {
    6        // IntentConfigurationに変更
    7        IntentConfiguration(kind: kind, intent: UserSelectionIntent.self, provider: Provider()) { entry in
    8            ToDoManagerWidgetEntryView(entry: entry)
    9        }
    10        .configurationDisplayName("ToDoの詳細")
    11        .description("ToDoの詳細を確認することができます")
    12        .supportedFamilies([.systemSmall, .systemMedium])
    13    }
    14}

    IntentConfiguration を使うには IntentTimeProvider が必要なので、Provider の TimelineProviderをIntentTimeProvider に変更します。

    また、選択したユーザーのToDoが表示されるように修正を加えます。

    1struct Provider: IntentTimelineProvider {
    2    typealias Entry = SimpleEntry
    3
    4    typealias Intent = UserSelectionIntent
    5
    6    func todo(for configuration: UserSelectionIntent) -> ToDo {
    7        switch configuration.user {
    8        case .hero:
    9            return .todo_3
    10        case .fighter:
    11            return .todo_2
    12        case .wizard:
    13            return .todo_1
    14        default:
    15            return .todo_1
    16        }
    17    }
    18
    19    func placeholder(in context: Context) -> SimpleEntry {
    20        SimpleEntry(date: Date(), todo: .todo_1)
    21    }
    22
    23    func getSnapshot(for configuration: UserSelectionIntent, in context: Context, completion: @escaping (SimpleEntry) -> Void) {
    24        let entry = SimpleEntry(date: Date(), todo: .todo_1)
    25        completion(entry)
    26    }
    27
    28    func getTimeline(for configuration: UserSelectionIntent, in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
    29        let selectedToDo = todo(for: configuration)
    30        let entries: [SimpleEntry] = [SimpleEntry(date: Date(), todo: selectedToDo)]
    31
    32        let timeline = Timeline(entries: entries, policy: .atEnd)
    33        completion(timeline)
    34    }
    35}

    Build&Runしてみると…

    ユーザー選択画面

    ユーザー選択ができるようになりましたね!

    WidgetBundleを設定する

    最後に、サポートしていなかったsystemLargeサイズの Widget を作成していきましょう。

    新しくファイルを作成しsystemLargeサイズ用の Widget を実装します。

    実装内容は今までの説明と同様です。

    1import WidgetKit
    2import SwiftUI
    3
    4struct LeaderboardProvider: TimelineProvider {
    5    func placeholder(in context: Context) -> LeaderboardEntry {
    6        LeaderboardEntry(date: Date())
    7    }
    8
    9    func getSnapshot(in context: Context, completion: @escaping (LeaderboardEntry) -> ()) {
    10        let entry = LeaderboardEntry(date: Date())
    11        completion(entry)
    12    }
    13
    14    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    15        let entries: [LeaderboardEntry] = [LeaderboardEntry(date: Date())]
    16
    17        let timeline = Timeline(entries: entries, policy: .atEnd)
    18        completion(timeline)
    19    }
    20}
    21
    22struct LeaderboardEntry: TimelineEntry {
    23    let date: Date
    24}
    25
    26struct LeaderboardWidgetEntryView : View {
    27    var entry: LeaderboardProvider.Entry
    28
    29    var body: some View {
    30        AllToDoListView()
    31            .padding()
    32    }
    33}
    34
    35@main
    36struct LeaderboardWidget: Widget {
    37    let kind: String = "LeaderboardWidget"
    38
    39    var body: some WidgetConfiguration {
    40        StaticConfiguration(kind: kind, provider: LeaderboardProvider()) { entry in
    41            LeaderboardWidgetEntryView(entry: entry)
    42        }
    43        .configurationDisplayName("ToDoの詳細")
    44        .description("3人分のToDoの詳細を確認することができます")
    45        .supportedFamilies([.systemLarge])
    46    }
    47}

    これでsystemLargeサイズの Widget ができたので、実行してみましょう!…と言いたいところですが、このままでは実行できません。

    なぜなら、複数の Widgetに@main を設定することはできないからです。

    このような場合は、WidgetBundle を設定する必要があります。

    下記のように Bundle を作成し@main を設定することで、一つの @main で事足りるようになります。

    1@main
    2struct ToDoBundle: WidgetBundle {
    3    @WidgetBundleBuilder
    4    var body: some Widget {
    5        ToDoManagerWidget()
    6        LeaderboardWidget()
    7    }
    8}

    これで、Build&Run を実行すると…

    systemLargeサイズの Widget が表示

    ちゃんとsystemLargeサイズの Widget が表示されましたね!

    さいごに

    今回は WidgetKit を使ってアプリに Widget 機能を追加してみました。

    今回は静的なデータを表示するのみでしたが、ある一定の時間になったらWidgetを更新したり、アプリ内から更新イベントを受け取って Widget を更新したりなどもできます。

    この記事が WidgetKit を使った実装への取っ掛かりになったら嬉しいです!

    (自分もWidget機能のあるアプリを作ってリリースしてみよう…!)

    こちらの記事もオススメ!

    featureImg2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
    featureImg2020.08.14スマホ技術 特集Android開発Android開発をJavaからKotlinへ変えていくためのお勉強DelegatedPropert...

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    広告メディア事業部

    広告メディア事業部

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background