0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アイスタイルAdvent Calendar 2024

Day 22

Swift初心者向け:WidgetKitで簡単メモウィジェットを作る方法

Last updated at Posted at 2024-12-21

はじめに

アイスタイル Advent Calendar 2024の22日目を担当させて頂きます、こたちゃんです:relaxed:
私は、新卒で入社して3年目のエンジニアです。
現在、iOSアプリのチームで保守運用を担当しております👩‍💻
今回はSwiftUI1でWidgetアプリを作成します:flushed:

ウィジェットとは?

ウィジェットとは、ホーム画面やロック画面に小さな情報を表示する機能です。
ウィジェットは3種類のサイズ(小、中、大)があり、幅広い情報をウィジェットに表示できます。
例えば以下の画像のように「今日の予定」や「天気」などアプリを開かずに確認できます。

widget

作成するアプリ

ファイル名

このアプリ開発で学べること

  • 開発環境のセットアップ方法
  • SwiftUIのデザインの基礎
  • WidgetKitの基本構造

実装手順

1.準備:開発環境のセットアップ
2.アプリの雛形作成
3.WidgetExtensionの追加
4.ウィジェットの基本コードを理解
5.データモデルの設計
6.TimelineEntryの修正
7.Providerの修正
8.viewの実装
9.ウィジェット本体の設定
10.プレビューの設定

1. 準備:開発環境のセットアップ

iOSの ウィジェット機能 を利用するには、以下のバージョンが最低条件となります:

iOSバージョン

Xcodeバージョン

  • Xcode12 以上
    ウィジェットの開発を行うにはOSバージョンをサポートするXcodeのバージョンが必要です。
    iOS14向けのウィジェットを開発する場合、Xcode12以降が必要になります。

    ダウンロード:https://developer.apple.com/download/all/

補足情報

  • iOS 16以降では、ロック画面にウィジェットを追加する機能が追加され、カスタマイズの幅が広がりました。その場合、Xcode 14以降が推奨されます。
  • Appleの最新技術(例:SwiftUIでのウィジェット開発)を利用する場合、XcodeとiOSの最新バージョンをダウンロードするのを推奨します。

最新のXcodeバージョンが常に全てのOSバージョンをサポートしているわけではありません。
開発中のアプリに適切な組み合わせを選んでください!

2. アプリの雛形作成

Xcodeを立ち上げると「Xcode」と表示されており、その下に3つの項目があります。
※今回はXcode16.1を使用しております。

新しいiOSアプリのプロジェクトを作成するため、「Create new project...」を選択してください。

プロジェクトを作成

iOSのAppを選択して「Next」ボタンをクリックください。

Next

次にProductNameに「Memo」と入力します。
InterfaceがSwiftUIか確認した後に「Next」ボタンをクリックしてください。

ProductName

アプリファイルの保存は、ご自身のPCの好きな場所を選択し「Create」ボタンをクリックしてください。
下記のような画面になりましたら雛形作成が完了です。

雛形

今回はウィジェットがメインのため、本体への実装は省略いたします。

3. WidgetExtensionの追加

プロジェクトの一番上をクリック

target

プロジェクト内のTARGETSの「Memo」をクリックして、画面左下の「+」ボタンをクリックします。

+

「Widget Extension」を選択して「Next」をクリックします。

Widget Extension

Product Nameに名前を入力し、「Finish」をクリックしてください。(例: MyWidget

チェックボックスが3つあるのですが今回はチェックを全て外します。

MyWidget

今回はチェックを外しましたがそれぞれのできることを以下にまとめています!


Include Live Activity
iOS 16.1以降で導入された新しいウィジェット機能の一部です。
ライブアクティビティを使うと、ロック画面などにリアルタイムの情報を表示できます。

Include Control
ウィジェットやライブアクティビティから直接操作が可能になり
アプリを開かずに便利な操作ができます。

Include Configuration App Intent
ウィジェットを追加する際に、ユーザー自身が設定を選択したり値の変更が可能です。

例 : 天気アプリで表示する都市を選択


下記のように「MyWidgetExtension」が作成されていればバッチリです。

MyWidget

4. ウィジェットの基本コードを理解

ウィジェットの実装に入る前に基本コードの解説をしていきます。

構造の説明

Provider
ウィジェットが表示するデータを提供
Entry
表示するデータの構造体
View
ウィジェットのデザインを管理する部分でSwiftUIを使用
Timeline
データをどのタイミングで更新するかを管理


続いて生成されたコードの解説をしていきます。
MyWidget.swiftを開いてみると下記のように右側にシュミレータが出てきてくれています。
デフォルトのウィジェットでは時刻と絵文字が表示されています。

MyWidget

デフォルトで下記の2つのファイルが生成されます。
※タップするとコードが確認できます

MyWidget
MyWidget
import WidgetKit
import SwiftUI

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

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), emoji: "😀")
        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, emoji: "😀")
            entries.append(entry)
        }

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

