WPF使用AppBar实现窗口停靠,适配缩放、全屏响应和多窗口并列(附封装好即开即用的附加属性)
在吕毅大佬的文章中已经详细介绍了什么是AppBar:
WPF 使用 AppBar 将窗口停靠在桌面上,让其他程序不占用此窗口的空间(附我封装的附加属性) - walterlv
即让窗口固定在屏幕某一边,并且保证其他窗口最大化后不会覆盖AppBar占据区域(类似于Windows任务栏)。
但是在我的环境中测试时,上面的代码出现了一些问题,例如非100%缩放显示时的坐标计算异常、多窗口同时停靠时布局错乱等。所以我重写了AppBar在WPF上的实现,效果如图:
一、AppBar的主要申请流程
主要流程如图:
核心代码其实在于如何计算停靠窗口的位置,要点是处理好一下几个方面:
1. 修改停靠位置时用原窗口的大小计算,被动告知需要调整位置时用即时大小计算
2. 像素单位与WPF单位之间的转换
3. 小心Windows的位置建议,并排停靠时会得到负值高宽,需要手动适配对齐方式
4. 有新的AppBar加入时,窗口会被系统强制移动到工作区(WorkArea),这点我还没能找到解决方案,只能把移动窗口的命令通过Dispatcher延迟操作
二、如何使用
1.下载我封装好的库:
AppBarTest/AppBarCreator.cs at master · TwilightLemon/AppBarTest (github.com)
2. 在xaml中直接设置:
<Window...> <local:AppBarCreator.AppBar> <local:AppBarx:Name="appBar"Location="Top"OnFullScreenStateChanged="AppBar_OnFullScreenStateChanged"/> </local:AppBarCreator.AppBar>...</Window>
或者在后台创建:
private readonly AppBar appBar=newAppBar();
...Window_Loaded...
appBar.Location=AppBarLocation.Top;
appBar.OnFullScreenStateChanged+=AppBar_OnFullScreenStateChanged;
AppBarCreator.SetAppBar(this, appBar);
3. 另外你可能注意到了,这里有一个OnFullScreenStateChanged事件:该事件由AppBarMsg注册,在有窗口进入或退出全屏时触发,参数bool为true指示进入全屏。
你需要手动在事件中设置全屏模式下的行为,例如在全屏时隐藏AppBar
private void AppBar_OnFullScreenStateChanged(object sender, boole)
{
Debug.WriteLine("Full Screen State:"+e);
Visibility= e ?Visibility.Collapsed : Visibility.Visible;
}
我在官方的Flag上加了一个RegisterOnly,即只注册AppBarMsg而不真的停靠窗口,可以此用来作全屏模式监听。
4. 如果你需要在每个虚拟桌面都显示AppBar(像任务栏那样),可以尝试为窗口使用SetWindowLong添加WS_EX_TOOLWINDOW标签(自行查找)
以下贴出完整的代码:
1 usingSystem.ComponentModel;2 usingSystem.Diagnostics;3 usingSystem.Runtime.InteropServices;4 usingSystem.Windows;5 usingSystem.Windows.Interop;6 usingSystem.Windows.Threading;7 8 namespaceAppBarTest;9 public static classAppBarCreator10 {11 public static readonly DependencyProperty AppBarProperty = 12 DependencyProperty.RegisterAttached(13 "AppBar",14 typeof(AppBar),15 typeof(AppBarCreator),16 new PropertyMetadata(null, OnAppBarChanged));17 private static voidOnAppBarChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)18 {19 if (d is Window window && e.NewValue isAppBar appBar)20 {21 appBar.AttachedWindow =window;22 }23 }24 public static voidSetAppBar(Window element, AppBar value)25 {26 if (value == null) return;27 element.SetValue(AppBarProperty, value);28 }29 30 public staticAppBar GetAppBar(Window element)31 {32 return(AppBar)element.GetValue(AppBarProperty);33 }34 }35 36 public classAppBar : DependencyObject37 {38 /// <summary> 39 ///附加到的窗口40 /// </summary> 41 publicWindow AttachedWindow42 {43 get =>_window;44 set 45 {46 if (value == null) return;47 _window =value;48 _window.Closing +=_window_Closing;49 _window.LocationChanged +=_window_LocationChanged;50 //获取窗口句柄hWnd 51 var handle = newWindowInteropHelper(value).Handle;52 if (handle ==IntPtr.Zero)53 {54 //Win32窗口未创建 55 _window.SourceInitialized +=_window_SourceInitialized;56 }57 else 58 {59 _hWnd =handle;60 CheckPending();61 }62 }63 }64 65 private void _window_LocationChanged(object?sender, EventArgs e)66 {67 Debug.WriteLine(_window.Title+ "LocationChanged: Top:"+_window.Top+"Left:"+_window.Left);68 }69 70 private void _window_Closing(object?sender, CancelEventArgs e)71 {72 _window.Closing -=_window_Closing;73 if (Location !=AppBarLocation.None)74 DisableAppBar();75 }76 77 /// <summary> 78 ///检查是否需要应用之前的Location更改79 /// </summary> 80 private voidCheckPending()81 {82 //创建AppBar时提前触发的LocationChanged 83 if(_locationChangePending)84 {85 _locationChangePending = false;86 LoadAppBar(Location);87 }88 }89 /// <summary> 90 ///载入AppBar91 /// </summary> 92 /// <param name="e"></param> 93 private void LoadAppBar(AppBarLocation e,AppBarLocation? previous=null)94 {95 96 if (e !=AppBarLocation.None)97 {98 if (e ==AppBarLocation.RegisterOnly)99 {100 //仅注册AppBarMsg101 //如果之前注册过有效的AppBar则先注销,以还原位置 102 if (previous.HasValue && previous.Value !=AppBarLocation.RegisterOnly)103 {104 if (previous.Value !=AppBarLocation.None)105 {106 //由生效的AppBar转为RegisterOnly,还原为普通窗口再注册空AppBar 107 DisableAppBar();108 }109 RegisterAppBarMsg();110 }111 else 112 {113 //之前未注册过AppBar,直接注册 114 RegisterAppBarMsg();115 }116 }117 else 118 {119 if (previous.HasValue && previous.Value !=AppBarLocation.None)120 {121 //之前为RegisterOnly才备份窗口信息 122 if(previous.Value ==AppBarLocation.RegisterOnly)123 {124 BackupWindowInfo();125 }126 SetAppBarPosition(_originalSize);127 ForceWindowStyles();128 }129 else 130 EnableAppBar();131 }132 }133 else 134 {135 DisableAppBar();136 }137 }138 private void _window_SourceInitialized(object?sender, EventArgs e)139 {140 _window.SourceInitialized -=_window_SourceInitialized;141 _hWnd = newWindowInteropHelper(_window).Handle;142 CheckPending();143 }144 145 /// <summary> 146 ///当有窗口进入或退出全屏时触发 bool参数为true时表示全屏状态147 /// </summary> 148 public event EventHandler<bool>?OnFullScreenStateChanged;149 /// <summary> 150 ///期望将AppBar停靠到的位置151 /// </summary> 152 publicAppBarLocation Location153 {154 get { return(AppBarLocation)GetValue(LocationProperty); }155 set{ SetValue(LocationProperty, value); }156 }157 158 public static readonly DependencyProperty LocationProperty = 159 DependencyProperty.Register(160 "Location",161 typeof(AppBarLocation), typeof(AppBar),162 newPropertyMetadata(AppBarLocation.None, OnLocationChanged));163 164 private bool _locationChangePending = false;165 private static voidOnLocationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)166 {167 if(DesignerProperties.GetIsInDesignMode(d))168 return;169 if (d is not AppBar appBar) return;170 if (appBar.AttachedWindow == null)171 {172 appBar._locationChangePending = true;173 return;174 }175 appBar.LoadAppBar((AppBarLocation)e.NewValue,(AppBarLocation)e.OldValue);176 }177 178 private int _callbackId = 0;179 private bool _isRegistered = false;180 private Window _window = null;181 privateIntPtr _hWnd;182 privateWindowStyle _originalStyle;183 privatePoint _originalPosition;184 private Size _originalSize =Size.Empty;185 privateResizeMode _originalResizeMode;186 private bool_originalTopmost;187 public Rect? DockedSize { get; set; } = null;188 private IntPtr WndProc(IntPtr hwnd, intmsg, IntPtr wParam,189 IntPtr lParam, ref boolhandled)190 {191 if (msg ==_callbackId)192 {193 Debug.WriteLine(_window.Title + "AppBarMsg("+_callbackId+"):" + wParam.ToInt32() + "LParam:" +lParam.ToInt32());194 switch(wParam.ToInt32())195 {196 case (int)Interop.AppBarNotify.ABN_POSCHANGED:197 Debug.WriteLine("AppBarNotify.ABN_POSCHANGED !"+_window.Title);198 if (Location !=AppBarLocation.RegisterOnly)199 SetAppBarPosition(Size.Empty);200 handled = true;201 break;202 case (int)Interop.AppBarNotify.ABN_FULLSCREENAPP:203 OnFullScreenStateChanged?.Invoke(this, lParam.ToInt32() == 1);204 handled = true;205 break;206 }207 }208 returnIntPtr.Zero;209 }210 211 public voidBackupWindowInfo()212 {213 _callbackId = 0;214 DockedSize = null;215 _originalStyle =_window.WindowStyle;216 _originalSize = newSize(_window.ActualWidth, _window.ActualHeight);217 _originalPosition = newPoint(_window.Left, _window.Top);218 _originalResizeMode =_window.ResizeMode;219 _originalTopmost =_window.Topmost;220 }221 public voidRestoreWindowInfo()222 {223 if (_originalSize !=Size.Empty)224 {225 _window.WindowStyle =_originalStyle;226 _window.ResizeMode =_originalResizeMode;227 _window.Topmost =_originalTopmost;228 _window.Left =_originalPosition.X;229 _window.Top =_originalPosition.Y;230 _window.Width =_originalSize.Width;231 _window.Height =_originalSize.Height;232 }233 }234 public voidForceWindowStyles()235 {236 _window.WindowStyle =WindowStyle.None;237 _window.ResizeMode =ResizeMode.NoResize;238 _window.Topmost = true;239 }240 241 public voidRegisterAppBarMsg()242 {243 var data = newInterop.APPBARDATA();244 data.cbSize =Marshal.SizeOf(data);245 data.hWnd =_hWnd;246 247 _isRegistered = true;248 _callbackId =Interop.RegisterWindowMessage(Guid.NewGuid().ToString());249 data.uCallbackMessage =_callbackId;250 var success = Interop.SHAppBarMessage((int)Interop.AppBarMsg.ABM_NEW, refdata);251 var source =HwndSource.FromHwnd(_hWnd);252 Debug.WriteLineIf(source == null, "HwndSource is null!");253 source?.AddHook(WndProc);254 Debug.WriteLine(_window.Title+"RegisterAppBarMsg:" +_callbackId);255 }256 public voidEnableAppBar()257 {258 if (!_isRegistered)259 {260 //备份窗口信息并设置窗口样式 261 BackupWindowInfo();262 //注册成为AppBar窗口 263 RegisterAppBarMsg();264 ForceWindowStyles();265 }266 //成为AppBar窗口之后(或已经是)只需要注册并移动窗口位置即可 267 SetAppBarPosition(_originalSize);268 }269 public voidSetAppBarPosition(Size WindowSize)270 {271 var data = newInterop.APPBARDATA();272 data.cbSize =Marshal.SizeOf(data);273 data.hWnd =_hWnd;274 data.uEdge = (int)Location;275 data.uCallbackMessage =_callbackId;276 Debug.WriteLine("\r\nWindow:"+_window.Title);277 278 //获取WPF单位与像素的转换矩阵 279 var compositionTarget = PresentationSource.FromVisual(_window)?.CompositionTarget;280 if (compositionTarget == null)281 throw new Exception("居然获取不到CompositionTarget?!");282 var toPixel =compositionTarget.TransformToDevice;283 var toWpfUnit =compositionTarget.TransformFromDevice;284 285 //窗口在屏幕的实际大小 286 if(WindowSize==Size.Empty)287 WindowSize = newSize(_window.ActualWidth, _window.ActualHeight);288 var actualSize = toPixel.Transform(newVector(WindowSize.Width, WindowSize.Height));289 //屏幕的真实像素 290 var workArea = toPixel.Transform(newVector(SystemParameters.PrimaryScreenWidth, SystemParameters.PrimaryScreenHeight));291 Debug.WriteLine("WorkArea Width: {0}, Height: {1}", workArea.X, workArea.Y);292 293 if (Location isAppBarLocation.Left or AppBarLocation.Right)294 {295 data.rc.top = 0;296 data.rc.bottom = (int)workArea.Y;297 if (Location ==AppBarLocation.Left)298 {299 data.rc.left = 0;300 data.rc.right = (int)Math.Round(actualSize.X);301 }302 else 303 {304 data.rc.right = (int)workArea.X;305 data.rc.left = (int)workArea.X - (int)Math.Round(actualSize.X);306 }307 }308 else 309 {310 data.rc.left = 0;311 data.rc.right = (int)workArea.X;312 if (Location ==AppBarLocation.Top)313 {314 data.rc.top = 0;315 data.rc.bottom = (int)Math.Round(actualSize.Y);316 }317 else 318 {319 data.rc.bottom = (int)workArea.Y;320 data.rc.top = (int)workArea.Y - (int)Math.Round(actualSize.Y);321 }322 }323 //以上生成的是四周都没有其他AppBar时的理想位置324 //系统将自动调整位置以适应其他AppBar 325 Debug.WriteLine("Before QueryPos: Left: {0}, Top: {1}, Right: {2}, Bottom: {3}", data.rc.left, data.rc.top, data.rc.right, data.rc.bottom);326 Interop.SHAppBarMessage((int)Interop.AppBarMsg.ABM_QUERYPOS, refdata);327 Debug.WriteLine("After QueryPos: Left: {0}, Top: {1}, Right: {2}, Bottom: {3}", data.rc.left, data.rc.top, data.rc.right, data.rc.bottom);328 //自定义对齐方式,确保Height和Width不会小于0 329 if (data.rc.bottom - data.rc.top < 0)330 {331 if (Location ==AppBarLocation.Top)332 data.rc.bottom = data.rc.top + (int)Math.Round(actualSize.Y);//上对齐 333 else if (Location ==AppBarLocation.Bottom)334 data.rc.top = data.rc.bottom - (int)Math.Round(actualSize.Y);//下对齐 335 }336 if(data.rc.right - data.rc.left < 0)337 {338 if (Location ==AppBarLocation.Left)339 data.rc.right = data.rc.left + (int)Math.Round(actualSize.X);//左对齐 340 else if (Location ==AppBarLocation.Right)341 data.rc.left = data.rc.right - (int)Math.Round(actualSize.X);//右对齐 342 }343 //调整完毕,设置为最终位置 344 Interop.SHAppBarMessage((int)Interop.AppBarMsg.ABM_SETPOS, refdata);345 //应用到窗口 346 var location = toWpfUnit.Transform(newPoint(data.rc.left, data.rc.top));347 var dimension = toWpfUnit.Transform(new Vector(data.rc.right -data.rc.left,348 data.rc.bottom -data.rc.top));349 var rect = new Rect(location, newSize(dimension.X, dimension.Y));350 DockedSize =rect;351 352 _window.Dispatcher.Invoke(DispatcherPriority.ApplicationIdle, () =>{353 _window.Left =rect.Left;354 _window.Top =rect.Top;355 _window.Width =rect.Width;356 _window.Height =rect.Height;357 });358 359 Debug.WriteLine("Set {0} Left: {1} ,Top: {2}, Width: {3}, Height: {4}", _window.Title, _window.Left, _window.Top, _window.Width, _window.Height);360 }361 public voidDisableAppBar()362 {363 if(_isRegistered)364 {365 _isRegistered = false;366 var data = newInterop.APPBARDATA();367 data.cbSize =Marshal.SizeOf(data);368 data.hWnd =_hWnd;369 data.uCallbackMessage =_callbackId;370 Interop.SHAppBarMessage((int)Interop.AppBarMsg.ABM_REMOVE, refdata);371 _isRegistered = false;372 RestoreWindowInfo();373 Debug.WriteLine(_window.Title + "DisableAppBar");374 }375 }376 }377 378 public enum AppBarLocation : int 379 {380 Left = 0,381 Top,382 Right,383 Bottom,384 None,385 RegisterOnly=99 386 }387 388 internal static classInterop389 {390 #region Structures & Flags 391 [StructLayout(LayoutKind.Sequential)]392 internal structRECT393 {394 public intleft;395 public inttop;396 public intright;397 public intbottom;398 }399 400 [StructLayout(LayoutKind.Sequential)]401 internal structAPPBARDATA402 {403 public intcbSize;404 publicIntPtr hWnd;405 public intuCallbackMessage;406 public intuEdge;407 publicRECT rc;408 publicIntPtr lParam;409 }410 411 internal enum AppBarMsg : int 412 {413 ABM_NEW = 0,414 ABM_REMOVE,415 ABM_QUERYPOS,416 ABM_SETPOS,417 ABM_GETSTATE,418 ABM_GETTASKBARPOS,419 ABM_ACTIVATE,420 ABM_GETAUTOHIDEBAR,421 ABM_SETAUTOHIDEBAR,422 ABM_WINDOWPOSCHANGED,423 ABM_SETSTATE424 }425 internal enum AppBarNotify : int 426 {427 ABN_STATECHANGE = 0,428 ABN_POSCHANGED,429 ABN_FULLSCREENAPP,430 ABN_WINDOWARRANGE431 }432 #endregion 433 434 #region Win32 API 435 [DllImport("SHELL32", CallingConvention =CallingConvention.StdCall)]436 internal static extern uint SHAppBarMessage(int dwMessage, refAPPBARDATA pData);437 438 [DllImport("User32.dll", CharSet =CharSet.Auto)]439 internal static extern int RegisterWindowMessage(stringmsg);440 #endregion 441 }
三、已知问题
1.在我的github上的实例程序中,如果你将两个同进程的窗口并排叠放的话,会导致explorer和你的进程双双爆栈,windows似乎不能很好地处理这两个并排放置地窗口,一直在左右调整位置,疯狂发送ABN_POSCHANGED消息。(快去clone试试,死机了不要打我) 但是并排放置示例窗口和OneNote地Dock窗口就没有问题。
2.计算停靠窗口时,如果选择停靠位置为Bottom,则系统建议的bottom位置值会比实际的高,测试发现是任务栏窗口占据了部分空间,应该是预留给平板模式的更大图标任务栏(猜测,很不合理的设计)
自动隐藏任务栏就没有这个问题:
3. 没有实现自动隐藏AppBar,故没有处理与之相关的WM_ACTIVATE等消息,有需要的可以参考官方文档。(嘻 我懒)
参考文档:
1).
SHAppBarMessage function (shellapi.h) - Win32 apps | Microsoft Learn
2).
ABM_QUERYPOS message (Shellapi.h) - Win32 apps | Microsoft Learn
ABM_NEW & ABM_SETPOS etc..
3).
使用应用程序桌面工具栏 - Win32 apps | Microsoft Learn
4).
判断是否有全屏程序正在运行(C#)_c# 判断程序当前窗口是否全屏如果是返回原来-CSDN博客
[打个广告] [入门AppBar的最佳实践]
看这里,如果你也需要一个高度可自定义的沉浸式顶部栏(Preview):
TwilightLemon/MyToolBar: 为Surface Pro而生的顶部工具栏 支持触控和笔快捷方式 (github.com)
本作品采用
知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
进行许可。欢迎转载、使用、重新发布,但务必保留文章署名TwilightLemon和原文网址,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。