Like Trello, but on a watch

We, like all of you, watched with great anticipation in September as Apple announced their latest technological marvel. At the time we had no idea what this meant for our current iOS app. What kind of capabilities would WatchKit have and how could we integrate that into our existing codebase?

Now that the first version of Trello for Apple Watch is safely through review and waiting for users to actually get watches, we thought we would share some of the things we learned along the way.

Apple Watch Lab – what we can tell you

You may have heard that Apple was allowing third party developers to sign up for a time to come and test their apps on actual watches. We may or may not have attended one, and it may or may not have been incredibly useful.

Reactive Watches are inherently reactive. As time passes, that signal is mutated by gears and springs into rotational movement of hands, which allows humans to observe time's persistent passage. Trello for Apple Watch is also reactive, but instead of gears and springs we use ReactiveCocoa.

Trello for iOS uses ReactiveCocoa heavily to keep our code clean and limit state. WatchKit presents interesting problems when attempting to update the UI in that UI elements that aren't currently on screen cannot be updated until they become active again. Indeed, updating values within a deactivated interface is wasteful of the user's precious battery life and processing power. To help ourselves along, we added a category to WKInterfaceController to make interface controller activation state reactive.

@interface WKInterfaceController (FCTAdditions)

@property (nonatomic, readonly) RACSignal *activatedSignal;
@property (nonatomic, readonly) RACSignal *deactivatedSignal;

@end
@interface WKInterfaceController (FCTAdditions_Private)

@property (nonatomic, strong, readonly) RACSignal *activationSignal;

@end

@implementation WKInterfaceController (FCTAdditions)

