A gesture-driven Windows Phone to-do application Part Two - drag re-ordering

A couple of weeks ago I blogged about a todo list application which uses gestures to achieve its basic functions, a left swipe deletes an item, while a right-swipe marks it as complete. In this blog post I am adding re-ordering which is initiated via a tap-and-hold gesture and performed via a drag.

As mentioned previously, this application is heavily inspired by the iPhone application Clear, who I give full credit for coming up with such an innovative user-interface design. This blog post is for fun an education, you are strictly prohibited from using this as the basis of a 'Clear' clone for WP7.

The video below shows the application so far, with the new re-order functionality:

Initiating a Drag

In the previous blog post we saw how the Toolkit GestureListener can be used to convert the low-level manipulation events into high level gestures. In order to initiate an item drag we could use the Hold event that the GestureListener exposes. The Hold event is fired when the user taps and holds the location of their finger for a few seconds. When I published my previous blogpost one of my readers Simon (Darkside) Jackson kindly pointed out that some of the gesture events are now supported by FrameworkElement directly, Hold is one such event. For this reason I'll use FrameworkElement.Hold, the equivalent event on GestureListener should be considered deprecated.

The easiest way to allow the user to 'pick up' and drag the item is to clone it using a WriteableBitmap, hiding the original. This technique allows us to place the item at a higher Z-index than the list which it comes from, so that it will always be top-most as it is moved up and down the list.

We'll add the element that is used to render the dragged item to the XAML:

<Grid>
  <ItemsControl ItemsSource="{Binding}" x:Name="todoList">
    ... markup from previous blog post
  </ItemsControl>

  <Grid x:Name="dragImageContainer"
        VerticalAlignment="Top"
        Visibility="Collapsed">
    <!-- the image that displays the dragged item -->
    <Image x:Name="dragImage"
          VerticalAlignment="Top">
    </Image>

    <!-- lower drop shadow -->
    <Rectangle Height="10"
                VerticalAlignment="Bottom">
      <Rectangle.Fill>
        <LinearGradientBrush EndPoint="0,1" StartPoint="0,0">
          <GradientStop Color="#AA000000"/>
          <GradientStop Color="#00000000" Offset="1"/>
        </LinearGradientBrush>
      </Rectangle.Fill>
      <Rectangle.RenderTransform>
        <TranslateTransform Y="10"/>
      </Rectangle.RenderTransform>
    </Rectangle>

    <!-- upper drop shadow -->
    <Rectangle Height="10"
               VerticalAlignment="Top">
      <Rectangle.Fill>
        <LinearGradientBrush EndPoint="0,1" StartPoint="0,0">
          <GradientStop Color="#00000000"/>
          <GradientStop Color="#AA000000" Offset="1"/>
        </LinearGradientBrush>
      </Rectangle.Fill>
      <Rectangle.RenderTransform>
        <TranslateTransform Y="-10"/>
      </Rectangle.RenderTransform>
    </Rectangle>
  </Grid>
</Grid>

The markup contains an image, and a couple of Rectangle elements, each of which is filled with a subtle opacity gradient and offset in order to give the impression of a drop shadow. The Source of the image is set in the Hold event handler as shown:

private void GestureListener_Hold(object sender, Microsoft.Phone.Controls.GestureEventArgs e)
{
  _dragReOrder = true;

  // copy the dragged item to our 'dragImage'
  FrameworkElement draggedItem = sender as FrameworkElement;
  var bitmap = new WriteableBitmap(draggedItem, null);
  dragImage.Source = bitmap;
  dragImageContainer.Visibility = Visibility.Visible;
  dragImageContainer.Opacity = 1.0;\
  dragImageContainer.SetVerticalOffset(draggedItem.GetRelativePosition(todoList).Y);

  // hide the real item
  draggedItem.Opacity = 0.0;

  // fade out the list
  todoList.Animate(1.0, 0.7, FrameworkElement.OpacityProperty, 300, 0);

  _initialDragIndex = _todoItems.IndexOf(((ToDoItem)draggedItem.DataContext));
}

The code above also hides the real object and offsets our 'fake' vertically so that it occupies the same position as the original. The list is also faded slightly to give a visual indication that the dragged item is now above the rest of the list:

Dragging the item

In order to allow the user to support dragging of the item we also need to handle ManipulationDelta on the Border element, as shown below:

