MAUI の CollectionView でツリー表示する方法(完成版)

以前、MAUI の CollectionView でアイテムをツリー表示してみた。

tnakamura.hatenablog.com

これは期待通りに動いた。ただ、ツリーのノードを開閉するときに選択されたアイテムのインデックスが必要で、インデックスを取得する方法が O(N) なのが気がかりだった。

しばらくして、CollectionView で選択されたアイテムのインデックスを多少効率良く取得する方法を思いついた。

tnakamura.hatenablog.com

画面に表示できるアイテムの数はたかだか数十個なので、O(1) と見なせなくもない。

これらを組み合わせて、CollectionView でアイテムをツリー表示する方法が完成した。

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

namespace HelloMaui;

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 partial class App : Application
{
    public App() : base()
    {
        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,
            Behaviors =
            {
                new CollectionViewSelectionBehavior(),
            },
            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));
            }),
        }
        .Invoke(x => x.SelectionChanged += HandleSelectionChanged);
    }

    private void HandleSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var view = (CollectionView)sender;
        if (e.CurrentSelection.FirstOrDefault() is TreeNode node)
        {
            var selectedIndex = CollectionViewSelectionBehavior.GetSelectedIndex(view);
            _viewModel.Select(node, selectedIndex);
        }
    }
}

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

    public void Select(TreeNode node, int? selectedIndex)
    {
        if (node.IsLeaf)
            return;

        var index = selectedIndex ?? 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;
}

public class CollectionViewSelectionBehavior : Behavior<CollectionView>
{
    // 表示範囲の最初のインデックス
    public static readonly BindableProperty FirstVisibleItemIndexProperty =
        BindableProperty.CreateAttached(
            "FirstVisibleItemIndex",
            typeof(int?),
            typeof(CollectionViewSelectionBehavior),
            defaultValue: null);

    public static void SetFirstVisibleItemIndex(CollectionView view, int? value) =>
        view.SetValue(FirstVisibleItemIndexProperty, value);

    public static int? GetFirstVisibleItemIndex(CollectionView view) =>
        (int?)view.GetValue(FirstVisibleItemIndexProperty);

    // 表示範囲の最後のインデックス
    public static readonly BindableProperty LastVisibleItemIndexProperty =
        BindableProperty.CreateAttached(
            "LastVisibleItemIndex",
            typeof(int?),
            typeof(CollectionViewSelectionBehavior),
            defaultValue: null);

    public static void SetLastVisibleItemIndex(CollectionView view, int? value) =>
        view.SetValue(LastVisibleItemIndexProperty, value);

    public static int? GetLastVisibleItemIndex(CollectionView view) =>
        (int?)view.GetValue(LastVisibleItemIndexProperty);

    public static readonly BindableProperty SelectedIndexProperty =
        BindableProperty.CreateAttached(
            "SelectedIndex",
            typeof(int?),
            typeof(CollectionViewSelectionBehavior),
            defaultValue: null);

    public static void SetSelectedIndex(CollectionView view, int? value) =>
        view.SetValue(SelectedIndexProperty, value);

    public static int? GetSelectedIndex(CollectionView view) =>
        (int?)view.GetValue(SelectedIndexProperty);

    protected override void OnAttachedTo(CollectionView bindable)
    {
        bindable.Scrolled += HandleScrolled;
        bindable.SelectionChanged += HandleSelectionChanged;
        base.OnAttachedTo(bindable);
    }

    protected override void OnDetachingFrom(CollectionView bindable)
    {
        bindable.Scrolled -= HandleScrolled;
        bindable.SelectionChanged -= HandleSelectionChanged;
        base.OnDetachingFrom(bindable);
    }

    // スクロールが発生したら表示範囲の最初と最後のインデックスを保存
    private void HandleScrolled(object sender, ItemsViewScrolledEventArgs e)
    {
        var view = (CollectionView)sender;
        SetFirstVisibleItemIndex(view, e.FirstVisibleItemIndex);
        SetLastVisibleItemIndex(view, e.LastVisibleItemIndex);
    }

    // 選択されているアイテムが変わったらインデックスを検索して保存
    private void HandleSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var selectedItem = e.CurrentSelection.FirstOrDefault();
        var view = (CollectionView)sender;
        if (selectedItem is not null && view.ItemsSource is not null)
        {
            if (view.ItemsSource is System.Collections.IList list)
            {
                var firstIndex = GetFirstVisibleItemIndex(view) ?? 0;
                var lastIndex = GetLastVisibleItemIndex(view) ?? (list.Count - 1);
                for (var i = firstIndex; i <= lastIndex; i++)
                {
                    var item = list[i];
                    if (selectedItem.Equals(item))
                    {
                        SetSelectedIndex(view, i);
                        return;
                    }
                }
            }
            else
            {
                var i = 0;
                foreach (var item in view.ItemsSource)
                {
                    if (selectedItem.Equals(item))
                    {
                        SetSelectedIndex(view, i);
                        return;
                    }
                    i++;
                }
            }
        }
        SetSelectedIndex(view, null);
    }
}