//    func relevances() async -> WidgetRelevances<Void> {
//        // Generate a list containing the contexts this widget is relevant in.
//    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let emoji: String
}

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text("Time:")
            Text(entry.date, style: .time)

            Text("Emoji:")
            Text(entry.emoji)
        }
    }
}

struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                MyWidgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                MyWidgetEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

#Preview(as: .systemSmall) {
    MyWidget()
} timeline: {
    SimpleEntry(date: .now, emoji: "😀")
    SimpleEntry(date: .now, emoji: "🤩")
}

MyWidgetBundle
MyWidgetBundle
import WidgetKit
import SwiftUI

@main
struct MyWidgetBundle: WidgetBundle {
    var body: some Widget {
        MyWidget()
    }
}

簡単にコードの解説をしていきます。

placeholder

ウィジェットが初めてロードされる際や、データがまだ取得できていない場合に表示されます。
空の設定をするとシステムが用意する半透明の背景が表示されます。

getSnapshot

ウィジェット追加時(下記の画像)に表示する内容のデータを生成します。
MyWidget

getTimeline

タイムラインデータを生成します。

let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!

デフォルトは、上記のようにbyAddingにhourを指定しているため
現在の時刻から5時間分のエントリを1時間ごとに作成しています。
例えば、minuteだと5分のエントリを1分ごとにということになります。

Timeline(entries: entries, policy: .atEnd)

タイムラインの更新ポリシーは.atEndを指定していますが、全部で3つあります。

  • .atEnd : 最後のエントリの有効期限が終わると、自動で更新が行われます。
  • .never : タイムラインの更新は行われず、ウィジェットの内容が固定されます。
  • .after(Date) : 指定した日時(Date)以降に更新が行われます。

TimelineEntry

SimpleEntryはウィジェットが表示するデータと
そのデータが表示される時間に関する情報をデータを保存しておくためのプロトコルです。
ウィジェットの内容を動的に更新するために使用されます。
ウィジェットの更新時SwiftUIViewに渡され、ウィジェットの表示内容を決定しています。

struct SimpleEntry: TimelineEntry {
    let date: Date
    let emoji: String
}

StaticConfiguration

ユーザがウィジェットをカスタマイズする必要がない時に使用します。
この設定を使うと、ウィジェットを長押ししても「ウィジェットを編集」は表示されません。

補足

IntentConfigurationを使用すると
ユーザーはウィジェットを編集してカスタマイズできます。
例えば下記の天気ウィジェットだと編集することで場所を変更できます。

MyWidget

kind

let kind: String = "MyWidget"

ウィジェットを区別するためのString型を渡します。
WidgetKitでは1つのWidgetExtensionを使用して、複数のウィジェットを作成できます。

ウィジェットをタップしてAppを起動した時、
App側でどのウィジェットから起動したかをkindで判別できます。

背景の指定

if #available(iOS 17.0, *) {
    MyWidgetEntryView(entry: entry)
        .containerBackground(.fill.tertiary, for: .widget)
} else {
    MyWidgetEntryView(entry: entry)
        .padding()
        .background()
}

iOSのバージョンが17以降の時は.containerBackgroundを使用し
それより前は.backgroundを使用して背景スタイルを設定します。

ウィジェットの追加画面

  • configurationDisplayName
    ウィジェットを設定する時に表示するタイトル文を指定します。

  • description
    ウィジェットを設定する時に表示する説明文を指定します。

MyWidget

Preview

#Preview(as: .systemSmall) {
    MyWidget()
} timeline: {
    SimpleEntry(date: .now, emoji: "😀")
    SimpleEntry(date: .now, emoji: "🤩")
}

Previewを使用することでXcodeのプレビュー画面でウィジェットの表示内容を確認できます。
as: .systemSmallはプレビューするウィジェットのサイズを指定しています。
ここでは「小サイズ(systemSmall)」のウィジェットをプレビューで表示します。

WidgetBundle

複数のウィジェットを1つのアプリにまとめるための仕組みです。
下記のように必要なだけウィジェットを追加できます。

WidgetBundle {
    var body: some Widget {
        Widget1()
        Widget2()
        // 必要なだけウィジェットを追加
    }
}

