Blog

iOS Today Widget in Swift – Tutorial

13 Sep, 2014
Xebia Background Header Wave

Since both the new app extensions of iOS 8 and Swift are both fairly new, I created a sample app that demonstrates how a simple Today extension can be made for iOS 8 in Swift. This widget will show the latest blog posts of the Xebia Blog within the today view of the notification center.
The source code of the app is available on GitHub. The app is not available on the App Store because it’s an example app, though it might be quite useful for anyone following this Blog.

In this tutorial, I’ll go through the following steps:

New Xcode project

Even though an extension for iOS 8 is a separate binary than an app, it’s not possible to create an extension without an app. That makes it unfortunately impossible to create stand alone widgets, which this sample would be since it’s only purpose is to show the latest posts in the Today view. So we create a new project in Xcode and implement a very simple view. The only thing the app will do for now is tell the user to swipe down from the top of the screen to add the widget.

iOS Simulator Screen Shot 11 Sep 2014 23.13.21

Time to add our extension target. From the File menu we choose New > Target… and from the Application Extension choose Today Extension.

Screen Shot 2014-09-11 at 23.20.34

We’ll name our target XebiaBlogRSSWidget and of course use Swift as Language.

XebiaBlogRSS

The created target will have the following files:

      • TodayViewController.swift
      • MainInterface.storyboard
      • Info.plist

Since we’ll be using a storyboard approach, we’re fine with this setup. If however we wanted to create the view of the widget programatically we would delete the storyboard and replace the NSExtensionMainStoryboard key in the Info.plist with NSExtensionPrincipalClass and TodayViewController as it’s value. Since (at the time of this writing) Xcode cannot find Swift classes as extension principal classes, we also would have to add the following line to our TodayViewController:
[objc]
@objc (TodayViewController)
[/objc]
Update: Make sure to set the "Embedded Content Contains Swift Code" build setting of the main app target to YES. Otherwise your widget written in Swift will crash.

Add dependencies with cocoapods

The widget will get the latest blog posts from the RSS feed of the blog: xebia blog RSS feed/. That means we need something that can read and parse this feed. A search on RSS at Cocoapods gives us the BlockRSSParser as first result. Seems to do exactly what we want, so we don’t need to look any further and create our Podfile with the following contents:
[ruby]
platform :ios, "8.0"
target "XebiaBlog" do
end
target "XebiaBlogRSSWidget" do
pod ‘BlockRSSParser’, ‘~> 2.1’
end
[/ruby]
It’s important to only add the dependency to the XebiaBlogRSSWidget target since Xcode will build two binaries, one for the app itself and a separate one for the widget. If we would add the dependency to all targets it would be included in both binaries and thus increasing the total download size for our app. Always only add the necessary dependencies to both your app target and widget target(s).
Note: Cocoapods or Xcode might give you problems when you have a target without any Pod dependencies. In that case you may add a dependency to your main target and run pod install, after which you might be able to delete it again.
The BlockRSSParser is written in objective-c, which means we need to add an objective-c bridging header in order to use it from Swift. We add the file XebiaBlogRSSWidget-Bridging-Header.h to our target and add the import.

[objc]
#import "RSSParser.h"
[/objc]

We also have to tell the Swift compiler about it in our build settings:

 

Load RSS feed

Finally time to do some coding. The generated TodayViewController has a function called widgetPerformUpdateWithCompletionHandler. This function gets called every once in awhile to ask for new data. It also gets called right after viewDidLoad when the widget is displayed. The function has a completion handler as parameter, which we need to call when we’re done loading data. A completion handler is used instead of a return function so we can load our feed asynchronously.
In objective-c we would write the following code to load our feed:

[objc]
[RSSParser parseRSSFeedForRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@" https://xebia.com/blog/feed/ "]] success:^(NSArray *feedItems) {
// success
} failure:^(NSError *error) {
// failure
}];
[/objc]

In Swift this looks slightly different. Here the the complete implementation of widgetPerformUpdateWithCompletionHandler:

[objc]
func widgetPerformUpdateWithCompletionHandler(completionHandler: ((NCUpdateResult) -> Void)!) {
let url = NSURL(string: " https://xebia.com/blog/feed/ ")
let req = NSURLRequest(URL: url)
RSSParser.parseRSSFeedForRequest(req,
success: { feedItems in
self.items = feedItems as? [RSSItem]
completionHandler(.NewData)
},
failure: { error in
println(error)
completionHandler(.Failed)
})
}
[/objc]

We assign the result to a new optional variable of type RSSItem array:

[objc]
var items : [RSSItem]?
[/objc]

