Prolog
Auf der Suche nach einer Möglichkeit, Zeilen innerhalb eines DataGrids per Drag&Drop zu verschieben, bin ich meistens auf Implementierungen gestoßen, die auf MouseUp- bzw MouseDown-Events reagieren. In dem Fall muss man sich natürlich um die ausgewählten Elemente selbst kümmern, die Selektion zwischenspeichern… sollte das WPF nicht mittlerweile selbst können? Und wie spricht man diese Funktionalität nun unter Caliburn.Micro an?
Die Spielwiese
Das Datenmodell bildet eine einfache Klasse GridItem, die Elemente werden in GridItems zu einer Liste zusammengefaßt und mit rudimentären Speicher- und Ladefunktionen versehen.
Um überhaupt eine Form der Reihenfolge zu ermöglichen, werden die Elemente beim Zugriff über ihre Id sortiert:
public class GridItems { List<GridItem> _items; public List<GridItem> Items { get { if (_items==null ) Load(); var query = from item in _items orderby item.Id select item; return query.ToList(); } } ...
Damit die Elemente im Grid bearbeitet werden können, bildet ein GridItemRowViewModel die Properties des GridItem mit den nötigen Aufrufen von NotifyOfPropertyChange ab. Das Bereitstellen der ItemSource für das DataGrid erfolgt ebenfalls per Linq
public class ShellViewModel : Screen, IShell { readonly GridItems _gridItems = new GridItems(); ObservableCollection<GridItemRowViewModel> _gridRows; public ObservableCollection<GridItemRowViewModel> GridRows { get { return _gridRows ?? (_gridRows = new ObservableCollection<GridItemRowViewModel>( _gridItems.Items.Select(x => new GridItemRowViewModel(x)))); } } ...
Die Properties werden eins zu eins im DataGrid abgebildet. Der SelectionMode wird hier auf Single gesetzt, es sollen nur einzelne Zeilen verschoben werden können. Wichtig hier ist natürlich, AllowDrop auf True zu setzen.
<DataGrid Name=„DataItemsGrid“ Margin=„5“ AutoGenerateColumns=„False“
AllowDrop=„True“ RowHeight=„16“ AlternationCount=„2“ AlternatingRowBackground=„#0F000000“ CanUserAddRows=„false“ CanUserResizeRows=„False“ SelectionMode=„Single“ RowStyle=„{StaticResource RowStyle}“ RowHeaderStyle=„{StaticResource RowHeaderStyle}“ ItemsSource=„{Binding Path=GridRows}“> <DataGrid.Columns> <DataGridTextColumn Header=„Id“ IsReadOnly=„True“ Binding=„{Binding Id}“/> …
Das Draggen soll in diesem Beispiel mit den beiden ReadOnly-Feldern Id und Name möglich sein. Man spart sich ein zusätzliches Icon-Feld, das nur zum “Anfassen” dient.
Kleine Helfer
Bei den verschiedenen Mausklicks muß man auf verschiedene Ebenen innerhalb des VisualTrees zugreifen. Einmal braucht man den Zugriff auf die geklickte Zelle, ein anderes mal die komplette Zeile. Eine generische Suchfunktion bietet sich geradezu an:
static T FindElementFromSource<T>(DependencyObject source) where T: UIElement { var dep = source; while ((dep != null) && !(dep is T)) dep = VisualTreeHelper.GetParent(dep); return dep as T; }
Als kleinen Sonderfall benötigt man noch das ViewModel einer GridRow:
static GridItemRowViewModel GetViewModelOfEvent(DependencyObject source) { var row = FindElementFromSource<DataGridRow>(source); if (row == null) return null; return row.DataContext as GridItemRowViewModel; }
Wenn wir uns den theoretischen Code eines beliebigen DragEvents anschauen, fällt einem direkt die stetige Null-Überprüfung ins Auge:
public void OnDragXXX(DragEventArgs e) { var dataObject = e.Data as DataObject; if (dataObject == null) return; var draggedItem = dataObject.GetData(typeof (DraggedGridItem)) as DraggedGridItem; if (draggedItem == null) return; var row = FindElementFromSource<DataGridRow>(source); if (row == null) return; ... // Do the action
Es kristallisieren sich 3 Grundfunktionalitäten heraus: es soll etwas mit dem im Event übermittelten DraggedGridItem geschehen, dadurch wird etwas in der Zeile geändert oder im zugrunde liegenden ViewModel der Zeile. Verknüpft man das mit anonymen Methoden, genügen die entsprechenden Funktionen:
static void WithDragItemDo(DragEventArgs e, Action<DraggedGridItem> action) { var dataObject = e.Data as DataObject; if (dataObject == null) return; var draggedItem = dataObject.GetData(typeof (DraggedGridItem)) as DraggedGridItem; if (draggedItem == null) return; action(draggedItem); } static void WithDragItemsDo(DragEventArgs e, Action<DraggedGridItems> action) { var dataObject = e.Data as DataObject; if (dataObject == null) return; var draggedItems = dataObject.GetData(typeof (DraggedGridItems)) as DraggedGridItems; if (draggedItems == null) return; action(draggedItems); } static void WithRowFromSourceDo(DependencyObject source, Action<DataGridRow> action) { var row = FindElementFromSource<DataGridRow>(source); if (row == null) return; action(row); }
Ziehen und Fallenlassen
Mit diesen Hilfsfunktionen besteht das Draggen nur noch aus der Visualisierung der betroffenen Zeilen. In diesem Fall soll lediglich die Hintergrundfarbe geändert werden. Die alte Hintergrundfarbe wird hierbei in den Event-Parametern selbst gespeichert.
public void OnDragEnter(DragEventArgs e) { WithDragItemDo(e, di=> WithRowFromSourceDo((DependencyObject)e.OriginalSource, row=> { di.OldBackground = row.Background; row.Background = Brushes.Aquamarine; })); } public void OnDragLeave(DragEventArgs e) { WithDragItemDo(e, di => WithRowFromSourceDo((DependencyObject)e.OriginalSource, row => { if (di.OldBackground != null) row.Background = di.OldBackground; })); }
Beim Fallenlassen wird der neue Index des Elements berechnet. Sofern er sich geändert hat, wird die gesamte Liste neu durchnummeriert:
public void OnDragDrop(DragEventArgs e) { OnDragLeave(e); WithDragItemDo(e, di=> WithViewModelFromSourceDo((DependencyObject)e.OriginalSource, vm => { var newIndex = GridRows.IndexOf(vm); if (newIndex == di.Index) return; GridRows.RemoveAt(di.Index); GridRows.Insert(newIndex, di.ViewModel); for (var i = 0; i < GridRows.Count; i++) GridRows[i].Id = i; })); }
View-Events
Bislang weiß die View natürlich noch nichts von diesen Event-Methoden. Caliburn.Miro muß noch angewiesen werden, die entsprechenden Aktionen an das ViewModel weiterzuleiten. Hierfür wird eine Referenz auf System.Windows.Interactivity benötigt, hierin werden die Interaction-Trigger bereitgestellt:
<Window x:Class=„DataGridDragDrop.Views.ShellView“
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:i=„clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity“
xmlns:cal=„clr-namespace:Caliburn.Micro;assembly=Caliburn.Micro“>
<DataGrid Name=„DataItemsGrid“ …> <i:Interaction.Triggers> <i:EventTrigger EventName=„PreviewMouseLeftButtonDown“> <cal:ActionMessage MethodName=„OnMouseDown“> <cal:Parameter Value=„$eventargs“ /> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName=„DragEnter“> <cal:ActionMessage MethodName=„OnDragEnter“> <cal:Parameter Value=„$eventargs“/> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName=„DragLeave“> <cal:ActionMessage MethodName=„OnDragLeave“> <cal:Parameter Value=„$eventargs“ /> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName=„PreviewDrop“> <cal:ActionMessage MethodName=„OnDragDrop“> <cal:Parameter Value=„$eventargs“/> </cal:ActionMessage> </i:EventTrigger> </i:Interaction.Triggers> <DataGrid.Columns> …
Ein kurzes Durchzählen der hier aufgeführten Events bestätigt, daß noch ein Event-Handler fehlt. Das Initialisieren des Drag&Drop-Effekts geschieht nach wie vor durch den MouseDown-Event. Je nach dem, ob das Klicken auf einer schreibgeschützten Zelle stattfand, wird entweder das Draggen initialisiert oder direkt in den Bearbeitungsmodus der Zelle gewechselt:
public void OnMouseDown(MouseButtonEventArgs e) { var dataGrid = (DataGrid) e.Source; if (dataGrid == null) return; var cell = FindElementFromSource<DataGridCell>((DependencyObject) e.OriginalSource); if (cell == null) return; if (!cell.Column.IsReadOnly) { cell.Focus(); dataGrid.BeginEdit(); return; } var viewModel = GetViewModelOfEvent((DependencyObject) e.OriginalSource); var index = GridRows.IndexOf(viewModel); var draggedElement = new DraggedGridItem {ViewModel = viewModel, Index = index}; var dataObject = new DataObject(typeof (DraggedGridItem), draggedElement); DragDrop.DoDragDrop(dataGrid, dataObject, DragDropEffects.Move); }
Multiselection
Mit oben genannter Lösung lassen sich leicht einzelne Elemente in ihrer Reihenfolge sortieren. Setzt man den SelectionMode des DataGrids jedoch auf Extended, funktioniert der Ansatz nicht mehr. Natürlich kann man sich im Datenobjekt des DragEvents die Liste der selektierten Einträge merken. Das Problem ist vielmehr, daß nun bei jedem Mausklick in das Grid jede selektierte Zeile in eine Drag-Aktion verpackt wird, die sofort als Drop interpretiert wird – ein richtiges Ziehen und Loslassen erfolgt in diesem Fall nicht mehr.
Wer an dieser Stelle weiter experimentieren möchte: das Projekt ist unter https://github.com/Slesa/Playground/tree/master/sketches/Caliburn.Micro/DataGridDragDrop abrufbar.