今回は簡単な説明になりますので、気になる方はぜひ調べてみてください。

それでは早速、メモをウィジェットに表示させていきたいと思います!

5. データモデルの設計

まずはモデルから作成していきます。
MyWidget.swiftの上の方に追記する形で記述してください。

MyWidget.swift
struct Memo: Identifiable {
    let id: UUID
    let title: String
    let content: String
    let date: Date
}

id : 各メモを一意に識別するためのUUID
title : メモのタイトル
content : メモの内容
date : メモが作成された日付

6. TimelineEntryの修正

TimelineEntryは、ウィジェットで表示するデータの1つの「状態」を表します。
今回はメモアプリなのでemojiは不要になります。
削除して、代わりにメモのリスト (memos) を追記します。
型は最初に定義したMemoモデルを使用して、配列にします。

struct SimpleEntry: TimelineEntry {
    let date: Date
-   let emoji: String
+   let memos: [Memo]
}

上記を元にサンプルデータを作成します。
今回は実際のデータを用意する代わりに、サンプルのメモデータを配列として定義しています。
実際に運用するとなるとAPIから値を取ってくる形になると思います。
定義したモデルの下に追記してください。

MyWidget.swift
let sampleMemos: [Memo] = [
    Memo(id: UUID(), title: "Shopping List", content: "Milk, Eggs, Bread, Butter", date: Date()),
    Memo(id: UUID(), title: "Meeting Notes", content: "Discuss project roadmap and timelines.", date: Date()),
    Memo(id: UUID(), title: "Ideas", content: "Start a blog about Swift development.", date: Date())
]

7. Providerの修正

SimpleEntryでemojiを削除したためemoji: "😀"は3箇所とも削除します。
そして新たにmemosを追加します。

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
-       SimpleEntry(date: Date(), emoji: "😀")
+       SimpleEntry(date: Date(), memos: [])
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
-       let entry = SimpleEntry(date: Date(), emoji: "😀")
+       let entry = SimpleEntry(date: Date(), memos: sampleMemos)
        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, emoji: "😀")
+           let entry = SimpleEntry(date: entryDate, memos: sampleMemos)
            entries.append(entry)
        }

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

補足:コメントアウトされているrelevances()について

//    func relevances() async -> WidgetRelevances<Void> {
//        // Generate a list containing the contexts this widget is relevant in.
//    }

relevances メソッドは、ウィジェットの優先度を定義するために使用されます。
ウィジェットを優先的に表示したい場合(例:ロック画面での優先順位)に便利ですが、
デフォルトで生成されたコードではその機能を使用していないため、コメントアウトされています。

8. viewの実装

メモのように見えるようにデザインを整えていきます。
bodyの中身を以下に書き換えてくだささい。

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            ForEach(entry.memos.prefix(2)) { memo in // 最大2つのメモを表示
                VStack(alignment: .leading, spacing: 4) {
                    Text(memo.title)
                        .font(.headline)
                        .foregroundColor(.blue)
                        .lineLimit(1)
                    Text(memo.content)
                        .font(.body)
                        .foregroundColor(.gray)
                        .lineLimit(2)
                }
                Divider()
            }
        }
        .padding()
        .background(Color.white)
    }
}

VStack

複数のビューを縦に並べるためのコンテナです。
簡単に言うと、VStackの中に入れたものが上から下へ順番に並ぶようになります。

ForEachprefix

ForEach: 配列やコレクションの各要素に対してビューを繰り返して作成します。
prefix: 配列や文字列の最初の部分を取得するためのメソッドです。
()内に数字を指定してその数字分、前から要素を取得します。
範囲外の数字を入れてもクラッシュしないので安全です。

alignment

alignment は、SwiftUIでビューを配置する際に、
そのビュー内で子ビューの位置を指定するためのプロパティです。

  • .leading: 左揃え(親ビューの左端に合わせて配置)
  • .center: 中央揃え(親ビューの中央に配置)
  • .trailing: 右揃え(親ビューの右端に合わせて配置)
  • .top: 上揃え(親ビューの上端に合わせて配置)
  • .bottom: 下揃え(親ビューの下端に合わせて配置)
  • .firstTextBaseline: 最初のテキストのベースラインに合わせて配置
  • .lastTextBaseline: 最後のテキストのベースラインに合わせて配置

複数のビューを整列させる際にレイアウトを柔軟に調整し、見た目をきれいに整えることができます。

spacing

VStackやHStackなどのコンテナ内で、ビュー同士の間隔を設定するプロパティです。

