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

前回の投稿でScrollViewerのマウスホイールの回転の挙動を変更する添付プロパティの詳細の設計をしました。この投稿は、その仕様変更とnugetライブラリーとして公開したことについてです。

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

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

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

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

設計変更

前回の投稿の段階では、DataGrid/ListView/ListBoxなどのコントロールに設定して利用する添付プロパティでした。この場合、子要素のScrollViewerは、ほとんどの場合一つしか存在しません。

しかし、実装を進めていく過程で、 DataGrid/ListView/ListBoxなどコントロール 以外にも設定できるようにしました。この添付プロパティがWindow/Grid/GroupBoxなどにも設定できることを意味します。これらのコントロールに設定できるということは、子要素のScrollViewerが複数あることを想定する必要があります。

また、既定値では親のコントロールの添付プロパティの値を子要素のScrollViewerが継承することになりますが、一度、添付プロパティを設定してしまうと、既定値である継承モードに戻せなくなります。

これらの二つの課題を解決するために設計の変更を行いました。

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

前回の投稿で、実現する添付プロパティでは、マウスホイールイベントのハンドリングモードとして3つのモードを用意するとしていましたが、最終的には4つになりました。Inheritが追加されました。

  • 親のコントロールの値を継承する (Inherit, 既定値)
  • 常にハンドリングする。既存のScrollViewerと同じ振る舞い (Normal)
  • スクロールバーが表示されているときのみ (OnlyVisible)
  • スクロールが可能の場合のみ (OnlyScrollable)

Inheritのときは、そのコントロールの値として、親のコントロールの値を利用します。この値は、この添付プロパティの既定値となります。

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

public enum MouseWheelHandlingMode
{
    Inherit,
    Normal,
    OnlyVisible,
    OnlyScrollable,
}

添付プロパティの実装変更

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

前回の投稿で説明した内容から、今回の仕様変更で大きく実装が変わるのは以下の要素となります。

  • 添付プロパティが変化したときの処理
  • ScrollViewerの添付プロパティの上書き
  • 内包する子要素のScrollViewerの検索

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

添付プロパティは、任意のコントロールに設定することを想定します。コントロールに設定されたら、子要素のScrollViewerコントロールを探して、PreviewMouseWheelイベントハンドラーを設定します。

今回、モードの値としてInheritが追加されたので、その対応をします。

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

static void OnMouseWheelHandlingModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (!(e.NewValue is MouseWheelHandlingMode mode)) { return; }

    if (d is ScrollViewer sv)
    {
        // The value of ScrollViewer is not inherited by the child ScrollViewers.

        sv.PreviewMouseWheel -= OnPreviewMouseWheel;   // remove if registered
        if (mode == MouseWheelHandlingMode.Inherit)
        {
            // Gets the inherited value of the parent
            mode = GetInheritMouseWheelHandlingMode(sv);
            if (mode == MouseWheelHandlingMode.Inherit)
            {
                mode = MouseWheelHandlingMode.Normal;
            }
            //  Reassigns mode value as a local current value
            sv.SetCurrentValue(MouseWheelHandlingModeProperty, mode);
        }
        else if (mode == MouseWheelHandlingMode.Normal)
        {
            SetHandlesMouseWheelScrolling(sv, true);
        }
        else
        {
            sv.PreviewMouseWheel += OnPreviewMouseWheel;
        }
    }
    else
    {
        // The value of FrameworkEelement other than ScrollViewer is inherited by the child ScrollViewers.

        if (d is FrameworkElement fe)
        {
            fe.Loaded -= ScrollViewerOwer_Loaded;   // remove if registered
            if (mode != MouseWheelHandlingMode.Inherit)
            {
                fe.Loaded += ScrollViewerOwer_Loaded;
            }
            if (fe.IsLoaded)
            {
                if (mode == MouseWheelHandlingMode.Inherit)
                {
                    // Gets the inherited value of the parent
                    mode = GetInheritMouseWheelHandlingMode(fe);
                    if (mode == MouseWheelHandlingMode.Inherit)
                    {
                        // Set normal mode to children ScrollViewers
                        mode = MouseWheelHandlingMode.Normal;
                    }
                }
                // Reassigns mode value as a local value to children ScrollViewers
                UpdateChildScrollViewers(fe, mode);
            }
        }
        else
        {
            return;
        }
    }
}

