Getting Crazy with the Spark Layout Framework

SOMETIMES GOOD JUST HAPPENS
I gotta say, Flex 4 has come with some serious improvements. This is notable considering how wonderful Flex 3 was. The Flex SDK team keep bringing on the good like it aint’ no thang. After learning Spark skinning and then delving into all that is Spark, I became smitten with their new philosophy toward the Flex component architecture. It is clean, decoupled and a joy to work with. Out of all the improvements however, I keep finding myself playing around with the LayoutBase class.
You see, layouts are no longer coupled to a container such as mx::canvas. They are freely interchangeable as long as they extend LayoutBase. This presents some exciting opportunities for guys like me who love to break from typical layout conventions and play with moving elements in interesting ways. Want a 3D carousel? Just create a 3DCarousel class which extends LayoutBase and slap that puppy in there. BOOM! You have yourself a 3D Carousel. But the best part is that you can swap those layouts in realtime, so if that carousel just isn’t scratching that itch, slap a SolitaireLayout in there. BOOM! Simple as pie.
EXAMPLES
The two examples I’m showing today are below. The first is a modified version of the example RobotLegs Flickr gallery app created by Joel Hooks, and when I say modified, I mean simply swapping the layout for the datagroup holding the image item renderers and making a few changes to its mediator. The second is an app I created to demonstrate the power of the layout manager and hopefully inspire some folks to see just how far the layout manager can go. Grab the Git repositories; the first is here and the second here.
Wait a minute for the images to load. Once all of the thumbnails are visible, click on one and watch the magic happen. All of the animation and grid layout are taking place in the Layout class.
The elements on the right are being laid out by analyzing the thumbnail image on the left. Click the drop down for some more images or add your own with the input beneath. Feel free to play with the Layout properties, such as grid density and contrast threshold. Even use the scratch pad to draw out your own Layout!
SPARK LAYOUT
So the above examples extend the Spark LayoutBase class. It’s a rather simple Spark class, aside from the methods to manage scrolling and drag/dropping. To get started, you really only need to override updateDisplayList(), and if you want it to play nice with other components then measure() as well (NOTE: my image layout component does not override measure). The Flickr Gallery example actually takes the existing s:TileLayout class and just adds an animation method to it, while also tying it to a specific item renderer type (yes, I should have used an interface so that the class is not tightly coupled, but I didn’t). So if you take a look at updateDisplayList(), it’s the same as the native s:TileLayout’s.
I’m almost 100% sure anything can be improved with a little bit of qualified animation – I use “qualified” because bad animation can and will ruin all that is great. However, if used appropriately, animation has the ability to inject a tremendous amount of character into a design. I will be exploring this concept in a future blog post, so stay tuned. Anyways, I wanted to make my grid act a little crazy, which is why I added a few methods. They are below. Check out Christophe Coenraet’s tutorial on adding animation to a Layout for more info.
/* Animation Code */
private var _flipEffects:Parallel;
public var isPieces:Boolean = true;
private var _oldHorizontalGap:int;
private var _oldVerticalGap:int;
public function animateFlip(vec:Vector.<BitmapData> = null):void
{
if (_flipEffects != null && _flipEffects.isPlaying) return;
target.autoLayout = false; // Do not call updateDisplayList()
_flipEffects = new Parallel();
var numElements:int = target.numElements;
if (numElements == 0) return;
var itemRenderer:GalleryImageThumbnailItemRenderer;
var flipEffect:AnimateProperty;
var delayCount:int = 0;
var reduceCount:int = 0;
for (var i:int = 0; i < requestedColumnCount; i++)
{
for (var j:int = 0; j < requestedRowCount; j++)
{
try
{
delayCount += 5;
itemRenderer = GalleryImageThumbnailItemRenderer(target.getElementAt((i*(requestedRowCount))+j));
// This doesn't work! Boo! Neither does translating the matrix3d manually! It also kills all mouse events on the renderer, lame.
// itemRenderer.maintainProjectionCenter = true;
itemRenderer.imagePiece = vec[(i*(requestedRowCount))+j];
flipEffect = new AnimateProperty(itemRenderer);
flipEffect.startDelay = delayCount;
flipEffect.duration = 400;
flipEffect.property = "rotationX";
flipEffect.fromValue = 0;
flipEffect.toValue = 360;
flipEffect.addEventListener(TweenEvent.TWEEN_UPDATE, function f(event:TweenEvent):void
{
var item:GalleryImageThumbnailItemRenderer = GalleryImageThumbnailItemRenderer(event.target.target);
if (event.value >= 180)
{
showPieces(item, isPieces);
AnimateProperty(event.currentTarget).removeEventListener(TweenEvent.TWEEN_UPDATE, f);
}
});
flipEffect.addEventListener(TweenEvent.TWEEN_END, function f(event:TweenEvent):void
{
var item:GalleryImageThumbnailItemRenderer = GalleryImageThumbnailItemRenderer(event.target.target);
if (item.isPiece == isPieces) showPieces(item, isPieces);
});
// Flip Effects
_flipEffects.addChild(flipEffect);
_flipEffects.addEventListener(EffectEvent.EFFECT_START, function f(event:EffectEvent):void
{
if (!isPieces)
{
_oldHorizontalGap = horizontalGap;
_oldVerticalGap = verticalGap;
}
});
_flipEffects.addEventListener(EffectEvent.EFFECT_END, tweenGap);
_flipEffects.addEventListener(EffectEvent.EFFECT_END, function f():void
{
target.autoLayout = true;
});
}
catch (error:Error)
{
trace(error.message);
}
}
if (reduceCount == 0) reduceCount = delayCount * .7;
delayCount -= reduceCount;
}
_flipEffects.play();
}
protected function showPieces(item:GalleryImageThumbnailItemRenderer, bool:Boolean):void
{
bool ? item.showThumb() : item.showPiece();
}
protected function tweenGap(event:EffectEvent):void
{
var tween:GTween;
if (!isPieces)
tween = new GTween(this, .2, {horizontalGap: 0, verticalGap: 0}, {ease:Back.easeIn});
else
tween = new GTween(this, .2, {horizontalGap: _oldHorizontalGap, verticalGap: _oldVerticalGap}, {ease:Back.easeOut});
}
Essentially the above code will flip every grid item in a falloff sequence making it shimmer like a ripple. I can then call this animation method from outside the layout class. So when you click on a tile, which is an Image that needs to be loaded, I fire off animateFlip after the load is complete and replace each tile with pieces of the larger image and pull them together. This all happens in the view’s mediator, which is where the following code exists.
protected function selectImage(image:GalleryImage):void
{
galleryView.animatedLayout.isPieces = galleryView.animatedLayout.isPieces ? false : true;
if (!galleryView.animatedLayout.isPieces)
dispatch(new GallerySearchEvent(GallerySearchEvent.SEARCH_NOT_AVAILABLE));
else
dispatch(new GallerySearchEvent(GallerySearchEvent.SEARCH_AVAILABLE));
progress.alpha = 0;
PopUpManager.addPopUp(progress, galleryView, false);
PopUpManager.centerPopUp(progress);
var toY:int = progress.y;
progress.y = -progress.height;
var tween1:GTween = new GTween(progress, .5, {alpha:1}, {ease:Sine.easeOut});
var tween2:GTween = new GTween(progress, .5, {y:toY}, {ease:Back.easeOut});
var loader:Loader = new Loader();
loader.contentLoaderInfo.addEventListener(Event.COMPLETE,
function f(event:Event):void
{
selectedImageVector = divideAndConquer(Bitmap(event.target.content));
var tween1:GTween = new GTween(progress, .5, {alpha:0}, {ease:Sine.easeIn});
var tween2:GTween = new GTween(progress, .5, {y:galleryView.height}, {ease:Back.easeIn});
tween2.dispatchEvents = true;
tween2.addEventListener(Event.COMPLETE, function f():void
{
galleryView.animatedLayout.animateFlip(selectedImageVector);
});
});
loader.contentLoaderInfo.addEventListener(IOErrorEvent.IO_ERROR,
function f(event:IOErrorEvent):void
{
var tween1:GTween = new GTween(progress, .5, {alpha:0}, {ease:Sine.easeIn});
var tween2:GTween = new GTween(progress, .5, {y:galleryView.height}, {ease:Back.easeIn});
Alert.show("Error loading image!");
});
loader.load(new URLRequest(image.URL));
}
private function divideAndConquer(source:Bitmap, rectWidth:int = 30, rectHeight:int = 30):Vector.<BitmapData>
{
var columns:int = galleryView.animatedLayout.requestedColumnCount;
var rows:int = galleryView.animatedLayout.requestedRowCount;
var background:BitmapData = new BitmapData(galleryView.dgContainer.width, galleryView.dgContainer.height, false, 0x000000);
var bgActualHeight:Number = background.height - (galleryView.animatedLayout.verticalGap * galleryView.animatedLayout.requestedRowCount) + galleryView.animatedLayout.verticalGap;
var bgActualWidth:Number = background.width - (galleryView.animatedLayout.horizontalGap * galleryView.animatedLayout.requestedColumnCount) + galleryView.animatedLayout.horizontalGap;
var scaleFactor:Number = getScaleFactor(source, bgActualWidth, bgActualHeight);
var matrix:Matrix = new Matrix(scaleFactor, 0, 0, scaleFactor, (bgActualWidth/2 - (source.width*scaleFactor)/2), (bgActualHeight/2 - (source.height*scaleFactor)/2));
background.draw(source.bitmapData, matrix);
var bitmapVector:Vector.<BitmapData> = new Vector.<BitmapData>();
for (var i:int = 0; i < rows; i++)
{
for (var j:int = 0; j < columns; j++)
{
var rect:Rectangle = new Rectangle(j * rectWidth, i * rectHeight, rectWidth, rectHeight);
var bm:BitmapData = new BitmapData(rectWidth, rectHeight, false, 0x000000);
bm.copyPixels(background, rect, new Point());
bitmapVector.push(bm);
}
}
return bitmapVector;
}
<s:DataGroup
id="dgThumbnails"
dataProvider="{this.dataProvider}"
itemRenderer="org.robotlegs.demos.imagegallery.views.components.renderers.GalleryImageThumbnailItemRenderer">
<s:layout>
<layouts:GalleryTileLayout
id="animatedLayout"
verticalGap="5" horizontalGap="5" requestedColumnCount="20" requestedRowCount="10" />
</s:layout>
</s:DataGroup>
And voila, you now have an animated grid that flips as expected!
IMAGE ANALYSIS LAYOUT
My second example, the ImageLayout class, does not blatantly steal any code from the existing Spark Layout classes. It is a relatively small class, with two methods who do most of the work: updateDisplayList() and arrangeByImage(). The ImageLayout takes a bitmap as a source property and when the container’s display list is invalidated, it will chop up the bitmap source into a grid, then lay its element according to the brightness values of the grid squares. For a finer analysis, increase the grid density or reverse it for the opposite effect. Not only does it place the elements on the X and Y axis, but will place on the Z according to the brightness intensity. This creates a bumpmap-like effect, similar to what you would see in 3D rendering though much more primitive.
protected function arrangeByImage():void
{
if (!target || !_source) return;
_layoutVector = new Vector.<Vector3D>();
// scale the image to the width and height of the target to interpolate the points
var nonScaledWidth:int = _source.width / _source.scaleX;
var nonScaledHeight:int = _source.height / _source.scaleY;
_source.scaleX = target.width / nonScaledWidth;
_source.scaleY = target.height / nonScaledHeight;
var scaledSource:BitmapData = new BitmapData(_source.width, _source.height, true, 0x000000);
scaledSource.draw(_source, _source.transform.matrix);
var rectWidth:Number = _source.width / gridInterval;
var rectHeight:Number = _source.height / gridInterval;
var columnCount:Number = _source.width / rectWidth;
var rowCount:Number = _source.height / rectHeight;
var i:Number;
var j:Number;
var reqElements:int = 0;
for (i = 0; i < rowCount; i++)
{
for (j = 0; j < columnCount; j++)
{
var rect:Rectangle = new Rectangle(j * rectWidth, i * rectHeight, rectWidth, rectHeight);
var bm:BitmapData = new BitmapData(rectWidth, rectHeight, false, 0x000000);
bm.copyPixels(scaledSource, rect, new Point(0, 0));
var brightnessPercentage:Number = averageColor(bm)/0xFFFFFF;
if (brightnessPercentage * 100 >= contrastThreshold)
{
_layoutVector.push(new Vector3D(j * rectWidth, i * rectHeight, brightnessPercentage * depthFactor));
reqElements++;
}
else
{
_layoutVector.push(null);
}
bm.dispose();
}
}
requiredElements = reqElements;
dispatchEvent(new ImageLayoutElementEvent(ImageLayoutElementEvent.ELEMENT_CHANGE, requiredElements, target.numElements));
scaledSource.dispose();
}
/*
** Overrides
*/
override public function updateDisplayList(w:Number, h:Number):void
{
if (!target || !_source || _source.height + _source.width < 2) return;
super.updateDisplayList(w, h);
arrangeByImage();
var el:ILayoutElement;
var numElements:uint = target.numElements;
var matrix:Matrix3D;
var i:int = 0;
if (!_layoutVector)
{
for (i = 0; i < numElements; i++)
{
el = target.getElementAt(i);
if (!el || !el.includeInLayout) continue;
matrix = new Matrix3D();
matrix.appendTranslation(0, 0, 0);
el.setLayoutMatrix3D(matrix, false);
}
return;
}
var vec3D:Vector3D;
var elementCounter:int = 0;
for (i = 0; i < _layoutVector.length; i++)
{
try
{
el = target.getElementAt(elementCounter);
if (elementCounter >= numElements) throw RangeError("Not enough elements");
vec3D = _layoutVector[i];
if (vec3D != null)
{
if (!el || !el.includeInLayout) continue;
UIComponent(el).visible = true;
matrix = el.getLayoutMatrix3D();
if (animate)
{
var originVector:Vector.<Vector3D> = matrix.decompose();
//originVector[0] = new Vector3D(Math.random()*animationStrength, Math.random()*animationStrength, randNeg()*Math.random()*animationStrength);
originVector[0] = new Vector3D(vec3D.x, vec3D.y, randNeg()*Math.random()*animationStrength);
var tweenProxyObject:Object = {x:originVector[0].x, y:originVector[0].y, z:originVector[0].z};
matrix.recompose(originVector);
el.setLayoutMatrix3D(matrix, false);
var tween:GTween = new GTween();
tween.autoPlay = false;
tween.target = tweenProxyObject;
tween.data = {element:el, origin:originVector};
tween.setValues({x:vec3D.x, y:vec3D.y, z:vec3D.z});
tween.ease = animationEase;
tween.duration = animationDuration;
tween.onChange = function f():void
{
var el:ILayoutElement = ILayoutElement(this.data.element);
var mtx:Matrix3D = el.getLayoutMatrix3D();
Vector.<Vector3D>(this.data.origin)[0] = new Vector3D(this.target.x, this.target.y, this.target.z);
mtx.recompose(Vector.<Vector3D>(this.data.origin));
el.setLayoutMatrix3D(mtx, false);
}
tween.paused = false;
}
else
{
var newVector:Vector.<Vector3D> = matrix.decompose();
newVector[0] = vec3D;
matrix.recompose(newVector);
el.setLayoutMatrix3D(matrix, false);
}
elementCounter++;
}
else
{
UIComponent(el).visible = false;
}
}
catch (error:RangeError)
{
//trace(error.message);
}
}
// Remove any remaining elements from the grid
if (elementCounter < numElements)
{
for (var v:int = elementCounter; v < numElements; v++)
{
el = target.getElementAt(v);
UIComponent(el).visible = false;
}
}
}
<s:DataGroup
id="dgThumbnails"
dataProvider="{this.dataProvider}"
width="400"
height="400">
<s:layout>
<layouts:ImageLayout
animate="true"
animationDuration="1"
animationEase="Back.easeInOut"
animationStrength="800"
depthFactor="-15"
gridInterval="35"
contrastThreshold="40"
id="imageLayout"/>
</s:layout>
</s:DataGroup>
The animation uses Grant Skinner’s GTween library, which at a small size provides several awesome features and wicked performance. If you find the performance of the app slowing down because of the grid density (anything above 500 elements tends to kill the framerate), you can turn animation off with the checkbox control on the left, which should help when you modify the bitmap source.
Anyhow, that’s it! I hope you’ve enjoyed this tutorial and feel free to ask any questions; I will be more than happy to answer anything I can! Stay tuned for more.
Follow me on twitter here.
-
Twist
-
Seth
-
GarthDB