Blog

Selektionen in WPF

Jörg Preiß
27. Februar 2013

Es gibt Funktionalitäten, die man früher oder später immer mal wieder braucht. Das korrekte Behandeln von Selektionen in WPF ist hierfür ein gutes Beispiel. Durchsucht man allerdings das Internet nach Lösungsansätzen, so lässt sich feststellen, dass die meisten Vorschläge zu Brüchen mit dem MVVM-Pattern oder unerwünschten Seiteneffekten führen. Dieser Artikel setzt sich zum Ziel, geeignete Methoden zur Arbeit mit Selektionen in der WPF vorzustellen.

Selektion innerhalb einer ListBox

Die Selektion innerhalb einer ListBox ist eigentlich eine einfache Sache. Die Einträge einer ListBox werden durch ihre ItemsSource-Eigenschaft an eine ObservableCollection des ViewModels gebunden. Durch den DisplayMemberPath wird das innerhalb einer ListBox-Zeile auszugebende Property bestimmt, im vorliegenden Beispiel Name. Weiterhin wird die aktuelle Selektion der ListBox an das Property SelectedItem gebunden. Somit ergibt sich als ViewModel die folgende Klasse:

public class SingleViewModel 
{
  ObservableCollection<ItemViewModel> _items;
  public ObservableCollection<ItemViewModel> Items
  {
    get { return _items ?? (_items = CollectionCreator.CreateItems(200)); } 
  }
  public ItemViewModel SelectedItem { get; set; }
}

Ein Eintrag in der OberservableCollection ist wiederum ein ViewModel. Es besteht nur aus dem Property Name:

public class ItemViewModel
{
  public ItemViewModel(string name)
  {
    Name = name;
  }
  public string Name { get; private set; }
}

Die View stellt zu Demonstrationszwecken das ausgewählte Element der ListBox als Text dar.

<DockPanel DataContext="{StaticResource ViewModel}">
  <TextBlock DockPanel.Dock="Bottom" 
    Text="{Binding SelectedItem.Name}" 
    Height="25"/>
    <ListBox ItemsSource="{Binding Items}" 
      DisplayMemberPath="Name"
      SelectedItem="{Binding SelectedItem}" />
</DockPanel>

Nachdem wir dem Datenkontext der View eine Instanz seines SingleViewModels zugewiesen haben, sehen wir, dass der Zugriff auf die aktuelle Selektion auf diese Weise bereits funktioniert.

Beispiel: Selektion einer ListBox

Selektion innerhalb einer TreeView

Das bisherige Beispiel bedient sich einer einfachen Liste. Was aber, wenn unsere Daten in einer zweistufigen Hierarchie ohne Gruppierung vorliegen? Für diesen Fall erweitern wir unser ItemViewModel um ein umschließendes NodeViewModel:

public class NodeViewModel
{
  public NodeViewModel(string name)
  {
    Name = name;
  }
 
  public string Name { get; internal set; }
  public ObservableCollection<ItemViewModel> Leafs { get; set; }
}

Wir erweitern das ViewModel unserer TreeView um eine zweite Selektionsmöglichkeit:

public class TreeViewModel 
{
  ObservableCollection<NodeViewModel> _nodes;
  public ObservableCollection<NodeViewModel> Nodes
  {
    get { return _nodes ?? (_nodes = CollectionCreator.CreateNodes(100, 5)); }
  }
 
  public NodeViewModel SelectedNode { get; set; }
  public ItemViewModel SelectedLeaf { get; set; }
 
  object _selectedItem;
  public object SelectedItem
  {
    get { return _selectedItem; }
    set
    {
      SelectedLeaf = value as ItemViewModel;
      SelectedNode = value as NodeViewModel;
    }
  }
}

