しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Windows App SDK を使ってモダンなタイトルバーを持つアプリケーションを作る

Windows 11 で全面的に導入された Fluent Design System は個人的には結構好みなので、自作アプリでも同じようなデザインを実現したいのですが意外に難しいです。コントロールだけに限れば Windows App SDK を使うとある程度は対応は可能ですが、まだ使いやすく提供されていない機能もあります。

例えば以下は Microsoft Store アプリですが、タイトルバーからして大きく異なっています。全体的に Mica が適用されていて、完全にカスタマイズされたタイトルバーが実装されています。

Windows 11 におけるタイトルバーのデザインについては、以下のドキュメントに典型的なパターンが紹介されているので、こちらを参照するとイメージしやすいと思います。

このように紹介されているということは、Windows 11 向けのアプリではタイトルバー含めデザインを統一した方が良いということでしょう。少なくとも私はそう読み取りました。

最新の Windows App SDK を利用すると、標準でこのようなモダンなタイトルバーが実現出来ると期待しそうですが、実際には以下のような古い Windows アプリと同じタイトルバーにしかなりません。

ペイントやメモ帳ですら実装されているモダンなタイトルバーが、Windows App SDK で標準提供されていないのは疑問だったのですが、実際には Windows 側で組み込まれているものではなく、アプリが個別に実装する必要があるものでした。なのでアプリ毎にアイコンとタイトルのレイアウトが微妙に異なっています。

Windows App SDK のサンプルでは簡単に調べた限りではタイトルバーのカスタマイズ周りがイマイチだったので、基本的なカスタマイズを含むサンプルアプリを作成して確認しました。

ソリューションを含んだ完全なサンプルコード一式は以下のリポジトリで公開していますので、実際に動かして試したい場合にはこちらを利用してください。ちなみに簡略化のためと、Windows App SDK で現在の DPI を取るには P/Invoke を使って自前計算する必要があるので、サンプルコードには高 DPI 向け対応は入れていません。

ここから先は実際にタイトルバーの完全なカスタマイズを行ったので、簡単に説明をしていきます。試したのは基本的なタイトルバー、ユーザーアイコン付きのタイトルバー、タブ付きのタイトルバーの 3 つです。

基本的なタイトルバーを作る

まずは基本的な Windows 11 のタイトルバーを作ってみます。具体的には電卓やペイントのようなシンプルなタイトルバーです。正直このくらいであれば、完全なカスタマイズをする必要はないかも知れません。

タイトルバーのカスタマイズ方法は以下のドキュメントで紹介されているので、実はこの通りに実装すれば問題なく対応できます。しかし思ったよりも必要なコードが多いので、ある程度まとまったサンプルコードがあった方が良い理由がここにあります。

タイトルバーのフルカスタマイズで重要になるのは TitleBar にある ExtendsContentIntoTitleBar プロパティの設定と SetDragRectangles メソッドを適切に呼び出すことです。前者の設定によりタイトルバー部分までカスタマイズ可能となり、後者の設定でタイトルバーとして動作する領域を定義します。

この時 Window クラスにも同名の ExtendsContentIntoTitleBar プロパティがありますが、同じ名前なのにこちらは挙動が異なるので間違えないようにします。

必要な XAML と C# の実装は以下の通りです。ExtendsContentIntoTitleBar を有効にすると Grid がタイトルバー部分まで広がるので、最初の行を 32px の高さにしてレイアウトを組みます。

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

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>
        <Grid x:Name="AppTitleBar" Height="32">
            <Grid.ColumnDefinitions>
                <ColumnDefinition x:Name="LeftPaddingColumn" Width="0" />
                <ColumnDefinition x:Name="IconColumn" Width="Auto" />
                <ColumnDefinition x:Name="TitleColumn" Width="Auto" />
                <ColumnDefinition x:Name="DragColumn" Width="*" />
                <ColumnDefinition x:Name="RightPaddingColumn" Width="0" />
            </Grid.ColumnDefinitions>
            <Image x:Name="TitleBarIcon"
                   Source="ms-appx:///WindowIcon.png"
                   Grid.Column="1"
                   VerticalAlignment="Center"
                   Width="16"
                   Height="16"
                   Margin="18,0,0,0" />
            <TextBlock x:Name="TitleTextBlock"
                       Text="App title"
                       Style="{StaticResource CaptionTextBlockStyle}"
                       Grid.Column="2"
                       VerticalAlignment="Center"
                       Margin="14,0,0,0" />
        </Grid>

        <WebView2 Grid.Row="1" Source="https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/" />
    </Grid>

