UWP

[UWP] FlyoutをDataContextによって切り替える(AU↑必要)

はじめに

以前にも ListViewBase等に使えるContextFlyoutの表示方法 を投稿しましたが、もう少し汎用的に利用できる実装を見つけたのでご紹介します。

以下でご紹介する方法は、マウスやタッチ環境の他にXboxOne等のコントローラーでもContextFlyoutを利用できることを確認しています。

前提条件

  • UWP
  • ビルドターゲットバージョン
    • Anniversary Update(1607)(Api Contract v3) 以上

コンテキストメニューの表示トリガーとしてAUからAPIに追加された UIElement.ContextRequestedを利用します。

Flyoutの切り替えはDataTemplateSelectorのユーザー実装によって行うようにします。

ContextRequestedの挙動について

UIElement.ContextFlyoutがnullの場合に、コンテキストメニューの表示アクション(右クリック、ホールドタップなど)がトリガーされるとContextRequestedが呼び出されます。

つまり、まずContextFlyoutが優先して呼び出され、もしContextFlyoutが設定されていない場合のみContextRequestedによる解決機会が与えられるようになっています。

参考:https://docs.microsoft.com/ja-jp/windows/uwp/controls-and-patterns/collection-commanding#creating-context-menus

ContextFlyoutExtensionの実装

ContextFlyoutExtension.cs
using System;
using System.Collections.Generic;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;

namespace SampleApp.Views.Extensions
{
    public class ContextFlyoutExtension : DependencyObject
    {
        public static readonly DependencyProperty FlyoutTemplateSelectorProperty =
            DependencyProperty.RegisterAttached(
                "FlyoutTemplateSelector",
                typeof(DataTemplateSelector),
                typeof(ContextFlyoutExtension),
                new PropertyMetadata(default(DataTemplateSelector), FlyoutTemplateSelectorPropertyChanged)
            );

        public static void SetFlyoutTemplateSelector(UIElement element, DataTemplateSelector value)
        {
            element.SetValue(FlyoutTemplateSelectorProperty, value);
        }
        public static DataTemplateSelector GetFlyoutTemplateSelector(UIElement element)
        {
            return (DataTemplateSelector)element.GetValue(FlyoutTemplateSelectorProperty);
        }

        static Dictionary<UIElement, DataTemplateSelector> SelectorDict = new Dictionary<UIElement, DataTemplateSelector>();
        static Dictionary<DataTemplate, FlyoutBase> TemplateToFlyoutInstance = new Dictionary<DataTemplate, FlyoutBase>();

        private static void FlyoutTemplateSelectorPropertyChanged(DependencyObject s, DependencyPropertyChangedEventArgs e)
        {
            if (e.NewValue != null)
            {
                var uiElement = s as UIElement;
                if (uiElement == null)
                {
                    throw new NotSupportedException($"FlyoutTemplateSelector must attached to UIElement. now attach to {s?.ToString() ?? "null"}");
                }

                SelectorDict.Add(uiElement, e.NewValue as DataTemplateSelector);
                uiElement.ContextRequested += UiElement_ContextRequested;
            }
            else
            {
                var uiElement = s as UIElement;
                if (SelectorDict.TryGetValue(uiElement, out var pair))
                {
                    uiElement.ContextRequested -= UiElement_ContextRequested;
                    SelectorDict.Remove(uiElement);
                }
            }
        }

        private static void UiElement_ContextRequested(UIElement sender, Windows.UI.Xaml.Input.ContextRequestedEventArgs args)
        {
            if (SelectorDict.TryGetValue(sender, out var flyoutTemplateSelector))
            {
                object dataContext = null;
                FrameworkElement element = null;

                // コントローラ操作かつListViewBase系コントロールの場合に対応する
                // フォーカスできるUIはListViewItemのItemContainer側に限定されるため
                // ListViewItemやGriViewItemの基底クラスであるContentControlまでダウンキャストしている
                if (args.OriginalSource is ContentControl)
                {
                    var contentControl = args.OriginalSource as ContentControl;
                    dataContext = contentControl.Content;
                    element = contentControl.ContentTemplateRoot as FrameworkElement;
                }
                else if (args.OriginalSource is FrameworkElement)
                {
                    element = args.OriginalSource as FrameworkElement;
                    dataContext = element?.DataContext;
                }

                if (dataContext != null)
                {
                    var template = flyoutTemplateSelector.SelectTemplate(dataContext, element);
                    if (template != null)
                    {
                        FlyoutBase flyout = null;
                        if (TemplateToFlyoutInstance.ContainsKey(template))
                        {
                            flyout = TemplateToFlyoutInstance[template];
                        }
                        else
                        {
                            flyout = template.LoadContent() as FlyoutBase;
                            TemplateToFlyoutInstance.Add(template, flyout);
                        }

                        if (flyout != null)
                        {
                            // elementのDataContextがFlyoutに注入された上で表示される
                            flyout.ShowAt(element);
                            args.Handled = true;
                        }
                    }
                }
            }
        }

    }
}

使い方

先に実装を示したContextFlyoutExtension.FlyoutTemplateSelectorを任意のUIElementに対する添付プロパティとして配置します。

FlyoutTemplateSelectorの実装によってDataContextに対するフライアウトの表示切替を行います。

SampleFlyoutTemplateSelector.cs
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace Sample.Views.TemplateSelector
{
    public class SampleFlyoutTemplateSelector : DataTemplateSelector
    {
        public DataTemplate StringFlyoutTemplate { get; set; }
        public DataTemplate DateTimeFlyoutTemplate { get; set; }

        protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
        {
            if (item is String)
            {
                return StringFlyoutTemplate;
            }
            else if (item is DateTime)
            {
                return DateTimeFlyoutTemplate ;
            }

            return base.SelectTemplateCore(item, container);
        }
    }
}
xamlでの配置例
<UserControl 
    ~略~
    xmlns:myextensions="using:SampleApp.Views.Extensions"
    xmlns:templateSelector="using:SampleApp.Views.TemplateSelector"
>
<Grid>
    <myextensions:ContextFlyoutExtension.FlyoutTemplateSelector>
        <templateSelector:SampleFlyoutTemplateSelector >
            <templateSelector:SampleFlyoutTemplateSelector.StringFlyoutTemplate>
                <DataTemplate>
                    <MenuFlyout>
                          <MenuFlyoutItem Text="Sample String Flyout" IsEnabled="False" />
                          <MenuFlyoutItem Text="{Binding}" IsEnabled="False" />
                    </MenuFlyout>
                </DataTemplate>
            </templateSelector:SampleFlyoutTemplateSelector.StringFlyoutTemplate>
            <templateSelector:SampleFlyoutTemplateSelector.DateTimeFlyoutTemplate >
                <DataTemplate>
                    <MenuFlyout>
                          <MenuFlyoutItem Text="Sample DateTime Flyout" IsEnabled="False" />
                          <MenuFlyoutItem Text="{Binding}" IsEnabled="False" />
                    </MenuFlyout>
                </DataTemplate>
            </templateSelector:SampleFlyoutTemplateSelector.DateTimeFlyoutTemplate >
        </templateSelector:SampleFlyoutTemplateSelector >
    </myextensions:ContextFlyoutExtension.FlyoutTemplateSelector>   
</Grid>
</UserControl>

利用時の注意点

FlyoutBase.ShowAtによるものかわかりませんが、この実装ではフライアウトの表示位置がユーザーがポイントした位置から大きめにズレるため、特にマウス操作時に違和感を与えるかもしれません。

また、追加実装としてアプリライフサイクルのEnterBackgroundやMemoryLimitChanged(名称うろ覚え)のタイミングで、FlyoutTemplateSelectorのTemplateToFlyoutInstanceにClearを掛けてキャッシュしているFlyoutインスタンスをガベージコレクトの対象になるようにしておくとベターかもしれません

さいごに

ページ全体をカバーするFlyoutセレクタ、またはアプリ丸ごと対応するFlyoutセレクタとして利用できそうですね。

Flyoutに限らずUIの共通化自体はリソースとして扱えば簡単にできますが、入力の違いをカバーして単純なコードでコンテキストフライアウトを利用できる実装として参考になれば幸いです。