Beim Versuch, den selektierten Eintrag einfach per Bindung zu setzen, werden wir herb enttäuscht: das Property SelectedItem ist schreibgeschützt und kann nicht geändert werden. Durchsuchen wir das Internet nach diesem Problem, finden wir den Hinweis, das IsSelected-Property eines jeden TreeViewItems auf ein entsprechendes Property im jeweils zugehörigen NodeViewModel abzubilden. Hiervon ist jedoch aus mehreren Gründen abzuraten:

  • Falls ein Command von dieser Selektion abhängig ist und sein CanExecute auf einen SelectionChangedEvent reagieren soll, müssten alle Items dem ViewModel eine Änderung ihres Status mitteilen.
  • Die Selektionseigenschaft sollte schon konzeptuell nicht am Item hängen. Dies verbietet es mitunter, zwei Listen mit identischen Item-ViewModels zu füllen, die Selektionen jedoch separat zu halten, wie dies beispielsweise in einer Implementierung des List-Builder Patterns sinnvoll sein kann.
  • Wie man weiter unten bei der Mehrfachselektion selbst probieren kann, zerstört man entweder die Virtualisierung der Liste, was zu enormen Performance-Einbußen führen kann, oder man erhält unzuverlässige Ergebnisse. Beispielsweise werden bei einem SelectAll-Kommando die ersten 20 Items als selektiert gemeldet, obwohl die Liste 100 Items beinhaltet. Erst beim Herunterscrollen werden wirklich alle Items als selektiert gemeldet. Der Grund ist, dass die übrigen 80 Items aufgrund der Virtualisierung ja noch gar nicht instanziiert worden sind, die Bindings an das IsSelected-Property demnach auch noch gar nicht etabliert worden sind.

Man kann durchaus diese Möglichkeit wählen, wenn man sich der Konsequenzen bewusst ist. Allerdings ist die Alternativmöglichkeit gar nicht so kompliziert: man implementiert ein Behavior, welches sich um die Synchronisation des Selektionsstatus der TreeViewItems kümmert und zwischen TreeView und deren ViewModel vermittelt, statt auf ein direktes Binding zurückzugreifen.

Einziger Nachteil ist eine zusätzliche Abhängigkeit zur Assembly System.Windows.Interactivity.dll.

Im XAML Code selbst werden für diesen Ansatz lediglich 3 zusätzliche Zeilen benötigt:

<DockPanel DataContext="{StaticResource ViewModel}">
  <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Height="25">
    <TextBlock Text="{Binding SelectedNode.Name}" />
    <TextBlock Text="{Binding SelectedLeaf.Name}" />
  </StackPanel>
  <TreeView ItemsSource="{Binding Nodes}">
    <TreeView.ItemTemplate>
      <HierarchicalDataTemplate ItemsSource="{Binding Leafs}">
        <TextBlock Text="{Binding Path=Name}" />
      </HierarchicalDataTemplate>         
    </TreeView.ItemTemplate>
    <i:Interaction.Behaviors>
      <behaviors:SelectedItemBehavior SelectedItem="{Binding SelectedItem}" />
    </i:Interaction.Behaviors>
  </TreeView>
</DockPanel>

Das Behavior selbst registriert lediglich das DependencyProperty SelectedItem und registriert sich selbst auf die Änderungen des selektierten Items der TreeView. Der entsprechende Event-Handler kümmert sich darum, das selektierte Item zwischen TreeView und TreeViewModel zu synchronisieren:

public class SelectedItemBehavior : Behavior<TreeView>
{
  public static readonly DependencyProperty SelectedItemProperty =
    DependencyProperty.Register("SelectedItem"
      , typeof(object)
      , typeof(SelectedItemBehavior)
      , new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemChanged));
 
  static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var item = e.NewValue as TreeViewItem;
    if (item != null)
      item.SetValue(TreeViewItem.IsSelectedProperty, true);
  }
 
  public object SelectedItem
  {
    get { return (object)GetValue(SelectedItemProperty); }
    set { SetValue(SelectedItemProperty, value); }
  }
 
  protected override void OnAttached()
  {
    base.OnAttached();
    AssociatedObject.SelectedItemChanged += OnTreeViewSelectedItemChanged;
  }
 
  protected override void OnDetaching()
  {
    base.OnDetaching();
    if (AssociatedObject != null)
    {
      AssociatedObject.SelectedItemChanged -= OnTreeViewSelectedItemChanged;
    }
  }
 
  void OnTreeViewSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
  {
    SelectedItem = e.NewValue;
  }
}

Wir sehen, je nach Art des selektierten Items, den entsprechenden Text in der Fußzeile:

Beispiel: Selektion eines TreeViews

Mehrfachselektion