static void ScrollViewerOwer_Loaded(object sender, RoutedEventArgs e)
{
    if (sender is FrameworkElement fe)
    {
        var mode = GetMouseWheelHandlingMode(fe);
        UpdateChildScrollViewers(fe, mode);
    }
}

static MouseWheelHandlingMode GetInheritMouseWheelHandlingMode(FrameworkElement fe)
{
    var mode = MouseWheelHandlingMode.Inherit;
    DependencyObject parent = fe;
    while (mode == MouseWheelHandlingMode.Inherit
        && (parent = VisualTreeHelper.GetParent(parent)) != null)
    {
        mode = GetMouseWheelHandlingMode(parent);
    }
    return mode;
}

前回からの大きな違いは、ホイールモードがInheritの処理が追加されたことです。

Inheritの場合は、GetInheritMouseWheelHandlingMode()メソッドで親のコントロールのホイールモードの値を再帰的に検索して、その値をそのコントロールの値にします。検索した親のコントロールのホイールモードがInheritの場合は、何も設定されていないことになるため、既存のScrollViewerと同じ振る舞いにするため、コントロールにはNormalを設定します。

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

今回の仕様変更により、子要素のScrollViewerが複数存在することを想定する必要があります。

ScrollViewer以外のコントロールで、この添付プロパティが設定された場合は、内包するすべての子要素のScrollViewerを探して、条件に合うScrollViewerが見つかれば、その値をScrollViewerの添付プロパティに現在値として設定します。

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

static bool UpdateChildScrollViewers(FrameworkElement fe, MouseWheelHandlingMode mode)
{
    var children = EnumChildScrollViewersWithoutMouseWheelHandlingMode(fe);
    if (children.Any())
    {
        foreach (var sv in children)
        {
            if (mode == MouseWheelHandlingMode.Inherit)
            {
                sv.ClearValue(MouseWheelHandlingModeProperty);
            }
            else
            {
                sv.SetCurrentValue(MouseWheelHandlingModeProperty, mode);
            }
        }
        return true;
    }
    else
    {
        return false;
    }
}

EnumChildScrollViewersWithoutMouseWheelHandlingMode()を使い、更新対象の子要素のScrollViewerを取得し、取得できたすべてのScrollViewerを対象として、添付プロパティを上書きします。

内包する子要素のScrollViewerの検索

内包する子要素のScrollViewerの検索には、VisualTreeHelperを使います。前回は、拡張メソッドを定義し、汎用的に実装しました。しかし、今回の仕様変更により、この添付プロパティ特有の検索をするので、専用メソッドを定義します。

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

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

static IEnumerable<ScrollViewer> EnumChildScrollViewersWithoutMouseWheelHandlingMode(DependencyObject frameworkElement)
{
    if (frameworkElement == null) yield break;

    var count = VisualTreeHelper.GetChildrenCount(frameworkElement);
    for (int i = 0; i < count; i++)
    {
        var child = VisualTreeHelper.GetChild(frameworkElement, i);
        if (IsDefaultMouseWheelHandlingMode(child)
            || GetMouseWheelHandlingMode(child) == MouseWheelHandlingMode.Inherit)
        {
            if (!(child is System.Windows.Controls.Primitives.TextBoxBase))
            {
                if (child is ScrollViewer sv)
                {
                    yield return sv;
                }
                var children = EnumChildScrollViewersWithoutMouseWheelHandlingMode(child);
                foreach (var j in children)
                {
                    yield return j;
                }
            }
        }
    }
}

