【WPF】ScrollViewerを入れ子にした時の挙動をいい感じにする

C#

こんにちは、働くC#プログラマーのさんさめです。

WPFで、ScrollViewer内にScrollViewerを入れるような
コントロールの作り方をしてしまうと、
直感的でない挙動を生じます。

具体的には、内側のScrollViewerに
マウスカーソルを合わせてホイールをした時に
外側のScrollViewerのマウスホイールが全く効かなくなります。
(下図参照)

上記画像のように、
内側のスクロールバーが見えている場合は、
内側のスクロールが優先されるのはまだ理解できます。

しかし、スクロールバーが見えていない時
やはり外側のスクロールは効きません

下の画像の内、
赤枠の中はスクロールバーが見えていませんが、
そこにマウスカーソルを合わせて
マウスホイールをすると、ピクリとも動きません。

本記事では、このユーザビリティの低い挙動を、
きわめていい感じにする方法を紹介します。

スポンサーリンク

なぜホイールによるスクロールが効かないのか?

そもそも、なぜマウスホイールが効かなくなるのでしょうか。

実はこれ、
ScrollViewerがマウスホイールイベントを、
必ずハンドリングしてしまっているのが原因です。
スクロールバーが見えてるとか見えてないとかおかまいなしなんですね。
ReferenceSourceを見れば一目瞭然です。

しかし、このReferenceSourceを見たとき、
あることに気づきました。

さんさめ
さんさめ

…ん?なんかe.Handled以外にも
早期returnしている条件があるな…?

そう、HandlesMouseWheelScrollingなる、
そのものずばりなプロパティが
あるではありませんか。

さんさめ
さんさめ

…よし、こいつ叩き折ろう

internalなプロパティなのですが(なんででしょうね?)、
存在を知ってしまえばこっちのものです。

このプロパティを使って、
いい感じに動く添付プロパティを作ってみました。

スクロールバーが見えてなければハンドリングしなくする実装

作成した添付プロパティのコードです

public enum ScrollViewerWheelMode
{
    Normal,
    OnlyVisible,
    Auto,
}

public static class ScrollViewerAttachment
{
    static private PropertyInfo handlesMouseWheelScrollingPropInfo;

    public static ScrollViewerWheelMode GetScrollViewerWheelMode(DependencyObject obj)
    {
        return (ScrollViewerWheelMode)obj.GetValue(ScrollViewerWheelModeProperty);
    }

    public static void SetScrollViewerWheelMode(DependencyObject obj, ScrollViewerWheelMode value)
    {
        obj.SetValue(ScrollViewerWheelModeProperty, value);
    }

    // Using a DependencyProperty as the backing store for ScrollViewerWheelMode.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty ScrollViewerWheelModeProperty =
        DependencyProperty.RegisterAttached(nameof(ScrollViewerWheelMode), 
            typeof(ScrollViewerWheelMode), 
            typeof(ScrollViewerAttachment), 
            new PropertyMetadata(ScrollViewerWheelMode.Normal,
                (d, e) =>
                {
                    if (!(e.NewValue is ScrollViewerWheelMode mode)) { return; }
                    ScrollViewer sv;
                    if (d is ScrollViewer dsv)
                    {
                        sv = dsv;
                        DispatchEvent(sv, mode);
                    }
                    else 
                    {
                        d.Dispatcher?.BeginInvoke((Action)(async () =>
                        {
                            // VisualTreeの子要素からScrollViewerを探す
                            await Task.Delay(10); // VisualTree構築待ち
                            sv = d.GetChildOfType<ScrollViewer>();
                            if (sv == null) { return; }
                            SetScrollViewerWheelMode(sv, mode);
                        }));
                    }
                }));

    private static void DispatchEvent(ScrollViewer sv, ScrollViewerWheelMode mode)
    {
        if (mode == ScrollViewerWheelMode.Normal)
        {
            handlesMouseWheelScrollingPropInfo?.SetValue(sv, true);
            sv.PreviewMouseWheel -= OnPreviewMouseWheel;
        }
        else
        {
            sv.PreviewMouseWheel += OnPreviewMouseWheel;
        }
    }

