Behind the scenes of product and engineering at Quri.

Introducing Titanium

We wrote Titanium in order to respond to the following user story:

As an EasyShift user, I want all images inside of a job / survey to be tappable, zoomable, etc., so that I can get a better look at them.

Strictly speaking, we could have dealt with this problem by using a good old UIScrollView and a standard modal transition. However, we felt like this deserved a little bit more love.


Custom transition

Apple provides an easy way to customise transitions between view controllers: the UIViewControllerAnimatedTransitioning protocol. It lets you manually specify all the animations for presenting and dismissing view controllers.

Scale and translate

In this case, I wanted to recreate an animation like the one in the Photos app, where a thumbnail preview enlarges to fill the entire screen. In order to achieve this, the logic was to instantiate the full-screen image view, apply a scale + translation transform to make it the size and position of the thumbnail, then revert the transform to CGAffineTransformIdentity while animating. Easy enough.


But we’re not done yet. Just like in the Photos app, the aspect ratio of the thumbnail is independent from that of the image itself. What’s more, in the Photos app the thumbnails are always square, whereas our thumbnails can have any arbitrary aspect ratio. In a plain UIImageView (or any other UIView subclass, for that matter), this is easily achieved like so:

[imageView setContentMode:UIViewContentModeScaleAspectFit];

However, we need an animated transition between the cropped image of the thumbnail and the full view. The solution here is to use the mask property of CALayer like so:

CALayer *mask = [CALayer layer];
// Set up the mask's bounds to correspond with the visible part of the thumbnail.
[imageView.layer setMask:mask];

Corner radius

We also wanted to enable users of Titanium to use thumbnails with rounded corners. That meant we had to use the cornerRadius property on our full screen view. However, we couldn’t just read the value from the thumbnail view, apply it to our full screen view and call it a day. Because our view was going to get scaled, we had to multiply the value by the inverse of the scale factor before applying it.


The major drawback of using CALayer properties is that, unlike CGAffineTransforms, they cannot be animated using UIView animation blocks. Instead, I had to create a CABasicAnimation for each property I wanted to animated (mask and cornerRadius). Here’s a quick example:

CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"bounds.size.width"];
animation.duration = 1.0;
animation.fromValue = @(200.0);
animation.toValue = @(320.0);
[mask addAnimation:animation forKey:@"bounds"];

Full screen image view

The easiest, most straightforward way of providing scrolling and zooming capabilities to a view with arbitrarily-sized content is to use a UIScrollView. In practice however, it proved too challenging to integrate into the custom animations, and an alternative solution was found.

Gesture recognizers

The full screen view is composed of a black background view and an image view. Interaction is achieved using UIGestureRecognizers that detect four different types of gestures: * pan * pinch * tap * double tap

The general structure of the code was derived from the Touches GestureRecognizers sample project (available here).

Pan & pinch

These two gesture recognizers work in tandem to provide direct manipulation of the image view. The UIPanGestureRecognizer affects the center property of the image view, while the UIPinchGestureRecognizer applies a CGAffineTransformScale to it.

Tap & double tap

In addition to the pinching and panning, two instances of UITapGestureRecognizer handle single- and double-taps. A single tap will revert to the original zoom level and dismiss the view, and a double tap will zoom to the maximum zoom level.

In order for these to work alongside each other, the gestureRecognizer:shouldRequireFailureOfGestureRecognizer: delegate method is implemented to return YES if the two gesture recognizers in question are the single-tap and the double-tap, respectively. One drawback of this solution is that it introduces a slight delay in the detection of a single-tap, while the system gives the user a chance to perform a double-tap.