WPF ScrollViewer:兼容触控/鼠标滚动与内部按钮样式触发
问题
我有一个包含ItemsControl的ScrollViewer,内部是Button控件,对应的XAML代码如下:
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> <ItemsControl Style="{DynamicResource FileItemsControlStyle}" ItemsSource="{Binding Files.Files}" Padding="4" ManipulationBoundaryFeedback="FileListBox_ManipulationBoundaryFeedback"> <ItemsControl.ItemTemplate> <DataTemplate> <Button Style="{StaticResource ImportsFileBorder}" Margin="0, 2" Height="75" HorizontalAlignment="Stretch"> <b:Interaction.Triggers> <b:EventTrigger EventName="PreviewMouseDown"> <b:InvokeCommandAction Command="{Binding Path=DataContext.RenderSelectedFileCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}" CommandParameter="{Binding}"/> </b:EventTrigger> </b:Interaction.Triggers> <DockPanel LastChildFill="True" ScrollViewer.HorizontalScrollBarVisibility="Disabled"> <ToggleButton IsChecked="{Binding IsSelected, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}" PreviewMouseDown="ToggleSliderButton_PreviewMouseDown" Style="{DynamicResource ToggleSliderStyle}" DockPanel.Dock="Left" Margin="10,15,10,0" /> <TextBlock Text="{Binding DisplayFileName}" Width="Auto" VerticalAlignment="Center" FontWeight="Bold" TextTrimming="CharacterEllipsis" FontSize="{DynamicResource LargeFontSize}" Foreground="Lime"/> </DockPanel> </Button> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </ScrollViewer>
当前遇到的问题:
- 当ScrollViewer的
PanningMode设为Both时,鼠标和触控都能正常滚动,但触控点击按钮时,按钮命令可执行但样式不会高亮; - 若将
PanningMode设为None,按钮样式触发正常,但无法滚动列表。
需求:
即使触控起始于按钮上,仍能滚动列表,同时区分用户是点击按钮还是滚动操作;滚动时起始触控的按钮最好不要高亮。
我曾参考一篇文章实现了自定义ScrollViewerWithTouch类,但该实现无法在按钮上起始触控进行滚动,代码如下:
public class ScrollViewerWithTouch : ScrollViewer { /// <summary> /// Original panning mode. /// </summary> private PanningMode panningMode; /// <summary> /// Set panning mode only once. /// </summary> private bool panningModeSet; /// <summary> /// Initializes static members of the <see cref="ScrollViewerWithTouch"/> class. /// </summary> static ScrollViewerWithTouch() { DefaultStyleKeyProperty.OverrideMetadata(typeof(ScrollViewerWithTouch), new FrameworkPropertyMetadata(typeof(ScrollViewerWithTouch))); } protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e) { base.OnManipulationCompleted(e); // set it back this.PanningMode = this.panningMode; } protected override void OnManipulationStarted(ManipulationStartedEventArgs e) { // figure out what has the user touched var result = VisualTreeHelper.HitTest(this, e.ManipulationOrigin); if (result != null && result.VisualHit != null) { var hasButtonParent = this.HasButtonParent(result.VisualHit); // if user touched a button then turn off panning mode, let style bubble down, in other case let it scroll this.PanningMode = hasButtonParent ? PanningMode.None : this.panningMode; } base.OnManipulationStarted(e); } protected override void OnTouchDown(TouchEventArgs e) { // store panning mode or set it back to it's original state. OnManipulationCompleted does not do it every time, so we need to set it once more. if (this.panningModeSet == false) { this.panningMode = this.PanningMode; this.panningModeSet = true; } else { this.PanningMode = this.panningMode; } base.OnTouchDown(e); } private bool HasButtonParent(DependencyObject obj) { var parent = VisualTreeHelper.GetParent(obj); if ((parent != null) && (parent is ButtonBase) == false) { return HasButtonParent(parent); } return parent != null; } }
请问如何修改该类,实现滚动与按钮样式触发兼容的效果?
解决方案
核心思路是:不再在触控开始时直接关闭滚动,而是先保留滚动能力,通过判断触控是否产生了有效位移来区分滚动和点击操作——有位移则判定为滚动,阻止按钮高亮;无位移则判定为点击,让按钮正常触发样式和命令。
修改后的ScrollViewerWithTouch代码如下:
public class ScrollViewerWithTouch : ScrollViewer { private PanningMode _originalPanningMode; private bool _isManipulating; private Point _touchStartPoint; private ButtonBase _touchedButton; // 位移阈值,超过则判定为滚动 private const double _moveThreshold = 5; static ScrollViewerWithTouch() { DefaultStyleKeyProperty.OverrideMetadata(typeof(ScrollViewerWithTouch), new FrameworkPropertyMetadata(typeof(ScrollViewerWithTouch))); } protected override void OnTouchDown(TouchEventArgs e) { _originalPanningMode = PanningMode; // 始终开启滚动,确保从按钮上也能触发滚动 PanningMode = PanningMode.Both; _isManipulating = false; _touchStartPoint = e.GetTouchPoint(this).Position; // 定位当前触控的按钮元素 var hitTestResult = VisualTreeHelper.HitTest(this, _touchStartPoint); _touchedButton = FindParentButton(hitTestResult?.VisualHit); base.OnTouchDown(e); } protected override void OnManipulationStarted(ManipulationStartedEventArgs e) { _isManipulating = true; // 滚动开始时,临时禁用按钮的按压状态,避免误高亮 _touchedButton?.SetCurrentValue(ButtonBase.IsPressedProperty, false); base.OnManipulationStarted(e); } protected override void OnManipulationDelta(ManipulationDeltaEventArgs e) { if (_isManipulating && _touchedButton != null) { var delta = e.DeltaManipulation.Translation; // 检测到有效位移,持续禁用按钮按压状态 if (Math.Abs(delta.X) > _moveThreshold || Math.Abs(delta.Y) > _moveThreshold) { _touchedButton.SetCurrentValue(ButtonBase.IsPressedProperty, false); } } base.OnManipulationDelta(e); } protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e) { base.OnManipulationCompleted(e); _isManipulating = false; var totalDelta = e.TotalManipulation.Translation; if (_touchedButton != null) { if (Math.Abs(totalDelta.X) <= _moveThreshold && Math.Abs(totalDelta.Y) <= _moveThreshold) { // 位移极小,判定为点击,触发按钮点击事件并恢复高亮 _touchedButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); _touchedButton.SetCurrentValue(ButtonBase.IsPressedProperty, true); } } // 恢复原始滚动模式 PanningMode = _originalPanningMode; _touchedButton = null; } private ButtonBase FindParentButton(DependencyObject visual) { while (visual != null) { if (visual is ButtonBase button) return button; visual = VisualTreeHelper.GetParent(visual); } return null; } }
关键修改说明:
- 触控开始强制开启滚动:不管点击的是按钮还是空白区域,都保持
PanningMode=Both,确保从按钮上也能触发滚动; - 位移阈值区分操作:设置5px的位移阈值,超过则判定为滚动,持续禁用按钮的
IsPressed状态;未超过则判定为点击,主动触发按钮的点击事件; - 临时控制按钮状态:滚动过程中阻止按钮高亮,点击操作时恢复高亮逻辑,避免视觉混淆;
- 优化按钮查找逻辑:简化父元素遍历逻辑,更高效定位触控目标按钮。
内容的提问来源于stack exchange,提问作者Brandon Berisford
相关产品推荐
相关产品推荐

