Q: What did the layout manager say to the anxious component?
A: Contain Yourself!
Q: Is it possible for components to be arranged without a layout manager?
A: Absolutely!
Q: How did the component feel about its first animated layout?
A: It was a moving experience.
Q: What do you call the animation objects owned by a component?
A: The component's personal Effects.
Q: Why is an animating container a risky real estate investment?
A: It's a transitional area.
Motivation
I've been playing around with animation and layout recently, and I wondered how I could combine the two. That is, when a container changes state and wants to re-layout its children, wouldn't it be nice if it could animate that change instead of simply blinking them all into place?
Suppose you have a picture-viewing application, where you're looking at thumbnails of the photos. Sometimes you want them to be quite small so that you can see more pictures in the view. But sometimes you want them to be larger instead so that you can see more details in any particular picture. So there's a slider that allows you to dynamically control the thumbnail sizes.
By default, in this application as well as other real applications that it's modeled on, changing the thumbnail sizes causes the application to re-display itself with all of the changed thumbnails in their new location. But what I want is for the change in size to animate, where all of the thumbnails will move and resize to their new location and dimension.
It turns out that this is not that hard to achieve, using the built-in effects of Flex.
Application: SlideViewer
First, the application (photographs courtesy of Romain Guy):
Usage: Move the slider around, which changes the sizes of the pictures. See the pictures move and resize into their new locations.
So how does it work?
How It Works
The application uses the built-in Move and Resize effects of Flex on each of the pictures. These effects, when combined in a Parallel effect, do exactly what we need - they move and resize their targets. But there are a couple of additional items that you need to account for in order for it to work correctly for our animated layout situation.
For one thing, you have to disable automatic updates to the container of the pictures during the animation. If you don't do this, the container will be trying to lay the pictures while you're trying to move them around. It's like watching an inverse of that video game anomaly, where your screen is having a seizure watching you.
Another trick is one that Flex transitions use internally; we first record the properties of all of the animating objects at their start location, using the Effect.captureStartValues()
function, then we change the thumbnail sizes, force a layout to occur,l and run the effect. This approach causes our effect to automatically animate from those cached initial values to the new values that our container has imposed on the resized images.
The Code
There are three source files for this application (downloaded from here):
- Slide.as: This class handles the rendering of each individual photograph on a custom background.
- SlideViewer.as: This subclass of the Tile container loads the images and creates Slide objects to hold them.
- SlideViewer.mxml: This is the main application class, which arranges the basic GUI elements and creates and runs the layout animation when thumbnail sizes change.
Slide.as
This class is a simple custom component that acts as a container for the Image object created from each photograph. It also adds some custom rendering to get a nice slide-like background for the image.
First, there is a simple override of addChild()
, so that Slide can cache the Image object that it is holding. This caching is not necessary, since we could retrieve this object from the component's children list at any time, but it seemed like a better way to cache a commonly-access object:
override public function addChild(child:DisplayObject):DisplayObject { var retValue:DisplayObject = super.addChild(child); image = Image(child); return retValue; }
The real reason for the custom subclass is the custom rendering that we perform to get the fancy gradient border for each image. This happens in the updateDisplayList()
function. The first part of the function is responsible for sizing and centering the contained Image object appropriately. The remainder of the function handles the custom rendering to get our nice gradient border:
override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void { super.updateDisplayList(unscaledWidth, unscaledHeight); // Set the image size to be PADDING pixels smaller than the size // of this component, preserving the aspect ratio of the image var widthToHeight:Number = image.contentWidth / image.contentHeight; if (image.contentWidth > image.contentHeight) { image.width = unscaledWidth - PADDING; image.height = unscaledWidth / widthToHeight; } else { image.height = unscaledHeight - PADDING; image.width = unscaledHeight * widthToHeight; } image.x = (unscaledWidth - image.width) / 2; image.y = (unscaledHeight - image.height) / 2; // Draw a nice gray gradient background with // a black rounded-rect border graphics.clear(); var colors:Array = [0x808080, 0x404040]; var ratios:Array = [0, 255]; var matrix:Matrix = new Matrix(); matrix.createGradientBox(width, height, 90); graphics.beginGradientFill(GradientType.LINEAR, colors, null, ratios, matrix); graphics.lineStyle(.04 * width); graphics.drawRoundRect(0, 0, width, height, .1 * width, .1 * width); }
SlideContainer.as
This class creates all of the Slide objects that will be in the view and also handles later resizing events due to movement of the slider component.
The images are added to the application through the addImages()
function. This function uses a hard-coded list of embedded images. The original version of this application was written with AIR, and I used the new File APIs to allow the user to browse the local file system and load images dynamically. That's important functionality, and it's preferable to the hard-coded approach below, but it's also orthogonal to the effect I'm trying to demonstrate here, so I dumbed-down the application to just load in some known images instead. Perhaps in a future blog I'll release the code and app for the AIR version. Note also that I'm only using embedded images here to simplify things for the weblog; it's easier to deploy this thing standalone (with embedded images) rather than having it refer by pathname or URL to images that live elsewhere, just because of the limited nature of this blogging engine (Grrrrr. Don't get me started...).
addImages()
iterates through the list of embedded images (stored in the bitmaps[]
array - check the source file for details), creating a Flex Image component for each one with the bitmap as the source property, and then creates a containing Slide object for each resulting Image.
public function addImages(event:Event):void { // Create an Image object for each of our embedded bitmaps, adding // that Image as a child to a new Slide component for (var i:int = 0; i < bitmaps.length; ++i) { var image:Image = new Image(); image.source = bitmaps[i]; image.width = slideSize; image.height = slideSize; image.cacheAsBitmap = false; if (!slides) { slides = new ArrayCollection(); } image.addEventListener(Event.COMPLETE, loadComplete); var slide:Slide = new Slide(); slide.addChild(image); addChild(slide); slides.addItem(slide); slide.width = slideSize; slide.height = slideSize; } }
Note in the above function that we added an event listener, loadComplete()
, that is called when each image load is finished:
private function loadComplete(event:Event):void { var image:Image = Image(event.target); // Enable smooth-scaling Bitmap(image.content).smoothing = true; }
We added the loadComplete()
functionality so that we could enable smooth-scaling for each image. This makes the thumbnails look better in general, but it also vastly improves the animated effect when the images are being scaled on the fly. Without smooth-scaling there can be some serious 'sparkly' artifacts as colors snap to pixel values. Unless all of your pictures are of fireworks and disco balls, the scaling artifact without smoothing
enabled is probably not one you'd like.
When the slider value changes in the GUI, that value gets propagated as the new size of the Slide objects through setter for the slideSize
property:
public function set slideSize(value:int):void { _slideSize = value; for (var i:int = 0; i < slides.length; ++i) { var slide:UIComponent = UIComponent(slides.getItemAt(i)); slide.width = value; slide.height = value; } }
SlideSorter.mxml
Now let's look at the code for our main application file, SlideSorter. This class is written mostly in MXML, with some embedded ActionScript3 code to handle starting the animation.
First, there's our Application tag. There's nothing amazing that happens here, but you can see that we define a nice gray gradient background for the application as well as a callback function for the creationComplete
event, which will call the addImages()
function we saw earlier to load the images:
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" xmlns:comps="components.*" layout="absolute" backgroundGradientColors="[#202020, #404040]" creationComplete="slides.addImages(event)">
The GUI of our application is quite simple; there is just the container for the slides and the HSlider that determines the thumbnail size. The container is defined by this simple declaration:
<comps:SlidesContainer right="10" bottom="45" top="10" left="10" id="slides"/>
The HSlider is a little more interesting. It defines a data binding expression for the slider value, to ensure that the location of the slider will be set according to the thumbnail size. This is really only for the first view of the application, so that the slider control is in the correct initial location; after that, it is the change in slider value that will change the thumbnail size, so they will automatically be synchronized through that other mechanism. Also, note that change events on the slider will call the segue()
method, which is where we set up and run our animation. Note that I changed the slideDuration
property from the default 300 down to 100; this makes our animation start sooner, rather than waiting for 300 milliseconds just for the slider animation to complete before we receive the change
event.
<mx:HSlider id="sizeSlider" bottom="10" width="100%" height="27" value="{slides.slideSize}" change="segue()" slideDuration="100" snapInterval="25" minimum="25" maximum="400"/>
Our animation for the layout transition is defined by a simple Parallel effect, which composes a Move and a Resize effect together such that they play at the same time. It is important to note that both Move and Size have built-in intelligence that can determine what values to animate from and to in the right situation. For example, when we set up our animation to collect the start values and then move all of the thumbnails to their end locations/sizes (by calling validateNow()
on the container), the effects are clever enough to sense that we want to animate from those initial values to the values that they currently detect.
<mx:Parallel id="shifter"> <mx:Resize/> <mx:Move/> </mx:Parallel>
Finally, we have the ActionScript3 code that we use to set up and run our animation. Note that we already have "set up" our animation by declaring the shifter
Parallel effect above. But we need to tell this animation which targets to act on when it runs. (In the situation of this particular demo, we could probably just do this once, since the components in the view are hard-coded at startup. But for a more general case where there might be image changes between transition runs, I wanted the behavior to be more dynamic).
This little snippet of code sets the targets
to animate (the slides in our SlidesContainer object) and collects the starting location/sizing info for those targets. It then sets the final slideSize property that we want to animate to and forces a layout validation on the container to position the objects in place. (Note, however, that this layout is not yet visible. It runs the logic internally, but those locations are not changed onscreen immediately; we're safe as long as we're still in this code block). It then sets autoLayout
to false
; this statement tells our layout manager that we don't want it interfering with trying to move and resize our slides while we're in the middle of animating them. This allows us to do absolute positioning of the objects inside of our container even though our container normally wants to position the slides in a flow-layout style. We then add an event listener to call our segueEnd()
function when the animation is complete, which we'll see below. Finally, we play()
our animation, which does what we want. Internally, the Move/Resize animation collects the end values for our slides and runs an animation from the start to the end values.
public function segue():void { shifter.targets = slides.getChildren(); shifter.captureStartValues(); slides.slideSize = sizeSlider.value; slides.validateNow(); slides.autoLayout = false; shifter.addEventListener(EffectEvent.EFFECT_END, segueEnd); shifter.play(); }
Finally, we handle the callback when the animation finishes in our segueEnd()
function. This simply resets the autoLayout
property on our container, to make sure that it resumes its normal behavior.
public function segueEnd(event:Event):void { slides.autoLayout = true; }
100 goto end
That's it. I frankly expected this to be much harder than it was. It took a bit to work through the details of how to set up the animation appropriately (Effect.captureStartValues()
) and how to get the layout manager out of my way during the animation (autoLayout = false
), but otherwise all of the intelligence for doing this kind of transition was built into the existing Flex framework.
Note that there are some limitations to this kind of transition. For example, the animation will detect changes only at the top-level children of the container. So, for example, I originally used a Box container to hold each slide, but I found that the images inside the slides were being resized immediately and were not being animated from start to finish. Making the change to simply add the images as direct children of the Slide component fixed this. Ideally, a more robust animated layout system would work with hierarchies of layout; that's something I'll have to think a lot more about.
Complete source code for this application is available here.