【WPF】カスタムGridLineDecoratorでListViewにグリッド線を描く

【WPF】カスタムGridLineDecoratorでListViewにグリッド線を描く

WPFのListViewを使用する際に、グリッド線の効果を出す方法をよく質問されることがあります。

最終更新 2024/02/04 5:21
大佛脚下
読了目安 7 分
カテゴリ
WPF
タグ
.NET WPF ListView

rgqancy から指摘されたバグに感謝します。修正済みです。

まずは効果図:

img

使用時のコード:

<l:GridLineDecorator>
    <ListView ItemsSource="{Binding}">
        <ListView.View>
            <GridView>
                <GridViewColumn Header="Id" DisplayMemberBinding="{Binding Id}"/>
                <GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}"/>
            </GridView>
        </ListView.View>
    </ListView>
</l:GridLineDecorator>

------------------------本文-------------------------------

WPF の ListView を使用する際に、グリッド線を表示したいという質問をよく見かけます。例えば http://www.bbniu.com/forum/thread-1090-1-1.html。

この問題に対する最初の解決策として、GridViewColumn の CellTemplate に Border を配置し、その Border の BorderBrush と BorderThickness を設定することが考えられます。

<GridViewColumn.CellTemplate>
    <DataTemplate>
        <Border BorderBrush="LightGray" BorderThickness="1" UseLayoutRounding="True">
            <TextBlock Text="{Binding Id}"/>
        </Border>
    </DataTemplate>
</GridViewColumn.CellTemplate>

しかし、すぐに気づくのは、Border が列幅の変化に追従しないことです。下図のようになります:

img

また、ListView の HorizontalContentAlignment を Stretch に設定しても効果はありません。ListViewItem に HorizontalContentAlignment="True" を設定する必要があります。そのため、ListViewItem のスタイルを追加して統一指定しなければなりません:

<Style TargetType="ListViewItem">
    <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>

それでも問題は解決しません。Border がセル全体を埋めないからです。下図のようになります:

img

そこで、各 Border の Margin を細かく調整して、それらが「ちょうど」すべて繋がって連続した線に見えるようにしなければなりません。Margin 調整だけでは足りず、ListViewItem のテンプレートを変更する必要もあるかもしれません。テンプレートを修正したら、今度は多数の Border を作成することでパフォーマンスが低下します。さらに厄介なのは、各カラムごとに CellTemplate を指定しなければならず、もしある日、線の色を一括で調整したいとなった場合……。そのため、この方法は確かに可能ではありますが、実際には非常に面倒です。

ListView に直接「線を描く」方法はないのでしょうか?もちろん、独自の ListView を作成し、OnRender 内で線を描くことも可能ですが、理想的なのは既存のコントロールを一切変更せずにグリッド描画機能を実現することです。同時に、グリッド線の色を自由に調整できるとなお良いでしょう。

したがって、全体の要件は次のとおりです。

  1. グリッドを描画できること

  2. ListView を変更したり、独自の ListView を作成する必要がないこと

  3. グリッド線の色を調整できること

デザインパターンに詳しい方なら、「既存のコードを変更せずに新機能を追加する」という場合、すぐにデコレータパターンを思い浮かべるでしょう。実際、WPF には Decorator コントロールが存在し、よく使われる Border も Decorator の一種であり、コントロールに背景色や枠線を描画するのに役立ちます。

したがって、そのような Decorator があって、ListView をその中に配置するだけで線を描画できるなら、それは素晴らしいでしょう?ただし、ここでは Decorator を直接継承して修正するつもりはありません。WPF が提供する Decorator はすべての UIElement を対象としているのに対し、今回は ListView に特化したいからです。

GridLineDecorator は FrameworkElement を直接継承し、VisualChild および LogicalChild 関連のコードをオーバーライドして、ラップした ListView を表示します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Threading;

namespace ListViewWithLines
{
    [ContentProperty("Target")]
    public class GridLineDecorator : FrameworkElement
    {
        private ListView _target;
        private DrawingVisual _gridLinesVisual = new DrawingVisual();
        private GridViewHeaderRowPresenter _headerRowPresenter = null;

        public GridLineDecorator()
        {
            this.AddVisualChild(_gridLinesVisual);
            this.AddHandler(ScrollViewer.ScrollChangedEvent, new RoutedEventHandler(OnScrollChanged));
        }

        #region GridLineBrush

        /// <summary>
        /// GridLineBrush 依存関係プロパティ
        /// </summary>
        public static readonly DependencyProperty GridLineBrushProperty =
            DependencyProperty.Register("GridLineBrush", typeof(Brush), typeof(GridLineDecorator),
                new FrameworkPropertyMetadata(Brushes.LightGray,
                    new PropertyChangedCallback(OnGridLineBrushChanged)));

        /// <summary>
        /// GridLineBrush プロパティを取得または設定します。この依存関係プロパティは ... を示します。
        /// </summary>
        public Brush GridLineBrush
        {
            get { return (Brush)GetValue(GridLineBrushProperty); }
            set { SetValue(GridLineBrushProperty, value); }
        }

