はじめに
アイスタイル Advent Calendar 2024の22日目を担当させて頂きます、こたちゃんです
私は、新卒で入社して3年目のエンジニアです。
現在、iOSアプリのチームで保守運用を担当しております👩💻
今回はSwiftUI1でWidgetアプリを作成します
ウィジェットとは?
ウィジェットとは、ホーム画面やロック画面に小さな情報を表示する機能です。
ウィジェットは3種類のサイズ(小、中、大)があり、幅広い情報をウィジェットに表示できます。
例えば以下の画像のように「今日の予定」や「天気」などアプリを開かずに確認できます。
作成するアプリ
このアプリ開発で学べること
- 開発環境のセットアップ方法
- SwiftUIのデザインの基礎
- WidgetKitの基本構造
実装手順
1.準備:開発環境のセットアップ
2.アプリの雛形作成
3.WidgetExtensionの追加
4.ウィジェットの基本コードを理解
5.データモデルの設計
6.TimelineEntryの修正
7.Providerの修正
8.viewの実装
9.ウィジェット本体の設定
10.プレビューの設定
1. 準備:開発環境のセットアップ
iOSの ウィジェット機能 を利用するには、以下のバージョンが最低条件となります:
iOSバージョン
-
iOS14 以上
ウィジェットをサポートした初めてのiOSバージョン。
ホーム画面にウィジェットを追加する機能が導入されました。
Xcodeバージョン
-
Xcode12 以上
ウィジェットの開発を行うにはOSバージョンをサポートするXcodeのバージョンが必要です。
iOS14向けのウィジェットを開発する場合、Xcode12以降が必要になります。
補足情報
- iOS 16以降では、ロック画面にウィジェットを追加する機能が追加され、カスタマイズの幅が広がりました。その場合、Xcode 14以降が推奨されます。
- Appleの最新技術(例:SwiftUIでのウィジェット開発)を利用する場合、XcodeとiOSの最新バージョンをダウンロードするのを推奨します。
最新のXcodeバージョンが常に全てのOSバージョンをサポートしているわけではありません。
開発中のアプリに適切な組み合わせを選んでください!
2. アプリの雛形作成
Xcodeを立ち上げると「Xcode」と表示されており、その下に3つの項目があります。
※今回はXcode16.1を使用しております。
新しいiOSアプリのプロジェクトを作成するため、「Create new project...」を選択してください。
iOSのAppを選択して「Next」ボタンをクリックください。
次にProductNameに「Memo」と入力します。
InterfaceがSwiftUI
か確認した後に「Next」ボタンをクリックしてください。
アプリファイルの保存は、ご自身のPCの好きな場所を選択し「Create」ボタンをクリックしてください。
下記のような画面になりましたら雛形作成が完了です。
今回はウィジェットがメインのため、本体への実装は省略いたします。
3. WidgetExtensionの追加
プロジェクトの一番上をクリック
プロジェクト内のTARGETSの「Memo」をクリックして、画面左下の「+」ボタンをクリックします。
「Widget Extension」を選択して「Next」をクリックします。
Product Nameに名前を入力し、「Finish」をクリックしてください。(例: MyWidget
)
チェックボックスが3つあるのですが今回はチェックを全て外します。
今回はチェックを外しましたがそれぞれのできることを以下にまとめています!
Include Live Activity
iOS 16.1以降で導入された新しいウィジェット機能の一部です。
ライブアクティビティを使うと、ロック画面などにリアルタイムの情報を表示できます。
Include Control
ウィジェットやライブアクティビティから直接操作が可能になり
アプリを開かずに便利な操作ができます。
Include Configuration App Intent
ウィジェットを追加する際に、ユーザー自身が設定を選択したり値の変更が可能です。
例 : 天気アプリで表示する都市を選択
下記のように「MyWidgetExtension」が作成されていればバッチリです。
4. ウィジェットの基本コードを理解
ウィジェットの実装に入る前に基本コードの解説をしていきます。
構造の説明
Provider
ウィジェットが表示するデータを提供
Entry
表示するデータの構造体
View
ウィジェットのデザインを管理する部分でSwiftUIを使用
Timeline
データをどのタイミングで更新するかを管理
続いて生成されたコードの解説をしていきます。
MyWidget.swiftを開いてみると下記のように右側にシュミレータが出てきてくれています。
デフォルトのウィジェットでは時刻と絵文字が表示されています。
デフォルトで下記の2つのファイルが生成されます。
※タップするとコードが確認できます
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
import WidgetKit
import SwiftUI
@main
struct MyWidgetBundle: WidgetBundle {
var body: some Widget {
MyWidget()
}
}
簡単にコードの解説をしていきます。
placeholder
ウィジェットが初めてロードされる際や、データがまだ取得できていない場合に表示されます。
空の設定をするとシステムが用意する半透明の背景が表示されます。
getSnapshot
ウィジェット追加時(下記の画像)に表示する内容のデータを生成します。
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
を使用すると
ユーザーはウィジェットを編集してカスタマイズできます。
例えば下記の天気ウィジェットだと編集することで場所を変更できます。
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
ウィジェットを設定する時に表示する説明文を指定します。
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
の上の方に追記する形で記述してください。
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から値を取ってくる形になると思います。
定義したモデルの下に追記してください。
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の中に入れたものが上から下へ順番に並ぶようになります。
ForEach と prefix
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の新規作成の際にぜひ参考にしていただけるとうれしいです!❤️
素敵なクリスマスを
参考文献
-
SwiftUIとは、iPhoneだけでなくiPadやMacOSX、Apple Watchなど、Apple製品のプラットフォームすべてに対応しているUIフレームワークです。 ↩