创建 Widget 扩展
添加 Widget Extension
1、在 Xcode 中打开项目。
2、点击 File → New → Target。

3、选择 Widget Extension,点击 Next。

4、取一个合适的名称,例如 BankletWidget。

5、添加后,Xcode 会询问是否激活扩展,选择“Activate”。

Widget 文件
Xcode 会自动生成一些文件来组织和管理Widget功能,以下是 BankletWidget 目录下各个文件的作用:

1、BankletWidgetBundle.swift(Widget 入口)
作用:
WidgetBundle 是 多个 Widget 的集合,用于在 一个 App Extension 里注册多个 Widget(比如 普通小组件 + Live Activity)。
import WidgetKit
import SwiftUI
@main
struct BankletWidgetBundle: WidgetBundle {
var body: some Widget {
BankletWidget() // 主要的 Widget
BankletWidgetLiveActivity() // 可能还有 Live Activity
}
}
@main:表示这个是 Widget 入口。
WidgetBundle:可以 包含多个 Widget,比如:
BankletWidget(普通 Widget)
BankletWidgetLiveActivity(Live Activity)
如果 App 只需要一个 Widget,可以直接在这里返回 BankletWidget()。
2、BankletWidget.swift(普通 Widget 逻辑)
这是主要的 Widget 文件,定义了 Widget 的内容、数据来源和展示方式。
import WidgetKit
import SwiftUI
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
}
// ...
}
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
}
struct BankletWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
// ...
}
}
struct BankletWidget: Widget {
let kind: String = "BankletWidget"
var body: some WidgetConfiguration {
// ...
}
}
extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent {
// ...
}
}
#Preview(as: .systemSmall) {
BankletWidget()
} timeline: {
SimpleEntry(date: .now, configuration: .smiley)
SimpleEntry(date: .now, configuration: .starEyes)
}
AppIntentConfiguration 允许 Widget 可配置(如选择存钱罐类型)。
Provider() 负责提供 Widget 的数据(getTimeline)。
BankletWidgetEntryView 是 Widget 的 UI 视图。
3、BankletWidgetLiveActivity.swift(Live Activity 组件,类似锁屏动态显示)
Live Activity 允许在锁屏或动态岛中显示实时数据(如存钱进度、定时任务)。
import ActivityKit
import WidgetKit
import SwiftUI
struct BankletWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var emoji: String
}
// Fixed non-changing properties about your activity go here!
var name: String
}
struct BankletWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
// ...
}
}
extension BankletWidgetAttributes {
fileprivate static var preview: BankletWidgetAttributes {
BankletWidgetAttributes(name: "World")
}
}
extension BankletWidgetAttributes.ContentState {
fileprivate static var smiley: BankletWidgetAttributes.ContentState {
// ...
}
}
#Preview("Notification", as: .content, using: BankletWidgetAttributes.preview) {
BankletWidgetLiveActivity()
} contentStates: {
BankletWidgetAttributes.ContentState.smiley
BankletWidgetAttributes.ContentState.starEyes
}
ActivityConfiguration 让 Widget 变成 Live Activity(实时更新)。
适用于实时追踪目标金额、每日存钱进度等场景。
如果不需要 Live Activity,可以删除这个文件。
4、AppIntent.swift(Widget的可配置项)
作用:
这个文件定义 Widget 的配置选项,用户可以在添加 Widget 时自定义参数。
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Configuration"
static var description = IntentDescription("This is an example widget.")
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}
5、Assets.xcassets(Widget 资源)
存放 Widget 专属图片、颜色等资源,不会影响主 App。
默认包含 AppIcon 资源(用于 Widget 图标)。
可以在这里放置 不同大小的 Widget 背景图。
6、Info.plist(Widget 配置文件)
Info.plist 定义 Widget 的元数据,包括:
NSExtension:声明 Widget 是 WidgetKit 扩展。
NSExtensionAttributes:设置 支持的 Widget 尺寸。
如果 Widget 出现找不到或无法使用的问题,可能是 Info.plist 缺少必要的配置。
运行Widget
首次创建Widget并运行,如果选择WidgetExtension,可能会报如下错误:

因此,在测试Widget时,需要选择项目(如“piglet”)运行,而不是选择WidgetExtension。

运行后,可以在iOS小组件中,查看示例Widget。


