2012年6月2日土曜日

wpf : マウスドラッグで並び替えができるTabControlを作成

標準のTabControlを継承してマウスドラッグで並び替えができるTabControlを作成しました。

移動先は三角形の移動先カーソル(コード内ではMoveCursor)で表現しています。 ドラッグでつかんだTabItemのヘッダの半透明コピー(以下ゴースト)をマウスに追従させて、移動中であることを分かりやすくしています。 ゴーストはVisualBrushで塗っています。 なのでImageやShapeが含まれていてもOK。

モノによってはTabItemのコンテンツの方をゴーストにしているソフトがありますが、そちらはメモリや計算量などをちゃんと管理しないとならないっぽいのでノータッチです。

とりあえず、コードを載せます。 まずはTabControl.cs。

using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using Util;

namespace UserControls
{
    /// <summary>
    /// マウスドラッグでTabItemの並び替えができるTabControl。
    /// <remarks>
    /// ItemsSourceがObservableCollectionの場合のみItemsSourceを並び替える。
    /// それ以外のときはItemsを並び替える。
    /// </remarks>
    /// </summary>
    public class TabControl : System.Windows.Controls.TabControl
    {
        // マウスドラッグでTabItemを移動しているときのindexに使用。
        // マウスカーソルがTabItemを指していないときはこの値になる。
        private const int INVALID_MOVE_TO_INDEX = int.MinValue;

        // PointToScreenなどで座標に誤差が生じる。その誤差含みの座標で判定するときの閾値。
        private const double ROUNDING_ERROR_THRESHOLD = 0.001;

        private const double DEFAULT_DRAGGED_ITEM_TO_MOVE_OPACITY = 0.5;

        #region Property definition

        public static readonly DependencyProperty DraggingCursorAdornerOpacityMaskProperty = DependencyProperty.Register(
                "DraggingCursorAdornerOpacityMask",
                typeof(Brush),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        null,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                )
        );

        /// <summary>
        /// TabItem移動中に表示される「ドラッグ中のTabItemヘッダーのコピー」のマスク。
        /// アルファ値のみ使用。RGBは無視。
        /// ドラッグ中に変更しても即座に反映されない。
        /// (未確認だが、タブを切り替えてからドラッグ開始するまでに変更したら反映されるはず。)
        /// </summary>
        public Brush DraggingCursorAdornerOpacityMask
        {
            get { return (Brush)GetValue(DraggingCursorAdornerOpacityMaskProperty); }
            set { SetValue(DraggingCursorAdornerOpacityMaskProperty, value); }
        }

