m13o

2023-05-05 Fri 20:50
MAUIでTreeViewを疑似表現するC# MAUI programming

MAUIが標準で用意しているコントロールにはTreeViewが存在していません。真面目にやるならコントロールを自分で作るか、サードパーティのTreeViewコントロールを利用するかですが、簡易的な物ならCollectionViewやListViewを活用できます。この時、階層構造として表現されるデータをViewに表示する時は平坦なデータリストとして扱う事が重要です。

dotnetのコマンドを使ってMAUIテンプレートで新規プロジェクトを作成したら、MainPageにCollectionViewを設定します。

<?xml version="1.0" encoding="utf-8"?>

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MAUdIo.Maui.Views.MainPage">
    <Grid>
        <CollectionView>
        </CollectionView>
    </Grid>
</ContentPage>

そしてCollectionViewのItemTemplateに表示するデータのテンプレートを生成します。

<CollectionView.ItemTemplate>
  <DataTemplate>
    <HorizontalStackLayout>

    </HorizontalStackLayout>
  </DataTemplate>
</CollectionView.ItemTemplate>

ItemTemplateのデータ型として、INotifyPropertyChangedを実装したHierarchicalItemNodeを定義します。

public class HierarchicalItemNode : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private bool _expanded;

    private int _indent;

    private string _text;

    public bool Expanded
    {
        get => _expanded;
        set => SetField(ref _expanded, value);
    }

    public int Indent
    {
        get => _indent;
        set => SetField(ref _indent, value);
    }

    public string Text
    {
        get => _text;
        set => SetField(ref _text, value);
    }

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}

メンバーとして、表示用のText、ノードを開いたかどうかを表すExpanded、そして階層構造における深さをViewに伝えるためのIndentを有しています。なお、INotifyPropertychangedの実装はRiderによる自動生成です。

これをDataTemplateの中でBindします。

<CollectionView.ItemTemplate>
  <DataTemplate>
    <HorizontalStackLayout>
      <Label Text="{Binding Text}"
             FontSize="20"
             VerticalTextAlignment="Center"
             LineBreakMode="NoWrap" />
    </HorizontalStackLayout>
  </DataTemplate>
</CollectionView.ItemTemplate>

Indentが階層構造を表せるようにView側で調整する必要があります。ここでは、HorizontalStackLayoutのPaddingに変換できれば良さそうですが、Indentはint型ですので、直接使用できません。PaddingはThickness型ですので、Indentの深さによって左側のPaddingを広くなるようなコンバータを作ります。

public class IndentToPaddingConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return new Thickness(32 * (int)value, 0, 0, 0);
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return null;
    }
}

逆変換は発生しない想定なので、nullを返すのみにしています。

これをxaml側に適用します。

<ContentPage.Resources>
  <local:IndentToPaddingConverter x:Key="IndentToPadding" />
</ContentPage.Resources>

namespaceがMainPageと異なる場合は、xmlnsでコンバータが属するnamespaceを使えるようにします。今回はMainPageと同じnamespace内という事でlocalを指定します。

このコンバータをHorizontalStackLayoutのPaddingに対して適用します。

<HorizontalStackLayout Padding="{Binding Path=Indent, 
                                Converter={StaticResource IndentToPadding}}">
  <Label Text="{Binding Text}"
         VerticalTextAlignment="Center"
         LineBreakMode="NoWrap" />
</HorizontalStackLayout>

ここで一度MainPageにアイテムを表示してみます。そのために、MainPage用のViewModelを作り、xamlにBindします。

public sealed class MainPage
{

    public MainPage()
    {
        CreateItems();
    }

    private void CreateItems()
    {
        for (var i = 0; i < 10; i++)
        {
            var item = new HierarchicalItemNode($"Item {i}", Items);

            Items.Add(item);
        }   
    }

    public ObservableCollection<HierarchicalItemNode> Items { get; } = new();
}
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:MAUdIo.Maui"
             x:Class="MAUdIo.Maui.Views.MainPage">
  <ContentPage.BindingContext>
    <local:MainPage />
  </ContentPage.BindingContext>
  <CollectionView ItemsSource="{Binding Items}">

  </CollectionView>
</ContentPage>

これで、垂直方向にアイテムを並べるContentViewができました。

階層構造になるデータなので、子のリストを有しているはずです。ここでは、HierarchicalItemNodeに子要素のリストをプロパティとして追加します。

class HierarchicalItemNode
{
    public ObservableCollection<HierarchicalItemNode> Children { get; } = new();
}

また、ツリーが開いているのか閉じているのかを管理すつためのExpandedプロパティに対応した表示もしておかなければなりません。ツリーの開閉各々を表す画像を用意して、コンバーターで変換し表示するようにします。画像はGoogle Fontsのマテリアルアイコンから右向きと下向きの三角形をダウンロードして、Resources/Imagesフォルダの中に保存します。そして、以下のようなコンバーターを作り、xamlにBindします。

