You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
    }
}

关键修改说明:

  1. 触控开始强制开启滚动:不管点击的是按钮还是空白区域,都保持PanningMode=Both,确保从按钮上也能触发滚动;
  2. 位移阈值区分操作:设置5px的位移阈值,超过则判定为滚动,持续禁用按钮的IsPressed状态;未超过则判定为点击,主动触发按钮的点击事件;
  3. 临时控制按钮状态:滚动过程中阻止按钮高亮,点击操作时恢复高亮逻辑,避免视觉混淆;
  4. 优化按钮查找逻辑:简化父元素遍历逻辑,更高效定位触控目标按钮。

内容的提问来源于stack exchange,提问作者Brandon Berisford

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.09 23:05:17