SourceChord

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

ウィンドウの動きに追従するPopupコントロールを作る

WPF標準のPopupコントロールについて

WPFのPopupコントロールを使うと、任意のUIElementをポップアップ表示することができます。
この時ポップアップ表示される内容は、別ウィンドウとして元のウィンドウの上に表示されます。

PopupのStaysOpenプロパティをFalseにしておくと、コンテキストメニューなどと同じように、フォーカスが外れたときに自動でPopupを閉じます。
一方、StaysOpenをTrueにした場合は、自分でIsOpenプロパティをfalseにするまで、ポップアップ表示したままの状態となります。

Popupコントロールのサンプル

PopupのIsOpenプロパティをToggleButtonのIsCheckedにバインドして、トグルボタンを押してポップアップ表示をするようにしています。
また、横のチェックボックスで、StaysOpenプロパティを切り替えます。
StaysOpenがfalseの場合はポップアップからフォーカスが外れたらすぐに閉じられて、trueの場合には開いたままになることを確認できるかと思います。

<Window x:Class="WpfBaseTemplate1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        Width="525"
        Height="350">
    <Grid>
        <ToggleButton x:Name="btnOpen"
                      Width="75"
                      HorizontalAlignment="Center"
                      VerticalAlignment="Center"
                      Content="Button" />
        <Popup IsOpen="{Binding IsChecked,
                                ElementName=btnOpen}"
               PlacementTarget="{Binding ElementName=btnOpen,
                                         Mode=OneWay}"
               StaysOpen="{Binding IsChecked,
                                   ElementName=chkStayOpen}">
            <TextBlock Background="Beige"
                       Padding="10, 5"
                       Text="ポップアップ表示のテスト" />
        </Popup>
        <CheckBox x:Name="chkStayOpen"
                  Margin="200,0,0,0"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center"
                  Content="StaysOpen"
                  IsChecked="True"/>

    </Grid>
</Window>

PopupコントロールをStaysOpen="True"にした時の問題点

StaysOpen="True"にした時、ポップアップ表示をした状態で元のウィンドウを移動させたりリサイズしたりすると、Popupコントロールだけは元の位置に居座り続け、残念な表示となってしまいます。
↓こんな感じ。

ウィンドウの動きに追従するPopupコントロールを作る

ということで、ウィンドウの動きに追従するPopupコントロールを作ってみました。
Popupコントロールを継承するカスタムコントロールとして作っています。

やってる内容

コンストラクタ

OverrideMetadataメソッドを使い、IsOpenProperty依存関係プロパティのメタデータを書き換えています。
ここで、IsOpenプロパティが更新時のコールバックとしてIsOpenChangedメソッドを設定しています。

IsOpenChangedメソッド

IsOpenがtrueになり、ポップアップが表示されるときに、各種イベントハンドラの登録を行います。
また、IsOpenがtrueから変更された際には、各種イベントハンドラを解除しておく、という処理もしてます。

各種イベントハンドラの処理

・OnFollowWindowChangedメソッド
PopupのTargetElementが属するウィンドウを取得しておき、ウィンドウのLocationChaged/SizeChangedのイベント発生時に、このメソッドが呼ばれるようにしています。

ここで、ポップアップ表示している部分の位置を、ウィンドウの移動に追従するように更新しています。
Popupコントロールのなんらかのプロパティを更新して、画面表示を更新させることで、適切な位置へと表示を更新させています。ここでは、HorizontalOffsetプロパティを更新しています。
「更新しないといけない」ということで、わざわざ違う値を2回セットしなおして、プロパティの更新イベントが起きるようにしています。
なんともDirtyな方法ですが、これが一番シンプルにできる対処方法な気がします。
参考にしたサイトは以下の通り。下のページ見なければ、こんな方法思いつきもしなかっただろうなぁ。。。
※参考サイト
http://stackoverflow.com/questions/1600218/how-can-i-move-a-wpf-popup-when-its-anchor-element-moves

・OnScrollChangedメソッド
こちらは、Popup表示を行う要素がListBoxなどのScrollViewerを持った要素内に配置された場合の対策です。
ListBoxなどに表示してポップアップ表示をした状態でスクロールを行うと、ポップアップ表示が元の場所に残ってしまいます。
これはでは邪魔になってしまうので、スクロール時にはPopupのIsOpenプロパティをfalseにして閉じるようにしています。

