💻

WinUI3もMVVMで実装する(画面遷移, DI)

に公開

はじめに

前回の記事では、CommunityToolkit.Mvvmを使用してWinUI3をMVVM実装した。
MAUIと比較して、画面遷移やDI部分が気になったので試してみる。

環境

Windows 11
Visual Studio 2022
.NET 8.0
Microsoft.WindowsAppSDK 1.7.250606001
CommunityToolkit.Mvvm 8.4.0
Microsoft.Extensions.Hosting 9.0.8 → DI用に今回導入
Microsoft.Xaml.Behaviors.WinUI 3.0.0 → イベントにコマンドをバインドするため今回導入

FrameとNavigationViewで画面遷移

今回はNavigationViewのサイドメニューで選択されたページへの遷移を実装する。

  • Frame ... ページを動的に切り替えるためのコンテナ
  • NavigationView ... サイドメニュー

https://learn.microsoft.com/ja-jp/windows/apps/design/controls/navigationview

ページ作成

  1. Pagesフォルダを作成
  2. 新規ページを作成
MainPage.xaml
<?xml version="1.0" encoding="utf-8"?>
<Page
    x:Class="WinUiApp.Pages.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinUiApp.Pages"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">

    <Grid HorizontalAlignment="Center">
        <TextBlock Text="メインページ" FontSize="20" />
    </Grid>
</Page>

もう一つ新規ページを作成し、前回実装したMainWindowのxamlとViewModelを移動。

SecondPage.xaml
<?xml version="1.0" encoding="utf-8"?>
<Page
    x:Class="WinUiApp.Pages.SecondPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinUiApp.Pages"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">

    <Grid>
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
            <TextBox x:Name="NameInput" Width="200" PlaceholderText="名前を入力" />
            <Button Content="{x:Bind ViewModel.ButtonText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                    Command="{x:Bind ViewModel.ButtonClickedCommand}"
                    CommandParameter="{x:Bind NameInput.Text, Mode=OneWay}" />
        </StackPanel>
    </Grid>
</Page>
SecondPage.xaml.cs
using Microsoft.UI.Xaml.Controls;

namespace WinUiApp.Pages
{
    public sealed partial class SecondPage : Page
    {
        public MainWindowViewModel ViewModel { get; } = new();
        public SecondPage()
        {
            this.InitializeComponent();
        }
    }
}
SecondPageViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace WinUiApp.ViewModels
{
    public partial class SecondPageViewModel : ObservableObject
    {
        [ObservableProperty]
        private string buttonText = "Click Me";

        [RelayCommand]
        private void OnButtonClicked(string name)
        {
            ButtonText = $"Clicked by {name}";
        }
    }
}

MainWindowにFrameとNavigationViewを追加

動作確認のため、一旦コードビハインドに実装する。

MainWindow.xaml
<?xml version="1.0" encoding="utf-8"?>
<Window
    x:Class="WinUiApp.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinUiApp"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="WinUiApp">

    <NavigationView SelectionChanged="NavigationView_SelectionChanged"
                    Loaded="NavigationView_Loaded">
        <NavigationView.MenuItems>
            <NavigationViewItem Content="メイン" Tag="MainPage" Icon="Home"/>
            <NavigationViewItem Content="セカンド" Tag="SecondPage" Icon="Setting"/>
        </NavigationView.MenuItems>
        <Frame x:Name="ContentFrame">
            <Frame.ContentTransitions>
                <TransitionCollection>
                    <NavigationThemeTransition />
                </TransitionCollection>
            </Frame.ContentTransitions>
        </Frame>
    </NavigationView>
</Window>
MainWindow.xaml.cs
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using WinUiApp.Pages;

namespace WinUiApp
{
    public sealed partial class MainWindow : Window
    {
        public MainWindowViewModel ViewModel { get; } = new();

        public MainWindow()
        {
            this.InitializeComponent();
        }

        /// <summary>
        /// NavigationViewの選択項目が変更されたときに呼び出されるイベントハンドラー
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="args"></param>
        private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
        {
            if (args.SelectedItemContainer is NavigationViewItem selectedItem)
            {
                string? pageTag = selectedItem.Tag?.ToString();

                Type? pageType = pageTag switch
                {
                    "MainPage" => typeof(MainPage),
                    "SecondPage" => typeof(SecondPage),
                    _ => null
                };

                if (pageType != null && ContentFrame.CurrentSourcePageType != pageType)
                {
                    ContentFrame.Navigate(pageType);
                }
            }
        }

