Silverlight 2 Samples: Dragging, docking, expanding panels (Part 2)

UPDATE: Get the latest Dragging, docking, expanding panel code from Blacklight, our new CodePlex project!  

In Part 1, we looked at how we construct a Dragging, docking, expanding panel, and added the ‘dragging’ functionality by placing the panel in a Canvas. In this part, we are going to look at how we do the docking element.

Take a peek at the finished sample running, here.

The positioning and docking logic takes place in a host control, called DragDockPanelHost. This is a panel control, that derives Canvas, that positions the panels in a grid and then moves them around when the user is dragging DragDockPanel.

[But first, a small digression...]

Why derive Canvas and not Panel I hear you cry??? Dave Relyea posted a while back on why he doesn’t like Canvas. Whilst he makes some good points, I feel he missed a trick when it comes to considering Canvas for the type of layout we do here.

Deriving Panel gives you 2 methods - Measure and Arrange - tell the layout system how big you want to be, then layout your children. Simple for when you want your children to go to a specific place every time the layout updates. However, with our layout system, we are more complicated. We have panels that can be dragged, shuffled around, maximised etc. etc.

To do all of this by deriving Panel seemed difficult and cumbersome, however, when starting with Canvas, you have a superbly basic layout system, using absolute positioning and giving you animatable positioning properties. I didn’t need to worry about layout updated events at all, but just consume the custom events my panels raised.

Simple. I like Canvas for this kind of purpose. Please challenge me if you feel differently!

[Digression ends.]

OK. So first off, we create out class and derive from Canvas...

    public class DragDockPanelHost : Canvas

    {

        #region Private members

        // A local store of the number of rows

        private int rows = 1;

        // A local store of the number of columns

        private int columns = 1;

        // The panel currently being dragged

        private DragDockPanel draggingPanel = null;

        #endregion

        #region Constructors

        /// <summary>

        /// Constructor

        /// </summary>

        public DragDockPanelHost()

        {

            this.Loaded +=

                new RoutedEventHandler(DragDockPanelHost_Loaded);

            this.SizeChanged +=

                new SizeChangedEventHandler(DragDockPanelHost_SizeChanged);

        }

        #endregion

    }

We also have some private variables that store the number of rows and columns (so we don’t have to recalculate every time) as well as the currently dragging panel. In the constructor, we hook up the Loaded events and SizeChanged events.

When we get the Loaded event, we do the work to calculate how may rows and columns we require (based on how many children the panel has), place each panel in a grid and row, and hook up some events from the panel.

(NOTE - we only do this in the Loaded event, meaning panels added later won’t be ‘hooked up’. Please check out the limitations section at the end of the post for more details on this.)

Firstly, we work out the number of rows. When placing the panels in a grid like layout, we try and lay them out in a square, giving preference to width over height (as most resolutions are 4:3 or wider). Let me give you an example...

If we had 6 panels, we would have 2 rows with 3 panel in each row, rather than 3 rows with 2 panels in each. If we had 8 panels, we would have 2 rows with 4 panels in each. If we had 9 panels, we would have 3 rows, with 3 panels in each.

To work out the rows, we take the square root of the number of children, and round it down.

Once we know how many rows we have, we can work out how many columns are required...

    // Calculate the number of rows and columns required

    this.rows =

        (int)Math.Floor(Math.Sqrt((double)this.Children.Count));

    this.columns =

        (int)Math.Ceiling((double)this.Children.Count / (double)rows);

We then loop through the rows and columns, assigning each panel a row and column, and hooking up the panels events...

    int child = 0;

    // Loop through the rows and columns and assign to children

    for (int r = 0; r < this.rows; r++)

    {

        for (int c = 0; c < this.columns; c++)

        {

            DragDockPanel panel = this.Children[child] as DragDockPanel;

            // Set starting row and column

            Grid.SetRow(panel, r);

            Grid.SetColumn(panel, c);

            // Hook up panel events

            panel.DragStarted +=

                new DragEventHander(dragDockPanel_DragStarted);

            panel.DragFinished +=

                new DragEventHander(dragDockPanel_DragFinished);

            panel.DragMoved +=

                new DragEventHander(dragDockPanel_DragMoved);

            child++;

            // if we are on the last child, break out of the loop

            if (child == this.Children.Count)

                break;

        }

        // if we are on the last child, break out of the loop

        if (child == this.Children.Count)

            break;

    }

