[Swift] Trajectory until adding WidgetKit to an existing application

This article is the 15th day article of and factory Advent Calendar 2020. Yesterday was @ MatsuNaoPen's wrike webhook with GAS.

Introduction

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.

Introducing personal apps

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.

Implementation

1. Add Widget Extension

スクリーンショット 2020-12-13 18.09.29.png Select File-> New-> Target in Xcode.

スクリーンショット 2020-12-13 18.09.39.png

Search for Widget and add Widget Extension.

スクリーンショット 2020-12-13 18.36.12.png Select Activate for the above that appears after creating the Extension.

スクリーンショット 2020-12-13 18.38.22.png

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">

2. Modify the Widget source code

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))
    }
}

2.1 Widget body part

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>

2.2 Entry and View part

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. スクリーンショット 2020-12-13 20.06.12.png

2.3 Timeline part

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)
}

Other

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>

Addictive points

Widget not displayed in simulator

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.

スクリーンショット 2020-12-09 23.02.52.png

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.

スクリーンショット 2020-12-10 0.59.27.png

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

Realm and UserDefault values ​​cannot be used in WidgetKit (App Extesion)

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

in conclusion

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!

Reference site

Creating a Widget Extension Keeping a Widget Up To Date [IOS 14] Widget (Widget Kit) Summary

Recommended Posts

[Swift] Trajectory until adding WidgetKit to an existing application
Rails6 I tried to introduce Docker to an existing application
[Rails] Create an application
[Swift] Trajectory until adding WidgetKit to an existing application
How to create an application
Rails6 I tried to introduce Docker to an existing application
[Swift 5] Note: Add Core Data to an existing project
How to create an application
Introduced Vue.js to an existing Rails app
How to publish an application on Heroku
Introduced Vuetify to an existing Rails app
Downgrade an existing app created with rails 5.2.4 to 5.1.6
I tried to develop an application in 2 languages
How to install Docker in the local environment of an existing Rails application [Rails 6 / MySQL 8]