WPF/MVVM 应用程序入门:视图之间的导航
如何在不将视图或视图模型耦合在一起的情况下从一个视图切换到另一个视图?这是我目前正在做的方式。
如何在不将视图或视图模型耦合在一起的情况下从一个视图切换到另一个视图?这是我目前正在做的方式。
整体应用程序设计布局
我们想要的布局是 Outlook 风格的导航,其中有一个固定的布局模板,所有应用程序视图都将在此模板上呈现。因此,开发人员将能够创建自己的视图并在应用程序的预定布局中显示它们。布局将视图分为两个部分:左侧的导航部分和右侧的主体部分。这两个部分形成一个原子视图,我们希望两者都绑定到同一个视图模型。左侧的导航内容允许我们更改设置或选择影响右侧主体内容显示的项目。
让我们进入 Expression Blend 并创建一个新项目。我将创建一个简单的股票交易应用程序,它最初只包含以下内容
- MainWindow.xaml
- NavigationControl.xaml
- MainView.xaml
- PortfolioView.xaml
- StockQuoteView.xaml

我创建了一个视图,它由一个带有三行三列的 Grid
、一个状态栏、一个菜单、一个网格分隔器和我自己的 NavigationControl
组成。我已将控件放置在适当的网格行和列位置。
请注意为主视图的导航方面创建单独控件的决定。导航的行为显然与主视图是独立的关注点,因此应将其封装到单独的控件中。例如,这将使导航行为以后可以轻松替换为更精细、更具吸引力的导航控件。这也有助于限制主视图 XAML 的复杂性。
您可能会问自己为什么我没有选择也将菜单作为一个单独的控件。简单的答案是:完全没有理由。事实上,那样做可能会更好,但是为了简单起见,我选择不走这条路,因为我将把菜单绑定的每个命令定义为底层视图模型的单独成员,因此在简单性或整体 XAML 行数方面不会有太多收获。但是,如果我决定所有命令都将由底层视图模型上的一个 IEnumerable<icommand>
属性提供,我将从中动态创建菜单项,那么这将是将该行为隔离到单独的 DynamicMenuControl
中的一个好理由。创建单独控件的另一个原因是,如果我认为菜单结构将非常广泛,因此将其定义在单独文件中会更有益。状态栏和任何其他控件也是如此。无论哪种方式,这仍然遵循 PMVVM,因此其余的只是一个实现细节。
这是主视图的 XAML
<Grid x:Name="LayoutRoot">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0.2*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="0.8*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<GridSplitter HorizontalAlignment="Left" Width="5"
Grid.Column="1"
Grid.Row="1"/>
<StatusBar Margin="0"
VerticalAlignment="Top"
Height="23"
Grid.ColumnSpan="3"
Grid.Row="2"/>
<Menu Margin="0" Height="23" Grid.ColumnSpan="3">
<MenuItem Header="Views">
<MenuItem Header="Stock Quotes"
Command="{Binding Path=NavigateToStocksCommand}"/>
<MenuItem Header="Portfolio"
Command="{Binding Path=NavigateToPortfolioCommand}"/>
</MenuItem>
</Menu>
<local:NavigationControl
Margin="0,-23,0,0"
Grid.Row="1"
DataContext="{Binding}"/>
<ContentControl Margin="0"
Content="{Binding Path=CurrentView}"
d:LayoutOverrides="Width, Height"
Grid.Row="1"
Grid.Column="2"/>
</Grid>
接下来我需要做的是设置应用程序的初始窗口。最好定义尽可能少的窗口,并在单独的用户控件中定义其内容。窗口维护成本很高,因为它们包含重复的代码,因此更改一个窗口意味着更改所有窗口。例如,假设您的应用程序进入测试阶段,测试人员发现所有弹出窗口都没有设置父级,或者窗口样式不正确,并且不应该有最大化按钮。在这种情况下,您需要找到所有弹出窗口并更改其属性。继承是一种选择,但在这种情况下,组合优于继承允许您只定义一个窗口,您可以在运行时动态设置其内容。您可以为所有窗口定义一个通用样式来解决此问题,但那样您就编写了无数窗口并重复了代码!考虑到这一点,MainWindow
XAML 如下所示
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PMVVM.MainWindow"
x:Name="Window"
Title="MainWindow"
Width="640" Height="480">
<Grid x:Name="LayoutRoot">
<ContentControl Content="{Binding}"/>
</Grid>
</Window>
就是这样!理想情况下,这是我希望在主窗口中拥有的内容。
此时,其他视图除了一个文本块之外没有太多内容,以说明该视图与下一个视图不同。我们稍后会回到这些视图。
最后,导航控件需要提供一些方法,允许用户调用相关命令
<Grid x:Name="LayoutRoot">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock
Text="{Binding Path=Name}"
TextWrapping="Wrap"
d:LayoutOverrides="Height"/>
<ContentControl
Content="{Binding Path=CurrentView}" Grid.Row="1"/>
<Button Grid.Row="2" Height="23"
Content="Stock Quotes"
Command="{Binding Path=NavigateToStocksCommand}"/>
<Button
Content="Portfolio" Grid.Row="3" Height="23"
Command="{Binding Path=NavigateToPortfolioCommand}"/>
</Grid>
现在我必须暂时摘下我相当旧的设计师帽子,戴上我的开发者帽子。因此,我将打开我在 Blend 中创建的解决方案,并在 Visual Studio 中打开它。