        public static readonly DependencyProperty DraggingCursorAdornerOffsetProperty = DependencyProperty.Register(
                "DraggingCursorAdornerOffset",
                typeof(Point),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        new Point(double.NaN, double.NaN),
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                )
        );

        /// <summary>
        /// TabItem移動中に表示される「ドラッグ中のTabItemヘッダーのコピー」のマウスカーソルからの相対位置。
        /// ドラッグ中に変更しても即座に反映されない。
        /// オフセットの初期値は(NaN,NaN)。
        /// offset.XがNaNのときは0が、offset.YがNaNのときは(TabItemのドラッグ開始時の実際の高さ÷2)が使用される。
        /// </summary>
        public Point DraggingCursorAdornerOffset
        {
            get { return (Point)GetValue(DraggingCursorAdornerOffsetProperty); }
            set { SetValue(DraggingCursorAdornerOffsetProperty, value); }
        }

        public static readonly DependencyProperty MoveCursorCanvasProperty = DependencyProperty.Register(
                "MoveCursorCanvas",
                typeof(Canvas),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        null,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        OnMoveCursorCanvasChanged
                )
        );

        /// <summary>
        /// TabItemの移動先(どのタブとどのタブの間に入るか)を指すカーソル。
        /// ホットスポットは(0,0)になるように調節すること。
        /// ドラッグ中に変更しても即座に反映されない。
        /// </summary>
        public Canvas MoveCursorCanvas
        {
            get { return (Canvas)GetValue(MoveCursorCanvasProperty); }
            set { SetValue(MoveCursorCanvasProperty, value); }
        }

        private static void OnMoveCursorCanvasChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            UserControls.TabControl thisCtrl = (UserControls.TabControl)obj;
            Debug.Assert(thisCtrl._moveCursor != null);

            thisCtrl._moveCursor.CursorCanvas = (Canvas)args.NewValue;
        }

        public static readonly DependencyProperty MoveCursorHitTestCorrectionProperty = DependencyProperty.Register(
                "MoveCursorHitTestCorrection",
                typeof(double),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        (double)1.0,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                )
        );

        /// <summary>
        /// マウスカーソルがTabItem上にあるか判定するときのItemsHostサイズの補正値。
        /// デフォルトのレイアウトだとItemsHost(TabPanel)のマージンが-1.0されており、正常に判定できない。
        /// そこで、MoveCursorHitTestCorrectionの分だけTabItemを縮めて判定する。
        /// Templateを指定する場合は0にしてもよい。
        /// </summary>
        public double MoveCursorHitTestCorrection
        {
            get { return (double)GetValue(MoveCursorHitTestCorrectionProperty); }
            set { SetValue(MoveCursorHitTestCorrectionProperty, value); }
        }

        public static readonly DependencyProperty IsDraggingItemToMoveProperty = DependencyProperty.Register(
                "IsDraggingItemToMove",
                typeof(bool),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        false,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                )
        );

        /// <summary>
        /// TabItemをドラッグで移動中かどうか。trueなら移動中。
        /// </summary>
        public bool IsDraggingItemToMove
        {
            get { return (bool)GetValue(IsDraggingItemToMoveProperty); }
            private set { SetValue(IsDraggingItemToMoveProperty, value); }
        }

        public void SetIsItemDraggingToMove(TabItem item, bool value)
        {
            item.SetValue(TabItemIsDraggingToMove.ValueProperty, value);
        }

        public bool GetIsItemDraggingToMove(TabItem item)
        {
            return (bool)item.GetValue(TabItemIsDraggingToMove.ValueProperty);
        }

        public static readonly DependencyProperty DraggedItemToMoveProperty = DependencyProperty.Register(
                "DraggedItemToMove",
                typeof(TabItem),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        null,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                )
        );

        /// <summary>
        /// ドラッグで移動中のTabItem。
        /// </summary>
        public TabItem DraggedItemToMove
        {
            get { return (TabItem)GetValue(DraggedItemToMoveProperty); }
            private set { SetValue(DraggedItemToMoveProperty, value); }
        }

        public static readonly DependencyProperty HeaderElementNameProperty = DependencyProperty.Register(
                "HeaderElementName",
                typeof(string),
                typeof(UserControls.TabControl),
                new FrameworkPropertyMetadata(
                        null,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        OnHeaderElementNameChanged
                )
        );

        /// <summary>
        /// Templateを使った場合、Templateの名前スコープ内でItemsHostを載せるコントロールの名前を指定。
        /// TabItemのドラッグ移動範囲のチェックに使用される。
        /// Templateで、レイアウトの都合上ItemsHostの一部が隠れてしまう場合や、
        /// ItemsHostをScrollViewerに載せる場合などを想定。
        /// このプロパティを指定する場合は、TemplateのTargetTypeをこのTabControlにすること。
        /// 
        /// このプロパティに登録されたコントロールの枠より外にあるTabItemにはドラッグできない。
        /// ドラッグ先の判定はTabItemの四隅のうつ2つ(TabStripPlacement.Topのときは下の左右など)の座標を使う。
        /// レイアウトでその隅が隠れないように注意。
        /// TabItemの四隅はちょっとしたマージンのとり方1つで隠れてしまうことがあるので、要調整。
        /// 
        /// 初期値はnullで、その場合thisのサイズでチェックされる。
        /// </summary>
        public string HeaderElementName
        {
            get { return (string)GetValue(HeaderElementNameProperty); }
            set { SetValue(HeaderElementNameProperty, value); }
        }

        private static void OnHeaderElementNameChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            UserControls.TabControl thisCtrl = (UserControls.TabControl)obj;
            string newName = args.NewValue as string;

            if (newName == null)
                return;

            if (thisCtrl.IsLoaded)
                thisCtrl.UpdateHeaderElement(newName);
        }

        #endregion // Property definition

        private TabMoveCursor _moveCursor;
        private TabDraggingCursorAdorner _draggingCursorAdorner;
        private Point _pointDragStart;
        private TabItem _dragOverItem;
        private int _moveToIndex;
        private FrameworkElement _headerElement;

        public TabControl()
        {
            _moveCursor = new TabMoveCursor(this);
            _draggingCursorAdorner = new TabDraggingCursorAdorner(this);
            ResetDragParameter();

            Loaded += OnLoaded;

            // ドラッグ移動中のTabItemヘッダーのデフォルトスタイルを設定
            Style style = new Style();
            Trigger trigger = new Trigger();
            trigger.Property = TabItemIsDraggingToMove.ValueProperty;
            trigger.Value = true;
            Setter setter = new Setter(TabItem.OpacityProperty, DEFAULT_DRAGGED_ITEM_TO_MOVE_OPACITY);
            trigger.Setters.Add(setter);
            style.Triggers.Add(trigger);
            ItemContainerStyle = style;
        }

        private void OnLoaded(object sender, RoutedEventArgs evt)
        {
            AdornerLayer layer = AdornerLayer.GetAdornerLayer(this);
            Debug.Assert(layer != null);
            layer.Add(_moveCursor);
            layer.Add(_draggingCursorAdorner);

            UpdateHeaderElement(HeaderElementName);
        }

        protected override void OnTemplateChanged(ControlTemplate oldTemplate, ControlTemplate newTemplate)
        {
            base.OnTemplateChanged(oldTemplate, newTemplate);

            if (IsLoaded)
                UpdateHeaderElement(HeaderElementName);
        }

        protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
        {
            base.PrepareContainerForItemOverride(element, item);

            TabItem tabItem = (TabItem)element;
            tabItem.AllowDrop = true;
            tabItem.PreviewMouseLeftButtonDown += OnTabItemPreviewMouseLeftButtonDown;
            tabItem.MouseLeave += OnTabItemMouseLeave;
            tabItem.PreviewMouseMove += OnTabItemPreviewMouseMove;
            tabItem.PreviewMouseLeftButtonUp += OnTabItemPreviewMouseLeftButtonUp;
            tabItem.PreviewDragEnter += OnTabItemPreviewDragEnter;
            tabItem.PreviewDragOver += OnTabItemPreviewDragOver;
            tabItem.PreviewDragLeave += OnTabItemPreviewDragLeave;
            tabItem.PreviewDrop += OnTabItemPreviewDrop;
            tabItem.GiveFeedback += OnTabItemDragGiveFeedback;
        }

        protected override void ClearContainerForItemOverride(DependencyObject element, object item)
        {
            TabItem tabItem = (TabItem)element;
            tabItem.PreviewMouseLeftButtonDown -= OnTabItemPreviewMouseLeftButtonDown;
            tabItem.MouseLeave -= OnTabItemMouseLeave;
            tabItem.PreviewMouseMove -= OnTabItemPreviewMouseMove;
            tabItem.PreviewMouseLeftButtonUp -= OnTabItemPreviewMouseLeftButtonUp;
            tabItem.PreviewDragEnter -= OnTabItemPreviewDragEnter;
            tabItem.PreviewDragOver -= OnTabItemPreviewDragOver;
            tabItem.PreviewDragLeave -= OnTabItemPreviewDragLeave;
            tabItem.PreviewDrop -= OnTabItemPreviewDrop;
            tabItem.GiveFeedback -= OnTabItemDragGiveFeedback;

            base.ClearContainerForItemOverride(element, item);
        }

        protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs evt)
        {
            base.OnPropertyChanged(evt);

            if (evt.Property == TabControl.TabStripPlacementProperty)
            {
                switch (this.TabStripPlacement)
                {
                case Dock.Top:
                    _moveCursor.Rotate(0);
                    break;
                case Dock.Left:
                    _moveCursor.Rotate(-90);
                    break;
                case Dock.Right:
                    _moveCursor.Rotate(90);
                    break;
                case Dock.Bottom:
                    _moveCursor.Rotate(180);
                    break;
                }
            }
        }

        private void OnTabItemPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs evt)
        {
            _pointDragStart = evt.GetPosition((TabItem)sender);
        }

        private void OnTabItemMouseLeave(object sender, MouseEventArgs evt)
        {
            if (evt.LeftButton == MouseButtonState.Pressed && (!IsDraggingItemToMove))
                ResetDragParameter();
        }

        private void OnTabItemPreviewMouseMove(object sender, MouseEventArgs evt)
        {
            if (evt.LeftButton == MouseButtonState.Released || IsDraggingItemToMove)
                return;

            TabItem item = sender as TabItem;
            if (CheckDragDistance(evt.GetPosition(item)))
            {
                PrepareDrag(item);
                DragDrop.DoDragDrop(item, item, DragDropEffects.Move);
                ResetDragParameter();
            }
        }

        private void OnTabItemPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs evt)
        {
            ResetDragParameter();
        }

        private void OnTabItemPreviewDragEnter(object sender, DragEventArgs evt)
        {
            CursorMoveTo(sender as TabItem, evt);
        }

        private void OnTabItemPreviewDragOver(object sender, DragEventArgs evt)
        {
            CursorMoveTo(sender as TabItem, evt);
        }

        private void OnTabItemPreviewDragLeave(object sender, DragEventArgs evt)
        {
            HideCursor();
        }

        private void OnTabItemPreviewDrop(object sender, DragEventArgs evt)
        {
            if (_moveToIndex != INVALID_MOVE_TO_INDEX)
                MoveItem();
        }

        void OnTabItemDragGiveFeedback(object sender, GiveFeedbackEventArgs evt)
        {
            _draggingCursorAdorner.Move(Win32dll.GetCursorPos(this));
            evt.UseDefaultCursors = false;
            evt.Handled = true;
        }

        private void PrepareDrag(TabItem item)
        {
            DraggedItemToMove = item;
            IsDraggingItemToMove = true;
            SetIsItemDraggingToMove(item, true);
            _draggingCursorAdorner.Show(item, Win32dll.GetCursorPos(this));
        }

        private void ResetDragParameter()
        {
            HideCursor();

            if (DraggedItemToMove != null)
                SetIsItemDraggingToMove(DraggedItemToMove, false);

            _draggingCursorAdorner.Hide();
            _pointDragStart = new Point(double.NaN, double.NaN);
            IsDraggingItemToMove = false;
            DraggedItemToMove = null;
            _moveToIndex = INVALID_MOVE_TO_INDEX;
        }

        private bool CheckDragDistance(Point pointNow)
        {
            if (double.IsNaN(_pointDragStart.X)) // MouseDown後ドラッグ判定前に他のコントロールに移るとNaNが入っている
                return false;

            double dx = Math.Abs(pointNow.X - _pointDragStart.X);
            double dy = Math.Abs(pointNow.Y - _pointDragStart.Y);
            double thresholdX = SystemParameters.MinimumHorizontalDragDistance;
            double thresholdY = SystemParameters.MinimumVerticalDragDistance;

            return thresholdX < dx || thresholdY < dy;
        }

        private void CursorMoveTo(TabItem item, DragEventArgs evt)
        {
            int dragged = ItemContainerGenerator.IndexFromContainer(DraggedItemToMove);
            int pointing = ItemContainerGenerator.IndexFromContainer(item);

            bool pointToHead = IsMouseCursorPointingToHead(item, evt.GetPosition(item));
            bool isVisible = IsMoveCursorVisible(item, dragged, pointing, pointToHead);

            _moveToIndex = INVALID_MOVE_TO_INDEX;
            if (isVisible)
                _moveToIndex = (pointToHead ? pointing : pointing + 1);

            _moveCursor.MoveTo(item, pointToHead, isVisible);

            _dragOverItem = item;
        }

        private void HideCursor()
        {
            _moveCursor.Hide();
            _dragOverItem = null;
            _moveToIndex = INVALID_MOVE_TO_INDEX;
        }

        private void MoveItem()
        {
            Debug.Assert(_moveToIndex != INVALID_MOVE_TO_INDEX);

            int dragged = ItemContainerGenerator.IndexFromContainer(DraggedItemToMove);
            int insert = _moveToIndex;
            if (dragged < insert)
                insert--;

            if (IsItemsSourceObservableCollection())
            {
                Type ItemsSourceType = ItemsSource.GetType();
                MethodInfo methodInfo = ItemsSourceType.GetMethod("Move");
                methodInfo.Invoke(ItemsSource, new object[] { dragged, insert });
            }
            else
            {
                var item = Items.GetItemAt(dragged);
                Items.RemoveAt(dragged);
                Items.Insert(insert, item);
            }
        }

        private bool IsItemsSourceObservableCollection()
        {
            if (ItemsSource == null)
                return false;
            Type itemsSourceType = ItemsSource.GetType();
            if (!itemsSourceType.IsGenericType)
                return false;

            Type itemsSourceTypeDefinition = itemsSourceType.GetGenericTypeDefinition();
            return itemsSourceTypeDefinition == typeof(ObservableCollection<>);
        }

        // マウスカーソルがTabItemの先頭側を指しているか末尾側を指しているか?
        // mousePositionは_dragOverItem上のマウスカーソル位置。
        private bool IsMouseCursorPointingToHead(TabItem item, Point mousePosition)
        {
            Debug.Assert(item != null);

            Dock placement = this.TabStripPlacement;
            if (placement == Dock.Top || placement == Dock.Bottom)
                return mousePosition.X < item.ActualWidth / 2.0;
            else
                return mousePosition.Y < item.ActualHeight / 2.0;
        }

        private bool IsMoveCursorVisible(TabItem item, int dragged, int pointing, bool pointToHead)
        {
            // MoveCursorがHeaderFrameElementおよびItemsHostのパネルからはみ出す場合はfalseを返す。
            // ドラッグ中はマウスカーソルのTabItemの出入りで可視/不可視が切り替わっているので、
            // この処理が必要になるのはレイアウトの都合でTabItemが一部隠れるときのみ。
            FrameworkElement headerElement = _headerElement;
            if (headerElement == null)
                headerElement = this;
            Panel hostPanel = FindTabItemsHostPanelFromAncestor(item);
            Dock placement = this.TabStripPlacement;
            Point cornerPoint = new Point();
            double curX, curY;
            double moveCursorHitTestThreshold = MoveCursorHitTestCorrection;

            if (placement == Dock.Top || placement == Dock.Bottom)
            {
                cornerPoint.X = (pointToHead ? 0 : item.ActualWidth);
                cornerPoint.Y = (placement == Dock.Top ? item.ActualHeight - moveCursorHitTestThreshold : moveCursorHitTestThreshold);
                Point screenPoint = item.PointToScreen(cornerPoint);

                Func<FrameworkElement, Point, bool> contains =
                        (FrameworkElement elem, Point p) =>
                {
                    p = elem.PointFromScreen(p);
                    curX = (p.X == 0 ? 0 : p.X - ROUNDING_ERROR_THRESHOLD);
                    curY = (p.Y == 0 ? 0 : p.Y - ROUNDING_ERROR_THRESHOLD);

                    if (curY < 0 || elem.ActualHeight < curY)
                    {
                        return false;
                    }
                    if (pointToHead && curX < 0)
                    {
                        return false;
                    }
                    if ((!pointToHead) && (elem.ActualWidth < curX))
                    {
                        return false;
                    }
                    return true;
                };

                if ((!contains(headerElement, screenPoint)) || (!contains(hostPanel, screenPoint)))
                {
                    return false;
                }
            }
            else if (placement == Dock.Left || placement == Dock.Right)
            {
                cornerPoint.X = (placement == Dock.Left ? item.ActualWidth - moveCursorHitTestThreshold : moveCursorHitTestThreshold);
                cornerPoint.Y = (pointToHead ? 0 : item.ActualHeight);
                Point screenPoint = item.PointToScreen(cornerPoint);

                Func<FrameworkElement, Point, bool> contains =
                        (FrameworkElement elem, Point p) =>
                {
                    p = elem.PointFromScreen(p);
                    curX = (p.X == 0 ? 0 : p.X - ROUNDING_ERROR_THRESHOLD);
                    curY = (p.Y == 0 ? 0 : p.Y - ROUNDING_ERROR_THRESHOLD);
                    if (curX < 0 || elem.ActualWidth < curX)
                    {
                        return false;
                    }
                    if (pointToHead && curY < 0)
                    {
                        return false;
                    }
                    if ((!pointToHead) && (elem.ActualHeight < curY))
                    {
                        return false;
                    }
                    return true;
                };

                if ((!contains(headerElement, screenPoint)) || (!contains(hostPanel, screenPoint)))
                {
                    return false;
                }
            }

            // タブ位置の入れ替わりが無いときはfalseを返す
            if (dragged == pointing - 1)
                return !pointToHead;
            else if (dragged == pointing + 1)
                return pointToHead;
            else if (dragged != pointing)
                return true;

            return false;
        }

        private void UpdateHeaderElement(string elemName)
        {
            _headerElement = null;

            if (elemName == null)
                return;

            if (Template == null)
                throw new InvalidOperationException("HeaderElementNameはTemplate使用時にのみ指定できます。");

            Type targetType = Template.TargetType;
            Type thisType = typeof(UserControls.TabControl);
            if (targetType != thisType && (!targetType.IsSubclassOf(thisType)))
                throw new InvalidOperationException("HeaderElementName指定時はTemplateのTargetTypeは" + thisType.FullName + "でなければなりません。");

            _headerElement = this.Template.FindName(elemName, this) as FrameworkElement;
            if (_headerElement == null)
                throw new ArgumentException("指定された名前の要素が見つかりません。");
            if (!this.IsAncestorOf(_headerElement))
                throw new ArgumentException("指定する要素は" + thisType.FullName + "のTemplateに含まれていなければなりません。");
            if (FindTabItemsHostPanelFromDescendant(_headerElement) == null)
                throw new ArgumentException("指定する要素はTabItemのItemsHostを持たなければなりません。");
        }

        private Panel FindTabItemsHostPanelFromAncestor(DependencyObject obj)
        {
            while (obj != null)
            {
                Panel panel = obj as Panel;
                if (panel != null && panel.IsItemsHost)
                {
                    return panel;
                }
                obj = VisualTreeHelper.GetParent(obj);
            }

            return null;
        }

        private Panel FindTabItemsHostPanelFromDescendant(DependencyObject obj)
        {
            for (int i = 0; i < VisualTreeHelper.GetChildrenCount(obj); i++)
            {
                DependencyObject child = VisualTreeHelper.GetChild(obj, i);
                Panel panel = child as Panel;
                if (panel != null && panel.IsItemsHost)
                {
                    return panel;
                }
                DependencyObject descendant = FindTabItemsHostPanelFromDescendant(child);
                if (descendant != null)
                {
                    return (Panel)descendant;
                }
            }

            return null;
        }
    }
}

