A Floating Panel Control for UWP

Windows Apps need to run in an increasing number of window sizes and resolutions and fortunately the Windows 10 ecosystem helps us with things like the RelativePanel and Visual State Triggers. But not all positioning issues can or should be solved by responsive layout techniques. Sometimes it’s just better to let the user decide where a control should be positioned. In a UI that shows a diagram –for example- it’s hard to predict where the bars or lines or shapes will appear. For that same diagram it’s also hard to predict the size of the legend: it depends on the number of series and the length of their names. In this case it would be appropriate to let the user drag the legend panel to a ‘free’ area in the diagram.

In this article we’ll build a XAML Control for the Universal Platform that can be dragged around the screen. Its position can optionally be restrained to a parent control’s rectangle or to the window itself. The control is decorated with an icon to indicate its ‘draggability’ behavior to the user.

Here’s how to use it:

<controls:FloatingContent Boundary="Parent">
    <!-- Any Content Here ... -->
</controls:FloatingContent>

As usual, we built a control together with a sample app. This app contains some instances of the so-called FloatingContent that are differently configured:

  • Joy is unbound, she can be dragged off the screen, 
  • Disgust is bound to the Window,
  • Sadness is bound to [the root control of] the Page (as you will see, this makes a subtle difference), 
  • Anger is bound to the red Border and doesn’t like it, and
  • Fear is also bound to the red Border, but he has a Margin.

Here’s how that app looks like:

FloatingContentControl

Style

The control is yet another templated control. Here’s the ControlTemplate:

<ControlTemplate TargetType="local:FloatingContent">
    <!-- This Canvas never covers other controls -->
    <Canvas Background="Transparent"
            Height="0"
            Width="0"
            VerticalAlignment="Top"
            HorizontalAlignment="Left">
        <!-- This Border handles the dragging -->
        <Border x:Name="PART_Border"
                ManipulationMode="TranslateX, TranslateY, TranslateInertia">
            <Grid>
                <!-- Content -->
                <ContentPresenter />
                <!-- Overlay -->
                <!-- Anything with an Opacity will do here... -->
                <Path x:Name="PART_Overlay"
                        Data="...(long Path Data omitted)..."
                        Stretch="Uniform"
                        Opacity="0"
                        Fill="White"
                        Width="24"
                        Height="24"
                        Margin="8"
                        VerticalAlignment="Top"
                        HorizontalAlignment="Left" />
            </Grid>
        </Border>
    </Canvas>
</ControlTemplate>

The Border control inside the template handles the dragging: through its ManipulationMode it is set to react to  combined horizontal and vertical manipulation with inertia. That border is wrapped in a zero-height, zero-width Canvas. That’s because the Border’s position is changed by updating its Canvas.Top and Canvas.Left attached properties. Inside the Border there’s the ContentPresenter that … presents the content. On top of the content there’s an overlay that displays a ‘draggability’ icon.

Class Definition

The class is decorated with TemplatePart attributes for the Border and the Overlay, since we use these in the code. [Note to template designers: the code also heavily relies on the outer Canvas in the template, so don’t drop that.]

In most cases we let a custom control inherit from Control, but this time ContentControl is a better parent:

/// <summary>
/// A Content Control that can be dragged around.
/// </summary>
[TemplatePart(Name = BorderPartName, Type = typeof(Border))]
[TemplatePart(Name = OverlayPartName, Type = typeof(UIElement))]
public class FloatingContent : ContentControl
{
    private const string BorderPartName = "PART_Border";
    private const string OverlayPartName = "PART_Overlay";

    private Border border;
    private UIElement overlay;

The FloatingContent control has just one dependency propertyBoundary-, which is a value from the FloatingBoundary enumeration. Here are the relevant code snippets:

public enum FloatingBoundary
{
    None,
    Parent,
    Window
}
public static readonly DependencyProperty BoundaryProperty =
    DependencyProperty.Register(
        "Boundary",
        typeof(FloatingBoundary),
        typeof(FloatingContent),
        new PropertyMetadata(FloatingBoundary.None));

public FloatingBoundary Boundary
{
    get { return (FloatingBoundary)GetValue(BoundaryProperty); }
    set { SetValue(BoundaryProperty, value); }
}

During the initialization of the control’s look and feel in the OnApplyTemplate method

