文檔
官方介紹
官方文檔
官方文檔-可用組件
官方文檔翻譯-非最新
官方文檔-用戶交互指南
主要參考文檔
修改為IntentConfiguration
簡單demo
各種widget效果demo
開發(fā)須知
- iOS14 以上支持 widget,iOS16.1以上支持 LiveActivity
- 使用 SwiftUI 開發(fā),在 iOS 上只有小、中、大三種尺寸
- iOS16以下不支持動效,iOS17以上支持動效
- 小組件的運行內(nèi)存,只有30M,確保不進行大量內(nèi)存消耗的操作。特別是避免一次性加載大量數(shù)據(jù)或圖片。
- widget中,并不是所有的視圖組件都可用。只是大部分可用。并且不能使用基于 UIViewRepresentable 包裹的 UIKit 組件,詳見可用組件。但是可以通過截圖的方式,獲取image,加載到Image(uiImage:)上來變相使用 UIKit 組件展示。
- 如果widget和主工程用同一個視圖,視圖基于UIKit實現(xiàn),那么可以通過 UIGraphicsGetImageFromCurrentImageContext 將視圖生成 UIImage,widget中只以 Image 加載的方式顯示,間接繞過 widget 中對 UIKit 的限制
- widget只能通過 Timeline 更新數(shù)據(jù)
- 可以支持多語言,與主工程類似,創(chuàng)建Localizable.strings即可
- 使用 App Group 與主工程共享數(shù)據(jù),例如 網(wǎng)絡(luò)請求用到的登錄用戶 token
- 點擊 widget,是通過主工程打點,以 scheme 區(qū)分
- 判斷 widget 是否安裝到桌面上,可以在 widget 刷新方法里,使用 AppGroup 記錄標志位,也可以調(diào)用widget的函數(shù)進行判斷
- widget 每天的刷新次數(shù)有限制,但具體多少次官方文檔沒說,只說有幾個因素會影響小部件接收的刷新次數(shù),例如包含應(yīng)用程序是在前臺還是后臺運行、小組件在屏幕上顯示的頻率以及包含應(yīng)用程序參與的活動類型
- 當 App 位于前臺時,調(diào)用 reloadTimelines(ofKind:) 刷新小組件,不會計入 widget 的每日限制
- image.png
核心代碼解釋
- 創(chuàng)建Widget時,可選Include Configuration Intent。開啟的話,小組件可以支持長按彈出自定義選項的功能,類似 App 的 3DTouch 選項。不開啟,則小組件長按只有“編輯主屏幕” 和 “移除小組件”
- StaticConfiguration,靜態(tài)widget,沒有開啟IntentConfiguration,不需要動態(tài)更新的小組件
- IntentConfiguration,可以自動接收 Intent 更新的小組件
- 創(chuàng)建好 Widget,會自動生成模版代碼,只需要在上面修改相應(yīng)代碼即可
// 入口代碼,如果需要支持多個相同尺寸的不同widget,需要在這里添加widget2(),widget3()..
// 每個widget,都可以支持小中大三種尺寸
@main
struct widgetBundle: WidgetBundle {
var body: some Widget {
widget()
widget2()
widgetLiveActivity()
}
}
// widget組件
struct widget: Widget {
// widget唯一標識符
// let kind: String = FSWidgetKindString
let kind: String = "com.company.project.widget"
// 添加 widgetEntryView 為 widget 的視圖
// supportedFamilies,設(shè)定當前widget支持的尺寸,可以設(shè)置小中大三種
// configurationDisplayName,顯示在小組件庫中的名字
// description,顯示在小組件庫中的描述
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
widgetEntryView(entry: entry)
}.supportedFamilies([.systemMedium])
.configurationDisplayName("WidgetDemo")
.description("New Message")
}
}
// 時間線更新
struct Provider: TimelineProvider {
// 顯示到主屏幕,等待數(shù)據(jù)加載,顯示的內(nèi)容
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), image: UIImage(named: "placeholder")!)
}
// 預(yù)覽和小組件庫中的顯示
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), image: UIImage(named: "placeholder")!)
completion(entry)
}
// **最重要的方法**,后續(xù)生成新的entry,用于刷新界面,都是在這里完成
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// 或者發(fā)起API請求,回調(diào)請求內(nèi)設(shè)置 timeline的更新策略
// API.fetchData{ result in }
// 每分鐘更新一次
let currentDate = Date()
let updateDate = Calendar.current.date(byAdding: .minute, value: 1, to: currentDate)!
// 生成新的entry數(shù)據(jù)
let entry = SimpleEntry(date: Date(), image: UIImage(named: "placeholder")!)
let timeline = Timeline(entries: [entry], policy: .after(updateDate))
completion(timeline)
}
}
// 更新widget所需要的所有數(shù)據(jù)
struct SimpleEntry: TimelineEntry {
let date: Date
let image: UIImage // 當前需要展示的圖片
}
// widget顯示的內(nèi)容,可以針對不同大小的widget設(shè)置不同的View
struct widgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
@ViewBuilder
var body: some View {
switch family {
case .systemSmall:
// 小,只支持widgetURL,點擊widget跳轉(zhuǎn)指定scheme
ZStack {
Image(uiImage: entry.image)
.resizable()
.scaledToFill()
}.widgetURL(URL(string: "demoapp://homepage")!)
case .systemMedium:
// 中,同時支持Link和widgetURL
ZStack {
Image(uiImage: entry.image)
.resizable()
.scaledToFill()
Link(destination: URL(string: "demoapp://link")!) {
Text("點擊跳轉(zhuǎn)")
}
}.widgetURL(URL(string: "demoapp://homepage")!)
default:
// 大,同時支持Link和widgetURL
ZStack {
Image(uiImage: entry.image)
.resizable()
.scaledToFill()
Link(destination: URL(string: "demoapp://link")!) {
Text("點擊跳轉(zhuǎn)")
}
}.widgetURL(URL(string: "demoapp://homepage")!)
}
}
}
與主App通過Group共享數(shù)據(jù)
// UserDefault 共享
let DRGroupDefaultSuitName = "group.com.demo"
func DRGroupDefault() -> UserDefaults {
return UserDefaults(suiteName: DRGroupDefaultSuitName)!
}
func DRGroupDefaultSet(_ value: Any?, forKey defaultName: String) {
DRGroupDefault().set(value, forKey: defaultName)
}
func DRGroupDefaultObject(forKey defaultName: String) -> Any? {
return DRGroupDefault().object(forKey: defaultName)
}
func DRGroupDefaultBool(forKey defaultName: String) -> Bool {
return DRGroupDefault().bool(forKey: defaultName)
}
func DRGroupDefaultSynchronize() {
let userDefault: UserDefaults = DRGroupDefault()
if Thread.isMainThread {
NSObject.cancelPreviousPerformRequests(withTarget: userDefault, selector: #selector(userDefault.synchronize), object: nil)
userDefault.perform(#selector(userDefault.synchronize), with: nil, afterDelay: 2.0)
} else {
userDefault.synchronize()
}
}
// File 文件共享
let DRGroupFileIdentifier = "group.com.demo"
func DRGroupFileURL() -> URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: DRGroupFileIdentifier)
}
func DRGroupFileWrite(toFile fileName: String, content: String) -> Bool {
guard let groupURL = DRGroupFileURL() else {
return false
}
let fileURL = groupURL.appendingPathComponent(fileName)
print(fileURL)
if DRFileManager.isExist(atPath: fileURL.absoluteString) {
DRFileManager.removeItem(atPath: fileURL.absoluteString)
}
do {
try content.write(to: fileURL, atomically: true, encoding: .utf8)
print("writeDataToFile 寫入文件成功")
return true
} catch {
print("writeDataToFile 寫入文件失敗")
return false
}
}
func DRGroupFileRead(fromFile fileName: String) -> String {
guard let groupURL = DRGroupFileURL() else {
return ""
}
let fileURL = groupURL.appendingPathComponent(fileName)
print(fileURL)
guard let contentData = FileManager.default.contents(atPath: fileURL.path) else {
return ""
}
let content = String(data: contentData, encoding: .utf8)
return content ?? ""
}
主App基于UIKit實現(xiàn)View,widget引入圖片
// 主App
public struct TestPainting {
static func uiKitView() -> UIView {
let view = UIImageView.init(frame: CGRect(x: 0, y: 0, width: DRHelper.screen_width, height: 180))
view.backgroundColor = .cyan
view.roundingCorners(corners: .allCorners, radii: 20)
let image = UIImage(named: "icon_placeholder")
view.image = image
view.contentMode = .center
return view
}
public static func uiKitContextImage() -> UIImage? {
let imageView = self.uiKitView()
let image = imageView.toImage()
return image
}
}
extension UIView {
func toImage() -> UIImage? {
let scale = UIScreen.main.scale
UIGraphicsBeginImageContextWithOptions(self.frame.size, false, scale)
guard let context = UIGraphicsGetCurrentContext() else {
UIGraphicsEndImageContext()
return nil
}
self.layer.render(in: context)
let viewImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return viewImage
}
}
// widget
struct widgetEntryView : View {
var entry: Provider.Entry
var body: some View {
ZStack {
let image = TestPainting.uiKitContextImage()
Image(uiImage: image ?? UIImage(named: "placeholder")!)
.resizable()
.scaledToFill()
}.widgetURL(URL(string: "demo://homepage")!)
}
}
// 主App 判斷是否添加了小組件
static func isWidgetExist(complete: @escaping (_ exist: Bool) -> Void) {
WidgetCenter.shared.getCurrentConfigurations { result in
var exist = false
defer {
complete(exist)
}
guard case let .success(widgets) = result else {
return
}
let currentWidget = widgets.first(where: { widget in
debugPrint("小組件名稱:\(widget.kind)")
return widget.kind == DRConstantKey.widgetKindString
})
if currentWidget != nil {
exist = true
}
}
}
真機運行報錯
- 手機系統(tǒng)更新到最新,xcode更新到最新,可以解決所有的報錯。不用去修改widget 的 edit scheme,XCWidgetKind,勾選XCWidgetDefaultView啥的,更新到最新,直接跑就行