本体です。 「どうせプリフィックスを付けて名前空間で判断するだろう」ということでクラス名はTabControlのままにしました。

TabMoveCursor.cs。 移動先カーソルです。

using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Resources;

namespace UserControls
{
    /// <summary>
    /// TabMoveCursor.xaml の相互作用ロジック
    /// </summary>
    public class TabMoveCursor : Adorner
    {
        // TODO パスが変わった場合はここも書き換えること。
        private const string CURSOR_RESOURCE_PATH = "/TabMoveCursor.xaml";

        protected override int VisualChildrenCount
        {
            get
            {
                return CursorCanvas == null ? 0 : 1;
            }
        }

        private TabItem _pointedItem;
        public TabItem PointedItem
        {
            get
            {
                return _pointedItem;
            }
        }

        private bool _pointToHead;
        public bool PointToHead
        {
            get
            {
                return _pointToHead;
            }
        }

        private Canvas _cursorCanvas = null;
        public Canvas CursorCanvas
        {
            get
            {
                return _cursorCanvas;
            }
            set
            {
                _cursorCanvas = value;
                InvalidateVisual();
            }
        }

        public TabMoveCursor(UserControls.TabControl tabControl)
            : base(tabControl)
        {
            Visibility = Visibility.Hidden;

            Uri uri = new Uri(CURSOR_RESOURCE_PATH, UriKind.Relative);
            StreamResourceInfo info = Application.GetResourceStream(uri);
            XamlReader reader = new XamlReader();
            CursorCanvas = reader.LoadAsync(info.Stream) as Canvas;
        }

