【问题标题】:Is there a workaround for throttled WPF DataTrigger events?是否有针对受限制的 WPF DataTrigger 事件的解决方法?
【发布时间】:2021-08-16 06:15:02
【问题描述】:

来自Why is this WPF animation only triggered once?,我最终发现 WPF 确实限制了它的绑定事件。

下面的代码演示了这种行为:单击按钮会启动一个任务,该任务具有 100 次迭代循环,每 2 毫秒更新一次属性。绑定到此属性的控件会计算 DataContextChanged 被触发的次数。

在我的机器上,如果我点击按钮 5 次,输出是这样的:

Iterations: 100, Count: 86
Iterations: 100, Count: 87
Iterations: 100, Count: 85
Iterations: 100, Count: 89
Iterations: 100, Count: 90

我最初的问题(我认为,请参见上面的链接)是动画只触发了一次。然后我发现,不是动画,而是DataTrigger 没有响应。然后我研究了DataContextChanged 的行为(参见代码),现在假设基本上 WPF 会限制所有事件。

我只想在 XAML 中定义动画,这意味着我必须依赖 DataTrigger“正确”触发,就像在“始终”中一样。显然没有。我必须 - 为了实现我最初想要的动画 - 实现我自己的事件(不会受到限制)并从代码触发动画吗?

编辑:

  • 澄清一下:我不想每2ms触发一次动画!动画应该只在设置特定值时触发 - 并且只会在用户交互时发生,在我当前的设置中通常最多每隔几秒一次。
  • 但是:观察到的属性只会在很短的时间内具有该特定值,例如2毫秒。而且,显然,由于 WPF 的限制,这种属性更改可能会丢失(如下面的答案中所指出的)。
  • 所以,问题仍然存在:我必须......实现我自己的事件......并从代码中触发动画吗?

AnimationTestControl.xaml:

<UserControl x:Class="AnimationTests.AnimationTestControl"
             x:Name="self"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:AnimationTests"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid DataContext="{Binding ElementName=self}">
        <StackPanel Orientation="Horizontal">
            <Button Content="Toggle Border" Click="ButtonToggle_Click" />

            <Border x:Name="TestBorder" DataContext="{Binding ElementName=self, Path=TestBorderItem.MyState}"/>
        </StackPanel>
    </Grid>
</UserControl>

AnimationTestControl.xaml.cs:

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;

namespace AnimationTests
{

    public class TestItem : INotifyPropertyChanged
    {
        private int _myState;
        public int MyState
        {
            get => _myState;
            set { _myState = value; OnPropertyChanged(); }
        }
        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    public partial class AnimationTestControl : UserControl
    {
        public AnimationTestControl()
        {
            InitializeComponent();
        }

        public TestItem TestBorderItem { get; } = new TestItem();

        private void ButtonToggle_Click(object sender, RoutedEventArgs e)
        {
            Task.Run(() =>
            {
                var cnt = 0;
                var max = 100;
                void handler(object s, DependencyPropertyChangedEventArgs args) => cnt++;
                
                TestBorder.DataContextChanged += handler;

                for (int i = 0; i < max; i++)
                {
                    TestBorderItem.MyState = i + 1;
                    Thread.Sleep(2);
                }

                Console.WriteLine($"{max}: {cnt}");

                TestBorder.DataContextChanged -= handler;
            });
        }
    }
}

【问题讨论】:

  • 关于您在主题中的问题。我不明白你的研究与找出原因有什么关系。您不需要以 2 毫秒的时间运行动画,对吗?
  • 你要问多少次实际上是同一个问题?
  • @Andy: ...因为一开始我认为这是一个动画/故事板问题 - 但事实证明这是一个更潜在的问题,原始问题的细节无关紧要,这就是为什么我问了一个新问题。

标签: c# wpf events data-binding


【解决方案1】:

所有带有用户界面元素的操作都通过主应用程序线程的调度程序队列发生。
下面我写的不是一个精确的,而是一个大概的工作场景。

当您更改 ViewModel 中的任何属性时,会创建一个任务来更改 UI 元素的属性(通过绑定),并将此任务推送到 Dispatcher 队列。
下次更改 ViewModel 属性时,下一个任务将排队。
如果您很快更改 ViewModel 属性的值,则下一个任务可能会在前一个任务之前排队。
当第一个任务启动时,它会读取 ViewModel 属性的当前值,这将不再是第一个属性值,而是第二个。
绑定会改变UI元素的属性值,这个改变会触发相应的事件,也会在同一个任务中执行。
处理完事件后,第一个任务就完成了。
稍等片刻,第二个任务就开始了。
绑定将重新分配 ViewModel 属性的当前值。 但是元素的 UI 属性已经包含了这个值。
并且由于 UI 元素的属性值没有发生变化,因此也就不会产生关于其变化的事件。

应要求添加:

如果您可以提供仅 XAML 的解决方案(这将有助于保持逻辑和设计分离),那就太好了!

实施。

要在 XAML 中使用,您需要修改 DataTrigger 以检查 PropertyChanged 事件流上的值。
但是更改 DataTrigger 很棘手。
它的许多成员被声明为internal

因此,您必须创建自己的中间触发器 (CockingTrigger),该触发器将根据给定条件触发。
DataTrigger 将绑定到 CockedTrigger 属性。
完成动作后,必须解除扳机。
为此,必须创建动画中使用的附加属性。

CockedTrigger 将在 Resources 中创建并需要绑定,因此它必须从 Freezable 派生。
此外,它在 Dispatcher 线程中部分不起作用,因此,需要实现 INotifyPropertyChanged,并且所有必要的 DependecyProperties 值都额外存储在私有字段中。

有很多代码。

using System;
using System.ComponentModel;
using System.Reflection;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media.Animation;

namespace Proxy
{
    public class CockedTrigger : Freezable, INotifyPropertyChanged
    {

