.NET MAUI WindowsでCollectionViewのホバー時に手カーソルを表示する方法(Flyoutメニュー限定)

.NET MAUI の Windows デスクトップアプリで Flyout メニューを CollectionView で実装すると、項目がクリックできるのにカーソルが矢印のままで「押せるのか分からない」状態になりがちです。この記事では、特定の CollectionView(メニュー)だけに限定して、ホバー時に手カーソル(Hand)へ切り替える実装を、Windows 固有コード+添付プロパティでまとめます。

目次

やりたいことと前提(CollectionView のメニュー項目だけ手カーソルにしたい)

要件は次の通りです。

  • .NET MAUI の Windows デスクトップ(WinUI)で、Flyout メニューとして使っている CollectionView の各項目に、ホバーしたときだけ 手カーソル(Hand) を表示したい
  • 対象はメニュー用 CollectionView のみ。アプリ内の 他の CollectionView には影響させない
  • ItemTemplate の中で Grid を使っている(見た目は Image+Label の典型的なメニュー)

メニューの XAML は、例えば次のような構造です(質問で提示されていた形)。

<CollectionView.ItemTemplate>
  <DataTemplate x:DataType="vm:FlyoutPageItem">
    <Grid Padding="5,10">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="30"/>
        <ColumnDefinition Width="*"/>
      </Grid.ColumnDefinitions>


  <Image Source="{Binding IconSource}" />

  <Label Grid.Column="1"
         Margin="20,0"
         Text="{Binding Title}"
         FontSize="20"
         FontAttributes="Bold"
         VerticalOptions="Center" />
</Grid>



これを「Behavior(添付ビヘイビア)」でやろうとすると、次のようなところで詰まりやすいです。

  • DataTemplate 内の Grid に付けても、意図通り反映されない(または反応が不安定)
  • SetCustomCursor が見つからずコンパイルエラーになる
  • ...Platforms.Windows の名前空間が見つからないと言われる

なぜ標準の .NET MAUI だけだと「カーソル変更」が難しいのか

ポイントは、カーソルは OS(プラットフォーム)依存だという点です。MAUI の VisualElement はクロスプラットフォームで動く一方、Windows でカーソルを変えるには WinUI の API(UIElement の Pointer イベントや InputCursor など)に触れる必要があります。

つまり、やるべきことはシンプルで、次の 2 つに分解できます。

  • MAUI の要素(VisualElement)を Windows の要素(UIElement)に変換する
  • PointerEntered / PointerExited で、Windows のカーソルを手/矢印に切り替える

さらに「メニューの CollectionView だけ」に限定するには、その ItemTemplate の Grid にだけカーソル設定を付けられる仕組みが必要です。そこで、XAML で一行で付けられる 添付プロパティ(添付ビヘイビア) が相性抜群です。

全体像(この構成にすると “影響範囲を限定” できる)

実装のゴールは次の構成です。

部品置き場所役割Windows 以外への影響
CursorExtensions(拡張メソッド)Platforms/WindowsWindows の PointerEntered/Exited でカーソルを切り替えるWindows ビルドにのみ含まれる(プラットフォームフォルダ)
CursorBehavior(添付プロパティ)共通コード(例:Behaviors フォルダ)XAML から behavior:CursorBehavior.Cursor="Hand" を付けられるようにする呼び出し部分だけを #if WINDOWS でガードすれば他プラットフォームでもビルド可
設定対象(Grid)メニュー用 CollectionView の ItemTemplateここにだけ添付プロパティを付ける他の CollectionView に付けなければ影響ゼロ

実装手順

CursorIcon(カーソル種別)を用意する

まず、XAML から指定しやすいように enum を作ります。最小構成なら Arrow と Hand だけで十分です(必要なら増やせます)。

namespace GssdDesktopClient.Maui.Behaviors
{
    // XAML から "Hand" のように指定できるようにする
    public enum CursorIcon
    {
        Arrow,
        Hand
    }
}

既に別の場所に enum を置きたい場合は、Behaviors でも Helpers でも構いません。大事なのは、XAML から見える(public)ことです。

Platforms/Windows に CursorExtensions.cs を作成する(Windows の Pointer イベントで切り替える)

Windows 固有 API を使うため、このファイルは プロジェクトの Platforms/Windows 配下に置きます。ここで VisualElement を ToPlatform で WinUI の UIElement に変換し、PointerEntered/Exited でカーソルを切り替えます。

