A Custom Layer Controller

From AwkwardTV

Jump to: navigation, search

<Google>WIKI</Google>

In our second tutorial we'll pull back a little and cover the BRLayerController class, and what you can do with it. If this isn't your first experience with Back Row plugins, then you're probably already familiar with the BRMenuController or BRMediaMenuController, but those are both fairly specialized subclasses. By creating our own BRLayerController subclasses we can define our own content and layout in a more comprehensive manner.

Along the way we will also take a look at an anciliary class, BRImageManager, which enables us to dynamically download and cache images from the Internet, or indeed from any URL, remote or local, and we will see how to use the BRHeaderControl and BRImageControl classes.

By the end of this tutorial, we will have created a custom controller that will download an image in the background and display it when ready.



Contents

Prerequisites

BRLayerController

The BRLayerController class is used to present each significant part of the AppleTV user interface. Examples include the full-screen lists, alerts, and the settings facade. It is also used for items such as preview controllers and parade controllers, which show static or animated content to the left of a list. Specifically, a layer controller differs from a control in that it will handle user input in some intelligent way: where a layer draws content and a control collects layers and handles events, a layer controller is where application-specific logic is contained.

Let's start by examining the interface:

Interface

Setup & Contents

- (BRRenderScene *) scene;
- (BRRenderLayer *) masterLayer;
- (NSRect) masterLayerFrame;
- (void) resetLayout;
- (void) setControls: (NSArray *) controls;
- (void) addControl: (BRControl *) control;
- (NSArray *) controls;

Like the BRControl class, BRLayerControllers have a single master layer to which others are added. Unlike BRControl however, this layer is provided for you by the base class. There is no specific -frame or -setFrame: method here; the controller class is not by definition a UI class, so these operations are performed directly on the master layer, retrieved by calling -masterLayer. As with all controls and layers, however, an explicit reference to the scene is kept by the controller, and is used to redraw the scene when the layer changes.

The -resetLayout method will reset the frame of the master layer according to the -masterLayerFrame function. Both of these can be overridden in subclasses to implement useful behaviour. For instance, a particular subclass might have its masterLayerFrame return the frame of the entire scene (i.e. the entire screen itself), as opposed to the default implementation which will return the frame of the interface area of the scene. By default, the -resetLayout method will simply reset the frame of the master layer. Subclasses can override it to realign their own sublayers according to a potentially changed master frame.

The layer controller also contains a list of controls. While it can have layers added to it directly via [[self masterLayer] addSubLayer: layer], it is generally used to contain controls, which are added to a list, and which have their layers added to the master layer for you. Note that no method exists to remove a control; instead, you must use the following snippet to perform that task:

- (void) removeControl: (BRControl *) aControl
{
    NSMutableArray * controls = [NSMutableArray arrayWithArray: [self controls]];
    [[aControl layer] removeFromSuperlayer];
    [controls removeObject: aControl];
    [self setControls: [NSArray arrayWithArray: controls]];
}

Also note that if the control being removed is retained by the controller subclass outside of the control list, it will need to be released explicitly in addition to the code shown above.

The Controller Stack

- (void) setStack: (BRControllerStack *) stack;
- (BRControllerStack *) stack;

Controllers are placed onto a stack which is used to maintain the display list. Each time you select an item from a menu or click a button which will put up a new interface layer through a controller, then that controller is added to the current stack. When you press the Menu button, the current controller will be popped from the stack, revealing the one underneath.

The functions listed above are used for book-keeping within the controller, but you are unlikely to need to use those explicitly, because the layer controller class defines a number of callbacks used by the BRControllerStack class to notify a layer of impending status changes:

- (void) willBePushed;
- (void) wasPushed;
- (void) willBePopped;
- (void) wasPopped;
- (void) willBeBuried;
- (void) wasBuriedByPushingController: (BRLayerController *) controller;
- (void) willBeExhumed;
- (void) wasExhumedByPoppingController: (BRLayerController *) controller;

Here we see pairs of notifications -- one called in advance of the operation, one called after it has been performed. There is a set for when the controller is being pushed onto the top of the stack (being put onscreen) and when it is being popped (removed before deallocation). There are also items for when the controller is to be buried (another controller pushed on top of this one) and exhumed (controllers on top of this one were removed). Each of these can be overridden to performs specific duties, but in this example we will use only the -wasPushed and -willBePopped methods, which are generally the most useful points at which to perform our operations.

Animations