  • we first lookup the Border in the template, and throw an Exception if it’s not found (after all: there’s no floating behavior without that border).
  • Then we register an event handler for ManipulationDelta which will manage the movement of the control when it is dragged around.
  • Last but not least, we make sure that any initial positioning of the control is maintained, and
  • in order to keep the boundary hit detection algorithm simple, we replace the control’s Margin by its Border’s Padding:
protected override void OnApplyTemplate()
{
    // Border
    this.border = this.GetTemplateChild(BorderPartName) as Border;
    if (this.border != null)
    {
        this.border.ManipulationDelta += Border_ManipulationDelta;

        // Move Canvas properties from control to border.
        Canvas.SetLeft(this.border, Canvas.GetLeft(this));
        Canvas.SetLeft(this, 0);
        Canvas.SetTop(this.border, Canvas.GetTop(this));
        Canvas.SetTop(this, 0);

        // Move Margin to border.
        this.border.Padding = this.Margin;
        this.Margin = new Thickness(0);
    }
    else
    {
        // Exception
        throw new Exception("Floating Control Style has no Border.");
    }

    this.Loaded += Floating_Loaded;
}

When the control is loaded, we register an event handler to the SizeChanged of its parent. When that parent is resized, we may need to update the FloatingControl’s position:

private void Floating_Loaded(object sender, RoutedEventArgs e)
{
    FrameworkElement el = GetClosestParentWithSize(this);
    if (el == null)
    {
        return;
    }

    el.SizeChanged += Floating_SizeChanged;
}

We have to pick the right parent for this: by crawling up the Visual Tree we look for the closest control with an actual size, since that is the one that will decently respond to SizeChanged

/// <summary>
/// Gets the closest parent with a real size.
/// </summary>
private FrameworkElement GetClosestParentWithSize(FrameworkElement element)
{
    while (element != null && 
	(element.ActualHeight == 0 || element.ActualWidth == 0))
    {
        // Crawl up the Visual Tree.
        element = element.Parent as FrameworkElement;
    }

    return element;
}

Runtime behavior

Dragging the control

The most important feature of the FloatingContent control, is following the pointer – finger, pen or mouse pointer, or other. This movement is triggered through the ManipulationDelta event handler. It calculates the theoretical –unbound- position of the control as a rectangle with top, left, width and height. It then calls the AdjustCanvasPosition() routine that will effectively update the control’s position:

private void Border_ManipulationDelta(object sender, 
	ManipulationDeltaRoutedEventArgs e)
{
    var left = Canvas.GetLeft(this.border) + e.Delta.Translation.X;
    var top = Canvas.GetTop(this.border) + e.Delta.Translation.Y;

    Rect rect = new Rect(
	left, 
	top, 
	this.border.ActualWidth, 
	this.border.ActualHeight);
    var moved = AdjustCanvasPosition(rect);

    // Not intuitive:
    //if (!moved)
    //{
    //    // We hit the boundary. Stop the inertia.
    //    e.Complete();
    //}
}

The move-the-control code returns a Boolean to let the caller know whether or not the manipulation did actually move the control. He may want to react on this. The commented code in the previous snippet implements some kind of auto-docking feature that stops all movement when a boundary is hit.

As already mentioned, the floating control is moved by updating its Canvas.Top and Canvas.Left attached properties and is constrained by its Boundary type:

  • When Boundary is None, no checks are done: the theoretical position becomes the actual position.
  • When Boundary is Parent, we’ll look up the closest parent with an actual size in the Visual Tree, and apply a collision detection algorithm.
  • When the Boundary is Window, we look up the relative position of the control in the app’s Window through TransformToVisual and Window.Current, and then adjust when a collision was detected.

Here’s the whole routine, except for the collision detection calculation which is fairly obvious:

/// <summary>
/// Adjusts the canvas position according to the Boundary property.
/// </summary>
/// <returns>True if there was a move, otherwise False.</returns>
private bool AdjustCanvasPosition(Rect rect)
{
    // Free floating.
    if (this.Boundary == FloatingBoundary.None)
    {
        Canvas.SetLeft(this.border, rect.Left);
        Canvas.SetTop(this.border, rect.Top);

        return true;
    }

    FrameworkElement el = GetClosestParentWithSize(this);

    // No parent
    if (el == null)
    {
        // We probably never get here.
        return false;
    }

    var position = new Point(rect.Left, rect.Top); ;

    if (this.Boundary == FloatingBoundary.Parent)
    {
        Rect parentRect = new Rect(0, 0, el.ActualWidth, el.ActualHeight);
        position = AdjustedPosition(rect, parentRect);
    }

    if (this.Boundary == FloatingBoundary.Window)
    {
        var ttv = el.TransformToVisual(Window.Current.Content);
        var topLeft = ttv.TransformPoint(new Point(0, 0));
        Rect parentRect = new Rect(topLeft.X, topLeft.Y, 
	Window.Current.Bounds.Width - topLeft.X, 
	Window.Current.Bounds.Height - topLeft.Y);
        position = AdjustedPosition(rect, parentRect);
    }

    // Set new position
    Canvas.SetLeft(this.border, position.X);
    Canvas.SetTop(this.border, position.Y);

    return position == new Point(rect.Left, rect.Top);
}

Note: when the Boundary is set to Window and you’re using a SplitView on the page, then the FloatingContent will remain on the window as expected, but it can be dragged under the Splitview’s Pane. That’s what we did with Disgust in the screenshots. If you don’t want that behavior, then use Parent for Boundary and place the control in the page’s root – that’s how we configured Sadness.

Showing the Draggability Indicator

When the user hovers over the control, when he taps on it, and when he’s dragging the control around, the FloatingContent displays a ‘dragging indicator’ in its upper left corner. It’s the white icon in Anger -not the red arrow- in the following screenshot:

FloatingContentControlOverlay

That so-called Overlay is defined in the control’s style, with a zero Opacity. We let the icon appear and disappear by animating this Opacity. That’s why we define it as UIElement instead of the Path in the style definition: the designer of a custom template can choose any element that comes with Opacity.

The overlay

All event handlers are registered in the OnApplyTemplate:

protected override void OnApplyTemplate()
{
    // Border
    this.border = this.GetTemplateChild(BorderPartName) as Border;
    if (this.border != null)
    {
        this.border.ManipulationStarted += Border_ManipulationStarted;
        this.border.ManipulationCompleted += Border_ManipulationCompleted;
        this.border.Tapped += Border_Tapped;
        this.border.PointerEntered += Border_PointerEntered;
    }
    else
    {
        // Exception
        throw new Exception("Floating Control Style has no Border.");
    }

    // Overlay
    this.overlay = GetTemplateChild(OverlayPartName) as UIElement;
}

The dragging icon appears and disappears smoothly. We defined a StoryBoard with a DoubleAnimation on Opacity. Here’s the code for one of the event handlers:

private void Border_ManipulationStarted(object sender, 
	ManipulationStartedRoutedEventArgs e)
{
    if (this.overlay != null)
    {
        var ani = new DoubleAnimation()
        {
            From = 0.0,
            To = 1.0,
            Duration = new Duration(TimeSpan.FromSeconds(1.5))
        };
        var storyBoard = new Storyboard();
        storyBoard.Children.Add(ani);
        Storyboard.SetTarget(ani, overlay);
        ani.SetValue(Storyboard.TargetPropertyProperty, "Opacity");
        storyBoard.Begin();
    }
}

The other event handlers are similar. The flash effect is the same as the fade-in, but with AutoReverse to True.

It’s Universal

Here’s how the control looks like on Windows 10 Mobile. In the right screenshot, Sadness is showing the dragging overlay. Also observe that when the Boundary is set to Screen –look at Disgust– the control can be dragged under the phone’s StatusBar. Again, if you don’t want this to happen, configure the control like Sadness and use the page’s root as boundary:

FloatingContentControlPhone FloatingContentControlPhoneOverlay

Here’s how the sample app looks like on the Raspberry Pi:

FloatingContentControlRaspberryPi

[Yep: in my house, the smallest computer gets the biggest screen.]

The code

The control and its sample app live here on GitHub. The FloatingContent control has its own project.

Enjoy!

Advertisements

5 thoughts on “A Floating Panel Control for UWP

  1. predalpha@hotmail.fr

    Thank you ! Awesome work ! Really gives the basics and more when we start from scratch about dragging elements inside a canvas.

    Like

    Reply

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s