        public event PropertyChangedEventHandler PropertyChanged;

        protected override Freezable CreateInstanceCore()
               => new CockedTrigger();

        /// <summary>The binding source.</summary>
        public object Source
        {
            get => GetValue(SourceProperty);
            set => SetValue(SourceProperty, value);
        }

        /// <summary><see cref="DependencyProperty"/> for property <see cref="Source"/>.</summary>
        public static readonly DependencyProperty SourceProperty =
            DependencyProperty.Register(nameof(Source), typeof(object), typeof(CockedTrigger), new PropertyMetadata(null, SourceChanged));

        private static void SourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            CockedTrigger trigget = (CockedTrigger)d;
            if (e.OldValue is INotifyPropertyChanged oldTarget)
            {
                oldTarget.PropertyChanged -= trigget.OnObservablePropertyChanged;
            }

            if (e.NewValue is INotifyPropertyChanged newTarget)
            {
                trigget.SetPropertyFunc();
                newTarget.PropertyChanged += trigget.OnObservablePropertyChanged;
            }
        }

        public Func<object> PropertyFunc
        {
            get => _propertyFunc; private set
            {
                if (!Equals(_propertyFunc, value))
                {
                    _propertyFunc = value;
                    PropertyChanged?.Invoke(this, PropertyFuncPropertyChangedEventArgs);
                }
            }
        }
        private Func<object> _propertyFunc;
        private static readonly PropertyChangedEventArgs PropertyFuncPropertyChangedEventArgs
            = new PropertyChangedEventArgs(nameof(PropertyFunc));

        private object fieldSource;
        private string fieldProperty;
        private void SetPropertyFunc()
        {
            fieldSource = Source;
            fieldProperty = Property;
            PropertyFunc = GetValuePropertyFunc(Source, Property);
        }

        public static Func<object> GetValuePropertyFunc(object source, string propertyName)
        {
            if (source == null || string.IsNullOrWhiteSpace(propertyName))
            {
                return null;
            }

            Type type = source.GetType();
            PropertyInfo property = type.GetProperty(propertyName);

            if (property == null)
            {
                return null;
            }

            if (property.PropertyType.IsClass ||
                property.PropertyType.IsInterface ||
                property.PropertyType.IsArray)
            {
                return (Func<object>)property?.GetMethod.CreateDelegate(typeof(Func<object>), source);
            }

            if (property.PropertyType.IsValueType ||
                property.PropertyType.IsEnum)
            {
                Type funcType = typeof(Func<>);
                Type funcTypeGeneric = funcType.MakeGenericType(property.PropertyType);
                Delegate funcDelegate = property.GetMethod.CreateDelegate(funcTypeGeneric, source);
                return () => funcDelegate.DynamicInvoke();
            }

            throw new NotImplementedException($"Not implemented for property type {property.PropertyType.FullName}.");
        }

        private void OnObservablePropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (!string.IsNullOrWhiteSpace(fieldProperty) &&
                e.PropertyName == fieldProperty &&
                PropertyFunc != null &&
                Equals(PropertyFunc(), fieldValue))
            {
                IsTriggerCocked = true;
            }
        }


        /// <summary>Name observable Property.</summary>
        public string Property
        {
            get => (string)GetValue(PropertyProperty);
            set => SetValue(PropertyProperty, value);
        }

        /// <summary><see cref="DependencyProperty"/> for property <see cref="Property"/>.</summary>
        public static readonly DependencyProperty PropertyProperty =
            DependencyProperty.Register(nameof(Property), typeof(string), typeof(CockedTrigger), new PropertyMetadata(null,
                (d, e) => ((CockedTrigger)d).SetPropertyFunc()));