- (BRAnimation *) popAnimation;
- (BRAnimation *) pushAnimation;

By default, a layer controller offers a fade-to-black animation when popped and a fade-from-black animation when pushed. Subclasses can override these to implement specific effects; for instance the main menu provides a custom animation where the icon zooms up to the header of the incoming menu. We will cover animations in a later tutorial.

Miscellaneous

- (BOOL) brEventAction: (BREvent *) event;
- (void) addLabel: (NSString *) label;
- (void) removeLabel: (NSString *) label;
- (BOOL) isLabelled: (NSString *) label;
- (BOOL) isNetworkDependent;
- (BOOL) popsOnBury;

Layer controllers can handle events directly; this allows them to customize the response to the menu button, or to move focus amongst their controls.

Labels can be used to 'tag' controllers. The BRControllerStack class offers methods based on these, such as fetching a controller with a specific label from the stack (if there), or popping everything above a certain label.

A layer can mark itself as being network dependant, in which case it will be monitored by the framework, so that some action can be taken should the network status change.

It can also choose to be popped automatically if another controller is pushed on top of it, removing itself from the stack immediately.

BRImageManager

The BRImageManager class implements a useful cache for images pulled from the network, along with an asynchronous notification-based loading system for remote images. A number of its methods are based around the iTunes/AppleTV media library format, but there are some which are more generally useful:

- (BOOL) isImageAvailable: (NSString *) name;
- (NSString *) imageNameFromURL: (NSURL *) url;
- (NSString *) writeImageFromURL: (NSURL *) url;
- (void) writeImage: (CGImageRef) image named: (NSString *) name;
- (void) removeImageNamed: (NSString *) name;
- (CGImageRef) imageNamed: (NSString *) name;

Firstly we have a routine to check whether a named image is already present in the cache. If it is, then all well and good. Otherwise, we can use an NSURL object to go fetch the image and cache it. The -imageNameFromURL: routine can be used to glean the name that will be assigned, and the -writeImageFromURL: method will actually go and fetch the image in the background. This method also returns the name assigned to the image, as a convenience.

Images loaded manually can also be committed to the cache using -writeImage:named:, and any image can be removed with -removeImageNamed:.

To retrieve an existing image from the cache, use -imageNamed:.

Asynchronous loading is performed on another thread, and when this is complete a notification is posted on the main thread. The notification name is "BRAssetImageUpdated", it contains a nil object, and has the following items in its userInfo dictionary:

Key Value
BRMediaAssetKey The name of the image that has been updated.
BRImageKey A CGImageRef containing the image itself.

BRHeaderControl

The header control implements the item seen at the top of the menus and sundry other screens in Back Row. It contains a text layer and an image layer, and so can contain both header text and an icon:

- (void) setTitle: (NSString *) title;
- (NSString *) title;
- (void) setIcon: (BRTexture *) texture horizontalOffset: (float) offset kerningFactor: (float) kerning;

The title is obvious enough, but the icon can be slightly problematic, since it doesn't accept a CGImageRef, which is what we would ordinarily use to provide the content of a BRImageControl. Instead, the following code snippet can be used to initialize a texture to use for the icon:

// assume 'iconImage' has been created somewhere, and is valid, and that '_header' is our BRHeaderControl
BRTexture * tex = [BRBitmapTexture textureWithImage: iconImage context: [[self scene] resourceContext] mipmap: YES]
[_header setIcon: tex horizontalOffset: 0.0f, kerningFactor: 0.0f];

BRImageControl

The image control is a little more involved. It contains method for setting its content with a CGImageRef (with optional down-sampling) or with a BRTexture object. It also provides routines to set the amount of reflection of the image, the offset at which reflection occurs, and whether blending is enabled or disabled. Lastly, there are routines to rotate around the y axis (the CoverFlow effect) and to set the bounding size of the image.

- (NSSize) pixelBounds;
- (void) setImage: (CGImageRef) image;
- (void) setImage: (CGImageRef) image downsampleTo: (NSSize) size;
- (void) setTexture:(BRTexture *) texture;
- (BRTexture *) texture;
- (void) setReflectionAmount: (float) amount;
- (float) reflectionAmount;
- (void) setReflectionOffset: (float) offset;
- (float) reflectionOffset;
- (void) setDisableBlending: (BOOL) flag;
- (BOOL) disableBlending;
- (void) setYRotation: (float) rotation;
- (float) yRotation;
- (void) setBoundingSize:(NSSize) size;

