无论是在工作和学习中使用WPF时,我们通常都会接触到CustomControl,今天我们就CustomWindow之后的一些边角技术进行探讨和剖析。
童鞋们在WPF开发过程中是否觉得默认的Style太丑,或者是由Balabala的一些原因,使你觉得重写一个“高大上”的Window来符合项目的UI要求(小明:“我们使用Telerik” 老师:“什么?你说你们使用第三方UI框架?滚出去!”)经过半天的努力我们搞定了一个帅气的Window! Like this:
[TemplatePart( Name = "PART_RichTitle", Type = typeof( RichTitleChrome ) )]
[TemplatePart( Name = "PART_PluginArea", Type = typeof( ScrollItemsContainer ) )]
[TemplatePart( Name = "PART_MenuButton", Type = typeof( Button ) )]
[TemplatePart( Name = "PART_MinButton", Type = typeof( Button ) )]
[TemplatePart( Name = "PART_MaxButton", Type = typeof( Button ) )]
[TemplatePart( Name = "PART_CloseButton", Type = typeof( Button ) )]
[TemplatePart( Name = "PART_NonWorkArea", Type = typeof( AdornerNonWorkArea ) )]
[TemplatePart( Name = "PART_BusyIndicator", Type = typeof( BusyIndicator ) )]
[TemplatePart( Name = "PART_ToolbarArea", Type = typeof( ScrollItemsContainer ) )]
[TemplatePart( Name = "PART_ResizeGrip", Type = typeof( ResizeGrip ) )]
[TemplatePart( Name = "PART_Shadow", Type = typeof( Border ) )]
[DefaultProperty( "ToolBarContent" )]
public class MultilayerWindowShell : Window, IFrameworkVisual, IWindowNavigationService, IBusyObservableVisual, IVersionComponent
{
static MultilayerWindowShell()
{
DefaultStyleKeyProperty.OverrideMetadata( typeof( MultilayerWindowShell ), new FrameworkPropertyMetadata( typeof( MultilayerWindowShell ) ) );
}
//
// 一大堆依赖属性啊 事件啊 什么的
//
protected override void OnInitialized( EventArgs e )
{
// To do sth.
}
public override void OnApplyTemplate()
{
// To do sth.
}
// Other sth.
}
哎呀,顿时感觉“高大上”起来,可以拿来跟产品经理去吹牛了。(小明:“经理经理!这UI帅气吧!符合要求吧!” 经理:“很好很好,看起来不错嘛,我就说你这娃有创意有思想不会令我失望的!balabalabala... 咦? ” 小明:“...” 经理:“小明啊!做事不能敷衍啊!你这窗口拖拽四边和顶点不能改变大小啊!小明啊,这最大化位置也不对啊!我们要的最大化是距离屏幕上方有150px啊不要全屏啊!小明啊!你这子窗口弹出来的是模态的吗?为什么不会Blink Blink的闪烁呀!小明,别忽悠我哟!!” 经理:“小明今晚加班搞定哟!” 小明:“....WQNMLGB....”。
那么为了解决小明的问题,为了满足我们神圣的产(qu)品(shi)经(ba)理,我们来逐个搞定它!
-
窗口(对话框)模态闪动(Blink)
首先我们说明一下模态闪动为什么没了? 因为我们自定义Window 将 WindowStyle设置为None了,窗口被隐藏掉了非工作区和边框,只剩下了工作区,所以我们就看不到闪动了。
先来了解什么叫模态闪动, 当我们在父窗口之上弹出来一个模态的子窗口(比如 弹出另存为对话框),我们都知道模态窗口除非关闭,否则后面的任何窗口都不能接受处理。windows系统为了友好的提醒用户,所以当用户点击或者想要操作除模态窗口之外的区域时,使用Blink来提示用户,闪动的窗口必须要关闭才可以进行其他操作。
然而我们干掉了系统默认的窗口非客户区和边框,导致我们失去了模态闪动,所以我们的工作是恢复它或者是说是重新模拟它!想要模拟Blink,那么我们就需要知道我们需要在什么情况下让模态窗口闪动和怎么让它闪动?
第一个问题:模态闪动的触发时机是什么? 是模态子窗口为关闭期间,欲操作其他窗口(或者说是父窗口)时。那么我们又是怎么个欲操作呢?通常都是鼠标去点的,但是发现没反应。我们通过使用SPY++来监视父窗口的消息得知,即使模态子窗口未关闭,我们父窗口一样能接受到系统发送的鼠标指针消息,那么我们的触发Blink时机就可以确定为接收 WM_SETCURSOR 消息时进行判定和处理。
第二个问题:怎么进行Blink? 曾经有过研究的同学可能就要发表看法了。(小明:“我知道!Win32 API 有提供 FlashWindow 和FlashWindowEx! ”)恩,小明说的对。FlashWindow(Ex)确实是闪烁窗口的API,但是,那只是闪烁有系统窗口边的窗口和在任务栏中闪烁(类似QQ来消息后的黄色闪烁),很遗憾API对于我们的无边框自定义窗口无效!(所以,小明!滚出去!),API不好使,那么我们怎么办呢?别忘了,区区一个闪烁是WPF的强项啊!动画呗!所以我们可以搞一段闪烁动画来模拟它的Blink!
So,we try it now!
为了拦截系统消息,我们先给我们的窗口安装一个钩子。
HwndSource hwndSource = PresentationSource.FromVisual( _wndTarget ) as HwndSource;
if ( hwndSource != null )
{
hwndSource.AddHook( new HwndSourceHook( WndProc ) );
}
private IntPtr WndProc( IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled )
{
switch ( msg )
{
case NativeMethods.WM_SETCURSOR:
{
WmSetCursor( lParam, ref handled );
}
break;
}
return IntPtr.Zero;
}
在哪里开始安装钩子呢? 随便 OnApplyTemplate OnInitialized Onloaded 自己喜欢哪就挂哪吧,对于AddHook,这是与Win32互操作的基础,这里就不做讲解了。有童鞋不明白不懂的请去搜索“HwndSource”“PresentationSource”“SourceInitialized”等关键字一看便知。对于 “WndProc”方法,其实他是一个回调函数。有C++ 基础的人都知道。WndProc 这个回调中就是我们拦截消息的地方。
如上面代码片段所示,我们拦截了WM_SETCURSOR消息。其中的lParam参数就是具体消息携带的消息值,我们可以获取鼠标的状态信息,比如我们截取在LeftButtonDown/LeftButtonUp时对Blink进行触发判定。
// 0x202fffe: WM_LBUTTONUP and HitTest // 0x201fffe: WM_LBUTTONDOWN and HitTest
我们再来分析我们的处理方式,我们从下面2点 1) 当父窗口未激活时 2)当父窗口激活时 来分析.
1父窗口未激活时
- 我们循环查找子窗口列表中处于激活状态的子窗口,然后Blink它,
- 如果父窗口没有子窗口,那么我们调用 GetActiveWindow 来获取当前进程中的Actived窗口,然后Blink它.为什么这么做,因为此时的模态窗口可能是MessageBox,或者文件打开/保存等通用对话框,并且没有设置它的Owner.
- 如果GetActiveWindow没有找到,我们在使用 Application.Current.Windows来找一找我们自己创建的窗口列表,并且找一找那个是模态的,然后Blink它. 如何判断某一个Window是否是模态,我们后面将.
2父窗口在上面而模态窗口跑到下面的情况同样需要找到,blink它.
private void WmSetCursor( IntPtr lParam, ref bool handled )
{
// 0x202fffe: WM_LBUTTONUP and HitTest
// 0x201fffe: WM_LBUTTONDOWN and HitTest
if ( lParam.ToInt32() == 0x202fffe || lParam.ToInt32() == 0x201fffe )
{
// if the wnd is not actived
if ( !_wndTarget.IsActive )
{
// we find the actived childwnd in parent's children wnds ,then blink it
if ( _wndTarget.OwnedWindows.Count > 0 )
{
foreach ( Window child in _wndTarget.OwnedWindows )
{
if ( child.IsActive )
{
// FlashWindowEx cann't use for non-border window...
child.Blink();
handled = true;
return;
}
}
}
else
{
// if target window has 0 children
// then , find current active wnd and blink it.
// eg: MessageBox.Show("hello!"); the box without
// owner, when setcursor to target window , we will
// blink this box.
IntPtr pWnd = NativeMethods.GetActiveWindow();
if ( pWnd != IntPtr.Zero )
{
HwndSource hs = HwndSource.FromHwnd( pWnd );
Window activeWnd = null == hs ? null : hs.RootVisual as Window;
if ( null != activeWnd && activeWnd.IsActive )
{
activeWnd.Blink();
handled = true;
return;
}
}
else
{
var wnds = Application.Current.Windows;
if ( null != wnds && wnds.Count > 1 )
{
Window modalWnd = wnds.OfType<Window>().Where( p => p != _wndTarget ).FirstOrDefault( p => p.IsModal() );
if ( null != modalWnd )
{
modalWnd.Activate();
modalWnd.Blink();
handled = true;
return;
}
}
}
}
}
else
{// 父窗口在上面 而模态的在下面的情况
var wnds = Application.Current.Windows;
if ( null != wnds && wnds.Count > 1 )
{
Window modalWnd = wnds.OfType<Window>().Where( p => p != _wndTarget ).FirstOrDefault( p => p.IsModal() );
if ( null != modalWnd )
{
modalWnd.Activate();
modalWnd.Blink();
handled = true;
return;
}
}
}
}
handled = false;
}
上面Code中有你没见过的方法,我们再写一下.
1) IsModal() 方法是一个扩展方法,用来判断指定窗口是不是模态的窗口.
public static bool IsModal<TWindow>( this TWindow wnd ) where TWindow : Window
{
return (bool)typeof( TWindow ).GetField( "_showingAsDialog", BindingFlags.Instance | BindingFlags.NonPublic ).GetValue( wnd );
}
其中的字段_showingAsDialog 为Window类私有的成员变量,用来保存窗口的显示模式.(小明:"你咋知道?" Me:"调试得来,休得再问,滚出去!") 所以我们只需要得到这个变量的值就知道了窗口是否是以模态形式Show的.
2)Blink() 方法同样为扩展类方法,用来生产动画并播放. 大致介绍一下Blinker类:
class DialogBlinker<TWindow> where TWindow : Window
{
//
//
//
public void Blink()
{
}
}
我就不贴完整的类,就不让你全看到然后无脑copy,表着急,听我慢慢白活~~~ :)
在讲这个类之前,我们先大致了解一下,我们的动画该怎么来模仿系统的Blink闪烁.我们通常看到的是系统窗口的边框在闪烁,忽大忽小,如此反复若干次.其实闪烁的不是Border,而是窗口的阴影.那么好办了,WPF的UIElement元素都有Effect属性来设置元素的位图效果,
我们可以为我们的Window加入DropShadowEffect阴影效果,并控制这个阴影的大小状态来模拟闪烁.
所以,我们先构造一个静态的位图阴影效果并缓存到static变量中,在Blink时使用它.
private static DropShadowEffect InitDropShadowEffect()
{
DropShadowEffect dropShadowEffect = new DropShadowEffect();
dropShadowEffect.BlurRadius = 8;
dropShadowEffect.ShadowDepth = 0;
dropShadowEffect.Direction = 0;
dropShadowEffect.Color = System.Windows.Media.Colors.Black;
return dropShadowEffect;
}
至于,DropShadowEffect的BlurRadius / Shadowdepth / Direction 属性的值,是在经过一万遍的实验中得到的一组相对靠谱的数据.如果想阴影再大些或者偏移些,请自行设定.
目前有了待处理的阴影效果,我们还需要一个来处理它的动画,来模拟系统Blink的具体动作方式.这里我使用了缓动关键帧动画(EasingDoubleKeyFrame)来处理它.然后我们通过动画来控制点啥呢?当然是控制DropShadowEffect的BlurRadius属性.
那么我们就让这个属性的值在指定时间内反复的变换吧, 再大些再粗些再大些再粗些再大些再粗些,再小些再细些再小些再细些再小些再细些,balabalabala~~~~.
我们来看看具体的Animation code:
Storyboard storyboard = new Storyboard();
DoubleAnimationUsingKeyFrames keyFrames = new DoubleAnimationUsingKeyFrames();
EasingDoubleKeyFrame kt1 = new EasingDoubleKeyFrame( 0, KeyTime.FromTimeSpan( TimeSpan.FromSeconds( 0 ) ) );
EasingDoubleKeyFrame kt2 = new EasingDoubleKeyFrame( 8, KeyTime.FromTimeSpan( TimeSpan.FromSeconds( 0.3 ) ) );
kt1.EasingFunction = new ElasticEase() { EasingMode = EasingMode.EaseOut };
kt2.EasingFunction = new ElasticEase() { EasingMode = EasingMode.EaseOut };
keyFrames.KeyFrames.Add( kt1 );
keyFrames.KeyFrames.Add( kt2 );
storyboard.Children.Add( keyFrames );
Storyboard.SetTargetProperty( keyFrames, new PropertyPath( System.Windows.Media.Effects.DropShadowEffect.BlurRadiusProperty ) );
return storyboard;
哎~这里就有小明问了: WPF动画从来没有这么写过啊,我们都是用Blend拖拽的!!我不认识这些东西.. 那么我们再看一组code:
<Storyboard x:Key="BlinkStory">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Effect).(DropShadowEffect.BlurRadius)" Storyboard.TargetName="border">
<EasingDoubleKeyFrame KeyTime="0" Value="8">
<EasingDoubleKeyFrame.EasingFunction>
<ElasticEase EasingMode="EaseOut"/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:0.3" Value="26">
<EasingDoubleKeyFrame.EasingFunction>
<ElasticEase EasingMode="EaseOut"/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
看懂了吗? 这俩如出一辙.一个是code behind 一个是xaml端的写法. 在0.3秒内让Effect的BlurRadius从0 到8的转变. 并且伴随 EasingMode.Easeout的缓动效果.
然后在Blink方法中 使用这个Storyboard来play就可以了. 还有一个技术点这里会涉及到.NameScope 的用法.那么它是个啥? 不过就是WPF对 名称-UI对象 的键值对映射而已.每一个NameScope都有一个识别范围.如果某个元素你想通过名称找到它,那么你需要向NameScope来注册这个名字(其实在XAML端,当你写出 <XXX x:Name="name" /> 时,Name已经被自动注册到了它所在的NameScope中). 我们需要使用Effect的名字,那么我需要注册它.
现在我们来看核心的Blink()
public void Blink()
{
if ( null != targetWindow )
{
if ( null == NameScope.GetNameScope( targetWindow ) )
NameScope.SetNameScope( targetWindow, new NameScope() );
originalEffect = targetWindow.Effect;
if ( null == targetWindow.Effect || targetWindow.Effect.GetType() != typeof( DropShadowEffect ) )
targetWindow.Effect = dropShadowEffect;
targetWindow.RegisterName( "_blink_effect", targetWindow.Effect );
Storyboard.SetTargetName( blinkStoryboard.Children[0], "_blink_effect" );
targetWindow.FlashWindowEx();
blinkStoryboard.Begin( targetWindow, true );
targetWindow.UnregisterName( "_blink_effect" );
}
}
为了保持Window原有的Effect 我们需要在动画执行完毕后 重新将之前保存起来的originalEffect赋回到Window中.
到此,我们的模态闪动就完成了.
下面展示完整的WindowBlinker<Window>类.