    private static void OnPreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
    {
        if (!(sender is ScrollViewer sv))
        {
            return;
        }

        if (handlesMouseWheelScrollingPropInfo == null)
        {
            // リフレクションでinternalなプロパティを取得
            var svType = typeof(ScrollViewer);
            handlesMouseWheelScrollingPropInfo = svType.GetProperty("HandlesMouseWheelScrolling", BindingFlags.NonPublic | BindingFlags.Instance);
        }

        var mode = GetScrollViewerWheelMode(sv);
        if (mode == ScrollViewerWheelMode.OnlyVisible)
        {
            // 縦スクロールバーが見えている時のみ、
            // マウスホイールイベントをハンドリングする
            handlesMouseWheelScrollingPropInfo.SetValue(sv, sv.ComputedVerticalScrollBarVisibility == Visibility.Visible);
        }
    }
    public static T GetChildOfType<T>(this DependencyObject depObj)
        where T : DependencyObject
    {
        if (depObj == null) return null;

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

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

使い方は以下のようになります。

<Window x:Class="ScrollViewerWheelModeSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:ScrollViewerWheelModeSample"
        Title="MainWindow" Height="450" Width="800">
    <ScrollViewer>
        <DockPanel>
            <TextBox Text="とても縦に長いコントロール"
                     AcceptsReturn="True"
                     DockPanel.Dock="Left"
                     VerticalAlignment="Top"
                     Height="800"/>
            <Button Content="リストを空にする"
                    Margin="4"
                    Click="ClearList"
                    DockPanel.Dock="Top"/>
            <Button Content="リストに100個つめる"
                    Margin="4 0"
                    Click="FillList"
                    DockPanel.Dock="Top"/>
            <GroupBox Header="ScrollViewr内部のListBox"
                       Margin="4 8 4 0">
                <ListBox Margin="4 0"
                     x:Name="MyListBox"
                     local:ScrollViewerAttachment.ScrollViewerWheelMode="OnlyVisible"
                     VerticalAlignment="Top"
                     MinHeight="200"
                     MaxHeight="600"/>
            </GroupBox>
        </DockPanel>
    </ScrollViewer>
</Window>

ScrollViewerの入れ子となったListBoxに

local:ScrollViewerAttachment.ScrollViewerWheelMode="OnlyVisible"

というように添付プロパティを入れます。

すると、内側のScrollViewer内でホイールした時の
挙動が以下のように変わります。

  • 内側の縦スクロールバーが見えているときは
    内側のスクロールが効く
  • 内側の縦スクロールバーが見えていないときは
    外側のスクロールが効く

より視覚的に分かりやすいようにgifアニメにしてみました。

同じマウスカーソル位置でも
ホイール時の挙動が変わっていることが分かります。

それ以上スクロールできなければスクロールしない

これだけでもすごく便利です。

しかし、もっと気の利いた挙動として、

  • 既に上端だったら上方向のホイールはハンドリングしない
  • 既に下端だったら下方向のホイールはハンドリングしない

といったものが考えられます。

それを考慮したモードが以下のAutoモードです。

といっても実装は簡単で、以下のように
OnPreviewMouseWheelメソッドを書きかえます。

private static void OnPreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
{
    if (!(sender is ScrollViewer sv))
    {
        return;
    }

    if (handlesMouseWheelScrollingPropInfo == null)
    {
        // リフレクションでinternalなプロパティを取得
        var svType = typeof(ScrollViewer);
        handlesMouseWheelScrollingPropInfo = svType.GetProperty("HandlesMouseWheelScrolling", BindingFlags.NonPublic | BindingFlags.Instance);
    }

    var mode = GetScrollViewerWheelMode(sv);
    if (mode != ScrollViewerWheelMode.Normal)
    {
        // 縦スクロールバーが見えている時のみ、
        // マウスホイールイベントをハンドリングする
        handlesMouseWheelScrollingPropInfo.SetValue(sv, sv.ComputedVerticalScrollBarVisibility == Visibility.Visible);
    }
            
    if(mode == ScrollViewerWheelMode.Auto)
    {
        if (e.Delta > 0)
        {
            handlesMouseWheelScrollingPropInfo.SetValue(sv, sv.VerticalOffset > 0);
        }
        else
        {
            handlesMouseWheelScrollingPropInfo.SetValue(sv, sv.VerticalOffset < sv.ScrollableHeight);
        }
    }
}

使い方はもちろん、次のようになります。

local:ScrollViewerAttachment.ScrollViewerWheelMode="Auto"

実行してみた結果は以下の通りです。
内側のScrollViewerにマウスカーソルを合わせて、
上方向にスクロールしていますが、
途中までは内側がスクロールし、
上端まで来ると、外側がスクロールします。

かなり直感的な挙動になりました。

まとめ

まとめです。

  • WPFではScrollViewerを入れ子にすると
    マウスホイールの操作性が悪くなります
  • ScrollViewer自体の実装として、
    HandlesMouseWheelScrollingプロパティが
    falseの時にはホイールイベントをハンドリングしなくなります
  • スクロールバーの状態に応じて上記プロパティを
    適切に設定することで直感的な挙動を実現できました

最後までお読みいただき、ありがとうございました。

コメント

タイトルとURLをコピーしました