</Window>

アイコンやタイトルのテキストも全て自前で定義する必要がありますが、Grid に入ってサイズ内のコントロールなら何でも追加できるので自由度が非常に高いです。

閉じるボタンなどはそのままレンダリングされるので、その部分は SetDragRectangles を使ってタイトルバーの領域としては除外する必要があります。

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        if (MicaController.IsSupported())
        {
            SystemBackdrop = new MicaBackdrop();
        }
        else if (DesktopAcrylicController.IsSupported())
        {
            SystemBackdrop = new DesktopAcrylicBackdrop();
        }

        if (AppWindowTitleBar.IsCustomizationSupported())
        {
            AppWindow.TitleBar.ExtendsContentIntoTitleBar = true;
            AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
            AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;

            AppTitleBar.Loaded += (_, _) => UpdateDragRectangles();
            AppTitleBar.SizeChanged += (_, _) => UpdateDragRectangles();
        }
    }

    private void UpdateDragRectangles()
    {
        LeftPaddingColumn.Width = new GridLength(AppWindow.TitleBar.LeftInset);
        RightPaddingColumn.Width = new GridLength(AppWindow.TitleBar.RightInset);

        var dragRect = new RectInt32
        {
            X = (int)LeftPaddingColumn.ActualWidth,
            Y = 0,
            Height = (int)AppTitleBar.ActualHeight,
            Width = (int)(AppTitleBar.ActualWidth - LeftPaddingColumn.ActualWidth - RightPaddingColumn.ActualWidth)
        };

        AppWindow.TitleBar.SetDragRectangles(new[] { dragRect });
    }
}

タイトルバーの Grid のサイズが変化する度に SetDragRectangles を呼び出すことで常にタイトルバーの領域を正しく設定し続けます。ついでに Mica / Acrylic の設定も行って Windows 11 っぽさを出しています。

Mica / Acrylic の設定は Windows App SDK 1.3 から非常に簡単になったので、詳細は以下のエントリを参照してください。これは Window クラスで自動的にやってくれていても良いと思います。

このサンプルコードを実行すると、標準のタイトルバーではなく完全にカスタマイズしたタイトルバーが表示されます。当然ながらドラッグでの位置変更や右クリックメニューもそのまま動作します。

これは比較的簡単だったと思いますが、完全にカスタマイズする利点としてタイトルバー部分に独自の UI 要素を簡単に追加できることにあります。

ユーザーアイコン付きのタイトルバーを作る

Microsoft Store アプリではタイトルバーが通常のアプリよりも高くなっていて、検索ボックスやユーザーの情報にアクセスするためのアイコンが表示されています。

タイトルバーが高くなっていて、検索ボックスやユーザーアイコンが表示されていると Windows 11 のアプリっぽさが増しますね。今回はユーザーアイコンを追加してみます。

ちなみに Windows App SDK というか WinUI 3 では PersonPicture というコントロールが用意されているので、これを使うと簡単にユーザーアイコンやイニシャルの表示ができます。

Windows 11 のデザインドキュメントにはタイトルバーに UI 要素を追加する際には、タイトルバーの高さを標準の 32px から 48px に拡大するように書かれています。今回はそれに従ってカスタマイズを行ってみます。

ユーザーアイコンは一般的にタイトルバーの右側に表示されるので、上手く Grid のカラムを定義して表現します。ユーザーアイコン自体はドラッグ不可能にしつつ、その左右はドラッグ可能な領域にするため、サイズ計算用のカラムを追加して対応しています。

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

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>
        <Grid x:Name="AppTitleBar" Height="48">
            <Grid.ColumnDefinitions>
                <ColumnDefinition x:Name="LeftPaddingColumn" Width="0" />
                <ColumnDefinition x:Name="IconColumn" Width="Auto" />
                <ColumnDefinition x:Name="TitleColumn" Width="Auto" />
                <ColumnDefinition x:Name="LeftDragColumn" Width="*" />
                <ColumnDefinition x:Name="ProfileIconColumn" Width="Auto" />
                <ColumnDefinition x:Name="RightDragColumn" Width="55" />
                <ColumnDefinition x:Name="RightPaddingColumn" Width="0" />
            </Grid.ColumnDefinitions>
            <Image x:Name="TitleBarIcon"
                   Source="ms-appx:///WindowIcon.png"
                   Grid.Column="1"
                   VerticalAlignment="Center"
                   Width="16"
                   Height="16"
                   Margin="20,0,0,0" />
            <TextBlock x:Name="TitleTextBlock"
                       Text="App title"
                       Style="{StaticResource CaptionTextBlockStyle}"
                       Grid.Column="2"
                       VerticalAlignment="Center"
                       Margin="20,0,0,0" />
            <Button Grid.Column="4"
                    Padding="0"
                    Background="Transparent"
                    BorderThickness="0"
                    VerticalAlignment="Center"
                    Click="PersonButton_Click">
                <PersonPicture Initials="TS"
                               Width="28"
                               Height="28" />
                <FlyoutBase.AttachedFlyout>
                    <MenuFlyout>
                        <MenuFlyoutItem Text="Item 1" />
                        <MenuFlyoutItem Text="Item 2" />
                        <MenuFlyoutSeparator />
                        <MenuFlyoutItem Text="Item 3" />
                    </MenuFlyout>
                </FlyoutBase.AttachedFlyout>
            </Button>
        </Grid>

        <WebView2 Grid.Row="1" Source="https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/" />
    </Grid>

