Unobtrusive hints of joy

We all, at least I do, love the small animations and transitions that tell a story when something happens with our data. The small movements that makes things obvious. They are wonderful, unobtrusive little hints to us as the user. They should be so natural that we don’t even think about them when we see them but we most definitely miss them when we don’t see them.

I particularly like the “open in background”-animation in Safari for iPhone so I’m going make an attempt at than animation and show how you can animate an arbitrary view down to a tab bar item. This is what I have in mind:

What we are going to do

At the tap of a button, a visual copy of the view will move along a path as if it was thrown into the air and land on the tab bar item. During its flight the view will make a slight rotation to make the “throw” look more realistic. The view will also scale down (while keeping its aspect ratio) to a size that would fit the bar item. The original view will become transparent at the beginning of the throw but then fade back to indicate that its still there. When the animated copy lands on the bar item, the badge of the bar item will increase.

I want to communicate that the data that the view represents was added to some list on another tab. Its still here but now its there as well. Maybe you bought a song in iTunes or saved an article to read later.

Let us break it down

Before we get started, we should break it down into smaller pieces. We need to know:

  • the visual representation of the views content so that we can reproduce it in another layer and animate it.
  • the approximate1 frame of the view and the bar item in the same coordinate space so that we know the start and end points of our animation.
  • the path we want to animate the view along so that the layer looks “thrown”.
  • the final size / scale of the view so that the view will fit the bar item.

Drawing the content of a UIView into a UIImage

Every view on iOS is backed by a layer which has the ability to draw its content in a graphics context. By creating a new image context with the properties of the view (size, opaque, scale factor) and drawing into that context we can create a UIImage from any view and set that as the content of our new layer.

CALayer * layerToThrow = [CALayer layer];
[layerToThrow setFrame:[viewToThrow frame]];
UIImage * viewContentImage = [self imageRepresentationOfView:viewToThrow];
[layerToThrow setContents:(id)[viewContentImage CGImage]];
[[[self view] layer] insertSublayer:layerToThrow above:[viewToThrow layer]];

The code to draw into a UIImage was put in its own method for reusability. There is a little bit of procedural C in there for Core Graphics image and context handling but don’t let that scare you2.

- (UIImage *)imageRepresentationOfView:(UIView *)view {
    CGSize imageSize = [view frame].size;
    BOOL imageIsOpaque = [view isOpaque];
    CGFloat imageScale = 0.0; // automatically set to scale factor of main screen
    UIGraphicsBeginImageContextWithOptions(imageSize, imageIsOpaque, imageScale);
    CALayer * drawingLayer = [view layer];
    [drawingLayer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage * image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();

    return image;
}

Now we can add a layer that looks just like our view on top of our view. Next we need to calculate the start and end frames to know the path to animate that layer along.

The frame of the view and the UITabBarItem

The frame of the original view

The frame of our view that we want to throw is easily obtained via the frame property on the view.

The frame of the UITabBarItem

The part about the frame of the bar item is by far the most difficult! If you look at the UIBarItem documentation you will see that it is neither a UIView subclass nor does is it have a view property. Since we don’t want to use private API or traverse the UITabBars subviews, we need to find another way.

I chose to rely on how the tab bar appears on the screen to make an approximation of the frame for the tab bar item. Here are my assumptions:

  • The items are centered within the tab bar
  • The distance from the left most part of one bar item to the next is approximately 110 points on the iPad.
  • The bar item is approximately 80 points wide and 45 points high on the iPad.
  • All items in the tab bar will be visible. There is no “more”-item.

There are a number of things missing from the above assumptions, iPhone being the big one since its tab bar drawing is a bit more complicated. I will post an updated version of the tab bar item frame approximation later on.

I also chose to animate down to a tab bar item instead of a tool bar item since it has a simpler layout.

Using these assumptions, the approximate frame is calculated by counting the total width of all bar items. These are centered, so going half the width of all the items left from the center of the tab bar takes us to the left most bar item. From there the approximate width of one bar item times our bar item index takes us to the left of our bar item.

- (CGRect)approximateFrameForTabBarItemAtIndex:(NSUInteger)barItemIndex 
                                      inTabBar:(UITabBar *)tabBar {
    CGFloat barMidX = CGRectGetMidX([tabBar frame]);
    CGSize barItemSize = CGSizeMake(80.0, 45.0);
    CGFloat distanceBetweenBarItems = 110.0;

    CGFloat barItemX = barItemIndex*distanceBetweenBarItems + barItemSize.width*0.5;
    CGFloat totalBarItemsWidth = ([barItems count]-1)*distanceBetweenBarItems + barItemSize.width;

    barItemX += barMidX - round(totalBarItemsWidth*0.5);

    return CGRectMake(barItemX, CGRectGetMinY([tabBar frame]), 30.0, barItemSize.height);
}

The width of all bar items is shown in red in the image below along with the calculated frame approximations for the images at all four bar items. Both the red and the blue frame are offset to appear above the tab bar.

Bar item image frame approximations

A change of coordinate systems.

When we perform the animation we want the moving layer to appear above everything else3 so we will need to convert the rects and frames to the root views coordinates to be able to add the layer to it without being offset. This is done by calling

- (CGRect)convertRect:(CGRect)rect toView:(UIView *)view

and

- (CGPoint)convertPoint:(CGPoint)point toView:(UIView *)view

on our current view with the toView-argument as [[[[UIApplication sharedApplication] keyWindow] rootViewController] view] (to get the root view controllers view of the key window).

Using the windows coordinates would be easier (we would convert toView:nil) but we can’t use it since it doesn’t handle orientation changes. If we’r only dealing with Portrait, using window coordinates would be fine.

The path we want to animate the view along.

This is the least predefined part of the entire animation. What path makes the animation look good? What makes it look thrown? Physics can describe how a real thrown path would look and the timing for when and how it turns in the air. While that would look very accurate, any toy-physics path that goes up in the air, turns and lands on the bar item is good enough. It may even be better. We’r not after accuracy but simple looks. After some tweaking of the path I went with throwing the view 100 points into the air and a third of the way along the x-axis for the keyframe where it turns. This looked good at most lengths of throws, from very short to very long.

CAAnimationGroup * throwGroup = [CAAnimationGroup animation];
[throwGroup setDuration:0.8];
[throwGroup setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn]];

