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

ちとせ寿司

用事で大村に行ったついでと言ってはなんだけど、ちょっと良い寿司を食べることにした。予約した「ちとせ寿司」は、大村市内でも評判の寿司屋みたいだ。子どもがスシロー好きで、回転寿司にはそれなりに行くけど、回らない寿司に連れていくのは初めてかも。

コースよりは握りを楽しみたかったので、おまかせの15貫にした。中トロ、ズケ、炙りサーモン、ウニetc。どれもクオリティ高かった。ホタルイカは初めてだったけど、柔らかくて思っていた以上に美味。15貫なんてペロリと平らげてしまった。併せて注文したすず音も進んだ。

久しぶりの寿司に舌鼓をうって、程よくお腹も満たされて満足。やはり寿司は良い。福岡市内での食べ歩きでも、たまには寿司を選んでみるかな。

r.gnavi.co.jp

BAR FIZZ

キャナルシティグランドハイアット地下1階にある「BAR FIZZ」へ、アフタヌーンティーを食べに行った。ここは子ども向けのアフタヌーンティーもやっている。初めてのアフタヌーンティー。4月は苺がテーマなのに対し、5〜6月は卵がテーマ。これは苺を食べるために今行くしか無い。早めの結婚記念日のお祝いいう名目にした。まだ2ヶ月以上早いけどね。

ストロベリーアフタヌーンティー「カミング オブ スプリング」は名前の通り苺づくし。ウェルカムドリンクは苺と人参のモクテル。ノンアルコールだけどシュワっとしていて大人な飲み物。

アフタヌーンティーは3段。下の段から順番に食べるのがマナーらしい。しらんけど。遅めの昼食になったので、この量だと足りないんじゃ無いかも思ってた。最初の頃は。

1段目はスコーンと苺のスコーン。自家製苺ジャムとクロテッドクリーム、レモンカードが添えてあった。レモンカードは程よい酸味が爽やかで、普通のスコーンによくあった。苺のスコーンには、苺ジャムとクロテッドクリームをたっぷり塗って食べると美味い。

2段目は、苺と海老のタルタル、苺とトマトと生ハムのサンドウィッチ、国産牛ほほ肉の苺と赤ワイン煮ミニバーガーの3品。ミニバーガーはほほ肉がほろほろと柔らかくて絶品だった。それ以外だと、苺はおかずの具にはならないかな、うん。

3段目は、苺とピスタチオのフレジエ、苺とチョコレートのケーキ、苺とラズベリーのタルト、苺のコンフィマスカルポーネムース、苺とローズヒップのゼリーの5品。ピスタチオクリームが非常に自分好みで、この中では一番気に入った。この時点で結構お腹パンパン。

ドリンクには、グランドハイアット福岡オリジナルブレンドティを頼んだ。普段は砂糖入れているけど、今回はノンシュガーで。紅茶あまり飲まない方だけど、雰囲気も相まってすごく上品な味に感じた。

苺のコンポートと練乳アイスクリーム。苺はもっと甘く煮てよかったんじゃないだろうか。練乳アイスクリームと比べて、甘さよりも酸味の方が際立ってしまっていた。

甘いものでお腹がいっぱいになってしまったので、プチフルールはキッシュにした。このときは、キッシュのしょっぱさがありがたかった。

一度アフタヌーンティーを食べてみたかったので、念願が叶った。もう十分満足。ケーキを何個も食べられない体になってしまっていたみたいだ。ケーキバイキングとかもまず無理だろうな。アフタヌーンティーの実績解除ってことで。

BAR FIZZ
〒812-0018 福岡県福岡市博多区住吉1-2-82 グランド ハイアット福岡B1F
r.gnavi.co.jp

珈琲ふじた

バナナマンのせっかくグルメ」で放送された「珈琲ふじた」に行ってみた。場所は渡辺通から一本入ったところ。電気ビルの裏の通り。カウンターの正面には日村のサインが飾られていた。ホントに来たんだな。

カツカレーとコーヒーのランチセットを注文。放送によるとカレーの隠し味にコーヒーを使っているとのこと。確かに、ほのかな苦みを感じるし、コク深さも今まで食べてきたカレーとは一線を画している。気がする。

食後のコーヒーはホットで。ネルドリップで淹れていた。スッキリとした飲み口。予想以上に飲みやすかった。

店内はコンパクトで常連に愛されてそうな感じ。喫煙席と禁煙席は隣でほぼ意味をなしていなかったけど。子ども連れには向かないかもな。大人様向け。

r.gnavi.co.jp

麺屋我ガ 天神店

福岡市中央区今泉にある「麺屋我ガ」に行ってみた。ここも一蘭や鳳凛みたいに臭みのない豚骨スープに辛いタレを浮かべた、いわゆる小郡系みたい。鳳凛のご子息が開いた店、みたいな記事を読んだけど、それが本当なら一蘭の正統と言える。

ラーメンを注文。スープは全く豚骨臭くなくスッキリとした飲み口で、丁寧な仕事が窺える。細麺にベストマッチ。これよコレコレ、といった感じ。やっぱり自分はこの味が好きだな。替え玉不可避。

自分は小郡系が一番好きみたい。それなのに鳳凛は天神から無くなるし、一蘭は値上げし過ぎだし。場所は今泉と、天神中心部からはちょっと離れているけど、一応徒歩圏内か。今後、小郡系を食べたいときの有力候補だな。

関連ランキング:ラーメン | 西鉄福岡駅(天神)天神南駅薬院大通駅

びっくり亭本家 赤坂店

南福岡に本店がある「びっくり亭本家」に赤坂店があったので行ってみた。本家とわざわざ主張しているのは、びっくり亭と同じ名前で違う店があるみたい。長浜屋みたいなことになってるな。

焼肉(1.5枚)に、ご飯と味噌汁セットの食券を購入。まずはご飯と味噌汁が運ばれてきた。ご飯は普通サイズにしたはずだけど、どんぶり並みの茶碗に盛られてきた。これは胃袋が試される予感。

焼肉(1.5枚)は、肉の量よりもまず山盛りのキャベツに圧倒される。ここのところ野菜不足だったのでちょうどいい。むしろ肉を食べる罪悪感が薄れる。肉汁に辛味噌を溶いて、肉やキャベツをディップして食べるのが旨い。ご飯も当然のように進む。

南福岡の本店にずっと行ってみたかったけど、交通費が発生するなぁ、とか、週末しか行けないけど家族では無理だなぁ、とかいった理由で長らく行けずにいた。本店ではなく赤坂店だけど、念願のびっくり亭本家に行けて感慨深い。

r.gnavi.co.jp

しばらく西新本店

福岡市内に数店舗ある「博多ラーメンしばらく」の本店が西新にあるので行ってみた。プラリバの裏。平日夜は空いていてすんなり座れたな。

ラーメンとご飯と餃子のセットを注文。ご飯はお焦げがちょっと固くて気になってた。保温してだいぶ経ってる?餃子は辛柚子胡椒がピッタリで旨かった。ご飯も進む。

ラーメンは博多ラーメンなのであまり白濁してない豚骨。ほどよい塩梅の塩味。これぞ博多ラーメンって感じ。一方で麺はこれまで食べた博多ラーメンと違ってプリっとしていた。自分にとっては新鮮。

しばらく 西新店
〒814-0002 福岡県福岡市早良区西新1-11-24 ゼニヤビル1F
600円(平均)
r.gnavi.co.jp