Monday, April 28, 2008

Sorted Details

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.


11 comments:

Anonymous said...

View Source doesn't work :))

Chet Haase said...

ARGH!

Another casualty of my sub-optimal blogging setup (can't host the SWF and source files locally on blogspot, so I serve them from elsewhere and it all gets confused...)

I updated the SWF and uploaded a .zip file; now just click on the link to download the zipped source directly.

Thanks,
Chet.

------ said...

Nice work! I have been developing a slide application recently. One problem I have run into is using bindable variables in an mx:Move. For example: (I am using # instead of "<" and ">")


#mx:Move target="{from_grid}" duration="500" xFrom="{slide_from_grid_from}" xTo="{slide_from_grid_to}"/#
#mx:Move target="{to_grid}" duration="500" xFrom="{slide_to_grid_from}" xTo="{slide_to_grid_to}"/#


If I replace the xTo's with the actual numbers, the slide occurs smoothly. Using bindables, the slide does not really even occur. It's more of a sudden move. Do you think this is a bug or is there a better solution?

(I have verified that the bindables have the correct values)

Thanks!

Anonymous said...

Hey Chet,
Here is a challenge for you ;-)
When I saw your app i immediately thought it would be cool if the change already happends while dragging and not only when you release it. This way the user gets immediate feedback of what`s happening and I think that`s the motivation for animations in UIs.
I think this is where the build in effects fall short. They don`t really work if the animation target change often and abrupt. I made a AIR version http://www.richapps.de/?p=150 that uses some old school flash animation techniques but lacks the beauty of your code. So how would you do this in a good way?

Have a great day
Benz

Chet Haase said...

Tony: Sorry, I didn't see this comment until today (normally get notified when there's a comment, but somehow didn't on this one).
I think the problem might come from how effects are instantiated: the variables xFrom/xTo/etc. are provided to a factory class (Move). When the effect is played, the factory creates instances on the targets with the current values. If you bind the attributes, they should have valid values at the time the effect is play()'d, but changing those values will not cause the effect to re-play (or to re-create more instances with the new values).
Does that help make sense of this?

Benz: Thanks for the idea; I'll chew on this (about time for another demo app anyway...). The trick, I think, in this kind of realtime interaction between the slider value (dragged in realtime) and animations to the current value (delayed because, well, animations happen over time). Getting it to feel natural and not laggy (animations completing before running the next one) or choppy (animatings getting cut off to react to the new value) maybe require some tweaking.

By the way, I'm curious about the problems you have with the Flex effects (as you said in your blog). I'm currently working on some new effects infrastructure and classes for Flex4, including reworking some of the Tween functionality. It would be great to know more about issues that you have with the current stuff. (I keep meaning to write a post on this to gather feedback generally, but in the meantime...)

------ said...

Thank you for getting back to me Chet. I actually solved the problem a while ago. I am however running into a much more interesting problem now with one of my apps.

I need to dynamically change the background color of a widget I created. But when I change the color, it messes up the layout.

I used validateNow() to try to fix this issue, but for some reason that only works for the first time I change the color. If I change it twice, the layout remains messed up.

Do you have any ideas for this? I have probably spent 6-8 hours investigating this issue already.

Thank you!

Anonymous said...

Hi Chet,
First thing I wanna say is Great Video, Props to you!!! I'm new to flex, I've been peeking it a little bit now and just started doing my first couple of apps.
Excuse my ignorance but I just downloaded the solution, tried running and I got the following error:

unable to resolve 'assets/a.jpg' for transcoding

I did create the folder and a "a.jpg" file. But I still get the same error, I tried to change the direction of the slash and still got the error. Any Pointers??

By the way, I'm from Argentina, I'm waiving the Flex flag here, if u wanna join my LinkedIn group http://www.linkedin.com/e/gis/976927

------ said...

try to right click on the folder and hit refresh...flex can be weird

Anonymous said...

hi chet,


I am learning Flex seince 3 month, but your video has help me to learn flex at an level at which i was looking...


Thanks Chet...

Unknown said...

hi chet,


I am learning Flex seince 3 month, but your video has help me to learn flex at an level at which i was looking...


Thanks Chet...

Jonathan said...

Hi Chet,

I was trying the example and i noticed that smoothing wasn't working. I though you weren't writting it explicitly but i saw your loadComplete event. I don't know why this event is not being called. I added another eventListener (Mouse Click) and it worked. It's not really cool clicking on every image you load in the Slide, so i do the smoothing when the image is being loaded to the slide, at Slide's addChild.

here's the code.

//Slide.as

override public function addChild(child:DisplayObject):DisplayObject
{
var retValue:DisplayObject = super.addChild(child);
image = Image(child);
Bitmap(image.content).smoothing = true;
return retValue;
}

With this, the smoothing occurs after image is loaded in the slide.
I hope you update the code.
It's just to delete image.addEventListener(Event.COMPLETE, loadComplete); at addImages in SlideContainer and to add Bitmap(image.content).smoothing = true; at Slide's addChild after image is setted.

Grettings from Cali, Colombia.

Jonathan Morales VĂ©lez