        public void Hide()
        {
            _pointedItem = null;
            Visibility = Visibility.Hidden;
        }

        // MoveCursorをitemのところに移動させる。
        // MoveCursorの位置は、マウスカーソルがitemの前半を指していた(pointToHead==true)ら前角、
        // 後半を指していたら後角。
        public void MoveTo(TabItem item, bool pointToHead, bool isVisible)
        {
            _pointedItem = item;
            _pointToHead = pointToHead;
            Visibility = (isVisible ? Visibility.Visible : Visibility.Hidden);

            AdornerLayer layer = this.Parent as AdornerLayer;
            layer.Update();
        }

        public void Rotate(double angle)
        {
            _cursorCanvas.RenderTransform = new RotateTransform(angle);
        }

        public override GeneralTransform GetDesiredTransform(GeneralTransform transform)
        {
            Debug.Assert(_cursorCanvas != null);
            if (Visibility != Visibility.Visible)
                return null;

            UserControls.TabControl tabControl = (UserControls.TabControl)AdornedElement;

            Point p = new Point();
            switch (tabControl.TabStripPlacement)
            {
            case Dock.Top:
                p.X = _pointToHead ? 0 : _pointedItem.ActualWidth;
                p.Y = _pointedItem.ActualHeight;
                break;
            case Dock.Left:
                p.X = _pointedItem.ActualWidth;
                p.Y = _pointToHead ? 0 : _pointedItem.ActualHeight;
                break;
            case Dock.Right:
                p.X = 0;
                p.Y = _pointToHead ? 0 : _pointedItem.ActualHeight;
                break;
            case Dock.Bottom:
                p.X = _pointToHead ? 0 : _pointedItem.ActualWidth;
                p.Y = 0;
                break;
            }

            p = _pointedItem.PointToScreen(p);
            p = tabControl.PointFromScreen(p);

            GeneralTransformGroup result = new GeneralTransformGroup();
            result.Children.Add(base.GetDesiredTransform(transform));
            result.Children.Add(new TranslateTransform(p.X,p.Y));
            return result;
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            if(_cursorCanvas != null)
                _cursorCanvas.Arrange(new Rect()); // なんか入れればいいらしい(Canvasだから?)

            return finalSize;
        }

