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

前回の投稿でScrollViewerのマウスホイールの回転の挙動を変更する添付プロパティを作成することにしました。その詳細の設計をしていきます。

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

ScrollViewerのマウスホイールによるスクロールモードを制御できる添付プロパティを作成します。

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

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

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

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

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

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

public enum MouseWheelHandlingMode
{
    Normal,
    OnlyVisible,
    OnlyScrollable,
}

添付プロパティの実装

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

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

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

この添付プロパティを実現するために必要な要素は以下の通りとなります。

  • 添付プロパティの定義とアクセス関数
  • ScrollViewer.HandlesMouseWheelScrollingプロパティの設定
  • PreviewMouseWheelイベントハンドラー
  • 添付プロパティが変化したときの処理
  • ScrollViewerの添付プロパティの上書き
  • 内包するScrollViewerの検索

これらは、 ScrollViewerProperties クラスに定義するものとします。そのため、添付プロパティの名前は、 ScrollViewerProperties.MouseWheelHandlingModeとなります。

添付プロパティの定義とアクセス関数

MouseWheelHandlingMode列挙値を設定する添付プロパティMouseWheelHandlingModeを定義します。また、コードから添付プロパティの値にアクセスするための関数GetMouseWheelHandlingMode() / SetMouseWheelHandlingMode()も用意します。

具体的には以下のようなコードになります。

/// <summary>
/// Using a DependencyProperty as the backing store for MouseWheelHandlingMode.
/// This enables animation, styling, binding, etc...
/// </summary>
public static readonly DependencyProperty MouseWheelHandlingModeProperty =
    DependencyProperty.RegisterAttached(nameof(MouseWheelHandlingMode), typeof(MouseWheelHandlingMode), typeof(ScrollViewerProperties),
        new PropertyMetadata(MouseWheelHandlingMode.Normal, OnMouseWheelHandlingModeChanged));

public static MouseWheelHandlingMode GetMouseWheelHandlingMode(DependencyObject d)
{
    return (MouseWheelHandlingMode)d.GetValue(MouseWheelHandlingModeProperty);
}

public static void SetMouseWheelHandlingMode(DependencyObject d, MouseWheelHandlingMode value)
{
    d.SetValue(MouseWheelHandlingModeProperty, value);
}

添付プロパティの値が変化したときに呼び出される OnMouseWheelHandlingModeChanged()は、後ほど定義します。

HandlesMouseWheelScrollingプロパティの設定

ScrollViewerコントロールのHandlesMouseWheelScrollingプロパティは、スコープがinternalのプロパティです。そのため、外部のモジュールから直接アクセスすることができません。そのためリフレクションを使って、値を設定します。

HandlesMouseWheelScrollingプロパティを設定するための実装として、具体的には以下のようなコードを用意します。

static PropertyInfo _handlesMouseWheelScrollingPropInfo;

static void SetHandlesMouseWheelScrolling(ScrollViewer sv, bool enable)
{
    if (sv == null)
    {
        return;
    }

    if (_handlesMouseWheelScrollingPropInfo == null)
    {
        // Get PropInfo of internal property using reflection
        var svType = typeof(ScrollViewer);
        _handlesMouseWheelScrollingPropInfo = svType.GetProperty("HandlesMouseWheelScrolling", BindingFlags.NonPublic | BindingFlags.Instance);
    }

    _handlesMouseWheelScrollingPropInfo?.SetValue(sv, enable);
}

_handlesMouseWheelScrollingPropInfo は、リフレクションでinternalのプロパティにアクセスすための情報です。これはScrollViewerのクラスに依存し、ScrollViewerのインスタンスに依存しないので、共通のstaticフィールドに情報を保存します。

最後のSetValue()で、ScrollViewerのインスタンスのinternalのHandlesMouseWheelScrollingプロパティに値を設定します。

PreviewMouseWheelイベントハンドラー

コントロールのマウスホイールのイベントは、PreviewMouseWheelイベント、MouseWheelイベントの順番で処理されます。

