WPFのScrollViewerのマウスホイールの挙動の最適化♯1

ScrollViewerは、マウスホイールの回転で、内部のコンテンツをスクロールできます。しかし、ScrollViewerの中にScrollViewerがあった時のマウスホイールの挙動がスマートではありません。

WPFのScrollViewer

WPFのScrollViewerコントロールは、内部のコンテンツをスクロールして表示できるようにするコントロールです。これにより、ウィンドウより大きいサイズのコンテンツを表示することができます。

ScrollViewerコントロールでの内部コンテンツの縦方向のスクロールは、マウスホイールの回転でも可能です。マウスホイールを回転すると期待通りのスクロールをします。

ScrollViewerの入れ子

しかし、ScrollViewerコントロールが入れ子状態になっているとき、期待される動作をしません。

ScrollViewerコントロールの入れ子状態とは、下記の画像のような状態です。”ScrollViewer in ScrollViewer”というGroupBoxコントロールの内部はScrollViewerコントロールでコンテンツを表示しており、縦スクロールバーを持っています。そして、内部コンテンツにはDataGridコントロールがあり、それも縦スクロールバーを持っています。

この投稿では、これをScrollViewerの入れ子状態と呼んでいます。

ScrollViwerの中にあるScrollViewer

さて、この状態のとき、WPFではマウスホイールの回転でのスクロールはどのように挙動すると思いますか?

ScrollViewerコントロールのマウスホイールの回転でのスクロールは、マウスポインターの位置により挙動が決まります。

ScrollViewerコントロール上のマウスポインターがあるときは、そのScrollViewerコントロールがマウスホイールのイベントを処理します。

はじめにあげたグループボックスのウィンドウの例であれば、下記の図のように色分けした領域で挙動が異なります。

ScrollViwerのマウスホイールの範囲(赤の領域は内側のScrollViewerが対象,青の領域は外側のScrollViewerが対象)

マウスポインターが水色の領域にある場合は、マウスホイールのイベントは、グループボックス内部の右側にあるスクロールバーを対象として処理されます。

マウスポインターが赤色の領域ある場合は、それぞれのDataGridコントロール内部のScrollViewerコントロールが対象となり、DataGridの右側の縦スクロールバーを対象として、マウスホイールのイベントが処理されます。

内部のScrollViewerに縦スクロールバーが表示されていないときの挙動

上側の赤色の領域のDataGridコントロールには、縦スクロールバーがありません。その場合でも、マウスホイールのイベントは、DataGridコントロール内のScrollViewerコントロールで処理されます。そのため、上側の赤色の領域でマウスホイールを回転させても、DataGridコントロールにはスクロールバーがないにもかかわらず、水色の領域のコンテンツがスクロールすることはありません。

内部のScrollViewerに縦スクロールバーが表示されているときの挙動

また、下側の赤色の領域のDataGridコントロールには、縦スクロールバーがあります。そのため、その領域でのマウスホイールのイベントをそのDataGridコントロール内のScrollViewerコントロールが処理することは、特に違和感がありません。

しかし、上記の絵の例では、縦スクロールバーの位置は、一番上にあり、それより上側にスクロールすることはありません。その場合でも、DataGridコントロール内のScrollViewerは、コンテンツをスクロールする必要がないにもかかわらず、マウスホイールのイベントを処理してしまいます。その結果、その領域でマウスホイールを上側に回転させても、水色の領域のコンテンツが上側にスクロールすることはありません。

これは、内部の縦スクロールバーが、一番下の位置にあった場合も同様です。赤色の領域内でのマウスホイールのイベントが外側の水色の領域のコンテンツのスクロールに影響することはありません。

ScrollViewerのスクロールのスマートな挙動

ScrollViewerコントロールが入れ子になった時の、より直感的な挙動はどのようなものでしょうか?

以下の二つになると思います。

縦スクロールバーが非表示の場合、マウスホイールのイベントをハンドリングしない

これにより、マウスホイールのイベントは、外側のコントロールに伝搬されます。上記の例であれば、上側の赤色の領域でマウスホイールの回転をすると水色の領域がスクロールするようになります。

縦スクロールバーが表示の場合、その方向にそれ以上スクロールできない場合は、マウスホイールのイベントをハンドリングしない

これにより、内部の縦スクロールバーがそれ以上スクロールでいない場合は、マウスホイールのイベントは、外側のコントロールに伝搬されます。上記の例であれば、下側の赤色の領域でマウスホイールの回転すると内側のスクロールバーが端まで到達しスクロールができなくなったら、外側の水色の領域がスクロールするようになります。

ScrollViewerの挙動をよりスマートに変えたい

ScrollViewerの挙動をよりスマートな挙動に変更することは可能なのでしょうか?

ScrollViewerでスマートなスクロールの挙動を実現するために必要なのは、ScrollViewerの状況(スクロールが可能かどうか)を考慮して、マウスホイールのイベントを処理するかどうかを変更できることです。

処理を変更できるかどうかを調べるためには、ScrollViewerのソースコードが必要です。WPFのソースコードは公開されているので、ScrollViewerのソースコードを確認します。

マウスホイールのイベントハンドラー

まずは一番関係しそうなScrollViewerのマウスホイールのイベントの処理を確認します。具体的には、マウスホイールのイベントをハンドリングしている実装です。以下に抜粋します。

