为什么我只在滚动查看器内的元素的 Y 轴上接收触摸操作增量转换值?

Why am I receiving touch manipulation delta translation values only on the Y axis for elements inside a scrollviewer?

我正在尝试复制默认的 Windows 10 行为,以便在 WPF 中进行触摸和拖动操作与触摸按住并拖动操作。为什么这在 2021 年还不是框架的一部分,只有 MS 霸主可以告诉我们,但是在网上搜索高低、尝试各种实现、尝试在我的 WPF 应用程序中硬塞 UWP 框架等之后,我决定尝试自己实现它。

为了更好地说明我所追求的并确保我们在同一页面上,我附上了以下剪辑来演示:

触摸并立即拖动 触摸、按住,然后拖动
目录内容滚动 触摸的文件夹被拖动

我已经到了看起来我的方法可以工作的地步,但是放置在滚动查看器中的元素的 ManipulationDelta 事件 平移模式 设置为 VerticalOnly 似乎只为 Translation.Y 提供值,Translation.X 始终为 0。显然,我的手指不会在屏幕上完全垂直移动,所以我想接收值对于两个轴。

为了实现这一点,我创建了几个自定义控件,第一个公开了我可以绑定的 ManipulationDelta 属性,以便我可以将值提供给其他控件.它还允许我在 ManipulationDeltaCallback 方法中更新控件的 RenderTransform,以便控件在屏幕上的位置发生变化:

public class Manipulatable : UserControl
{
    public static readonly DependencyProperty ManipulationDeltaProperty =
        DependencyProperty.Register("ManipulationDelta", typeof(ManipulationDelta), typeof(Manipulatable), new PropertyMetadata(null, new PropertyChangedCallback(ManipulationDeltaCallback)));

    private TransformGroup _transformGroup;
    private TranslateTransform _translation;
    //private ScaleTransform scale;
    //private RotateTransform rotation;

    public Manipulatable()
    {
        _transformGroup = new TransformGroup();
        _translation = new TranslateTransform(0, 0);
        //scale = new ScaleTransform(1, 1);
        //rotation = new RotateTransform(0);

        _transformGroup.Children.Add(_translation);
        //transformGroup.Children.Add(scale);
        //transformGroup.Children.Add(rotation);

        RenderTransform = _transformGroup;
    }

    public new ManipulationDelta ManipulationDelta
    {
        get => (ManipulationDelta)GetValue(ManipulationDeltaProperty);
        set => SetValue(ManipulationDeltaProperty, value);
    }

    private static void ManipulationDeltaCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var manipulationDelta = e.NewValue as ManipulationDelta;
        var manipulatable = d as Manipulatable;
        manipulatable._translation.X += manipulationDelta.Translation.X;
        manipulatable._translation.Y += manipulationDelta.Translation.Y;
    }
}

第二个 class 再次公开所有相关触摸操作事件的属性,因此我可以绑定到它们,以及 WPF 缺少的触摸和按住功能,天知道是什么原因:

public class TouchAndHold : Manipulatable
    {
        public static readonly DependencyProperty TouchedAndHeldProperty =
            DependencyProperty.Register("TouchedAndHeld", typeof(RelayCommand), typeof(TouchAndHold), new PropertyMetadata(null));

        public static readonly DependencyProperty TouchedAndHeldParamProperty =
            DependencyProperty.Register("TouchedAndHeldParam", typeof(object), typeof(TouchAndHold), new PropertyMetadata(null));

        public static readonly DependencyProperty ManipulationStartingProperty =
            DependencyProperty.Register("ManipulationStarting", typeof(RelayCommand<TouchAndHoldEventArgs>), typeof(TouchAndHold), new PropertyMetadata(null));

        public static readonly DependencyProperty ManipulationStartedProperty =
            DependencyProperty.Register("ManipulationStarted", typeof(RelayCommand<TouchAndHoldEventArgs>), typeof(TouchAndHold), new PropertyMetadata(null));

        public static readonly DependencyProperty ManipulationDeltaChangedProperty =
            DependencyProperty.Register("ManipulationDeltaChanged", typeof(RelayCommand<TouchAndHoldEventArgs>), typeof(TouchAndHold), new PropertyMetadata(null));

        public static readonly DependencyProperty ManipulationCompletedProperty =
            DependencyProperty.Register("ManipulationCompleted", typeof(RelayCommand<TouchAndHoldEventArgs>), typeof(TouchAndHold), new PropertyMetadata(null));

        private double length;
        private bool _overrideTouch;
        private bool _held;
        private DispatcherTimer _touchHoldTimer;

        public TouchAndHold()
        {
            IsManipulationEnabled = true;
            _touchHoldTimer = new DispatcherTimer();
            _touchHoldTimer.Tick += _touchHoldTimer_Tick;
            _touchHoldTimer.Interval = new TimeSpan(5000000);
        }

        public RelayCommand TouchedAndHeld
        {
            get { return (RelayCommand)GetValue(TouchedAndHeldProperty); }
            set { SetValue(TouchedAndHeldProperty, value); }
        }

        public object TouchedAndHeldParam
        {
            get { return GetValue(TouchedAndHeldParamProperty); }
            set { SetValue(TouchedAndHeldParamProperty, value); }
        }

        public new RelayCommand<TouchAndHoldEventArgs> ManipulationStarting
        {
            get { return (RelayCommand<TouchAndHoldEventArgs>)GetValue(ManipulationStartingProperty); }
            set { SetValue(ManipulationStartingProperty, value); }
        }

        public new RelayCommand<TouchAndHoldEventArgs> ManipulationStarted
        {
            get { return (RelayCommand<TouchAndHoldEventArgs>)GetValue(ManipulationStartedProperty); }
            set { SetValue(ManipulationStartedProperty, value); }
        }

        public RelayCommand<TouchAndHoldEventArgs> ManipulationDeltaChanged
        {
            get { return (RelayCommand<TouchAndHoldEventArgs>)GetValue(ManipulationDeltaChangedProperty); }
            set { SetValue(ManipulationDeltaChangedProperty, value); }
        }

        public new RelayCommand<TouchAndHoldEventArgs> ManipulationCompleted
        {
            get { return (RelayCommand<TouchAndHoldEventArgs>)GetValue(ManipulationCompletedProperty); }
            set { SetValue(ManipulationCompletedProperty, value); }
        }

        protected override void OnPreviewTouchDown(TouchEventArgs e)
        {
            length = 0;
            _held = false;
            _overrideTouch = true;
            _touchHoldTimer.Start();
        }

        protected override void OnPreviewTouchUp(TouchEventArgs e)
        {
            e.Handled = !_overrideTouch || _held;
            _held = false;
            _overrideTouch = false;
            _touchHoldTimer.Stop();
        }

        protected override void OnManipulationStarting(ManipulationStartingEventArgs e)
        {
            e.Handled = !_overrideTouch || _held;
            ManipulationStarting?.Execute(new TouchAndHoldEventArgs(_overrideTouch, _held, e));
        }

        protected override void OnManipulationStarted(ManipulationStartedEventArgs e)
        {
            e.Handled = !_overrideTouch || _held;
            ManipulationStarted?.Execute(new TouchAndHoldEventArgs(_overrideTouch, _held, e));
        }

        protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
        {
            if (_held)
            {
                e.Handled = true;
                ManipulationDeltaChanged?.Execute(new TouchAndHoldEventArgs(_overrideTouch, _held, e));
                return;
            }

            length += e.DeltaManipulation.Translation.Length;
            if (length >= 10)
            {
                _overrideTouch = false;
                _touchHoldTimer.Stop();
                return;
            }

            e.Handled = !_overrideTouch || _held;
        }

        protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e)
        {
            e.Handled = !_overrideTouch || _held;
            ManipulationCompleted?.Execute(new TouchAndHoldEventArgs(_overrideTouch, _held, e));
        }

        private void _touchHoldTimer_Tick(object sender, EventArgs e)
        {
            _held = true;
            _overrideTouch = false;
            _touchHoldTimer.Stop();
            TouchedAndHeld?.Execute(TouchedAndHeldParam);
        }

        public class TouchAndHoldEventArgs
        {
            public TouchAndHoldEventArgs(bool isTouchOverriden, bool isHeld, ManipulationStartingEventArgs eventArgs)
            {
                IsTouchOverriden = IsTouchOverriden;
                IsHeld = isHeld;
                ManipulationStartingEventArgs = eventArgs;
            }

            public TouchAndHoldEventArgs(bool isTouchOverriden, bool isHeld, ManipulationStartedEventArgs eventArgs)
            {
                IsTouchOverriden = isTouchOverriden;
                IsHeld = isHeld;
                ManipulationStartedEventArgs = eventArgs;
            }

            public TouchAndHoldEventArgs(bool isTouchOverriden, bool isHeld, ManipulationDeltaEventArgs eventArgs)
            {
                IsTouchOverriden = isTouchOverriden;
                IsHeld = isHeld;
                ManipulationDeltaEventArgs = eventArgs;
            }

            public TouchAndHoldEventArgs(bool isTouchOverriden, bool isHeld, ManipulationCompletedEventArgs eventArgs)
            {
                IsTouchOverriden = isTouchOverriden;
                IsHeld = isHeld;
                ManipulationCompletedEventArgs = eventArgs;
            }

            public bool IsTouchOverriden { get; }
            public bool IsHeld { get; }

            public ManipulationStartingEventArgs ManipulationStartingEventArgs { get; }
            public ManipulationStartedEventArgs ManipulationStartedEventArgs { get; }
            public ManipulationDeltaEventArgs ManipulationDeltaEventArgs { get; }
            public ManipulationCompletedEventArgs ManipulationCompletedEventArgs { get; }
        }
    }

触摸和按住功能通过一个布尔值工作,当设置为真时,确保所有操作事件都设置为已处理 (e.Handled = true)。这确保了所有触摸事件都被忽略,例如,滚动查看器不会开始滚动。

