在呂毅大佬的文章中已經詳細介紹了什麼是AppBar: WPF 使用 AppBar 將視窗停靠在桌面上,讓其他程式不佔用此視窗的空間(附我封裝的附加屬性) - walterlv
即讓視窗固定在螢幕某一邊,並且保證其他視窗最大化後不會覆蓋AppBar佔據的區域(類似於Windows工作列)。
但是在我的環境中測試時,上面的程式碼出現了一些問題,例如非100%縮放顯示時的座標計算異常、多視窗同時停靠時佈局錯亂等。所以我重寫了AppBar在WPF上的實現,效果如圖:
一、AppBar的主要申請流程
主要流程如圖:
(圖注:ABN_POSCHANGED訊息在任何需要調整位置之時都會觸發,括號只是舉個例子)
核心程式碼其實在於如何計算停靠視窗的位置,要點是處理好一下幾個方面:
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:AppBar x:Name="appBar" Location="Top" OnFullScreenStateChanged="AppBar_OnFullScreenStateChanged"/> </local:AppBarCreator.AppBar> ... </Window>
或者在後臺建立:
private readonly AppBar appBar=new AppBar(); ...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, bool e) { Debug.WriteLine("Full Screen State: "+e); Visibility = e ? Visibility.Collapsed : Visibility.Visible; }
我在官方的Flag上加了一個RegisterOnly,即只註冊AppBarMsg而不真的停靠視窗,可以此用來作全屏模式監聽。
4. 如果你需要在每個虛擬桌面都顯示AppBar(像工作列那樣),可以嘗試為視窗使用SetWindowLong新增WS_EX_TOOLWINDOW標籤(自行查詢)
以下貼出完整的程式碼:
1 using System.ComponentModel; 2 using System.Diagnostics; 3 using System.Runtime.InteropServices; 4 using System.Windows; 5 using System.Windows.Interop; 6 using System.Windows.Threading; 7 8 namespace AppBarTest; 9 public static class AppBarCreator 10 { 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 void OnAppBarChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 18 { 19 if (d is Window window && e.NewValue is AppBar appBar) 20 { 21 appBar.AttachedWindow = window; 22 } 23 } 24 public static void SetAppBar(Window element, AppBar value) 25 { 26 if (value == null) return; 27 element.SetValue(AppBarProperty, value); 28 } 29 30 public static AppBar GetAppBar(Window element) 31 { 32 return (AppBar)element.GetValue(AppBarProperty); 33 } 34 } 35 36 public class AppBar : DependencyObject 37 { 38 /// <summary> 39 /// 附加到的視窗 40 /// </summary> 41 public Window AttachedWindow 42 { 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 = new WindowInteropHelper(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 void CheckPending() 81 { 82 //建立AppBar時提前觸發的LocationChanged 83 if (_locationChangePending) 84 { 85 _locationChangePending = false; 86 LoadAppBar(Location); 87 } 88 } 89 /// <summary> 90 /// 載入AppBar 91 /// </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 //僅註冊AppBarMsg 101 //如果之前註冊過有效的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 = new WindowInteropHelper(_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 public AppBarLocation Location 153 { 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 new PropertyMetadata(AppBarLocation.None, OnLocationChanged)); 163 164 private bool _locationChangePending = false; 165 private static void OnLocationChanged(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 private IntPtr _hWnd; 182 private WindowStyle _originalStyle; 183 private Point _originalPosition; 184 private Size _originalSize = Size.Empty; 185 private ResizeMode _originalResizeMode; 186 private bool _originalTopmost; 187 public Rect? DockedSize { get; set; } = null; 188 private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, 189 IntPtr lParam, ref bool handled) 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 return IntPtr.Zero; 209 } 210 211 public void BackupWindowInfo() 212 { 213 _callbackId = 0; 214 DockedSize = null; 215 _originalStyle = _window.WindowStyle; 216 _originalSize = new Size(_window.ActualWidth, _window.ActualHeight); 217 _originalPosition = new Point(_window.Left, _window.Top); 218 _originalResizeMode = _window.ResizeMode; 219 _originalTopmost = _window.Topmost; 220 } 221 public void RestoreWindowInfo() 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 void ForceWindowStyles() 235 { 236 _window.WindowStyle = WindowStyle.None; 237 _window.ResizeMode = ResizeMode.NoResize; 238 _window.Topmost = true; 239 } 240 241 public void RegisterAppBarMsg() 242 { 243 var data = new Interop.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, ref data); 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 void EnableAppBar() 257 { 258 if (!_isRegistered) 259 { 260 //備份視窗資訊並設定視窗樣式 261 BackupWindowInfo(); 262 //註冊成為AppBar視窗 263 RegisterAppBarMsg(); 264 ForceWindowStyles(); 265 } 266 //成為AppBar視窗之後(或已經是)只需要註冊並移動視窗位置即可 267 SetAppBarPosition(_originalSize); 268 } 269 public void SetAppBarPosition(Size WindowSize) 270 { 271 var data = new Interop.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 = new Size(_window.ActualWidth, _window.ActualHeight); 288 var actualSize = toPixel.Transform(new Vector(WindowSize.Width, WindowSize.Height)); 289 //螢幕的真實畫素 290 var workArea = toPixel.Transform(new Vector(SystemParameters.PrimaryScreenWidth, SystemParameters.PrimaryScreenHeight)); 291 Debug.WriteLine("WorkArea Width: {0}, Height: {1}", workArea.X, workArea.Y); 292 293 if (Location is AppBarLocation.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, ref data); 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, ref data); 345 //應用到視窗 346 var location = toWpfUnit.Transform(new Point(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, new Size(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 void DisableAppBar() 362 { 363 if (_isRegistered) 364 { 365 _isRegistered = false; 366 var data = new Interop.APPBARDATA(); 367 data.cbSize = Marshal.SizeOf(data); 368 data.hWnd = _hWnd; 369 data.uCallbackMessage = _callbackId; 370 Interop.SHAppBarMessage((int)Interop.AppBarMsg.ABM_REMOVE, ref data); 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 class Interop 389 { 390 #region Structures & Flags 391 [StructLayout(LayoutKind.Sequential)] 392 internal struct RECT 393 { 394 public int left; 395 public int top; 396 public int right; 397 public int bottom; 398 } 399 400 [StructLayout(LayoutKind.Sequential)] 401 internal struct APPBARDATA 402 { 403 public int cbSize; 404 public IntPtr hWnd; 405 public int uCallbackMessage; 406 public int uEdge; 407 public RECT rc; 408 public IntPtr 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_SETSTATE 424 } 425 internal enum AppBarNotify : int 426 { 427 ABN_STATECHANGE = 0, 428 ABN_POSCHANGED, 429 ABN_FULLSCREENAPP, 430 ABN_WINDOWARRANGE 431 } 432 #endregion 433 434 #region Win32 API 435 [DllImport("SHELL32", CallingConvention = CallingConvention.StdCall)] 436 internal static extern uint SHAppBarMessage(int dwMessage, ref APPBARDATA pData); 437 438 [DllImport("User32.dll", CharSet = CharSet.Auto)] 439 internal static extern int RegisterWindowMessage(string msg); 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和原文網址,不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。