以前、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);
}
}