MauiのCollectionViewに行番号を表示する方法(Converterを使う)

# はじめに

Microsoft Maui の CollectionView に行番号を表示する方法の一つをここに残します。
ちなみに私は、行番号を表示するという見かけ上の要件のために ViewModel に項目を追加することは悪手だという考えの持ち主ですので、ここで触れる方法はView側だけで対応するものになっています。

# この記事で得られるもの

* Microsoft Maui の CollectionView に、Converter を使って行番号を表示する方法。
* MarkupExtension を使って行番号を表示する方法は WinUI 以外ではうまくいかないという罠の学び。

# 前提スキル

* VisualStudio と Maui のセットアップを完了し、CollectionView の基本的なレイアウト方法（[.NET MAUI CollectionView レイアウトの指定](https://learn.microsoft.com/ja-jp/dotnet/maui/user-interface/controls/collectionview/layout?view=net-maui-6.0)）を理解していること。

# 検証環境

(Windows)
* Visual Studio 2022 17.3.6
* dotnet sdk 6.0.402

(Mac)
* Visual Studio for Mac 17.4 Preview (build 2366)
* XCode 14.0.1

(iOS)
* iPhone SE (2gen) iOS 16.0

(Android)
* ZenFone Live (L1) Android 8.0

【2022/11/13 追記】以下の環境でも同じ動作／現象となることを確認しました。
* Visual Studio 2022 17.4
* dotnet sdk 6.0.403

# お急ぎの方へ

* CollectionView の ItemsSource と 行データインスタンスを元に、行番号を計算する「RowNumberConverter」を作成します。
* 作成したソースコードはこちら: [Github - atsuteru/MauiAppCollectionWithRowNumber](https://github.com/atsuteru/MauiAppCollectionWithRowNumber)

# 本編

これから説明する内容は、Maui の CollectionView を使って、次のスクリーンショットのように行番号を表示する方法を説明するものです。

#### WinUI

<img width="90%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109362/4b9587e4-522e-d56a-acf7-00c3c338df60.png" alt="MauiAppCollectionWithRowNumber_WinUI.png"/> 

#### MacCatalyst
<img width="90%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109362/50da0667-168f-7235-0da8-55860028c4e9.jpeg" alt="MauiAppCollectionWithRowNumber_MacCatalyst.JPEG"/> 

#### iOS
<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109362/e93a6cd1-c8cb-4765-bbe9-77eb512ead93.png" alt="MauiAppCollectionWithRowNumber_iOS.png"/> 

#### Android
<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109362/79367566-0eae-831f-bda2-cf625b9a6e44.jpeg" alt="MauiAppCollectionWithRowNumber_Android.jpg"/> 

## 概要

* 行番号の表示箇所は、IValueConverter を実装する RowNumberConverter を作成し、実行時に行番号を計算させます。
## 詳細

### 1. RowNumberConverterを作成します。

* 変換対象の値(value)は行データを想定します。
* コレクションにおける行データの位置を計算するため、コレクションを CollectionView#ItemsSource から受け取ります。そのために BindableProperty ItemsSourceProperty を宣言します。
* ついでに 行番号の表示フォーマットを ConverterParameter で指定できるようにします。
* あとは、行番号を計算するメソッド ToRowNumber(行データ, コレクション, 表示フォーマット) を実装すれば完成です。

```c#:RowNumberConverter.cs
using System.Collections;
using System.Globalization;

namespace MauiAppCollectionWithRowNumber
{
    public class RowNumberConverter : BindableObject, IValueConverter
    {
        public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create(
            nameof(ItemsSource), typeof(IEnumerable), typeof(RowNumberConverter), defaultValue: null, defaultBindingMode: BindingMode.OneWay);

        public IEnumerable ItemsSource { get => (IEnumerable)GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); }

        object IValueConverter.Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return ToRowNumber(rowData: value, rows: ItemsSource, format: parameter.ToString());
        }

        object IValueConverter.ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return null;
        }

        private string ToRowNumber(object rowData, IEnumerable rows, string format)
        {
            return (rows.Cast<object>().TakeWhile(x => !ReferenceEquals(x, rowData)).Count() + 1).ToString(format);
        }
    }
}

```

### 2. RowNumberConverterを利用して、CollectionViewで行番号を表示します。

* RowNumberConverterを、CollectionView の Resource として宣言し、ItemsSource をバインドします。
* 宣言された RowNumberConverter を、行番号の表示箇所で StaticResource として指定します。この時、Binding#Path の指定は省略します(行データそのものをRowNumberConverterに渡すため)
* 行番号の表示箇所ではついでに、ConverterParameter に表示フォーマットも渡しています。

```xml:MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage 
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:MauiAppCollectionWithRowNumber"
    x:Class="MauiAppCollectionWithRowNumber.MainPage">

    <CollectionView
        x:Name="PersonCollectionView"
        ItemsSource="{Binding Persons}"
        Margin="40">
        <CollectionView.Resources>
            <local:RowNumberConverter x:Key="RowNumberConverter" ItemsSource="{Binding Source={Reference PersonCollectionView}, Path=ItemsSource}"/>
        </CollectionView.Resources>
        <CollectionView.ItemTemplate>
            <DataTemplate>
                <Grid MinimumHeightRequest="40">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="0.2*"/>
                        <ColumnDefinition Width="0.8*"/>
                    </Grid.ColumnDefinitions>
                    <Label Grid.Column="0" Text="{Binding Converter={StaticResource RowNumberConverter}, ConverterParameter='000'}"/>
                    <Label Grid.Column="1" Text="{Binding Name}"/>
                </Grid>
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>

</ContentPage>
```

### 3. 全体のソースコード

[Github - atsuteru/MauiAppCollectionWithRowNumber](https://github.com/atsuteru/MauiAppCollectionWithRowNumber) をご覧ください。

## 蛇足

実はこの検証の際、「Converter よりも MarkupExtension を使ったほうがおしゃれに書けんじゃね？」と思ってやってみたのですが、その結果は「WinUI では問題なく動いているように見えるが、MacCatalyst/iOS/Android ではスクロールすると行番号が狂う」というものでした。同じ無駄を踏む方が一人でも減るよう、その失敗作をご紹介しておきます。

### 起きた現象

1つ目の列が Converter, 2つ目の列が、MarkupExtension を使った結果です。
2つ目の列は行番号が狂っていますね。スクロールするとこうなってしまうのですが、原因はおそらく「セルの再利用機構」です。
MarkupExtensionの場合、そのExtensionのインスタンスがセルの値として登録されてしまい、スクロール時の再計算判定において『再計算不要』とジャッジされてしまうからではないか、と推測しています。

#### iOS
<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109362/4158458f-a699-beca-3f86-766f16347878.png" alt="MauiAppCollectionWithRowNumber_iOS_ext.png"/> 

### ダメだったソースコード

こう書けたらおしゃれだと思ったのですが。。。

```xml:MainPage.xamlのCollectionView2列目
<Label Grid.Column="1" Text="{local:RowNumber Item={Binding}, ItemsSource={Binding Source={Reference PersonCollectionView}, Path=ItemsSource}, Format='000'}"/>
```

スクロール時に ProvideValueメソッド が呼ばれていませんでした。

```c#:RowNumberExtension.cs
using System.Collections;
using System.Runtime.CompilerServices;

namespace MauiAppCollectionWithRowNumber
{
    public class RowNumberExtension : BindableObject, IMarkupExtension<BindingBase>
    {
        public static readonly BindableProperty ItemsSourceProperty =
            BindableProperty.Create(nameof(ItemsSource), typeof(IEnumerable), typeof(RowNumberExtension), null, BindingMode.OneWay);
        public static readonly BindableProperty ItemProperty =
            BindableProperty.Create(nameof(Item), typeof(object), typeof(RowNumberExtension), null, BindingMode.OneWay);
        public static readonly BindableProperty FormatProperty =
            BindableProperty.Create(nameof(Format), typeof(string), typeof(RowNumberExtension), null, BindingMode.OneWay);
        public static readonly BindableProperty ValueProperty =
            BindableProperty.Create(nameof(Value), typeof(string), typeof(RowNumberExtension), string.Empty, BindingMode.OneWayToSource);

        public IEnumerable ItemsSource
        {
            get => (IEnumerable)GetValue(ItemsSourceProperty);
            set => SetValue(ItemsSourceProperty, value);
        }
        public object Item
        {
            get => (object)GetValue(ItemProperty);
            set => SetValue(ItemProperty, value);
        }
        public string Format
        {
            get => (string)GetValue(FormatProperty);
            set => SetValue(FormatProperty, value);
        }
        public string Value
        {
            get => (string)GetValue(ValueProperty);
            set => SetValue(ValueProperty, value);
        }

        BindingBase IMarkupExtension<BindingBase>.ProvideValue(IServiceProvider serviceProvider)
        {
            return (BindingBase)((IMarkupExtension)this).ProvideValue(serviceProvider);
        }

        object IMarkupExtension.ProvideValue(IServiceProvider serviceProvider)
        {
            ProvideContext(serviceProvider);
            return new Binding(ValueProperty.PropertyName, source: this);
        }
        private void ProvideContext(IServiceProvider serviceProvider)
        {
            var target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
            var targetObject = (BindableObject)target.TargetObject;
            EventHandler bindingChanged = null;
            bindingChanged = (sender, e) =>
            {
                BindingContext = targetObject.BindingContext;
                targetObject.BindingContextChanged -= bindingChanged;
            };
            targetObject.BindingContextChanged += bindingChanged;
        }

        protected override void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            switch (propertyName)
            {
                case nameof(ItemsSource):
                case nameof(Item):
                case nameof(Format):
                    Value = ToRowNumber(Item, ItemsSource, Format);
                    break;
                default:
                    break;
            }
            base.OnPropertyChanged(propertyName);
        }

        protected virtual string ToRowNumber(object rowData, IEnumerable rows, string format)
        {
            if (rows == null || rowData == null)
            {
                return string.Empty;
            }
            return (rows.Cast<object>().TakeWhile(x => !ReferenceEquals(x, rowData)).Count() + 1).ToString(format ?? "#");
        }
    }
}
```

MarkupExtensionを使ってみたパターンの全体のソースコードは、
[Github - atsuteru/MauiAppCollectionWithRowNumber/tree/try_extension](https://github.com/atsuteru/MauiAppCollectionWithRowNumber/tree/try_extension) をご覧ください。

もしこの問題の解決方法をご存じの方は、よろしければご教授ください。。。🙇‍♂️

# おわりに

この記事を書いている時(2022/11/12)に、気が付いたのですが…
その4日前(2022/11/8)に VisualStudio 17.4 と dotnet 6.0.11 / 7.0.0 がリリースされていました。

公開日時点で、古い環境での投稿となってしまうとは・・・
でももうそのまま上げます。しょっちゅうリリースされているので、付き合ってたらキリがないとうことで！😂