CollectionView の SelectedIndex を取得するビヘイビア

.NET MAUI の CollectionView には SelectedItem プロパティはあるけど SelectedIndex プロパティは無い。ItemsSource にセットしているコレクションから SelectedItem と同じインスタンスを検索すれば取得はできるけど、O(N) なので件数が増えるとツラくなりそう。

何か良い方法は無いものかと考えていたら、たまたま Scrolled イベントが目に入った。Scrolled イベントのイベント引数は、表示している範囲の開始インデックスと終了インデックスを保持しているみたい。

閃いた。Scrolled イベントが発生したら表示範囲の開始インデックスと終了インデックスを添付プロパティで保持しておいて、SelectionChanged イベントが発生したときにその範囲内を検索すればいいかも。コレクションのサイズは数千件を超えても、表示できるのはたかたが数十件。O(1)と見なせなくもない。

ビヘイビアとして実装してみた。

using Microsoft.Extensions.Logging;

namespace HelloMaui;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .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();
    }
}

record Item(string Name);

public partial class MainPage : ContentPage
{
    private readonly CollectionView _collectionView;

    public MainPage() : base()
    {
        var items = new List<Item>();
        for (var i = 0; i < 200; i++)
        {
            items.Add(new Item($"Item{i}"));
        }

        Content = _collectionView = new CollectionView
        {
            SelectionMode = SelectionMode.Single,
            Behaviors =
            {
                new CollectionViewSelectionBehavior(),
            },
            ItemTemplate = new DataTemplate(() =>
            {
                var label = new Label();
                label.SetBinding(Label.TextProperty, nameof(Item.Name));
                return label;
            }),
            ItemsSource = items,
        };
        _collectionView.SelectionChanged += HandleSelectionChanged;
    }

    private async void HandleSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var view = (CollectionView)sender;
        var selectedItem = e.CurrentSelection.FirstOrDefault() as Item;
        var selectedIndex = CollectionViewSelectionBehavior.GetSelectedIndex(view);
        var firstVisibleItemIndex = CollectionViewSelectionBehavior.GetFirstVisibleItemIndex(view);
        var lastVisibleItemIndex = CollectionViewSelectionBehavior.GetLastVisibleItemIndex(view);
        await DisplayAlert(
            title: $"Name: {selectedItem?.Name}",
            message: @$"SelectedIndex: {selectedIndex}
 FirstVisibleItemIndex: {firstVisibleItemIndex}
 LastVisibleItemIndex: {lastVisibleItemIndex}",
            cancel: "閉じる");
    }
}

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);
    }
}

このサンプルでは期待通りに動いた。CollectionView でツリーを実装する際に活かせそう。

tnakamura.hatenablog.com