        protected override Visual GetVisualChild(int index)
        {
            if (_cursorCanvas == null)
                throw new IndexOutOfRangeException("子要素はない");
            if (index != 0)
                throw new IndexOutOfRangeException("子要素は1つのみ");
            return _cursorCanvas;
        }
    }
}

TabMoveCursor.xaml。 デフォルトの移動先カーソルが書かれています。 ソリューションエクスプローラでxamlファイルのプロパティを選んで、ビルドアクションをResourceにして使います。

<Canvas xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
>
    <!--
        TabMoveCursor.csで使用されるデフォルトカーソル。
        カーソルの変更はUserControls.TabControl.MoveCursorCanvasの指定を推奨。
        こちらを書き換える場合はホットスポットが(0,0)になるように調節すること。
    -->
    <Polygon
        Points="0,0 -10,10 10,10"
        Fill="Black"
        Stroke="White"
        StrokeThickness="1"
    />
</Canvas>

TabDraggingCursorAdorner.cs。 ゴーストを描くコードです。

using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Shapes;

namespace UserControls
{
    public class TabDraggingCursorAdorner : Adorner
    {
        private Rectangle _rect;
        private double _leftBase;
        private double _topBase;
        private double _left;
        private double _top;

        public TabDraggingCursorAdorner(UserControls.TabControl tabControl)
            : base(tabControl)
        {
            Visibility = Visibility.Hidden;

            _rect = new Rectangle();
        }