/// <summary>
/// This is the method that responds to the MouseWheel event.
/// </summary>
/// <param name="e">Event Arguments</param>
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
    if (e.Handled) { return; }
 
    if (!HandlesMouseWheelScrolling)
    {
        return;
    }
 
    if (ScrollInfo != null)
    {
        if (e.Delta < 0) { ScrollInfo.MouseWheelDown(); }
        else { ScrollInfo.MouseWheelUp(); }
    }
 
    e.Handled = true;
}

このイベントハンドラーでは、「このイベントが処理済み(e.Handledがtrue)」および、「 HandlesMouseWheelScrolling がfalse」以外では、常にホイールのイベントを処理していることになります。

縦スクロールバーが非表示であっても、また、縦スクロールが端まで到達しており、その先のスクロールができない場合でも、マウスホイールのイベントを処理していることになります。そのため、スクロールできなかったとしても、外側のScrollViewerコントロールにマウスホイールイベントが伝搬されることはありません。

さて、ScrollViewerのマウスホイールのイベントハンドラーをチェックすると、 HandlesMouseWheelScrollingプロパティによる条件があります。

    if (!HandlesMouseWheelScrolling)
    {
        return;
    }

このプロパティを利用できれば、ScrollViewerのマウスホイールの挙動を制御できそうです。

HandlesMouseWhellScrollingプロパティ

そこで、ScrollViewerのHandlesMouseWheelScrollingプロパティの実装を確認します。 以下に抜粋します。

/// <summary>
/// Whether or not the ScrollViewer should handle mouse wheel events.  This property was
/// specifically introduced for TextBoxBase, to prevent mouse wheel scrolling from "breaking"
/// if the mouse pointer happens to land on a TextBoxBase with no more content in the direction
/// of the scroll, as with a single-line TextBox.  In that scenario, ScrollViewer would
/// try to scroll the TextBoxBase and not allow the scroll event to bubble up to an outer
/// control even though the TextBoxBase doesn't scroll.
///
/// This property defaults to true.  TextBoxBase sets it to false.
/// </summary>
internal bool HandlesMouseWheelScrolling
{
    get
    {
        return ((_flags & Flags.HandlesMouseWheelScrolling)
            == Flags.HandlesMouseWheelScrolling);
    }
    set
    {
        SetFlagValue(Flags.HandlesMouseWheelScrolling, value);
    }
}

Summaryにヒントがありそうです。そのためSummaryのところを日本語訳してみます。

ScrollViewerがマウスホイールイベントを処理するかどうかを指定します。このプロパティは特にTextBoxBaseのために導入され、単一行テキストボックスのような縦スクロールの方向にコンテンツがないTextBoxBaseの上にマウスポインタがきた場合に、マウスホイールのスクロールができなくならないようにするためです。そのシナリオでは、ScrollViewerはTextBoxBaseをスクロールしようとしますが、実際はTextBoxBaseがスクロールしないにもかかわらず、スクロールイベントを外側のコントロールに伝搬(バブルアップ)させません。
 このプロパティのデフォルトはtrueです。TextBoxBaseはこれをfalseに設定します。

マウスホイールのイベントを処理するかどうかをScrollViewerコントロールの外から制御するために用意されたプロパティのようです。

今回のScrollViewerのスマートなスクロールの挙動を実現するために必要なことは、マウスホイールのイベントを外側のコントロール(ScrollViewerなど)に伝搬させることなので、目的にぴったりのプロパティとなります。このプロパティを使うことで、ScrollViewerコントロールの入れ子状態のときの挙動を制御できそうです。

一つ問題なのは、このプロパティのスコープがpublicではなくinternalになっていることです。このプロパティは外部のモジュールから直接触ることはできません。ただ、リフレクションを使えば、モジュールの外部からもこのプロパティを触ることができます。

ScrollViewerのマウスホイールの挙動を制御する添付プロパティ

ScrollViewerのスクロールモードを制御できる添付プロパティを用意すれば、容易に利用できるようになります。

ScrollViewerのマウスホイールのイベント処理を制御できるプロパティが見つかったので、添付プロパティの実装ではHandlesMouseWhellScrollingプロパティを利用します。

マウスホイールのイベントがScrollViewerにマウスホイールのイベントハンドラーにが渡る前に、HandlesMouseWheelScrollingプロパティを適切に設定します。そのためには、マウスホイールのPreviewEventで、ScrollViewerの縦スクロールの状態を確認して、 HandlesMouseWheelScrollingプロパティを設定します。

ScrollViewerのマウスホイールのモード

今回、実現する添付プロパティでは、マウスホイールイベントのハンドリングモードとして3つのモードを用意します。

  • 既存のScrollViewerと同じ振る舞い (Normal)
  • スクロールバーが表示されているときのみ (OnlyVisible)
  • スクロールが可能の場合のみ (OnlyScrollable)

具体的には以下のような列挙値を添付プロパティに設定できるようにします。

public enum MouseWheelHandlingMode
{
    Normal,
    OnlyVisible,
    OnlyScrollable,
}

添付プロパティの対象

添付プロパティは、ScrollViewerを利用するコントロールに設定して利用するものとします。主にDataGrid/ListView/ListBoxなどのコントロールに設定して利用します。

<DataGrid 
    nsAttachedProps:ScrollViewerProperties.MouseWheelHandlingMode="OnlyScrollable"/>
...

添付プロパティの実装では、添付プロパティが設定されたコントロールの子供コントロールのScrollViewerの挙動を制御するようにします。


新規作成する添付プロパティの仕様と原理が決まりました。次回の投稿で詳細な設計を進め、最終的にはnugetから利用できる添付プロパティライブラリーを作成としたいと思います。

“WPFのScrollViewerのマウスホイールの挙動の最適化♯1” への2件の返信

コメントを残す