现在我要介绍 PMVVM 的两个非常重要的方面,除了视图模型之外,它们对其论点至关重要。它们是:命令和依赖注入。命令允许我们以解耦的方式与视图模型进行交互和通信。依赖注入通过允许我们处理契约(接口)而不是视图模型的具体实现来进一步促进这一点。这两个功能都能够创建极其灵活的应用程序。
现在,首先是重要的事。让我们为我们的视图模型创建契约
using System.Windows.Input;
namespace PMVVM.ViewModels
{
public interface IMainViewModel : IViewModelBase{
object CurrentView { get; }
ICommand NavigateToStocksCommand { get; }
ICommand NavigateToPortfolioCommand { get; }
void NavigateToView(object viewToNavigate);
}
}
namespace PMVVM.ViewModels
{
public interface IStockQuotesViewModel :
IViewModelBase{
string Name { get; }
}
}
using System.Collections.Generic;
namespace PMVVM.ViewModels
{
public interface IPortofolioViewModel :
IViewModelBase{
string Name { get; }
IEnumerable<string> Portfolios { get; }
}
}
using System.ComponentModel;
namespace PMVVM.ViewModels
{
public interface IViewModelBase :
INotifyPropertyChanged{
}
}
请注意空的 IViewModelBase
接口。我稍后会向其中添加一些成员。但目前,它将作为我们的 INotifyPropertyChanged
接口的默认实现。
让我们也看看 MainViewModel
的实现,这是我们目前真正感兴趣的唯一实现
using System.Windows.Input;
using PMVVM.Commands;
namespace PMVVM.ViewModels.Implementation
{
public class MainViewModel : ViewModelBase,IMainViewModel{
private object _currentView;
public object CurrentView{
get { return _currentView; }
private set
{_currentView = value;
OnPropertyChanged("CurrentView");
}
}
public ICommand NavigateToStocksCommand{
get { return new NavigateToViewCommand
(Container.Container.GetA<IStockQuotesViewModel>()); }
}
public ICommand NavigateToPortfolioCommand{
get { return new NavigateToViewCommand
(Container.Container.GetA<IPortofolioViewModel>()); }
}
public void NavigateToView(object viewToNavigate){
CurrentView = viewToNavigate;
}
}
}
在这里,我实现了 NavigateToView
方法的行为,并声明了设计师期望存在的导航命令属性。显然,当当前视图更改时,我们需要调用 NotifyPropertyChanged
。
那么为什么我创建了一个方法来设置当前视图,而没有在接口中公开 setter 呢?目前,这似乎只是一种编码风格,但是导航的行为不太可能仅仅是一个属性的 setter,因此如果我们遵循最佳实践,我们不应该滥用属性 setter,将其包含与更改该成员状态无关的行为代码。为了追求干净的代码,我们还希望在调用时明确,以便开发人员阅读代码时意图清晰。
这里的命令只是 Microsoft 在 MVVM 上认可的 RelayCommand
变体的子类(在线参考 #1 Josh Smith on MVVM)。为了说明,这里是
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;
namespace PMVVM.Commands
{
public abstract class WpfCommand : ICommand{
private readonly string _verb;
protected WpfCommand(string verb){
_verb = verb;
}
public string Verb{
get { return _verb; }
}
public void Execute(object parameter){
RunCommand(parameter);
}
protected abstract void RunCommand(object parameter);
protected abstract IEnumerable<string> GetPreconditions(object parameter);
public bool CanExecute(object parameter){
return GetPreconditions(parameter).Count() < 1;
}
public event EventHandler CanExecuteChanged{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}
}
动词参数此时并不重要,但当我们想要动态地将命令链接到快捷方式或动态控制菜单项中命令的显示文本时,它将在以后证明是无价的。
这是导航命令子类
using System.Collections.Generic;
using PMVVM.ViewModels;
namespace PMVVM.Commands
{
public class NavigateToViewCommand : WpfCommand{
private readonly object _viewToNavigate;
public NavigateToViewCommand(object viewToNavigate) : base("Navigate"){
_viewToNavigate = viewToNavigate;
}
protected override void RunCommand(object parameter){
Container.Container.GetA<IMainViewModel>().NavigateToView(_viewToNavigate);
}
protected override IEnumerable<string> GetPreconditions(object parameter){
yield break;
}
}
}
您在此处看到的容器的实现并不重要,但您应该熟悉依赖注入和控制反转的概念,并可以查阅 Windsor Container 框架。有关我已实现的容器框架的实现细节,请参阅源代码。
那么当我们编译并运行解决方案时会发生什么呢?当我们点击投资组合视图按钮时,我们在左侧和右侧面板中都看到了我们的投资组合视图。股票报价视图也是如此。但这不是我们想要的,对吧?在左侧,我们希望有一个不同的视图,显示一些用户可以与之交互并调用更改的控件,这些更改显示在右侧。

那么我们该怎么做呢?我们创建另一个视图模型,其中包含一个用于在面板之间进行通信的复杂系统,对吗?错了!我的意思是如果您喜欢,您可以那样做,但这不是必需的!这就是我们利用 WPF 和数据模板的奇迹的地方。就逻辑树而言,左右面板的视图模型是相同的,也应该如此。这让我又提出了另一个分离关注点的论点:需要告诉导航控件,它对同一视图模型的可视表示必须不同,因为它有不同的目的。
在导航控件中,我们需要添加以下 XAML
<UserControl.Resources>
<DataTemplate DataType="{x:Type Implementation:StockQuotesViewModel}">
<PMVVM:StocksQuoteNavigationView/>
</DataTemplate>
<DataTemplate DataType="{x:Type Implementation:PortofolioViewModel}">
<PMVVM:PortfolioNavigationView/>
</DataTemplate>
</UserControl.Resources>
在这里,我使用不同的视觉表示覆盖了视图模型的现有数据模板。
我现在需要再创建两个控制每个原子视图导航方面的视图。我可以继续创建它们,但是我会将它们交给设计师来实际实现它们

为了说明目的,我只通过添加几个 TextBlock
和一个 ListBox
来更改投资组合导航视图,以便用户可以选择他/她的投资组合。我将列表框绑定到底层视图模型上的投资组合列表。
<Grid x:Name="LayoutRoot">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock
Text="{Binding Path=Name}" TextWrapping="Wrap"/>
<ListBox Grid.Row="2"
ItemsSource="{Binding Path=Portfolios}"/>
<TextBlock HorizontalAlignment="Left"
Text="Select a portfolio"
TextWrapping="Wrap"
d:LayoutOverrides="Height" Grid.Row="1"/>
</Grid>
那么现在当我编译并运行应用程序时会发生什么呢?