需要注意的是,如果运行示例时,发生某些意外的报错,可以尝试以下方法:
1、在访达中找到DerivedData文件夹,删除派生数据。
~/Library/Developer/Xcode/DerivedData

2、关闭并重新打开Xcode。
以上方法用于解决运行Xcode项目时,Widget可能导致的报错问题。
解析Widget文件
BankletWidget.swift文件
BankletWidget.swift文件定义了iOS 17+小组件 (Widget),使用 WidgetKit 和 AppIntents 来提供可配置的时间表更新。
1、Provider(数据提供者)
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: Date(), configuration: configuration)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
return Timeline(entries: entries, policy: .atEnd)
}
}
这个结构体 Provider 负责提供 Widget 需要显示的数据,它实现了 AppIntentTimelineProvider 协议,定义了 Widget 如何更新数据。
1、placeholder(in context:):用于Widget Gallery 预览,显示静态占位数据(如默认内容)。
当 Widget 还未获取真实数据 时(比如刚添加到主屏幕,或者系统重启后第一次加载)。
在 Widget Gallery(小组件选择界面)里显示预览时。
只用于占位,保证 Widget 在加载正式数据前不会是空白的。
应该是静态的,不要依赖外部动态数据(如网络请求、数据库)。

2、snapshot(for:in:):当 Widget 需要快速更新一次数据时会调用,通常用于锁屏小组件的快照。
snapshot 应该返回一个尽量真实的 Widget 预览,比 placeholder 更贴近实际展示的数据。
在某些情况下,可以提供 模拟数据,让 Widget 预览效果更直观。

3、timeline(for:in:):
这里创建了未来 5 个小时的时间表,每个小时创建一个 SimpleEntry 作为 Widget 的显示内容。
Timeline(entries: entries, policy: .atEnd) 表示 用完所有的 entries 后再请求新的数据(policy: .atEnd)。

placeholder、snapshot 和 timeline 这三个方法都是 AppIntentTimelineProvider 协议要求实现的。它们分别用于占位、预览和正式时间线展示,目的是让 Widget 在不同情况下都有合适的数据可以显示。

2、SimpleEntry(数据模型)
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
}
SimpleEntry 是时间轴上的数据条目,它遵循 TimelineEntry 协议。
含日期 (date) 和配置 (configuration),代表 Widget 需要展示的数据,它的作用是存储 Widget 在某个时间点要显示的数据。
SimpleEntry 主要用于 placeholder、snapshot 和 timeline 方法中。
3、BankletWidgetEntryView(小组件 UI)
struct BankletWidgetEntryView: View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
}
}
}
BankletWidgetEntryView 负责小组件的 UI 展示。
它使用 SwiftUI 显示:
当前时间(entry.date)。
用户配置的 Emoji(entry.configuration.favoriteEmoji)。

