読者です 読者をやめる 読者になる 読者になる

SourceChord

C#とXAML好きなプログラマの備忘録。最近はWPF系の話題が中心です。

WPFでシンプルな独自ナビゲーション処理のサンプルを書いてみた

WPF C#

WPFに標準で用意されてるナビゲーション系のクラス類が何かと扱いにくいので、もうちょいシンプルな独自のページ遷移を行うサンプルを書いてみました。

なぜ作った?

WPFには標準で、ナビゲーションを行うための仕組みとして、NavigationWindow、Frame、NavigationServiceなどといったコントロールやクラスが用意されてます。
しかし、まぁこの辺のコントロール類は使いにくい。。

特にイヤなのが以下のような点。

  • ナビゲーションの履歴管理が邪魔
    • Frame/NavigationWindowを使ったページ遷移では、特に何もしなくてもページ遷移の履歴などが記録されます。
    • この履歴がviewクラスの参照を掴んでいて、なかなかGCされなくなる
    • ナビゲーション関係のコマンドを受け付けると、勝手にナビゲーション動作をする。。
      • F5キー押したらページリロードする・・・
      • 一方通行なページ遷移しかさせたくないのに、マウスの戻るボタン押したら前のページに戻ってしまう・・・
      • などなど。
    • ナビゲーション時に音が出るケースがある
      • OSの設定によっては、ページ遷移するときに、エクスプローラで移動したときとかに鳴るような「カチッ」という音がします。
      • Win8.x系/Win10では標準ではオフになってるようですが、Win7では・・・
  • ナビゲーション先の指定方法が微妙
    • XAML上でページ遷移先の定義をするときは、遷移先ページのURI指定。
    • ⇒もっと厳密に型で指定したい
  • NavigationSeriviceの管理する範囲が微妙に扱いにくい
    • NavigationServiceは、Frameコントロールの内部に位置する。
      • Frame内部からページ遷移するのと、Frame外部からページ遷移するときとで、扱い方が変わる
      • ページ遷移用のメニューを作るときなどは、Frame外部からのページ遷移を多用するかと思います。
    • ↓参照
    • https://msdn.microsoft.com/ja-jp/library/ms750478%28v=vs.110%29.aspx#Navigation_Hosts

※補足
このページ遷移で音が鳴るっていう動作は、OSの以下の設定に依存します。(「ナビゲーションの開始」の項目など)
f:id:minami_SC:20160201002804p:plain:w250

で、このナビゲーション時の音は、基本的にはアプリ側から消すことができません。
細かいことですが、気にし始めると地味にイライラします・・・w
(ちょっとこだわったデザインのUI作った時などは特に・・・)

ということで、以下のような方針で必要最低限なナビゲーションを独自に行うサンプルを書いてみました。

方針
  • Frameは使わない
    • 前述の通り、なにかと問題があるので、、
    • ナビゲーション対象のページはContentControlで表示
  • ナビゲーションの履歴とかはいらない
    • 履歴も何かと問題になるので、あえて排除
    • 履歴が必要、と思ったタイミングでコード足せばいいかな。
  • MVVM関係
    • ナビゲーションはあくまでもView側のレイヤーとして作る
    • VMから直接ページ遷移の指示などは行わない。
      • VMからページ遷移を指示したければ、VMからViewになんらかのメッセージを送ってViewがナビゲーションを行えばいいかな。

使い方

概要