        /// <summary>Comparison value.</summary>
        public object Value
        {
            get => GetValue(ValueProperty);
            set => SetValue(ValueProperty, value);
        }

        /// <summary><see cref="DependencyProperty"/> for property <see cref="Value"/>.</summary>
        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register(nameof(Value), typeof(object), typeof(CockedTrigger), new PropertyMetadata(null,
                (d, e) => ((CockedTrigger)d).fieldValue = e.NewValue));
        private object fieldValue;


        /// <summary>
        /// Read-only property.
        /// Returns true if, after changing the observed property, its value is equal to the expected value.
        /// Can return to false only with the UnloadTrigger method.
        /// </summary>
        public bool IsTriggerCocked
        {
            get => _isTriggerCocked; private set
            {
                if (!Equals(_isTriggerCocked, value))
                {

                    _isTriggerCocked = value;
                    PropertyChanged?.Invoke(this, IsTriggerCockedPropertyChangedEventArgs);
                }
            }
        }
        private bool _isTriggerCocked;
        private static readonly PropertyChangedEventArgs IsTriggerCockedPropertyChangedEventArgs
            = new PropertyChangedEventArgs(nameof(IsTriggerCocked));

        public void UnloadTrigger() => IsTriggerCocked = false;

        public static CockedTrigger GetResetWhenCompleted(Timeline animation)
        {
            return (CockedTrigger)animation.GetValue(ResetWhenCompletedProperty);
        }

        public static void SetResetWhenCompleted(Timeline animation, CockedTrigger value)
        {
            animation.SetValue(ResetWhenCompletedProperty, value);
        }

        // Using a DependencyProperty as the backing store for ResetWhenCompleted.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty ResetWhenCompletedProperty =
            DependencyProperty.RegisterAttached("ResetWhenCompleted", typeof(CockedTrigger), typeof(CockedTrigger), new PropertyMetadata(null, ResetWhenCompletedChanged));

        private static void ResetWhenCompletedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (!(d is Timeline animation))
            {
                throw new NotImplementedException($"Implemented only for classes derived from {nameof(Timeline)}.");
            }

            if (e.OldValue is CockedTrigger oldTrigger)
            {
                animation.Completed -= oldTrigger.UnloadTrigger;
            }
            if (e.NewValue is CockedTrigger newTrigger)
            {
                animation.Completed += newTrigger.UnloadTrigger;
            }
        }

        private void UnloadTrigger(object sender, EventArgs e)
            => UnloadTrigger();

        protected override bool FreezeCore(bool isChecking)
        {
            if (isChecking)
                return true;

            object source = fieldSource;
            object value = fieldValue;
            string property = fieldProperty;

            BindingOperations.ClearBinding(this, SourceProperty);
            BindingOperations.ClearBinding(this, ValueProperty);
            BindingOperations.ClearBinding(this, PropertyProperty);

            Source = source;
            Value = value;
            Property = property;

            return base.FreezeCore(isChecking);
        }
    }
}

使用示例。

一个简单的 ViewModel,其中,在给定的时间间隔内,AnyProperty 属性将尽可能短的时间设置为 true,然后立即重置为 false。
为了方便测试,还显示了值下一次跳转的剩余间隔。

using Simplified;
using System;
using System.Timers;

namespace CheckingProxyTrigger
{
    public class CptViewModel : BaseInpc
    {
        private bool _anyProperty;
        private double _tickInterval = 10; // 10 s
        private double _intervalToNextTick;

        public bool AnyProperty { get => _anyProperty; set => Set(ref _anyProperty, value); }
        public double TickInterval { get => _tickInterval; set => Set(ref _tickInterval, value); }

        public double IntervalToNextTick { get => _intervalToNextTick; private set => Set(ref _intervalToNextTick, value); }

        private DateTime nextTime;

        private readonly Timer timer = new Timer()
        {
            Interval = 10 // 10 ms
        };

        public CptViewModel()
        {
            nextTime = DateTime.Now.AddSeconds(TickInterval);
            timer.Elapsed += OnTick;
            timer.Start();
        }

        private void OnTick(object sender, ElapsedEventArgs e)
        {
            IntervalToNextTick = (nextTime - DateTime.Now).TotalSeconds;

            if (IntervalToNextTick <= 0)
            {
                timer.Stop();

                AnyProperty = true;
                AnyProperty = false;

                nextTime = DateTime.Now.AddSeconds(TickInterval);
                timer.Start();
            }
        }
    }
}

该窗口由几个TextBlock 组成,用于跟踪各种状态。

其中一个使用数据上下文绑定到AnyProperty 并处理其更改事件以计算值跳转。
在每次跳转时,事件(根据逻辑)应该被调用两次。
一次为True,第二次为False。
但是因为有队列,只处理了一个事件。

