Getting started with Today Extension in Swift 5 iOS 15.05.2020

iOS 8 introduced App Extensions: a way for you to share your app’s functionality with other apps or the OS itself. One of these types of extensions is a Today Extension, also known as a Widget. These allow you to present information in the Notification Center and Lock Screen, and are a great way to provide immediate and up-to-date information that the user is interested in.

Extensions are packaged as a separate binary from their host app. So you’ll need to add a Today Extension target to your project.

Steps to add a today widget

  1. Select File > New > Add Target, and select Today Extension.
  2. Give a name to your extension, and add it to your project.

XCode will show a warning offering to activate the extension scheme. Since this will allow us to debug the extension, we will agree to Activate the scheme.

A folder with the Today Extension name appears in our project’s files list. By the default, there is TodayViewController which implements the NCWidgetProviding protocol, MainInterface.storyboard, in which you may realize your widget’s UI and the Info.plist settings file.

Select the storyboard, and start to design the screen for our extension. Add a table view to display our items, and set the constraints so that the table view takes the whole of the view controller.

Next we should select the table view, and add a protoype cell. We will assign an identifier to the cell: cellId.

Lets take a look at our TodayViewController: this is the default code created automatically when we add the extension.

The function widgetPerformUpdate it is called to update the widget, so we will put the code to fetch the items to display here. But before that we are going to finish to set up the Table View.

class TodayViewController: UIViewController, NCWidgetProviding {

    var items: [String] = ["Item 1", "Item 2", "Item 3"]

    @IBOutlet weak var tbl: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        tbl.delegate = self
        tbl.dataSource = self
    }

    func widgetPerformUpdate(completionHandler: (@escaping (NCUpdateResult) -> Void)) {
        completionHandler(NCUpdateResult.newData)
    }
}

And implement the datasource, and delegate extensions:

extension TodayViewController : UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: "cellId", for: indexPath)
        cell.textLabel?.text = items[indexPath.row]
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let item = items[indexPath.row]

        self.extensionContext?.open(URL(string: "xxxUrl://\(item))")!, completionHandler: nil)
    } 
}

Add following snippet to the AppDelegate

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    if url.scheme == "xxxUrl" {

    let item = url.absoluteString.components(separatedBy:CharacterSet.decimalDigits.inverted).joined(separator: "")
        print(item)
    }

}

Before deploying the extension we can set the name it will display in the TodayView by modifying the attribute Bundle display name int the Info.plist of our extension folder.

Height

The widget’s functionality allows you to expand it and view more detailed information. To do this you should, for example, in viewDidLoad() make widgetLargestAvailableDisplayMode equal expanded:

override func viewDidLoad() {
    super.viewDidLoad()
    self.extensionContext?.widgetLargestAvailableDisplayMode = .expanded
}

But it will not work until you implement the NCWidgetProviding protocol method:

func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
    if activeDisplayMode == .compact {
        self.preferredContentSize = maxSize
    } else if activeDisplayMode == .expanded {
        self.preferredContentSize = CGSize(width: maxSize.width, height: 150)
    }
}

This method will be called each time you click on the More/Less button. At the moment activeDisplayMode can be equal to compact or expanded:

  • compact — the folded mode (the More button is shown). The Today Extension altitude is 110 (without the possibility to modify it), considering the system font is set by default. If it will be changed then the widget’s altitude will vary respectively;
  • expanded — the deployed mode (the Less button is shown). The Today Extension altitude can range from 110 to 440. Also, there is a possibility that when changing the system font the widget’s altitude may vary.

UserDefaults

If your data is static or has been taken from the Foundation (e.g. Date) everything is simple. But if you need to output the data, written from the host application, then you need to create a container (group) for targets.

Add a group with your name and activate it for both the app and the extension. You can activate App groups in the Capabilities tab inside your Project settings. The group will also appear at the widget’s target, you need to activate it.

After the container is created, you can record the data, for example, using UserDefault and the group’s name:

let sharedDefaults = UserDefaults(suiteName: "group.xxx")
sharedDefaults?.setValue("Greeting!", forKey: "greeting")

To show this data in the widget you need just call UserDefaults with the same suiteName:

let sharedDefaults = UserDefaults.init(suiteName: "group.xxx")
let text = sharedDefaults?.value(forKey: "greeting")

CoreData

To use CoreData, the work through the container is also required. But in this case, work with the container will be run with a database file. To make NSPersistentContainer read and to write data into the main container, you need to create its inheritor and override some methods:

class SharedPersistentContainer: NSPersistentContainer {
    override open class func defaultDirectoryURL() -> URL {
        var storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.xxx")
        storeURL = storeURL?.appendingPathComponent("xxx.sqlite")
        return storeURL!
    }
}

class CoreDataManager {
    static let shared = CoreDataManager()
    private init() {}

    lazy var context: NSManagedObjectContext = {
        return CoreDataManager.shared.persistentContainer.viewContext
    }()

    lazy var persistentContainer: NSPersistentContainer = {
        let container = SharedPersistentContainer(name: "xxx")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

    func saveContext() {
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
}

You also need to mark the target for this widget to have the access to the CoreData model.

You can now use the new PersistentContainer and work with it in the mobile app as well as in the widget.

Shared container

After enabling app groups you are also able to use shared container - the entire folder in the file system. We can wrap it into an extension as well:

extension FileManager {
    static func sharedContainerURL() -> URL {
        return FileManager.default.containerURL(
            forSecurityApplicationGroupIdentifier: "group.xxx"
        )!
    }
}

You can use this URL to read/write whatever is necessary. Just remember that access to the container is not thread safe so you might need to use some locks to prevent data corruption. Starting from iOS 9 we are able to use NSFileCoordinator class designed specifically for that.

Open the mobile app using Today Extension

To do this you need to open an URL with a scheme of your main container-app:

let url = URL(string: "xxxUrl://")!
    self.extensionContext?.open(url, completionHandler: { (success) in
        if (!success) {
            print("error: failed to open app from Today Extension")
        }
    })

In the URL you can specify, for example, the ID or object’s name, by using which a container-programme knows what ViewController, what request to send to the server, etc.

If the container-app still does not have its URL scheme, you can add it. Open Targets > Info > URL Types and click plus.

Useful links