<ItemsControl.ItemTemplate>
  <DataTemplate>
    <Border Background="{Binding Path=Color, Converter={StaticResource ColorToBrushConverter}}"
            ManipulationDelta="Border_ManipulationDelta"
            ManipulationCompleted="Border_ManipulationCompleted"
            Hold="Border_Hold"
            Canvas.ZIndex="0">
      <!-- gestures that were added in the last blog post to support delete / complete -->
      <toolkit:GestureService.GestureListener>
        <toolkit:GestureListener
                  DragStarted="GestureListener_DragStarted"
                  DragDelta="GestureListener_DragDelta"
                  DragCompleted="GestureListener_DragCompleted"
                  GestureCompleted="GestureListener_GestureCompleted"
                  Flick="GestureListener_Flick"/>
      </toolkit:GestureService.GestureListener>

      <Grid>
        <!-- the todo item XAML from the previous blog post -->
      </Grid>
    </Border>
  </DataTemplate>
</ItemsControl.ItemTemplate>

So why am I handling these events on the Border rather than re-using the DragDelta / GestureComplete on the GestureListener? There are a couple of reasons:

  1. The GestureListener 'drag' has a tolerance (as discussed in the previous blog post). The user has to move their finger beyond a certain distance before a drag is started. In the current context we want a drag to occur as soon as the Hold event fires.
  2. When handling the element 'drag' we need to suppress the event by setting e.Handled=true, otherwise a drag will bubble up to the ScrollViewer that hosts our elements causing it to scroll. I found (through trial and error) that the GestureListener.DragDelta.Handled property doesn't appear to have any effect.

The handler for the drag event is pretty simple, moving the copy of our item by the required distance:

private void Border_ManipulationDelta(object sender, ManipulationDeltaEventArgs e)
{
  Debug.WriteLine("ManipulationDelta");

  if (!_dragReOrder)
    return;

  // set the event to handled in order to avoid scrolling the ScrollViewer
  e.Handled = true;

  // move our 'drag image'.
  dragImageContainer.SetVerticalOffset(dragImageContainer.GetVerticalOffset().Value + e.DeltaManipulation.Translation.Y);

  ShuffleItemsOnDrag();
}

Offsetting the items underneath

The ShuffleItemsOnDrag method is where the fun starts, we'll get to that shortly. First we'll take a look at a simple utility method that is used to determine the index that the item being dragged would occupy if it were dropped at the present location. This is achieved by a simple measurement:

// Determines the index that the dragged item would occupy when dropped
private int GetDragIndex()
{
  double dragLocation = dragImageContainer.GetRelativePosition(todoList).Y +
                          VerticalScrollViewer.VerticalOffset +
                          dragImage.ActualHeight / 2;
  int dragIndex = (int)(dragLocation / dragImage.ActualHeight);
  return dragIndex;
}

private ScrollViewer _scrollViewer;


// gets the scrollviewer from the ItemsControl template
private ScrollViewer VerticalScrollViewer
{
  get
  {
    if (_scrollViewer == null)
    {
      _scrollViewer = todoList.Descendants<ScrollViewer>()
                              .Cast<ScrollViewer>()
                              .Single();
    }
    return _scrollViewer;
  }
}

The above code needs to take the current scroll location into consideration, which is why the ScrollViewer property above uses Linq-to-VisualTree to find the ScrollViewer that the ItemsControl generates to hosts our elements.

ShuffleItemsOnDrag is where the fun begins, we want to create an effect where the dragged item 'pushes' the other items out of the way as it hovers over them, giving the impression that the list is re-ordering as we drag.

The method below iterates over all of the items in the list to determine whether they need to be offset. An item needs to be offset if it is between the current dragged item index and the items original location.

private void ShuffleItemsOnDrag()
{
  // find its current index
  int dragIndex = GetDragIndex();

  // iterate over the items in the list and offset as required
  double offset = dragImage.ActualHeight;
  for (int i = 0; i < _todoItems.Count; i++)
  {
    FrameworkElement item = todoList.ItemContainerGenerator.ContainerFromIndex(i) as FrameworkElement;

    // determine which direction to offset this item by
    if (i <= dragIndex && i > _initialDragIndex)
    {
      OffsetItem(-offset, item);
    }
    else if (i >= dragIndex && i < _initialDragIndex)
    {
      OffsetItem(offset, item);
    }
    else
    {
      OffsetItem(0, item);
    }
  }
}

