.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/Windows | Windows の 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"を付けると、余白でもホバーが効いて “メニューらしさ” が上がる

コメント