You will see that we actually use the Grid.Row and Grid.Column attached properties to record the row and column, even though the panels aren’t actually in a Grid control. We couldn’t think of a reason why this would be bad!

There are 3 layout methods in DragDockPanelHost - UpdatePanelLayout, AnimatePanelSizes, AnimatePanelLayout. The first sets the child panels size and positions without animation, the other two animate the sizes and positions respectively, using AnimateSize and AnimatePosition (from the AnimatedContentControl base class).

Let’s look at UpdatePanelLayout...

    private void UpdatePanelLayout()

    {

        // Layout children as per rows and columns

        foreach (UIElement child in this.Children)

        {

            DragDockPanel panel = (DragDockPanel)child;

            Canvas.SetLeft(

                panel,

                (Grid.GetColumn(panel) *

                    (this.ActualWidth / (double)this.columns))

                );

           

            Canvas.SetTop(

                panel,

                (Grid.GetRow(panel) *

     (this.ActualHeight / (double)this.rows))

                );

            panel.Width =

                (this.ActualWidth / (double)this.columns) -

                panel.Margin.Left - panel.Margin.Right;

           

            panel.Height =

                (this.ActualHeight / (double)this.rows) -

                panel.Margin.Top - panel.Margin.Bottom;

        }

    }

In this method, we loop through the children in the host, setting the position and size of each panel. To set the position, we get the column and row the panel sits in, and multiply by the size of the host over the number of columns and rows.

For the size, we set the width and height to be the size of the host over the number of columns or rows and subtracting any margin the panel has.

The UpdatePanelLayout method is called every time the host changes size...

    void DragDockPanelHost_SizeChanged(

        object sender, SizeChangedEventArgs e)

    {

        this.UpdatePanelLayout();

    }

So, we now have our grid layout, and as the host resizes, the panels will stay in position.

Next, let’s deal with the dragging / docking.

In the loaded event, we hooked up 3 events for each of the panels - DragStarted, DragFinished and DragMoved. These are the 3 events that tell the host when a panel is being dragged about and when it has been dropped.

The handlers for DragStarted and DragFinished are very simple...

    void dragDockPanel_DragStarted(object sender, DragEventArgs args)

    {

        DragDockPanel panel = sender as DragDockPanel;

        // Keep reference to dragging panel

        this.draggingPanel = panel;

    }

In DragStarted we just keep a reference to the panel that is being dragged.

    void dragDockPanel_DragFinished(object sender, DragEventArgs args)

    {

        // Set dragging panel back to null

        this.draggingPanel = null;

        // Update the layout (to reset all panel positions)

        this.UpdatePanelLayout();

    }

In DragFinished, we clear the reference the dragging panel and call UpdatePanelLayout to reset all of the panels to their current position and size.

The smart stuff happens in the DragMoved event. This handler is called every time the mouse moves when a panel is being dragged. It works out the position of the mouse (which row and column it’s in), whether there is a panel in that row and column (that is not the panel being dragged), and if so, slides that panel into the available space. Lets look at the handler...

    void dragDockPanel_DragMoved(object sender, DragEventArgs args)

    {

        ...

    }

First thing worth noting is the argument type - DragEventArgs. These event arguments contain the source mouse event arguments for getting the position. We use these arguments to work out which row and column we are in (using the same logic we used to position the panels in UpdatePanelLayout)...

    Point mousePosInHost =

        args.MouseEventArgs.GetPosition(this);

   

    int currentRow =

        (int)Math.Floor(mousePosInHost.Y /

        (this.ActualHeight / (double)this.rows));

    int currentColumn =

        (int)Math.Floor(mousePosInHost.X /

        (this.ActualWidth / (double)this.columns));