The OffsetItem method performs the actual offset by animating the Y position of each item. The target location is stored in the elements Tag property so that we don't repeatedly fire the same animation on an element.

private void OffsetItem(double offset, FrameworkElement item)
{
  double targetLocation = item.Tag != null ? (double)item.Tag : 0;
  if (targetLocation != offset)
  {
    var trans = item.GetVerticalOffset().Transform;
    trans.Animate(null, offset, TranslateTransform.YProperty, 500, 0);
    item.Tag = offset;
    _moveSound.Play();
  }
}

Completing the drag

When the user stops dragging the item, the ManipulationCompleted event is fired. Here we perform a number of tasks:

  1. Fade the list back to full opacity
  2. Animate the dragged item so that it 'snaps' into location
  3. When the above is complete, we need to re-order the underlying collection of model items, then re-populate the ObservableCollection exposed to the view. This causes all the items to be re-rendered, removing all of the TranslateTransforms that have been applied.
  4. Finally, remove the image which is our copy of the dragged item.

This sounds like a lot of work, but our Animate utility method makes it quite simple:

private void Border_ManipulationCompleted(object sender, ManipulationCompletedEventArgs e)
{
  if (!_dragReOrder)
    return;

  _dragReOrder = false;
  _autoScrollTimer.Stop();

  int dragIndex = GetDragIndex();

  // fade in the list
  todoList.Animate(null, 1.0, FrameworkElement.OpacityProperty, 200, 0);

  // animated the dragged item into location
  double targetLocation = dragIndex * dragImage.ActualHeight - VerticalScrollViewer.VerticalOffset;
  var trans = dragImageContainer.GetVerticalOffset().Transform;
  trans.Animate(null, targetLocation, TranslateTransform.YProperty, 200, 0, null,
    () =>
    {
      // clone the list and move the dragged item
      var items = _todoItems.ToList();
      var draggedItem = items[_initialDragIndex];
      items.Remove(draggedItem);
      items.Insert(dragIndex, draggedItem);

      // re-populate our ObservableCollection
      _todoItems.Clear();
      _todoItems.AddRange(items);
      UpdateToDoColors();

      // fade out the dragged image and collapse on completion
      dragImageContainer.Animate(null, 0.0, FrameworkElement.OpacityProperty, 1000, 0, null, ()
        => dragImageContainer.Visibility = Visibility.Collapsed);
    });

}

Scrolling the list

The current implementation only allows the user to drag the item within the bounds of the current screen. What if the list is larger than the screen and the users want to drag right from the bottom to the top?

A common solution to this problem is to auto-scroll the list if the item is dragged near to the top. The following method is invoked periodically by a timer to see whether the item has been dragged within the top or bottom 'scroll zones'. The velocity of the scroll is proportional to just how far within these zones the item has been dragged. Scrolling is simply a matter of setting the scroll location on the ScrollViewer we located earlier:

// checks the current location of the item being dragged, and scrolls if it is
// close to the top or the bottom
private void AutoScrollList()
{
  // where is the dragged item relative to the list bounds?
  double draglocation = dragImage.GetRelativePosition(todoList).Y + dragImage.ActualHeight / 2;

  if (draglocation < AutoScrollHitRegionSize)
  {
    // if close to the top, scroll up
    double velocity = (AutoScrollHitRegionSize - draglocation);
    VerticalScrollViewer.ScrollToVerticalOffset(VerticalScrollViewer.VerticalOffset - velocity);
  }
  else if (draglocation > todoList.ActualHeight - AutoScrollHitRegionSize)
  {
    // if close to the bottom, scroll down
    double velocity = (AutoScrollHitRegionSize - (todoList.ActualHeight - draglocation));
    VerticalScrollViewer.ScrollToVerticalOffset(VerticalScrollViewer.VerticalOffset + velocity);
  }
}

You can see the scroll zones illustrated below:

And finally, we are all done! With our todo application you can use flick and drag gestures to mark items as complete or delete them, and now use hold and drag to re-order. I think it's about time I made it so that you can add or edit items. We'll get to that next time!

You can download the sourcecode here: ClearStyle.zip

Regards, Colin E.

blog comments powered by Disqus