The completion handler gets called with either NCUpdateResult.NewData if the call was successful or NCUpdateResult.Failed when the call failed. A third option is NCUpdateResult.NoData which is used to indicate that there is no new data. We’ll get to that later in this post when we cache our data.

Show items in a table view

Now that we have fetched our items from the RSS feed, we can display them in a table view. We replace our normal View Controller with a Table View Controller in our Storyboard and change the superclass of TodayViewController and add three labels to the prototype cell. No different than in iOS 7 so I won’t go into too much detail here (the complete project is on GitHub).
We also create a new Swift class for our custom Table View Cell subclass and create outlets for our 3 labels.

[objc]
import UIKit
class RSSItemTableViewCell: UITableViewCell {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var authorLabel: UILabel!
@IBOutlet weak var dateLabel: UILabel!
}
[/objc]

Now we can implement our Table View Data Source functions.

[objc]
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let items = items {
return items.count
}
return 0
}
[/objc]

Since items is an optional, we use Optional Binding to check that it’s not nil and then assign it to a temporary non optional variable: let items. It’s fine to give the temporary variable the same name as the class variable.

[objc]
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("RSSItem", forIndexPath: indexPath) as RSSItemTableViewCell
if let item = items?[indexPath.row] {
cell.titleLabel.text = item.title
cell.authorLabel.text = item.author
cell.dateLabel.text = dateFormatter.stringFromDate(item.pubDate)
}
return cell
}
[/objc]

In our storyboard we’ve set the type of the prototype cell to our custom class RSSItemTableViewCell and used RSSItem as identifier so here we can dequeue a cell as a RSSItemTableViewCell without being afraid it would be nil. We then use Optional Binding to get the item at our row index. We could also use forced unwrapping since we know for sure that items is not nil here:

[objc]
let item = items![indexPath.row]
[/objc]

But the optional binding makes our code saver and prevents any future crash in case our code would change.
We also need to create the date formatter that we use above to format the publication dates in the cells:

[objc]
let dateFormatter : NSDateFormatter = {
let formatter = NSDateFormatter()
formatter.dateStyle = .ShortStyle
return formatter
}()
[/objc]

Here we use a closure to create the date formatter and to initialise it with our preferred date style. The return value of the closure will then be assigned to the property.

Preferred content size

To make sure we that we can actually see the table view we need to set the preferred content size of the widget. We’ll add a new function to our class that does this.

[objc]
func updatePreferredContentSize() {
preferredContentSize = CGSizeMake(CGFloat(0), CGFloat(tableView(tableView, numberOfRowsInSection: 0)) * CGFloat(tableView.rowHeight) + tableView.sectionFooterHeight)
}
[/objc]

Since the widgets all have a fixed width, we can simply specify 0 for the width. The height is calculated by multiplying the number of rows with the height of the rows. Since this will set the preferred height greater than the maximum allowed height of a Today widget it will automatically shrink. We also add the sectionFooterHeight to our calculation, which is 0 for now but we’ll add a footer later on.
When the preferred content size of a widget changes it will animate the resizing of the widget. To have the table view nicely animating along this transition, we add the following function to our class which gets called automatically:

[objc]
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animateAlongsideTransition({ context in
self.tableView.frame = CGRectMake(0, 0, size.width, size.height)
}, completion: nil)
}
[/objc]

Here we simply set the size of the table view to the size of the widget, which is the first parameter.
Of course we still need to call our update method as well as reloadData on our tableView. So we add these two calls to our success closure when we load the items from the feed

[objc]
success: { feedItems in
self.items = feedItems as? [RSSItem]
self.tableView.reloadData()
self.updatePreferredContentSize()
completionHandler(.NewData)
},
[/objc]

Let’s run our widget:

Screen Shot 2014-09-09 at 13.12.50

It works, but we can make it look better. Table views by default have a white background color and black text color and that’s no different within a Today widget. We’d like to match the style with the standard iOS Today widget so we give the table view a clear background and make the text of the labels white. Unfortunately that does make our labels practically invisible since the storyboard editor in Xcode will still show a white background for views that have a clear background color.
If we run again, we get a much better looking result:

Screen Shot 2014-09-09 at 13.20.05

Open post in Safari

To open a Blog post in Safari when tapping on an item we need to implement the tableView:didSelectRowAtIndexPath: function. In a normal app we would then use the openURL: method of UIApplication. But that’s not available within a Today extension. Instead we need to use the openURL:completionHandler: method of NSExtensionContext. We can retrieve this context through the extensionContext property of our View Controller.

[objc]
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
if let item = items?[indexPath.row] {
if let context = extensionContext {
context.openURL(item.link, completionHandler: nil)
}
}
}
[/objc]