        /// <summary>
        /// GridLineBrush プロパティの変更を処理します。
        /// </summary>
        private static void OnGridLineBrushChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            ((GridLineDecorator)d).OnGridLineBrushChanged(e);
        }

        /// <summary>
        /// 派生クラスが GridLineBrush プロパティの変更を処理する機会を提供します。
        /// </summary>
        protected virtual void OnGridLineBrushChanged(DependencyPropertyChangedEventArgs e)
        {
            DrawGridLines();
        }

        #endregion

        #region Target

        public ListView Target
        {
            get { return _target; }
            set
            {
                if (_target != value)
                {
                    if (_target != null) Detach();
                    RemoveVisualChild(_target);
                    RemoveLogicalChild(_target);

                    _target = value;

                    AddVisualChild(_target);
                    AddLogicalChild(_target);
                    if (_target != null) Attach();

                    InvalidateMeasure();
                }
            }
        }

        private void GetGridViewHeaderPresenter()
        {
            if (Target == null)
            {
                _headerRowPresenter = null;
                return;
            }
            _headerRowPresenter = Target.GetDesendentChild<GridViewHeaderRowPresenter>();
        }

        #endregion

        #region DrawGridLines

        private void DrawGridLines()
        {
            if (Target == null) return;
            if (_headerRowPresenter == null) return;

            var itemCount = Target.Items.Count;
            if (itemCount == 0) return;

            var gridView = Target.View as GridView;
            if (gridView == null) return;

            // drawingContext を取得
            var drawingContext = _gridLinesVisual.RenderOpen();
            var startPoint = new Point(0, 0);
            var totalHeight = 0.0;

            // ピクセルへのアライメントのための計算パラメータ。これがないと一部の線がぼやけて見える
            var dpiFactor = this.GetDpiFactor();
            var pen = new Pen(this.GridLineBrush, 1 * dpiFactor);
            var halfPenWidth = pen.Thickness / 2;
            var guidelines = new GuidelineSet();

            // 横線を描く
            for (int i = 0; i < itemCount; i++)
            {
                var item = Target.ItemContainerGenerator.ContainerFromIndex(i) as ListViewItem;
                if (item != null)
                {
                    var renderSize = item.RenderSize;
                    var offset = item.TranslatePoint(startPoint, this);

                    var hLineX1 = offset.X;
                    var hLineX2 = offset.X + renderSize.Width;
                    var hLineY = offset.Y + renderSize.Height;

                    // ガイドラインを追加してピクセルに合わせる
                    guidelines.GuidelinesY.Add(hLineY + halfPenWidth);
                    drawingContext.PushGuidelineSet(guidelines);
                    drawingContext.DrawLine(pen, new Point(hLineX1, hLineY), new Point(hLineX2, hLineY));
                    drawingContext.Pop();

                    // 縦線の総高さを計算
                    totalHeight += renderSize.Height;
                }
            }

            // 縦線を描く
            var columns = gridView.Columns;
            var headerOffset = _headerRowPresenter.TranslatePoint(startPoint, this);
            var headerSize = _headerRowPresenter.RenderSize;

            var vLineX = headerOffset.X;
            var vLineY1 = headerOffset.Y + headerSize.Height;

            foreach (var column in columns)
            {
                var columnWidth = column.GetColumnWidth();
                vLineX += columnWidth;

                // ガイドラインを追加してピクセルに合わせる
                guidelines.GuidelinesX.Add(vLineX + halfPenWidth);
                drawingContext.PushGuidelineSet(guidelines);
                drawingContext.DrawLine(pen, new Point(vLineX, vLineY1), new Point(vLineX, totalHeight));
                drawingContext.Pop();
            }

            drawingContext.Close();
        }

        #endregion

        #region Target とグリッド線を表示するためのオーバーライド

        protected override int VisualChildrenCount
        {
            get { return Target == null ? 1 : 2; }
        }

        protected override System.Collections.IEnumerator LogicalChildren
        {
            get { yield return Target; }
        }

        protected override Visual GetVisualChild(int index)
        {
            if (index == 0) return _target;
            if (index == 1) return _gridLinesVisual;
            throw new IndexOutOfRangeException(string.Format("ビジュアル子要素のインデックス '{0}' が範囲外です", index));
        }

        protected override Size MeasureOverride(Size availableSize)
        {
            if (Target != null)
            {
                Target.Measure(availableSize);
                return Target.DesiredSize;
            }

            return base.MeasureOverride(availableSize);
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            if (Target != null)
                Target.Arrange(new Rect(new Point(0, 0), finalSize));

            return base.ArrangeOverride(finalSize);
        }

        #endregion

        #region イベント処理

        private void Attach()
        {
            _target.Loaded += OnTargetLoaded;
            _target.Unloaded += OnTargetUnloaded;
        }