NSUInteger barItemIndex = index;
CGPoint startPoint = [[viewToThrow layer] position];
CGPoint endPoint = [self approximateFrameForTabBarItemAtIndex:barItemIndex inTabBar:[[self tabBarController] tabBar]].origin;
CGPoint topPoint = startPoint;
CGFloat direction = (startPoint.x > endPoint.x) ? 1.0 : -1.0;

UIView *rootView = [[[[UIApplication sharedApplication] keyWindow] rootViewController] view];
startPoint = [[self view] convertPoint:startPoint toView:rootView];
endPoint   = [[self view] convertPoint:endPoint toView:rootView];
topPoint   = [[self view] convertPoint:topPoint toView:rootView];

CGFloat scaleFactor = [self scaleFactorForView:viewToThrow toFitInSize:CGSizeMake(40, 40)];
CGFloat throwDistance = 100.0;

topPoint.x -= ABS(startPoint.x - endPoint.x)*0.35*direction;
topPoint.y -= throwDistance;

CABasicAnimation * rotate = [CABasicAnimation animationWithKeyPath:@"transform"];
CATransform3D t = CATransform3DIdentity;
t = CATransform3DScale(t, scaleFactor, scaleFactor, 1.0);
t = CATransform3DRotate(t, M_PI/30*direction, 0.0, 0.0, 1.0);
[rotate setToValue:[NSValue valueWithCATransform3D:t]];

CAKeyframeAnimation * throw = [CAKeyframeAnimation animationWithKeyPath:@"position"];
[throw setValues:[NSArray arrayWithObjects:
                  [NSValue valueWithCGPoint:startPoint],
                  [NSValue valueWithCGPoint:topPoint],
                  [NSValue valueWithCGPoint:endPoint], nil]];

[throw setCalculationMode:kCAAnimationCubic];
[throw setFillMode:kCAFillModeForwards];
[throwGroup setAnimations:[NSArray arrayWithObjects:rotate, throw, nil]];

Scaling the view to fit.

When our view lands on the bar item we want it to be small enough to fit the bar item. If the view already fits we want it to stay the same size, scaling it up would look weird. Also, we really need to keep the original aspect ratio. Changing the aspect ratio always makes things look ugly.

- (CGFloat)scaleFactorForView:(UIView *)view toFitInSize:(CGSize)size {
    CGFloat viewWidth = CGRectGetWidth([view frame]);
    CGFloat viewHeight = CGRectGetHeight([view frame]);

    CGFloat viewAspect = viewWidth/viewHeight;
    CGFloat sizeAspect = size.width/size.height;

    if (viewAspect > sizeAspect) {
        return MIN(1.0, (size.width/viewWidth));
    } else {
        return MIN(1.0, (size.height/viewHeight));
    }
}