font

テキストのフォントを指定する修飾子です。
例えば、font(.title) でタイトルフォントを適用できます。

foregroundColor

テキストや文字やアイコンの色を設定する修飾子です。

lineLimit

テキストの行数を制限するプロパティです。
例えば、lineLimit(1) の場合は1行に制限できます。

Divider

水平または垂直の区切り線を表示するビューになります。

padding

ビューの周囲に余白を追加する修飾子です。
例えば、padding(10) で10ポイントの余白を追加できます。

background

ビューの背景色や背景ビューを設定する修飾子です。
例えば、background(Color.blue) の場合、青色の背景に設定できます。

9. ウィジェット本体の設定

struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                MyWidgetEntryView(entry: entry)
                    .containerBackground(.white, for: .widget)
            } else {
                MyWidgetEntryView(entry: entry)
                    .padding()
                    .background(Color.white)
            }
        }
        .configurationDisplayName("My Memos")
        .description("View your latest memos.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

iOS 17以降では、.containerBackground(.white, for: .widget) として白に統一し
それ以前のバージョンでは、.background(Color.white) を使用して白に統一しています。

.configurationDisplayName.descriptionは前回説明したため省略させて頂きます。
.supportedFamiliesは、ウィジェットが対応するサイズ(ウィジェットファミリー)を指定するプロパティになります。

主なファミリー

  • .systemSmall: 小サイズのウィジェット
  • .systemMedium: 中サイズのウィジェット
  • .systemLarge: 大サイズのウィジェット
  • .accessoryCircular: Apple Watchの小型円形ウィジェット
  • .accessoryRectangular: Apple Watchの長方形ウィジェット

アプリのデザインや用途に合わせてsupportedFamilies を設定すると
そのウィジェットが対応するサイズだけを表示できます。
上記の例では、ウィジェットは小サイズ(systemSmall)と中サイズ(systemMedium)で利用可能になります。他のサイズでは表示されません。

10. プレビューの設定

#Preview(as: .systemMedium) {
    MyWidget()
} timeline: {
    SimpleEntry(date: .now, memos: sampleMemos)
}

プレビューでは、ウィジェットを中サイズ (systemMedium) で表示します。
サンプルデータ (sampleMemos) を使って表示内容を確認できます。

完成コード

Memoアプリ
import WidgetKit
import SwiftUI

struct Memo: Identifiable {
    let id: UUID
    let title: String
    let content: String
    let date: Date
}

let sampleMemos: [Memo] = [
    Memo(id: UUID(), title: "Shopping List", content: "Milk, Eggs, Bread, Butter", date: Date()),
    Memo(id: UUID(), title: "Meeting Notes", content: "Discuss project roadmap and timelines.", date: Date().addingTimeInterval(-3600)),
    Memo(id: UUID(), title: "Ideas", content: "Start a blog about Swift development.", date: Date().addingTimeInterval(-7200))
]

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

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), memos: sampleMemos)
        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, memos: sampleMemos)
            entries.append(entry)
        }

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

//    func relevances() async -> WidgetRelevances<Void> {
//        // Generate a list containing the contexts this widget is relevant in.
//    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let memos: [Memo]
}

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            ForEach(entry.memos.prefix(2)) { memo in // 最大2つのメモを表示
                VStack(alignment: .leading, spacing: 4) {
                    Text(memo.title)
                        .font(.headline)
                        .foregroundColor(.blue)
                        .lineLimit(1)
                    Text(memo.content)
                        .font(.body)
                        .foregroundColor(.gray)
                        .lineLimit(2)
                }
                Divider()
            }
        }
        .padding()
        .background(Color.white)
    }
}

struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                MyWidgetEntryView(entry: entry)
                    .containerBackground(.white, for: .widget)
            } else {
                MyWidgetEntryView(entry: entry)
                    .padding()
                    .background(Color.white)
            }
        }
        .configurationDisplayName("My Memos")
        .description("View your latest memos.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

#Preview(as: .systemMedium) {
    MyWidget()
} timeline: {
    SimpleEntry(date: .now, memos: sampleMemos)
}

最後に

今年もSwiftでの記事を書かせていただきました。
Widgetの新規作成の際にぜひ参考にしていただけるとうれしいです!❤️
素敵なクリスマスを:santa_tone2::snowflake:

参考文献

  1. SwiftUIとは、iPhoneだけでなくiPadやMacOSX、Apple Watchなど、Apple製品のプラットフォームすべてに対応しているUIフレームワークです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?