下面有两个Border。 一个被设置为从跳跃属性开始。 它只适用于第一次跳跃。

第二个来自资源中的 CockedTrigger。 它适用于每次跳跃。

<Window x:Class="CheckingProxyTrigger.CptWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:CheckingProxyTrigger" xmlns:proxy="clr-namespace:Proxy;assembly=Common"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        mc:Ignorable="d"
        Title="CptWindow" Height="450" Width="800">
    <FrameworkElement.DataContext>
        <local:CptViewModel/>
    </FrameworkElement.DataContext>
    <FrameworkElement.Resources>
        <proxy:CockedTrigger x:Key="trigger"
                             Source="{Binding Mode=OneWay}"
                             Property="AnyProperty">
            <proxy:CockedTrigger.Value>
                <sys:Boolean>True</sys:Boolean>
            </proxy:CockedTrigger.Value>
        </proxy:CockedTrigger>
    </FrameworkElement.Resources>
    <StackPanel>
        <TextBlock>
            <Run Text="Interval until next tick:"/>
            <Run Text="{Binding IntervalToNextTick, Mode=OneWay, StringFormat=\{0:F1\}}"/>
        </TextBlock>
        <TextBlock DataContext="{Binding AnyProperty}" DataContextChanged="TextBlock_DataContextChanged">
            <Run Text="Count of property value changes:"/>
            <Run x:Name="countChanges" Text="0"/>
        </TextBlock>
        <TextBlock>
            <Run Text="Trigger cocked:"/>
            <Run Text="{Binding IsTriggerCocked, Mode=OneWay, Source={StaticResource trigger}}"/>
        </TextBlock>
        <Border Height="50" Width="50">
            <Border.Style>
                <Style TargetType="Border">
                    <Setter Property="Background" Value="Red"/>
                    <Style.Triggers>
                        <Trigger Property="ActualWidth" Value="50">
                            <Setter Property="Background" Value="Green"/>
                        </Trigger>
                        <DataTrigger Binding="{Binding AnyProperty}" Value="True">
                            <DataTrigger.EnterActions>
                                <BeginStoryboard>
                                    <Storyboard>
                                        <DoubleAnimation Storyboard.TargetProperty="Width"
                                                         From="400"
                                                         To="50"
                                                         Duration="0:0:2"/>
                                    </Storyboard>
                                </BeginStoryboard>
                            </DataTrigger.EnterActions>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </Border.Style>
        </Border>
        <Border Height="50" Width="50" Margin="10">
            <Border.Style>
                <Style TargetType="Border">
                    <Setter Property="Background" Value="Red"/>
                    <Style.Triggers>
                        <Trigger Property="ActualWidth" Value="50">
                            <Setter Property="Background" Value="Green"/>
                        </Trigger>
                        <DataTrigger Binding="{Binding IsTriggerCocked, Mode=OneWay, Source={StaticResource trigger}}" Value="True">
                            <DataTrigger.EnterActions>
                                <BeginStoryboard>
                                    <Storyboard>
                                        <DoubleAnimation Storyboard.TargetProperty="Width"
                                                         From="400"
                                                         To="50"
                                                         Duration="0:0:2"
                                                         proxy:CockedTrigger.ResetWhenCompleted="{StaticResource trigger}"/>
                                    </Storyboard>
                                </BeginStoryboard>
                            </DataTrigger.EnterActions>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </Border.Style>
        </Border>
    </StackPanel>
</Window>
using System.Windows;

namespace CheckingProxyTrigger
{
    public partial class CptWindow : Window
    {
        public CptWindow()
        {
            InitializeComponent();
        }

        private int count;
        private void TextBlock_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            if (countChanges != null)
            {
                count++;
                countChanges.Text = count.ToString();
            }
        }
    }
}

【讨论】:

  • 这几乎是我的假设(从技术上讲,我猜这不是节流),但没有回答这个问题:我必须......实现我自己的事件......并触发代码动画?
  • 要获得更准确的答案,我需要知道您正在执行的任务的详细信息。我已经在你的问题的 cmets 中写过这个。不清楚每秒应该触发 500 次是什么样的动画?无论如何,做这样的动画是没有意义的。即使在 Sharpe 上创建事件并调用动画,动画本身仍然必须在 Dispatcher 队列中执行。因此,您将以不同的方式返回,但会遇到相同的问题。
  • 请阅读我的编辑:我不想每 2 毫秒触发一次动画!
  • 现在我阅读并理解了问题所在。我需要考虑最佳解决方案。我稍后再回答。
  • 如果您需要一次性使用它,那么最简单的方法是订阅 PropertyChanged 事件并检查其处理程序中的属性值。如果这是预期值,则启动动画。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2013-06-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-02-02
  • 1970-01-01
相关资源
最近更新 更多