【WPF】ページングできるListBox

C#

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

WPFを使ったアプリケーションを開発する上で、
快適な動作速度の維持はかかせません。

そこで障害となる最たるものは、
「大量のデータを扱わねばならない時」
でしょうか。

WPFで複数のデータを扱うクラスと言えば、
ItemsControl
もしくはそれを継承した
ListBoxListView,DataGridとなります。

これらのコントロールは、
UIの仮想化を行うことで
大抵の場合は複数のデータが存在しても
動作速度を維持できます。

しかし、UIの仮想化が行えない
ケースもあります。

たとえば、要素のUIに
Expanderが含まれているなどして、
1要素あたりの高さが可変なケースです。

この場合、下手にUI仮想化をすると、
動作速度は良くても、
Expanderの展開状況が変など
そもそも動作自体がおかしくなってしまう
可能性があります。

仮想化パネルを自作できれば、
この問題も解決できるのですが、
WPFに対する深い理解が必要となり
やや難易度が高めです。

そこで、Googleの検索結果のように、
一度に表示する行数を
制限したListBoxというものを作ってみました。

本記事ではPagingListBoxと名付けた、
UIコントロールの動作イメージと、
コードの内容について解説します。

スポンサーリンク

動作イメージ

まずは動作イメージです。

とりあえず要素数1000のコレクションを
作ってBindingします。

要素数1000のコレクションをBinding
したはずですが、
以下のように明らかにスクロールバーが短いです。

一番下までスクロールすると、
「もっと表示する」
ボタンがあり、押すと次の要素が読み込まれて
UIに表示されるようになります。
(gifアニメです)

また、ホイールによるスクロールで
最下部に到達したときも、
次の要素が読み込まれるようにしています。
(やや見づらいですが再びgifアニメです)

このように、逐次的に
UIに要素が表示されるようになるのが
このコントロールの特徴です。

では、次にコードを見てみましょう。

コード

PagingListBoxはカスタムコントロールとしているため、
xamlのコードとC#のコードに分かれています。

まずはxamlの方から見てみましょう。

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:PagingListBoxSample">
    <SolidColorBrush x:Key="ListBox.Static.Background" Color="#FFFFFFFF" />
    <SolidColorBrush x:Key="ListBox.Static.Border" Color="#FFABADB3" />
    <SolidColorBrush x:Key="ListBox.Disabled.Background" Color="#FFFFFFFF" />
    <SolidColorBrush x:Key="ListBox.Disabled.Border" Color="#FFD9D9D9" />
    <Style TargetType="{x:Type local:PagingListBox}">
        <Setter Property="Background" Value="{StaticResource ListBox.Static.Background}" />
        <Setter Property="BorderBrush" Value="{StaticResource ListBox.Static.Border}" />
        <Setter Property="BorderThickness" Value="1" />
        <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" />
        <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto" />
        <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
        <Setter Property="ScrollViewer.CanContentScroll" Value="true" />
        <Setter Property="ScrollViewer.PanningMode" Value="Both" />
        <Setter Property="Stylus.IsFlicksEnabled" Value="False" />
        <Setter Property="VerticalContentAlignment" Value="Center" />
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:PagingListBox}">
                    <Border
                        x:Name="Bd"
                        Padding="1"
                        Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}"
                        SnapsToDevicePixels="true">
                        <ScrollViewer
                            x:Name="PART_ScrollViewer"
                            Padding="{TemplateBinding Padding}"
                            Focusable="false">
                            <DockPanel>
                                <Button
                                    x:Name="PART_MoreButton"
                                    Content="もっと表示する"
                                    DockPanel.Dock="Bottom" />
                                <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
                            </DockPanel>
                        </ScrollViewer>
                    </Border>
                    <ControlTemplate.Triggers>
                        <Trigger Property="IsEnabled" Value="false">
                            <Setter TargetName="Bd" Property="Background" Value="{StaticResource ListBox.Disabled.Background}" />
                            <Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource ListBox.Disabled.Border}" />
                        </Trigger>
                        <MultiTrigger>
                            <MultiTrigger.Conditions>
                                <Condition Property="IsGrouping" Value="true" />
                                <Condition Property="VirtualizingPanel.IsVirtualizingWhenGrouping" Value="false" />
                            </MultiTrigger.Conditions>
                            <Setter Property="ScrollViewer.CanContentScroll" Value="false" />
                        </MultiTrigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

大半は、
ListBoxのStyleとTemplateの実装を利用し、
少しだけ改造しています。

