Sample Code

Building a Simple Widget for the Today View

Provide users with a glanceable look at your app’s data.

Download

Overview

This sample code builds a weather app that displays a simple five-day weather forecast using a horizontally paging collection view. Each collection view cell contains a label showing the day the forecast applies to, an icon representing the forecast, and a label describing it. In this example, the Today extension provides a more compact, glanceable view of the app’s data.

This sample project consists of three targets:

  • The main app — WeatherApp

  • A Today extension containing the widget — WeatherTodayExtension

  • An embedded framework for shared code — WeatherFramework

Place Shared Code in an Embedded Framework

If both the main app and the Today extension require access to the same classes, those classes must be extracted into an embedded framework. In this sample project, the model code is located in WeatherFramework.

Any classes or other entities in WeatherFramework must be made explicitly public so they’re available from the other targets. The initializer of the WeatherForecast struct—which is private in the implicit implementation—must also be reimplemented and made public.

public init(daysFromNow: Int, forecast: Weather) {
    self.daysFromNow = daysFromNow
    self.forecast = forecast
}

For information about creating an embedded framework, see Technical Note TN2435.

Place Shared Assets in the Embedded Framework

If both the main app and the Today extension use images or other assets, as in this sample, include them in an asset catalog contained within the embedded framework. Load image assets from the embedded framework by using the init(named:in:compatibleWith:) method of UIImage.

let assetName = self.imageAssetName
let assetBundle = Bundle(identifier: "com.example.apple-samplecode.WeatherFramework")
guard let image = UIImage(named: assetName, in: assetBundle, compatibleWith: nil)
    else { preconditionFailure("Expected an image named \(assetName)") }

Support Multiple Widget Sizes

When the user taps the Show More or the Show Less button in the top-right corner of a Today widget, the display mode switches between NCWidgetDisplayMode.compact and NCWidgetDisplayMode.expanded.

This switch triggers the system to call the widgetActiveDisplayModeDidChange(_:withMaximumSize:) method of the NCWidgetProviding protocol, which is implemented by the ForecastExtensionViewController. The sample code uses this method to set the value of preferredContentSize for the view controller. In this sample, it’s set to either the maximum size of the compact display mode, for NCWidgetDisplayMode.compact, or the size of all cells, for NCWidgetDisplayMode.expanded.

func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
    switch activeDisplayMode {
    case .compact:
        // The compact view is a fixed size.
        preferredContentSize = maxSize
    case .expanded:
        // Dynamically calculate the height of the cells for the extended height.
        var height: CGFloat = 0
        for index in weatherForecastData.indices {
            switch index {
            case 0: height += ForecastTableViewCell.todayCellHeight
            default: height += ForecastTableViewCell.standardCellHeight
            }
        }
        preferredContentSize = CGSize(width: maxSize.width, height: min(height, maxSize.height))
    @unknown default:
        preconditionFailure("Unexpected value for activeDisplayMode.")
    }
}

When the display mode changes, you can also override the viewWillTransition(to:with:) method to animate smoothly between the two states. In this sample code, cells that are being inserted or deleted are animated using performBatchUpdates(_:completion:).

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)

    let updatedVisibleCellCount = numberOfTableRowsToDisplay()
    let currentVisibleCellCount = self.tableView.visibleCells.count
    let cellCountDifference = updatedVisibleCellCount - currentVisibleCellCount

    // If the number of visible cells has changed, animate them in/out along with the resize animation.
    if cellCountDifference != 0 {
        coordinator.animate(alongsideTransition: { [unowned self] (UIViewControllerTransitionCoordinatorContext) in
            self.tableView.performBatchUpdates({ [unowned self] in
                // Build an array of IndexPath objects representing the rows to be inserted or deleted.
                let range = (1...abs(cellCountDifference))
                let indexPaths = range.map({ (index) -> IndexPath in
                    return IndexPath(row: index, section: 0)
                })

                // Animate the insertion or deletion of the rows.
                if cellCountDifference > 0 {
                    self.tableView.insertRows(at: indexPaths, with: .fade)
                } else {
                    self.tableView.deleteRows(at: indexPaths, with: .fade)
                }
            }, completion: nil)
        }, completion: nil)
    }
}

Move from the Today Extension to the Main App

When the user taps any of the weather forecast cells in the Today extension, the main app is activated, and it scrolls the collection view to bring the appropriate day into view.

To make this behavior possible, the weatherwidget: URL scheme is registered with the main app, and the Today extension then constructs a URL with the tapped day encoded as a parameter:

weatherwidget://?daysFromNow=2

The open(_:completionHandler:) method of the extensionContext is then called, opening the app.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // Open the main app at the correct page for the day tapped in the widget.
    let weatherForecast = weatherForecastData[indexPath.row]
    if let appURL = URL(string: "weatherwidget://?daysFromNow=\(weatherForecast.daysFromNow)") {
        extensionContext?.open(appURL, completionHandler: nil)
    }

    // Don't leave the today extension with a selected row.
    tableView.deselectRow(at: indexPath, animated: true)
}

The main app processes the URL in the application(_:open:options:) delegate method, where it parses the parameters before scrolling the collection view in the ForecastViewController to the correct cell.

// Extract the daysFromNow query string parameter from the incoming URL.
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let daysFromNowQueryItem = components?.queryItems?.first(where: { queryItem -> Bool in
    return queryItem.name == "daysFromNow"
}) else { preconditionFailure("Expected a daysFromNow parameter") }
guard let daysFromNowValue = daysFromNowQueryItem.value
    else { preconditionFailure("Expected daysFromNow parameter to have a value") }
guard let daysFromNow = Int(daysFromNowValue)
    else { preconditionFailure("Expected daysFromNow to be an integer") }

// Scroll the initial view controller to the correct index.
initialViewController.scrollToForecast(index: daysFromNow)

For information about allowing your app to respond to custom URL schemes, see Implementing Custom URL Schemes.

Share Data Between the App and the Extension

Weather forecast data in this sample is generated randomly and stored on disk in a property list (.plist) file. To allow both targets to access the same area of the file system, you add the App Group entitlement to each target that requires access. Once the entitlement is added and an App Group has been created, you can use the containerURL(forSecurityApplicationGroupIdentifier:) method of FileManager to get the URL to the shared directory.

static var sharedDataFileURL: URL {
    let appGroupIdentifier = "group.com.example.apple-samplecode.WeatherWidget"
    guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
        else { preconditionFailure("Expected a valid app group container") }
    return url.appendingPathComponent("Data.plist")
}

For more information about app groups, see Adding an App to an App Group.

Respond to Data Updates

Tapping the refresh button on the navigation bar of the main app generates a new random weather forecast and writes it to the shared app group’s container. To ensure that the Today extension always shows the latest data, use the widgetPerformUpdate(completionHandler:) method of the NCWidgetProviding protocol to update your data.

func widgetPerformUpdate(completionHandler: (@escaping (NCUpdateResult) -> Void)) {
    // Reload the data from disk.
    weatherForecastData = WeatherForecast.loadSharedData()

    completionHandler(NCUpdateResult.newData)
}

See Also

Core Widget

protocol NCWidgetProviding

The interface for customizing the appearance and behavior of a Today widget.

class NCWidgetController

An object used to specify whether a Today widget has content to display.