WPF中在MVVM模式下实现导航功能
一、利用TabControl
使用场景:项目小,不用考虑内存开销的问题。
实现方式1-手动指定ViewModel
- 分别定义3个
UserControl
作为View用于演示
xml
<UserControl
...>
<Grid>
<StackPanel Orientation="Vertical">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Top"
Text="Page 1" />
<TextBlock
d:Text="Page 1"
FontSize="50"
Text="{Binding PageMessage}" />
</StackPanel>
</Grid>
</UserControl>
- 分别定义ViewModel
csharp
public abstract class PageViewModelBase
{
public string? Header { get; set; }
}
public class MainViewModel
{
public List<PageViewModelBase> ViewModels { get; }
public MainViewModel(Page1ViewModel p1, Page2ViewModel p2, Page3ViewModel p3)
{
ViewModels = new List<PageViewModelBase> { p1, p2, p3 };
}
}
public class Page1ViewModel : PageViewModelBase
{
public Page1ViewModel() => Header = "Page 1";
public string PageMessage { get; set; } = "Hello, Page 1";
}
public class Page2ViewModel : PageViewModelBase
{
public Page2ViewModel() => Header = "Page 2";
public string PageMessage { get; set; } = "Hello, Page 2";
}
public class Page3ViewModel : PageViewModelBase
{
public Page3ViewModel() => Header = "Page 3";
public string PageMessage { get; set; } = "Hello, Page 3";
}
- 在MainWindow上定义Tabcontrol
xml
<Window
...>
<Grid>
<TabControl ItemsSource="{Binding ViewModels}">
<TabItem Header="Pag1">
<view:Page1>
<view:Page1.DataContext>
<local:Page1ViewModel />
</view:Page1.DataContext>
</view:Page1>
</TabItem>
<TabItem Header="Pag2">
<view:Page1>
<view:Page1.DataContext>
<local:Page2ViewModel />
</view:Page1.DataContext>
</view:Page1>
</TabItem>
<TabItem Header="Pag3">
<view:Page1>
<view:Page1.DataContext>
<local:Page3ViewModel />
</view:Page1.DataContext>
</view:Page1>
</TabItem>
</TabControl>
</Grid>
</Window>
这种方式需要手动指定每个View的ViewModel
实现方式2-利用ItemTemplate
- 在MainViewModel中声明一个ViewModel列表
csharp
public class MainViewModel
{
public List<PageViewModelBase> ViewModels { get; }
public MainViewModel(Page1ViewModel p1, Page2ViewModel p2, Page3ViewModel p3)
{
ViewModels = new List<PageViewModelBase> { p1, p2, p3 };
}
}
- 在MainWindow中为TabControl指定ItemTemplate,上一步声明的ViewModel列表作为 TabControl 的 ItemsSource;为 TabControl.Resources 添 加多个 DataTemplate,指定 VM 对应什么样的 Page
xml
<Window d:DataContext="{d:DesignInstance Type=local:MainViewModel}"
....>
<Grid>
<TabControl ItemsSource="{Binding ViewModels}">
<TabControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Header}"/>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.Resources>
<DataTemplate DataType="{x:Type local:Page1ViewModel}">
<view:Page1/>
</DataTemplate>
<DataTemplate DataType="{x:Type local:Page2ViewModel}">
<view:Page2/>
</DataTemplate>
<DataTemplate DataType="{x:Type local:Page3ViewModel}">
<view:Page3/>
</DataTemplate>
</TabControl.Resources>
</TabControl>
</Grid>
</Window>
这样的好处是自动会为不同的View绑定了相应的ViewModel。
小技巧 :在xaml中加上了d:DataContext="{d:DesignInstance Type=local:MainViewModel}
,这样在写Binding的时候就有了智能提示。
以上两种方式均可结合依赖注入的方式来实现
二、自定义NavigationService服务
- 实现一个NavigationService服务,并作为单例
csharp
class NavigationService
{
//设置一个单例服务
public static NavigationService Instance { get; private set; } = new NavigationService();
//声明一个事件,当更改CurrentViewModel时触发
public event Action? CurrentViewModelChanged;
//设置一个当前VM的属性,并在属性改变时触发CurrentViewModelChanged
private ViewModelBase? currentViewModel;
public ViewModelBase? CurrentViewModel
{
get => currentViewModel;
set
{
currentViewModel = value;
CurrentViewModelChanged?.Invoke();
}
}
//页面导航方法,给CurrentViewModel赋值,触发CurrentViewModelChanged事件
public void NavigateTo(ViewModelBase viewModel)=>CurrentViewModel = viewModel;
}
- 设置MainViewModel中的CurrentViewModel属性
csharp
public class ViewModelBase : ObservableObject{}
public partial class MainViewModel : ViewModelBase
{
[ObservableProperty]
private ViewModelBase? currentViewModel;//当前的VM
public MainViewModel()
{
//为事件绑定委托方法,设置CurrentVM和NavigationService中的CurrentVM保持一致
NavigationService.Instance.CurrentViewModelChanged += () =>
{
CurrentViewModel = NavigationService.Instance.CurrentViewModel;
};
//调用导航方法
NavigationService.Instance.NavigateTo(new LoginViewModel());
}
}
其他两个ViewModel分别为
csharp
public partial class LoginViewModel : ViewModelBase
{
[ObservableProperty]
string? userName = "Sean";
[RelayCommand]
void Login()
{
NavigationService.Instance.NavigateTo(new HomeViewModel());
}
}
public partial class HomeViewModel : ViewModelBase
{
[ObservableProperty]
string? userName;
[RelayCommand]
void Logout()
{
NavigationService.Instance.NavigateTo(new LoginViewModel());
}
}
- 使用ContentControl作为MainWindow上不同页面载体显示内容,并借助DataTemplate来实现View和ViewModel的映射
xml
<Window ...>
<ContentControl Content="{Binding CurrentViewModel}">
<ContentControl.Resources>
<DataTemplate DataType="{x:Type vm:LoginViewModel}">
<view:Login />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:HomeViewModel}">
<view:Home />
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
</Window>
在ContentControl.Resources中设置DataTemplate,根据DataType自动选择相应的VM,这样做的好处是会自动将View和VM进行了绑定。
改进
- 单例方式可以采用依赖注入的方式来实现
- 在NavigationService服务中,可以改进页面导航的方法
csharp
public void NavigateTo<T>() where T : ViewModelBase
=> CurrentViewModel = App.Current.Services.GetService<T>();
//在调用导航方法时可以使用
navigationService.NavigateTo<HomeViewModel>();
三、借助ValueConverter
实现上一章节的功能,这种方法本质上是通过View来自动绑定VM。
- 定义Page的枚举
csharp
public enum ApplicationPage
{
Empty,
Login,
Home
}
- 定义各ViewModel
csharp
public class ViewModelBase : ObservableObject{}
public partial class MainViewModel : ViewModelBase
{
//MainViewModel中的CurrentPage是一个枚举类型
[ObservableProperty]
ApplicationPage currentPage;
public MainViewModel()
{
CurrentPage = ApplicationPage.Login;
}
}
public partial class LoginViewModel : ViewModelBase
{
public string UserName { get; set; } = "AngelSix";
[RelayCommand]
void Login()
{
var mainVM= App.Current.MainWindow.DataContext as MainViewModel;
mainVM!.CurrentPage = ApplicationPage.Home;
}
}
public partial class HomeViewModel : ViewModelBase
{
[RelayCommand]
void Logout()
{
var mainVM = App.Current.MainWindow.DataContext as MainViewModel;
mainVM!.CurrentPage = ApplicationPage.Login;
}
}
-
定义Page基类和各个Page
这种方法本质上是通过View来自动绑定VM,所以在此处使用泛型
csharp
public abstract class BasePage<VM> : UserControl where VM : ViewModelBase, new()
{
public BasePage()
{
DataContext = new VM();
}
}
- 实现Home页面
将Home.xaml.cs中的继承删掉,以为它和Home.xaml相互为分部类,只在一个分部类上实现继承就可以。
xml
<local:BasePage
x:TypeArguments="vm:HomeViewModel"
...>
<!--x:TypeArguments指定泛型-->
<Grid>
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="Home"
FontSize="32" />
<Button Margin="10" Grid.Row="1"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Content="Logout"
Command="{Binding LogoutCommand}" />
</Grid>
</local:BasePage>
- 实现Login页面
方法和实现Home页面方法相同
xml
<local:BasePage
x:TypeArguments="vm:LoginViewModel" ...>
<Grid>
<Border
Padding="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="10">
<StackPanel Width="300">
<TextBlock HorizontalAlignment="Center" FontSize="28">Login</TextBlock>
<Separator Margin="0,10" />
<TextBlock>User name:</TextBlock>
<TextBox
Margin="0,10"
InputMethod.IsInputMethodEnabled="False"
Text="{Binding UserName}" />
<TextBlock>Password:</TextBlock>
<PasswordBox Margin="0,10" Password="123456" />
<Button Command="{Binding LoginCommand}" Content="Login" />
</StackPanel>
</Border>
</Grid>
</local:BasePage>
-
定义PageViewConverter
csharppublic class PageViewConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { switch ((ApplicationPage)value) { case ApplicationPage.Empty: return new TextBlock { Text = "404 Not Found" }; case ApplicationPage.Login: return new Login(); case ApplicationPage.Home: return new Home(); default: throw new ArgumentException("Invalid value passed to ApplicationPageViewConverter"); } } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } }
-
完成MainWindow
xml
<Window ...>
<Window.DataContext>
<local:MainViewModel/>
</Window.DataContext>
<Window.Resources>
<share:PageViewConverter x:Key="pageConv"/>
</Window.Resources>
<ContentControl Content="{Binding CurrentPage,Converter={StaticResource pageConv}}"/>
</Window>
改进
-
可以结合依赖注入的方式来实现
-
导航方法可以封装为一个NavigationService服务
csharp
//封装服务
class NavigationService
{
public static NavigationService Instance { get; } = new NavigationService();
private MainViewModel mainVM;
public void Navigate(ApplicationPage page)
{
if (mainVM == null)
{
mainVM = (MainViewModel)App.Current.MainWindow.DataContext;
}
mainVM.CurrentPage = page;
}
}
//原来的方式
void Logout()
{
var mainVM = App.Current.MainWindow.DataContext as MainViewModel;
mainVM!.CurrentPage = ApplicationPage.Login;
}
//使用封装好的服务
void Login()
{
NavigationService.Instance.Navigate(ApplicationPage.Login);
}
四、使用Frame和NavigationService
实现上一章节功能,本质上是使用依赖注入的方式将View和ViewModel进行绑定,并利用Frame的自带的Navigate方法进行导航
- 定义ViewModel
csharp
public class ViewModelBase : ObservableObject{}
public partial class MainWindowViewModel : ViewModelBase
{
private readonly NavigationService navigationService;
//依赖注入
public MainWindowViewModel(NavigationService navigationService)
{
this.navigationService = navigationService;
}
[RelayCommand]
void Loaded()
{ //navigationService实现的导航方法
navigationService.Navigate<LoginViewModel>();
}
}
public partial class HomeViewModel : ViewModelBase
{
[ObservableProperty]
string? userName;
}
public partial class LoginViewModel : ViewModelBase
{
private readonly NavigationService navigationService;
//依赖注入
public string UserName { get; set; } = "Sergio";
public LoginViewModel(NavigationService navigationService)
{
this.navigationService = navigationService;
}
[RelayCommand]
void Login()
{ //navigationService实现的导航方法,此处进行了传参
navigationService.Navigate<HomeViewModel>(new Dictionary<string, object?>
{
[nameof(HomeViewModel.UserName)] = UserName
});
}
}
- 定义View
主窗口,使用Behaviors实现mvvm模式
xml
<Window
xmlns:b="http://schemas.microsoft.com/xaml/behaviors">
<b:Interaction.Triggers>
<b:EventTrigger>
<b:InvokeCommandAction Command="{Binding LoadedCommand}" />
</b:EventTrigger>
</b:Interaction.Triggers>
</Window>
主窗口后台类
csharp
public partial class MainWindow : Window
{
public MainWindow(MainWindowViewModel viewModel,Frame frame)
{
InitializeComponent();
DataContext = viewModel;
AddChild(frame);
}
}
其他View
xml
<!--使用Page来承载内容-->
<Page ...>
<Grid>
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
d:Text="Hello, world!"
Text="{Binding UserName, StringFormat='Hello, {0}!'}"
FontSize="32" />
</Grid>
</Page>
<Page ...>
<Grid>
<Border Padding="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
BorderThickness="1"
CornerRadius="10"
BorderBrush="LightGray">
<StackPanel Width="300">
<TextBlock HorizontalAlignment="Center" FontSize="28">Login</TextBlock>
<Separator Margin="0,10" />
<TextBlock>User name:</TextBlock>
<TextBox Margin="0,10" Text="{Binding UserName}" InputMethod.IsInputMethodEnabled="False" />
<TextBlock>Password:</TextBlock>
<PasswordBox Margin="0,10" Password="123456" />
<Button Content="Login" Command="{Binding LoginCommand}" />
</StackPanel>
</Border>
</Grid>
</Page>
在后台类中使用依赖注入的方式定义DataContext
csharp
public Home(HomeViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
public Login(LoginViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
- 实现NavigationService
csharp
public class NavigationService
{
//注册了单例的Frame
private readonly Frame? mainFrame;
public NavigationService(Frame? frame)
{
mainFrame = frame;
//要使用LoadCompleted事件
mainFrame.LoadCompleted += MainFrame_LoadCompleted;
}
private void MainFrame_LoadCompleted(object sender, System.Windows.Navigation.NavigationEventArgs e)
{
if (e.ExtraData is not Dictionary<string,object?> extraData)
{
return;
}
if ((mainFrame?.Content as FrameworkElement)?.DataContext is not ViewModelBase vm)
{
return;
}
foreach (var item in extraData)
{
//为每个属性赋值
vm.GetType().GetProperty(item.Key)?.SetValue(vm, item.Value);
}
}
//根据VM类型查找View,要注意VM和View的命名规范
private Type? FindView<T>()
{
return Assembly.GetAssembly(typeof(T))?.GetTypes().FirstOrDefault(x => x.Name == typeof(T).Name.Replace("ViewModel", ""));
}
public void Navigate<T>(Dictionary<string,object?>? extraData=null) where T:ViewModelBase
{
var viewType = FindView<T>();
if (viewType is null)
return;
var page = App.Current.Services.GetService(viewType) as Page;
//利用Frame的Navigate方法进行导航和传参
mainFrame?.Navigate(page,extraData);
}
}
- 注册需要的类,此案例在App.cs中进行注册
csharp
public partial class App : Application
{
public IServiceProvider Services { get; }
public static new App Current => (App)Application.Current;
public App()
{
var container = new ServiceCollection();
container.AddSingleton(_ => new Frame { NavigationUIVisibility = NavigationUIVisibility.Hidden });
container.AddSingleton<MainWindow>();
container.AddSingleton<MainWindowViewModel>();
container.AddTransient<Login>();
container.AddTransient<Home>();
container.AddTransient<LoginViewModel>();
container.AddTransient<HomeViewModel>();
container.AddSingleton<NavigationService>();
Services = container.BuildServiceProvider();
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
MainWindow = Services.GetRequiredService<MainWindow>();
MainWindow.Show();
}
}