こうすることで、以下の動画のようにウィンドウの動きに追従してくれるPopupコントロールを作ることができます。

全コードは以下の通り

FollowablePopup.cs
    public class FollowablePopup : Popup
    {
        static FollowablePopup()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(FollowablePopup), new FrameworkPropertyMetadata(typeof(FollowablePopup)));

            // PopupのIsOpenプロパティ更新のイベントハンドラを設定する。
            FollowablePopup.IsOpenProperty.OverrideMetadata(typeof(FollowablePopup), new FrameworkPropertyMetadata(IsOpenChanged));
        }

        private static void IsOpenChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var ctrl = d as FollowablePopup;
            if (ctrl == null)
                return;

            var target = ctrl.PlacementTarget;
            if (target == null)
                return;

            var win = Window.GetWindow(target);
            // ポップアップの親要素にいるScrollViewer要素があれば取得する。
            var scrollViewer = ctrl.GetDependencyObjectFromVisualTree(ctrl, typeof(ScrollViewer)) as ScrollViewer;

            // 更新前のIsOpenプロパティがtrueだったので、
            // 登録済みのイベントハンドラを解除する。
            if (e.OldValue != null && (bool)e.OldValue == true)
            {
                if (win != null)
                {
                    // ウィンドウの移動/リサイズ時の処理を設定
                    win.LocationChanged -= ctrl.OnFollowWindowChanged;
                    win.SizeChanged -= ctrl.OnFollowWindowChanged;
                }

                if (scrollViewer != null)
                {
                    // ListBoxなどのようなScrollViewerを持った要素内に設定された場合の動作
                    scrollViewer.ScrollChanged -= ctrl.OnScrollChanged;
                }
            }

            // IsOpenプロパティをtrueに変更したので、
            // 各種イベントハンドラを登録する。
            if (e.NewValue != null && (bool)e.NewValue == true)
            {
                if (win != null)
                {
                    // ウィンドウの移動/リサイズ時の処理を設定
                    win.LocationChanged += ctrl.OnFollowWindowChanged;
                    win.SizeChanged += ctrl.OnFollowWindowChanged;
                }

                if (scrollViewer != null)
                {
                    // ListBoxなどのようなScrollViewerを持った要素内に設定された場合の動作
                    scrollViewer.ScrollChanged += ctrl.OnScrollChanged;
                }
            }
        }

        private void OnFollowWindowChanged(object sender, EventArgs e)
        {
            var offset = this.HorizontalOffset;
            // HorizontalOffsetなどのプロパティを一度変更しないと、ポップアップの位置が更新されないため、
            // 同一プロパティに2回値をセットしている。
            this.HorizontalOffset = offset + 1;
            this.HorizontalOffset = offset;
        }

        private void OnScrollChanged(object sender, ScrollChangedEventArgs e)
        {
            this.IsOpen = false;
        }

        private DependencyObject GetDependencyObjectFromVisualTree(DependencyObject startObject, Type type)
        {
            var parent = startObject;
            while (parent != null)
            {
                if (type.IsInstanceOfType(parent))
                    break;
                else
                    parent = VisualTreeHelper.GetParent(parent);
            }
            return parent;
        }
    }
MainWindow.xaml

使用側のコードは以下の通り。
今までPopupと書いてた部分を、今回のFollowablePopupコントロールに変えるだけです。

<Window x:Class="WpfBaseTemplate1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfBaseTemplate1"
        Title="MainWindow"
        Width="400"
        Height="200">
    <Grid>
        <ToggleButton x:Name="btnOpen"
                      Width="75"
                      HorizontalAlignment="Center"
                      VerticalAlignment="Center"
                      Content="Button" />
        <local:FollowablePopup IsOpen="{Binding IsChecked,
                                                ElementName=btnOpen}"
                               PlacementTarget="{Binding ElementName=btnOpen,
                                                         Mode=OneWay}"
                               StaysOpen="{Binding IsChecked,
                                                   ElementName=chkStayOpen}">
            <TextBlock Background="Beige"
                       Padding="10, 5"
                       Text="ポップアップ表示のテスト" />
        </local:FollowablePopup>
        <CheckBox x:Name="chkStayOpen"
                  Margin="200,0,0,0"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center"
                  Content="StaysOpen"
                  IsChecked="True" />

    </Grid>
</Window>