在 BankletWidgetEntryView 中:
var entry: Provider.Entry
entry 由 Provider 提供的数据填充。
Provider.Entry 实际上是 SimpleEntry,它存储着时间(date)和用户配置的 emoji(configuration)。
4、BankletWidget(小组件本体)
struct BankletWidget: Widget {
let kind: String = "BankletWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
BankletWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
}
}
BankletWidget 是小组件的主体,它定义了小组件的结构,符合 Widget 协议,这意味着它是一个可以被 iOS 系统识别并显示的小组件。
kind: String = “BankletWidget” 是 Widget 的唯一标识符,用于在系统中识别不同的小组件。
body: some WidgetConfiguration 定义 Widget 的更新方式,是 Widget 协议的要求,它返回一个 WidgetConfiguration,定义了 Widget 的行为、外观和数据来源。
AppIntentConfiguration 是配置 Widget 的主要方式。它包含了:
kind: kind:指定 Widget 的类型(与前面定义的 kind 匹配)。
intent: ConfigurationAppIntent.self:指定使用的配置类型,这里是 ConfigurationAppIntent,它包含了用户自定义的配置(例如:Emoji)。
provider: Provider():指定一个数据提供者,Provider 是实现 TimelineProvider 协议的对象,它提供数据(例如时间和 Emoji)。
BankletWidgetEntryView(entry: entry):用于展示 Widget 的视图内容,接收 Provider 提供的数据 (entry) 并进行渲染(例如:显示时间和 Emoji)。
.containerBackground(.fill.tertiary, for: .widget):设置 Widget 的背景样式,这里使用 .fill.tertiary,并将其应用于整个 Widget 背景。
5、ConfigurationAppIntent(可配置项)
extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "😀"
return intent
}
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
}
}
ConfigurationAppIntent 允许用户配置 Widget,比如选择自己喜欢的 Emoji。
这里 smiley 和 starEyes 是 两个默认的配置示例,用于 Widget 预览。
6、Widget 预览
#Preview(as: .systemSmall) {
BankletWidget()
} timeline: {
SimpleEntry(date: .now, configuration: .smiley)
SimpleEntry(date: .now, configuration: .starEyes)
}
#Preview 是一个 SwiftUI 特性,允许在 Xcode 中快速预览组件的外观。这里,as: .systemSmall 指定了预览的 大小,即显示为小型(systemSmall)Widget。
BankletWidget() 是实际需要预览的 Widget 视图。这里我们预览的是 BankletWidget,它展示了 Widget 在特定大小下的样式和内容。
timeline 是用于展示不同时间条目的配置。在这个案例中,我们使用了两个 SimpleEntry,它们代表 Widget 在不同配置下的内容:
1、SimpleEntry(date: .now, configuration: .smiley):这个条目使用 smiley 配置,意味着 favoriteEmoji 设置为 “😀”。
2、SimpleEntry(date: .now, configuration: .starEyes):这个条目使用 starEyes 配置,意味着 favoriteEmoji 设置为 “🤩”。
timeline 指定了 Widget 在不同时间点的显示内容。在这里,SimpleEntry 就是代表了在某个时间点的 Widget 配置,它通过 configuration 来指定 favoriteEmoji。
实际应用Widget
例如,在iOS应用中想要实现下面的Widget效果:

主要涉及到LottieFiles动画、存钱罐的信息共两部分内容。经过查询了解到Widget并不适用于显示LottieFiles动画,同时经过实测发现Widget无法显示Gif动画,只能显示静态图片。
同时,Widget也不支持SwiftData,因此在展示存钱罐信息时,需要从iOS应用端将数据传递到Widget中,比较常用的传递方式为:通过App Group共享数据,将主应用传递到Widget。
其中App Group配置步骤,请见《SwiftUI UserDefaults使用App Group共享数据》,这里不做过多的累赘。
iOS应用端配置
在iOS应用端,配置一个保存存钱罐数据的方法,在适当的时间调用该方法,将存钱罐的数据共享到UserDefaults中:
func saveWidgetData() {
let userDefaults = UserDefaults(suiteName: "group.com.fangjunyu.piglet")
// 存储存钱罐数据
userDefaults?.set(piggyBank[0].icon, forKey: "piggyBankIcon")
userDefaults?.set(piggyBank[0].name, forKey: "piggyBankName")
userDefaults?.set(piggyBank[0].amount, forKey: "piggyBankAmount")
userDefaults?.set(piggyBank[0].targetAmount, forKey: "piggyBankTargetAmount")
userDefaults?.set(LoopAnimation, forKey: "LoopAnimation")
// 然后手动触发 Widget 刷新
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
WidgetCenter.shared.reloadTimelines(ofKind: "BankletWidget")
WidgetCenter.shared.reloadTimelines(ofKind: "BankletWidgetBackground")
}
}
我在这里考量的是退出应用执行该方法:
VStack {}
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
// 在应用进入后台时保存数据
saveWidgetData()
print("应用移入后台,调用Widget保存数据")
}
if newPhase == .inactive {
// 应用即将终止时保存数据(iOS 15+)
saveWidgetData()
print("非活跃状态,调用Widget保存数据")
}
}
通过scenePhase检测应用的生命周期,在暂停和后台运行时,调用saveWidgetData()保存数据。
在saveWidgetData方法中,除了保存UserDefaults,我还调用了WidgetCenter,这里的ofKind和Widget中的kind相对应。
struct BankletWidget: Widget {
let kind: String = "BankletWidget" // kid 相对应
var body: some WidgetConfiguration {
...
}
}
在 iOS 中,当通过 UserDefaults 更新共享的数据时,Widget 可能不会立即更新。原因可能包括:
1、Widget Timeline 更新延迟:
TimelineProvider 会根据设定的时间线更新 Widget。即使UserDefaults 更新了数据,Widget 并不会立刻反应。需要确保 Widget 的时间线被刷新。可以手动调用 WidgetCenter.shared.reloadTimelines(ofKind:) 来强制刷新 Widget 的时间线。
WidgetCenter.shared.reloadTimelines(ofKind: "BankletWidget")
这行代码会强制更新指定的 Widget(通过 kind)。如果更新 UserDefaults 后调用了这个方法,Widget 会及时反映数据的变化。
2、Widget Timeline Policy:
如果在 getTimeline(in:completion:) 中使用的是 .atEnd 的更新策略,那么 Widget 只有在下一次更新时才会加载新的数据。这种更新策略通常适用于一次性展示的数据,而不适用于实时更新的需求。可以调整策略,例如使用 .after 来确保 Widget 会在某个时间点后更新。
3、Widget 不能实时读取 UserDefaults 的变化:
Widget 会读取共享的 UserDefaults 数据,但是它不是实时监听数据变化的。要确保 Widget 在 UserDefaults 数据变化时更新,需要手动触发 Widget 的刷新,如前面提到的 WidgetCenter.shared.reloadTimelines(ofKind:)。
4、App Group 和 UserDefaults 的同步问题:
在 Widget 和主应用之间共享 UserDefaults 时,可能会存在同步延迟。如果更新了主应用中的 UserDefaults,Widget 可能需要一些时间才能读取到新值。在这种情况下,可以使用手动刷新时间线来确保更新立即生效。
Widget配置
在Widget中,通过相同的UserDefaults(suiteName:)读取主应用存储的存钱罐数据。
struct BankletWidgetEntryView : View {
@State private var piggyBankIcon: String = ""
@State private var piggyBankName: String = ""
@State private var piggyBankAmount: Double = 0.0
@State private var piggyBankTargetAmount: Double = 0.0
@State private var LoopAnimation: String = ""
var entry: Provider.Entry
var body: some View {
VStack {
// 读取主应用存储的存钱罐数据
Text("Piggy Bank Name: \(piggyBankName)")
Text("Amount: \(piggyBankAmount, specifier: "%.2f")")
Text("Target Amount: \(piggyBankTargetAmount, specifier: "%.2f")")
Image(systemName: "piggyBankIcon") // 显示存钱罐图标
Image(LoopAnimation) // 显示存钱罐图标
}
.onAppear {
// 读取存钱罐的数据
let userDefaults = UserDefaults(suiteName: "group.com.fangjunyu.piglet")
piggyBankIcon = userDefaults?.string(forKey: "piggyBankIcon") ?? ""
piggyBankName = userDefaults?.string(forKey: "piggyBankName") ?? ""
piggyBankAmount = userDefaults?.double(forKey: "piggyBankAmount") ?? 0.0
piggyBankTargetAmount = userDefaults?.double(forKey: "piggyBankTargetAmount") ?? 0.0
LoopAnimation = userDefaults?.string(forKey: "LoopAnimation") ?? ""
}
}
}
在使用自定义的Color(hex:)方法时,发现Color(hex:)方法必须单独定义在BankletWidget文件中才能生效,定义在Widget项目中,并不会生效,并且还会报错:
Invalid redeclaration of 'init(hex:)'

还有一个问题,那就是如果想要设置上下分层的背景,在Widget中会存在背景含有边距的情况。

这个效果在Xcode预览以及真机上,都是一致的。我的理解是白色区域实际上就是Widget的安全区域,不允许Widget内容占据这一安全区域。
如果想要实现Widget的背景颜色,可以配置BankletWidget文件的containerBackground部分:
struct BankletWidget: Widget {
let kind: String = "BankletWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
BankletWidgetEntryView(entry: entry)
.containerBackground(.fill.secondary, for: .widget) // 背景色
}
}
}
当设置containerBackground为紫色时:
BankletWidgetEntryView(entry: entry)
.containerBackground(.purple, for: .widget) // 背景色

背景颜色变为紫色。
根据containerBackground的定义,表示有两种用法。

一种是设置背景颜色:
someView
.containerBackground(Color.blue, for: .navigation) // 设置导航容器背景为蓝色
第二种则可以设置一个自定义视图(例如一张图片)作为容器背景:
someView
.containerBackground(for: .navigation) {
Image("backgroundImage") // 自定义的背景视图
}
通过设置一个橙色和白色上下分层的图片:

在containerBackground中应用:
BankletWidgetEntryView(entry: entry)
.containerBackground(for: .widget) {
Image("WidgetBackground")
}