Badging the bar item

When the animation finishes we want the bar item to increase its badge. We could set ourselves as the delegate for the appropriate animation, and doing so would be fine, to know when the animation finishes but instead we are going to badge the item and clean up the animation in a CATransaction completion handler block:

[CATransaction begin];
[CATransaction setCompletionBlock:^{
    [layerToThrow removeFromSuperlayer];

    UITabBarItem * barItem = [[[[self tabBarController] tabBar] items] objectAtIndex:barItemIndex];
    NSString * currentBarBadgeText = [barItem badgeValue];
    NSInteger barBadgeValue = [currentBarBadgeText integerValue];
    barBadgeValue++;
    NSString * newBarBadgeText = [NSString stringWithFormat:@"%d", barBadgeValue];
    [barItem setBadgeValue:newBarBadgeText];
}];
[layerToThrow addAnimation:throwGroup forKey:@"throw"];
[CATransaction commit];

Badging the bar item is simple, even though the barValue property is a string.

Extra details to make the animation look better

Shadow on the thrown layer

Right now the animation looks a little flat. We could fix that with a drop shadow on the layer that flies through the air. However, adding the shadow right away would make it visually jump out. Instead we animate it’s opacity from 0.0 to 1.0 and start with a slight delay after the layer has been thrown. This has the bonus that it ads to the illusion of the thrown layer lifting from the ground.

[layerToThrow setShadowPath:[[UIBezierPath bezierPathWithRect:[layerToThrow bounds]] CGPath]];
[layerToThrow setShadowRadius:3.0];
[layerToThrow setShadowOpacity:0.0];
[layerToThrow setShadowColor:[[UIColor blackColor] CGColor]];
[layerToThrow setShadowOffset:CGSizeMake(0.0, 1.0)];
CABasicAnimation * showShadow = [CABasicAnimation animationWithKeyPath:@"shadowOpacity"];
[showShadow setDuration:0.3];
[showShadow setBeginTime:CACurrentMediaTime()+0.1];
[showShadow setToValue:[NSNumber numberWithFloat:1.0]];
[layerToThrow addAnimation:showShadow forKey:@"shadow"];

Fading out the original view in the beginning of the animation

If we leave the original layer behind while the copied content flies down to the bar item it visually distracts us from following the layer down to where it ends up and makes the animation less successful at telling where the data goes.

On the other hand, if we fade out the original view completely during the animation then the story of our animation tells us that the data was moved instead of copied and that would become confusing once the original layer faded back after the animation.

So to make it clear that we are only copying the data while not being distracting we should fade the original layer out enough so that it’s still clearly visible while looking out it while not taking to much focus. After some experimenting, 20% opacity seemed like a good fit.

[[viewToThrow layer] setShouldRasterize:YES];
CABasicAnimation * fadeOriginalView = [CABasicAnimation animationWithKeyPath:@"opacity"];
[fadeOriginalView setFromValue:[NSNumber numberWithFloat:0.2]];
[fadeOriginalView setToValue:[NSNumber numberWithFloat:1.0]];
[fadeOriginalView setDuration:0.4];
[fadeOriginalView setBeginTime:CACurrentMediaTime()+0.5];
[fadeOriginalView setFillMode:kCAFillModeBackwards];
[fadeOriginalView setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
[[viewToThrow layer] addAnimation:fadeOriginalView forKey:@"fadeOutAndBack"];

I also made it so that the fade back animation is swift but doesn’t start until the half way through the throws so that the layer is well out of its way when the original view fades back. I found that fading back before the animation finishes strengthen the message that the data were only copied.


Why we didn’t animate the tab bar item

It would have been great to make the bar item jump a little when the view landed on it, just like the toolbar item does in Safari but since we can’t get to the view of the bad item we can neither animate it nor make a visual copy of it and animate that. Therefor we chose to badge the tab bar item instead.


  1. There is no public API to get the view for a UIBarItem and thus not its frame. Therefore we look for a visually close approximation of the frame. See The frame of the view and the UITabBarItem

  2. Whenever I use C-APIs I try to create well named variables for all the arguments. That makes the code more readable and C a little less scary if your new to it. 

  3. If the layer we are animating from lies within a scroll view that visually scrolls down behind the tab bar, we risk that a partially visible view suddenly will appear in front of the tab bar when we are animating f the layer is added above the tab bar. That would look bad. in that case we would need to handle this as a special case.