These routines are all quite straightforward, although it should be noted that -setBoundingSize: will maintain the aspect ratio of the image itself, so you don't have to.

Putting It All Together

So, we've gone over the basic building blocks, now let's create our custom controller class. We'll have a header cell containing a title, and we will attempt to fetch an image from the internet. When that image arrives, we will change our header title and fade in the image.

The Interface

To start off, let's define our interface. To keep things simple, we'll have everything public (no internal methods in another category).

@interface ATVDelayedImageController : BRLayerController
{
    BRHeaderControl *_header;
    BRImageControl *_image;
    NSString *_imageName;
}

- (id) initWithScene: (BRRenderScene *) scene;
- (void) dealloc;
- (void) wasPushed;
- (void) willBePopped;
- (void) imageLoaded: (NSNotification *) note;
- (void) setImage: (CGImageRef) image fadeIn: (BOOL) fade;

The class contains references to its header (so we can change the title later), the image (so we can update that and animate it), and the name of the image. We keep the name around so that we can compare it against the notification, since it's conceivable that the image manager might post a notification regarding a different image while we're waiting.

Initialize/Deallocate

- (id) initWithScene: (BRRenderScene *) scene
{
    if ( [super initWithScene: scene] == nil )
        return ( nil );

    // setup the header cell with default contents
    _header = [[BRHeaderControl alloc] initWithScene: scene];
    [_header setTitle: @"Loading Image..."];

    // position it near the top of the screen (remember, origin is lower-left)
    NSRect frame = [[self masterLayer] frame];
    frame.origin.y = frame.size.height * 0.82f;
    frame.size.height = [[BRThemeInfo sharedTheme] listIconHeight];
    [_header setFrame: frame];

    // add the header control to our master layer
    [self addControl: _header];

    // we'll leave the image alone until we go onscreen
    return ( self );
}

- (void) dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver: self];
    [_header release];
    [_image release];
    [_imageName release];

    [super dealloc];
}

The initializer here only sets up the header cell. We will do everything regarding the image when we are pushed, to maximise the impression of the download taking place when viewed. However, there is nothing to stop you from checking the image manager within your -init method and setting up the image control there & then.

The header is placed with its lower-left coordinate 82% of the way up the screen. Since the actual size of the screen will vary depending on the size of the attached hardware, all coordinates need to be put together in this way; there is no constant 'virtual canvas size' in Back Row. Having set the y offset, we leave it at the full width of the master layer, and fetch the list icon height from the BRThemeInfo object. This will return a suitable value for the current display resolution.

Stack Callbacks

- (void) wasPushed
{
    // here we'll setup the image, or wait for it to download
    BRImageManager * mgr = [BRImageManager sharedInstance];
    NSURL * imageURL = [NSURL URLWithString: @"http://www.penny-arcade.com/images/2006/20060306.jpg"];
    _imageName = [[mgr imageNameFromURL: imageURL] retain];

    if ( [mgr isImageAvailable: _imageName] == NO )
    {
        // register for notifications and start downloading
        [[NSNotificationCenter defaultCenter] addObserver: self
                                         selector: @selector(imageLoaded:)
                                             name: @"BRAssetImageUpdated"
                                           object: nil];

        // this will begin the background download
        [mgr writeImageFromURL: imageURL];
    }
    else
    {
        // fetch the image and display it
        CGImageRef image = [mgr imageNamed: _imageName];
        [self setImage: image fadeIn: NO];

        // change the header title
        [_header setTitle: @"Cached Image:"];
    }

    // always call super -- here, it will redraw the scene
    [super wasPushed];
}

- (void) willBePopped
{
    // remove request for image update notification
    [[NSNotificationCenter defaultCenter] removeObserver: self name @"BRAssetImageUpdated" object: nil];

    // always call superclass
    [super willBePopped];
}

In the -wasPushed handler, we talked to the image manager to see if it already had our image cached. If this is the first time this controller has been instantiated, it won't be there, so we will download it. In this case, we register for notifications and ask the manager to write the image at our URL into its cache. Our notification callback will be called whenever an image is written to the cache, so we will need to check the added image name against the one we want.

If the image is already present in the cache, we fetch it directly and hand it off to our image setter method. In this case we will not fade it in, because in all likelihood the layer controller will start to fade the whole lot in at this time.