        public void Show(TabItem item, Point position)
        {
            UserControls.TabControl ownerTab = AdornedElement as UserControls.TabControl;
            Debug.Assert(ownerTab != null);

            VisualBrush brush = new VisualBrush(item);
            if (ownerTab.DraggingCursorAdornerOpacityMask != null)
                _rect.OpacityMask = ownerTab.DraggingCursorAdornerOpacityMask;
            _rect.Fill = brush;
            _rect.Width = item.ActualWidth;
            _rect.Height = item.ActualHeight;

            Visibility = Visibility.Visible;

            Point offset = ownerTab.DraggingCursorAdornerOffset;
            _leftBase = (double.IsNaN(offset.X) ? 0 : offset.X);
            _topBase = (double.IsNaN(offset.Y) ? item.ActualHeight / 2.0 : offset.Y);
            _left = position.X;
            _top = position.Y;

            Update();
        }

        public void Move(Point position)
        {
            _left = position.X;
            _top = position.Y;

            Update();
        }

        public void Hide()
        {
            Visibility = Visibility.Hidden;
            _rect.Fill = null;
            _rect.Width = 0;
            _rect.Height = 0;
        }

        public void Update()
        {
            AdornerLayer layer = this.Parent as AdornerLayer;
            if (layer != null)
                layer.Update(this.AdornedElement);
        }

        public override GeneralTransform GetDesiredTransform(GeneralTransform transform)
        {
            GeneralTransformGroup result = new GeneralTransformGroup();
            result.Children.Add(base.GetDesiredTransform(transform));
            result.Children.Add(new TranslateTransform(
                    _leftBase + _left,
                    _topBase + _top
            ));
            return result;
        }
        
        protected override Size ArrangeOverride(Size finalSize)
        {
            _rect.Arrange(new Rect(_rect.DesiredSize));
            return finalSize;
        }

        protected override int VisualChildrenCount
        {
            get
            {
                return 1;
            }
        }

        protected override Visual GetVisualChild(int index)
        {
            return _rect;
        }
    }
}

TabItemIsDraggingToMove.cs。 TabItemがマウスでつかまれているかどうかを表す添付プロパティです。

using System.Windows;
using System.Windows.Controls;

namespace UserControls
{
    public static class TabItemIsDraggingToMove
    {
        // TabItem用の添付プロパティ。
        // TabItemが移動のためドラッグされている間はtrueとなる。
        public static readonly DependencyProperty ValueProperty = DependencyProperty.RegisterAttached(
                "Value",
                typeof(bool),
                typeof(TabItemIsDraggingToMove),
                new FrameworkPropertyMetadata(false)
        );

