.NET MAUI の ListView でツリー表示

MAUI の CollectionView を使ってツリーを表示するために、 SelectedIndex を取得する添付ビヘイビアを作ったりして頑張ったが、ListView を使えばそんな努力不要だった。

tnakamura.hatenablog.com

ListView には SelectedItemIndex プロパティがあるので、これを普通に使えば済む。

using System.Collections.ObjectModel;
using CommunityToolkit.Maui.Markup;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.Logging;

namespace TreeSample;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseMauiCommunityToolkitMarkup()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

public class App : Application
{
    public App()
    {
        MainPage = new MainPage();
    }
}


public class MainPage : ContentPage
{
    private readonly MainViewModel _viewModel;

    public MainPage()
    {
        BindingContext = _viewModel = new MainViewModel();

        // サンプルデータ
        for (var i = 0; i < 5; i++)
        {
            var node = new TreeNode
            {
                Level = 0,
                Name = $"{i}",
            };
            _viewModel.TreeNodes.Add(node);

            for (var j = 0; j < 5; j++)
            {
                var subNode = new TreeNode
                {
                    Level = 1,
                    Name = $"{i}.{j}",
                };
                node.Children.Add(subNode);

                for (var k = 0; k < 5; k++)
                {
                    var subSubNode = new TreeNode
                    {
                        Level = 2,
                        Name = $"{i}.{j}.{k}",
                    };
                    subNode.Children.Add(subSubNode);
                }
            }
        }

        Content = new ListView
        {
            SelectionMode = ListViewSelectionMode.Single,
            ItemsSource = _viewModel.TreeNodes,
            ItemTemplate = new DataTemplate(() =>
            {
                return new ViewCell
                {
                    View = new HorizontalStackLayout
                    {
                        new Label()
                            .Bind(
                                Label.TextProperty,
                                path: nameof(TreeNode.IsOpened),
                                convert:(bool x)=> x ? "-" : "+")
                            .Bind(
                                Label.IsVisibleProperty,
                                path: nameof(TreeNode.IsLeaf),
                                convert: (bool x) => !x),
                        new Label()
                            .Bind(
                                Label.TextProperty,
                                path: nameof(TreeNode.Name)),
                    }
                    .Bind(
                        HorizontalStackLayout.PaddingProperty,
                        path: nameof(TreeNode.Level),
                        // ネストが深いほど左のマージンを増やす
                        convert: (int x) => new Thickness(left: 10 + 10 * x, right: 10, top: 10, bottom: 10)),
                };
            }),
        }
        .Invoke(x => x.ItemSelected += (sender, e) =>
        {
            _viewModel.Select(e.SelectedItemIndex);
            if (sender is ListView listView)
            {
                listView.SelectedItem = null;
            }
        });
    }
}

public partial class MainViewModel : ObservableObject
{
    public ObservableCollection<TreeNode> TreeNodes { get; } = new();

    public void Select(int index)
    {
        if (index < 0 || TreeNodes.Count <= index)
        {
            return;
        }

        var node = TreeNodes[index];
        if (node.IsOpened)
        {
            // 選択したアイテムより後かつ、
            // 自分より深いノードを削除することで、
            // 閉じたように振舞う。
            var i = index + 1;
            while (i < TreeNodes.Count)
            {
                var subNode = TreeNodes[i];
                if (subNode.Level > node.Level)
                {
                    subNode.IsOpened = false;
                    TreeNodes.RemoveAt(i);
                }
                else
                {
                    break;
                }
            }
            node.IsOpened = false;
        }
        else
        {
            // 自分の後ろにノードを挿入することで
            // 開いたように振舞う。
            for (var i = 0; i < node.Children.Count; i++)
            {
                var insertIndex = index + 1 + i;
                TreeNodes.Insert(insertIndex, node.Children[i]);
            }
            node.IsOpened = true;
        }
    }
}

public partial class TreeNode : ObservableObject
{
    [ObservableProperty]
    private int _level;

    [ObservableProperty]
    private bool _isOpened;

    [ObservableProperty]
    private string? _name;

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

    public bool IsLeaf =>
        Children.Count == 0;
}

どうしても CollectionView が必要な場面でもない限りは、これでいいや。随分と廻り道をしてしまったな。