まずは「質問のコードに近い、分かりやすい版」です。

using System;
using System.Reflection;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Platform;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.UI.Core;
using GssdDesktopClient.Maui.Behaviors;

namespace GssdDesktopClient.Maui.Platforms.Windows
{
    public static class CursorExtensions
    {
        // WinUI の UIElement.ProtectedCursor は直接触れないことがあるため、反射で設定する
        private static readonly PropertyInfo? ProtectedCursorProperty =
            typeof(UIElement).GetProperty(
                "ProtectedCursor",
                BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);

        public static void SetCustomCursor(this VisualElement visualElement, CursorIcon cursor, IMauiContext? mauiContext)
        {
            if (visualElement == null) throw new ArgumentNullException(nameof(visualElement));
            if (mauiContext == null) throw new ArgumentNullException(nameof(mauiContext));

            UIElement view = visualElement.ToPlatform(mauiContext);

            // 既にイベントを付けている場合に重複登録しないよう、Tag を使って簡易ガード(必要に応じて改善可)
            if (view is FrameworkElement fe && fe.Tag is string tag && tag == "CursorHooked")
            {
                // 既にフック済みなら、何もしない(カーソル種類を変えたい場合は実装を拡張)
                return;
            }
            else if (view is FrameworkElement fe2)
            {
                fe2.Tag = "CursorHooked";
            }

            view.PointerEntered += ViewOnPointerEntered;
            view.PointerExited  += ViewOnPointerExited;

            void ViewOnPointerExited(object sender, PointerRoutedEventArgs e)
            {
                view.ChangeCursor(InputCursor.CreateFromCoreCursor(
                    new CoreCursor(GetCursor(CursorIcon.Arrow), 1)));
            }

            void ViewOnPointerEntered(object sender, PointerRoutedEventArgs e)
            {
                view.ChangeCursor(InputCursor.CreateFromCoreCursor(
                    new CoreCursor(GetCursor(cursor), 1)));
            }
        }

        private static void ChangeCursor(this UIElement element, InputCursor cursor)
        {
            // ProtectedCursor が取れない環境では何もしない
            if (ProtectedCursorProperty == null) return;
            ProtectedCursorProperty.SetValue(element, cursor);
        }

        private static CoreCursorType GetCursor(CursorIcon cursor) => cursor switch
        {
            CursorIcon.Hand  => CoreCursorType.Hand,
            _                => CoreCursorType.Arrow
        };
    }
}

ポイント

  • Platforms/Windows に置くことで、Windows 以外のターゲットにこのファイルが入らず、ビルドが安全になります。
  • PointerEntered/Exited で、Hand と Arrow を切り替えています。
  • UIElement.ProtectedCursor は状況によって直接触れないことがあるため、サンプルでは反射で設定しています(このあたりは Windows App SDK のバージョン差やアクセス制御で詰まりやすい部分です)。

補足(Tag ガードについて)
上のコードでは、分かりやすさを優先して「二重登録防止」を FrameworkElement.Tag で簡易的に行っています。Tag は他用途でも使われ得るため、プロジェクトの方針に合わせて次のような “より安全な版” に差し替えると運用で安心です。

  • ConditionalWeakTable<UIElement, CursorState> で状態を管理する
  • または Behavior<VisualElement> 派生の本物の Behavior を作って、OnDetachingFrom でイベント解除する

CursorBehavior(添付プロパティ)を作成する

次に、XAML から behavior:CursorBehavior.Cursor="Hand" と書けるように、添付プロパティを定義します。ここが「メニュー用 CollectionView だけに限定する」ための肝です。

重要なのは次の 2 点です。

  • Windows 固有の拡張メソッド SetCustomCursor を呼ぶ部分は #if WINDOWS で囲む
  • VisualElement の Handler(MauiContext)生成タイミングによっては null のことがあるため、必要なら HandlerChanged を使って後から適用する
using System;
using Microsoft.Maui.Controls;

#if WINDOWS
using GssdDesktopClient.Maui.Platforms.Windows;
#endif

namespace GssdDesktopClient.Maui.Behaviors
{
    public static class CursorBehavior
    {
        public static readonly BindableProperty CursorProperty =
            BindableProperty.CreateAttached(
                "Cursor",
                typeof(CursorIcon),
                typeof(CursorBehavior),
                CursorIcon.Arrow,
                propertyChanged: CursorChanged);