Wir konnten bereits bei Einfachselektionen eine Redundanz bei der Haltung von Selektionsinformationen feststellen – dieser Nachteil fällt bei Mehrfachselektionen noch stärker ins Gewicht. Denn bei jeder Abfrage, ob eine Selektion vorliegt, muss der Selektionsstatus jedes einzelnen Items abgefragt werden, beispielsweise durch Items.Any(x=>x.IsSelected). Sobald ein Kommando auf der Selektion ausgeführt werden soll, ist wiederum eine Suche nach denjenigen Items nötig,die als selektiert markiert wurden, beispielsweise mittels var selection = Items.Where(x=>x.IsSelected). Hinzu kommt die oben erwähnte Problematik der Virtualisierung – das Selektions-Property wird entweder „unzuverlässig“, oder die TreeView büßt Performance ein.

Auch hier hilf ein Behavior weiter. Es verbindet sich mit dem SelectionChanged-Event der ListBox und synchronisiert so die Liste der selektierten Items der ListBox mit dem ViewModel. Dies sollte natürlich in beide Richtungen geschehen: wenn das ViewModel die Selektion ändert, muss sich dies auch in der ListBox widerspiegeln und umgekehrt.

Das hier gezeigte SynchronizeSelectedItems-Behavior stammt inklusive des WeakEventHandlers aus der Referenzimplementation von Prism, MVVM RI. Man mag sich fragen, warum diese Klasse nicht zum Lieferumfang der WPF gehört. Andererseits können wir froh sein, dass sie es nicht tut, sind doch in der aktuellen Version von Prism zwei Fehler enthalten. Die Änderungen der Selektion im ViewModel beeinflussen eben nicht die verbundene ListBox. Bei einem Update wird die aktuelle Selektion gelöscht und alle Änderungen der Selektionsliste neu eingespielt – was bei einem SelectAll zu langer Laufzeit und fehlerhaftem Verhalten führt. Außerdem wird beim Detach der Eventhandler ein zweites Mal an das SelectionChanged gebunden, statt den Handler abzumelden.

Es folgt eine Version des SynchronizeSelectedItems-Behavior, die diese Problematik beseitigt:

public class SynchronizeSelectedItems : Behavior<ListBox>
{
  public static readonly DependencyProperty SelectionsProperty =
    DependencyProperty.Register(
      "Selections",
      typeof(IList),
      typeof(SynchronizeSelectedItems),
      new PropertyMetadata(null, OnSelectionsPropertyChanged));
 
  bool _updating;
  WeakEventHandler<SynchronizeSelectedItems, object, NotifyCollectionChangedEventArgs> _currentWeakHandler;
 
