This article is the 15th day article of and factory Advent Calendar 2020. Yesterday was @ MatsuNaoPen's wrike webhook with GAS.
It's been half a year since Apple announced WidgetKit at this year's WWDC2020. In a blink of an eye, WidgetKit insights were shared within iOS engineers.
So, this time, I wrote an article about the trajectory of adding WidgetKit to my personally developed application.
We are currently developing a tool app for a game called Clash Royale (hereinafter referred to as Clash Royale). The main functions are the following three.
Trophy(Numerical value indicating strength)Transition for each period | Create a deck and copy it to the Clash Royale app | RoyaleAPIA function that allows you to browse the web |
---|---|---|
So, this time, I decided to make a widget with the following functions that seem to be compatible with the widget.
Transition of trophies (numbers indicating strength) over a period of time
I chose this feature because I thought it would be useful if the widgets on my home screen would reflect the content without launching the app after playing Clash Royale.
[Completion drawing] <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/339d0fe0-2e4b-276d-8489-17da4d89078b.png ", width=300> It's a simple widget that shows how much the most recent wins and losses and the number of trophies have changed, the current number of trophies and the time of the last update.
Select File-> New-> Target
in Xcode.
Search for Widget
and add Widget Extension
.
Select Activate
for the above that appears after creating the Extension.
Then, if the schema has TodaysWidgetExtension
as above and there isTodaysWidget
in the project folder on the left, the creation is successful.
If you build at this point, you can see the widget on the simulator that only shows the current time! (Very easy!)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/80d7431f-3952-8ebd-1fd5-1b48e570fece.png ", width="300">
As mentioned in the screenshot above, the basic source code is one file under Extension. (TodaysWidget.swift this time) So let's take a look at the source code
TodaysWidget.swift
//
// TodaysWidget.swift
// TodaysWidget
//
// Created by nakandakari on 2020/12/13.
//
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date())
completion(entry)
}
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)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
@main
struct TodaysWidget: Widget {
let kind: String = "TodaysWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TodaysWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
struct TodaysWidget_Previews: PreviewProvider {
static var previews: some View {
TodaysWidgetEntryView(entry: SimpleEntry(date: Date()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
First of all, I will unravel from the following part with the entry point @ main
python
@main
struct TodaysWidget: Widget {
let kind: String = "TodaysWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TodaysWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
First of all, regarding Configuration, the configuration changes depending on whether the user can dynamically change the information represented by the widget.
type | Contents |
---|---|
StaticConfiguration | Used when the user does not specifically customize the data. |
IntentConfiguration | Used when the user wants to customize the data. For example, it is used in cases where you want to obtain information on a specified location instead of your current location in a weather app. |
With IntentConfiguration, you can customize the data to be acquired by long-pressing the widget as shown below. <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/7c0a259c-2498-ee8f-e777-033bad4b2daf.png ", width=300> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/a9b9dac1-76c1-1c72-0d27-75990b14b13b.png ", width=300> The description of each property is as follows.
Property | meaning |
---|---|
Kind | A unique string that indicates the widget. Basically, a string that describes the widget is recommended. |
Provider | Widget has the concept of timeline, and what kind of data is used at regular intervals(=An object called Entry)The role of deciding the rule of telling the widget. |
Content Closure | Data received from Provider(=Entry)Returns a SwiftUI View using. |
ConfigurationDisplayName | The title wording that appears when you add a widget. |
Description | A descriptive wording that appears when you add a widget. |
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/b09a22ac-c77d-438e-78e9-12f7ab16a654.png ", width=300>
Next, let's take a look at the View part of SwiftUI, which shows the contents of the widget.
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
First, I explained that View receives data called Entry and displays its contents.
Here, SimpleEntry
is the data.
As a supplement, the protocol of TimelineEntry
is as follows.
public protocol TimelineEntry {
/// The date for WidgetKit to render a widget.
var date: Date { get }
/// The relevance of a widget’s content to the user.
var relevance: TimelineEntryRelevance? { get }
}
The View is also defined as a custom view Struct called TodaysWidgetEntryView
, and the time is displayed using the SimpleEntry mentioned earlier.
It turns out that this SimpleEntry is the data and we are creating a view that receives it and displays it in TodaysWidgetEntryView. So this time, I should modify it, so I tried as follows.
struct SimpleEntry: TimelineEntry {
let date: Date
let todaysResult: TodaysResultInfo //Add necessary data group
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack(alignment: .center, spacing: 5, content: {
Text("Recenlty Result")
// self.entry.Create a view using todaysResult
})
}
}
TodaysResultInfo
struct TodaysResultInfo {
let win : Int
let lose : Int
let draw : Int
let changes : Int
let currentTrophy : Int
init(win: Int, lose: Int, draw: Int, changes: Int, currentTrophy: Int) {
self.win = win
self.lose = lose
self.draw = draw
self.changes = changes
self.currentTrophy = currentTrophy
}
}
extension TodaysResultInfo {
var changesLabel: String {
if self.changes > 0 {
return "+\(changes)" //When it is positive+I want to write ◯
}
return "\(changes)"
}
}
extension TodaysResultInfo {
//Preview test data
static var dummyData: TodaysResultInfo {
TodaysResultInfo(win: 4, lose: 2, draw: 1, changes: 50, currentTrophy: 2300)
}
}
Now let's create a View as well.
TodaysResultVIew
//
// TodaysResultView.swift
// WidgetTest
//
// Created by nakandakari on 2020/12/13.
//
import SwiftUI
import WidgetKit
struct TodaysResultView: View {
let entry: SimpleEntry
var body: some View {
VStack(alignment: .center, spacing: 10, content: {
Text("Recentlty Result")
HStack {
LabelAndCountView(label: "Win", count: self.entry.todaysResult.win)
LabelAndCountView(label: "Lose", count: self.entry.todaysResult.lose)
LabelAndCountView(label: "Draw", count: self.entry.todaysResult.draw)
}
HStack {
Text("Changes")
Text(self.entry.todaysResult.changesLabel)
}
HStack {
Image("player_trophy")
.resizable()
.frame(width: 28, height: 28)
Text("\(self.entry.todaysResult.currentTrophy)")
Text(self.entry.date, style: .time)
}
})
}
}
struct LabelAndCountView: View {
let label: String
let count: Int
var body: some View {
VStack(alignment: .center, spacing: 3, content: {
Text(label)
Text("\(count)")
})
}
}
struct TodaysResultView_Previews: PreviewProvider {
static var previews: some View {
TodaysResultView(entry: SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.dummyData))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
TodaysWidget.swift
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
TodaysResultView(entry: entry) //Change here
}
}
If you do so far, it will look like this on the Preview screen.
Finally, let's look at the Provider part of what kind of data is generated every hour.
TodaysWidget.swift
struct Provider: TimelineProvider {
typealias Entry = SimpleEntry //This is added when creating the View earlier
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.placeHolderDummyData)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.snapShotDummyData)
completion(entry)
}
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, todaysResult: TodaysResultInfo.dummyData)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
Method | My interpretation | Supplementary image |
---|---|---|
func placeholder | It was in the view and Apple documentation that the widget handles when it first appears. The placeholder view is said to be used to give the user a general look of the widget and to show what this widget looks like. (But honestly I don't understand well here...) |
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/4ba6f1f0-78ab-fca6-7e2c-4d1bae94671a.png ", width=100> The view part is a placeholder(mosaic)Is displayed in the state where |
func getSnapshot | Gallery for adding widgets(Widget gallery)It is a method that returns the data displayed as a preview in. The documentation says it's important to show the user a quick preview snapshot here, so you might not want to do anything heavy to generate an Entry. |
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/733699fd-9a9d-3364-dc2d-e8a0afeb1787.png ", width=100> ウィジェット追加する際のWidget galleryでの見た目を定義出来る |
func getTimeline | This timeline method is called after getSnapshot is first called. Here, decide how often to update and what kind of data should be reflected next. |
Here, it seems that it is necessary to customize the getTimeline
method according to" how often you want the widget to be updated ".
The following is an example of "update the widget after 30 minutes".
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let date = Date()
var entries: [SimpleEntry] = []
entries.append(SimpleEntry(date: date, todaysResult: TodaysResultInfo.dummyUpdatedData))
//Update widget after 30 minutes
let nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 30, to: date)!
let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate))
completion(timeline)
}
However, there is no guarantee that it will be updated after the specified time & it is better to set an update interval of at least 15 minutes. Click here for details: Keeping a Widget Up To Date
Since the article has become long, I have omitted the details, but in this widget, the player data is acquired from RoyaleAPI in this getTimeline
method and the timeline is generated. I will.
Here are some of the features I didn't use this time that should be covered by WidgetKit.
function | Explanation | Supplementary image |
---|---|---|
supportedFamilies | Specifies the size of the widgets available. There are 3 sizes. systemSmall, systemMedium, systemLarge * Unless otherwise specified, widgets of all sizes can be added to the widget gallery. |
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/ba34ae98-1772-a218-fc65-d406f3eab94e.png ", width=300> |
widgetFamily | You can make it return a specific SwiftUI View depending on the size of the supported widgets. | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/46f1588c-194e-4a9a-3a34-0412af2c0b29.png ", width=300> |
widgetURL | A function that allows the widget view to have a specific URL and perform specific processing when the app is started. systemMedium and systemLarge can set multiple URLs by providing multiple SwiftUI Views in the widget. |
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/9d6697c8-383d-1f18-287c-a8d34be7c8af.png ", width=300> |
WidgetBundle | AfunctionthatallowsyoutodefinemultiplewidgetswithoneWidgetKitExtension. Example)With the PayPay app, there are multiple widgets for "balance / payment" and "bonus operation". |
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/1220437a-7964-8786-749e-a0b9f138a753.png ", width=300><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/1b28b801-696c-0b7a-0ef0-aba0c6497a61.png ", width=300> |
Normally, a long press tap from the home screen should bring up the widget list, but if it is a simulator, it will not be displayed at all ... It seems that it is useless to build the application itself in the simulator, so it seems necessary to build and debug with the Widget Extension as the build target.
Reference: Widgets Not Appearing in Simulator
Failed to get descriptors for extensionBundleID
In conclusion, it was caused by the omission of the entry point of @ main
.
I was doing it while looking at the Sample code (* DL) provided by Apple, but there is no entry point for @ main
in EmojiRangerWidget.swift
and it is imitated. It was because it was.
There was just another entry point for LeaderboardWidget.swift
...
LeaderboardWidget.swift
@main
struct EmojiRangerBundle: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
EmojiRangerWidget()
LeaderboardWidget()
}
}
However, in my case, after performing Clean Build once, a crash log appeared as shown in the screenshot below and the cause was easy to understand, but at first it was an error that this log did not appear and the build could not be done in the first place.
Reference: Failed to get descriptors for extensionBundleID
linking against a dylib which is not safe for use in application extensions:
It seems that this Warning appears when using the Embedded Framework with the Widget extension (or app-extension).
To remove the Warning, check Allow app extension API only
in General-> DevelopmentInfo
of Embedded Framework.
The Embedded Framework used on the app-extension side causes a compile error and points out when using code that cannot be used with the App Extension (such as UIApplication). (Is it a nuance like "Warning is not safe because it may be called by dylib that links code that cannot be called on the extension side!")
Reference: App Extension # 2 – Convert shared code to Framework using Embedded Framework
This is also about App Extension, not WidgetKit, but you need to add a Capabilities called App Groups
to use the data stored on the device using Realm or UserDefault in the main project on the WidgetKit side.
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/77d06430-4225-e5e6-37a6-572ca49fe708.png ", width=300>
Add App Groups
from Signing & Capabilities.
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/709b83e6-5f03-431a-7283-e9f8aa4da749.png ", width=300>
Set an appropriate group name (group.WidgetTest
).
Then modify the Realm side as follows.
Realm
// Before
private func realm() -> Realm? {
var config = Realm.Configuration()
config.fileURL = config.fileURL!.appendingPathComponent("test_db.realm")
return try? Realm(configuration: config)
}
// After
private func realm() -> Realm? {
var config = Realm.Configuration()
//add to
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.WidgetTest")!
config.fileURL = url.appendingPathComponent("test_db.realm")
return try? Realm(configuration: config)
}
UserDefault is almost the same, and it is OK if you use UserDefaults (suiteName:" group.WidgetTest ")? .XXX
instead of UserDefaults.standard.XXX
.
Reference: [iOS 14] I tried to display the data saved in Realm on the Widget
WidgetKit has become "this and that" and it has become a fairly long article, but I think that the introduction of WidgetKit itself is not that difficult. I'm new to App Extension itself, and I get the impression that it took a long time because of the involvement of XcodeGen and Embedded Framework. I think you can enjoy developing with WidgetKit alone!
Creating a Widget Extension Keeping a Widget Up To Date [IOS 14] Widget (Widget Kit) Summary
Recommended Posts