Original address: NetEase Cloud Music iOS14 Widget Practical Manual
foreword
Apple released a widget (WidgetKit) at this year's WWDC20, which supports the display of dynamic information and personalized content on the home screen of iOS and iPadOS. Coupled with the addition of the iOS system application drawer, Apple has made a big move against the conservative home screen, causing users to look forward to widgets. However, there are many restrictions on the operation of widgets, and how to make a good user experience in a limited mechanism becomes a challenge that needs to be completed.
Brief description of the widget
Widgets can realize content display and function jump on the home screen.
The system will obtain the timeline from the widget, and display the data on the timeline according to the current time. Click the visual element being displayed to jump to the APP and realize the corresponding function.
The widget effect of cloud music is as follows:
Talking about Development Ideas
First of all, it needs to be clear that the widget is independent of the App environment (ie App Extension), and the life cycle, storage space, and running process of the widget are different from the App. So we need to introduce some infrastructure in this environment, such as network communication framework, image caching framework, data persistence framework, etc.
The life cycle of the widget itself is an interesting point. To put it bluntly, the life cycle of a widget is consistent with that of the desktop process, but this does not mean that the widget can execute code at any time to complete the business. Widgets use the data defined by the Timeline to render views, and our code can only be executed when refreshing the Timeline ( getTimeline
) and creating a snapshot ( getSnapshot
). In general, network data is fetched when the Timeline is refreshed, and appropriate views are rendered when snapshots are created.
In most cases, you need to use data to drive the view display. This data can be obtained through a network request, or it can be obtained from the App using the sharing mechanism of App Groups.
After the data is obtained when refreshing the Time Line, the Timeline can be synthesized according to business requirements. Timeline is an array TimelineEntry
with elements. ATimelineEntry
time object containing a that tells the system when to use this object to create a snapshot of the widget. date
It can also be inherited TimelineEntry
and added to the data model or other information required by the business.
In order for widgets to display views, SwiftUI needs to be used to complete the layout and style building of widgets. How to implement layout and style will be described below.
After the user clicks on the widget, the App will be opened AppDelegate
and openURL:
the method of will be called. We need to handle this event openURL:
in so that the user jumps directly to the desired page or calls a function.
Finally, if you need the customization options that are exposed to the user widget, use theIntents
The framework defines the data structure in advance, and provides the data when the user edits the widget, and the system will draw the interface according to the data. The custom data selected by the user will be provided in the form of parameters when refreshing the Time Line ( getTimeline
) and creating a snapshot ( ), and then execute different business logics according to different custom data.getSnapshot
App Extension
If you already have App Extension development experience, you can skip this chapter.
According to Apple: App Extension can extend custom functions and content outside the application and provide it to users when they interact with other applications or systems. For example, your app can appear as a widget on your home screen. That is to say, the widget is a kind of App Extension, and the development work of the widget is basically in the environment of the App Extension.
What is the relationship between App and App Extension?
In essence, they are two independent programs. Your main program can neither access the code of the App Extension nor its storage space. This is completely two processes and two programs. App Extension relies on your App body as a carrier. If the App is uninstalled, the App Extension will not exist in the system. Moreover, most of App Extension's life cycle acts on a specific field, and is managed by system control according to events triggered by users.
Create App Extension and configuration file
The following briefly describes how to create the App Extension of the widget and configure the certificate environment.
Add a Widget Extension in Xcode (the path is as follows: File-New-Target-iOS tab-Widget Extension). Don't forget to check if you need the custom function of the widget Include Configuration Intent
.
Add App Groups in the Target of Widget Extension, and keep the same App Group ID as the main program. If there are no App Groups in the main program, you need to add the App Groups of the main App at this time and define the Group ID.
If your developer account is logged in Xcode, then the configuration file and App ID of the application will be correct at this time. If you are not logged into Xcode, you need to go to the Apple Developer Center and manually create the App ID and configuration file for the App Extension. Don't forget to configure App Groups in App ID at this point.
App Groups Data Communication
Because App and App Extension can't communicate directly, when you need to share information, you need to use App Groups to communicate. App Groups have two ways of sharing data, NSUserDefaults
and NSFileManager
.
NSUserDefaults shared data
initWithSuiteName:
Initialize the instance with NSUserDefaults . suitename
Pass in the previously defined App GroupID.
- (instancetype)initWithSuiteName:(NSString *)suitename;
Then you can use NSUserDefaults
the access method of the instance to store and get the shared data. For example, if we need to share the current user information with the widget, we can do the following.
//使用 Groups ID 初始化一个供 App Groups 使用的 NSUserDefaults 对象
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.company.appGroupName"];
//写入数据
[userDefaults setValue:@"123456789" forKey:@"userID"];
//读取数据
NSString *userIDStr = [userDefaults valueForKey:@"userID"];
NSFileManager shared data
Use NSFileManager containerURLForSecurityApplicationGroupIdentifier:
to obtain the address of the storage space shared by the App Group, and then perform file access operations.
- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier;
SwiftUI building components
It should be based on considerations such as power consumption. Apple requires that widgets can only use SwiftUI and cannot UIViewRepresentable
be UIKit
used through bridging.
The interaction method of the widget is simple, only click, and the view is small. The SwiftUI knowledge required for development is relatively simple, just build a widget view reasonably, and generally speaking, operations such as data binding are not involved.
This chapter mainly introduces how to use SwiftUI to build widgets, and I will assume that readers already have basic knowledge of SwiftUI. If you are still relatively new to SwiftUI, you can improve your understanding through the two video tutorials in the reference materials ( [Fifteen Minutes to Understand SwiftUI] Layout / [Fifteen Minutes to Understand SwiftUI] Style ). You can also refer to the development documentation or related topics of WWDC19/20 to get more knowledge about SwiftUI.
Complete Widget Views with SwiftUI
Let's use a simple development example to help you develop widget views using SwiftUI.
First look at the visual draft of the widget:
briefly analyze the view elements in the visual draft:
- Cover all the background images (
Image
) - Black gradient from bottom to top (
LinearGradient
) - Cloud Music Logo (
Image
) in the upper right corner Image
The calendar icon ( ) in the middle of the widget- Two lines of text below the calendar icon (
Text
)
Through the analysis, it is not difficult to find that to achieve the effect of the visual draft, it needs to use Text
, Image
, and LinearGradient
three components to complete it.
Classify visual elements 1/2/3 as background views for easy reuse by other components. The 4/5 related to the component's content type are then assigned to the foreground view.
First implement the background view:
struct WidgetSmallBackgroundView: View {
// 底部遮罩的占比为整体高度的 40%
var contianerRatio : CGFloat = 0.4
// 背景图片
var backgroundImage : Image = Image("backgroundImageName")
// 从上到下的渐变颜色
let gradientTopColor = Color(hex:0x000000, alpha: 0)
let gradientBottomColor = Color(hex:0x000000, alpha: 0.35)
// 遮罩视图 简单封装 使代码更为直观
func gradientView() -> LinearGradient {
return LinearGradient(gradient: Gradient(colors: [gradientTopColor, gradientBottomColor]), startPoint: .top, endPoint: .bottom)
}
var body: some View {
// 使用 GeometryReader 获取小组件的大小
GeometryReader{
geo in
// 使用 ZStack 叠放 logo 图标 和 底部遮罩
ZStack{
// 构建 logo 图标, 使用 frame 确定图标大小, 使用 position 定位图标位置
Image("icon_logo")
.resizable()
.scaledToFill()
.frame(width: 20, height: 20)
.position(x: geo.size.width - (20/2) - 10 , y : (20/2) + 10)
.ignoresSafeArea(.all)
// 构建 遮罩视图, 使用 frame 确定遮罩大小, 使用 position 定位遮罩位置
gradientView()
.frame(width: geo.size.width, height: geo.size.height * CGFloat(contianerRatio))
.position(x: geo.size.width / 2.0, y: geo.size.height * (1 - CGFloat(contianerRatio / 2.0)))
}
.frame(width: geo.size.width, height: geo.size.height)
// 添加上覆盖底部的背景图片
.background(backgroundImage
.resizable()
.scaledToFill()
)
}
}
}
The effect of the background view is as follows:
Next, place the background view in the view of the widget, and realize the icon and text view in the middle, thus completing the visual construction process of the entire component:
struct WidgetSmallView : View {
// 设置大图标的宽高为小组件高度的 40%
func bigIconWidgetHeight(viewHeight:CGFloat) -> CGFloat {
return viewHeight * 0.4
}
var body: some View {
GeometryReader{
geo in
VStack(alignment: .center, spacing : 2){
Image("iconImageName")
.resizable()
.scaledToFill()
.frame(width: bigIconWidgetHeight(viewHeight: geo.size.height), height: bigIconWidgetHeight(viewHeight: geo.size.height))
Text("每日推荐")
.foregroundColor(.white)
.font(.system(size: 15))
.fontWeight(.medium)
.lineLimit(1)
.frame(height: 21)
Text("为你带来每日惊喜")
.foregroundColor(.white)
.font(.system(size: 13))
.fontWeight(.regular)
.opacity(0.8)
.lineLimit(1)
.frame(height: 18)
}
// 增加 padding 使 Text 过长时不会触及小组件边框
.padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
// 设置背景视图
.background(WidgetSmallBackgroundView())
}
}
}
Through the above simple example, we can find that in the conventional flow layout VStack
, HStack
the layout effect can be achieved by using and . And if you want to achieve the effect of the logo icon in the example, you need to use position/offset
to change the positioning coordinates to achieve the goal.
A little supplement about the Link view
Link is a clickable view that will open in the associated application if possible, otherwise in the user's default web browser. Medium/large size widgets can use it to set different jump parameters for the click area. Because the above example is a small-sized component, Link cannot be used to distinguish jumps, so I will add it here.
Link("View Our Terms of Service", destination: URL(string: "https://www.example.com/TOS.html")!)
retrieve data
network request
It can be used in the widget URLSession
, so the network request is basically the same as that in the App, so I won't go into details here.
Points to note:
- To use a third-party framework, you need to introduce the Target where the widget resides.
- A network request is invoked when the Timeline is refreshed.
- If you need to share information with App, you need to access it through App Group.
image loading cache
The image cache is different from that in the App. Image
Views in SwiftUI currently do not support passing URLs to load images from the web. It is also impossible to use the method Data
of to complete the loading of network pictures.
You can only get all the network pictures on the Timeline by refreshing the Timeline and calling the network request data
.
func getTimeline(for configuration: Intent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// 发起网络请求
widgetManager.requestAPI(family : context.family, configuration: configuration) {
widgetResponse, date in
// 在接口回调中生成 Timeline entry
let entry = WidgetEntry(date: Date(), configuration: configuration, response: widgetResponse, family : context.family)
// 解析出 Timeline entry 所需要的网络图片
let urls = entry.urlsNeedDownload()
// 查询本地缓存以及下载网络图片
WidgetImageManager().getImages(urls: urls) {
let entries = [entry]
let timeline = Timeline(entries: entries, policy: .after(date))
completion(timeline)
}
}
}
getImages
In the method, we need to maintain a queue to sequentially query the local cache and download network pictures when the cache misses.
public func getImages(urls : [String] , complition : @escaping () -> ()){
// 创建目录
WidgetImageManager.createImageSaveDirIfNeeded()
// 去重
let urlSet = Set(urls)
let urlArr = Array(urlSet)
self.complition = complition
self.queue = OperationQueue.main
self.queue?.maxConcurrentOperationCount = 2
let finishBlock = BlockOperation {
self.complition?()
}
for url in urlArr {
let op = SwiftOperation {
finish in
self.getImage(url: url) {
finish(true)
}
}
finishBlock.addDependency(op)
self.queue?.addOperation(op)
}
self.queue?.addOperation(finishBlock)
}
public func getImage(url : String , complition : @escaping () -> ()) -> Void {
let path = WidgetImageManager.pathFromUrl(url: url)
if FileManager.default.fileExists(atPath: path) {
complition()
return
}
let safeUrl = WidgetImageManager.filterUrl(url: url)
WidgetHttpClient.shareInstance.download(url: safeUrl, dstPath: path) {
(result) in
complition()
}
}
Data Acquisition for Preview Status
When the user adds a widget, he will see the view of the widget in the preview interface. At this point, the system will trigger placeholder
the method , and we need to return a Timeline in this method to render the preview view.
In order to ensure the user experience, it is necessary to prepare a piece of local data for the interface call to ensure that the user can see the real view on the preview interface, and try not to display the skeleton screen without data.
TimeLine
The content changes of widgets all depend on Timeline. Widgets are essentially a series of static views driven by Timeline.
Understanding TimeLine
As mentioned earlier, Timeline is an array TimelineEntry
with elements. ATimelineEntry
time object containing a that tells the system when to use this object to create a snapshot of the widget. date
It can also be inherited TimelineEntry
and added to the data model or other information required by the business.
Before generating a new Timeline, the system will always use the last generated Timeline to display data.
If there is only one entry in the Timeline array, the view is immutable. If you need widgets to change over time, you can generate multiple entries in Timeline and give them an appropriate time, and the system will use the entry to drive the view at the specified time.
Reload
The so-called widget refresh actually refreshes the Timeline, which causes the widget view driven by Timeline data to change.
There are two refresh methods:
- System reloads
- App-driven reloads
System reloads
Timeline refresh initiated by the system. The system determines the frequency of System Reloads for each different Timeline. Refresh requests exceeding the frequency will not take effect. Frequently used widgets can be refreshed more frequently.
ReloadPolicy:
When generating Timeline, we can define a ReloadPolicy to tell the system when to update Timeline. ReloadPolicy has three flavors:
- atEnd
- Refresh after all the entries provided by Timeline are displayed, that is to say, the current timeline will not be refreshed as long as there are entries that are not displayed
- after(date)
- date is the specified next refresh time, and the system will refresh Timeline at this time.
- never
- ReloadPolicy will never refresh the Timeline, and the widget will keep the display content of that entry after the last entry is displayed
The timing of Timeline Reload is uniformly controlled by the system, and in order to ensure performance, the system will decide whether to refresh Timeline at a certain moment according to the refresh timing required by the APP according to the importance level of each Reload request. Therefore, if you request to refresh the Timeline too frequently, it is likely to be restricted by the system and cannot achieve the ideal refresh effect. In other words, the time to refresh Timeline defined in atEnd, after(date) mentioned above can be regarded as the earliest time to refresh Timeline, and according to the arrangement of the system, these times may be delayed.
App-driven reloads
The refresh of the widget Timeline is triggered by the App. When the App is in the background, background push can trigger reload; when the App is in the foreground, WidgetCenter can actively trigger reload.
Calling WidgetCenter can refresh some widgets or all widgets according to kind
the identifier .
/// Reloads the timelines for all widgets of a particular kind.
/// - Parameter kind: A string that identifies the widget and matches the
/// value you used when you created the widget's configuration.
public func reloadTimelines(ofKind kind: String)
/// Reloads the timelines for all configured widgets belonging to the
/// containing app.
public func reloadAllTimelines()
Click to land
When the user clicks on the content or function entry on the widget, it is necessary to correctly respond to the user's needs after opening the App, and present the corresponding content or function to the user.
This needs to be done in two parts. First, define different parameters for different click areas in the widget, and then present different interfaces openURL:
in according to different parameters.
Distinguish between different click areas
If you want to define different parameters for different regions, you need to combine widgetURL and Link.
widgetURL
The scope of widgetURL is the entire widget, and there can only be one widgetURL on a widget. Additional widgetURL parameters will not take effect.
code show as below:
struct WidgetLargeView : View {
var body: some View {
GeometryReader{
geo in
WidgetLargeTopView()
...
}
.widgetURL(URL(string: "jump://Large")!)
}
}
Link
The Link scope is the actual size of the Link component. Multiple Links can be added, and there is no limit to the number. It should be noted that the Link API cannot be used under the systemSmall type of the widget.
code show as below:
struct WidgetLargeView : View {
var body: some View {
GeometryReader{
geo in
WidgetLargeTopView()
Link(destination: URL(string: "自定义的Scheme://Unit")!) {
WidgetLargeUnitView()
}
...
}
.widgetURL(URL(string: "自定义的Scheme://Large")!)
}
}
URL Schemes
URL Schemes are the bridge for widgets to jump to App, and also the channel for jumping between Apps. The average developer should be familiar with it.
Registering a custom URL Scheme is very simple, set it through info.plist
--> URL Types
--> item0
--> URL Schemes
--> 自定义的Scheme
.
Afterwards, in the widget, you can open your own App through 自定义的Scheme://
the spliced URL object, and ://
you can add parameters after to indicate the desired function or content.
Note: When adding parameters, the Chinese characters that appear must be escaped. Here you can use NSURLComponents
and NSURLQueryItem
to concatenate the jump URL string. It has its own escape effect and the operation URL is more standardized.
NSURLComponents *components = [NSURLComponents componentsWithString:@"自定义的Scheme://"];
NSMutableArray<NSURLQueryItem *> *queryItems = @[].mutableCopy;
NSURLQueryItem *aItem = [NSURLQueryItem queryItemWithName:@"a" value:@"参数a"];
[queryItems addObject:aItem];
NSURLQueryItem *bItem = [NSURLQueryItem queryItemWithName:@"b" value:@"参数b"];
[queryItems addObject:bItem];
components.queryItems = queryItems;
NSURL *url = components.URL;
Processing after launching the app
After clicking the widget to jump to the App, the openURL method of AppDelegate will be triggered.
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options
In the openURL method, by parsing the url parameters, the function jump or content display required by the user is clarified, and then the corresponding implementation is carried out. This puts forward certain requirements for the routing capability of the project, and because it has little connection with the development of small components, it will not be described in detail.
Dynamically configure widgets
Widgets allow users to configure custom data without opening the application. Using Intents
the framework , you can define the configuration page that users see when editing widgets.
The definition of words used here instead of drawing is because the configuration data can only be generated Intents
through , and the system will build the configuration page based on the generated data.
Build a simple custom function
Building a simple custom function requires two steps:
- Create and configure an IntentDefinition file
- Modify the related parameters of Widget to support ConfigurationIntent.
1. Create and configure IntentDefinition file
If you checked it when creating the widget Target Include Configuration Intent
, Xcode will automatically generate IntentDefinition
the file .
If Include Configuration Intent
the option , then you need to add IntentDefinition
the file manually.
Menu File
-> New
-> ThenFile
find and add it to the widget Target. After the file is created, open the file to configure it. First, you need to remember the class name in the Custom Class on the left. Xcode will automatically generate a ConfigurationIntent class after compilation according to this name, which stores user configuration information. Of course, you can also fill in a class name you specify here. It should be noted that this class will not be generated until the project is compiled. Then we need to create a custom parameter template, click the sign below to create a parameter. After that, you can define the Type of the created Parameter. In addition to the relatively intuitive system type, there are two more difficult to understand Enums and Types columns. System types Specific types have further customization options to customize the input UI. For example, the Decimal type can choose to use the input box (Number Field) input or the slider (Slider) input, and can customize the upper and lower limits of the input; the Duration type can customize the input value unit as seconds, minutes or hours; Date Components can specify Enter a date or time, specify the format of the date, and more. The simple understanding of Enum is that Enums are written to deathSiri Intent Definition File
.intentdefinition
Parameter
+
.intentdefinition
The static configuration in the file can only be updated after the release.
Type
Types are much more flexible and can be dynamically generated at runtime. Generally speaking, we use Types to make custom options.
Support for inputting multiple values
Most types of parameters support inputting multiple values, that is, inputting an array. At the same time, it supports to limit the fixed length of the array according to different Widget sizes.
Controlling the Display Conditions of Configuration Items
You can control a configuration item to display only when another configuration item contains any/specific value. As shown in the figure below, the Up Next Widget of the Calendar App will only display the Calendars configuration item when the Mirror Calendar App option is not selected.
In the Intent definition file, set a certain parameter A to another parameter B Parent Parameter
, so that the display of parameter B depends on the value of parameter A.
For example, in the image below, calendar
the parameter is mirrorCalendarApp
only false
displayed when the value of the parameter is :
2. Modify the relevant parameters of the Widget to support ConfigurationIntent
Replace in the Widget class StaticConfiguration
withIntentConfiguration
old:
@main
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) {
entry in
MyWidgetEntryView(entry: entry)
}
}
}
new:
@main
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: WidgetConfiguratIntent.self, provider: Provider()) {
entry in
MyWidgetEntryView(entry: entry)
}
}
}
Add the ConfigurationIntent parameter in the Timeline Entry class.
The code is as follows:
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: WidgetConfiguratIntent
}
Change the inheritance of IntentTimelineProvider
Provider
to IntentTimelineProvider
, and Intent
add the type alias of .
old:
struct Provider: TimelineProvider {
...
}
new:
struct Provider: IntentTimelineProvider {
typealias Intent = WidgetConfiguratIntent
...
}
Modify the input parameters of getSnapshot / getTimeline in turn to add support for customization. And when creating Timeline Entry, pass in the configuration.
Build custom portals using interface data
In Intent
Target, find IntentHandler
the file and follow ConfiguratIntentHandling
the protocol in the ConfigurationIntent generated class. A method
that implements the protocol requirements . In this method, we can call the interface to obtain custom data and generate the data source input parameters required by the block.provideModeArrOptionsCollectionForConfiguration:withCompletion:
completion
- (void)provideModeArrOptionsCollectionForConfiguration:(WidgetConfiguratIntent *)intent withCompletion:(void (^)(INObjectCollection<NMWidgetModel *> * _Nullable modeArrOptionsCollection, NSError * _Nullable error))completion {
[self apiRequest:(NSDictionary *result){
// 处理获取到的数据
....
NSMutableArray *allModelArr = ....;
// 生成配置所需要的数据
INObjectCollection *collection = [[INObjectCollection alloc] initWithItems:allModeArr];
completion(collection,nil);
}];
}
Widget gets custom parameters
When the widget generates a view based on the Timeline Entry, read the configuration attribute of the Entry to obtain whether the user defines the attribute and the detailed value of the attribute.
Summarize
Both advantages and disadvantages
Widgets are a thing with obvious advantages and disadvantages. It is really convenient to click and use on the desktop, but the lack of interactive methods and the inability to update data in real time are very big flaws. As Apple said: "Widgets are not mini-apps", don't use the thinking of developing App to make widgets. Widgets are just static views driven by a series of data.
Advantage:
- Standing on the desktop greatly increases the exposure of the product.
- Utilizing a web interface and data sharing, it is possible to present personalized content relevant to the user.
- Shortened access paths to functions. One click can let the user reach the desired function.
- It can be added repeatedly, with custom and recommendation algorithms, adding multiple widgets with different styles and data.
- Custom configuration is simple.
- Multiple sizes, large size can carry complex content display.
shortcoming:
- Data cannot be updated in real time.
- Only click to interact.
- The background of the widget cannot be set transparent.
- Moving images (video/motion graphics) cannot be displayed.
Tail
The development practice of small components has come to an end. It can be seen that although the components are small, they still need a lot of knowledge. Including Timeline, Intents, SwiftUI and other frameworks and concepts that are difficult to come into contact with in normal development, you need to understand and learn.
The weak interactive ability and data refresh mechanism of widgets are its flaws. Apple is very restrained about the ability of small components. During development, many ideas and requirements are limited by framework capabilities and cannot be realized. I hope that Apple can open up new capabilities in subsequent iterations. For example, the support part does not need to start the interactive form of the App.
But the flaws do not hide the advantages, showing users the content they like or providing the function entrance that users want, and amplifying the advantages of widgets is the correct development method of widgets at present.
References
- Know widgets
- Widgets watch and write-1
- Widgets Watch and Write-2
- Widgets Watch and Write-3
- Enable your Widget to support personalized configuration & intelligent display
- iOS 14 widgets from a developer's perspective
- [Fifteen minutes to understand SwiftUI] Layout articles
- [Fifteen minutes to understand SwiftUI] style articles