启用 WPF ListView 的多选(2)






4.47/5 (8投票s)
让您的 ListView 支持通过拖动选择项目
引言

在我尝试编写一个 WPF 文件浏览器时,多选列表视图对我来说非常重要。两年前,我开发了一个 `MultiSelection` 辅助类,它使用 `HitTest` 来完成这个功能,但正如 Mickey Mousoff 指出的那样,这是一种过度复杂且效率低下的方法,我同意,但当时我只能提供这些。
新的方法更加复杂,我重写了除 `ListView` 之外的所有内容,但这是值得的,因为它能提供更好的性能。
目录
- `SelectionHelper` 类
- 如何使用?
- 它是如何工作的?
- 滚动问题
- 面板无法报告位置
- 无法正确绘制选择区域
- 不完整的项目
- 参考文献
- 版本历史
SelectionHelper 类
如何使用?
您可以通过使用 `SelectionHelper.EnableSelection` 附加属性来启用多选。
<ListView x:Name="listView" uc:SelectionHelper.EnableSelection="True" />
如果您不使用 `GridView`,您必须使用支持 `IChildInfo` 接口的 `Panel`。它只包含一个方法。
Rect GetChildRect(int itemIndex);
`VirtualWrapPanel` 和 `VirtualStackPanel` 已经实现了这个功能。
您可以使用类似以下的代码来定义一个视图:
<uc:VirutalWrapPanelView x:Key="ListView" ItemHeight="20" ItemWidth="100"
HorizontalContentAlignment="Left" Orientation="Vertical" >
<uc:VirutalWrapPanelView.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Image x:Name="img" Source="Generic_Document.png" Width="16"/>
<TextBlock Text="{Binding}" Margin="5,0" />
</StackPanel>
</DataTemplate>
</uc:VirutalWrapPanelView.ItemTemplate>
</uc:VirutalWrapPanelView>
您可以更改 `ListViewItem` 的 `ControlTemplate`,以便在 `SelectionHelper.IsDragging`(演示未包含)时触发。
摘自 *VirtualWrapPanelView.xaml*
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Grid>
<Border Background="{TemplateBinding Background}" />
<Border Background="#BEFFFFFF" Margin="1,1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Border Margin="2,1,2,0" Grid.Row="0" Background="#57FFFFFF" />
</Grid>
</Border>
<ContentPresenter Margin="5,0" />
</Grid>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsSelected" Value="False"/>
</MultiTrigger.Conditions>
<Setter Property="Background" Value="{DynamicResource fileListHotTrackBrush}" />
</MultiTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource fileListSelectionBrush}" />
</Trigger>
<Trigger Property="uc:SelectionHelper.IsDragging" Value="True">
<Setter Property="Background" Value="Black" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
它是如何工作的?

