Every Sunday, my wife and I head down to Starbucks with the Sunday paper and a red felt-tip pen, and tackle the New York Times crossword puzzle (or, if we’re particularly sleep-deprived because of our baby boy, the Seattle Times crossword – yes, I feel guilty every time we “wimp out”). Each time I open the paper, I’m hoping to see something different in the puzzle; mysterious circles in the squares, an odd-shaped puzzle, and so on. The best puzzle yet was the one that had tiny comic panels as clues; I don’t remember what they ended up meaning, but I do remember being excited about it.
When I see this – something different – I love it because it means we’re in for an Aha moment. Why are those circles there? What do those comics mean? The reason why I like the NYT Sunday puzzle so much is even if they don’t have some visual trick to them, discovering the common theme almost always elicits an “ah, cool.” It’s like humor in a way – the funniest stuff is that which ‘tricks’ you; looking at something for a long time and then seeing something that wasn’t there a moment ago (and yet, was there) triggers something pleasurable in the brain.
Yes, I’m going somewhere with this. Be patient…
Programming is just like crossword puzzles. It’s a mental exercise that lets you set attainable, quantifiable goals for yourself, and receive pleasure when you achieve those goals. And the Aha moment is the greatest part of it. It doesn’t matter if you’re working through a new algorithm or tracking down bugs and memory leaks. When you “get it”, you inevitably smile or let out a sigh of relief. Aha. It gives you that little mental-adrenaline boost that keeps you coding, even if it’s well past midnight.
Dan Crevier wrote a couple of blog entries (part 1, part 2) last October that talk about how to create a smoothly animating layout panel akin to what we’ve got in Project Max. Basically, when the window is resized, his TilePanel class smoothly animates its childrens’ movement so that they layout in a grid pattern (it’s hard to describe – you can download Max to see what I’m talking about).
The blog entries are very well written, but as I looked over the code, I kept wondering how it could work if the user resized the window while objects were currently smoothly re-laying themselves out. Offhand, it seemed like it’d require some pretty hairy tracking, but he didn’t seem to have any variables for that purpose.
It took about five minutes of thinking about the code before I got my ‘Aha’ moment; he’s found a way to let Avalon worry about all of the animation. All he’s doing is tracking where the child object should be, and he’s letting Avalon worry about (a) where the child object currently is, and (b) how to get from there to where it should be. This is done through the RenderTransform, which deals with the hairy tracking itself.
This was a twin Aha moment – not only did I suddenly understand how his code worked, but the sheer simplicity of it provided another twinge of coooool. He was able to offload the gross/fragile stuff to Avalon and just focus on where the child objects should be.
Why mention this? Because that particular Aha moment, like so many others, made me wonder what new doors my new found knowledge had opened. How hard, for example, would it be to add drag/drop to his TilePanel, and get a smoothly animating TilePanel that supported child reordering? And that question provided the impetus for me to create this blog.
So; finally, some code.
The first block of posts here will focus on adding Drag/Drop. I’ll be deriving a DragDropTilePanel from Dan’s TilePanel, with an admittedly fairly arbitrary goal of keeping the changes to the base TilePanel to a minimum.
The first (and pretty much only) change necessary in the base TilePanel class is a conceptual change. Because his TilePanel doesn’t support adding or removing children, the ArrangeOverride method is able to just worry about “Ignoring animation, where should the child object be right now?” He does this by using the child’s index in the list and calculating it’s grid (row/column) position.
Now though, those indices can change when children are added or removed, and this requires the TilePanel to now track two things:
(1) Where should the child object be right now, and
(2) Where should the child object have been the last time we moved it around?
By tracking both of these variables, we can smoothly animate adding/removing child objects, and notice that we still aren’t worrying about “Where is the child object right now” – Avalon continues to do that through the child's RenderTransform.
So how does the code change? The answer is very minimally. Here is Dan’s original ArrangeOverride, with changes marked in bold:
protected override Size ArrangeOverride(Size finalSize)
{
// Calculate how many children fit on each row
int childrenPerRow = Math.Max(1, (int)Math.Floor(finalSize.Width / this.ChildSize));
for (int i = 0; i < this.Children.Count; i++)
FrameworkElement child = this.Children[i] as FrameworkElement;
// Figure out where the child goes
Point newOffset = CalcChildOffset(i, childrenPerRow, this.ChildSize);
if (_oldChildrenPerRow != -1)
// Figure out where the child is now
// If the child's index has changed, it's because something has been added or
// removed. We need to use the child's old index to determine its old offset.
// The index is stored (for simplicity) in the child's DataContext.
Point oldOffset = CalcChildOffset((int)child.DataContext, _oldChildrenPerRow, _oldChildSize);
if (child.RenderTransform != null)
oldOffset = child.RenderTransform.Transform(oldOffset);
}
// Transform the child from the new location back to the old position
TranslateTransform childTransform = new TranslateTransform();
child.RenderTransform = childTransform;
// Decay the transformation with an animation
childTransform.BeginAnimation(TranslateTransform.XProperty, MakeAnimation(oldOffset.X - newOffset.X));
childTransform.BeginAnimation(TranslateTransform.YProperty, MakeAnimation(oldOffset.Y - newOffset.Y));
// Store the child's new index in the list. This'll normally
// be the same, unless an item has been added or removed from the list.
child.DataContext = i;
// Position the child and set its size
child.Arrange(new Rect(newOffset, new Size(this.ChildSize, this.ChildSize)));
_oldChildrenPerRow = childrenPerRow;
_oldChildSize = this.ChildSize;
return finalSize;
Ignoring comments, we only changed a few lines of code and we’ve now got the ability to smoothly add and remove child objects. This works by tracking the child object’s previous index (stored for convenience in child.DataContext), which gives us where the child should have been, and the child object’s current index (which is obtained simply by enumerating the children), which gives us where the child should currently be. Again, Avalon uses the child's RenderTransform to handle where the child currently is. After we’re done setting the child’s position, we store the child’s new index back in child.DataContext.
Storing the “previous index” in DataContext isn’t a very real-world thing to do – normally the children are objects which use DataContext for their own purposes. I’m using it here because I want to minimize changes to the base TilePanel code - plus, it’s kind of cool to see that this all works with basic Avalon elements, not just custom “drag-enabled” objects. Heck, try replacing one of the <Border>s with:
<TextBox Text="Test" />
And it’ll smoothly relayout just like any other child. Pretty cool!
In order to test this, we can easily add the ability to remove a child object by clicking on it. Add the following to the TilePanel class…
protected override void OnInitialized(EventArgs e)
// If the user clicks on a child object, we want to remove it.
foreach (FrameworkElement child in Children)
child.MouseLeftButtonDown += new MouseButtonEventHandler(ChildPressed);
void ChildPressed(object sender, MouseButtonEventArgs eventArgs)
Children.Remove((UIElement)sender);
…and you can now click on child objects to remove them . Try changing the animation time (the “500” in MakeAnimation) to 2000 or so, and try mixing removing child objects and resizing the border – it all works.
(Note that, in what may be a bug in Avalon, you won’t get subsequent mouse down events if you click the mouse button on an object that Avalon has moved “under” the cursor – you’ll need to move the mouse to get the event).
Alright, the babble/code ratio was dangerously off-kilter in this post – that shouldn’t be the norm moving forward. Next up, dragging and dropping!
I’ve attached a link to the project file. Note that this works with the Dec CTP. YMMV with other versions.
</Jeff>