完成小组件的整个设计效果。
背景图片需要放在Widget项目的Assets中,否则可能不会显示。

总结
因为本篇文章篇幅过长,因此仅解析Widget文件部分,WidgetLiveActivity则考虑在以后的时间里,推出相关的解析文章。
通过使用Widget,可以进一步扩展应用的可用性和功能。
关于Widget的扩展知识,比如根据小组件的大小显示不同的内容,则在后续的文章中进一步分析,本篇的知识分享先讲到这里。
相关文章
1、SwiftUI UserDefaults使用App Group共享数据:https://fangjunyu.com/2025/01/24/swiftui%e9%9a%94%e7%a6%bbuserdefaults%e5%ae%9e%e4%be%8b/
2、小组件、复杂功能和实时活动:https://developer.apple.com/cn/widgets/
3、WidgetKit:https://developer.apple.com/documentation/widgetkit
4、ActivityKit:https://developer.apple.com/documentation/ActivityKit/
扩展知识
Timeline是什么?
Timeline 是 WidgetKit 中用来定义和管理 Widget 更新周期的数据结构。它允许Widget 在不同时间点显示的内容,通常由时间轴条目(TimelineEntry) 组成。每个条目包含了一个指定时间和展示内容。
1、TimelineEntry:表示在特定时间点应该展示的数据。它通常包含一个日期 和 数据,可以把它理解为在某一时刻 Widget 应该展示的状态。
2、Timeline:由多个 TimelineEntry 组成,定义了 在一段时间内 Widget 的更新计划。
Timeline 的作用
Timeline 控制了 Widget 何时更新,每个 TimelineEntry 中的内容会被按照时间顺序展示。可以为每个时间点设定数据,这样 Widget 就会按照预定的时间周期更新。
Timeline 的基本结构
Timeline 包含一个 条目数组,这个数组包含了 Widget 在不同时间点显示的内容。
代码示例:
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
let currentDate = Date()
// 创建未来5个小时的条目
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
// 返回时间轴和更新策略(.atEnd 表示当最后一个条目显示完后才会更新)
return Timeline(entries: entries, policy: .atEnd)
}
1、创建条目:这里,我们生成了一个未来 5 小时的时间轴,每一小时生成一个 SimpleEntry。
SimpleEntry 包含两个字段:date 和 configuration。date 是时间,configuration 是展示的数据(如 Emoji)。
2、返回 Timeline:在 Timeline 的构造函数中传入 entries(条目数组)和 policy(更新策略)。
Timeline 的更新策略(policy)
.atEnd:表示所有条目都展示完后,再请求新的数据更新。适用于需要定时更新的 Widget。
.after(Date):指定在某个特定日期后更新 Widget。
.never:表示永不自动更新,通常在静态 Widget 或不需要更新的场景中使用。
Timeline 的生命周期
1、时间条目(TimelineEntry)定义了 Widget 在特定时间点的显示内容。
2、Timeline 会随着时间的推移按照设定的规则逐步更新 Widget 的内容。
3、每次 Widget 需要刷新数据时,Timeline 会重新生成,通常是因为时间周期已到。
何时使用 Timeline?
动态数据更新:例如,展示实时进度条、天气信息、新闻头 等动态内容时,使用 Timeline 可以让Widget 按照预设的时间间隔自动更新。
时间驱动更新:比如,展示定时提醒、任务进度、每日摘要,可以为这些内容创建定期更新的时间轴。
Timeline 使用示例
假设开发的 Widget 显示一个 天气预报,希望每小时更新一次天气数据:
func timeline(for configuration: WeatherConfiguration, in context: Context) async -> Timeline<WeatherEntry> {
var entries: [WeatherEntry] = []
let currentDate = Date()
// 每小时更新天气数据
for hourOffset in 0..<24 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
// 假设 fetchWeather() 是获取天气数据的异步方法
let weatherData = await fetchWeather(for: entryDate)
// 创建并添加条目
let entry = WeatherEntry(date: entryDate, weather: weatherData)
entries.append(entry)
}
// 返回时间轴,设置为 .atEnd,表示所有条目都展示完后会再请求新的数据
return Timeline(entries: entries, policy: .atEnd)
}
在这个示例中,24小时的天气预报将根据 Timeline 中的条目展示,并且每小时更新一次数据。