</Window>

折角なので PersonPicture コントロールに MenuFlyout を追加して、クリックされた時にコンテキストメニューを表示するようにしています。このようなケースでは MenuFlyoutAttachedFlyout として定義すると、任意のタイミングで表示可能なメニューを実現できます。

C# 側ではシンプルなケースほぼ同様に、カスタマイズに必要な設定を行ってリサイズのタイミングでタイトルバーの領域を設定しているだけです。今回はタイトルバーを通常より高くするために PreferredHeightOption プロパティに TitleBarHeightOption.Tall を指定しています。これで高いタイトルバーに合わせたレンダリングが行われます。

public sealed partial class MainWindow
{
    public MainWindow()
    {
        InitializeComponent();

        if (MicaController.IsSupported())
        {
            SystemBackdrop = new MicaBackdrop();
        }
        else if (DesktopAcrylicController.IsSupported())
        {
            SystemBackdrop = new DesktopAcrylicBackdrop();
        }

        if (AppWindowTitleBar.IsCustomizationSupported())
        {
            AppWindow.TitleBar.ExtendsContentIntoTitleBar = true;
            AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
            AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
            AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;

            AppTitleBar.Loaded += (_, _) => UpdateDragRectangles();
            AppTitleBar.SizeChanged += (_, _) => UpdateDragRectangles();
        }
    }

    private void UpdateDragRectangles()
    {
        LeftPaddingColumn.Width = new GridLength(AppWindow.TitleBar.LeftInset);
        RightPaddingColumn.Width = new GridLength(AppWindow.TitleBar.RightInset);

        var leftDragRect = new RectInt32
        {
            X = (int)LeftPaddingColumn.ActualWidth,
            Y = 0,
            Width = (int)(IconColumn.ActualWidth + TitleColumn.ActualWidth + LeftDragColumn.ActualWidth),
            Height = (int)AppTitleBar.ActualHeight
        };

        var rightDragRect = new RectInt32
        {
            X = (int)(AppTitleBar.ActualWidth - RightDragColumn.ActualWidth - RightPaddingColumn.ActualWidth),
            Y = 0,
            Width = (int)RightDragColumn.ActualWidth,
            Height = (int)AppTitleBar.ActualHeight
        };

        AppWindow.TitleBar.SetDragRectangles(new[] { leftDragRect, rightDragRect });
    }

    private void PersonButton_Click(object sender, RoutedEventArgs e)
    {
        FlyoutBase.ShowAttachedFlyout((FrameworkElement)sender);
    }
}

ドラッグ用の領域は PersonPicture より左と右に分けて計算して指定しています。もしタイトルバーに検索ボックスなどを追加する場合には、更に検索ボックスの左右に分けて領域を計算するようにします。

実行してみるとタイトルバーが高くなり、右側にユーザーアイコンが表示されるようになります。同時に閉じるボタンの大きさも変わっていることが分かりますね。

ユーザーアイコンをクリックするとメニューが表示されるので、ドラッグ領域の設定が上手くいっていることが分かります。失敗していた場合はマウスイベントに全く反応しなくなるので分かりやすいです。

タブ付きのタイトルバーを作る

最後はタイトルバー部分にタブを表示しているアプリを作ってみます。最近だと Terminal や Explorer と同じ UI を実現する方法と考えればわかりやすいと思います。

