The iOS 8 Notification Center is becoming the go-to dashboard for all important and time-sensitive information. With a single gesture (pulling down from the top of the screen), even when the phone is locked, you can see the date and time, your schedule, news, weather, and any other app information you choose. More apps are plugging into Notification Center every day, and I saw an opportunity to add a “Quote of the Day” Today extension for my Totem 2 app.
It’s a simple extension, and there is a lot of room for improvement and enhancement. But I thought I’d run through some of the things I learned about creating one to save others some frustration. This won’t be a step-by-step “how to”, but will cover some of the highlights.
App Target and Settings
After you launch the new version of Xcode and open your project, you need to add a new Target, and choose a Today Extension.
This target has its own build settings, version, bundle identifier, etc. Most of that is self-explanatory. But I did notice that the icon displayed in the extension will be pulled from the primary app’s asset catalog, so you don’t need to create a catalog specifically for the extension. I did provide Spotlight search and Settings icons in my asset catalog, and it likely used one of those since the icons in Notification Center are pretty small. Other app extension types (like sharing) use the primary app icon by default.
A Today extension is simply a single view controller, and using autolayout is highly recommended as there are a bunch of different aspect ratios and screen resolution combinations you’ll have to code for. When configuring the constraints for your various controls, like UILabels, it’s advisable to deselect the “constrain to margins” checkbox. This will allow you to position items without worrying about the deep margins that are the default for a Today extension.
Your target app runs independently of the main app, though it can access code libraries and such whose header files are included at compile time. So sharing utility classes, data abstraction layer classes, and other code is basically no different. But if you want to share files at run time, that’s a different matter. The iOS sandbox is pretty draconian, but new to iOS 8 is the concept of “app groups”, allowing you to share files at run time.
To use an app group, you need to set one up in the developer portal. It’s basically a unique, reverse-DNS formatted name (like the bundle identifier) used to group apps targets together. When you set one up in the portal, it’s as simple as typing the RDNS name and saving. For example, the Totem 2 app uses the app group name “group.us.argentini.Totem”, since the bundle identifier is “us.argentini.Totem”.
Once established in the portal, you need to configure each target in Xcode to belong to this new app group. Doing this is also easy. Just view the properties of a target, choose the “Capabilities” section, and expand the “App Groups” section to add the RDNS name you set up in the portal. Now both target apps can access a single shared container at run time.
In my case, the work wasn’t complete. I needed to place a copy of the daily quote data (along with the people and source data) into this shared container before my Today extension would work. Unfortunately there is no way to do this prior to launching the primary app at least once, and having it do that job. So I simply have the widget display a message reading something like “Please launch Totem at least once before using this widget” if they attempt to use it before launching Totem for the first time. It’s not as elegant as I’d like, but it’s clear and most likely not seen by most users given that they generally launch the app first.
The most obvious alternative is to evolve the app to pull data from a remote server, which would also allow for easier updates (no App Store approval process). Then the extension could simply request the data from the server like the primary app, even with the same class libraries. For now, this works really well.
So how do you use the shared container? It’s basically a folder to which you have access at run time. To obtain a reference to this shared container, I used code like the snippet below. This gives you an NSURL object that can be used with an NSFileManager object to read and write files. It references the app group by name and obtains the dynamic location of the container.
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.us.argentini.Totem"];
containerURL = [containerURL URLByAppendingPathComponent:@"quotes.json"];
Introduced in iOS 7 was the concept of dynamic type. Simplified, this means that users can change the size of the type on their devices. And you have to account for this to meet user expectations and create a great experience.
To handle this, you want a function like the one below, which iterates through the various objects in the view and sets their font size based on the abstracted system font types. In my case I had three labels: Quote, Author, and Source. The font function [UIFont preferredFont…] is part of my Halide for iOS library on Github at https://github.com/argentini/Halide-for-iOS, and provided here for convenience.
+ (UIFont *)preferredFont:(UIFont *)font scale:(CGFloat)scale
// We first get prefered font descriptor for provided style.
UIFontDescriptor *currentDescriptor = font.fontDescriptor;
// Then we get the default size from the descriptor.
// This size can change between iOS releases.
// and should never be hard-codded.
CGFloat headlineSize = [currentDescriptor pointSize];
// We are calculating new size using the provided scale.
CGFloat scaledHeadlineSize = lrint(headlineSize * scale);
// This method will return a font which matches the given descriptor
// (keeping all the attributes like 'bold' etc. from currentDescriptor),
// but it will use provided size if it's greater than 0.0.
return [UIFont fontWithDescriptor:currentDescriptor size:scaledHeadlineSize];
self.QuoteLabel.font = [UIFont preferredFont:[UIFont fontWithPreferredTextStyle:UIFontTextStyleBody familyName:@"Helvetica Neue" faceAttribute:@"Regular"] scale:1.0f];
[UIFont setLineSpacingForlabel:self.QuoteLabel withScale:1.3f];
self.QuoteAuthor.font = [UIFont preferredFont:[UIFont fontWithPreferredTextStyle:UIFontTextStyleBody familyName:@"Helvetica Neue" faceAttribute:@"Bold"] scale:0.85f];
[UIFont setLineSpacingForlabel:self.QuoteAuthor withScale:1.3f];
self.QuoteSource.font = [UIFont preferredFont:[UIFont fontWithPreferredTextStyle:UIFontTextStyleBody familyName:@"Helvetica Neue" faceAttribute:@"Italic"] scale:0.85f];
[UIFont setLineSpacingForlabel:self.QuoteSource withScale:1.3f];
Surprisingly, I call *setupFonts* in the *viewDidLoad* event, which is explained in the next section.
Event Model and View Height
Surprisingly, this is where I spent most of my time; debugging the view height and dynamic type support. I’ll cut to the chase. Today extensions reload the view each time they’re rendered. This also means that defaults kick in each time. So unlike your other view controllers that load once and stay in memory until unloaded, every time is the first time with a Today extension. How romantic 😉
My Today extension changes height every time it loads a new quote of the day (once per day), so I couldn’t use a static height. What problems did this cause? Well, first, the view width and height references a property named PreferredContentSize. If not set, it defaults to a very tall height. So you need to store the last rendered height in order to retrieve and assign it during viewDidLoad each time. If you don’t, the height will, at best, wiggle into the right size when Notification Center is pulled down (ugh), causing content below it to flicker (ugh).
Below is the code I use to save my Today extension height for use in subsequent views (using persistent NSUserDefaults app settings). This is called after viewDidLoad so this is where you want to calculate, assign, and store the correct, current height.
CGFloat h = [self getHeight];
if (self.preferredContentSize.height != h)
[self setPreferredContentSize:CGSizeMake(0, h)];
NSUserDefaults *prefs = [NSUserDefaults standardUserDefaults];
[prefs setObject:[NSString stringFromFloat:h] forKey:@"TotemDailyQuoteLastHeight"];
In *viewDidLoad*, I retrieve the previously stored height and set the *PreferredContentSize* to that value.
NSString *lastUpdateHeight = [[NSUserDefaults standardUserDefaults] valueForKey:@"TotemDailyQuoteLastHeight"];
[self setPreferredContentSize:CGSizeMake(0, [lastUpdateHeight floatValue])];
This constant loading also allowed me to put my fontSetup call into the viewDidLoad event. The primary app leverages a system callback that’s triggered whenever the system font size is changed. Since this view is reloaded each time, I didn’t have to use that method.
If you want to test your Today extension, you can simply compile and run the app as you would normally. But the debugger will be attached to the primary app. If you want to debug your extension, you have to run its target instead of the primary app target. It will launch directly in Notification Center, potentially before your primary app launches for the first time. So this is a good way to handle testing that scenario. I did discover that cleaning and rebuilding your app or extension prior to testing will not reliably replace the code on your test device (or in the simulator). Very frustrating. I found myself removing the app from the test device each time I wanted to test, to be sure it was an accurate test. I’ll be anxiously watching the Xcode updates to see if this bug is squished.
Remember to test the new iPhone models, including the iPhone 6 Plus in landscape mode. These introduce new screen sizes and will be a good way to ensure your autolayout settings are configured properly.
Use our contact form if you have any questions or comments, and I’ll update this article as necessary. These widgets will be a great addition to the iOS platform. I hope I’ve helped you avoid some of the pitfalls I ran into. Happy coding!