在 C#/WPF 项目里,窗口的最大化、最小化和还原看起来是系统标题栏自带的能力。可一旦进入自定义标题栏、无边框窗口、托盘菜单、快捷键联动这类场景,窗口状态就需要由 ViewModel 里的命令来驱动。这个过程虽然不复杂,但如果对 WPF 的 MVVM 机制理解不深,很容易把代码写成 View 里直接访问窗口、ViewModel 里又通过全局窗口列表查找窗口的混合模式,时间一长,窗口管理工作越来越难维护。
这里围绕一个真实常见的问题展开:如何在 ViewModel 中定义命令,切换 WPF 窗口的最大化、最小化和还原状态。文章会先从 WPF 的 WindowState 机制讲起,再给出一个可以直接运行的 MVVM 最小示例,最后补充自定义标题栏场景下的细节、常见问题和生产环境建议。读完以后,你可以把这套写法直接放进自己的 WPF 项目中。
1. 窗口状态控制的本质是 WindowState 属性,不是窗口方法
1.1 WindowState 是一个依赖属性,不是一组窗口方法
WPF 的Window类没有提供Maximize()、Minimize()、Restore()这类公开方法。窗口的最大化、最小化和还原,本质上是修改Window.WindowState这个依赖属性的值。
WindowState是枚举类型,主要有三个值:
| 枚举值 | 含义 | 典型使用场景 |
|---|---|---|
WindowState.Normal | 普通还原状态 | 表示窗口恢复为之前设置的大小和位置 |
WindowState.Maximized | 最大化 | 窗口铺满屏幕工作区 |
WindowState.Minimized | 最小化 | 窗口收缩到任务栏 |
所以,在代码里想让窗口最大化,并不需要调用某种特殊 API,只需要执行:
window.WindowState = WindowState.Maximized;如果想让窗口从最大化状态还原,把WindowState设置回WindowState.Normal即可。
这个机制决定了 MVVM 模式下的实现思路:窗口状态更适合作为一个状态属性来管理,而不是作为一个动作来调用。只要 ViewModel 能把自己的状态同步给窗口,窗口自然就会切换状态。
1.2 为什么不能把所有逻辑都写在 Button.Click 里
如果只在默认标题栏上运行,系统已经帮我们处理了最小化、最大化、还原按钮。但是下面这些场景会逼着你把控制权从 View 中拿出来:
- 窗口使用了自定义标题栏,右上角的最小化、最大化、还原按钮是开发人员自己画的。
- 窗口状态需要和设置项联动,比如开机后记住上次是最大化还是还原。
- 托盘菜单需要控制某个窗口最小化或还原。
- 快捷键需要触发窗口切换状态。
- 需要自动化测试窗口逻辑。
如果把状态逻辑直接写在 Button.Click 事件里,窗口动作和 ViewModel 状态很容易脱节。例如,你在代码里用this.WindowState = WindowState.Maximized;把窗口放大了,但 ViewModel 组件如果需要知道当前窗口是否处于最大化状态,它只能通过另一个窗口事件再去同步,状态来源就变得分散。
MVVM 的做法是让 ViewModel 持有“窗口应该处于什么状态”这个事实,View 只负责把 ViewModel 的状态作用到真正的窗口对象上。这样,无论按钮点击、快捷键,还是菜单命令触发,入口都统一在 ViewModel 的命令层。
1.3 一个先要确认的架构判断:ViewModel 是否允许依赖 System.Windows
WPF 中WindowState枚举位于System.Windows命名空间。设计 ViewModel 时,要先确定一个边界:
| 分层要求 | 可以在 ViewModel 中使用的状态类型 | 说明 |
|---|---|---|
| 单项目 WPF,ViewModel 和 View 在同一程序集 | 可以直接使用System.Windows.WindowState | 代码最简单,绑定最直接 |
| ViewModel 独立类库,且不允许引用 WPF 类型 | 建议自定义WindowDisplayState枚举或使用 string | 需要额外做一层值转换 |
| 需要跨 WPF、Avalonia 或 MAUI 共用 ViewModel | 建议通过接口或者自定义 UI 状态枚举 | 窗口控制逻辑下沉到服务层 |
如果项目本身只在 WPF 中运行,并且 ViewModel 不会被其他 UI 框架复用,直接使用WindowState完全可行。文章后面的示例默认采用这种做法。若你的项目严格要求 ViewModel 不依赖System.Windows,可以保留“控制命令”和“状态属性”的核心结构,只是把属性的枚举类型换成自定义类型,再增加一个转换器。
2. 准备工作:搭建可以跑起来的最小 MVVM 项目
2.1 项目环境与结构
下面示例基于 Visual Studio 2022 和 .NET 8 创建 WPF 项目。项目名假设为ViewModelWindowStateDemo。其他现代版本,例如 .NET 6 或 .NET 9,操作方式相同。
创建 WPF 项目后,项目文件主要包含:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <Nullable>enable</Nullable> <UseWPF>true</UseWPF> </PropertyGroup> </Project>示例代码会涉及下面这些文件:
ViewModelWindowStateDemo ├── App.xaml ├── App.xaml.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs ├── ViewModels │ ├── ViewModelBase.cs │ ├── RelayCommand.cs │ └── MainWindowViewModel.csViewModelBase.cs和RelayCommand.cs是 MVVM 中最基础的类型,不依赖具体业务。
2.2 ViewModelBase 只负责属性通知
为了把窗口状态显示到界面上,属性必须实现INotifyPropertyChanged。这里写一个简洁的基类:
using System.ComponentModel; using System.Runtime.CompilerServices; namespace ViewModelWindowStateDemo.ViewModels { public abstract class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetProperty<T>( ref T field, T value, [CallerMemberName] string? propertyName = null) { if (EqualityComparer<T>.Default.Equals(field, value)) { return false; } field = value; OnPropertyChanged(propertyName); return true; } protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } }SetProperty方法的作用有两个:第一,只在值真正变化时才通知界面,避免无效刷新;第二,用CallerMemberName自动带出调用属性的名字,这样属性 setter 里不需要手动写字符串。
2.3 RelayCommand 把按钮动作包装成命令
WPF 中的Button支持Command属性。为了让 ViewModel 里的方法能被按钮调用,需要实现ICommand。常见的最小实现如下:
using System; using System.Windows.Input; namespace ViewModelWindowStateDemo.ViewModels { public sealed class RelayCommand : ICommand { private readonly Action<object?> _execute; private readonly Predicate<object?>? _canExecute; public RelayCommand( Action<object?> execute, Predicate<object?>? canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } public bool CanExecute(object? parameter) { return _canExecute?.Invoke(parameter) ?? true; } public void Execute(object? parameter) { _execute(parameter); } public event EventHandler? CanExecuteChanged { add => CommandManager.RequerySuggested += value; remove => CommandManager.RequerySuggested -= value; } } }要让Command绑定生效,需要注意两个点。
第一,RelayCommand的构造函数要求传入一个Action<object?>。即使方法不需要参数,也要写成_ =>这样能够适配 object 参数的形式。
第二,CanExecuteChanged使用了CommandManager.RequerySuggested,这是 WPF 中比较常见的写法。它保证当界面焦点、鼠标等系统状态变化时,WPF 会重新查询命令是否可执行。后面如果某个命令需要根据窗口状态控制是否可用,会用到这个机制。
3. 核心方案:在 ViewModel 中暴露窗口状态属性
3.1 不建议在 ViewModel 里持有 Window 对象
设计方案时,最容易想到的做法是让 ViewModel 命令直接拿到窗口对象:
private void MinimizeButtonClick(object? parameter) { if (parameter is Window window) { window.WindowState = WindowState.Minimized; } }这种写法虽然在简单场景下能用,但它有几个问题:
- ViewModel 需要感知
Window类型,View 层特点被带进了业务逻辑层。 - 在多窗口项目中,按钮到底控制哪个窗口,必须靠 CommandParameter 传入,参数传错或没有传就会出现按钮点了没反应。
- 单元测试不方便,测试时要先创建真实窗口,而不是只测一个状态。
因此,更稳妥的主方案是:ViewModel 只维护一个WindowState属性,WPF 通过双向绑定把属性值应用到真实窗口上。
3.2 编写 MainWindowViewModel
在ViewModels文件夹下添加MainWindowViewModel.cs:
using System.Windows; using System.Windows.Input; using ViewModelWindowStateDemo.ViewModels; namespace ViewModelWindowStateDemo.ViewModels { public class MainWindowViewModel : ViewModelBase { private WindowState _currentWindowState = WindowState.Normal; public MainWindowViewModel() { MinimizeCommand = new RelayCommand(_ => CurrentWindowState = WindowState.Minimized); ToggleMaximizeRestoreCommand = new RelayCommand(_ => ToggleMaximizeRestore()); } public WindowState CurrentWindowState { get => _currentWindowState; set { if (SetProperty(ref _currentWindowState, value)) { OnPropertyChanged(nameof(IsMaximized)); OnPropertyChanged(nameof(MaximizeRestoreButtonText)); } } } public bool IsMaximized => CurrentWindowState == WindowState.Maximized; public string MaximizeRestoreButtonText => CurrentWindowState == WindowState.Maximized ? "还原" : "最大化"; public ICommand MinimizeCommand { get; } public ICommand ToggleMaximizeRestoreCommand { get; } private void ToggleMaximizeRestore() { CurrentWindowState = CurrentWindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; } } }这里有几个关键点需要解释。
第一个关键点是,CurrentWindowState的类型直接是WindowState。它保存的是 ViewModel 认为“窗口当前应该处于什么状态”。MinimizeCommand只做一件事,就是把这个状态改成WindowState.Minimized。
第二个关键点是,Windows 的还原和最大化通常使用同一个按钮。窗口处于普通状态时按钮叫“最大化”,处于最大化状态时按钮叫“还原”。因此 ViewModel 提供了ToggleMaximizeRestoreCommand,用它来处理这个切换逻辑。
第三个关键点是,CurrentWindowState发生变化后,除了通知自身变化,还要通知依赖它的两个属性:
IsMaximized:供后续界面触发器使用。MaximizeRestoreButtonText:让按钮文案自动切换。
如果不通知这两个依赖属性,界面上的文字仍然保持旧值,就会出现“窗口已经最大化了,按钮还显示最大化”的问题。