Widget 的實現依賴 SwiftUI,SwiftUI 部分的內容推薦你們自行學習,給出了一些質量較高的文章,能夠配置 WWDC19/20 SwiftUI 相關 session 搭配食用。git
Demo 工程:SwiftUIWidgetgithub
iOS14 的 Widget 和 iOS14 以前的 Widget 已經完成了統一,以前老樣式的 Widget 只能經過在老版本上進行查看,後續僅支持 iOS14 目前的 Widget。只能使用 SwiftUI 進行開發。shell
Widget 不是 mini app,應該看做是把 app 的內容在主屏幕的映射關係。官方給出的數據,通常咱們會在一天的時間裏進入主屏幕超過 90 次,並在主屏幕上短暫停留。編程
Widget 有三個尺寸,但不強迫每一個尺寸都實現,由於不是全部 app 都適合全尺寸 widget 展現,但推薦都實現(猜想就是要給用戶最大自由度。json
Text
組件新增了能夠實時展現時間的 API。
Text(Date(), style: .time)
控制容許用戶選擇的小組件類型swift
@main
struct PJWidget: Widget {
private let kind: String = "PJWidget"
public var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
PJWidgetEntryView(entry: entry)
}
.configurationDisplayName("PJWidget")
.description("2333")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
複製代碼
在構建 entryView
時,根據當前選擇的 widgetFamily
值來返回不一樣的樣式。api
struct PJWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
@ViewBuilder
var body: some View {
switch family {
case .systemSmall:
PJAvatarView(entry.name)
default:
Text("PJHubs")
}
}
}
複製代碼
使用 Xcode Widget Extension 模版建立完後,會自動給默認 Widget 加上 @main
修飾符標記出當前 app Widget 的入口。bash
換句話說,此時咱們進入到「Widget 搜索」,找到咱們的 app,只會看到一個 Widget。markdown
@main
struct SwiftUIWidgetDemo: Widget {
let kind: String = "SwiftUIWidgetDemo"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
SwiftUIWidgetDemoEntryView(entry: entry)
}
.configurationDisplayName("西瓜做者-數據日報")
.description("你的數據精簡日報")
.supportedFamilies([.systemSmall])
}
}
複製代碼
@main
struct Widgets: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
SwiftUIWidgetDemo()
SwiftUIWidgeMediumDemo()
SwiftUIWidgeMediumFansDemo()
}
}
複製代碼
注意:最多隻容許塞入五個 Widget 樣式。網絡
@main
?提及 @main
你們可能會先想到以前的 @UIApplicationMain
這個修飾詞,說到 @UIApplicationMain
可能又會想到 main.swift 或者 main.m 等等這些文件。總的來講,它們之間是存在某種神祕聯繫的!咱們來寫一個簡單的 Swift 代碼:
class demoSwift {
class func test() {
print("world!")
}
}
demoSwift.test()
複製代碼
此時使用 swiftc demo.swift
後會獲得一個可執行文件,看上去 Swift 的語法讓新上手的同窗使人感到愉快,不會再有類 C 系那種必須寫一系列又臭又長的 main 函數初始化流程,但本質上真的不用寫了嗎? 咱們來看看中間代碼。
# 查看生成的中間代碼
swiftc demo.swift -emit-sil
複製代碼
sil_stage canonical
import Builtin
import Swift
import SwiftShims
class demoSwift {
class func test() @objc deinit init() } // main sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
%2 = metatype $@thick demoSwift.Type // user: %4
// function_ref static demoSwift.test()
%3 = function_ref @$s4demo0A5SwiftC4testyyFZ : $@convention(method) (@thick demoSwift.Type) -> () // user: %4
%4 = apply %3(%2) : $@convention(method) (@thick demoSwift.Type) -> ()
%5 = integer_literal $Builtin.Int32, 0 // user: %6
%6 = struct $Int32 (%5 : $Builtin.Int32) // user: %7 return %6 : $Int32 // id: %7 } // end sil function 'main' // ... 如下省略 複製代碼
能夠看到,所謂的對新人友好都是假的,全都是編譯期間 swiftc
作的自動化插入,自動給咱們的方法插入了與以前相似的流程,若是咱們須要多文件編譯依賴,要有一個 main.swift 做爲入口文件進行索引其餘文件進行編譯。 @UIApplicationMain
出現後,咱們再也不須要 main.swift 文件來作入口切割,可經過自定義類並加上該標記便可,這個好處在 Swift 5.3 中正式推廣到語言層面,咱們僅需使用 @main
便可標記出 Swift 文件的入口,再也不是 Cocoa 特性,進而替代掉了 @UIApplicationMain
。
Widget 提供了用戶可配置數據源的方式,能夠經過此類方式來繞過 Widget 成組後最大上限五個的限制。提供兩種配置方式
其中 IntentConfiguration 可提供給用戶有限的「自由」,自行選擇對應 Widget 下須要展現的數據源。利用了基於 Intents.framework 框架實現,並能夠直接複用 SiriKit 的功能來達到 Widget 的智能化(後文再敘)。
配置 IntentConfiguration 的步驟以下:
struct SwiftUIWidgetDemoMediumEntryView : View {
var entry: Provider.Entry
@ViewBuilder
var body: some View {
switch entry.configuration.countType {
case .money:
// 此處需傳入數據源
MediumWidgetFansView()
default:
// 此處需傳入數據源
MediumWidgetView()
}
}
}
複製代碼
@ViewBuilder
從實際問題看 SwiftUI 和 Combine 編程 已說明,能夠前往瞭解。
struct Provider: IntentTimelineProvider {
// NOTE: 小組件佔位視圖,第一次添加或 loading 狀態中的視圖
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), count: 0, image: nil, configuration: ConfigurationIntent())
}
// NOTE: 第一次添加或小組件第一次被展現時調用
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), count: 0, image: nil, configuration: configuration)
completion(entry)
}
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// ...
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
複製代碼
placeholder方法中返回 widget 在初始化 loading 過程當中的佔位 UI。
這個視圖是系統行爲,只要咱們使用的是標準 SwiftUI 組件,會自動根據組件類型,如 Image
、Text
結合咱們自定義的顏色和背景來自動完成佔位圖的設置,若是咱們不想要的系統自定義的話,也能夠在方法中自行返回自定義的佔位組件。
注意點:在構建 PlaceHolderView
時,Session 中所給的 isPlaceHoler
經過屬性的方式去作已經不行了,得經過如下方式來進行(若是咱們須要預覽的話):
PJWidgetEntryView(entry: SimpleEntry(date: Date())).redacted(reason: .placeholder)
複製代碼
Widget 的目的很是簡單,目前在 Widget 上所作的事情,全都是爲了引導用戶能夠輕鬆的點擊小組件和經過 deepLink 跳轉到咱們的 app 中。而從 Widget 跳轉到 app 中針對不一樣類型的 Widget 有共有兩種跳轉方式。
.widgetURL
struct SmallWidgetView: View {
var body: some View {
VStack(alignment:.leading) {
Text("PJHubs")
}
.widgetURL(URL(string: "urlschema://pjhubsWidgetURL"))
}
}
複製代碼
Link
struct MediumWidgetView: View {
var body: some View {
Link(destination:URL(string: "urlschema://pjhubsLink")!) {
VStack(alignment:.leading) {
Text("PJHubs")
}
}
}
}
複製代碼
SceneDelegate.m 中的內容爲:
#import "SceneDelegate.h"
#import "WidgetURLViewController.h"
#import "LinkViewController.h"
@implementation SceneDelegate
- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
if (URLContexts.allObjects.count != 0) {
UIOpenURLContext *urlContext = URLContexts.allObjects.firstObject;
NSURL *url = urlContext.URL;
if ([url.absoluteString isEqualToString:@"urlschema://pjhubsWidgetURL"]) {
[self.window.rootViewController presentViewController:[WidgetURLViewController new] animated:YES completion:nil];
}
if ([url.absoluteString isEqualToString:@"urlschema://pjhubsLink"]) {
[self.window.rootViewController presentViewController:[LinkViewController new] animated:YES completion:nil];
}
}
}
@end
複製代碼
#import "OCView.h"
@implementation OCView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self initView];
}
return self;
}
- (void)initView {
self.backgroundColor = [UIColor whiteColor];
UILabel *textLabel = [[UILabel alloc] init];
textLabel.text = @"這是 OC View";
textLabel.font = [UIFont systemFontOfSize:30];
[textLabel sizeToFit];
textLabel.frame = CGRectMake((self.frame.size.width - textLabel.frame.size.width)/2, (self.frame.size.height - textLabel.frame.size.height)/2, textLabel.frame.size.width, textLabel.frame.size.height);
[self addSubview:textLabel];
}
@end
複製代碼
import Foundation
import SwiftUI
struct OCWidgetView: UIViewRepresentable {
func makeUIView(context: Context) -> OCView {
return OCView()
}
func updateUIView(_ uiView: OCView, context: Context) {
}
}
複製代碼
SwiftUI 中提供了 Coordinator
這個理念來做爲 OCView
可能存在的各類 delegate 相關回調事件,在 SwiftUI 中一樣能夠進行使用,在此不作展開。
此時就能夠在 SwiftUI 中引入 OCWidgetView 了!
struct MediumWidgetFansView: View {
var body: some View {
VStack(alignment:.leading) {
OCWidgetView()
}
}
}
複製代碼
Widget 的 UI 部分只能使用 SwiftUI 框架下的 UI 組件,不能使用任何 UIKit 相關的組件,就算用 SwiftUI 包一層也不行(UIViewRepresentable),強行使用的話,會在 Widget 視圖上獲得一個黃色背景紅叉:
Widget 須要經過 Timeline 來進行數據刷新,但其刷新的時機由系統控制,但有時咱們設置了刷新間隔時間也不必定會在該時間點進行刷新。
若是咱們徹底依賴 Widget 自身的數據更新策略,每次間隔 1s 刷新數據,每次更新時拉取 5 個數據,設置 Timeline policy爲 .atEnd
,也即當 timeline 中的數據用完後當即拉取下一條數據,則最短也許要 1min 時間才能拉取下一條 timeline(自測)。
atEnd
: 拉取到最後一個數據後從新拉取。atAfter
: 在指定時間後以必定時間間隔拉取數據。never
:該 timeline 不須要刷新。不是都須要一次在 timeline 中構造好多個數據實體,能夠一次返回一個,刷新間隔設置爲 1min(或其它時間),這樣比較適合對數據實時性要求較高的產品。系統並很多按照咱們所規定的那樣執行邏輯,系統考慮的因素用官方的話語來講,要結合耗電量等等問題綜合給到不一樣 Widget 的刷新時機和此時,但總的來講,常常被查看的 widget 會得到更多的刷新機會。
若是咱們想要在 app 內主動同步 widget 上所展現的消息,或在當前時刻必須刷新,如「開言英語」 Widget 用戶登陸先後的 UI 表現不一樣等,在這種需求背景下,咱們可使用 WidgetKit 的 WidgetCenter API 來完成。
WidgetCenter.shared.reloadAllTimelines()
。reloadAllTimelines
方法會從新 load 所屬 app 內的全部已配置的 Widget,從新拉取 Timeline。
須要注意的是,WidgetKit 爲 Swift Only,想要在 OC 工程中使用該方法刷新 Widget Timeline 得經過 Swift 包一層,且要求 app 處於活躍狀態。
import WidgetKit
@objcMembers class PJWidgetCenter: NSObject {
class func reloadWidgetTimeline() {
WidgetCenter.shared.reloadAllTimelines();
WidgetCenter.shared.reloadTimelines(ofKind: "What kind of widget?")
}
}
複製代碼
支持全部 widget 刷新或某一個 widget。
#import "ViewController.h"
#import "SwiftUIWidget-Swift.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[PJWidgetCenter reloadWidgetTimeline];
}
複製代碼
數據來源 getTimeline
方法支持異步操做,咱們若是須要動態的走網絡請求拉取構造 timeline 數據,能夠直接丟出一個異步回調。
struct Provider: IntentTimelineProvider {
// ...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
networkHandler {
let timeline = Timeline(entries: $0, policy: .atEnd)
completion(timeline)
}
}
func networkHandler(completion: @escaping ([SimpleEntry]) -> Void) {
URLSession.shared.dataTask(with: URL(string: "http://pjhubs.com")!) { (data, response, error) in
let originalDict = try? JSONSerialization.jsonObject(with: data!, options: JSONSerialization.ReadingOptions.allowFragments) as? NSDictionary
print(originalDict as Any)
completion([SimpleEntry(date: Date(), count: 1234, image: UIImage(named: "avatar")!, configuration: ConfigurationIntent())])
}.resume()
}
}
複製代碼
注意請求間隔、Timeline 更新時間和數據轉換等問題。
以在 Widget 展現用戶頭像舉例,在以往的開發經歷中,咱們都不但願有同步操做阻塞主線程從而形成 app 卡頓,故在 Widget 中咱們也會天然而然的在「圖片展現」這一環節中套用異步請求資源的思路去作,但這在 Widget 中是不被容許的,咱們須要轉變一個思路。
如下這種把圖片資源延後到 UI 層的作法能夠拉取成功,但不會被加載。
struct SmallWidgetView: View {
@State var networkImage: UIImage?
var body: some View {
VStack(alignment:.leading) {
HStack {
Image(uiImage: self.networkImage ?? UIImage(named: "avatar")!)
// ...
.onAppear(perform: getNetworkImage)
}
// ...
}
// ...
}
func getNetworkImage() {
URLSession.shared.dataTask(with: URL(string: "https://tu.sioe.cn/gj/qiege/image.jpg")!) { (data, _, _) in
self.networkImage = UIImage(data: data!)
}.resume()
}
}
複製代碼
解決這一問題目前有三種方法但都是一種思路,核心就是把圖片的加載過程從異步轉化爲同步,這個同步的過程能夠是在 getTimeline
初始化時間線時,也能夠是在構造 Widget UI 層邏輯時。
如下爲在 getTimeline 初始化時間線時的事例:
struct SmallWidgetView: View {
var uiImage: UIImage?
var body: some View {
VStack(alignment:.leading) {
HStack {
Image(uiImage: uiImage ?? UIImage(named: "avatar")!)
// ...
}
// ...
}
// ...
}
}
複製代碼
struct Provider: IntentTimelineProvider {
// ...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
let currentDate = Date()
for hourOffset in 0 ..< 3 {
let entryDate = Calendar.current.date(byAdding: .second, value: hourOffset, to: currentDate)!
// NOTE: 在處理 timeline 時就把資源加載好
var image: UIImage? = nil
if let imageData = try? Data(contentsOf: URL(string: "https://tu.sioe.cn/gj/qiege/image.jpg")!) {
image = UIImage(data: imageData)
}
let entry = SimpleEntry(date: entryDate, count: Int.random(in: 0...100), image: image, configuration: configuration)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
複製代碼
Widget 在最初放出的 beta 版本中是能夠支持圖片資源的異步回調的,但後來又改爲了目前的這種只能經過同步的方式進行資源獲取。
若是出現不一樣類型的 widget 須要複用圖片資源,可使用系統內輕量級 cache 方法(如:NSCache
等)來完成在 A 類型 Widget 下已經加載完成的圖片資源,後續用戶再手動添加 B 類型 Widget 後能夠加速 Widget 渲染。
當咱們須要從 app target 傳遞數據到 widget target 時,能夠組成 App Groups,經過 UserDefualt
來完成數據傳遞,注意兩個 target 都須要增長 app groups。
在 app target 中設置測試代碼,10s 後刷新 widget 的顯示內容,以此來模擬真實 app 中主工程觸發某個網絡事件,等待延時後同步數據給 Widget。
App target
#import "ViewController.h"
#import "SwiftUIWidget-Swift.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[PJWidgetCenter reloadWidgetTimeline];
});
}
@end
複製代碼
Swift 處理過程(非必需)
import WidgetKit
import Foundation
@objcMembers class PJWidgetCenter: NSObject {
class func reloadWidgetTimeline() {
if let userDefaults = UserDefaults(suiteName: "group.com.pjhubs.swiftuiwidge") {
userDefaults.setValue("2333", forKey: "integer")
}
WidgetCenter.shared.reloadAllTimelines();
}
}
複製代碼
Widget
struct Provider: IntentTimelineProvider {
@AppStorage("integer", store: UserDefaults(suiteName: "group.com.pjhubs.swiftuiwidge"))
var intString: String = ""
//...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .second, value: hourOffset, to: currentDate)!
// ...
let entry = SimpleEntry(date: entryDate, count: Int(intString)!, image: image, configuration: configuration)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
複製代碼
推薦看完 爲小組件添加智能和配置
基於 iOS12 引入的 Intent.framework,目前有兩種提升 Widget 在智能堆疊中展現的辦法。
WWDC20 Session - 爲小組件添加智能和配置視頻截圖。
咱們能夠把一些自定義的關鍵組合信息構造出一個 intent 捐贈給系統,經過 Intent.framework,系統不但能夠把這些信息傳遞給咱們 app widget 還能夠傳遞到 spotlight 等其它依賴 Intent 的場景從而減小進入特定場景/app 的步驟。
轉換成咱們的產品視角,看成者天天都在 14 點查看本身的視頻播放量這一個指標數據,能夠在做者進入到指標頁面時,經過構造 Intent 實例進行捐贈給系統,當累計到必定次數(不定)後,系統會在天天用戶 14 點先後解鎖進入主屏時,在「智能堆疊」Widget 中自動翻滾到咱們加入其中的 Widget 並展現出對應的播放量 Widget。
若是咱們想要在特定時間主動突出小組件在智能堆疊上的展現機會,可使用「數據源評分展現」策略,在構造 Timeline 時能夠給不一樣的數據實體塞入不一樣的評分,從而達到在不一樣時間節點或特定時間節點下的突出展現。
轉換成咱們的產品視角,看成者新發布了一個視頻,可能想要在將來的一天、兩天甚至一週內關注視頻自己的播放量這一指標,咱們能夠經過固定分數和持續時間來達到提高展現,從而關閉其它數據源更新時的
struct Provider: IntentTimelineProvider {
// ...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// ...
for hourOffset in 0 ..< 3 {
// ...
if (hourOffset == 1) {
let revelance = TimelineEntryRelevance(score: 2000, duration: 60);
let entry = SimpleEntry(date: entryDate, count: 2000, image: image, configuration: configuration, relevance: revelance)
entries.append(entry)
} else {
let revelance = TimelineEntryRelevance(score: 10, duration: 0);
let entry = SimpleEntry(date: entryDate, count: Int(intString)!, image: image, configuration: configuration, relevance: revelance)
entries.append(entry)
}
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
複製代碼
須要注意的是,咱們給在第二分鐘時要展示的數據分數 Relevance 分數設置爲 2000,其它數據的分數設置爲 10 分,此時運行 Widget 並等待到第二分鐘,智能堆疊的 Widget 並不會必定翻轉到咱們的 Widget 上,但 Widget 上的數據是確確實實被更新了的,同時也說明了系統並不會必定認爲當前數據比同一個 Widget 下的其它數據評分高,就必定爲在智能堆疊上必須展現咱們的 Widget,只是說在智能堆疊執行翻轉時,咱們的 Widget 會得到比其它 Widget 可能會得到更高的展現機會。
而且該評分也僅僅只是和當前 Widget 內的數據源作的對比。