上面显示了 `ListView` 的结构,它包含一个 `ScrollViewer`,然后是一个 `ScrollContentPresenter`,最后是 `ItemPresenter`。`ItemPresenter` 包含了 `View` 中指定的 `Panel`,在本例中是 `VirtualWrapPanel`,它是承载、测量和排列项目的 `Panel`。
由于 `ScrollContentPresenter` 是没有滚动条的最顶层控件,我将大多数事件附加在这里,以及上面的 `AdornerLayer` 中的装饰器。
`SelectionHelper` 的工作方式如下:
PreviewMouseDown
SetStartPosition
SetStartScrollbarPosition
SetIsDragging
- 捕获鼠标,这样即使用户将鼠标移出 `listview` 也能正常工作
- `MouseMove` - 如果 `IsDragging`...
- 显示 `SelectionAdorner`?
- (否) 如果 (移动距离 > 阈值) 显示 `SelectionAdorner`
- (是)
GetMousePosition
GetScrollbarPostion
UpdateAdornerPosition
- `UpdateSelection` (预览)
HighlightItems
- 显示 `SelectionAdorner`?
- `MouseUp` - 如果 `IsDragging`...
- `UpdateSelection`(最终)
- Update `SelectedItems`
- Hide `SelectionAdorner`
- 设置 `IsDragging` 为 `False`
- 释放鼠标
- `UpdateSelection`(最终)
这看起来很简单,但有很多需要解决的问题。
滚动问题
由于大多数事件都附加到了 `ScrollContentPresenter`,它无法感知滚动位置。因此,在拖动开始时,我必须获取滚动条的位置并记录下来。可以通过以下方式获取位置:
ScrollViewer scrollViewer = UITools.FindAncestor<ScrollViewer>(p);
return new Point(p.ActualWidth / scrollViewer.ViewportWidth *
scrollViewer.HorizontalOffset,
p.ActualHeight / scrollViewer.ViewportHeight * scrollViewer.VerticalOffset);
之所以需要一些计算(而不是直接返回偏移量),是因为 `GridView` 将 `storeViewportHeight` 和 `VerticalOffset` 存储为不同:`ViewportHeight` 是项目总数,而 `VerticalOffset` 是滚动到的项目。
当用户在拖动时移动鼠标时,鼠标位置和滚动条位置都用于计算选中的项目。
UpdateSelection(p, new Rect(
new Point(startPosition.X + startScrollbarPosition.X,
startPosition.Y + startScrollbarPosition.Y),
new Point(curPosition.X + curScrollbarPosition.X,
curPosition.Y + curScrollbarPosition.Y)));
面板无法报告位置
对于 `GridView`,处理起来很简单,因为我只需要返回第一个和最后一个选中项目之间的项目。
对于其他视图,`VirtualWrapPanel` 和 `VirtualStackPanel` 就是为此目的而设计的,因为大多数列表视图都使用这两个面板(除 `gridview` 外,所有文件列表都可以通过 `VirtualWrapView` 表示),这两个面板都基于 Dan Crevier 的 `VirtualizingTilePanel`。由于这些面板是虚拟的,列表视图项只在需要时生成,因此您必须指定项目大小。这两个面板都公开了一个名为 `GetChildRect()` 的方法,允许 `SelectionHelper` 获取单个 `ListViewItem` 的位置。
`VirutalWrapPanelView` 是一个 `ViewBase`,它允许编码人员一次性设置 `ListView` 的多个属性。因此,要更改列表方法,只需将 `ListView.View` 分配给一个新的即可,而不是分配十几个属性。`VirtualWrapPanelView` 公开了其 `VirtualWrapPanel` 的 `ItemWidth`、`ItemHeight` 和 `Orientation` 属性。
由于使用的是 `VirtualPanel`,并非所有 `ListViewItem` 都已生成,因此我无法触发 `ListView.SelectedEvent` 和 `ListView.UnselectedEvent`。请监听 `ListView.SelectionChangeEvent`。
无法正确绘制选择区域
`SelectionAdorner` 就是为此目的而设计的,它是一个附加到 `ScrollContentPresenter`(`ListView` 内部用于保存子项的控件)的装饰器。它可以根据其三个属性显示拖动区域:`IsSelecting`(装饰器是否可见)、`StartPosition` 和 `EndPosition`。
参考文献
- IScrollInfo (作者:Ben Constable)
- 实现虚拟化面板 (作者:Dan Crevier)
- ViewBase 类 (MSDN)
- 上一篇文章
版本历史
- 10-03-11 - 版本 0.1
- 初始版本
- 10-03-12 - 版本 0.2
- 正确处理 Shift / Control 键
- 正确处理滚动控件外的拖动
- (现在大多数事件都附加到 `listview` 了)
- 10-03-17 - 版本 0.3
- `SelectedItem`s 仅在拖动完成时更改。
`SelectionHelper.GetIsDragging(aListViewItem)` 在 `ListViewItem` 位于用户选择区域内时为 `true`(因此您可以自定义选择的样式)。 - `SelectedItem`s 现在通过添加/删除项目来更改,而不是先清除再重新轮询列表。
- `SelectedItem`s 仅在拖动完成时更改。
- 10-07-19 - 版本 0.4
- 修复了点击 GridView Header 被识别为拖动开始的问题。对于 GridView,仅当拖动发生在第一列内时才支持选择。通过添加 `IVirtualListView` 接口修复了 `VirtualListView` 选择问题。