More and Less buttons

Right now our widget takes up a bit too much space within the notification center. So let’s change this by showing only 3 items by default and 6 items maximum. Toggling between the default and expanded state can be done with a button that we’ll add to the footer of the table view. When the user closes and opens the notification center, we want to show it in the same state as it was before so we need to remember the expand state. We can use the NSUserDefaults for this. Using a computed property to read and write from the user defaults is a nice way to write this:

[objc]
let userDefaults = NSUserDefaults.standardUserDefaults()
var expanded : Bool {
get {
return userDefaults.boolForKey("expanded")
}
set (newExpanded) {
userDefaults.setBool(newExpanded, forKey: "expanded")
userDefaults.synchronize()
}
}
[/objc]

This allows us to use it just like any other property without noticing it gets stored in the user defaults. We’ll also add variables for our button and number of default and maximum rows:

[objc]
let expandButton = UIButton()
let defaultNumRows = 3
let maxNumberOfRows = 6
[/objc]

Based on the current value of the expanded property we’ll determine the number of rows that our table view should have. Of course it should never display more than the actual items we have so we also take that into account and change our function into the following:

[objc]
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let items = items {
return min(items.count, expanded ? maxNumberOfRows : defaultNumRows)
}
return 0
}
[/objc]

Then the code to make our button work:

[objc]
override func viewDidLoad() {
super.viewDidLoad()
updateExpandButtonTitle()
expandButton.addTarget(self, action: "toggleExpand", forControlEvents: .TouchUpInside)
tableView.sectionFooterHeight = 44
}
override func tableView(tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
return expandButton
}
[/objc]

Depending on the current value of our expanded property we wil show either "Show less" or "Show more" as button title.

[objc]
func updateExpandButtonTitle() {
expandButton.setTitle(expanded ? "Show less" : "Show more", forState: .Normal)
}
[/objc]

When we tap the button, we’ll invert the expanded property, update the button title and preferred content size and reload the table view data.

[objc]
func toggleExpand() {
expanded = !expanded
updateExpandButtonTitle()
updatePreferredContentSize()
tableView.reloadData()
}
[/objc]

And as a result we can now toggle the number of rows we want to see.

Screen Shot 2014-09-13 at 18.53.39
Screen Shot 2014-09-13 at 18.53.43

Caching

At this moment, each time we open the widget, we first get an empty list and then once the feed is loaded, the items are displayed. To improve this, we can cache the retrieved items and display those once the widget is opened before we load the items from the feed. The TMCache library makes this possible with little effort. We can add it to our Pods file and bridging header the same way we did for the BlockRSSParser library.
Also here, a computed property works nice for caching the items and hide the actual implementation:

[objc]
var cachedItems : [RSSItem]? {
get {
return TMCache.sharedCache().objectForKey("feed") as? [RSSItem]
}
set (newItems) {
TMCache.sharedCache().setObject(newItems, forKey: "feed")
}
}
[/objc]

Since the RSSItem class of the BlockRSSParser library conforms to the NSCoding protocol, we can use them directly with TMCache. When we retrieve the items from the cache for the first time, we’ll get nil since the cache is empty. Therefore cachedItems needs to be an optional as well as the downcast and therefore we need to use the as? operator.
We can now update the cache once the items are loaded simply by assigning a value to the property. So in our success closure we add the following:

[objc]
self.cachedItems = self.items
[/objc]

And then to load the cached items, we add two more lines to the end of viewDidLoad:

[objc]
items = cachedItems
updatePreferredContentSize()
[/objc]

And we’re done. Now each time the widget is opened it will first display the cached items.
There is one last thing we can do to improve our widget. As mentioned earlier, the completionHandler of widgetPerformUpdateWithCompletionHandler can also be called with NCUpdateResult.NoData. Now that we have the items that we loaded previously we can compare newly loaded items with the old and use NoData in case they haven’t changed. Here is our final implementation of the success closure:

[objc]
success: { feedItems in
if self.items == nil || self.items! != feedItems {
self.items = feedItems as? [RSSItem]
self.tableView.reloadData()
self.updatePreferredContentSize()
self.cachedItems = self.items
completionHandler(.NewData)
} else {
completionHandler(.NoData)
}
},
[/objc]

And since it’s Swift, we can simply use the != operator to see if the arrays have unequal content.

Source code on GitHub

As mentioned in the beginning of this post, the source code of the project is available on GitHub with some minor changes that are not essential to this blog post. Of course pull requests are always welcome. Also let me know in the comments below if you’d wish to see this widget released on the App Store.

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts