// (c) Copyright 2010 Microsoft Corporation. // This source is subject to the Microsoft Public License (MS-PL). // Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. // All other rights reserved. // // Author: Jason Ginchereau - jasongin@microsoft.com - http://blogs.msdn.com/jasongin/ // using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Media.Imaging; using Microsoft.Phone.Controls; using System.Diagnostics; namespace ReorderListBoxDemo { /// /// Extends ListBox to enable drag-and-drop reorder within the list. /// [TemplatePart(Name = ReorderListBox.ScrollViewerPart, Type = typeof(ScrollViewer))] [TemplatePart(Name = ReorderListBox.DragIndicatorPart, Type = typeof(Image))] [TemplatePart(Name = ReorderListBox.DragInterceptorPart, Type = typeof(Canvas))] [TemplatePart(Name = ReorderListBox.RearrangeCanvasPart, Type = typeof(Canvas))] public class ReorderListBox : ListBox { #region Template part name constants public const string ScrollViewerPart = "ScrollViewer"; public const string DragIndicatorPart = "DragIndicator"; public const string DragInterceptorPart = "DragInterceptor"; public const string RearrangeCanvasPart = "RearrangeCanvas"; #endregion private const string ScrollViewerScrollingVisualState = "Scrolling"; private const string ScrollViewerNotScrollingVisualState = "NotScrolling"; private const string IsReorderEnabledPropertyName = "IsReorderEnabled"; #region Private fields private double dragScrollDelta; private Panel itemsPanel; private ScrollViewer scrollViewer; private Canvas dragInterceptor; private Image dragIndicator; private object dragItem; private ReorderListBoxItem dragItemContainer; private bool isDragItemSelected; private Rect dragInterceptorRect; private int dropTargetIndex; private Canvas rearrangeCanvas; private Queue> rearrangeQueue; #endregion /// /// Creates a new ReorderListBox and sets the default style key. /// The style key is used to locate the control template in Generic.xaml. /// public ReorderListBox() { this.DefaultStyleKey = typeof(ReorderListBox); } #region IsReorderEnabled DependencyProperty public static readonly DependencyProperty IsReorderEnabledProperty = DependencyProperty.Register( ReorderListBox.IsReorderEnabledPropertyName, typeof(bool), typeof(ReorderListBox), new PropertyMetadata(false, (d, e) => ((ReorderListBox)d).OnIsReorderEnabledChanged(e))); /// /// Gets or sets a value indicating whether reordering is enabled in the listbox. /// This also controls the visibility of the reorder drag-handle of each listbox item. /// public bool IsReorderEnabled { get { return (bool)this.GetValue(ReorderListBox.IsReorderEnabledProperty); } set { this.SetValue(ReorderListBox.IsReorderEnabledProperty, value); } } protected void OnIsReorderEnabledChanged(DependencyPropertyChangedEventArgs e) { if (this.dragInterceptor != null) { this.dragInterceptor.Visibility = (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed; } this.InvalidateArrange(); } #endregion #region AutoScrollMargin DependencyProperty public static readonly DependencyProperty AutoScrollMarginProperty = DependencyProperty.Register( "AutoScrollMargin", typeof(int), typeof(ReorderListBox), new PropertyMetadata(32)); /// /// Gets or sets the size of the region at the top and bottom of the list where dragging will /// cause the list to automatically scroll. /// public double AutoScrollMargin { get { return (int)this.GetValue(ReorderListBox.AutoScrollMarginProperty); } set { this.SetValue(ReorderListBox.AutoScrollMarginProperty, value); } } #endregion #region ItemsControl overrides /// /// Applies the control template, gets required template parts, and hooks up the drag events. /// public override void OnApplyTemplate() { base.OnApplyTemplate(); this.scrollViewer = (ScrollViewer)this.GetTemplateChild(ReorderListBox.ScrollViewerPart); this.dragInterceptor = this.GetTemplateChild(ReorderListBox.DragInterceptorPart) as Canvas; this.dragIndicator = this.GetTemplateChild(ReorderListBox.DragIndicatorPart) as Image; this.rearrangeCanvas = this.GetTemplateChild(ReorderListBox.RearrangeCanvasPart) as Canvas; if (this.scrollViewer != null && this.dragInterceptor != null && this.dragIndicator != null) { this.dragInterceptor.Visibility = this.IsReorderEnabled ? Visibility.Visible : Visibility.Collapsed; this.dragInterceptor.ManipulationStarted += this.dragInterceptor_ManipulationStarted; this.dragInterceptor.ManipulationDelta += this.dragInterceptor_ManipulationDelta; this.dragInterceptor.ManipulationCompleted += this.dragInterceptor_ManipulationCompleted; } } protected override DependencyObject GetContainerForItemOverride() { return new ReorderListBoxItem(); } protected override bool IsItemItsOwnContainerOverride(object item) { return item is ReorderListBoxItem; } /// /// Ensures that a possibly-recycled item container (ReorderListBoxItem) is ready to display a list item. /// protected override void PrepareContainerForItemOverride(DependencyObject element, object item) { base.PrepareContainerForItemOverride(element, item); ReorderListBoxItem itemContainer = (ReorderListBoxItem)element; itemContainer.ApplyTemplate(); // Loads visual states. // Set this state before binding to avoid showing the visual transition in this case. string reorderState = this.IsReorderEnabled ? ReorderListBoxItem.ReorderEnabledState : ReorderListBoxItem.ReorderDisabledState; VisualStateManager.GoToState(itemContainer, reorderState, false); itemContainer.SetBinding(ReorderListBoxItem.IsReorderEnabledProperty, new Binding(ReorderListBox.IsReorderEnabledPropertyName) { Source = this }); if (item == this.dragItem) { itemContainer.IsSelected = this.isDragItemSelected; VisualStateManager.GoToState(itemContainer, ReorderListBoxItem.DraggingState, false); if (this.dropTargetIndex >= 0) { // The item's dragIndicator is currently being moved, so the item itself is hidden. itemContainer.Visibility = Visibility.Collapsed; this.dragItemContainer = itemContainer; } else { itemContainer.Opacity = 0; this.Dispatcher.BeginInvoke(() => this.AnimateDrop(itemContainer)); } } else { VisualStateManager.GoToState(itemContainer, ReorderListBoxItem.NotDraggingState, false); } } /// /// Called when an item container (ReorderListBoxItem) is being removed from the list panel. /// This may be because the item was removed from the list or because the item is now outside /// the virtualization region (because ListBox uses a VirtualizingStackPanel as its items panel). /// protected override void ClearContainerForItemOverride(DependencyObject element, object item) { base.ClearContainerForItemOverride(element, item); ReorderListBoxItem itemContainer = (ReorderListBoxItem)element; if (itemContainer == this.dragItemContainer) { this.dragItemContainer.Visibility = Visibility.Visible; this.dragItemContainer = null; } } #endregion #region Drag & drop reorder /// /// Called when the user presses down on the transparent drag-interceptor. Identifies the targed /// drag handle and list item and prepares for a drag operation. /// private void dragInterceptor_ManipulationStarted(object sender, ManipulationStartedEventArgs e) { if (this.dragItem != null) { return; } if (this.itemsPanel == null) { ItemsPresenter scrollItemsPresenter = (ItemsPresenter)this.scrollViewer.Content; this.itemsPanel = (Panel)VisualTreeHelper.GetChild(scrollItemsPresenter, 0); } GeneralTransform interceptorTransform = this.dragInterceptor.TransformToVisual( Application.Current.RootVisual); Point targetPoint = interceptorTransform.Transform(e.ManipulationOrigin); targetPoint = ReorderListBox.GetHostCoordinates(targetPoint); List targetElements = VisualTreeHelper.FindElementsInHostCoordinates( targetPoint, this.itemsPanel).ToList(); ReorderListBoxItem targetItemContainer = targetElements.OfType().FirstOrDefault(); if (targetItemContainer != null && targetElements.Contains(targetItemContainer.DragHandle)) { VisualStateManager.GoToState(targetItemContainer, ReorderListBoxItem.DraggingState, true); GeneralTransform targetItemTransform = targetItemContainer.TransformToVisual(this.dragInterceptor); Point targetItemOrigin = targetItemTransform.Transform(new Point(0, 0)); Canvas.SetLeft(this.dragIndicator, targetItemOrigin.X); Canvas.SetTop(this.dragIndicator, targetItemOrigin.Y); this.dragIndicator.Width = targetItemContainer.RenderSize.Width; this.dragIndicator.Height = targetItemContainer.RenderSize.Height; this.dragItemContainer = targetItemContainer; this.dragItem = this.dragItemContainer.Content; this.isDragItemSelected = this.dragItemContainer.IsSelected; this.dragInterceptorRect = interceptorTransform.TransformBounds( new Rect(new Point(0, 0), this.dragInterceptor.RenderSize)); this.dropTargetIndex = -1; } } /// /// Called when the user drags on (or from) the transparent drag-interceptor. /// Moves the item (actually a rendered snapshot of the item) according to the drag delta. /// private void dragInterceptor_ManipulationDelta(object sender, ManipulationDeltaEventArgs e) { if (this.Items.Count <= 1 || this.dragItem == null) { return; } if (this.dropTargetIndex == -1) { if (this.dragItemContainer == null) { return; } // When the drag actually starts, swap out the item for the drag-indicator image of the item. // This is necessary because the item itself may be removed from the virtualizing panel // if the drag causes a scroll of considerable distance. Size dragItemSize = this.dragItemContainer.RenderSize; WriteableBitmap writeableBitmap = new WriteableBitmap( (int)dragItemSize.Width, (int)dragItemSize.Height); // Swap states to force the transition to complete. VisualStateManager.GoToState(this.dragItemContainer, ReorderListBoxItem.NotDraggingState, false); VisualStateManager.GoToState(this.dragItemContainer, ReorderListBoxItem.DraggingState, false); writeableBitmap.Render(this.dragItemContainer, null); writeableBitmap.Invalidate(); this.dragIndicator.Source = writeableBitmap; this.dragIndicator.Visibility = Visibility.Visible; this.dragItemContainer.Visibility = Visibility.Collapsed; if (this.itemsPanel.Children.IndexOf(this.dragItemContainer) < this.itemsPanel.Children.Count - 1) { this.UpdateDropTarget(Canvas.GetTop(this.dragIndicator) + this.dragIndicator.Height + 1, false); } else { this.UpdateDropTarget(Canvas.GetTop(this.dragIndicator) - 1, false); } } double dragItemHeight = this.dragIndicator.Height; TranslateTransform translation = (TranslateTransform)this.dragIndicator.RenderTransform; double top = Canvas.GetTop(this.dragIndicator); // Limit the translation to keep the item within the list area. // Use different targeting for the top and bottom edges to allow taller items to // move before or after shorter items at the edges. double y = top + e.CumulativeManipulation.Translation.Y; if (y < 0) { y = 0; this.UpdateDropTarget(0, true); } else if (y >= this.dragInterceptorRect.Height - dragItemHeight) { y = this.dragInterceptorRect.Height - dragItemHeight; this.UpdateDropTarget(this.dragInterceptorRect.Height - 1, true); } else { this.UpdateDropTarget(y + dragItemHeight / 2, true); } translation.Y = y - top; // Check if we're within the margin where auto-scroll needs to happen. bool scrolling = (this.dragScrollDelta != 0); double autoScrollMargin = this.AutoScrollMargin; if (autoScrollMargin > 0 && y < autoScrollMargin) { this.dragScrollDelta = y - autoScrollMargin; if (!scrolling) { VisualStateManager.GoToState(this.scrollViewer, ReorderListBox.ScrollViewerScrollingVisualState, true); this.Dispatcher.BeginInvoke(() => this.DragScroll()); return; } } else if (autoScrollMargin > 0 && y + dragItemHeight > this.dragInterceptorRect.Height - autoScrollMargin) { this.dragScrollDelta = (y + dragItemHeight - (this.dragInterceptorRect.Height - autoScrollMargin)); if (!scrolling) { VisualStateManager.GoToState(this.scrollViewer, ReorderListBox.ScrollViewerScrollingVisualState, true); this.Dispatcher.BeginInvoke(() => this.DragScroll()); return; } } else { // We're not within the auto-scroll margin. This ensures any current scrolling is stopped. this.dragScrollDelta = 0; } } /// /// Called when the user releases a drag. Moves the item within the source list and then resets everything. /// private void dragInterceptor_ManipulationCompleted(object sender, ManipulationCompletedEventArgs e) { if (this.dragItem == null) { return; } if (this.dropTargetIndex >= 0) { this.MoveItem(this.dragItem, this.dropTargetIndex); } if (this.dragItemContainer != null) { this.dragItemContainer.Visibility = Visibility.Visible; this.dragItemContainer.Opacity = 0; this.AnimateDrop(this.dragItemContainer); this.dragItemContainer = null; } this.dragScrollDelta = 0; this.dropTargetIndex = -1; this.ClearDropTarget(); } /// /// Slides the drag indicator (item snapshot) to the location of the dropped item, /// then performs the visibility swap and removes the dragging visual state. /// private void AnimateDrop(ReorderListBoxItem itemContainer) { GeneralTransform itemTransform = itemContainer.TransformToVisual(this.dragInterceptor); Rect itemRect = itemTransform.TransformBounds(new Rect(new Point(0, 0), itemContainer.RenderSize)); double delta = Math.Abs(itemRect.Y - Canvas.GetTop(this.dragIndicator) - ((TranslateTransform)this.dragIndicator.RenderTransform).Y); if (delta > 0) { // Adjust the duration based on the distance, so the speed will be constant. TimeSpan duration = TimeSpan.FromSeconds(0.25 * delta / itemRect.Height); Storyboard dropStoryboard = new Storyboard(); DoubleAnimation moveToDropAnimation = new DoubleAnimation(); Storyboard.SetTarget(moveToDropAnimation, this.dragIndicator.RenderTransform); Storyboard.SetTargetProperty(moveToDropAnimation, new PropertyPath(TranslateTransform.YProperty)); moveToDropAnimation.To = itemRect.Y - Canvas.GetTop(this.dragIndicator); moveToDropAnimation.Duration = duration; dropStoryboard.Children.Add(moveToDropAnimation); dropStoryboard.Completed += delegate { this.dragItem = null; itemContainer.Opacity = 1; this.dragIndicator.Visibility = Visibility.Collapsed; this.dragIndicator.Source = null; ((TranslateTransform)this.dragIndicator.RenderTransform).Y = 0; VisualStateManager.GoToState(itemContainer, ReorderListBoxItem.NotDraggingState, true); }; dropStoryboard.Begin(); } else { // There was no need for an animation, so do the visibility swap right now. this.dragItem = null; itemContainer.Opacity = 1; this.dragIndicator.Visibility = Visibility.Collapsed; this.dragIndicator.Source = null; VisualStateManager.GoToState(itemContainer, ReorderListBoxItem.NotDraggingState, true); } } /// /// Automatically scrolls for as long as the drag is held within the margin. /// The speed of the scroll is adjusted based on the depth into the margin. /// private void DragScroll() { if (this.dragScrollDelta != 0) { double scrollRatio = this.scrollViewer.ViewportHeight / this.scrollViewer.RenderSize.Height; double adjustedDelta = this.dragScrollDelta * scrollRatio; double newOffset = this.scrollViewer.VerticalOffset + adjustedDelta; this.scrollViewer.ScrollToVerticalOffset(newOffset); if (this.scrollViewer.VerticalOffset == newOffset) { this.Dispatcher.BeginInvoke(() => this.DragScroll()); } else { VisualStateManager.GoToState(this.scrollViewer, ReorderListBox.ScrollViewerNotScrollingVisualState, true); } double dragItemOffset = Canvas.GetTop(this.dragIndicator) + ((TranslateTransform)this.dragIndicator.RenderTransform).Y + this.dragIndicator.Height / 2; this.UpdateDropTarget(dragItemOffset, true); } else { VisualStateManager.GoToState(this.scrollViewer, ReorderListBox.ScrollViewerNotScrollingVisualState, true); } } /// /// Updates spacing (drop target indicators) surrounding the targeted region. /// /// Vertical offset into the items panel where the drag is currently targeting. /// True if the drop-indicator transitions should be shown. private void UpdateDropTarget(double dragItemOffset, bool showTransition) { Point dragPoint = ReorderListBox.GetHostCoordinates( new Point(this.dragInterceptorRect.Left, this.dragInterceptorRect.Top + dragItemOffset)); IEnumerable targetElements = VisualTreeHelper.FindElementsInHostCoordinates(dragPoint, this.itemsPanel); ReorderListBoxItem targetItem = targetElements.OfType().FirstOrDefault(); if (targetItem != null) { GeneralTransform targetTransform = targetItem.DragHandle.TransformToVisual(this.dragInterceptor); Rect targetRect = targetTransform.TransformBounds(new Rect(new Point(0, 0), targetItem.DragHandle.RenderSize)); double targetCenter = (targetRect.Top + targetRect.Bottom) / 2; int targetIndex = this.itemsPanel.Children.IndexOf(targetItem); int childrenCount = this.itemsPanel.Children.Count; bool after = dragItemOffset > targetCenter; ReorderListBoxItem indicatorItem = null; if (!after && targetIndex > 0) { ReorderListBoxItem previousItem = (ReorderListBoxItem)this.itemsPanel.Children[targetIndex - 1]; if (previousItem.Tag as string == ReorderListBoxItem.DropAfterIndicatorState) { indicatorItem = previousItem; } } else if (after && targetIndex < childrenCount - 1) { ReorderListBoxItem nextItem = (ReorderListBoxItem)this.itemsPanel.Children[targetIndex + 1]; if (nextItem.Tag as string == ReorderListBoxItem.DropBeforeIndicatorState) { indicatorItem = nextItem; } } if (indicatorItem == null) { targetItem.DropIndicatorHeight = this.dragIndicator.Height; string dropIndicatorState = after ? ReorderListBoxItem.DropAfterIndicatorState : ReorderListBoxItem.DropBeforeIndicatorState; VisualStateManager.GoToState(targetItem, dropIndicatorState, showTransition); targetItem.Tag = dropIndicatorState; indicatorItem = targetItem; } for (int i = targetIndex - 5; i <= targetIndex + 5; i++) { if (i >= 0 && i < childrenCount) { ReorderListBoxItem nearbyItem = (ReorderListBoxItem)this.itemsPanel.Children[i]; if (nearbyItem != indicatorItem) { VisualStateManager.GoToState(nearbyItem, ReorderListBoxItem.NoDropIndicatorState, showTransition); nearbyItem.Tag = ReorderListBoxItem.NoDropIndicatorState; } } } this.UpdateDropTargetIndex(targetItem, after); } } /// /// Updates the targeted index -- that is the index where the item will be moved to if dropped at this point. /// private void UpdateDropTargetIndex(ReorderListBoxItem targetItemContainer, bool after) { int dragItemIndex = this.Items.IndexOf(this.dragItem); int targetItemIndex = this.Items.IndexOf(targetItemContainer.Content); int newDropTargetIndex; if (targetItemIndex == dragItemIndex) { newDropTargetIndex = dragItemIndex; } else { newDropTargetIndex = targetItemIndex + (after ? 1 : 0) - (targetItemIndex >= dragItemIndex ? 1 : 0); } if (newDropTargetIndex != this.dropTargetIndex) { this.dropTargetIndex = newDropTargetIndex; } } /// /// Hides any drop-indicators that are currently visible. /// private void ClearDropTarget() { foreach (ReorderListBoxItem itemContainer in this.itemsPanel.Children) { VisualStateManager.GoToState(itemContainer, ReorderListBoxItem.NoDropIndicatorState, false); itemContainer.Tag = null; } } /// /// Moves an item to a specified index in the source list. /// private bool MoveItem(object item, int toIndex) { object itemsSource = this.ItemsSource; System.Collections.IList sourceList = itemsSource as System.Collections.IList; if (!(sourceList is System.Collections.Specialized.INotifyCollectionChanged)) { // If the source does not implement INotifyCollectionChanged, then there's no point in // changing the source because changes to it will not be synchronized with the list items. // So, just change the ListBox's view of the items. sourceList = this.Items; } int fromIndex = sourceList.IndexOf(item); if (fromIndex != toIndex) { double scrollOffset = this.scrollViewer.VerticalOffset; sourceList.RemoveAt(fromIndex); sourceList.Insert(toIndex, item); if (fromIndex <= scrollOffset && toIndex > scrollOffset) { // Correct the scroll offset for the removed item so that the list doesn't appear to jump. this.scrollViewer.ScrollToVerticalOffset(scrollOffset - 1); } return true; } else { return false; } } #endregion #region View range detection /// /// Gets the indices of the first and last items in the view based on the current scroll position. /// /// True to include items that are partially obscured at the top and bottom, /// false to include only items that are completely in view. /// Returns the index of the first item in view (or -1 if there are no items). /// Returns the index of the last item in view (or -1 if there are no items). public void GetViewIndexRange(bool includePartial, out int firstIndex, out int lastIndex) { if (this.Items.Count > 0) { firstIndex = 0; lastIndex = this.Items.Count - 1; if (this.scrollViewer != null && this.Items.Count > 1) { Thickness scrollViewerPadding = new Thickness( this.scrollViewer.BorderThickness.Left + this.scrollViewer.Padding.Left, this.scrollViewer.BorderThickness.Top + this.scrollViewer.Padding.Top, this.scrollViewer.BorderThickness.Right + this.scrollViewer.Padding.Right, this.scrollViewer.BorderThickness.Bottom + this.scrollViewer.Padding.Bottom); GeneralTransform scrollViewerTransform = this.scrollViewer.TransformToVisual( Application.Current.RootVisual); Rect scrollViewerRect = scrollViewerTransform.TransformBounds( new Rect(new Point(0, 0), this.scrollViewer.RenderSize)); Point topPoint = ReorderListBox.GetHostCoordinates(new Point( scrollViewerRect.Left + scrollViewerPadding.Left, scrollViewerRect.Top + scrollViewerPadding.Top)); IEnumerable topElements = VisualTreeHelper.FindElementsInHostCoordinates( topPoint, this.scrollViewer); ReorderListBoxItem topItem = topElements.OfType().FirstOrDefault(); if (topItem != null) { GeneralTransform itemTransform = topItem.TransformToVisual(Application.Current.RootVisual); Rect itemRect = itemTransform.TransformBounds(new Rect(new Point(0, 0), topItem.RenderSize)); firstIndex = this.ItemContainerGenerator.IndexFromContainer(topItem); if (!includePartial && firstIndex < this.Items.Count - 1 && itemRect.Top < scrollViewerRect.Top && itemRect.Bottom < scrollViewerRect.Bottom) { firstIndex++; } } Point bottomPoint = ReorderListBox.GetHostCoordinates(new Point( scrollViewerRect.Left + scrollViewerPadding.Left, scrollViewerRect.Bottom - scrollViewerPadding.Bottom - 1)); IEnumerable bottomElements = VisualTreeHelper.FindElementsInHostCoordinates( bottomPoint, this.scrollViewer); ReorderListBoxItem bottomItem = bottomElements.OfType().FirstOrDefault(); if (bottomItem != null) { GeneralTransform itemTransform = bottomItem.TransformToVisual(Application.Current.RootVisual); Rect itemRect = itemTransform.TransformBounds( new Rect(new Point(0, 0), bottomItem.RenderSize)); lastIndex = this.ItemContainerGenerator.IndexFromContainer(bottomItem); if (!includePartial && lastIndex > firstIndex && itemRect.Bottom > scrollViewerRect.Bottom && itemRect.Top > scrollViewerRect.Top) { lastIndex--; } } } } else { firstIndex = -1; lastIndex = -1; } } #endregion #region Rearrange /// /// Private helper class for keeping track of each item involved in a rearrange. /// private class RearrangeItemInfo { public object Item = null; public int FromIndex = -1; public int ToIndex = -1; public double FromY = Double.NaN; public double ToY = Double.NaN; public double Height = Double.NaN; } /// /// Animates movements, insertions, or deletions in the list. /// /// Duration of the animation. /// Performs the actual rearrange on the list source. /// /// The animations are as follows: /// - Inserted items fade in while later items slide down to make space. /// - Removed items fade out while later items slide up to close the gap. /// - Moved items slide from their previous location to their new location. /// - Moved items which move out of or in to the visible area also fade out / fade in while sliding. /// /// The rearrange action callback is called in the middle of the rearrange process. That /// callback may make any number of changes to the list source, in any order. After the rearrange /// action callback returns, the net result of all changes will be detected and included in a dynamically /// generated rearrange animation. /// /// Multiple calls to this method in quick succession will be automatically queued up and executed in turn /// to avoid any possibility of conflicts. (If simultaneous rearrange animations are desired, use a single /// call to AnimateRearrange with a rearrange action callback that does both operations.) /// /// public void AnimateRearrange(Duration animationDuration, Action rearrangeAction) { if (rearrangeAction == null) { throw new ArgumentNullException("rearrangeAction"); } if (this.rearrangeCanvas == null) { throw new InvalidOperationException("ReorderListBox control template is missing " + "a part required for rearrange: " + ReorderListBox.RearrangeCanvasPart); } if (this.rearrangeQueue == null) { this.rearrangeQueue = new Queue>(); this.scrollViewer.ScrollToVerticalOffset(this.scrollViewer.VerticalOffset); // Stop scrolling. this.Dispatcher.BeginInvoke(() => this.AnimateRearrangeInternal(rearrangeAction, animationDuration)); } else { this.rearrangeQueue.Enqueue(new KeyValuePair(rearrangeAction, animationDuration)); } } /// /// Orchestrates the rearrange animation process. /// private void AnimateRearrangeInternal(Action rearrangeAction, Duration animationDuration) { // Find the indices of items in the view. Animations are optimzed to only include what is visible. int viewFirstIndex, viewLastIndex; this.GetViewIndexRange(true, out viewFirstIndex, out viewLastIndex); // Collect information about items and their positions before any changes are made. RearrangeItemInfo[] rearrangeMap = this.BuildRearrangeMap(viewFirstIndex, viewLastIndex); // Call the rearrange action callback which actually makes the changes to the source list. // Assuming the source list is properly bound, the base class will pick up the changes. rearrangeAction(); this.rearrangeCanvas.Visibility = Visibility.Visible; // Update the layout (positions of all items) based on the changes that were just made. this.UpdateLayout(); // Find the NEW last-index in view, which may have changed if the items are not constant heights // or if the view includes the end of the list. viewLastIndex = this.FindViewLastIndex(viewFirstIndex); // Collect information about the NEW items and their NEW positions, linking up to information // about items which existed before. RearrangeItemInfo[] rearrangeMap2 = this.BuildRearrangeMap2(rearrangeMap, viewFirstIndex, viewLastIndex); // Find all the movements that need to be animated. IEnumerable movesWithinView = rearrangeMap .Where(rii => !Double.IsNaN(rii.FromY) && !Double.IsNaN(rii.ToY)); IEnumerable movesOutOfView = rearrangeMap .Where(rii => !Double.IsNaN(rii.FromY) && Double.IsNaN(rii.ToY)); IEnumerable movesInToView = rearrangeMap2 .Where(rii => Double.IsNaN(rii.FromY) && !Double.IsNaN(rii.ToY)); IEnumerable visibleMoves = movesWithinView.Concat(movesOutOfView).Concat(movesInToView); // Set a clip rect so the animations don't go outside the listbox. this.rearrangeCanvas.Clip = new RectangleGeometry() { Rect = new Rect(new Point(0, 0), this.rearrangeCanvas.RenderSize) }; // Create the animation storyboard. Storyboard rearrangeStoryboard = this.CreateRearrangeStoryboard(visibleMoves, animationDuration); if (rearrangeStoryboard.Children.Count > 0) { // The storyboard uses an overlay canvas with item snapshots. // While that is playing, hide the real items. this.scrollViewer.Visibility = Visibility.Collapsed; rearrangeStoryboard.Completed += delegate { rearrangeStoryboard.Stop(); this.rearrangeCanvas.Children.Clear(); this.rearrangeCanvas.Visibility = Visibility.Collapsed; this.scrollViewer.Visibility = Visibility.Visible; this.AnimateNextRearrange(); }; this.Dispatcher.BeginInvoke(() => rearrangeStoryboard.Begin()); } else { this.rearrangeCanvas.Visibility = Visibility.Collapsed; this.AnimateNextRearrange(); } } /// /// Checks if there's another rearrange action waiting in the queue, and if so executes it next. /// private void AnimateNextRearrange() { if (this.rearrangeQueue.Count > 0) { KeyValuePair nextRearrange = this.rearrangeQueue.Dequeue(); this.Dispatcher.BeginInvoke(() => this.AnimateRearrangeInternal(nextRearrange.Key, nextRearrange.Value)); } else { this.rearrangeQueue = null; } } /// /// Collects information about items and their positions before any changes are made. /// private RearrangeItemInfo[] BuildRearrangeMap(int viewFirstIndex, int viewLastIndex) { RearrangeItemInfo[] map = new RearrangeItemInfo[this.Items.Count]; for (int i = 0; i < map.Length; i++) { object item = this.Items[i]; RearrangeItemInfo info = new RearrangeItemInfo() { Item = item, FromIndex = i, }; // The precise item location is only important if it's within the view. if (viewFirstIndex <= i && i <= viewLastIndex) { ReorderListBoxItem itemContainer = (ReorderListBoxItem) this.ItemContainerGenerator.ContainerFromIndex(i); if (itemContainer != null) { GeneralTransform itemTransform = itemContainer.TransformToVisual(this.rearrangeCanvas); Point itemPoint = itemTransform.Transform(new Point(0, 0)); info.FromY = itemPoint.Y; info.Height = itemContainer.RenderSize.Height; } } map[i] = info; } return map; } /// /// Collects information about the NEW items and their NEW positions after changes were made. /// private RearrangeItemInfo[] BuildRearrangeMap2(RearrangeItemInfo[] map, int viewFirstIndex, int viewLastIndex) { RearrangeItemInfo[] map2 = new RearrangeItemInfo[this.Items.Count]; for (int i = 0; i < map2.Length; i++) { object item = this.Items[i]; // Try to find the same item in the pre-rearrange info. RearrangeItemInfo info = map.FirstOrDefault(rii => rii.ToIndex < 0 && rii.Item == item); if (info == null) { info = new RearrangeItemInfo() { Item = item, }; } info.ToIndex = i; // The precise item location is only important if it's within the view. if (viewFirstIndex <= i && i <= viewLastIndex) { ReorderListBoxItem itemContainer = (ReorderListBoxItem) this.ItemContainerGenerator.ContainerFromIndex(i); if (itemContainer != null) { GeneralTransform itemTransform = itemContainer.TransformToVisual(this.rearrangeCanvas); Point itemPoint = itemTransform.Transform(new Point(0, 0)); info.ToY = itemPoint.Y; info.Height = itemContainer.RenderSize.Height; } } map2[i] = info; } return map2; } /// /// Finds the index of the last visible item by starting at the first index and /// comparing the bounds of each following item to the ScrollViewer bounds. /// /// /// This method is less efficient than the hit-test method used by GetViewIndexRange() above, /// but it works when the controls haven't actually been rendered yet, while the other doesn't. /// private int FindViewLastIndex(int firstIndex) { int lastIndex = firstIndex; GeneralTransform scrollViewerTransform = this.scrollViewer.TransformToVisual( Application.Current.RootVisual); Rect scrollViewerRect = scrollViewerTransform.TransformBounds( new Rect(new Point(0, 0), this.scrollViewer.RenderSize)); while (lastIndex < this.Items.Count - 1) { ReorderListBoxItem itemContainer = (ReorderListBoxItem) this.ItemContainerGenerator.ContainerFromIndex(lastIndex + 1); if (itemContainer == null) { break; } GeneralTransform itemTransform = itemContainer.TransformToVisual( Application.Current.RootVisual); Rect itemRect = itemTransform.TransformBounds(new Rect(new Point(0, 0), itemContainer.RenderSize)); itemRect.Intersect(scrollViewerRect); if (itemRect == Rect.Empty) { break; } lastIndex++; } return lastIndex; } /// /// Creates a storyboard to animate the visible moves of a rearrange. /// private Storyboard CreateRearrangeStoryboard(IEnumerable visibleMoves, Duration animationDuration) { Storyboard storyboard = new Storyboard(); ReorderListBoxItem temporaryItemContainer = null; foreach (RearrangeItemInfo move in visibleMoves) { Size itemSize = new Size(this.rearrangeCanvas.RenderSize.Width, move.Height); ReorderListBoxItem itemContainer = null; if (move.ToIndex >= 0) { itemContainer = (ReorderListBoxItem)this.ItemContainerGenerator.ContainerFromIndex(move.ToIndex); } if (itemContainer == null) { if (temporaryItemContainer == null) { temporaryItemContainer = new ReorderListBoxItem(); } itemContainer = temporaryItemContainer; itemContainer.Width = itemSize.Width; itemContainer.Height = itemSize.Height; this.rearrangeCanvas.Children.Add(itemContainer); this.PrepareContainerForItemOverride(itemContainer, move.Item); itemContainer.UpdateLayout(); } WriteableBitmap itemSnapshot = new WriteableBitmap((int)itemSize.Width, (int)itemSize.Height); itemSnapshot.Render(itemContainer, null); itemSnapshot.Invalidate(); Image itemImage = new Image(); itemImage.Width = itemSize.Width; itemImage.Height = itemSize.Height; itemImage.Source = itemSnapshot; itemImage.RenderTransform = new TranslateTransform(); this.rearrangeCanvas.Children.Add(itemImage); if (itemContainer == temporaryItemContainer) { this.rearrangeCanvas.Children.Remove(itemContainer); } if (!Double.IsNaN(move.FromY) && !Double.IsNaN(move.ToY)) { Canvas.SetTop(itemImage, move.FromY); if (move.FromY != move.ToY) { DoubleAnimation moveAnimation = new DoubleAnimation(); moveAnimation.Duration = animationDuration; Storyboard.SetTarget(moveAnimation, itemImage.RenderTransform); Storyboard.SetTargetProperty(moveAnimation, new PropertyPath(TranslateTransform.YProperty)); moveAnimation.To = move.ToY - move.FromY; storyboard.Children.Add(moveAnimation); } } else if (Double.IsNaN(move.FromY) != Double.IsNaN(move.ToY)) { if (move.FromIndex >= 0 && move.ToIndex >= 0) { DoubleAnimation moveAnimation = new DoubleAnimation(); moveAnimation.Duration = animationDuration; Storyboard.SetTarget(moveAnimation, itemImage.RenderTransform); Storyboard.SetTargetProperty(moveAnimation, new PropertyPath(TranslateTransform.YProperty)); const double animationDistance = 200; if (!Double.IsNaN(move.FromY)) { Canvas.SetTop(itemImage, move.FromY); if (move.FromIndex < move.ToIndex) { moveAnimation.To = animationDistance; } else if (move.FromIndex > move.ToIndex) { moveAnimation.To = -animationDistance; } } else { Canvas.SetTop(itemImage, move.ToY); if (move.FromIndex < move.ToIndex) { moveAnimation.From = -animationDistance; } else if (move.FromIndex > move.ToIndex) { moveAnimation.From = animationDistance; } } storyboard.Children.Add(moveAnimation); } DoubleAnimation fadeAnimation = new DoubleAnimation(); fadeAnimation.Duration = animationDuration; Storyboard.SetTarget(fadeAnimation, itemImage); Storyboard.SetTargetProperty(fadeAnimation, new PropertyPath(UIElement.OpacityProperty)); if (Double.IsNaN(move.FromY)) { itemImage.Opacity = 0.0; fadeAnimation.To = 1.0; Canvas.SetTop(itemImage, move.ToY); } else { itemImage.Opacity = 1.0; fadeAnimation.To = 0.0; Canvas.SetTop(itemImage, move.FromY); } storyboard.Children.Add(fadeAnimation); } } return storyboard; } #endregion #region Private utility methods /// /// Gets host coordinates, adjusting for orientation. This is helpful when identifying what /// controls are under a point. /// private static Point GetHostCoordinates(Point point) { PhoneApplicationFrame frame = (PhoneApplicationFrame)Application.Current.RootVisual; switch (frame.Orientation) { case PageOrientation.LandscapeLeft: return new Point(frame.RenderSize.Width - point.Y, point.X); case PageOrientation.LandscapeRight: return new Point(point.Y, frame.RenderSize.Height - point.X); default: return point; } } #endregion } }