- 前言
- MVVM、BaseAttachedProperty、BaseValueConverter、以及动画功能的实现
- AttachedProperty实现逆时针顺时针旋转功能
- ScrollView嵌套导致子ScrollView鼠标滚动事件吞没父ScrollView鼠标滚动事件
前言
最近尝试开发WPF项目中,遇到了很多困难,每次都是StackOverflow流,很多方案都是前所未见的,我觉得有记录的价值,也供以后自己参考,由于时间跨度比较大,有些方案我已经找不到当时查找的资料了。
WPF
中给我感触最深的地方是条条道路通罗马,实现一种视觉效果有N种方法,但是有的方法看上去又优雅,The MVVM
方式,有的方法看上去就像hack,比较多的是 Attached Property
方式
WPF
在今天看来可能是辉煌不再了,有更多的桌面跨平台实现方案,但是我觉得有些编程思想还是很有学习价值的,再加上我自己的项目主要还是在Windows平台上运行为主,今后再考虑用Mono或者.Net Core迁移跨平台,至少目前看下来,WPF
仍然是Windows桌面开发的最好选择。
如果你是WPF初学者,又像我一样看书在前几章就被各种 XAML
、 Dependency Property
搞得云里雾里,推荐你去油管上看AngelSix的WPF UI教程,虽然时间长,但是看一遍并参照模仿,能让你迅速从 Winform
的 CodeBehind
模式转为MVVM
模式。学习WPF对初学者来说绝对不算简单,所以不要觉得经常去网上找‘XXXX怎么实现’很丢人。
由于我也是初学,如果有不正确的地方欢迎指正,谢谢。
MVVM、BaseAttachedProperty、BaseValueConverter、以及动画功能的实现
MVVM
:Model-View-ViewModel
,UI
层面主要关注的是 View-ViewModel
,WPF可能有一半内容就是在ViewModel
变化通知View
,View
变化通知ViewModel
过程中,通常实现某个功能的套路就是:
- 创建用户自定义控件A、以及对应的
ViewModel
- 将自定义控件的
DataContext
绑定到ViewModel
上(创建一个DesignModel
用来给设计器提供数据) - 将
ViewModel
中的属性Bind
到自定义控件的子属性上,如果需要转换,创建对应的ValueConverter
- 对于
View
上的用户操作例如点击鼠标、按下回车键等,绑定上ViewModel
上的Command
对象
具体原理我就不多说了,这里主要简单贴上代码实现套路,这里基本照搬AngelSix
的方法
BaseViewModel
public class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = (sender, e) => { };
public void OnPropertyChanged(string name)
{
PropertyChanged(this, new PropertyChangedEventArgs(name));
}
}
所有ViewModel
全部继承自BaseViewModel
,然后安装PropertyChanged.Fody
的Nuget
包,项目目录增加FodyWeavers.xml
文件并写入
<?xml version="1.0" encoding="utf-8" ?>
<Weavers>
<PropertyChanged/>
</Weavers>
这个包的作用主要是用来在编译的时候将PropertyChanged
方法植入到public
属性的Set
方法中,这样你就不用自己每个Set
都写PropertyChanged
了,有兴趣研究Fody
的同学可以到Github
上看看,有很多预编译的强大东东。这是ViewModel
项目唯一需要安装的包,其它的例如DI
容器,可以随自己喜好安装
BaseAttachedProperty
public abstract class BaseAttachedProperty<Parent, Property>
where Parent : new()
{
public event Action<DependencyObject, DependencyPropertyChangedEventArgs> ValueChanged = (sender, e) => { };
public event Action<DependencyObject, object> ValueUpdated = (sender, value) => { };
public static Parent Instance { get; private set; } = new Parent();
public static readonly DependencyProperty ValueProperty = DependencyProperty.RegisterAttached(
"Value",
typeof(Property),
typeof(BaseAttachedProperty<Parent, Property>),
new UIPropertyMetadata(
default(Property),
new PropertyChangedCallback(OnValuePropertyChanged),
new CoerceValueCallback(OnValuePropertyUpdated)
));
private static void OnValuePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
(Instance as BaseAttachedProperty<Parent, Property>)?.OnValueChanged(d, e);
(Instance as BaseAttachedProperty<Parent, Property>)?.ValueChanged(d, e);
}
private static object OnValuePropertyUpdated(DependencyObject d, object value)
{
(Instance as BaseAttachedProperty<Parent, Property>)?.OnValueUpdated(d, value);
(Instance as BaseAttachedProperty<Parent, Property>)?.ValueUpdated(d, value);
return value;
}
public static Property GetValue(DependencyObject d) => (Property)d.GetValue(ValueProperty);
public static void SetValue(DependencyObject d, Property value) => d.SetValue(ValueProperty, value);
public virtual void OnValueChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) { }
public virtual void OnValueUpdated(DependencyObject sender, object value) { }
}
所有Attached Property
(例如Grid.Column
就是Attached Property
,以后都简写作AP
)都继承自BaseAttachedProperty
,这样如果你想创建一个新的AP
就很简单了
public class IsHighlightProperty : BaseAttachedProperty<IsHighlightProperty, bool>
{
}
BaseValueConverter
public abstract class BaseValueConverter<T> : MarkupExtension, IValueConverter
where T : class, new()
{
private static readonly T Converter = new T();
public override object ProvideValue(IServiceProvider serviceProvider)
{
return Converter ;
}
public abstract object Convert(object value, Type targetType, object parameter, ureInfo culture);
public abstract object ConvertBack(object value, Type targetType, object parameter, ureInfo culture);
}
这里增加MarkupExtension
的实现,就可以在XAML
中直接使用
public class BooleanToVisiblityConverter : BaseValueConverter<BooleanToVisiblityConverter>
{
public override object Convert(object value, Type targetType, object parameter, ureInfo culture)
{
if (parameter == null)
return (bool)value ? Visibility.Hidden : Visibility.Visible;
else
return (bool)value ? Visibility.Visible : Visibility.Hidden;
}
public override object ConvertBack(object value, Type targetType, object parameter, ureInfo culture)
{
throw new NotImplementedException();
}
}
<Border Height="4"
Background="{StaticResource IconHoverBlueBrush}"
VerticalAlignment="Bottom"
Visibility="{TemplateBinding local:IsHighlightProperty.Value,Converter={local:BooleanToVisiblityConverter},ConverterParameter=True}" />
动画实现
<Border x:Name="border">
<!-- Add a render scale transform -->
<Border.RenderTransform>
<ScaleTransform />
</Border.RenderTransform>
<Border.RenderTransformOrigin>
<Point X="0.5" Y="0.5" />
</Border.RenderTransformOrigin>
</Border>
<!-- ... -->
<ControlTemplate.Triggers>
<EventTrigger RoutedEvent="MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation To="1.4" Duration="0:0:0.15" Storyboard.TargetName="border" Storyboard.TargetProperty="(RenderTransform).(ScaleTransform.ScaleX)" />
<DoubleAnimation To="1.4" Duration="0:0:0.15" Storyboard.TargetName="border" Storyboard.TargetProperty="(RenderTransform).(ScaleTransform.ScaleY)" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</ControlTemplate.Triggers>
主要就是创建Storyboard
,然后往里面加各种Animation
,还有一种在AP
中创建动画的,在下一节介绍
AttachedProperty实现逆时针顺时针旋转功能
AP实现动画原理
这套方法是AngelSix
的代码,几经他自己修改,我觉得已经挺完美了,我们先看下调用的时候。
<TextBox
Text="{Binding EditedText, UpdateSourceTrigger=PropertyChanged}"
local:AnimateFadeInProperty.Value="{Binding Editing}"
/>
根据ViewModel
的值,转为true
的时候就会fadeIn
,false
就会fadeOut
,可以和丑陋的XAML
说拜拜了,开心。
这是AP的实现
public class AnimateFadeInProperty : AnimateBaseProperty<AnimateFadeInProperty>
{
protected override async void DoAnimation(FrameworkElement element, bool value, bool firstLoad)
{
if (value)
await element.FadeInAsync(firstLoad, firstLoad ? 0 : 0.3f);
else
await element.FadeOutAsync(firstLoad ? 0 : 0.3f);
}
}
其中继承自AnimateBaseProperty
,这个基类封装处理了是否第一次载入、是否已经载入等一系列问题,使用弱引用可以防止内存对象不被回收
public abstract class AnimateBaseProperty<Parent> : BaseAttachedProperty<Parent, bool>
where Parent : BaseAttachedProperty<Parent, bool>, new(){
private readonly Dictionary<WeakReference, bool> mAlreadyLoaded = new Dictionary<WeakReference, bool>();
private readonly Dictionary<WeakReference, bool> mFirstLoadValue = new Dictionary<WeakReference, bool>();
public override void OnValueUpdated(DependencyObject sender, object value)
{
if (!(sender is FrameworkElement element))
return;
var alreadyLoadedReference = mAlreadyLoaded.FirstOrDefault(f => Equals(f.Key.Target, sender));
if ((bool) sender.GetValue(ValueProperty) == (bool) value && alreadyLoadedReference.Key != null)
return;
if (alreadyLoadedReference.Key == null)
{
var weakReference = new WeakReference(sender);
mAlreadyLoaded[weakReference] = false;
element.Visibility = Visibility.Hidden;
async void onLoaded(object ss, RoutedEventArgs ee)
{
element.Loaded -= onLoaded;
await Task.Delay(5);
var firstLoadReference = mFirstLoadValue.FirstOrDefault(f => Equals(f.Key.Target, sender));
DoAnimation(element, firstLoadReference.Key != null ? firstLoadReference.Value : (bool) value,
true);
mAlreadyLoaded[weakReference] = true;
}
element.Loaded += onLoaded;
}
else if (!alreadyLoadedReference.Value)
{
mFirstLoadValue[new WeakReference(sender)] = (bool) value;
}
else
{
DoAnimation(element, (bool) value, false);
}
}
protected virtual void DoAnimation(FrameworkElement element, bool value, bool firstLoad)
{
}
}
所有UI Element
基本都继承自FrameworkElement
,所以这个AP
基本可以在任何控件上用,但是要当心Visible
和Collapse
的问题。
public static class FrameworkElementAnimations
{
public static async Task FadeInAsync(this FrameworkElement element, bool firstLoad, float seconds = 0.3f)
{
var sb = new Storyboard();
sb.AddFadeIn(seconds);
sb.Begin(element);
if (Math.Abs(seconds) > 1e-5 || firstLoad)
element.Visibility = Visibility.Visible;
await Task.Delay((int)(seconds * 1000));
}
}
这是StoryBorder
的Helper
类,通过这个可以组合多个Animation
对象同时执行
public static class StoryboardHelpers
{
public static void AddFadeIn(this Storyboard storyboard, float seconds, bool from = false)
{
var animation = new DoubleAnimation
{
Duration = new Duration(TimeSpan.FromSeconds(seconds)),
To = 1,
};
if (from)
animation.From = 0;
Storyboard.SetTargetProperty(animation, new PropertyPath("Opacity"));
storyboard.Children.Add(animation);
}
}
几种动画的坑
顺时针逆时针旋转
<TextBlock
RenderTransformOrigin="0.5,0.5"
local:AnimateCWProperty.Value="{Binding IsExpanded}">
<TextBlock.RenderTransform>
<TransformGroup>
<ScaleTransform />
<SkewTransform />
<RotateTransform x:Name="rtAngle" Angle="0" />
<TranslateTransform />
</TransformGroup>
</TextBlock.RenderTransform>
</TextBlock>
这里如果不使用 x:Name
命名 RotateTransform
是无法让动画生效的。
public static void AddRotateCW(this Storyboard storyboard, float seconds)
{
var animation = new DoubleAnimation
{
Duration = new Duration(TimeSpan.FromSeconds(seconds)),
From = 360,
To = 180,
};
Storyboard.SetTargetName(animation, "rtAngle");
PropertyPath PropP = new PropertyPath(RotateTransform.AngleProperty);
Storyboard.SetTargetProperty(animation, PropP);
storyboard.Children.Add(animation);
}
public static void AddRotateCCW(this Storyboard storyboard, float seconds)
{
var animation = new DoubleAnimation
{
Duration = new Duration(TimeSpan.FromSeconds(seconds)),
From=180,
To = 360,
};
Storyboard.SetTargetName(animation, "rtAngle");
PropertyPath PropP = new PropertyPath(RotateTransform.AngleProperty);
Storyboard.SetTargetProperty(animation, PropP);
storyboard.Children.Add(animation);
}
变形扩大缩小
同旋转
public static void AddScaleYExpand(this Storyboard storyboard, float seconds)
{
var animation = new DoubleAnimation
{
Duration = new Duration(TimeSpan.FromSeconds(seconds)),
From = 0,
To = 1,
}
Storyboard.SetTargetName(animation, "stScaleY");
PropertyPath PropP = new PropertyPath(ScaleTransform.ScaleYProperty);
Storyboard.SetTargetProperty(animation, PropP)
storyboard.Children.Add(animation);
}
ScrollView 展开收缩
这个比较麻烦了,用到了 MutiBinding
,主要思想是根据所有子元素的高度,去乘以一个 double
值 Tag
,如果 double
值为0
,那么就收起 ScrollView
,如果为1
,则全部展开
public static void AddScrollViewExpand(this Storyboard storyboard, float seconds, FrameworkElement element)
{
if (DesignerProperties.GetIsInDesignMode(element))
{
element.SetValue(FrameworkElement.TagProperty, 1);
return;
}
var animation = new DoubleAnimation
{
Duration = new Duration(TimeSpan.FromSeconds(seconds)),
From = 0,
To = 1,
};
PropertyPath PropP = new PropertyPath("Tag");
Storyboard.SetTargetProperty(animation, PropP);
storyboard.Children.Add(animation);
}
<ScrollViewer
x:Name="ExpandScrollView"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden"
HorizontalContentAlignment="Stretch"
local:AnimateScrollViewExpandProperty.Value="{Binding IsExpand}"
VerticalContentAlignment="Bottom">
<ScrollViewer.Tag>
<system:Double>1.0</system:Double>
</ScrollViewer.Tag>
<ScrollViewer.Height>
<MultiBinding Converter="{local:MultiplyConverter}">
<Binding Path="ActualHeight" ElementName="ExpanderContent" />
<Binding Path="Tag" RelativeSource="{RelativeSource Self}" />
</MultiBinding>
</ScrollViewer.Height>
<ContentControl x:Name="ExpanderContent"></ContentControl>
</ScrollViewer>
这里用到了 MultiplyConverter
,可以同时绑定多个数据,照例还是封装一个基类使用
public abstract class BaseMutiValueConverter<T> : MarkupExtension, IMultiValueConverter where T:class,new()
{
private static readonly T Converter = new T();
public override object ProvideValue(IServiceProvider serviceProvider)
{
return Converter ;
}
public abstract object Convert(object[] values, Type targetType, object parameter, CultureInfo culture);
public abstract object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture);
}
public class MultiplyConverter : BaseMutiValueConverter<MultiplyConverter>
{
public override object Convert(object[] values, Type targetType,
object parameter, CultureInfo culture)
{
double result = 1.0;
foreach (var t in values)
{
if (t is double d)
result *= d;
}
return result;
}
public override object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
参考文献:https://www.codeproject.com/Articles/248112/Templating-WPF-Expander-Control#animation
ScrollView嵌套导致子ScrollView鼠标滚动事件吞没父ScrollView鼠标滚动事件
如题,在子ScrollView
控件中鼠标滚轮的滚动事件会被handle
掉,这样,即使你滚动到子控件的底部,父ScrollView
仍然不能滚动,这个在做复杂的ScrollView
控件时可能会碰到,网上的解决方案使用Code Behind
方式,我稍加修改为AP
方式,在使用上注意加载顺序
public class MouseWheelEventBubbleUpAttachedProperty:BaseAttachedProperty<MouseWheelEventBubbleUpAttachedProperty,bool>
{
public override void OnValueChanged(DependencyObject sender, ndencyPropertyChangedEventArgs e)
{
if (!(sender is ScrollViewer scrollViewer)) return;
if ((bool) e.NewValue)
{
void OnLoaded(object s, RoutedEventArgs ee)
{
scrollViewer.Loaded -= OnLoaded;
//Hook the event
scrollViewer.FindAndActToAllChild<ScrollViewer>((scrollchildview) =>
{
scrollchildview.PreviewMouseWheel += (sss, eee) => PreviewMouseWheel(sss, eee, scrollViewer);
});
}
scrollViewer.Loaded += OnLoaded;
}
}
private void PreviewMouseWheel(object sender, MouseWheelEventArgs e, ScrollViewer scrollViewer)
{
if (!e.Handled)
{
e.Handled = true;
var eventArg =
new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)
{
RoutedEvent = UIElement.MouseWheelEvent,
Source = sender
};
scrollViewer.RaiseEvent(eventArg);
}
}
}
<ScrollViewer
local:MouseWheelEventBubbleUpAttachedProperty.Value="True"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- 可能包含子ScrollView -->
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>