        private void Detach()
        {
            _target.Loaded -= OnTargetLoaded;
            _target.Unloaded -= OnTargetUnloaded;
        }

        private void OnTargetLoaded(object sender, RoutedEventArgs e)
        {
            if (_headerRowPresenter == null)
                GetGridViewHeaderPresenter();
            DrawGridLines();
        }

        private void OnTargetUnloaded(object sender, RoutedEventArgs e)
        {
            DrawGridLines();
        }

        private void OnScrollChanged(object sender, RoutedEventArgs e)
        {
            DrawGridLines();
        }

        #endregion
    }
}

ここで、Target は ListView 型のプロパティであり、_gridLinesVisual はグリッド線を描画するための DrawingVisual です。なぜ OnRender メソッドをオーバーライドして線を描かないのか疑問に思う人もいるかもしれません。

その理由は、OnRender メソッドで線を描画すると、ListView に背景が設定されている場合、描いた線が隠れてしまうからです。これは、コントロールの背景がテンプレート内の Border によって描画され、Border も OnRender で描画されるため、後から描画される Border が先に描かれた線を覆ってしまうからです。また、ListView の Column のサイズが変更されても GridLineDecorator が再描画されないため、グリッド線が同期して変更されないこともわかります。

実際、GridLineDecorator 内の GetVisualChild のオーバーライドも非常に重要です。

protected override Visual GetVisualChild(int index)
{
    if (index == 0) return _target;
    if (index == 1) return _gridLinesVisual;
    throw new IndexOutOfRangeException(string.Format("ビジュアル子要素のインデックス '{0}' が範囲外です", index));
}

最初に ListView を返し、次に _gridLinesVisual を返しています。しかし、DrawingVisual を使用しても、Column 幅の変更を通知して再描画するという問題があります。この問題を解決するにはいくつかの方法があります。

  1. GridViewColumn の幅の変化を監視する
  2. CompositionTarget.Rendering イベントを監視する

1 つ目の方法は、GridViewColumn の幅変更イベントが見つからないため、実用的ではありません。2 つ目の方法は可能ですが、効率は……。

研究の末、ついに実用的な方法を見つけました。それは、ScrollViewer の ScrollChanged イベントを監視することです。ListView 内部には 2 つの ScrollViewer が配置されており、1 つはヘッダー表示用、もう 1 つはアイテム表示用です。Column の幅が変更されると、ScrollViewer の ScrollChanged イベントが発生します。

そのため、コンストラクタで次のようにします。

public GridLineDecorator()
{
    this.AddVisualChild(_gridLinesVisual);
    this.AddHandler(ScrollViewer.ScrollChangedEvent, new RoutedEventHandler(OnScrollChanged));
}

線を描画するロジックは主に、すべての Container(実際には ListViewItem)を反復処理し、それらの GridLineDecorator に対する相対位置を計算し、横線と縦線の座標と長さを求め、線を描画します。コードは多いので、ダウンロードしてご自身でご確認ください。

注意深い方は、ListViewItem が完全に表示されない場合に、下部の線が最下端まで描画されないことがあることに気づくかもしれません。これは ListView が仮想化処理を行っているためです。VirtualizingStackPanel.IsVirtualizing="False" を設定することで強制的に描画できます。

コード添付: https://files.cnblogs.com/RMay/ListViewWithLines.zip

サイト管理者注:

原作者の記述は非常に優れており、効果も良好です。データ量が少ない場合(数千件など)は、上記の方法で全く問題ありません。プログラムが数十万件のデータ(ページング受信)を扱う必要がある場合、デコレータ方式の効率はあまり良くありません(最適化方法を検討する余地あり)。以下のコードで簡単に水平線を追加できます。

<ListView.ItemContainerStyle>
    <Style TargetType="{x:Type ListViewItem}">
        <Setter Property="BorderThickness" Value="0 0 1 1" />
        <Setter Property="BorderBrush" Value="Black" />
    </Style>
</ListView.ItemContainerStyle>

img

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2025/09/13

WPF から Avalonia への移行シリーズ:なぜ WPF プログラムを Avalonia に移行しなければならないのか

過去数年間、当社の上位機ソフトウェアは主に WPF と WinForm で開発されてきました。これらの技術は Windows プラットフォームで非常に便利であり、小規模試作から現在の規模拡大による納品まで、私たちを支えてきました。しかし、ビジネスの発展や顧客ニーズの変化に伴い、単一の Windows テクノロジースタックは私たちが必ず乗り越えなければならない壁となってきました。

続きを読む
同じカテゴリ / 同じタグ 2025/01/26

WPF カスタムXMLファイルによる国際化

この記事では、WPFプログラムでカスタムXMLファイルを使用して国際化を実現する方法について詳しく説明します。必要なNuGetパッケージのインストール、言語リストの動的取得、言語の動的切り替え、コードおよびXAMLインターフェースでの翻訳文字列の使用などを含み、ソースコードのリンクも提供し、開発者がWPFアプリケーションの国際化を簡単に実装できるように支援します。

続きを読む