        private static void CursorChanged(BindableObject bindable, object oldValue, object newValue)
        {
            if (bindable is not VisualElement visualElement)
                return;

#if WINDOWS
            // 反映処理を関数化(Handler が無い場合に備える)
            void Apply()
            {
                var context =
                    visualElement.Handler?.MauiContext
                    ?? Application.Current?.MainPage?.Handler?.MauiContext;

                if (context != null)
                {
                    visualElement.SetCustomCursor((CursorIcon)newValue, context);
                }
            }

            if (visualElement.Handler == null)
            {
                EventHandler? handlerChanged = null;
                handlerChanged = (s, e) =>
                {
                    visualElement.HandlerChanged -= handlerChanged;
                    Apply();
                };
                visualElement.HandlerChanged += handlerChanged;
            }
            else
            {
                Apply();
            }
#endif
        }

        public static CursorIcon GetCursor(BindableObject view)
            => (CursorIcon)view.GetValue(CursorProperty);

        public static void SetCursor(BindableObject view, CursorIcon value)
            => view.SetValue(CursorProperty, value);
    }
}

Windows 専用アプリの場合
もしターゲットが Windows のみ(例:net9.0-windows)で、他プラットフォームに出す予定がないなら、CursorBehavior 自体を #if WINDOWS で囲む設計でも問題になりにくいです。

Android/iOS も同じ XAML を共有する場合(重要)
XAML から CursorBehavior を参照しているのに、クラス自体を #if WINDOWS で消してしまうと、他プラットフォームのビルドで XAML コンパイルが失敗することがあります。
その場合はこの記事のサンプルのように、クラスは常に存在させて、中身だけを #if WINDOWS でガードして「Windows 以外は何もしない」にするのが安全です。

XAML から CursorBehavior を参照できるようにする(xmlns の設定)

ページ(ContentPage / FlyoutPage / Shell など)のルート要素に、Behavior の CLR 名前空間を追加します。

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:behavior="clr-namespace:GssdDesktopClient.Maui.Behaviors"
             x:Class="GssdDesktopClient.Maui.MyRootPage">

チェックポイント

  • clr-namespace: の値が、CursorBehavior を定義した namespace と一致しているか
  • CursorBehavior が public(ここでは public static)になっているか
  • CursorIcon enum も public になっているか(XAML で文字列から enum に変換されます)

CollectionView の ItemTemplate 内の Grid にだけ「手カーソル」を設定する

いよいよ適用です。メニュー用 CollectionView の ItemTemplate の Grid にだけ、添付プロパティを付けます。

ここで地味に効くのが BackgroundColor=”Transparent” です。Windows の UI では、背景が null のパネルはヒットテストされず「余白部分にマウスを置いたときイベントが取れない」ことがあります。
メニュー項目としては行全体をホバー判定したいことが多いので、透明背景を付けるのがおすすめです。

<CollectionView.ItemTemplate>
  <DataTemplate x:DataType="vm:FlyoutPageItem">
    <Grid Padding="5,10"
          BackgroundColor="Transparent"
          behavior:CursorBehavior.Cursor="Hand">

      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="30" />
        <ColumnDefinition Width="*" />
      </Grid.ColumnDefinitions>

      <Image Source="{Binding IconSource}" />
      <Label Grid.Column="1"
             Margin="20,0"
             Text="{Binding Title}"
             FontSize="20"
             FontAttributes="Bold"
             VerticalOptions="Center" />
    </Grid>
  </DataTemplate>
</CollectionView.ItemTemplate>

これで、次が実現できます。

  • マウスがこの Grid 上にある間だけカーソルが Hand になる
  • 他のページ・他の CollectionView には添付プロパティを付けなければ良いので、影響範囲が限定される

運用で楽にする:Style にして “メニューだけ” まとめて適用する

ItemTemplate が複数あったり、将来アイテムデザインを変更したりすると、毎回 Grid に添付プロパティを書くのが面倒になります。そういう場合は、メニュー専用ページの Resources に Style を置くと保守が楽です。

<ContentPage.Resources>
  <ResourceDictionary>
    <Style x:Key="FlyoutMenuItemGridStyle" TargetType="Grid">
      <Setter Property="BackgroundColor" Value="Transparent" />
      <Setter Property="behavior:CursorBehavior.Cursor" Value="Hand" />
      <Setter Property="Padding" Value="5,10" />
    </Style>
  </ResourceDictionary>
</ContentPage.Resources>

ItemTemplate 側はこうなります。

<Grid Style="{StaticResource FlyoutMenuItemGridStyle}">
  ...
</Grid>

「メニューにだけ適用」という方針は維持しつつ、変更箇所を 1 か所に集約できます。

よくあるエラーと対処(質問で出ていた問題の整理)

つまずきやすいポイントを表にまとめます。

症状原因として多いもの対処
SetCustomCursor がコンパイルエラー(見つからない)拡張メソッドの namespace を using していない/クラスが public static になっていない/ファイルが Windows 側に存在しないCursorExtensions が public static class になっているか確認 Platforms/Windows に置いているか確認 Behavior 側に #if WINDOWS 内で using ...Platforms.Windows; を追加
...Maui.Platforms.Windows 名前空間が見つからないファイルの namespace(宣言)がプロジェクトの RootNamespace とズレているCursorExtensions.cs の namespace が実際のプロジェクト構成と一致するか確認 プロジェクト名変更後に古い namespace が残っていないか確認 Behavior 側の using と CursorExtensions の namespace を揃える
XAML で behavior:CursorBehavior.Cursor が認識されないxmlns:behavior が違う/CursorBehavior が public でない/ビルドエラーで XAML 生成に失敗しているルート要素に xmlns:behavior="clr-namespace:...Behaviors" を追加 CursorBehavior が public(public static)か確認 まず C# 側のビルドエラーをゼロにしてから XAML のエラーを見直す
ホバーしても手カーソルにならない(反応が不安定)Grid のヒットテストが取れていない/余白に背景が無い/Handler 生成前に処理しているGrid に BackgroundColor="Transparent" を付けて余白でも反応するようにする CursorBehavior 側で HandlerChanged を使って、Handler 生成後に適用する
他プラットフォームのビルドで落ちる#if WINDOWS で CursorBehavior 自体を消し、XAML 参照が解決できないXAML から参照される型は “全ターゲットに存在” させ、Windows 固有呼び出しだけを #if WINDOWS でガードする どうしても Windows 専用にしたいなら、その XAML 自体を Windows 専用に分ける(ページを分離するなど)

動作確認のチェックリスト(「メニューだけ」狙い通りに効いているか)

  • Flyout メニューの各行にマウスを載せると、カーソルが手になる
  • 行から外すと矢印に戻る(戻らない場合は PointerExited が取れているか確認)
  • 同じアプリ内の別 CollectionView(一覧画面など)ではカーソルが変わらない
  • 行の余白(Padding 部分)でも反応する(反応しなければ Grid に透明背景を追加)

補足:ホバー時の “見た目” も合わせると、メニューの UX が上がる

手カーソルだけでも「クリックできる」ことは伝わりますが、メニューとしてはホバー時に背景色を薄く変えるなどのフィードバックもあると、さらに迷いが減ります。MAUI 側で簡単に行うなら、Grid の背景色を Binding で切り替えるよりも、まずは「ホバーできる要素の範囲」を明確にするのが効果的です。

例えば次のように Border を使って、行の当たり判定と見た目を一体化すると、デザイン変更にも強くなります。

<CollectionView.ItemTemplate>
  <DataTemplate x:DataType="vm:FlyoutPageItem">
    <Border BackgroundColor="Transparent"
            StrokeThickness="0"
            Padding="5,10"
            behavior:CursorBehavior.Cursor="Hand">

      <Grid>
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="30" />
          <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <Image Source="{Binding IconSource}" />
        <Label Grid.Column="1"
               Margin="20,0"
               Text="{Binding Title}"
               FontSize="20"
               FontAttributes="Bold"
               VerticalOptions="Center" />
      </Grid>

    </Border>
  </DataTemplate>
</CollectionView.ItemTemplate>

この形にしておくと、将来的に「ホバー時は背景色を変更」「押下時は少し暗くする」なども、Border 側の設定に寄せやすくなります。

まとめ(拡張メソッド+添付プロパティで “その CollectionView だけ” を実現する)

  • .NET MAUI の Windows デスクトップでカーソルを変えるには、WinUI の Pointer イベントと InputCursor を使う必要がある
  • Windows 固有の処理は Platforms/Windows に寄せると安全
  • XAML から一行で付けられる添付プロパティにしておけば、メニュー用 CollectionView だけに限定できる
  • Grid(または Border)に BackgroundColor="Transparent" を付けると、余白でもホバーが効いて “メニューらしさ” が上がる

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次