调用 OnPreviewTouchDown 时,我将布尔值设置为 true,这样事件就不会传播。我还为触摸和保持部分启动了一个计时器。同时,我监视手指在屏幕上移动的距离。如果它移动得足够远,我假设用户想要滚动,所以我停止做任何事情并让 WPF 按需要处理所有事情。如果在计时器触发之前手指没有移动超过设定的限制,我认为这实际上是一个触摸并保持事件。此时我执行以下操作:

最后,我们准备好实际使用这个东西了。因此,我有一个带有触摸和按住控件的滚动查看器:

<ScrollViewer Style="{StaticResource MyScrollViewer}">
    <ItemsControl ItemsSource="{Binding SomeButtons}" x:Name="SomeButtonsRoot">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel Style="{StaticResource SomeButtonsWrapPanel}"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>

        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <controls:SomeButton Icon="{Binding Icon}" Text="{Binding Name}"
                                     SelectCmd="{Binding ButtonSelectedCmd}" SelectCmdParam="{Binding SomeParam}"
                                     TouchedAndHeld="{Binding DataContext.SomeButtonDragStartedCmd, ElementName=SomeButtonsRoot}"
                                     ManipulationDeltaChanged="{Binding DataContext.SomeButtonDraggingCmd, ElementName=SomeButtonsRoot}">
                                     
                    <controls:SomeButton.TouchedAndHeldParam>
                        <MultiBinding Converter="{StaticResource List}">
                            <Binding />
                            <Binding RelativeSource="{RelativeSource Self}"/>
                            <Binding ElementName="CanvasRoot"/>
                        </MultiBinding>
                    </controls:SomeButton.TouchedAndHeldParam>
                    
                </controls:SomeButton>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</ScrollViewer>

滚动查看器放在 Canvas 内。在同一个 Canvas 中,我有另一个控件:

<controls:SomeButton Icon="{Binding Icon}" Text="{Binding Name}" Panel.ZIndex="100"
                     Canvas.Left="{Binding InitialPosition.X}" Canvas.Top="{Binding InitialPosition.Y}"
                     ManipulationDelta="{Binding Translation}"/>

这是实际将被拖动的控件。滚动查看器中的控件启动触摸并按住拖动,然后我将所有操作数据从启动控件传递到此控件。我没有拖动启动该过程的控件的原因是,根据 this 和堆栈溢出的其他答案,滚动查看器将始终剪辑到边界。因此,我没有弄乱边距、填充和 Z 索引,而是在 canvas 中放置了一个代理控件,我可以将其移动到任何我想移动的地方,而且我知道它是可见的。

到目前为止的代码很有前途。当我触摸并按住滚动查看器中的控件时,另一个控件会在其正上方弹出,当我移动手指时,该控件完美地反映了我的手势 但仅在 Y 轴 上。这是为什么?

我决定看看ScrollViewer到底在做什么,所以我搜索了源代码。幸运的是,我找到了代码 right here.

从第 1645 行开始,在 OnManipulationStarting 覆盖内,代码检查为 ScrollViewer 设置的平移模式,并根据该值更改 ManipulationModes 的 [=14] =].对于 PanningMode.VerticalOnly,模式设置为 ManipulationModes.TranslateY。啊哈!这就是为什么我只得到 Y 值。

为了修复这个“功能”,我简单地扩展了 ScrollViewer class 并覆盖了相同的 OnManipulationStarting 并确保操作模式保持不变:

public class TouchScrollViewer : ScrollViewer
{
    protected override void OnManipulationStarting(ManipulationStartingEventArgs e)
    {
        var initialMode = e.Mode;       // Keep note of the original manipulation mode.
        base.OnManipulationStarting(e); // Let the ScrollViewer do it's thing.
        e.Mode = initialMode;           // Ensure the original manipulation mode is used.
    }
}

谢天谢地,OnManipulationStarting 实际上是 UIElement class 的一部分,ScrollViewer 最终扩展了它,并标记为 protected,所以我们可以在我们认为合适的扩展 class 中覆盖它。

我在 XAML 中改为使用 TouchScrollViewer 而不是原来的 ScrollViewer,现在一切正常。

我暂时不会将此标记为最终答案,以防万一有人提供更好的解决方案。

万一 link 出现故障或页面上的代码发生变化,这里是相关位:

protected override void OnManipulationStarting(ManipulationStartingEventArgs e)
{
    ...

    PanningMode panningMode = PanningMode;
    if (panningMode != PanningMode.None)
    {
        ...
        
        if (ShouldManipulateScroll(e, viewport))
        {
            // Set Manipulation mode and container
            if (panningMode == PanningMode.HorizontalOnly)
            {
                e.Mode = ManipulationModes.TranslateX;
            }
            else if (panningMode == PanningMode.VerticalOnly)
            {
                e.Mode = ManipulationModes.TranslateY;
            }
            else
            {
                e.Mode = ManipulationModes.Translate;
            }
            ...
        }
        ...
    }
}