このコードでは、内包するすべてのScrollViewerを取得することができます。ただし、以下のScrollViewerは除外されます。

  • 明示的に添付プロパティがInherit以外に設定されているScrollViewer
  • 明示的に添付プロパティがInherit以外に設定されているコントロールの子要素のScrollViewer
  • TextBoxBase コントロールの子要素のScrollViewer

これにより、前回の投稿で、最終版では対応が必要となるとしていた、複数ScrollViewer対応とTextBoxBaseの例外処理が実装されたことになります。

nugetライブラリー化

今回、この添付プロパティを容易に利用できるようにするため、nugetライブラリー化しました。

nuget.orgに、NishySoftware.Wpf.AttachedProperties (nsAttachedProperties)として公開しました。パッケージを参照したら、すぐに利用できるようになります。

DataGrid/ListView/ListBoxへの設定

xaml上では、内包するDataGrid/ListView/ListBoxのマウスホイールによるスクロールを制御するために、以下のように利用します。

<Window
  ...
  xmlns:nsAttachedProps="http://schemas.nishy-software.com/xaml/attached-properties">
    <ScrollViewer Width="300" Height="300">
...
      <DataGrid
          ItemsSource="{Binding Source={StaticResource SmallDataItemsView1}}"
          nsAttachedProps:ScrollViewerProperties.MouseWheelHandlingMode="OnlyScrollable">
...
</Window>

まとめて設定

また、今回から、DataGrid/ListView/ListBox以外でも設定されることを想定したため、以下のようにWindowに設定して、ウィンドウ全体のScrollViewerに反映することもできます。

<Window
  ...
  xmlns:nsAttachedProps="http://schemas.nishy-software.com/xaml/attached-properties"
  nsAttachedProps:ScrollViewerProperties.MouseWheelHandlingMode="OnlyScrollable"/>
  ...
</Window>

ただし、WPFのパフォーマンスに影響を与えないようにするために、子要素のScrollViewerを検索するタイミングは、「そのコントロールの添付プロパティの値が変更されたとき」と、「そのコントロールのLoadedイベントが発生したとき」に限定しています。

そのため、親コントロールに設定した添付プロパティの値が一部の子要素のScrollViewerに反映されないことがあります。

たとえば、TabControlのTabItemなどは、TabItemがアクティブになった時に、そのコンテンツのコントロールが実体化されます。そのコンテンツが実体化されたときに発生するLoadedイベントは、TabControlやTabItemには伝搬されません。そのため、TabControlやTabItemに設定した添付プロパティの値は、TabItem内のコンテンツに反映されることはありません。この場合は、TabItemのコンテンツ内の一番親になるコントロールに添付プロパティを設定する必要があります。

もし、添付プロパティの値が子要素のScrollViewerに反映されないことがあった場合は、このような状況になっている可能性があります。この場合は、遅れて実体化されそうなコントロールに対して添付プロパティを設定すると反映されるようになります。

ScrollViewerに設定

個別のScrollViewerに対して、マウスホイールによるスクロールを制御したい場合は、以下のように、ScrollViewerに対して添付プロパティを設定します。

<Window
  ...
  xmlns:nsAttachedProps="http://schemas.nishy-software.com/xaml/attached-properties">
    <ScrollViewer Width="300" Height="300">
...
      <ScrollViewer 
          nsAttachedProps:ScrollViewerProperties.MouseWheelHandlingMode="OnlyScrollable">
...
</Window>

ScrollViewerに対して設定した場合は、その子要素のScrollViewerには値は継承されません。


以上、今回の投稿では、

  • 入れ子になったScrollViewerのマウスホイールによるスクロールの挙動を改善するための添付プロパティの設計
  • それをライブラリー化したnugetパッケージ NishySoftware.Wpf.AttachedProperties (nsAttachedProperties)の公開

について説明しました。

コメントを残す