WPF - 非模态窗口管理器
在需要对无模式窗口的创建/显示时进行更多控制的情况下
引言
在工作中,我最近不得不开发一个创建多个无模式窗口的工具。这些无模式窗口用于展示个人的培训数据、个人的测试指标以及可用的课程数据。虽然无模式窗口本身并不新颖或令人兴奋,但我还需要考虑一个额外的因素:不允许用户重复选择同一个用户并打开两个不同的窗口(数据是静态的,因此没有理由让两个窗口显示相同的数据)。这个要求使得简单地显示无模式窗口变得不可能,迫使我提出了一个无模式窗口管理器,这也是本文的主题。
本文按以下顺序呈现信息:引言、用法和代码描述。这对注意力不集中的人很有益,他们不想知道代码是如何工作的,但又想要代码,而且无需忍受技术知识的负担和枯燥。

特点
ModelessWindowMgr
类实现了以下功能
- 如果窗口的
Owner
属性没有被特别设置,则使用MainWindow
作为所有者。
- 自动处理
Owner_Closing
事件,以便在所有者窗口关闭时,由该窗口创建的所有无模式窗口也将自动关闭。Owner_Closing
事件处理程序是虚拟的,因此您可以在需要时重写它。
- 窗口管理器可以配置为替换具有相同标识属性值的现有窗口。
- 窗口管理器可以配置为允许具有相同标识属性值的窗口的重复实例。
- 您可以使用
IModelessWindow
接口中提供的 id 属性,或者Window
属性中的许多可能提供唯一标识给定窗口的方法(Title, Name, Tag 等)。
- 您可以阻止给定无模式窗口被用作模态窗口。
用法
以下是最小化实现 ModelessWindowMgr
的步骤。下面提供的示例代码仅用于说明需要做什么,并且是示例代码中内容的简化版本。除了创建无模式窗口之外,实际实现中没有 XAML,但即使是它们也不使用任何窗口管理器功能。
0) 以任何适合您项目的方式将 ModelessWindowMgr
文件添加到您的项目中。在我的例子中,我有一个 WpfCommon
程序集,我所有的通用 WPF 代码都在其中。
1) 当您创建一个打算放入窗口管理器的窗口时,必须继承并实现 IModelessWindow
接口。在下面的示例中,我还有一个基类窗口,它继承/实现了 INotifyPropertyChanged
(NotifiableWindow
),因此我继承自该窗口类以及 IModelessWindow
接口。请注意 HandleModeless()
方法 - 该方法由窗口管理器调用,以便为您添加 Owner_Closing 事件处理程序。
我**强烈**建议您采用类似的基类策略。这将使您基本上可以忘记特定于窗口的实现,而只需继续您的编码。
public class ModelessWindowBase : NotifiableWindow, IModelessWindow
{
#region IModelessWindow implementation
// the two properties that must be included in your window class
public string IDString { get; set; }
public int IDInt { get; set; }
public bool IsModal()
{
return ((bool)(typeof(Window).GetField("_showingAsDialog",
BindingFlags.Instance | BindingFlags.NonPublic).GetValue(this)));
}
public void HandleModeless(Window owner)
{
// we can't user the Window.Owner property because doing so causes the
// modeless window to always be on top of its owner. This is a "bad thing"
// (TM). Therefore, it's up to the developer to supply the owner window
// when he adds the modeless window to the manager.
owner.Closing += this.Owner_Closing;
}
public virtual void Owner_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (!this.IsModal())
{
this.Close();
}
}
#endregion IModelessWindow implementation
public TestWindowBase():this(string.Empty, 0)
{
}
public TestWindowBase(string idString, int idInt=0) : base()
{
// populate the id properties
this.IDString = idString;
this.IDInt = idInt;
}
}
2) 创建一个继承自基类的窗口
public partial class Window1 : ModelessWindowBase
{
public Window1()
{
this.InitializeComponent();
}
public Window1(string id):base(id)
{
this.InitializeComponent();
}
}
并相应地更改您的 xaml 以匹配基类
local:ModelessWindowBase x:Class="WpfModelessWindowMgr.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
...
xmlns:local="clr-namespace:WpfModelessWindowMgr"
Title="Window1" Height="100" Width="600"
x:Name="ModelessWindow1" Tag="1">
...
</local:ModelessWindowBase>
3) 将类管理器添加到您的 MainWindow
对象。
public partial class MainWindow : NotifiableWindow
{
private ModelessWindowMgr manager;
public ModelessWindowMgr Manager
{ get { return this.manager; }
set { if (value != this.manager) { this.manager = value; } } }
private string Window1Guid { get; set; }
public MainWindow()
{
this.InitializeComponent();
this.DataContext = this;
string idProperty = "IDString";
bool replaceIfExists = false;
bool allowDuplicates = false;
this.Manager = new ModelessWindowMgr("IDString",
replaceIfExists,
allowDuplicates);
}
}
4) 添加代码以创建您(在上面第 2 步中创建)的无模式窗口的实例。如果您不希望窗口立即显示,请将 false
作为 Add
方法的第二个参数。
Window1 form = new Window1(this.Window1Guid);
this.Manager.Add(form);
差不多就是这样了。您现在拥有一个功能齐全的无模式窗口管理器实现。由于继承自 IModelessWindow
的窗口会自动挂钩 Owner_Closing
事件,因此当您关闭主窗口时,任何(添加到窗口管理器的)仍然打开的无模式窗口都将自动清理。
代码
本节将只介绍 ModelessWindowMgr
类以及 IModelessWindow
接口的实现。
IModelessWindow 接口
IModelessWindow
接口支持两种类型的 id 属性,以及一些必要的方法,以确保给定窗口可以在窗口管理器维护的窗口列表中正常工作。id 属性允许使用通用方法来标识窗口列表中的给定窗口。我认为提供整数和字符串之间的选择是有意义的。此 id 值最好通过窗口构造函数参数,或窗口内部的某些其他机制(如窗口 XAML 中的 Title
、Name
或 Tag
属性)来分配。
该接口的实际实现还涉及几个方法。建议的实现(包括下面的代码)可以在 IModelessWindow.cs
中的注释中找到。
/// <summary>
/// Determine if the window is modal
/// </summary>
/// <returns></returns>
public bool IsModal()
{
// uses a private Window property to determine if this window is modal
return ((bool)(typeof(Window).GetField("_showingAsDialog",
BindingFlags.Instance | BindingFlags.NonPublic).GetValue(this)));
}
/// <summary>
/// Adds an event handler for the Owner window's Closing event.
/// </summary>
public void HandleModeless()
{
// we can't user the Window.Owner property because doing so causes the modeless
// window to always be on top of its owner. This is a "bad thing" (TM).
// Therefore, it's up to the developer to supply the owner window when he adds
// the modeless window to the manager.
owner.Closing += this.Owner_Closing;
}
/// <summary>
/// The closing event for this window's owner.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
public virtual void Owner_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (!this.IsModal())
{
this.Close();
}
}
接口实现的另一个方面是能够实际阻止窗口被用作模态窗口。默认情况下,PreventModal
属性设置为 false,但如果您需要/想要,它就在那里。
public new bool? ShowDialog()
{
if (this.PreventModal)
{
throw new InvalidOperationException("Can't treat a this window like a modal window.");
}
return base.ShowDialog();
}
ModelessWindowManager 类
ModelessWindowMgr
的基本功能是添加、显示和移除窗口。
添加/显示窗口
窗口的添加和显示由一对方法处理。Add
方法允许您指定已实例化(但未显示)的无模式窗口,并且您可以选择指定是否立即显示窗口(默认是立即显示)。我将直接展示代码,而不是提供叙述和代码,因为我认为代码注释已足够详尽。
/// <summary>
/// Add/activate the specified window
/// </summary>
/// <param name="owner">The owner window (cannot be null)</param>
/// <param name="window">The IModelessWindow window (cannot be null)</param>
/// <param name="showNow">Show the window now, or simply add it to manager</param>
public void Add(IModelessWindow window, bool showNow=true)
{
// sanity checks
if (owner == null)
{
throw new ArgumentNullException("owner");
}
if (window == null)
{
throw new ArgumentNullException("window");
}
// first, make sure the "window" is really a window (to keep the developer from trying
// to catch us asleep at the wheel)
if (window is Window)
{
IModelessWindow existingWindow = null;
bool addIt = false;
// if we're allowing duplicates, we can skip all this and just get down to
// showing the window.
if (this.AllowDuplicates)
{
addIt = true;
}
else
{
// see if it's already in the list
existingWindow = this.Find(window);
// if it is already in the list
if (existingWindow != null)
{
// go ahead and close the new window and set it to null. There's
// no point in keeping it around.
((Window)window).Close();
window = null;
// if we need to replace it, eradicate the existing instance first
if (this.ReplaceIfOpen)
{
((Window)(existingWindow)).Close();
this.Windows.Remove(existingWindow);
existingWindow = null;
addIt = true;
}
}
else
{
addIt = true;
}
}
// add or focus the window
if (addIt)
{
// make sure we hook the Owner_Closing event
window.HandleModeless(owner);
// and add the window to the list
this.Windows.Add(window);
// if we want immediate gratification, show the window
if (showNow)
{
((Window)(window)).Show();
}
}
else
{
Window wnd = ((Window)(existingWindow));
if (wnd.Visibility == Visibility.Collapsed)
{
wnd.Show();
}
if (wnd.WindowState == WindowState.Minimized)
{
wnd.WindowState = WindowState.Normal;
}
wnd.Focus();
}
}
}
/// <summary>
/// Find the window in the manager's window list that matches <br/>
/// the ID property value of the specified window.
/// </summary>
/// <param name="window">The window to find</param>
/// <returns>The matching window if found, or null</returns>
protected IModelessWindow Find(IModelessWindow window)
{
// "The relationship is new. Let's not sully it with an exchange of saliva..."
// George Takei, Big Bang Theory, S4, E4
Window foundAsWindow = null;
Window windowAsWindow = ((Window)window);
// find a window with the same id property value. We can check both the string and int
// id properties because we converted the value we're looking for into a string. We
// also check to make sure the window is actually modeless
IModelessWindow found = null;
try
{
// if the id property is one of the two defined in the IModelessWindow interface
if (new string[] { "IDString", "IDInt" }.Contains(this.IDProperty))
{
// get the named property's value as a string
string value = typeof(IModelessWindow).GetProperty(this.IDProperty).GetValue(window).ToString();
// and find the first window that has the same value for the id property
found = this.Windows.FirstOrDefault(x => x.IDString == value || x.IDInt.ToString() == value);
}
else
{
// get the value of the specified Window property, could be Title, Tag, or some
// other property that could be used to uniquely identify a window
object windowPropertyValue = typeof(Window).GetProperty(IDProperty).GetValue(window);
// and find the first window that has the same value for the specified property
found = this.Windows.FirstOrDefault(x => typeof(Window).GetProperty(IDProperty).GetValue(x).Equals(windowPropertyValue));
}
foundAsWindow = ((Window)found);
}
catch (Exception ex)
{
// avoid the compiler warning while allowing us to examine the exception in the
// debugger if necessary
if (ex != null) { }
// We don't want to do anything but eat the exception. This means if there's an
// exception, this method will return null, thus guaranteeing that the window
// will be shown.
}
// return the result (null or the found window)
return found;
}
移除窗口
窗口可以按 id 属性值或底层窗口类型移除,并且可以一次移除一个(第一个或最后一个)或全部移除。所有这些方法都会获取一个要移除的窗口列表(即使只是移除一个窗口)。
/// <summary>
/// Removes all windows from the windows manager
/// </summary>
public void RemoveAll()
/// <summary>
/// Remove the first window with the specified id value from the windows manager
/// </summary>
/// <param name="id">The value of the IDProperty property</param>
public void RemoveFirstWithID(object id)
/// <summary>
/// Remove the last window with the specified id value from the windows manager
/// </summary>
/// <param name="id">The value of the IDProperty property</param>
public void RemoveLastWithID(object id)
/// <summary>
/// Remove the all windows with the specified id value from the windows manager
/// </summary>
/// <param name="id">The value of the IDProperty property</param>
public void RemoveAllWithID(object id)
/// <summary>
/// Remove the first window of the specified type from the windows manager
/// </summary>
/// <param name="windowType">The underlying window type</param>
public void RemoveFirstWindowOfType(Type windowType)
/// <summary>
/// Remove the first window of the specified type from the windows manager
/// </summary>
/// <param name="windowType">The underlying window type</param>
public void RemoveLastWindowOfType(Type windowType)
/// <summary>
/// Remove the first window of the specified type from the windows manager
/// </summary>
/// <param name="windowType">The underlying window type</param>
public void RemoveAllWindowsOfType(Type windowType)
这使得实际的移除代码存在于一个通用的方法中,从而更容易进行故障排除。该方法如下所示
/// <summary>
/// Remove specified windowswindows manager
/// </summary>
/// <param name="windows">The list of found windows to remove</param>
protected void RemoveThese(List<IModelessWindow> windows)
{
while (windows.Count > 0)
{
IModelessWindow window = windows[0];
((Window)window).Close();
this.Windows.Remove(window);
windows.Remove(window);
}
}
关注点
如果您希望在整个应用程序中都可以访问窗口管理器,我建议您使用您最喜欢的方法来实现。我通常会这样做:
- 创建一个静态全局类,并在其中实例化它。
- 创建一个代表
ModelessWindowMgr
对象的单例。
我还发现,如果您在 Window
上设置 Owner
属性,您正在创建的窗口将始终显示在所有者窗口的顶部 - 这不是一个理想的效果。这导致我在尝试将窗口添加到窗口管理器时,要求指定所有者窗口。
最后,将此代码转换为 .Net Core 应该非常简单。尽情享受吧。
标准 JSOP 免责声明
这是我关于解决实际编程问题文章系列的最新一篇。这里没有理论的空谈,没有“如果怎样”,也没有假设的废话。如果您正在寻找一些突破性的、颠覆性的甚至接近前沿的东西,我建议您另寻阅读材料(.Net Core 的粉丝们似乎为自己感到非常自豪)。我并不是那种为他人开辟道路的人(事实上,我应该作为榜样的唯一例子是**不**应该做什么),而且我绝不会声称我的方法是“唯一正确的方式”(除了关于邪恶的 Entity Framework)。如果这篇文章不合您的口味,请便,祝您旅途愉快,并且我以我德州灵魂最深处的爱与尊重(我能尽可能地表达),AMF。
历史
- 2021.03.02 - 修正了 IModelessWindow 实现的代码片段(下载中的实际代码是正确的,但我忘记更新文章文本)。
- 2021.03.01 - 初次发布。