【WPF】Templateを標準の実装からちょっとだけ変更したい

にて、
テンプレートの実装をコピーする方法を紹介しているので、
そちらもご覧ください。

さて、少しだけ改造と述べた通り
大きな変更点はなく、
ScrollViewerの中に
DockPanelとButtonが入っているのみです。

<ScrollViewer
    x:Name="PART_ScrollViewer"
    Padding="{TemplateBinding Padding}"
    Focusable="false">
    <DockPanel>
        <Button
            x:Name="PART_MoreButton"
            Content="もっと表示する"
            DockPanel.Dock="Bottom" />
        <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
    </DockPanel>
</ScrollViewer>

ここでは、Button.Contentに
日本語を決め打ちで入れていますが、
外部に公開してTemplateBindingにするのも良いと思います。

次に、C#側のコードです。
長いので閉じています。

using System;
using System.Collections;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace PagingListBoxSample
{
    [TemplatePart(Name = PartScrollViewer)]
    [TemplatePart(Name = PartMoreButton)]
    public class PagingListBox : ListBox
    {
        private const string PartScrollViewer = "PART_ScrollViewer";
        private const string PartMoreButton = "PART_MoreButton";

        static PagingListBox()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(PagingListBox), new FrameworkPropertyMetadata(typeof(PagingListBox)));
        }



        public new IEnumerable ItemsSource
        {
            get { return (IEnumerable)GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        // Using a DependencyProperty as the backing store for ItemsSource.  This enables animation, styling, binding, etc...
        public new static readonly DependencyProperty ItemsSourceProperty =
            DependencyProperty.Register(nameof(ItemsSource), typeof(IEnumerable), typeof(PagingListBox), new PropertyMetadata(null, (d, e) =>
            {
                if (!(d is PagingListBox plv)) { return; }
                if (!(e.NewValue is IEnumerable enu)) { return; }

                plv.InitializeInnerCollection(enu);

                // INotifyCollectionChangedだったら増減を監視する必要あり
            }));


        /// <summary>
        /// 最初から表示されている要素数
        /// </summary>
        public int DefaultDisplayNum
        {
            get { return (int)GetValue(DefaultDisplayNumProperty); }
            set { SetValue(DefaultDisplayNumProperty, value); }
        }

        // Using a DependencyProperty as the backing store for DefaultDisplayNum.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty DefaultDisplayNumProperty =
            DependencyProperty.Register("DefaultDisplayNum", typeof(int), typeof(PagingListBox), new PropertyMetadata(20, (d, e) =>
            {
                if (!(d is PagingListBox plv)) { return; }
                if (!(e.NewValue is int defaultDisplayNum)) { return; }

                plv.currentDisplayNum = defaultDisplayNum;

                plv.InitializeInnerCollection(plv.ItemsSource);
            }));


        /// <summary>
        /// 新しいページを表示したときに追加される要素数
        /// </summary>
        public int DisplayNumOnPage
        {
            get { return (int)GetValue(PageCountProperty); }
            set { SetValue(PageCountProperty, value); }
        }

        // Using a DependencyProperty as the backing store for AddCount.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty PageCountProperty =
            DependencyProperty.Register(nameof(DisplayNumOnPage), typeof(int), typeof(PagingListBox), new PropertyMetadata(10));

        /// <summary>
        /// Bindingしたコレクションの要素が全て表示された状態かどうか
        /// </summary>
        public bool IsDisplayAll
        {
            get { return (bool)GetValue(IsDisplayAllProperty); }
            private set { SetValue(IsDisplayAllPropertyKey, value); }
        }

        private static readonly DependencyPropertyKey IsDisplayAllPropertyKey =
            DependencyProperty.RegisterReadOnly(nameof(IsDisplayAll), typeof(bool), typeof(PagingListBox), new PropertyMetadata(false));
        public static readonly DependencyProperty IsDisplayAllProperty = IsDisplayAllPropertyKey.DependencyProperty;

        private readonly ObservableCollection<object> innerCollection
            = new ObservableCollection<object>();
        private int currentDisplayNum;
        private int totalCount;

        private ScrollViewer partScrollViewer;
        private Button partMoreButton;

        public PagingListBox()
        {
            currentDisplayNum = DefaultDisplayNum;
            base.ItemsSource = innerCollection;
        }

        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();

            partScrollViewer = GetTemplateChild(PartScrollViewer) as ScrollViewer;

            if (partMoreButton != null)
            {
                partMoreButton.Click -= OnPartMoreButtonClick;
            }

            partMoreButton = GetTemplateChild(PartMoreButton) as Button;
            partMoreButton.Click += OnPartMoreButtonClick;
            UpdateState();
        }

        private void OnPartMoreButtonClick(object sender, RoutedEventArgs e)
        {
            TryAddNewPage();
        }

        protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)
        {
            base.OnPreviewMouseWheel(e);

            if (e.Delta <= 0
                && partScrollViewer.VerticalOffset >= partScrollViewer.ScrollableHeight)
            {
                if (TryAddNewPage())
                {
                    e.Handled = true;
                }
            }
        }

        private bool TryAddNewPage()
        {
            // 現在の表示数がオリジナルのコレクションの合計数に到達している場合は何もしない
            if (IsDisplayAll)
            {
                return false;
            }

            var enumerable = ItemsSource
                .OfType<object>()
                .Skip(currentDisplayNum);

            var count = 1;
            foreach (var item in enumerable)
            {
                innerCollection.Add(item);
                if (count++ >= DisplayNumOnPage)
                {
                    break;
                }
            }

            currentDisplayNum = Math.Min(currentDisplayNum + DisplayNumOnPage, totalCount);
            UpdateState();
            return true;
        }

        private void InitializeInnerCollection(IEnumerable enu)
        {
            var enumerable = enu.OfType<object>();
            totalCount = enumerable.Count();
            innerCollection.Clear();
            var count = 1;
            foreach (var item in enumerable)
            {
                innerCollection.Add(item);
                if (count++ >= currentDisplayNum)
                {
                    break;
                }
            }
            UpdateState();
        }

        private void UpdateState()
        {
            IsDisplayAll = currentDisplayNum >= totalCount;

            if (partMoreButton == null)
            {
                return;
            }
            partMoreButton.Visibility = IsDisplayAll
                ? Visibility.Collapsed
                : Visibility.Visible;
        }
    }
}

