M3搭載13インチMacBook Air購入

家のMacBook Proは2016年購入で、丸8年になろうかとしている。Touch Bar搭載のIntel Mac。ただ使うだけならまだ問題ないんだけど、古すぎてmacOSを最新に更新できなくなってしまった。それに伴ってXcodeも更新できなくなり、iOS SDKまでも更新不可。

SwiftUIは一応使えたので、それで粘ろうとしたが、実機に転送できなくて詰んだ。個人開発でスマホアプリを開発する道は断たれた。新しいMacBookを買うしかない。そんなことを呟いていたら購入許可が出た。やったね。

発売されて間もないM3搭載13インチMacBook Airを購入。初Apple Silicon。SSDを512GB、メモリを24GBに増やした。これから何年も使うものだから、16GBでは心許ないからね。

色はミッドナイト。基本的に買ったことのない色を選ぶ方針。

MacBook Proからファイルの移行は終わったので、これから開発環境を構築していく。ようやく新しいXcodeが使える。SwiftUIとSwiftDataで遊びたいかも。.NET8のMAUIも試してみるかな。

邂逅

4ヶ月ぶりの新曲は「陰陽師0」の主題歌。歌詞からは映画のスピンオフを読んでいるような印象を受けた。YOASOBIを筆頭に、最近のアーティストのタイアップは作品の世界観に寄り添った曲が当たり前になってきたけど、バンプは初タイアップのsailing dayの頃からそうだったな。あの頃のような、疾走感溢れるナンバーがそろそろ聴きたいかも。懐古厨だな。

邂逅

邂逅

月光軒

川端商店街にあった頃よく行っていた「月光軒」。博多に移転して生活範囲から遠のいたので移転後まったく行けてなかった。いつか行きたいな、と思っていたら、なんと天神に移転しているじゃないか。天神ビル地下一階の飲食店フロアに。すごく久しぶりに行ってみた。

「中華そば 醤油」の食券を購入。ひさしぶりの月光軒の中華そば。見た目が美しい。プチトマトの赤が映える。麺はつるっとモッチリ。スープは旨味に溢れていて、つい完飲してしまった。豚と鶏のチャーシューはどちらもしっとり柔らかく美味。クオリティの高さは相変わらずで安心した。

月光軒では「中華そば 醤油」「中華そば しお」「つけそば」をローテしていた。これから何度もお世話になるのは間違いない。川端商店街時代には無かった「つけそば 塩」も増えていた。気になる。

一蘭やシンシン、兼虎といった天神にある有名ラーメン屋はどこも観光客で大行列なので、この店は観光客には見つかって欲しくないな。

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

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

めんとスープ

福岡市早良区野芥にある「めんとスープ」に行ってみた。地下鉄七隈線野芥駅から徒歩15分くらい。この店は、過去に一度行ったら貸切で入れなかったんだよな。今回はリベンジ。

多くの客が注文するとオススメされた、オマール海老ラーメンにした。見た目はラーメンって感じがしない。オマール海老のスープ。ビスクみたいなの想像してたけど、ブイヨンもブレンドしてあって、さらっとしていて旨味が濃い。麺はスープに負けずプリプリでもっちり。まさにフレンチのヌードル。普段豚骨や醤油を食べている身なので、新鮮でより美味に感じる。

今回無事リベンジを果たすことができた。コースの予約で満席だったり貸切だったりがしょっちゅうなので、Instagram で予約状況を確認する必要がある。ラーメンは予約不要。というか、コース等の予約が入っていない日じゃないとラーメンが食べられない。カジュアルフレンチの店がコースの予約がない日にラーメンを出している、というのが実態に近いかな。

関連ランキング:ラーメン | 野芥駅梅林駅

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