        /// <summary>
        /// 初期化時にMainPageを表示するためのイベントハンドラー
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void NavigationView_Loaded(object sender, RoutedEventArgs e)
        {
            ContentFrame.Navigate(typeof(MainPage));
        }
    }
}

以下を確認。

  • Frameによる画面遷移
  • NavigationViewの動作
  • 前回作成した処理が正しく動くこと

画面遷移をMVVMパターン化

  • 画面遷移をサービス化
  • サービスをDI
  • ViewModelでサービスを使用して画面遷移
INavigationService.cs
public interface INavigationService
{
    void NavigateTo(object? selectedPage);
    void NavigateToMain();
}

以下2つのメソッドを実装。

  • NavigationViewItemに設定されたTagをもとにPage遷移
  • MainPageへ遷移
NavigationService.cs
using Microsoft.UI.Xaml.Controls;
using System;
using System.Collections.Generic;
using WinUiApp.Pages;

namespace WinUiApp.Services
{
    public class NavigationService : INavigationService
    {
        private Frame? _frame;

        /// <summary>
        /// タグとページの型をマッピングする辞書。
        /// </summary>
        private readonly Dictionary<string, Type> _pageMap = new()
        {
            { "MainPage", typeof(Pages.MainPage) },
            { "SecondPage", typeof(Pages.SecondPage) }
        };

        public void Initialize(Frame frame)
        {
            _frame = frame;
        }

        public void NavigateTo(object? selectedPage)
        {
            string? tag = null; 
            if (selectedPage is NavigationViewItem navItem)
            {
                tag = navItem.Tag as string;
            }
            else if (selectedPage is string str)
            {
                tag = str;
            }

            if (tag != null && _pageMap.TryGetValue(tag, out var pageType) && _frame?.CurrentSourcePageType != pageType)
            {
                _frame?.Navigate(pageType);
            }
        }

        public void NavigateToMain()
        {
            _frame?.Navigate(typeof(MainPage));
        }
    }
}

DI設定

MainWindowFrameをアプリケーション起動時にサービス登録するため、公開する。

MainWindow.xaml.cs
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace WinUiApp
{
    public sealed partial class MainWindow : Window
    {
        public MainWindowViewModel ViewModel { get; private set; }

        // NavigationServiceに設定するため公開
        public Frame ContentFrame => this.contentFrame;

        public MainWindow(MainWindowViewModel vm) : base()
        {
            this.InitializeComponent();
            this.ViewModel = vm;
        }
    }
}

IHostApplicatonBuilderでホストを作成し、NavigationServiceのDI設定

https://learn.microsoft.com/ja-jp/dotnet/core/extensions/generic-host?tabs=appbuilder

App.xaml.cs
using Microsoft.UI.Xaml;
using Microsoft.Extensions.Hosting;
using WinUiApp.Services;
using Microsoft.Extensions.DependencyInjection;

namespace WinUiApp
{
    public partial class App : Application
    {
        public App()
        {
            this.InitializeComponent();
        }

        protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
        {
            var builder = Host.CreateApplicationBuilder();

            //  NavigationService を登録
            builder.Services.AddSingleton<INavigationService, NavigationService>();
            builder.Services.AddSingleton<MainWindowViewModel>();
            builder.Services.AddSingleton<MainWindow>();

            var host = builder.Build();
            m_window = host.Services.GetService<MainWindow>();

            // NavigationService を取得
            var navigationService = host.Services.GetService<INavigationService>();

            // Frame を m_window から取得して NavigationService に設定
            if (navigationService is NavigationService navService && m_window is MainWindow main)
            {
                navService.Initialize(main.ContentFrame);
            }

            m_window?.Activate();
        }

        private Window? m_window;
    }
}

MainWindowのMVVM化

View

  • 初期表示(Loaded)時の画面遷移コマンドをバインドするために、Microsoft.Xaml.Behaviors.WinUIを使用する。(NuGetでインストール)
  • NavigationView.SelectgedItemをViewModelのプロパティにバインド