ScrollViewerの既存のMouseWheelイベントハンドラーで処理される前に、ScrollViewerのHandlesMouseWheelScrollingプロパティを適切に設定したいので、PreviewMouseWheelイベントハンドラーを利用します。

PreviewMouseWheelのイベントハンドラーでは、ScrollViewerの現在の状態に合わせて、 HandlesMouseWheelScrolling プロパティを設定します。

PreviewMouseWheelのイベントハンドラーの実装として、具体的には以下のようなコードを用意します。

static void OnPreviewMouseWheel(object s, MouseWheelEventArgs e)
{
    if (!(s is ScrollViewer sv))
    {
        return;
    }

    var mode = GetMouseWheelHandlingMode(sv);
    var handleEvent = true;
    switch (mode)
    {
        case MouseWheelHandlingMode.OnlyScrollable:
            if (e.Delta > 0)
            {
                handleEvent = sv.VerticalOffset > 0;
            }
            else
            {
                handleEvent = sv.VerticalOffset < sv.ScrollableHeight;
            }
            break;
        case MouseWheelHandlingMode.OnlyVisible:
            handleEvent = sv.ComputedVerticalScrollBarVisibility == Visibility.Visible;
            break;
        case MouseWheelHandlingMode.Normal:
            handleEvent = true;
            break;
    }
    SetHandlesMouseWheelScrolling(sv, handleEvent);
}

GetMouseWheelHandlingMode()は、前述したように指定したコントロールの添付プロパティScrollViewerProperties.MouseWheelHandlingModeの値を取得するためのメソッドです。

このイベントハンドラーの引数のe.Deltaには、ホイールの回転量が設定されています。

OnlyScrollableモード

ホイールモードがMouseWheelHandlingMode.OnlyScrollableモードの場合は、回転量が正の場合は、上方向にスクロール可能なら、ScrollViewerがマウスホイールのイベントをハンドルするようにします。回転量が負の場合は、下方向にスクロール可能なら、ScrollViewerがマウスホイールのイベントをハンドルするようにします。

OnlyVisibleモード

ホイールモードがMouseWheelHandlingMode.OnlyScrollableモードの場合は、スクロールバーが表示状態なら、ScrollViewerがマウスホイールのイベントをハンドルするようにします。

Normalモード

ホイールモードがMouseWheelHandlingMode.Normalモードの場合は、既存のScrollViewerの処理と同じとするため、ScrollViewerがマウスホイールのイベントをハンドルするようにします。ただし、イベントハンドラーでNormalモードの処理を実装はしておきますが、実際には不要な処理を減らすため、Normalモードのときは、PreviewMouseWheelイベントハンドラーの登録自体をしないようにします。

添付プロパティが変化したときの処理

添付プロパティは、DataGridコントロール、ListBoxコントロール、ListViewコントロールなどに設定することを想定します。これらのコントロールに設定されたら、内部のScrollViewerコントロールを探して、PreviewMouseWheelイベントハンドラーを設定します。

添付プロパティの値が変更されたときの実装は、具体的には以下のようなコードになります。

static void OnMouseWheelHandlingModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (!(e.NewValue is MouseWheelHandlingMode mode)) { return; }
    if (d is ScrollViewer sv)
    {
        sv.PreviewMouseWheel -= OnPreviewMouseWheel;   // remove if registered
        if (mode == MouseWheelHandlingMode.Normal)
        {
            SetHandlesMouseWheelScrolling(sv, true);
        }
        else
        {
            sv.PreviewMouseWheel += OnPreviewMouseWheel;
        }
    }
    else
    {
        if (d is FrameworkElement fe)
        {
            fe.Loaded -= ScrollViewerOwer_Loaded;   // remove if registered
            fe.Loaded += ScrollViewerOwer_Loaded;
            if (fe.IsLoaded)
            {
                UpdateChildScrollViewer(fe);
            }
        }
        else
        {
            return;
        }
    }
}

static void ScrollViewerOwer_Loaded(object sender, RoutedEventArgs e)
{
    if (sender is FrameworkElement fe)
    {
        UpdateChildScrollViewer(fe);
    }
}

