MAUI の CollectionView でツリー表示

MAUI に標準で TreeView が無いので CollectionView で頑張ってみた。iOS だと UITableView でツリーを表現しているアプリあるし、MAUI の CollectionView でも出来るのでは、と。

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 CollectionView
        {
            SelectionMode = SelectionMode.Single,
            ItemsSource = _viewModel.TreeNodes,
            ItemTemplate = new DataTemplate(() =>
            {
                return 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));
            }),
            SelectionChangedCommand = new Command(x =>
            {
                _viewModel.Select(x as TreeNode);
            }),
        }
        .Bind(
            CollectionView.SelectedItemProperty,
            path: nameof(_viewModel.SelectedNode))
        .Bind(
            CollectionView.SelectionChangedCommandParameterProperty,
            path: nameof(_viewModel.SelectedNode));
    }
}

public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private TreeNode _selectedNode = null;

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

    public void Select(TreeNode node)
    {
        if (node.IsLeaf)
            return;

        var index = TreeNodes.IndexOf(node);
        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 がタップしたインデックスを教えてくれたら手っ取り早いんだけど。