MainWindow.xaml
<?xml version="1.0" encoding="utf-8"?>
<Window
    x:Class="WinUiApp.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinUiApp"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    xmlns:behaviors="using:Microsoft.Xaml.Interactivity"
    Title="WinUiApp">

    <NavigationView x:Name="NavView" SelectedItem="{x:Bind ViewModel.SelectedPage, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
        <!--初期表示時の画面遷移-->
        <behaviors:Interaction.Behaviors>
            <behaviors:EventTriggerBehavior EventName="Loaded">
                <behaviors:InvokeCommandAction 
                    Command="{x:Bind ViewModel.NavigationLoadedCommand}" />
            </behaviors:EventTriggerBehavior>
        </behaviors:Interaction.Behaviors>

        <NavigationView.MenuItems>
            <NavigationViewItem Content="メイン" Tag="MainPage" Icon="Home"/>
            <NavigationViewItem Content="セカンド" Tag="SecondPage" Icon="Setting"/>
        </NavigationView.MenuItems>
        <Frame x:Name="contentFrame">
            <Frame.ContentTransitions>
                <TransitionCollection>
                    <NavigationThemeTransition />
                </TransitionCollection>
            </Frame.ContentTransitions>
        </Frame>
    </NavigationView>
</Window>

ViewModel

  • DIによってNavigationServiceが注入される
  • NavigationView.SelectgedItemをViewModelのプロパティにバインドし、プロパティ変更時の処理でNavigationServiceによる画面遷移を実施する。
MainWindowViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WinUiApp.Services;

namespace WinUiApp
{
    public partial class MainWindowViewModel(INavigationService navigation) : ObservableObject
    {
        private readonly INavigationService _navigationService = navigation;

        [ObservableProperty]
        private object selectedPage;

        // SelectedPage変更時の処理を実装
        partial void OnSelectedPageChanged(object value)
        {
            this._navigationService.NavigateTo(value);
        }

        [RelayCommand]
        private void OnNavigationLoaded()
        {
            this._navigationService.NavigateToMain();
        }
    }
}

プロパティ変更時の処理について

以下のように、ObservablePropertyを付与した場合はコードが自動生成される。
今回は変更後の値が設定された後に実行される、OnSelectedPageChanged()で画面遷移を行うように実装した。

自動生成されるコード例
public object? SelectedPage
{
    get => selectedPage;
    set
    {
        if (!EqualityComparer<object?>.Default.Equals(selectedPage, value))
        {
            object? oldValue = selectedPage;
            OnSelectedPageChanging(value);
            OnSelectedPageChanging(oldValue, value);
            OnPropertyChanging();
            selectedPage = value;
            // ↓このタイミングで画面遷移
            OnSelectedPageChanged(value);
            OnSelectedPageChanged(oldValue, value);
            OnPropertyChanged();
        }
    }
}

partial void OnSelectedPageChanging(object? value);
partial void OnSelectedPageChanged(object? value);

partial void OnSelectedPageChanging(object? oldValue, object? newValue);
partial void OnSelectedPageChanged(object? oldValue, object? newValue);

https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/mvvm/generators/observableproperty

SelectionChangedではうまくいかない

以下のようにNavigationViewSelectionChangedイベントでは正しく画面遷移しない。
イベントが以下の順で発生するため、直前に選択していた画面に遷移することになる。

  1. SelectionChangedイベント
  2. バインドされたプロパティの更新
MainWindow.xaml
...
<NavigationView x:Name="NavView">
    <!--選択項目変更時のイベント-->
    <behaviors:EventTriggerBehavior EventName="SelectionChanged">
        <behaviors:InvokeCommandAction 
            Command="{x:Bind ViewModel.NavigationSelectionChangedCommand}"
            CommandParameter="{x:Bind NavView.SelectedItem, Mode=OneWay}" />
    </behaviors:EventTriggerBehavior>
...
MainWindowViewModel.cs
[RelayCommand]
private void OnNavigationSelectionChanged(object? selectedItem)
{
    this._navigationService.NavigateTo(selectedItem);
}

完成

Discussion