public class BoolToExpandedConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return (bool)value ? "arrow_drop_down.png" : "arrow_right.png";
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return null;
    }
}
<ContentPage.Resources>
  <local:BoolToExpandedConverter x:Key="BoolToExpanded" />
</ContentPage.Resources>

<CollectionView.ItemTemplate>
  <DataTemplate x:DataType="local:HierarchicalItemNode">
    <HorizontalStackLayout>
      <ImageButton
          Source="{Binding Expanded,
                  Converter={StaticResource BoolToExpanded}}"
          Command="{Binding ExpandCommand}" />
      <Label Text="{Binding Text}"
             VerticalTextAlignment="Center"
             LineBreakMode="NoWrap" />
    </HorizontalStackLayout>
  </DataTemplate>
</CollectionView.ItemTemplate>

こうする事で、子を持つアイテムを開いた時と閉じた時で、ボタンの画像が変化するようになりました。

開いた時に子の要素を表示し、閉じた時に子の要素を非表示にする処理を記述します。先のxamlには、ImageButtonのCommandとしてExpandCommandというコマンドをバインドしています。このコマンドの処理を実装するにあたり、MainPageのItemsをコンストラクタで受け取り、アイテムの開閉に合わせてHierarchicalItemNode側で追加削除します。

class HierarchicalItemNode
{
    public HierarchicalItemNode(string text, ObservableCollection<HierarchicalItemNode> owner, int indent = 0)
    {
        _text = text;
        _indent = indent;
        Owner = owner;
        ExpandCommand = new Command(() =>
        {
            Expanded = !Expanded;
            if (Expanded)
            {
                Expand();
            }
            else
            {
                Collapse();
            }
        });
    }

    public ICommand ExpandCommand { get; }

    private void Expand()
    {
        Owner.Insert(Owner.IndexOf(this) + 1, Children);
    }

    private void Collapse()
    {
        Collapse(Children);
    }

    private void Collapse(ICollection<HierarchicalItemNode> collection)
    {
        foreach (var item in collection)
        {
            Collapse(item.Children);
            item._expanded = false;
        }

        Owner.Remove(collection);
    }
}

コマンドは1つですが、ボタンの押下時は、Expandedプロパティの状態によって開くと閉じる、どちらかの処理を実行するので、開いた時はExpand()を、閉じた時はCollapse()を実行します。

HierarchicalItemNodeは子要素を持っており、閉じる時は子を再帰的に削除する必要があるので再帰メソッドにしています。

さてこれで擬似的にTreeViewを表現する一通りの実装が終わりましたので、実際に表示するデータを用意して、動作確認しましょう。面倒なので、MainPageのコンストラクタで子を持つHierarchicalItemNodeのリストを構築します。雑に3段ループで生成し、Itemsプロパティに一番上の要素を追加していくだけのものです。

public sealed class MainPage
{
    public MainPage()
    {
        CreateItems();
    }

    private void CreateItems()
    {
        for (var i = 0; i < 10; i++)
        {
            var item = new HierarchicalItemNode($"親 {i}", Items);
            for (var j = 0; j < 10; j++)
            {
                var child = new HierarchicalItemNode($"子 {j}", Items, item.Indent + 1);
                item.Children.Add(child);
                for (var k = 0; k < 10; k++)
                {
                    var grandChild = new HierarchicalItemNode($"孫 {k}", Items, child.Indent + 1);
                    child.Children.Add(grandChild);
                }
            }

            Items.Add(item);
        }   
    }
}

これを実行すると、動くには動きますが、アイテムを開いたり閉じたりする時の動作がもっさりします。これはObservableCollection対する要素の追加と削除をする度にプロパティ変更イベントが発生しているためです。これでは大量のデータを描画する際に問題にしかならないので、ObservableCollectionを拡張し一括で追加削除できるようにします。

public class BulkObservableCollection<T> : ObservableCollection<T>
{
    private bool _suppress;

    public void InsertRange(int index, IEnumerable<T> collection)
    {
        if (collection == null) throw new ArgumentNullException();

        _suppress = true;
        try
        {
            foreach (var item in collection)
            {
                Insert(index, item);
                index++;
            }
        }
        finally
        {
            _suppress = false;
        }

        OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
    }

    public void RemoveRange(IEnumerable<T> collection)
    {
        if (collection == null) throw new ArgumentNullException();

        _suppress = true;
        try
        {
            foreach (var item in collection) Remove(item);
        }
        finally
        {
            _suppress = false;
        }

        OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
    }

このようなクラスを作り、HierarchicalItemNodeのObservableCollectionとなっているMainPageのItemsと、HierarchicalItemNodeのChildrenを置き換えます。

public sealed class MainPage
{
    public BulkObservableCollection<HierarchicalItemNode> Items { get; } = new();
}

public class HierarchicalItemNode
{
    public BulkObservableCollection<HierarchicalItemNode> Children { get; } = new();
}

これで処理速度の面でも一定耐えられるようになりました。大量のデータを表示しなければならない場合は、処理が滞る可能性はありますが、その場合はそもそもTreeViewの表示をやめるか、全うな手段でコントロールを実装するかした方が良いでしょう。