        [AttachedPropertyBrowsableForType(typeof(TabItem))]
        public static bool GetValue(DependencyObject obj)
        {
            return (bool)obj.GetValue(ValueProperty);
        }

        [AttachedPropertyBrowsableForType(typeof(TabItem))]
        public static void SetValue(DependencyObject obj, bool value)
        {
            obj.SetValue(ValueProperty, value);
        }
    }
}

最後にWin32dll.cs。 ドラッグ中のマウス座標はwpfでは調べることが出来ないので要るらしい。

using System;
using System.Runtime.InteropServices;
using System.Windows.Media;

namespace Util
{
    public class Win32dll
    {
        [DllImport("user32.dll", CallingConvention = CallingConvention.StdCall)]
        private static extern void GetCursorPos(out Win32dll.Point pt);

        public struct Point
        {
            public UInt32 X;
            public UInt32 Y;
        }

        public static System.Windows.Point GetCursorPos(Visual visual)
        {
            Win32dll.Point p;
            GetCursorPos(out p);
            return visual.PointFromScreen(new System.Windows.Point(p.X, p.Y));
        }
    }
}

勢いで自分で使う以上の機能を実装してコードが膨れ上がってしまいました。 ちょっと後悔。 そして微妙なレイアウトの調整をしないと動かない可能性が...

これはPointToScreenなどの座標変換で誤差が生じるからなんですよね。 一応、多少の誤差があっても動くような工夫はしているんで色々大丈夫なはずです。 ただ、色んな環境で試す機会がないので断言はできません。 Windows7のdpiの設定を変えると動かない可能性とかがあります。 コピー&ペーストで使いたい人は各自要チェックでお願いします。

コードを書くにあたって参考にしたのはこのページです。

自分の過去の投稿内容も踏まえて作っています。

以下、使い方です。 基本的に標準のTabControlと同じように使えます。 過去の投稿を踏まえて作ったのでTemplateやStyleも記述可能です。 (ただしデフォルトでItemContainerStyleを指定しているので、スタイルの書き方によってはそちらが優先されてしまうかもしれません。)

投稿の最初に挙げたスナップショットを録った時の簡単なサンプルはこのようになっています。 MainWindow.xamlは、

<Window x:Class="CustomTabControl_ReplaceOnMouseDragging.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:appCtrls="clr-namespace:UserControls"
        Title="MainWindow" Height="350" Width="525"
>
    <appCtrls:TabControl x:Name="tabControl">
        <appCtrls:TabControl.ItemTemplate>
            <DataTemplate>
                <StackPanel>
                    <TextBlock Text="{Binding}"/>
                    <Image Width="64" Height="48" Source="{Binding Bitmap}"/>
                </StackPanel>
            </DataTemplate>
        </appCtrls:TabControl.ItemTemplate>
        <appCtrls:TabControl.ContentTemplate>
            <DataTemplate>
                <Image Source="{Binding Bitmap}"/>
            </DataTemplate>
        </appCtrls:TabControl.ContentTemplate>
    </appCtrls:TabControl>
</Window>

MainWindow.xaml.csは、

// 余分なusingが混じっているかも...
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;

namespace CustomTabControl_ReplaceOnMouseDragging
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            DirectoryInfo di = new DirectoryInfo("画像の入ったフォルダ");
            FileInfo[] fileList = di.GetFiles("*.jpg");
            fileList = fileList.Concat(di.GetFiles("*.png")).ToArray<FileInfo>();
            fileList = fileList.Concat(di.GetFiles("*.gif")).ToArray<FileInfo>();

            ObservableCollection<Content> items = new ObservableCollection<Content>();
            foreach (FileInfo file in fileList)
                items.Add(new Content(file));
            tabControl.ItemsSource = items;
        }
    }
}

Content.csは、

using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Windows.Media.Imaging;

namespace CustomTabControl_ReplaceOnMouseDragging
{
    public class Content
    {
        public FileInfo ImageFile { get; private set; }
        public BitmapFrame Bitmap { get; private set; }

        public Content(FileInfo file)
        {
            ImageFile = file;
            Bitmap = BitmapFrame.Create(new Uri(file.FullName, UriKind.Absolute));
        }

        public override string ToString()
        {
            Regex r = new Regex(@"(\.jpg|\.gif|\.png)$");
            return r.Replace(ImageFile.Name, "");
        }
    }
}

ここまでは標準のTabControlと全く同じ使い方です。 マウスドラッグで並び替えをする機能が追加されているだけの状態。

xamlなどでプロパティを指定すれば簡単なカスタマイズができます。 説明はコード内のコメントを読んでもらうとして、それだけでは分からないような項目だけ補足。

MoveCursorを変更するにはMoveCursorCanvasを書きます。

<appCtrls:TabControl>
    <appCtrls:TabControl.MoveCursorCanvas>
        <Canvas>
            ホットスポットが(0,0)になるように調節して絵を描く
        </Canvas>
    </appCtrls:TabControl.MoveCursorCanvas>
    ~略~
</appCtrls:TabControl>