公式ドキュメントでもタイトルバーにタブを表示する UI については言及とサンプルコードが公開されていますが、Windows App SDK 向けの書き方ではないのでそのままでは使えません。とはいえ考え方は同じなので、このコードをベースに Windows App SDK 向けに新しく書き起こしました。

今回はタブがそのままタイトルバーになるので、これまでのように Grid を使ってレイアウトを作ることなく前面に TabView を置いて対応します。これだけでも見た目は意図した通りになりますが、タブが増えてくるとドラッグ領域がなくなってしまうので TabStripFooter を使ってドラッグ領域を確保します。

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

    <TabView x:Name="AppTabView" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
        <TabViewItem Header="Home" IsClosable="False">
            <TabViewItem.IconSource>
                <SymbolIconSource Symbol="Home" />
            </TabViewItem.IconSource>

            <WebView2 Source="https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/" />
        </TabViewItem>
        <TabViewItem Header="Document 0">
            <TabViewItem.IconSource>
                <SymbolIconSource Symbol="Document" />
            </TabViewItem.IconSource>
        </TabViewItem>
        <TabViewItem Header="Document 1">
            <TabViewItem.IconSource>
                <SymbolIconSource Symbol="Document" />
            </TabViewItem.IconSource>
        </TabViewItem>
        <TabViewItem Header="Document 2">
            <TabViewItem.IconSource>
                <SymbolIconSource Symbol="Document" />
            </TabViewItem.IconSource>
        </TabViewItem>

        <TabView.TabStripHeader>
            <Grid x:Name="LeftPaddingInset" Background="Transparent" />
        </TabView.TabStripHeader>
        <TabView.TabStripFooter>
            <Grid x:Name="RightPaddingInset" Background="Transparent" />
        </TabView.TabStripFooter>
    </TabView>

</Window>

TabStripHeaderTabStripFooter の両方を定義しているのは RTL 対応ですが、RTL の環境がないので正しく動作するのかは確認できていません。もしかすると Windows App SDK では TabStripFooter だけで良い可能性もありますが、こちらも同様に未確認です。

C# 側も XAML で定義する要素が減ったのでシンプルになりました。少しわかりにくいのがドラッグ領域の再設定をどのイベントで行うか、という点だけです。現状最適なのは TabView のサイズ変更と TabStripFooter に指定した Grid のサイズ変更の組み合わせでした。

public sealed partial class MainWindow
{
    public MainWindow()
    {
        InitializeComponent();

        if (MicaController.IsSupported())
        {
            SystemBackdrop = new MicaBackdrop();
        }
        else if (DesktopAcrylicController.IsSupported())
        {
            SystemBackdrop = new DesktopAcrylicBackdrop();
        }

        if (AppWindowTitleBar.IsCustomizationSupported())
        {
            AppWindow.TitleBar.ExtendsContentIntoTitleBar = true;
            AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
            AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;

            AppTabView.SizeChanged += (_, _) => UpdateDragRectangles();
            RightPaddingInset.SizeChanged += (_, _) => UpdateDragRectangles();
        }
    }

    private const int DragRegionMinWidth = 50;

    private void UpdateDragRectangles()
    {
        LeftPaddingInset.MinWidth = AppWindow.TitleBar.LeftInset;
        RightPaddingInset.MinWidth = AppWindow.TitleBar.RightInset + DragRegionMinWidth;

        var dragRect = new RectInt32
        {
            X = (int)(AppTabView.ActualWidth - RightPaddingInset.ActualWidth),
            Y = 0,
            Height = (int)AppTabView.ActualHeight,
            Width = (int)(RightPaddingInset.ActualWidth - AppWindow.TitleBar.RightInset)
        };

        AppWindow.TitleBar.SetDragRectangles(new[] { dragRect });
    }
}

これまでとは大きく異なるのがドラッグ領域の計算です。タイトルバーにタブが表示されていますが、そのタブ自体はマウスイベントで操作できる必要があるので、ドラッグ可能にするのは TabStripFooter の領域だけにする必要があります。この計算を間違えるとタブの切り替えが全く動かなくなります。

実行してみるとドラッグ用の領域は確保されつつも、タブが表示されていることが分かります。クリックでのタブ切り替えはもちろん、ドラッグでの並び替えも問題なく動作するはずです。

Windows App SDK でのタイトルバーの完全カスタマイズは、この 3 つのパターンを理解しておくと後は何とかなると思います。やはり一番の驚きは Windows 11 のデザインで定義されているのに、Windows App SDK などで組み込みでの対応がされていないことですね。