iOS Message Center Widget

As of iOS 8, apps can now deploy associated widgets to the Today screen in the iOS Notification Center. This topic guide will cover how to build a custom widget that embeds a message center in the user’s Today screen. Use cases for this may include apps that either don’t need or can’t include a message center in their own UI, as well as the desire to allow users to check for new messages without needing to launch the parent application.

Requirements and Limitations

As mentioned above, widgets are only supported on iOS 8 and above. Also be aware that as a new iOS feature, widgets may still be somewhat buggy. We recommend experimenting with these features early to become acquainted with them before deciding whether to integrate them into a production app.

How It Works

Widgets are not portions of the same app, but rather, separate processes that, together with the host application, form an app group, or container. In order for these processes to communicate with one another, the parent app and the widget must share data through this container. One of the most straightforward ways of doing this is through a special instance of NSUserDefaults that is shared between processes in the app group. When the parent app decides it is time to share new information with the widget, it can store data under a shared key in the user defaults, and notify the widget by calling the setHasContent method on the NCWidgetController class. When the widget is later displayed, the OS will notify the widget that it should update its display, by calling the widgetPerformUpdateWithCompletionHandler method, which is part of the NCWidgetProvidingProtocol.

For the purposes of this example, we will be sharing arrays of message data serialized in NSDictionary objects between the host app and the widget. We will also show how to update the widget’s contents in response to a background push notification, so that the widget can remain up-to-date even when the parent application is not being displayed.

A Note about Background Push and Responsiveness

Conceptually, it’s important to keep in mind that in this arrangement, the host application bears all responsibility for notifying the widget of updated content. The host application, by virtue of the Urban Airship SDK, maintains the local database of Message Center messages, and periodically fetches new messages from the server, either when the app is initially foregrounded or in response to a push notification associated with new Message Center content. In other words, the widget will only ever update its contents when the host app knows that there is new content.

Because of this limitation, it’s a good idea to add background notification support to your app, and to send new messages out with push notifications featuring the content-available flag, which will cause iOS to allow the host app to handle the notification event while it is in the background, and in turn will allow the host app to update the widget. This way, you can ensure users will see as up-to-date a listing of messages as possible when they enter the Today screen.

Content-Available Flag

See iOS Overrides for information about using the content-available flag in the API.

In the UI, enable Background Processing to use the content-available flag. See: Optional Delivery Features: Background Processing.

Initial Setup

To create a new Today widget, open XCode and navigate to File -> New -> Target, and select the “Today Extension” template under iOS -> Application Extension:

Choose an appropriate name for your widget, and under the “Embed in Application” section, select your main app’s target if it is not already selected, and proceed. This should add a new extension to your project’s source tree, along with a “Hello World” storyboard file and a TodayViewController class. We’ll return to these later.

The next step is to register an app group in the iOS Developer Portal. Pick a unique identifier for the group, such as “group.com.mycompany.myappname”. An example is shown below:

Because widgets are separate applications, they have their own app and bundle identifiers as well. In order to correctly provision your widget, you will need to set up a corresponding app identifier in the Developer portal. These will typically take the form of the host app’s bundle, appended with the widget’s product name, such as “com.mycompany.myappname.WidgetName”:

The app identifiers for both the parent app and the widget must also have app groups enabled under Application Services. Click the “Edit” button and add “App Group” for each identifier, and ensure they they light up green before proceeding.

Lastly, make sure that both the parent app and the widget have app groups enabled, and that the app group you created is selected in the target’s capabilities tab in XCode. This should result in the creation or update of entitlements files for the both the host and the widget, containing an entry for your new app group:

Sharing Messages from the App Delegate

The app delegate will be responsible for sharing messages with the widget when the UAInboxMessageList is updated, as well as fetching new messages in response to a background push. It will also handle deep links opened from the widget, so that message titles tapped in the widget will open the corresponding message view in the parent app.

In the app delegate’s application:didFinishLaunching:WithOptions method, sign up for NotificationCenter notifications when the UAInboxMessageList is updated, like so:

// Add a listener for inbox updates that updates our widget
[[NSNotificationCenter defaultCenter] addObserver:self
                                         selector:@selector(shareInboxMessages)
                                             name:UAInboxMessageListUpdatedNotification
                                           object:nil];

The shareInboxMessages method referenced in the above snippet will copy the current message list to the group user defaults, and notify the widget that there is new content.

- (void)shareInboxMessages {

    // Copy the current inbox list into an array
    NSMutableArray *widgetMessages = [NSMutableArray array];

    for (UAInboxMessage *message in [UAirship inbox].messageList.messages) {
        NSMutableDictionary *simpleMessage = [NSMutableDictionary dictionary];
        simpleMessage[@"title"] = message.title;
        simpleMessage[@"id"] = message.messageID; // Message ID (used for deep linking back into the app)
        simpleMessage[@"date"] = message.messageSent;
        simpleMessage[@"read"] = message.unread ? @"NO" : @"YES";
        simpleMessage[@"iconURL"] = message.rawMessageObject[@"icons"][@"list_icon"] ?: @"";
        simpleMessage[@"subheading"] = message.rawMessageObject[@"extra"][@"com.urbanairship.listing.field1"] ?: @"";
        [widgetMessages addObject:simpleMessage];
    }

    // Add the new message list to the common storage area for the app group
    NSUserDefaults *groupDefaults = [[NSUserDefaults alloc] initWithSuiteName:MY_GROUP_IDENTIFIER];
    [groupDefaults setValue:widgetMessages forKey:@"inboxMessages"];
    [groupDefaults synchronize];

    // Notify the widget that the list has been updated
    [[NCWidgetController widgetController] setHasContent:YES forWidgetWithBundleIdentifier:MY_WIDGET_IDENTIFIER];
}

