開發須知
1、WidgetExtension 使用的是新的WidgetKit不同於Today Widget,它只能使用SwiftUI
進行開發,所以需要SwiftUI和Swift基礎
2、Widget只支持3種尺寸systemSmall (2x2)、 systemMedium (4x2)、 systemLarge(4x4)
3、默認點擊Widget打開主應用程序
4、Widget類似於Today Widget是一個獨立運行的程序,需要在項目中進行 App Groups 的設置才能使其與主程序互通數據,這點與Today Widget相同
Widget實現
0.創建Target所需的Profile
這個都懂,這里就忽略了
1.創建添加Widget Extension
File
-> New
-> Target
-> Widget Extension
Include Configuration Intent
如果你所創建的Widget需要支持 用戶自定義配置屬性,則需要勾選這個(例如天氣組件,用戶可以選擇城市;記事本組件,用戶記錄信息等),
未勾選
用戶配置屬性,網絡加載數據顯示小組件,跳轉到APP指定頁面

cannot preview in this file — New build system required
2.Widget文件函數解析
Provider
TimelineProvider
協議,產生一個時間線,告訴 WidgetKit 何時渲染與刷新 Widget,
struct Provider: TimelineProvider { // 占位視圖 // placeholder:提供一個默認的視圖,例如網絡請求失敗、發生未知錯誤、第一次展示小組件都會展示這個view
func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date()) } /* 編輯屏幕在左上角選擇添加Widget、第一次展示時會調用該方法 getSnapshot:為了在小部件庫中顯示小部件,WidgetKit要求提供者提供預覽快照,在組件的添加頁面可以看到效果 */ func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date()) completion(entry) } /* getTimeline:在這個方法內可以進行網絡請求,拿到的數據保存在對應的entry中,調用completion之后會到刷新小組件 */ 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) entries.append(entry) } /* 參數policy:刷新的時機 .never:不刷新 .atEnd:Timeline 中最后一個 Entry 顯示完畢之后自動刷新。Timeline 方法會重新調用 .after(date):到達某個特定時間后自動刷新 !!!Widget 刷新的時間由系統統一決定,如果需要強制刷新Widget,可以在 App 中使用 WidgetCenter 來重新加載所有時間線:WidgetCenter.shared.reloadAllTimelines() Timeline的刷新策略是會延遲的,並不一定根據你設定的時間精確刷新。同時官方說明了每個widget窗口小部件每天接收的刷新都會有數量限制 */ let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } }
Entry
渲染 Widget 所需的數據模型,需要遵守TimelineEntry
協議。
struct SimpleEntry: TimelineEntry { let date: Date }
@main 主入口
/* @main:代表着Widget的主入口,系統從這里加載,可用於多Widget實現 kind:是Widget的唯一標識 WidgetConfiguration:初始化配置代碼 StaticConfiguration : 可以在不需要用戶任何輸入的情況下自行解析,可以在 Widget 的 App 中獲 取相關數據並發送給 Widget IntentConfiguration: 主要針對於具有用戶可配置屬性的Widget ,依賴於 App 的 Siri Intent,會自動接收這些 Intent 並用於更新 Widget,用於構建動態 Widget configurationDisplayName:添加編輯界面展示的標題 description:添加編輯界面展示的描述內容 supportedFamilies:設置Widget支持的控件大小,不設置則默認三個樣式都實現 */ @main struct getWidget: Widget { let kind: String = "getWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in getWidgetEntryView(entry: entry) } .configurationDisplayName("My Widget") .description("This is an example widget.") } }
Widget控件尺寸大小
首次運行
首次運行會顯示一個text,顯示的是時間
3.Widget數據請求及網絡圖片加載
首先定個小目標,實現一個這樣的頁面
swift數據處理
struct Poster { /* posterImage:默認圖片占位 */ let dic: Dictionary<String, Any> let idStr: String var posterImage: UIImage? = UIImage(named: "getWidgettest") }
Entry
中綁定對應的模型
struct SimpleEntry: TimelineEntry { let date: Date let poster : Poster }
創建請求函數,並且回調請求參數,聲明一個請求工具,實現數據請求並將網絡圖片同步
請求
struct Poster { /* posterImage:默認圖片占位 */ let dic: Dictionary<String, Any> let idStr: String var posterImage: UIImage? = UIImage(named: "getWidgettest") } struct PosterData { static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) { let urlString:String = "http://XXXXXXXXXXXXXXXXx"
// 加密,當傳遞的參數中含有中文時必須加密
let newUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) //創建請求配置
let config = URLSessionConfiguration.default
// 創建請求URL
let url = URL(string: newUrlString!) // 創建請求實例
let request = URLRequest(url: url!) // 進行請求頭的設置 // request.setValue(Any?, forKey: String) // 創建請求Session
let session = URLSession(configuration: config) // 創建請求任務
let task = session.dataTask(with: request) { (data,response,error) in
// print(String(data: data! , encoding: .utf8) as Any) // 將json數據解析成字典 // let dictionary = try? JSONSerialization.jsonObject(with: data!, options: .mutableContainers)
let poster=posterFromJson(fromData: data!) completion(.success(poster)) } // 激活請求任務
task.resume() } static func posterFromJson(fromData data:Data) -> Poster { let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any] guard let result = json["data"] as? [Any] else{ return Poster(dic:["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888], idStr: "1", posterImage: UIImage(named: "getWidgettest")) } let randomInt = Int(arc4random() % 2) let datafirst = result[randomInt] as? [String: Any] let idStr = String(datafirst!["id"] as! Int) let posterImage = datafirst!["image_url"] as! String let vDic = datafirst //圖片同步請求
var image: UIImage? = nil if let imageData = try? Data(contentsOf: URL(string: posterImage)!) { image = UIImage(data: imageData) } return Poster(dic:vDic!, idStr: idStr, posterImage: image) } }
在
getTimeline
中進行數據請求中
completion(timeline)
執行完之后,不再支持圖片的異步回調,用異步加載的方式就無法加載網絡圖片,所以必須在數據請求回來的處理中采用
同步方式,將圖片的data獲取,轉換成UIImage,在賦值給Image展示
數據加載處理
struct Provider: TimelineProvider { let poster = Poster(dic:["name":"Air Jordan 1 Mid “Chicago”","id":1,"market_price":8888],idStr: "1",posterImage:UIImage(named: "getWidgettest")) // 占位視圖 // placeholder:提供一個默認的視圖,例如網絡請求失敗、發生未知錯誤、第一次展示小組件都會展示這個view
func placeholder(in context: Context) -> SimpleEntry { return SimpleEntry(date: Date(),poster: poster) } /* 編輯屏幕在左上角選擇添加Widget、第一次展示時會調用該方法 getSnapshot:為了在小部件庫中顯示小部件,WidgetKit要求提供者提供預覽快照,在組件的添加頁面可以看到效果 */ func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), poster: poster) completion(entry) } /* getTimeline:在這個方法內可以進行網絡請求,拿到的數據保存在對應的entry中,調用completion之后會到刷新小組件 */ func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { let currentDate = Date() //設定1小時更新一次數據
let updateDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! PosterData.getTodayPoster { result in let poster: Poster if case .success(let fetchedData) = result{ poster = fetchedData }else{ poster=Poster(dic: ["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888],idStr: "1"); } /* 參數policy:刷新的時機 .never:不刷新 .atEnd:Timeline 中最后一個 Entry 顯示完畢之后自動刷新。Timeline 方法會重新調用 .after(date):到達某個特定時間后自動刷新 !!!Widget 刷新的時間由系統統一決定,如果需要強制刷新Widget,可以在 App 中使用 WidgetCenter 來重新加載所有時間線:WidgetCenter.shared.reloadAllTimelines() Timeline的刷新策略是會延遲的,並不一定根據你設定的時間精確刷新。同時官方說明了每個widget窗口小部件每天接收的刷新都會有數量限制 */ let entry = Entry(date: currentDate, poster: poster) let timeline = Timeline(entries: [entry], policy: .after(updateDate)) completion(timeline) } } }
頁面搭建展示
這里只舉例systemSmall
struct getWidgetEntryView : View { var entry: Provider.Entry //針對不同尺寸的 Widget 設置不同的 View
@Environment(\.widgetFamily) var family // 尺寸環境變量
var body: some View { //使用 GeometryReader 獲取小組件的大小
GeometryReader{ geo in VStack(content: { //HStack:縱向布局,默認居中對齊
VStack(alignment: .center, spacing: 5) { let content = entry.poster.dic["name"] as! String Text("get 0元抽獎") .padding(EdgeInsets(top: 10, leading: 14, bottom: 0, trailing: 14)) .frame(width: geo.size.width, height: 20, alignment: .leading) .font(.system(size: 14, weight: .bold, design: .default)) .lineLimit(1) Image(uiImage: entry.poster.posterImage!) .resizable() .frame(width:60, height: 60) .clipShape(Circle()) Text(content) // 增加 padding 使 Text 過長時不會觸及小組件邊框
.padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14)) .frame(width: geo.size.width, height: 20, alignment: .center) .font(.system(size: 13)) .lineLimit(1) } Spacer().frame(width: geo.size.width, height: 5 , alignment: .leading) // .border(Color.green, width: 1) //可以查看控件范圍
HStack(alignment: .center, spacing: 0){ Spacer() let money = String(entry.poster.dic["market_price"] as! Int) Text("¥0") .foregroundColor(.red) // .background(Color.green)//可以查看范圍
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .frame(width: 30, height: 25, alignment: .leading) // .font(.system(size: 20, weight: .bold, design: .default)) //也可以自定義字體
.font(Font.custom("HelveticaNeue-CondensedBold", size: 26)) .lineLimit(1) let color: Color = Color(red: 0.6, green: 0.6, blue: 0.6) Text(money) .foregroundColor(color) .strikethrough(true, color: .gray) .padding(EdgeInsets(top: 7, leading: -4, bottom: 0, trailing: 0)) .frame(width: 40, height: 25, alignment: .leading) .font(.system(size: 13)) .lineLimit(1) // .background(color)
Spacer() Text("去抽獎") .foregroundColor(.white) .frame(width: 50, height: 20, alignment: .center) .font(.system(size: 12, weight: .bold, design: .default)) .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .background(Color.orange) Spacer() } // .border(Color.yellow, width: 1)
.frame(width: geo.size.width, height:25 , alignment: .leading) .widgetURL(URL(string: "appXXXt://XXX?" + entry.poster.idStr)) }) } } }
Widget點擊交互
點擊Widget窗口喚起APP進行交互指定跳轉支持兩種方式:
1、widgetURL
:點擊區域是Widget的所有區域,適合元素、邏輯簡單的小部件
2、Link
:通過Link修飾,允許讓界面上不同元素產生點擊響應
3、systemSmall
只能用widgetURL實現URL傳遞接收
4、systemMedium
、systemLarge
可以用Link或者widgetUrl處理
var body: some View { Link(destination: URL(string: "跳轉鏈接Link")!){ VStack{ //UI編寫
} } }
接收方式
//swift
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { } //OC
-(BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options{ if ([url.scheme isEqualToString:@"NowWidget"]){ //執行跳轉后的操作
} return YES; }
全部代碼
import WidgetKit import SwiftUI struct Poster { /* posterImage:默認圖片占位 */ let dic: Dictionary<String, Any> let idStr: String var posterImage: UIImage? = UIImage(named: "getWidgettest") } struct PosterData { static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) { let urlString:String = "XXXXXXXXXXXXXXXXXX"
// 加密,當傳遞的參數中含有中文時必須加密
let newUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) //創建請求配置
let config = URLSessionConfiguration.default
// 創建請求URL
let url = URL(string: newUrlString!) // 創建請求實例
let request = URLRequest(url: url!) // 進行請求頭的設置 // request.setValue(Any?, forKey: String) // 創建請求Session
let session = URLSession(configuration: config) // 創建請求任務
let task = session.dataTask(with: request) { (data,response,error) in
// print(String(data: data! , encoding: .utf8) as Any) // 將json數據解析成字典 // let dictionary = try? JSONSerialization.jsonObject(with: data!, options: .mutableContainers)
let poster=posterFromJson(fromData: data!) completion(.success(poster)) } // 激活請求任務
task.resume() } static func posterFromJson(fromData data:Data) -> Poster { let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any] guard let result = json["data"] as? [Any] else{ return Poster(dic:["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888], idStr: "1", posterImage: UIImage(named: "getWidgettest")) } let randomInt = Int(arc4random() % 2) let datafirst = result[randomInt] as? [String: Any] let idStr = String(datafirst!["id"] as! Int) let posterImage = datafirst!["image_url"] as! String let vDic = datafirst //圖片同步請求
var image: UIImage? = nil if let imageData = try? Data(contentsOf: URL(string: posterImage)!) { image = UIImage(data: imageData) } return Poster(dic:vDic!, idStr: idStr, posterImage: image) } } struct Provider: TimelineProvider { let poster = Poster(dic:["name":"Air Jordan 1 Mid “Chicago”","id":1,"market_price":8888],idStr: "1",posterImage:UIImage(named: "getWidgettest")) // 占位視圖 // placeholder:提供一個默認的視圖,例如網絡請求失敗、發生未知錯誤、第一次展示小組件都會展示這個view
func placeholder(in context: Context) -> SimpleEntry { return SimpleEntry(date: Date(),poster: poster) } /* 編輯屏幕在左上角選擇添加Widget、第一次展示時會調用該方法 getSnapshot:為了在小部件庫中顯示小部件,WidgetKit要求提供者提供預覽快照,在組件的添加頁面可以看到效果 */ func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), poster: poster) completion(entry) } /* getTimeline:在這個方法內可以進行網絡請求,拿到的數據保存在對應的entry中,調用completion之后會到刷新小組件 */ func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { let currentDate = Date() //設定1小時更新一次數據
let updateDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! PosterData.getTodayPoster { result in let poster: Poster if case .success(let fetchedData) = result{ poster = fetchedData }else{ poster=Poster(dic: ["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888],idStr: "1"); } /* 參數policy:刷新的時機 .never:不刷新 .atEnd:Timeline 中最后一個 Entry 顯示完畢之后自動刷新。Timeline 方法會重新調用 .after(date):到達某個特定時間后自動刷新 !!!Widget 刷新的時間由系統統一決定,如果需要強制刷新Widget,可以在 App 中使用 WidgetCenter 來重新加載所有時間線:WidgetCenter.shared.reloadAllTimelines() Timeline的刷新策略是會延遲的,並不一定根據你設定的時間精確刷新。同時官方說明了每個widget窗口小部件每天接收的刷新都會有數量限制 */ let entry = Entry(date: currentDate, poster: poster) let timeline = Timeline(entries: [entry], policy: .after(updateDate)) completion(timeline) } } } struct SimpleEntry: TimelineEntry { let date: Date let poster : Poster } struct getWidgetEntryView : View { var entry: Provider.Entry //針對不同尺寸的 Widget 設置不同的 View
@Environment(\.widgetFamily) var family // 尺寸環境變量
var body: some View { //使用 GeometryReader 獲取小組件的大小
GeometryReader{ geo in VStack(content: { //HStack:縱向布局,默認居中對齊
VStack(alignment: .center, spacing: 5) { let content = entry.poster.dic["name"] as! String Text("get 0元抽獎") .padding(EdgeInsets(top: 10, leading: 14, bottom: 0, trailing: 14)) .frame(width: geo.size.width, height: 20, alignment: .leading) .font(.system(size: 14, weight: .bold, design: .default)) .lineLimit(1) Image(uiImage: entry.poster.posterImage!) .resizable() .frame(width:60, height: 60) .clipShape(Circle()) Text(content) // 增加 padding 使 Text 過長時不會觸及小組件邊框
.padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14)) .frame(width: geo.size.width, height: 20, alignment: .center) .font(.system(size: 13)) .lineLimit(1) } Spacer().frame(width: geo.size.width, height: 5 , alignment: .leading) // .border(Color.green, width: 1)
HStack(alignment: .center, spacing: 0){ Spacer() let money = String(entry.poster.dic["market_price"] as! Int) Text("¥0") .foregroundColor(.red) // .background(Color.green)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .frame(width: 30, height: 25, alignment: .leading) // .font(.system(size: 20, weight: .bold, design: .default))
.font(Font.custom("HelveticaNeue-CondensedBold", size: 26)) .lineLimit(1) let color: Color = Color(red: 0.6, green: 0.6, blue: 0.6) Text(money) .foregroundColor(color) .strikethrough(true, color: .gray) .padding(EdgeInsets(top: 7, leading: -4, bottom: 0, trailing: 0)) .frame(width: 40, height: 25, alignment: .leading) .font(.system(size: 13)) .lineLimit(1) // .background(color)
Spacer() Text("去抽獎") .foregroundColor(.white) .frame(width: 50, height: 20, alignment: .center) .font(.system(size: 12, weight: .bold, design: .default)) .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .background(Color.orange) Spacer() } // .border(Color.yellow, width: 1)
.frame(width: geo.size.width, height:25 , alignment: .leading) .widgetURL(URL(string: "appXXX://?XXX=" + entry.poster.idStr)) }) } } } @main struct getWidget: Widget { let kind: String = "getWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in getWidgetEntryView(entry: entry) } .configurationDisplayName("get 抽獎") .description("更多活動快來參與吧.") .supportedFamilies([.systemSmall]) } } struct getWidget_Previews: PreviewProvider { static var previews: some View { let poster = Poster(dic: ["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888],idStr: "1") getWidgetEntryView(entry: SimpleEntry(date: Date(), poster: poster)) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }
展示如下
備注:
1.如果發現顯示黑色,或者控件顯示不全,請檢查數據,數據錯誤會導致這樣
2.如果發現xcode真機運行后搜不到小組件,重啟手機試一下,這個我遇到過
結束語
先到這里,剛開始了解設計小組件,有什么不對的地方,還請大佬指教。
參考:https://www.jianshu.com/p/94a98c203763