开发须知
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( geo.size.width, height: 20, alignment: .leading)
.font(.system(size: 14, weight: .bold, design: .default))
.lineLimit(1)
Image(uiImage: entry.poster.posterImage!)
.resizable()
.frame(60, height: 60)
.clipShape(Circle())
Text(content)
// 增加 padding 使 Text 过长时不会触及小组件边框
.padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
.frame( geo.size.width, height: 20, alignment: .center)
.font(.system(size: 13))
.lineLimit(1)
}
Spacer().frame( geo.size.width, height: 5 , alignment: .leading)
// .border(Color.green, 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( 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( 40, height: 25, alignment: .leading)
.font(.system(size: 13))
.lineLimit(1)
// .background(color)
Spacer()
Text("去抽奖")
.foregroundColor(.white)
.frame( 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, 1)
.frame( 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( geo.size.width, height: 20, alignment: .leading)
.font(.system(size: 14, weight: .bold, design: .default))
.lineLimit(1)
Image(uiImage: entry.poster.posterImage!)
.resizable()
.frame(60, height: 60)
.clipShape(Circle())
Text(content)
// 增加 padding 使 Text 过长时不会触及小组件边框
.padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
.frame( geo.size.width, height: 20, alignment: .center)
.font(.system(size: 13))
.lineLimit(1)
}
Spacer().frame( geo.size.width, height: 5 , alignment: .leading)
// .border(Color.green, 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( 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( 40, height: 25, alignment: .leading)
.font(.system(size: 13))
.lineLimit(1)
// .background(color)
Spacer()
Text("去抽奖")
.foregroundColor(.white)
.frame( 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, 1)
.frame( 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