Be aware that where you see placeholders such as MY_GROUP_IDENTIFIER, you must provide the appropriate identifier for your app group. An incorrect identifier will cause data sharing across NSUserDefaults to silently fail.

In order to provide a more consistent user experience, we’ll also want to retrieve messages whenever a background notification comes in. This will give the parent app a chance to retrieve new messages over the network, and share the results with the widget. We’ll do the same whenever the app transitions into the background, as well.

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo
                                                       fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {

    [[UAirship inbox].messageList retrieveMessageListWithSuccessBlock:^{
        [self shareInboxMessages];
        completionHandler(UIBackgroundFetchResultNewData);
    } withFailureBlock:^{
        completionHandler(UIBackgroundFetchResultFailed);
    }];
}

- (void)applicationDidEnterBackground:(UIApplication *)application {
    [self shareInboxMessages];
}

Retrieving Messages in the Widget

Any new Today Extension should include a TodayViewController by default. A common approach to displaying a Message Center is to list message titles in a table view, although other designs are possible. Covering the specific design and display of the widget is beyond the scope of this document, however, and thus left to the discretion of the reader.

First off, we will need properties for storing cached messages, the group identifier, the group defaults, and a chosen deep link scheme:

// In-memory message cache
@property (nonatomic, strong) NSMutableArray *messages;

// App group suite name
@property (nonatomic, copy) NSString *inboxAppGroupSuiteName;

// Shared user defaults
@property(nonatomic, strong) NSUserDefaults *groupDefaults;

// Deep link scheme, e.g. "vnd.mycompany.myapp"
@property(nonatomic, copy) NSString *inboxDeepLinkScheme;

The viewDidLoad method is a good place to initialize some of these values, and trigger an initial display update:

- (void)viewDidLoad {
    [super viewDidLoad];

    self.inboxAppGroupSuiteName = MY_GROUP_IDENTIFIER;
    self.inboxDeepLinkScheme = MY_DEEP_LINK_SCHEME;
    self.groupDefaults = [[NSUserDefaults alloc] initWithSuiteName:self.inboxAppGroupSuiteName];

    // Trigger an immediate update
    [self widgetPerformUpdateWithCompletionHandler:nil];
}

Perhaps one of the most important methods, hasNewMessages checks in the group user defaults whether there is any new data shared with the widget:

- (BOOL)hasNewMessages {
    NSMutableArray *oldMessages = self.messages;

    self.messages = [self.groupDefaults objectForKey:@"inboxMessages"];

    BOOL found = NO;
    if (oldMessages.count == self.messages.count) {
        for (int i = 0; i < oldMessages.count; ++i) {
            if (![oldMessages[i] isEqualToDictionary:self.messages[i]]) {
                found = YES;
                break;
            }
        }
    } else {
        found = YES;
    }
    return found;
}

As mentioned above, TodayViewController implements the NCWidgetProviding protocol, so we’ll need to provide an implementation of the widgetPerformUpdateWithCompletionHandler method. This example implementation calls the above method to check for new messages, updates the widget UI, and finally calls the completion handler:

- (void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult))completionHandler {

    BOOL foundNewMessages = [self hasNewMessages];

    // Update widget UI here (reload tableview, etc)

    completionHandler(foundNewMessages ? NCUpdateResultNewData : NCUpdateResultNoData);
}

Opening Messages from the Widget

When designing a Message Center widget, it is probably desirable to allow users to tap messages in the widget in order to open them in the host app for viewing. Because the widget and host app are separate processes, this cannot be done directly, but can be achieved indirectly through deep linking.

After choosing a deep link scheme and registering it in the main app’s Info.plist, we can initiate a deep link to the app by constructing a URL based on this scheme and opening the URL via the extension context. Given a message dictionary from the self.messages array, the following code opens a deep link conveying the ID of the message to display:

NSDictionary *message = self.messages[MY_MESSAGE_INDEX];
NSURL *msgURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@://message/%@", self.inboxDeepLinkScheme, message[@"id"]]];
[self.extensionContext openURL:msgURL completionHandler:nil];

Likewise, the host application must handle links of this type and respond by displaying the appropriate message. In the main app delegate, override the application::openURL::sourceApplication method, like so:

- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation {

    // Display the message if our deep link matches <scheme>://message/<messageID>
    if ([url.scheme isEqualToString:MY_SCHEME] && [url.host isEqualToString:@"message"] && url.pathComponents.count > 1) {
        UAInboxMessage *message = [[UAirship inbox].messageList messageForID:url.pathComponents[1]];
        [[UAirship defaultMessageCenter] displayMessage:message];
        return YES;
    }
}