- (RACSignal *)activationSignal {
    void* activationSignalKey = &activationSignalKey;
    RACSignal *signal = objc_getAssociatedObject(self, activationSignalKey);
    
    if (!signal) {
        signal = [RACSignal merge:@[[[self rac_signalForSelector:@selector(willActivate)] mapReplace:@YES],
                                    [[self rac_signalForSelector:@selector(didDeactivate)] mapReplace:@NO]]];
        objc_setAssociatedObject(self, activationSignalKey, signal, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    
    return signal;
}

- (RACSignal *)activatedSignal {
    return [self.activationSignal ignore:@NO];
}

- (RACSignal *)deactivatedSignal {
    return [self.activationSignal ignore:@YES];
}

@end

These two simple signals allow us to express fairly sophisticated logic in a very concise manner. For example, consider an interface controller that has a model object that has property representing a card name. If that model value gets updated, you would want to update your UI accordingly, but the KVO code required to do that is messy and error prone. With our signal affordances above this requirement can be expressed as:

@weakify(self);
[[[self.activatedSignal map:^id(id value) {
    @strongify(self);
    return [RACObserve(self, model.cardName) takeUntil:self.deactivatedSignal];
}] switchToLatest] subscribeNext:^(NSString *name) {
    @strongify(self);
    [self.cardNameLabel setText:name];
}];

Another common use case is to do initial data fetches on first appearance of an interface controller. There are many ways to accomplish this, but many of them involve unfortunate instance variables with names like didAppearOnce or firstAppear. With our activation signals this is easy:

- (void)awakeWithContext:(id)context {
    [super awakeWithContext:context];
    
    @weakify(self);
    [[[self.activatedSignal take:1] then:^RACSignal *{
        @strongify(self);
        return [self loadDataFromServer];
    }] subscribeCompleted:^{
        @strongify(self);
        [self refreshTable];
    }];
}

Another desirable behavior is pushing or presenting an interface controller that allows the user to perform an action, then dismissing or popping that controller when the action has been performed. It can be tricky in WatchKit apps to manage the state properly and ensure any completion block or delegate call isn't made after the user has already dismissed the modal or hit the back arrow on the pushed interface. Fortunately for us, we have access to ReactiveCocoa which has a class that fits this bill nicely, RACCommand. We added another helper method into WKInterfaceController that essentially returns @YES until the interface controller comes back on screen. Which allows us to do this:

- (void)addComment {
    @weakify(self);
    RACCommand *completion = [[RACCommand alloc] initWithEnabled:[self yesUntilNextActivationSignal] signalBlock:^RACSignal *(id input) {
        @strongify(self);
        [self dismissController];
        return [RACSignal empty];
    }];
    
    [self presentControllerWithName:FCTAddCommentInterfaceControllerIdentifier
                            context:[FCTAddCommentContext contextWithCard:self.card completion:completion]];
}

We can now safely implement that nice auto-dismiss behavior with confidence! Our child interface controller simply executes the RACCommand if it's enabled. Easy!

Context

WatchKit introduced a new paradigm of navigation wherein you present or push interface controllers by identifier. What this means is your interface controller doesn't actually initialize a child controller before pushing it onto the navigation stack. You push the child controller by identifier and pass along a "context" variable, which can be anything (i.e. id). There isn't a good precedent for this, so we tried to stick to two types of contexts:

  1. Model objects: For interfaces that simply display a model object, we just pass it along. Simple.
  2. Purpose built, opaque and immutable context objects: For any interface that needed more context than simply a model object, we created special objects that are opaque and immutable. This has the advantage of keeping the code simple by preventing us from passing around references to interface controllers, while still providing all the functionality we need. For example, the following listing is for our "Add Comment" interface. Notice also the use of RACCommand for task completion as described above.
@interface FCTAddCommentContext : NSObject

+ (instancetype)contextWithReplyToAction:(FCTAction *)action completion:(RACCommand *)completionCommand;
+ (instancetype)contextWithCard:(FCTCard *)card completion:(RACCommand *)completionCommand;

@end

It's groups all the way down…

At first glance it seems like WatchKit is severely limited. There are only 11 controls, three of which have basically no settings. It seems like you can't do any sophisticated layouts, until you look at WKInterfaceGroup. The group object is fairly basic as well; it has settings for its background, insets, spacing, corner radius, and layout direction. The key feature of groups is that they are infinitely nestable. Pretty much any layout (within reason) that your designer can come up with is possible with the right set of groups. Take for example our card rows. The nesting goes pretty deep here:

Groups as far as the eye can see...

Another useful function of groups is allowing you to layer images behind other controls. A standard WKInterfaceImage element can't go behind another control, because WatchKit views can't stack. If you use the background image capability of groups, however, then you can setup any number of composited layers (though you'd be wise to keep it simple). You can see in our hierarchy we actually have a group to show cover images and another group on top of it to provide nice gradient shading.

Bluetooth is slow

It's easy to forget that your WatchKit code isn't running on the watch. The simulator does seem to attempt to add latency to image transfers and other calls, but it isn't even close to running on the real thing. There are many factors that will impact your WatchKit app in the wild: other devices interfering, proximity to the phone, or your microwave oven. We were very surprised at the long lag between setting interface element properties and seeing them updated on the actual device. To combat this we employed a few overarching stratagem.

Compress images like there's no tomorrow

WatchKit interface objects that accept images have three ways to do so. You can set images by name, UIImage or NSData. In practice the best way to do so is by name, since the image will already be physically located on the watch. If you need dynamic images that can't be preloaded into your app bundle, then the next best is by NSData. We're not privy to the details, but it's a good bet that sending an image via UIImage to the watch sends an uncompressed representation over the air. This is painfully slow. For our app, we compress all images to JPEG via  UIImageJPEGRepresentation(image, 0) and send the resulting data over the air.

"But…but compression quality zero?" you cry out. Yes, we know that compression quality zero basically tells the JPEG algorithm, "Hey, I don't like these images, so could you smush them into some colorful pixel soup?" This is one of those things where in theory these images will look terrible, but in practice everything looks OK. When looking at a tiny screen on a tiny watch that is far away from your eye, trust me, you can't tell.

We saw immediate improvements to our app's load times and responsiveness. Images loaded quicker and everyone was happy. One other tidbit that we learned in the lab was that your iPhone extension will batch images to send over to the watch, so you may see groups of images appearing together rather than one at a time.

Radio silence

Another way to improve app responsiveness and save the user's battery life at the same time is to only send updates to UI elements that truly need them. It would be very easy to write some code like this:

@weakify(self);
[RACObserve(self, model.name) subscribeNext:^(NSString *newName) {
    @strongify(self);
    [self.nameLabel setText:newName];
}];

Depending on how the rest of your code works this could be terribly wasteful. WatchKit attempts to coalesce updates and drop superfluous updates, but this would still be terribly wasteful of phone battery. A better approach would be to only update the label when the corresponding value actually changes, which is trivial to do with ReactiveCocoa.

@weakify(self);
[[RACObserve(self, model.name) distinctUntilChanged] subscribeNext:^(NSString *newName) {
    @strongify(self);
    [self.nameLabel setText:newName];
}];

Conclusion

Overall the experience of taking Trello to the Apple Watch was a fun one. It was a "back to basics" sort of feeling, where we had to rely on cleverness to extend the functionality of a very elemental set of tools. At the same time, there are probably some patterns that make a lot of sense to bring back to iOS, like contexts. If you're interested in our WKInterfaceController extensions, feel free to peruse them on the Gist we created here.

Thanks for reading!

Be sure to read the post on the main Trello blog for the full story of how Trello came to Apple Watch.