独自のナビゲーション処理の起点となる、NavigationServiceExというクラスを添付プロパティとして、任意のコントロールに付けられるようにしています。
使い方の概要は以下の通り。

  • NavigationServiceExの設定
    • Targetプロパティで、ページ遷移を行う領域を指定
    • Startupプロパティで、初期表示に利用するページを指定
  • ページ遷移動作の定義
    • XAML上でページ遷移の定義
      • NavigationCommands.GoToPageコマンドを送るとページ遷移する
      • 遷移先ページは、{x:Type ・・・という形で型情報で指定する
    • コードビハインドからのページ遷移

こんなイメージです。
f:id:minami_SC:20160201002951p:plain

ナビゲーション領域の定義

こんな風に、ナビゲーションを管理したいレイヤーに、NavigationServiceExクラスのTarget/Startup添付プロパティを設定します。
ここではWindowクラスにナビゲーション機能の設定を行い、ナビゲーションを行う領域として「content」という名前のContentControlを指定してます。

<Window x:Class="CustomNavigationSample.Shell"

           省略

        nav:NavigationServiceEx.Target="{Binding ElementName=content}"
        nav:NavigationServiceEx.Startup="{x:Type view:MainView}">
    <DockPanel>
        <Grid Background="LightGray" DockPanel.Dock="Top">
           :
           省略
           :
        <ContentControl x:Name="content" />
    </DockPanel>
</Window>

ページ遷移させる

ナビゲーション領域の外部からでも内部からでも、同じような書き方でページ遷移できます。

XAML上でのページ遷移定義
<Hyperlink Command="NavigationCommands.GoToPage" CommandParameter="{x:Type view:MainView}">Main Page</Hyperlink>
コードビハインドからのページ遷移
        private void button_Click(object sender, RoutedEventArgs e)
        {
            // 遷移先ページとなるインスタンスを渡してナビゲーション
            this.Navigate(new SubView());
            // ↓こんな風に、型情報を指定して遷移も可能
            //this.Navigate(typeof(SubView));
        }

コード

今回追加した独自クラスのコードはこれだけ。
サンプル一式は↓に置いときました。

https://github.com/sourcechord/WPFSamples/tree/master/NavigationSamples

    class NavigationServiceEx : DependencyObject
    {
        /// <summary>
        /// ページナビゲーションを行う領域となるContentControlを保持するプロパティ
        /// </summary>
        public ContentControl Content
        {
            get { return (ContentControl)GetValue(ContentProperty); }
            set { SetValue(ContentProperty, value); }
        }
        // Using a DependencyProperty as the backing store for Content.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty ContentProperty =
            DependencyProperty.Register("Content", typeof(ContentControl), typeof(NavigationServiceEx), new PropertyMetadata(null));


        #region ナビゲーションで利用する各種メソッド
        /// <summary>
        /// view引数で指定されたインスタンスのページへとナビゲーションを行います。
        /// </summary>
        /// <param name="view"></param>
        /// <returns></returns>
        public bool Navigate(FrameworkElement view)
        {
            this.Content.Content = view;
            return true;
        }

        /// <summary>
        /// viewType引数で指定された型のインスタンスを生成し、そのインスタンスのページへとナビゲーションを行います。
        /// </summary>
        /// <param name="viewType"></param>
        /// <returns></returns>
        public bool Navigate(Type viewType)
        {
            if (viewType == null) { return false; }

            var view = Activator.CreateInstance(viewType) as FrameworkElement;
            return this.Navigate(view);
        }
        #endregion

        /// <summary>
        /// NavigationCommands.GoToPageコマンドに対する応答処理
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void OnGoToPage(object sender, ExecutedRoutedEventArgs e)
        {
            var nextPage = e.Parameter as Type;
            this.Navigate(nextPage);
        }


        // 以下、添付プロパティなどの定義

        #region ページナビゲーションを行う領域となるContentControlを指定するための添付プロパティ
        // この添付プロパティで指定した値は、NavigationServiceEx.Contentプロパティとバインドして同期するようにして扱う。
        public static ContentControl GetTarget(DependencyObject obj)
        {
            return (ContentControl)obj.GetValue(TargetProperty);
        }
        public static void SetTarget(DependencyObject obj, ContentControl value)
        {
            obj.SetValue(TargetProperty, value);
        }
        // Using a DependencyProperty as the backing store for Target.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty TargetProperty =
            DependencyProperty.RegisterAttached("Target", typeof(ContentControl), typeof(NavigationServiceEx), new PropertyMetadata(null, OnTargetChanged));

        private static void OnTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var element = d as FrameworkElement;
            var target = e.NewValue as ContentControl;

            if (element != null && target != null)
            {
                // NavigationServiceExのインスタンスを、添付対象のコントロールに付加する。
                var nav = new NavigationServiceEx();
                NavigationServiceEx.SetNavigator(element, nav);

                // ContentプロパティとTargetをバインドしておく。
                BindingOperations.SetBinding(nav, NavigationServiceEx.ContentProperty, new Binding() { Source = target });

                // ナビゲーション用のコマンドバインディング
                element.CommandBindings.Add(new CommandBinding(NavigationCommands.GoToPage, nav.OnGoToPage));

                var startup = NavigationServiceEx.GetStartup(element);
                if (startup != null)
                {
                    nav.Navigate(startup);
                }
            }
        }
        #endregion

        #region スタートアップ時に表示するページを指定するための添付プロパティ
        public static Type GetStartup(DependencyObject obj)
        {
            return (Type)obj.GetValue(StartupProperty);
        }
        public static void SetStartup(DependencyObject obj, Type value)
        {
            obj.SetValue(StartupProperty, value);
        }
        // Using a DependencyProperty as the backing store for Startup.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty StartupProperty =
            DependencyProperty.RegisterAttached("Startup", typeof(Type), typeof(NavigationServiceEx), new PropertyMetadata(null, OnStartupChanged));

        private static void OnStartupChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var element = d as FrameworkElement;
            var startupType = e.NewValue as Type;

            if (element != null && startupType != null)
            {
                var nav = NavigationServiceEx.GetNavigator(element);
                nav?.Navigate(startupType);
            }
        }
        #endregion


        #region 任意のコントロールに対して、NavigationServiceExをアタッチできるようにするための添付プロパティ
        public static NavigationServiceEx GetNavigator(DependencyObject obj)
        {
            return (NavigationServiceEx)obj.GetValue(NavigatorProperty);
        }
        // ↓protectedにして外部からは利用できないように。
        public static void SetNavigator(DependencyObject obj, NavigationServiceEx value)
        {
            obj.SetValue(NavigatorProperty, value);
        }
        // Using a DependencyProperty as the backing store for Navigator.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty NavigatorProperty =
            DependencyProperty.RegisterAttached("Navigator", typeof(NavigationServiceEx), typeof(NavigationServiceEx), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.Inherits));
        #endregion
    }



    public static class NavigationServiceExtensions
    {
        /// <summary>
        /// view引数で指定されたインスタンスのページへとナビゲーションを行います。
        /// </summary>
        /// <param name="element"></param>
        /// <param name="view"></param>
        /// <returns></returns>
        public static bool Navigate(this FrameworkElement element, FrameworkElement view)
        {
            var navigator = NavigationServiceEx.GetNavigator(element);
            return navigator.Navigate(view);
        }

        /// <summary>
        /// viewType引数で指定された型のインスタンスを生成し、そのインスタンスのページへとナビゲーションを行います。
        /// </summary>
        /// <param name="element"></param>
        /// <param name="viewType"></param>
        /// <returns></returns>
        public static bool Navigate(this FrameworkElement element, Type viewType)
        {
            var navigator = NavigationServiceEx.GetNavigator(element);
            return navigator.Navigate(viewType);
        }
    }

これでFrameを使わず最低限のページ遷移が行えます。
WPF標準のFrame/NavigationWindowといったコントロール類のイヤな動作は塞ぎつつ、これらを使った時に近い感覚でのページ遷移もできるかと思います。(NavigationCommands.GoToPageでの遷移など)

余力があれば、ページ遷移前後のイベント通知を追加したり、遷移時のアニメーションなどをやってみようかな。