  [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "Dependency property")]
  public IList Selections
  {
    get { return (IList)GetValue(SelectionsProperty); }
    set { SetValue(SelectionsProperty, value); }
  }
 
  protected override void OnAttached()
  {
    base.OnAttached();
    AssociatedObject.SelectionChanged += OnSelectedItemsChanged;
    UpdateSelectedItems();
  }
 
  protected override void OnDetaching()
  {
    AssociatedObject.SelectionChanged -= OnSelectedItemsChanged;
    base.OnDetaching();
  }
 
  static void OnSelectionsPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var behavior = d as SynchronizeSelectedItems;
 
    if (behavior != null)
    {
      if (behavior._currentWeakHandler != null)
      {
        behavior._currentWeakHandler.Detach();
        behavior._currentWeakHandler = null;
      }
      if (e.NewValue != null)
      {
        var notifyCollectionChanged = e.NewValue as INotifyCollectionChanged;
        if (notifyCollectionChanged != null)
        {
          behavior._currentWeakHandler =
            new WeakEventHandler<SynchronizeSelectedItems, object, NotifyCollectionChangedEventArgs>(
              behavior,
              (instance, sender, args) => instance.OnSelectionsCollectionChanged(sender, args),
              listener => notifyCollectionChanged.CollectionChanged -= listener.OnEvent);
              notifyCollectionChanged.CollectionChanged += behavior._currentWeakHandler.OnEvent;
        }
        behavior.UpdateSelectedItems();
      }
    }
  }
 
  void OnSelectedItemsChanged(object sender, SelectionChangedEventArgs e)
  {
    UpdateSelections(e);
  }
 
  void UpdateSelections(SelectionChangedEventArgs e)
  {
    ExecuteIfNotUpdating(
      () =>
        {
          if (Selections != null)
          {
            foreach (var item in e.AddedItems)
            {
              Selections.Add(item);
            }
            foreach (var item in e.RemovedItems)
            {
              Selections.Remove(item);
            }
          }
        });
  }
 
  void OnSelectionsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
  {
    UpdateSelectedItems(e);
  }
 
  void UpdateSelectedItems()
  {
    ExecuteIfNotUpdating(
      () =>
        {
          if (AssociatedObject != null)
          {
            AssociatedObject.SelectedItems.Clear();
            foreach (var item in Selections ?? new object[0])
            {
              AssociatedObject.SelectedItems.Add(item);
            }
          }
        });
  }
 
  void UpdateSelectedItems(NotifyCollectionChangedEventArgs e)
  {
    ExecuteIfNotUpdating(
      () =>
        {
          if (AssociatedObject != null)
          {
            if (e.Action == NotifyCollectionChangedAction.Reset)
            {
              AssociatedObject.SelectedItems.Clear();
              return;
            }
            if (e.Action == NotifyCollectionChangedAction.Add)
            {
              foreach (var item in e.NewItems)
              {
                AssociatedObject.SelectedItems.Add(item);
              }
            }
            if (e.Action == NotifyCollectionChangedAction.Remove)
            {
              foreach (var item in e.OldItems)
              {
                AssociatedObject.SelectedItems.Remove(item);
              }
            }
          }
        });
  }
 
  void ExecuteIfNotUpdating(Action execute)
  {
    if (_updating) return;
    try
    {
      _updating = true;
      execute();
    }
    finally
    {
      _updating = false;
    }
  }
}

Damit haben wir eine ListView, bei der nicht nur die Selektion funktioniert, sondern auch z.B. das Filtern. Es folgt das zugehörige ViewModel:

public class MultiViewModel
{
  public MultiViewModel()
  {
    SelectAllCommand = new DelegateCommand(SelectAll);
    ClearSelectionCommand = new DelegateCommand(ClearSelection, CanClearSelection);
    SelectedItems = new ObservableCollection<ItemViewModel>();
    SelectedItems.CollectionChanged += (sender, args) => ClearSelectionCommand.RaiseCanExecuteChanged();
  }
 
  public DelegateCommand SelectAllCommand { get; private set; }
  public DelegateCommand ClearSelectionCommand { get; private set; }
 
  void SelectAll()
  {
    if (SelectedItems.Any())
    {
      SelectedItems.Clear();
      return;
    }
    foreach (var entry in Items)
    {
      SelectedItems.Add(entry);
    }
  }
 
  bool CanClearSelection()
  {
    return SelectedItems.Any();
  }
 
  void ClearSelection()
  {
    SelectedItems.Clear();
  }
 
  ObservableCollection<ItemViewModel> _items;
  public ObservableCollection<ItemViewModel> Items
  {
    get { return _items ?? (_items = CollectionCreator.CreateItems(200)); }
  }
 
  ICollectionView _itemsViewSource;
  public ICollectionView ItemsViewSource
  {
    get
    {
      if (_itemsViewSource == null)
      {
        _itemsViewSource = CollectionViewSource.GetDefaultView(Items);
        _itemsViewSource.Filter = ItemFilter;
      }
      return _itemsViewSource;
    }
  }
 
  public ObservableCollection<ItemViewModel> SelectedItems { get; private set; }
 
  string _search;
  public string SearchItem
  {
    get { return _search; }
    set
    {
      _search = value;
      ItemsViewSource.Refresh();
    }
  }
  bool ItemFilter(object o)
  {
    if (_search == null) return true;
    var resource = (ItemViewModel)o;
    return resource.Name == null || resource.Name.ToLower().Contains(_search.ToLower());
  }
}

Die View dazu visualisiert die Selektion in einer eigenen ListBox. Zu beachten ist, dass ausgefilterte Einträge aus der Auswahl verschwinden.