できるだけ標準のListBoxと
同じ使い勝手になるように
あえて「ItemsSource」プロパティをnew宣言して、
ItemsControl.ItemsSourceプロパティを隠しています。

そして、ItemsSourceプロパティに
値が入ったタイミングで、
内部用のObservableCollectionに対して、
既定の表示数だけが表示されるように
要素を追加します。

private void InitializeInnerCollection(IEnumerable enu)
{
    var enumerable = enu.OfType<object>();
    totalCount = enumerable.Count();
    innerCollection.Clear();
    var count = 1;
    foreach (var item in enumerable)
    {
        innerCollection.Add(item);
        if (count++ >= currentDisplayNum)
        {
            break;
        }
    }
    UpdateState();
}

今回の実装では、一度追加表示された数は覚えていて、
新たにItemsSourceプロパティに値が入った時は、
追加表示した分だけ表示するようにしていますが、
ここはオプションなどでリセットするように
してもいいかもしれません。

PreviewMouseWheelイベントを監視し、
次の要素を表示する処理が入った時は、
スクロールイベントのハンドリングを終了して、
新規要素表示とスクロール挙動が
同時に走ることが無いようにしています。

protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)
{
    base.OnPreviewMouseWheel(e);

    if (e.Delta <= 0
        && partScrollViewer.VerticalOffset >= partScrollViewer.ScrollableHeight)
    {
        if (TryAddNewPage())
        {
            e.Handled = true;
        }
    }
}

使い方

ListBox継承なので
実際の使い方はとても簡単です。

ListBoxのところを
このコントロールに置き換えるだけでほぼ大丈夫です。

<Window
    x:Class="PagingListBoxSample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:PagingListBoxSample"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <DockPanel>
        <local:PagingListBox ItemsSource="{Binding People}">
            <local:PagingListBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}" />
                </DataTemplate>
            </local:PagingListBox.ItemTemplate>
        </local:PagingListBox>
    </DockPanel>
</Window>

今回はListBoxしか作っていませんが、
実装自体はシンプルなので、
ListViewバージョンやDataGridバージョンを
作ってみてもいいかもしれませんね。

まとめ

まとめです。

  • 膨大なデータ数を表示するアプリケーションでは
    動作速度に気を付ける必要がある
  • UIの仮想化を使えば大抵は大丈夫だが
    対応しきれないケースもある
  • 一度に表示する要素数を制限する
    PagingListBoxを実装してみた

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

コメント

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