If we're about to be popped, we stop observing BRAssetImageUpdated notifications. This will avoid any confusing fade-in happening if the layer is popped just as a notification is received and the image starts to fade in.

Notification Handler

- (void) imageLoaded: (NSNotification *) note
{
    // can check for nil _imageName here, and remove observer
    NSDictionary * userInfo = [note userInfo];

    if ( [_imageName isEqualToString: [userInfo objectForKey: @"BRMediaAssetKey"]] == NO )
        return;

    // we have our image, so we don't need any more notifications
    [[NSNotificationCenter defaultCenter: removeObserver: self name: @"BRAssetImageUpdated" object: nil];

    // fetch the image and fade it in
    CGImageRef image = [userInfo objectForKey: @"BRImageKey"];
    [self setImage: image fadeIn: YES];

    // set a new title
    [_header setTitle: @"Loaded Image:"];

    // tell the scene to redraw with the new title
    [[self scene] renderScene];
}

Here we start by checking the name of our image against the name in the notification. If they don't match, we just ignore the notification. If our image has arrived, we hand it off to our setter function, this time telling it to fade the image in, since the rest of the layer has been drawn already. We also set a new title and tell the scene to redraw so that the new text is visible immediately.

Drawing The Image

- (void) setImage: (CGImageRef) image fadeIn: (BOOL) fade
{
    // allocate the image control
    _image = [[BRImageControl alloc] initWithScene: [self scene]];

    if ( fade == YES )
        [_image setAlphaValue: 0.0f];
    [_image setReflectionAmount: 0.3f];
    [_image setReflectionOffset: 0.08f];
    [_image setImage: image];

    // lay it out nicely
    NSRect masterFrame = [[self masterLayer] frame];
    NSRect frame = masterFrame;
    frame.size = [_image pixelBounds];
    NSRect headerFrame = [_header frame];
    float spacer = masterFrame.size.height * 0.01999f
    frame.origin.y = headerFrame.origin.y - spacer - frame.size.height;
    frame.origin.x = (masterFrame.size.width - frame.size.width) * 0.5f;

    if ( frame.origin.y < 0.0f )
    {
        frame.size.height += frame.origin.y
        frame.origin.y = 0.0f;
    }
    [_image setFrame: frame];

    // add it to our master layer
    [self addControl: _image];

    // fade it in
    if ( fade == YES )
    {
        [_image setAlphaValue: 0.0f];
        BRValueAnimation * animation = [BRValueAnimation fadeInAnimationWithTarget: _image scene: [self scene]];
        [animation setDuration: [[BRThemeInfo sharedTheme] fadeThroughBlackDuration]];
        [animation run];
    }
}

The image is created with a moderate reflection amount. The alpha value and reflection are set prior to adding the image because the layer will be rendered when we add the image, and we want these changes to be reflected immediately, rather than have the image flash up briefly without a reflection, or before disappearing to fade back in.

The image's ideal size is gleaned by calling -pixelBounds. Using this information, we setup the image such that its top edge is a short distance below the bottom of the header. Again, we calculate the offset based on the overall size of the master layer. Should the image be so tall that its lower edge is now off the bottom of the screen, we adjust its height accordingly, then set its new frame.

If the fade option is specified, we set the image's alpha value to zero again (this is what we like to call paranoia) and create a BRValueAnimation to alter that value. The duration we provide is once again a value gleaned from BRThemeInfo. Calling [animation run] starts the animation going; this will operate synchronously in this case -- unlike what I said in my first draft. You need to call -setAsynchronous: to get asynchronous operation, but then you need to keep a reference to the animation and call -stop to prevent it from trying to animate deleted objects. That is beyond the scope of today's tutorial, so I'll go into it more thoroughly in a dedicated animation tutorial later in the series.

Extra Credit

Try changing the BRValueAnimation for a different type of animation. Better still, combine a number of different animations together to create a nice swooping effect for the image. See what you can come up with and then take a look at the Extra Credit section to see one idea with some more in-depth information on animations.

Conclusion

That's all (!) there is to it. Now you should have a good idea of the basic principles of laying out controls on screen, and how to use the BRImageManager to fetch images from a remote location and keep them around for quick access in the future.

The code above has now been tweaked to be confirmed accurate, and I have a sample project available for download here.

Part 3 of this series has now been posted: A Downloader Controller

Contact Details

You can reach me on irc.moofspeak.net #awkwardtv, username alan_quatermain, or http://forum.awkwardtv.org/ username Quatermain.

Personal tools