<DockPanel DataContext="{StaticResource ViewModel}">
  <Grid DockPanel.Dock="Top">
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="Auto"/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <TextBlock Text="Filter:" />
    <TextBox Grid.Column="1" Text="{Binding SearchItem, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
  </Grid>
  <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal">
    <Button Content="Clear" Command="{Binding ClearSelectionCommand}" Width="60" />
    <Button Content="Select all" Command="{Binding SelectAllCommand}" Width="60" />
  </StackPanel>
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <ListView Grid.Column="0" ItemsSource="{Binding ItemsViewSource}" 
      DisplayMemberPath="Name" HorizontalAlignment="Stretch"
      SelectionMode="Multiple">
      <i:Interaction.Behaviors>
        <Behaviors:SynchronizeSelectedItems Selections="{Binding SelectedItems}" />
      </i:Interaction.Behaviors>
    </ListView>
    <ListBox Grid.Column="1" ItemsSource="{Binding SelectedItems}" 
      DisplayMemberPath="Name" HorizontalAlignment="Stretch" />
  </Grid>
</DockPanel>

Damit ergibt sich folgende Ausgabe:

Beispiel: Multiselektion einer ListBox

Selektion in der SurfaceListBox

Nun sind eine ListBox oder eine ListView im Rahmen des Windows 8-Hypes nicht wirklich elegant per Touch-Bedienung zu steuern. Von iOS bekannte Techniken, wie beispielsweise „Swipe to scroll“, sind in diesen Klassen gar nicht vorgesehen.

Mit dem Surface SDK von Microsoft wird eine SurfaceListBox mitgeliefert, die genau diese Funktionalität bereitstellt. Und da sie von ListBox abgeleitet ist, kann das oben genannte Behavior ebenso eingesetzt werden. Hier zeigt sich wieder, welch enormen Vorteil Behaviors gegenüber einer simplen Vererbung haben. Hätten wir von ListBox abgeleitet, um die Selektionssynchronisation zu realisieren, hätten wir die SurfaceListBox nur mit Hilfe einer weiteren Vererbung (also letztendlich mit Hilfe von Code-Verdopplung) erweitern können.

Nach Deklaration des entsprechenden Surface-Namespaces und der Änderung der ListView-Definition aus dem MultiSelect-Beispiel haben wir eine Windows 8-ähnliche Ansicht.

<UserControl x:Class="Selections.Views.SurfaceView"
             ...
             xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
             xmlns:s="clr-namespace:Microsoft.Surface.Presentation.Controls;assembly=Microsoft.Surface.Presentation"
             xmlns:Behaviors="clr-namespace:Selections.Behaviors" mc:Ignorable="d" 
             ...
 
  <s:SurfaceListBox Grid.Column="0" ItemsSource="{Binding ItemsViewSource}" Background="Brown"
    DisplayMemberPath="Name" HorizontalAlignment="Stretch"
    SelectionMode="Multiple">
    <i:Interaction.Behaviors>
      <Behaviors:SynchronizeSelectedItems Selections="{Binding SelectedItems}" />
    </i:Interaction.Behaviors>
  </s:SurfaceListBox>
 
  ...

Aber Vorsicht: das Surface SDK muss hierfür installiert werden. Ein einfaches Einbinden der Assemblies reicht nicht aus, die Elemente der ListBox werden mitunter nicht angezeigt.

Beispiel: Selektion einer Surface-ListBox

Fazit

Auch wenn sich auf den ersten Blick das Abbilden der Selektionseigenschaft auf die einzelnen Einträge anbietet, sollte sie aus mehr als einem Grund heraus nicht am Item hängen.

Die gezeigte Herangehensweise über ein Behavior ermöglicht es, die Ausführbarkeit von Commands von einer ListBox-Auswahl abhängig zu machen, ohne bei jedem Aufruf von CanExecute die Selektion überprüfen zu müssen. Da die aktuelle Auswahl der ListBox als Eigenschaft des ViewModels bereitgestellt wird, ist darüber hinaus die Bindung an diese Selektion möglich.

Das komplette Beispiel kann hier geladen werden.

Möchten Sie mehr zu unseren Leistungen, Produkten oder zu unserem UX-Prozess erfahren?
Wir sind gespannt auf Ihre Anfrage.

Employee Experience Manager
+49 681 959 3110

Bitte bestätigen Sie vor dem Versand Ihrer Anfrage über die obige Checkbox, dass wir Sie kontaktieren dürfen.