今回は .NET Framework で動作する UI フレームワーク、WPF 4 のお話です。

WPF では大量のアイテムを表示するために ItemsControl というコントロールが用いられますが、多くのアイテムを描画するには時間がかかる上、パフォーマンスの悪化にもつながります。そこで WPF ではアイテムを高速かつ省メモリで描画するために、UI 仮想化という機能が用意されており、ItemsControl の派生コントロールである ListBox などではこれが既定でオンになっています。やったぜ!

というわけにもいかず……本題はここからです。
ListBox ではアイテムをスクロールするときに、アイテム単位でのスクロールが行われます。アイテム単位でのスクロールでは、ボックスからはみ出てしまった文字を読むことができないほか、アイテムによって高さが違う場合、スクロールしたときに読みにくくなってしまいます。

アイテム単位スクロール
アイテム単位スクロール

ピクセル単位スクロール
ピクセル単位スクロール

各アイテムが同じ高さである場合はアイテム単位スクロールでも使いにくくなることはありませんが、Twitter クライアントのようにアイテムごとの高さが違う場合は、これだとイマイチです。

WPF 4 では次のようにしてスクロール単位を切り替えることができます。

<!-- アイテム単位スクロール -->
<ListBox ScrollViewer.CanContentScroll="True" />
<!-- ピクセル単位スクロール -->
<ListBox ScrollViewer.CanContentScroll="False" />

しかしながら、この方法でスクロール単位を切り替えると、UI 仮想化が無効に設定されるため、アプリケーションのパフォーマンスが悪化します。
参考:コントロールのパフォーマンスを最適化する - WPF .NET Framework | Microsoft Learn

WPF 4.5 ではこれを解決するために、VirtualizingPanel に設定できる添付プロパティが追加されました。

<!-- ScrollViewer.CanContentScroll が True のままでも仮想化される -->
<ListBox ScrollViewer.CanContentScroll="True"
         VirtualizingPanel.ScrollUnit="Pixel" />

しかしこれが使えるのは .NET Framework 4.5 以降……。そこでこの記事では WPF 4 からこいつを使ってやろう、っていうわけです!(前置きが長い)

コード

まあいろいろ言う前にまずはコードを。C# 側がこちら。

using System;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;

public class PixelBasedScrollBehavior
{
    #region IsEnabled 添付プロパティ

    public static bool GetIsEnabled(DependencyObject obj)
    {
        return (bool)obj.GetValue(IsEnabledProperty);
    }

    public static void SetIsEnabled(DependencyObject obj, bool value)
    {
        obj.SetValue(IsEnabledProperty, value);
    }

    public static readonly DependencyProperty IsEnabledProperty =
        DependencyProperty.RegisterAttached("IsEnabled", typeof(bool), typeof(PixelBasedScrollBehavior), new PropertyMetadata(false, IsEnabled_Changed));

    #endregion

    private static void IsEnabled_Changed(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var itemsControl = sender as ItemsControl;
        var panel = sender as VirtualizingStackPanel;
        bool enabled = (bool)e.NewValue;

        try
        {
            if (itemsControl != null)
            {
                SetScrollUnitProperty(itemsControl, enabled);
            }
            if (panel != null)
            {
                SetScrollUnitProperty(panel, enabled);
                SetIsPixelBasedProperty(panel, enabled);
            }
        }
        catch { }
    }

    private static void SetScrollUnitProperty(DependencyObject sender, bool enabled)
    {
        var fieldInfo = typeof(VirtualizingPanel).GetField("ScrollUnitProperty", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
        if (fieldInfo == null)
        {
            return;
        }
        var property = (DependencyProperty)fieldInfo.GetValue(null);

        var type = typeof(Window).Assembly.GetType("System.Windows.Controls.ScrollUnit");
        if (type == null)
        {
            return;
        }
        var scrollUnitEnum = Enum.Parse(type, enabled ? "Pixel" : "Item");
        sender.SetValue(property, scrollUnitEnum);
    }

    private static void SetIsPixelBasedProperty(VirtualizingStackPanel sender, bool enabled)
    {
        var prop = typeof(VirtualizingStackPanel).GetProperty("IsPixelBased", BindingFlags.NonPublic | BindingFlags.Instance);
        if (prop == null)
        {
            return;
        }
        prop.SetValue(sender, enabled, null);
    }
}

XAML では次のように指定します。

<ListBox behaviors:PixelBasedScrollBehavior.IsEnabled="True">
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel behaviors:PixelBasedScrollBehavior.IsEnabled="True" />
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
</ListBox>

XAML 側の XML 名前空間は適切に設定してください。ここでは behaviors: としましたが自由に指定できます。

仕組み

実はこの機能、WPF 4 から使用することもできるものの、それにアクセスする手段は用意されていません。そこでリフレクションを使用して、WPF で内部的に設定されている値をコード側から設定し直し、ピクセル単位のスクロールと UI 仮想化を両立させています。

コードではコントロールごとに次のプロパティを変更しています。

  • System.Windows.Controls.ItemsControl
    • System.Windows.Controls.VirtualizingPanel.ScrollUnitProperty 依存関係プロパティ
  • System.Windows.Controls.VirtualizingStackPanel
    • System.Windows.Controls.VirtualizingPanel.ScrollUnitProperty 依存関係プロパティ
    • System.Windows.Controls.VirtualizingStackPanel.IsPixelBased プロパティ

誰得なの

分かりません。対応環境ならさっさと .NET 4.5 にアップデートした方がいいです……。(完)