Once we know what column and row the mouse is in, we can loop through the children and work out which panel is in that row and column. If it’s not the panel being dragged, we store it.

    // Stores the panel we will swap with

    DragDockPanel swapPanel = null;

    // Loop through children to see if there is a panel to swap with

    foreach (UIElement child in this.Children)

    {

        DragDockPanel panel = child as DragDockPanel;

        // If the panel is not the dragging panel and is in the current row

        // or current column... mark it as the panel to swap with

        if (panel != this.draggingPanel &&

            Grid.GetColumn(panel) == currentColumn &&

            Grid.GetRow(panel) == currentRow)

        {

            swapPanel = panel;

            break;

        }

    }

Finally, if we found a panel to swap with, we swap the row and column for it with the dragging panel’s row and column and animate the all the panels to their new positions...

    // If there is a panel to swap with

    if (swapPanel != null)

    {

        // Store the new row and column

        int draggingPanelNewColumn = Grid.GetColumn(swapPanel);

        int draggingPanelNewRow = Grid.GetRow(swapPanel);

        // Update the swapping panel row and column

        Grid.SetColumn(swapPanel, Grid.GetColumn(this.draggingPanel));

        Grid.SetRow(swapPanel, Grid.GetRow(this.draggingPanel));

        // Update the dragging panel row and column

        Grid.SetColumn(this.draggingPanel, draggingPanelNewColumn);

        Grid.SetRow(this.draggingPanel, draggingPanelNewRow);

      // Animate the layout to the new positions

        this.AnimatePanelLayout();

    }

The AnimatePanelLayout method is almost the same as UpdatePanelLayout, only, it just updates the panels positions, and uses AnimatePosition rather than setting directly...

    private void AnimatePanelLayout()

    {

        // Loop through children and size to row and columns

        foreach (UIElement child in this.Children)

        {

            DragDockPanel panel = (DragDockPanel)child;

            if (panel != this.draggingPanel)

            {

                panel.AnimatePosition(

                    (Grid.GetColumn(panel) *

                    (this.ActualWidth / (double)this.columns)),

                    (Grid.GetRow(panel)

                    * (this.ActualHeight / (double)this.rows))

                    );

            }

        }

    }

And there we have it - dragging, docking panels. We are now ready to use this on our page. In part one, where we previously had 6 panels in a canvas, we can replace the canvas with our DragDockPanelHost...

  <local:DragDockPanelHost Margin="50">

    <local:DragDockPanel Margin="10">

      <MediaElement Source="..." />

    </local:DragDockPanel>

    <local:DragDockPanel Margin="10">

      <MediaElement Source="..." />

    </local:DragDockPanel>

    <local:DragDockPanel Margin="10">

      <MediaElement Source="..." />

    </local:DragDockPanel>

    <local:DragDockPanel Margin="10" >

      <MediaElement Source="..." />

    </local:DragDockPanel>

    <local:DragDockPanel Margin="10">

      <MediaElement Source="..." />

    </local:DragDockPanel>

    <local:DragDockPanel Margin="10" >

      <MediaElement Source="..." />

    </local:DragDockPanel>

   

  </local:DragDockPanelHost>

The result should look like this...

Experiment with resizing your browser, dragging the panels around, and, adding more panels to the UI!

I mentioned earlier on that there is a limitation with this implementation of DragDockPanelHost... we can’t add new panels dynamically at run-time. This could be worked around by creating an Add method that will place the child in the visual tree, add an addition row / column is required, hook up the events and update the layout, however, if a consumer of the control attempted to add a child using the panels Children.Add(...) then this would go ignored. I had thought about designing this control as an ItemsControl, but wanted to keep this example simple, and focus on the layout, however, and ItemsControl would be a good solution, allowing you to be notified when a child is added the collection.

I would be more than interested to hear people’s ideas about how this could be done cleanly!

I hope you have enjoyed this post - watch out for Part 3 which will deal with Maximising panels and creating new templates J

As always, source code is at www.codeplex.com/blacklight.

Martin