添付プロパティの値が変化したとき、その添付プロパティのオーナーがScrollViewerのときは、ホイールモードがNormalのときはScrollViewerのHandlesMouseWheelScrollingプロパティをtrueに設定します。ホイールモードがNormal以外のときはPreviewMouseWheelイベントにイベントハンドラーOnPreviewMouseWheel()を登録します。

添付プロパティのオーナーがScrollViewer以外の場合は、LoadedイベントにイベントハンドラーScrollViewerOwer_Loaded()を登録します。このイベントハンドラー、および、オーナーがロード済みの場合は、 UpdateChildScrollViewer()を呼び出します。この UpdateChildScrollViewer()では、子供コントロールからScrollViewerを探し、必要な設定をします。

ScrollViewerの添付プロパティの上書き

最終的にScrollViewerのHandlesMouseWheelScrollingプロパティを上書きするのは、ScrollViewerの添付プロパティの一連の処理となります。

ScrollViewer以外のコントロールで、この添付プロパティが設定された場合は、内包するScrollViewerを探して、見つかれば、その値をScrollViewerの添付プロパティに設定します。ただし、ScrollViewerの添付プロパティに設定するのは、ScrollViewerに明示的に添付プロパティが設定されていないときのみです。明示的に添付プロパティが設定されているときは、その値を優先します。

具体的には、以下のようなコードとなります。

static bool IsDefaultMouseWheelHandlingMode(DependencyObject d)
{
    var vs = DependencyPropertyHelper.GetValueSource(d, MouseWheelHandlingModeProperty);
    return vs.BaseValueSource == BaseValueSource.Default;
}

static bool UpdateChildScrollViewer(FrameworkElement fe)
{
    // Find ScrollViewer in child elements of VisualTree
    var sv = fe.GetChildOfType<ScrollViewer>();
    if (sv == null) { return false; }

    var mode = GetMouseWheelHandlingMode(fe);
    // Overwrite the current value if the mode is default
    if (IsDefaultMouseWheelHandlingMode(sv))
    {
        sv.SetCurrentValue(MouseWheelHandlingModeProperty, mode);
    }
    return true;
}

IsDefaultMouseWheelHandlingMode()は、指定のコントロールに添付プロパティが明示的に設定されているかを調べるメソッドです。具体的には、添付プロパティがデフォルト値であるかどうかを調べます。

UpdateChildScrollViewer()は、指定したコントロールの子供コントロールから、ScrollViewerを探します。ScrollViewerが見つかれば、添付プロパティが明示的に設定されていないなら、コントロールの添付プロパティの値をScrollViewerの添付プロパティとして設定します。

内包するScrollViewerの検索

内包するScrollViewerの検索には、 VisualTreeHelperを使います。拡張メソッドを定義すると使いやすくなります。

具体的には、以下のようなコードを用意します。

static class ScrollViewerPropertiesExtenstions
{
    public static T GetChildOfType<T>(this DependencyObject depObj)
        where T : DependencyObject
    {
        if (depObj == null) return null;

        var count = VisualTreeHelper.GetChildrenCount(depObj);
        for (int i = 0; i < count; i++)
        {
            var child = VisualTreeHelper.GetChild(depObj, i);

            var result = (child as T) ?? GetChildOfType<T>(child);
            if (result != null) return result;
        }
        return null;
    }
}

このコードで、内包する一番初めに見つかったScrollViewerを取得することができます。ただ、最終的なコードとしては、「内包する複数のScrollViewerに対応する」、「TextBoxBase コントロールの子要素を除外する」などが必要となります。

また、DataGrid/ListView/ListBoxなどでは、内包するScrollViewerは通常一つしかないので、最初に見つかったScrollViewerを処理の対象とすればよいです。しかし、二つ以上あることも考慮した実装が必要かもしれません。


添付プロパティの具体的な実装方法が決まりましたので、あとはこれを、ライブラリー化するだけです。

ライブラリー化したモジュールができたら、それに関する投稿をしたいと思います。

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

コメントを残す