これはTabMoveCursor.xamlを書き換えてもいいです。 けど、まぁ、デフォルトを書き換えると後で戻したくなったときに困る可能性があるのでこちらを書いた方がいいかな?

次。 ドラッグ中のTabItemのスタイルを変えたい場合はこんな感じに書きます。

<appCtrls:TabControl>
    <appCtrls:TabControl.Resources>
        <DataTemplate x:Key="templateTabItem">
            ~略~
        </DataTemplate>
        <DataTemplate x:Key="templateDraggingTabItem">
            ~略~
        </DataTemplate>
    </appCtrls:TabControl.Resources>
    <appCtrls:TabControl.ItemContainerStyle>
        <Style TargetType="TabItem">
            <Setter Property="HeaderTemplate" Value="{StaticResource templateTabItem}"/>
            <Style.Triggers>
                <Trigger Property="appCtrls:TabItemIsDraggingToMove.Value" Value="True">
                    <Setter Property="HeaderTemplate" Value="{StaticResource templateDraggingTabItem}"/>
                    <Setter Property="Opacity" Value="0.5"/>
                </Trigger>
            </Style.Triggers>
        </Style>
    </appCtrls:TabControl.ItemContainerStyle>
    <appCtrls:TabControl.ContentTemplate>
        <DataTemplate>
            ~略~
        </DataTemplate>
    </appCtrls:TabControl.ContentTemplate>
</appCtrls:TabControl>

TabItemの添付プロパティTabItemIsDraggingToMoveでTabItemがドラッグ中かどうかを調べられます。 それをトリガーにしてHeaderTemplateを変更。 HeaderTemplateを使う場合は、優先順位の関係でTabControl.ItemTemplateは使わない方がよさそうです。 TabControl.ContentTemplateはそのままでOK。

デフォルトではゴーストはTabItemのヘッダーと全く同じ見た目になります。 そのためTabItem.Opacityを指定しないとゴーストは半透明になりません。 それで↑のサンプルではドラッグ中の TabItem.Opacity=0.5 を指定しています。

ヘッダーは不透明にしてゴーストだけ半透明にしたい場合は、TabItem.Opacityを指定せずに、 TabControl.DraggingCursorAdornerOpacityMaskを指定します。 このプロパティはUIElement.OpacityMaskと同様Brush型で「ゴーストのどの部分をどれだけ透過するか」を指定できます。 指定されたBrushのアルファ値のみを使用し、その他の色要素(RGB)は無視されます。

<appCtrls:TabControl DraggingCursorAdornerOpacityMask="#80000000"/>

TabItem.Opacityを指定すると透明度がかけ合わせられるので注意。 ↑の例はSolidColorBrushでゴースト全体の透明度を指定していますが、LinearGradientBrushなども指定できます。 例えば、タブヘッダーの幅が大きくなりすぎたときのことを考慮してゴーストの表示幅を制限したい場合は、

<appCtrls:TabControl>
    <appCtrls:TabControl.DraggingCursorAdornerOpacityMask>
        <LinearGradientBrush
                MappingMode="Absolute"
                StartPoint="0,0"
                EndPoint="64,0"
        >
            <GradientStop Offset="0.0" Color="#a0000000" />
            <GradientStop Offset="0.7" Color="#a0000000" />
            <GradientStop Offset="1.0" Color="#00000000" />
        </LinearGradientBrush>
    </appCtrls:TabControl.DraggingCursorAdornerOpacityMask>
</appCtrls:TabControl>

最後にHeaderElementNameプロパティについて。 コード内のコメントだけでは分かりづらいので補足です。 このプロパティは「TabItemの移動先が有効かどうか?」を判定するときに使うコントロールを指定します。

まず、デフォルトのレイアウトでTabItemを移動するとこんな風になるコードがあって、

それをTemplateを書き換えて右肩に別のコントロールを載せる(参考)なりマージンを取るなりしたとします。

↑の図の青い斜線のところが別のコントロールまたはマージンを載せたところだと思ってください。 「狭い路地と店」のタブヘッダーは全部表示されていない状況です。 何も判定しないと表示されていない右下の角に移動カーソルが表示されてしまいます。 これは不自然です。

そういう不自然なカーソル表示にならないように、このカスタムTabControlでは移動先のTabItemの角が表示されているかをチェックしています。 チェックするのは三角形の移動先カーソルが実際に指す座標です。 たとえば、TabControl.TabStripPlacement=Dock.Top なら下の左右の角が表示されているかチェックします。

「ItemsHostのパネルと、HeaderElementNameで指定したコントロール(省略可)の両方の領域内に角が表示されているなら移動可能」という判定をしています。 このため、TabItemの四隅が表示されるようにレイアウトを調整しないと正常に動きません。 ちょっとしたマージンのとり方で四隅が隠れてしまうことがあるので要調整。

このチェックコードのせいで丸め誤差の影響を受けるようになってしまいました。 (一応対策っぽいことはしたけど。) 上の方で「Windows7のdpiの設定を変えると動かない可能性」とかって書いているのはこのためです。

もし「Templateの書き換えとかしないからこのチェックコードいらない」ってときはIsMoveCursorVisibleメソッドの中の該当部分をコメントアウトしたら、dpiうんぬんの心配なしにこのコードを使えます。