The Veiled UI
Despite what Apple’s Human Interface Guidelines may say, sometimes you want to prevent your user from doing anything until some long-running task (like a network API call or a login task, for example) completes. How do you do that without knowing, necessarily, what UI elements are visible and active? Use a full-screen veil to cover them!
Several times now, I have implemented my own, lightweight but flexible full-screen veil. It’s not that complex, but it took some time to get it right. The idea is that the app presents a full-screen view on top of everything else on the screen, thereby blocking access to any UI elements that may be visible on screen. Some of the nuances of a good veil are that it is semi-transparent (so the user can see the context of why they are waiting), the message is configurable, and there is a visual indicator that something is happening (like a UIActivityIndicator
).
We start with the .h file:
[code lang=”objc”]
//
// Veil.h
//
// Created by Mark Granoff on 3/19/11.
// Copyright 2011 Hawk iMedia. All rights reserved.
//
#import <Foundation/Foundation.h>
@interface Veil : NSObject
+(void)veilWithMessage:(NSString *)msg;
+(void)remove;
@end
[/code]
Simple, no? Just two class methods: one to put up the veil with a message in it, and one to remove it. In other implementations I’ve needed to test if the veil was visible; that was accomplished with an additional, simple class method that I’ll leave as an exercise for the reader.
Now for the implementation.
[code lang=”objc”]
//
// Veil.m
//
// Created by Mark Granoff on 3/19/11.
// Copyright 2011 Hawk iMedia. All rights reserved.
//
#import "Veil.h"
#import <QuartzCore/QuartzCore.h>
#define DEGREES_TO_RADIANS(__ANGLE__) ((__ANGLE__) / 180.0 * M_PI)
[/code]
Standard stuff here. We need QuartzCore (don’t forget to include that framework as well!) because we use a little bit of layer magic to polish the UI. This particular version of the veil supports all device orientations, so we need the common and useful DEGREES_TO_RADIANS
macro.
[code firstline=”13″ lang=”objc”]
@implementation Veil
static UIView *_veil;
+(void)initialize
{
if (self == [Veil class]) {
CGRect bounds = [[UIScreen mainScreen] bounds];
UIView *v = [[[UIView alloc] initWithFrame:bounds] retain];
v.layer.backgroundColor = [UIColor lightGrayColor].CGColor;
v.layer.opacity = 0.8;
UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 260, 260)];
container.backgroundColor = [UIColor whiteColor];
container.layer.cornerRadius = 10;
container.layer.opaque = YES;
container.clipsToBounds = YES;
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
label.backgroundColor = [UIColor clearColor];
label.textColor = [UIColor blackColor];
label.font = [UIFont systemFontOfSize:14];
label.numberOfLines = 0;
label.lineBreakMode = UILineBreakModeWordWrap;
label.textAlignment = UITextAlignmentCenter;
[container addSubview:label];
[label release];
UIActivityIndicatorView *spinner = [[UIActivityIndicatorView alloc]
initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
spinner.hidesWhenStopped = YES;
[spinner stopAnimating];
[container addSubview:spinner];
[spinner release];
[v addSubview:container];
_veil = v;
[v release];
[container release];
}
}
[/code]
Here you can see that the veil is just a static UIView
. In the class initializer, which is invoked only once, we create a UIView
with a frame equal in size to the main screen bounds. We set the layer background color to light gray and the layer opacity to .8, which was a value that lets enough of what was being covered through for context without being a distraction. Your taste may be different.
Within the view, we add a container with a fairly arbitrary position and size, because we’ll change it based on the message being displayed later so that it is nicely centered on the screen. We give the container a white background and rounded corners.
Next, we create a label to contain the message, with a CGRectZero
frame, again because we’ll set it appropriately later when we know what the message is. The label gets a clear background, black text, a reasonable font size, and we take care of some other attributes we’ll need set later. To the container, we add the label.
The last UI element to add is a spinner, or UIActivityIndicatorView
. We create a standard, small grey spinner, and add it to the container. We’ll position it just so later.
Finally, we add the container to the view, set the static _veil
variable, and release our remaining temporary variables. (This code was written well before iOS5 and ARC, for what it’s worth.)
Now the good stuff. The point of this class is to be able to put up any message, with a spinner, and it should just look good all the time. The veilWithMessage:
method does just that, taking into account device orientation, too.
[code firstline=”55″ lang=”objc”]
+(void)veilWithMessage:(NSString *)msg
{
UIView *container = [[_veil subviews] objectAtIndex:0];
NSArray *subviews = [container subviews];
UILabel *l = [subviews objectAtIndex:0];
UIActivityIndicatorView *spinner = [subviews objectAtIndex:1];
if (msg) {
container.transform = CGAffineTransformIdentity;
CGRect frame = CGRectMake(0, 0, 260, 260);
container.frame = frame;
l.text = msg;
CGSize constraint = CGSizeMake(240, MAXFLOAT);
CGSize labelSize = [l.text sizeWithFont:l.font constrainedToSize:constraint lineBreakMode:UILineBreakModeWordWrap];
frame = l.frame;
frame.size = labelSize;
frame.origin.x = 10;
frame.origin.y = 10;
l.frame = frame;
l.hidden = NO;
frame = container.frame;
frame.size.width = l.frame.size.width + 20;
frame.size.height = l.frame.size.height + spinner.frame.size.height + 30;
frame.origin.x = 160 – frame.size.width / 2;
frame.origin.y = 230 – frame.size.height / 2;
container.frame = frame;
frame = spinner.frame;
frame.origin.x = container.frame.size.width / 2 – spinner.frame.size.width / 2;
frame.origin.y = l.frame.origin.y + l.frame.size.height + 10;
spinner.frame = frame;
UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation;
switch (orientation) {
case UIInterfaceOrientationPortrait:
break;
case UIInterfaceOrientationPortraitUpsideDown:
container.transform = CGAffineTransformRotate(CGAffineTransformIdentity, DEGREES_TO_RADIANS(180));
break;
case UIInterfaceOrientationLandscapeRight:
container.transform = CGAffineTransformRotate(CGAffineTransformIdentity, DEGREES_TO_RADIANS(90));
break;
case UIInterfaceOrientationLandscapeLeft:
container.transform = CGAffineTransformRotate(CGAffineTransformIdentity, DEGREES_TO_RADIANS(-90));
break;
default:
break;
}
[spinner startAnimating];
} else {
l.hidden = YES;
}
if (![_veil superview]) {
_veil.layer.opacity = 0;
[[UIApplication sharedApplication].keyWindow addSubview:_veil];
[UIView animateWithDuration:0.25 animations:^{ _veil.layer.opacity = 0.8; }];
}
}
[/code]
The first thing we do is pull out the various elements that were created in +initialize
, specifically the container, the label and the spinner. Only if there is a msg provided do we bother setting up the label. Otherwise, we just hide it. But if there is a message (and there usually is) it’s time to setup the label so that it appears nicely in the container.
We start by resetting the container’s transform, which just puts it back to its original orientation, i.e. with no rotation. If the device orientation is anything other than portrait, we’ll adjust the container accordingly.
Next we setup the label. Recall that we set it’s numberOfLines
to zero and its lineWrapMode
to UILineBreakModeWordWrap
. These are key, because we want whatever the message is to fit nicely within the container both in terms of the container’s width and however high the formatted, wrapped text turns out to be. The NSString
method -sizeWithFont:constrainedToSize:lineBreakMode:
does most of the heavy lifting here. We provide a constraint that specifies the width we want (240 pixels in this case) and a sufficiently large height, meaning we don’t care (yet) but we don’t want the text cut off either. The result of this method call, a CGSize
structure, contains the key piece of information we need: the height of the formatted and wrapped text. We can use that returned size to set the label’s frame. While we’re at it we set the labels position in the container to (10,10), which gives the label a nice margin inside the container.
Next we reset the container’s frame to match the label’s frame width plus 10 pixels on each side. (This actually means the container’s frame is 20 pixels wider than the labels frame.) Notice that we set the height of the container to the height of the label plus the height of the spinner plus 30 pixels. That 30 pixels accounts for the margins between the bottom of the label and the spinner, and the bottom of the spinner and bottom edge of the container. And finally, we re-position the spinner accordingly to be under the label and centered in the container.
The last bit before showing the veil is to adjust the container’s rotation according to the device’s orientation, and start the spinner a’spinning.
And finally, the magic. If the veil is not already visible (i.e. it has no superview) we prepare it to fade into view by setting it’s opacity to zero and adding it as a subview to the app key window. The veil is made visible with a quick animation that change’s the veil’s layer opacity to 0.8.
The last method simply removes the veil, if it is showing, with a similarly nice animation to fade it out of view.
[code firstline=”119″ lang=”objc”]
+(void)remove
{
if ([_veil superview]) {
UIView *container = [[_veil subviews] objectAtIndex:0];
NSArray *subviews = [container subviews];
UIActivityIndicatorView *spinner = [subviews objectAtIndex:1];
[UIView animateWithDuration:.25
animations:^{ _veil.layer.opacity = 0.0; }
completion:^(BOOL finished){
[spinner stopAnimating];
[_veil removeFromSuperview];
}];
}
}
@end
[/code]
Like in -veilWithMessage:
, this method extracts the view that is the spinner from the hierarchy of views in the veil view. Then with a short animation, the veil’s layer opacity is set to 0, after which the spinner is stopped and the veil removes itself from it’s superview.
Here’s a screenshot of the veil in action:
Notice that, as promised, the veil covers the whole UI. In addition, notice the precise sizing of the container with the text “Searching…” and the placement of the activity indicator centered below the text.
Some final notes on using this. Since showing and removing the veil manipulates the UI, the Veil class methods need to be executed on the main thread. What I have typically done is invoke -veilWithMessage:
in response to a button press on the main thread, then perform one or more actions in the background with one of them executing a block of code to remove the veil on the main thread. It is important to note that whatever work you do should be done off the main thread — the thread on which any UI updates occur — otherwise the veil’s spinner won’t spin.
Improvements? Suggestions? Comments? Let me know!
If you do all your work in the main thread, the spinner in the veil wont spin!
@Maciej: That’s a good point; of course! My mistake. I’ve edited the post.