Xamarin
Xamarin.Android
Xamarin.Forms
Xamarin.iOS

Xamarin.FormsのListViewやTableViewで使えるCustomCell(NativeCell)の作り方のまとめ

今年は個人でも仕事でもListViewのCellはViewCellではなくNativeCellを利用することが多かったので、ここでまとめてみました。一部こちらのブログと内容がかぶりますが、今回はiOSの方法も追加した上でその後にいろいろ最適化したのでその内容も反映させたものにしました。

リポジトリ

https://github.com/muak/NativeCell

基本的な手順

  1. Forms側で使うCellをFormsプロジェクトに作成する
  2. Native側で使うCellをPlatformプロジェクトに作成する
  3. 1と2を対応させるRendererをPlatformプロジェクトに作成する

FormsCell

サンプルとしてタイトルラベルだけが存在するセルを作成していきます。

基本的にはCellを継承して作りますが、もしListViewのContextAction(iOSスワイプメニュー/Androidロングタップメニュー)を利用する場合はCellではなくViewCellを使います。

public class CustomCell:Cell //ContextActionを使いたい場合は継承元をViewCellにする
{
    //タイトルの文字
    public static BindableProperty TitleProperty =
        BindableProperty.Create(
            nameof(Title),
            typeof(string),
            typeof(CustomCell),
            default(string),
            defaultBindingMode: BindingMode.OneWay
        );

    public string Title
    {
        get { return (string)GetValue(TitleProperty); }
        set { SetValue(TitleProperty, value); }
    }

    //タイトルの文字色
    public static BindableProperty TitleColorProperty =
        BindableProperty.Create(
            nameof(TitleColor),
            typeof(Color),
            typeof(CustomCell),
            default(Color),
            defaultBindingMode: BindingMode.OneWay
        );

    public Color TitleColor
    {
        get { return (Color)GetValue(TitleColorProperty); }
        set { SetValue(TitleColorProperty, value); }
    }
}

FormsCellにはNativeで使う項目をBindablePropertyとして定義していきます。
この例のように細かく項目ごとに指定しても良いですし、面倒であれば全部入りのクラス1つを1つのBindablePropertyにしても良いです。ただし全部入りクラスにした場合はその中の1つだけを動的に変更といったことが難しくなるのでケースバイケースで決めましょう。

その他FormsCellですること

プロパティの定義以外でFormsCellで記述することは

  • 共通処理
  • 共通のPropertyChanged / PropertyChanging / Validation処理

などが考えられます。どのPlatformでも共通の処理などはこっちに定義してPlatform側から呼び出す形で使うようにすればコードの共通化ができます。

NativeCell

セルの実体

Nativeにおけるセルが何を指すかというと

  • iOS
    • UITableViewCell
    • またはこれを継承したXamarin.FormsのクラスのCellTableViewCell
  • Android
    • 何らかのViewGroup(LinearLayout, RelativeLayout)
    • Androidではそもそもセルとは言わないっぽい…ですがここではセルと言うことにします。

という感じになります。
したがってNativeCellはこれらを継承して作成します。

セルで必要な処理

だいたい以下のような組み合わせになると思います。

  • 部品の生成と配置(レイアウト)
  • セル内容をFormsCellから持ってきて更新(まとめて・個別それぞれ)
  • PropertyChangedによる動的変更への対応(CachingStrategy.Recycleの場合は必須)
  • Disposeでの後始末
  • 画像の非同期読み込み・キャンセル

画像に関しては、ちょっとややこしいので今回はしません。

iOS 実装

public class NativeCustomCell : CellTableViewCell
{
    CustomCell CustomCell => Cell as CustomCell;
    UILabel _titleLabel;

    public NativeCustomCell(Cell formsCell) : base(UIKit.UITableViewCellStyle.Default, formsCell.GetType().FullName)
    {
        Cell = formsCell;

        //UILabelの生成と配置
        _titleLabel = new UILabel();

        ContentView.AddSubview(_titleLabel);
        _titleLabel.TranslatesAutoresizingMaskIntoConstraints = false;
        _titleLabel.CenterXAnchor.ConstraintEqualTo(ContentView.CenterXAnchor).Active = true;
        _titleLabel.CenterYAnchor.ConstraintEqualTo(ContentView.CenterYAnchor).Active = true;

    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _titleLabel.Dispose();
            _titleLabel = null;
            CustomCell.PropertyChanged -= CellPropertyChanged;
        }
        base.Dispose(disposing);
    }

    //リサイクル時に呼ばれるセル内容全更新メソッド
    public void UpdateCell()
    {
        UpdateTitle();
        UpdateTitleColor();

        SetNeedsLayout();
    }

    //ProperyChanged対応
    public void CellPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == CustomCell.TitleProperty.PropertyName)
        {
            UpdateTitle();
        }
        else if (e.PropertyName == CustomCell.TitleColorProperty.PropertyName)
        {
            UpdateTitleColor();
        }
    }

    //タイトル文字の更新
    void UpdateTitle()
    {
        _titleLabel.Text = CustomCell.Title;
    }

    //タイトル文字色の更新
    void UpdateTitleColor()
    {
        _titleLabel.TextColor = CustomCell.TitleColor.ToUIColor();
    }
}

コンストラクタではFormsCellを受け取りBaseにはそのクラス名をキー用に渡します。
この場合はクラス名がリサイクルで使うキーとなります。もちろん独自のものに変えても大丈夫です。

セルに必要は部品の生成と配置はコンストラクタで行います。動的に部品の数やレイアウトが変わったりしなければここで問題ないと思います。
iOSの場合は必要な部品はContentViewにAddSubViewしていく形になります。ここにUIStackViewなどを入れると良いかも知れません。
レイアウトに関しては別記事で書いていますのでもし良かったらそちらを参考にしてください。

https://qiita.com/muak_x/items/38cabb93ad3a70677082

Disposeでは後始末を記述します。ここでは使った部品をDisposeしたりイベント購読解除したりといった処理を書きます。

UpdateCellはセル内容を全更新するメソッドで後述するRendererから呼び出されます。初回とリサイクル時にセルの内容をFormsCellから持ってきて反映させます。

個別のUpdateメソッドはPropertyChangedなどで個別に値が変わった時に対応できるようにしたものです。

CellPropertyChangedはFormsCellのPropertyChangedのハンドラで登録はRendererで行います。
これはRenderer側に置いても良いんですが、そうするとRendererに余計な参照を残すことになるので個人的には可能な限りNativeCell側に置くようにしています。

Android 実装

public class NativeCustomCell : Android.Widget.RelativeLayout, INativeElementView
{
    public Cell Cell { get; set; }
    public Element Element => Cell;
    public CustomCell CustomCell => Cell as CustomCell;

    TextView _titleLabel;

    public NativeCustomCell(Context context, Cell formsCell) : base(context)
    {
        Cell = formsCell;

        SetMinimumHeight((int)context.ToPixels(44));

        //Layout呼び出して親とドッキングする(AddViewする必要無し)
        var contentView = LayoutInflater.FromContext(context).Inflate(Resource.Layout.NativeCustomCellLayout, this, true);
        //必要部品を取り出す
        _titleLabel = contentView.FindViewById<TextView>(Resource.Id.TitleLabel);
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _titleLabel.Dispose();
            _titleLabel = null;
            Cell = null;
            CustomCell.PropertyChanged -= CellPropertyChanged;
        }
        base.Dispose(disposing);
    }

    //リサイクル時に呼ばれるセル内容全更新メソッド
    public void UpdateCell()
    {
        UpdateTitle();
        UpdateTitleColor();

        Invalidate();
    }

    //ProperyChanged対応
    public void CellPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == CustomCell.TitleProperty.PropertyName)
        {
            UpdateTitle();
        }
        else if (e.PropertyName == CustomCell.TitleColorProperty.PropertyName)
        {
            UpdateTitleColor();
        }
    }

    //タイトル文字の更新
    void UpdateTitle()
    {
        _titleLabel.Text = CustomCell.Title;
    }

    //タイトル文字色の更新
    void UpdateTitleColor()
    {
        _titleLabel.SetTextColor(CustomCell.TitleColor.ToAndroid());
    }
}

iOSとほぼ同じですので、違うところに関してだけ書きます。
Androidの方は簡単にxmlでレイアウトを組めるので、なるべくそうした方が良いと思います。
その際はmergeタグを使って書いた方がセルクラスに直接ドッキングできてエコです。
ここで普通にRelativeLayoutとかにするとレイアウトの階層が1つ多くなってしまいます。

レイアウトを呼び出すのはLayoutInflater.FromContext(context).Inflateが個人的に一番使いやすいかなと思いました。今回のように自分自身にマージさせるような場合は第2引数に自身を第3引数をtrueにすると良いみたいです。この場合は戻り値のviewをAddViewする必要はありません。

マージした後は参照が必要な部品をFindViewById<T>で取り出します。

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/TitleLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true" />
</merge>

LayoutのxmlはResources/Layoutに置きます。

Renderer

CellRendererで必要な処理

  • 新規セル生成
  • リサイクルセル判定
  • セル更新
  • PropertyChangedの購読・解除
  • ExportRenderer属性を付加

iOS 実装

継承元はコメントの通りでFormCellの継承元に合わせます。

[assembly: ExportRenderer(typeof(CustomCell), typeof(CustomCellRenderer))]
namespace Sample.iOS.Cells
{
    public class CustomCellRenderer:CellRenderer // FormsのCellがViewCell派生ならViewCellRendererを使う
    {
        public override UIKit.UITableViewCell GetCell(Cell item, UIKit.UITableViewCell reusableCell, UIKit.UITableView tv)
        {
            var nativeCell = reusableCell as NativeCustomCell;

            if(nativeCell == null){
                //リサイクルでなければセルを生成する
                nativeCell = new NativeCustomCell(item);
            }

            //リサイクル前のFormsCellのPropertyChangedを解除する
            nativeCell.Cell.PropertyChanged -= nativeCell.CellPropertyChanged;

            //NativeCellに持たせてあるFormsCellへの参照を更新する(リサイクル前の値のままにしない)
            nativeCell.Cell = item;

            //リサイクル後のFormsCellのPropertyChangedを購読する
            item.PropertyChanged += nativeCell.CellPropertyChanged;

            //セル内容の更新
            nativeCell.UpdateCell();

            return nativeCell;
        }
    }
}

GetCellのoverrideを記述するだけです。
ここではreusableCellがnullだったら新規セル、そうでなければリサイクルセルとして新規の時だけnewでインスタンスを生成するようにします。UITableViewで使うセルは1画面分+αくらいの数だけ生成されて、あとはそれをリサイクルするようになっているので、リサイクル時は前のデータを新しいデータに更新しないといけません。

実際のデータの更新はNativeCell側のUpdateCellに書いて、ここではそれを呼ぶだけにしています。

注意すべきなのはPropertyChangedの購読と解除です。
購読する時は引数で送られてきた現在のFormsCellに対して行いますが、解除する時は1つ前のFormsCellに対して解除します。こうしないとリサイクル前のFormsCellのPropertyChangedが解除されないまま生き続けてしまいます。

Android 実装

[assembly: ExportRenderer(typeof(CustomCell), typeof(CustomCellRenderer))]
namespace Sample.Droid.Cells
{
    public class CustomCellRenderer:CellRenderer // FormsのCellがViewCell派生ならViewCellRendererを使う
    {
        protected override Android.Views.View GetCellCore(Cell item, Android.Views.View convertView, Android.Views.ViewGroup parent, Android.Content.Context context)
        {
            var nativeCell = convertView as NativeCustomCell;

            if (nativeCell == null)
            {
                //リサイクルでなければセルを生成する
                nativeCell = new NativeCustomCell(context, item);
            }

            //リサイクル前のFormsCellのPropertyChangedを解除する
            nativeCell.Cell.PropertyChanged -= nativeCell.CellPropertyChanged;

            //NativeCellに持たせてあるFormsCellへの参照を更新する(リサイクル前の値のままにしない)
            nativeCell.Cell = item;

            //リサイクル後のFormsCellのPropertyChangedを購読する
            item.PropertyChanged += nativeCell.CellPropertyChanged;

            //セル内容の更新
            nativeCell.UpdateCell();

            return nativeCell;
        }
    }
}

iOS実装とほぼ同じなので説明は省略します。

ListViewのCachingStrategy.RecycleElementとPropertyChangedについて

ListViewのCachingStrategy.RetainElementの場合はiOSではGetCell、AndroidではGetCellCoreがスクロールする度に呼ばれ、そこでセルの更新を呼び出して動くのですが、これがRecycleElementの場合は挙動が変わります。

RecycleElementの場合はGetCell / GetCellCoreは1画面分程度のセルの数(新規生成時)しか呼ばれず、スクロールしても呼ばれません。
なので画面更新メソッドも呼ばれず、スクロールしても同じ内容がループするようになってしまいます。

その場合の画面更新はPropertyChangedを通じて行われます。今回のサンプルではPropertyChangedでも更新処理を書いているので動作しますが、これがないとRecycleElementでは動作しないので注意が必要です。

動作確認

<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms" 
    xmlns:c="clr-namespace:Sample.Cells;assembly=Sample"
    prism:ViewModelLocator.AutowireViewModel="True" 
    x:Class="Sample.Views.MainPage" Title="MainPage">
    <ListView HasUnevenRows="true" ItemsSource="{Binding ItemsSource}">
        <x:Arguments>
            <ListViewCachingStrategy>RecycleElement</ListViewCachingStrategy>
        </x:Arguments>
        <ListView.ItemTemplate>
            <DataTemplate>
                <c:CustomCell Title="{Binding Name}" TitleColor="Black" />
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

42c95e1b497f90cdf9dbcbd3493e6a85.png

RecycleElementでもRetainElementでも動作を確認できました。

セル内タップ処理などをどうするか

Cell単位のタップならListViewのSelectedから取れますが、セル内部の部品のタップの場合は、NativeCell内の該当部品にタップ処理を付加してForms側へはCommandを通して伝える方法が考えられます。
以下実装例です。手抜き実装なので実際は後始末などできる形にした方が良いと思います。

iOS

public NativeCustomCell(Cell formsCell) : base(UIKit.UITableViewCellStyle.Default, formsCell.GetType().FullName)
{
    Cell = formsCell;

    //UILabelの生成と配置
    _titleLabel = new UILabel();

    ContentView.AddSubview(_titleLabel);
    _titleLabel.TranslatesAutoresizingMaskIntoConstraints = false;
    _titleLabel.CenterXAnchor.ConstraintEqualTo(ContentView.CenterXAnchor).Active = true;
    _titleLabel.CenterYAnchor.ConstraintEqualTo(ContentView.CenterYAnchor).Active = true;

    var tap = new UITapGestureRecognizer(_=>{
        CustomCell.Command?.Execute(_titleLabel.Text); //タップでCommandを発火
    });
    _titleLabel.AddGestureRecognizer(tap);
    _titleLabel.UserInteractionEnabled = true;
}

Android

public NativeCustomCell(Context context, Cell formsCell) : base(context)
{
    Cell = formsCell;

    SetMinimumHeight((int)context.ToPixels(44));

    //Layout呼び出して親とドッキングする(AddViewする必要無し)
    var contentView = LayoutInflater.FromContext(context).Inflate(Resource.Layout.NativeCustomCellLayout, this, true);
    //必要部品を取り出す
    _titleLabel = contentView.FindViewById<TextView>(Resource.Id.TitleLabel);

    _titleLabel.Click += (sender, e) => {
        CustomCell.Command?.Execute(_titleLabel.Text); //タップでCommandを発火
    };
}

おまけ CellRendererをジェネリックにしてテンプレート化

CellのRendererはどのCellでもほぼ形が変わらないのでジェネリッククラスにしてBaseClassとして使えるようにすると量産化する時に便利です。

iOS

public class CellBaseRenderer<TnativeCell> : CellRenderer where TnativeCell : NativeCellBase
{
    internal static class InstanceCreator<T1, TInstance>
    {
        public static Func<T1, TInstance> Create { get; } = CreateInstance();

        private static Func<T1, TInstance> CreateInstance()
        {
            var constructor = typeof(TInstance).GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, Type.DefaultBinder,
                new[] { typeof(T1) }, null);
            var arg1 = Expression.Parameter(typeof(T1));
            return Expression.Lambda<Func<T1, TInstance>>(Expression.New(constructor, arg1), arg1).Compile();
        }
    }

    public override UITableViewCell GetCell(Cell item, UITableViewCell reusableCell, UITableView tv)
    {
        TnativeCell nativeCell = reusableCell as TnativeCell;
        if (nativeCell == null)
        {
            nativeCell = InstanceCreator<Cell, TnativeCell>.Create(item);
        }

        nativeCell.Cell.PropertyChanged -= nativeCell.CellPropertyChanged;

        nativeCell.Cell = item;

        item.PropertyChanged += nativeCell.CellPropertyChanged;

        nativeCell.UpdateCell();

        return nativeCell;
    }
}

public abstract class NativeCellBase : CellTableViewCell
{
    public NativeCellBase(Cell formsCell) : base(UITableViewCellStyle.Default, formsCell.GetType().FullName)
    {
        Cell = formsCell;
    }

    public abstract void CellPropertyChanged(object sender, PropertyChangedEventArgs e);

    public abstract void UpdateCell();

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            Cell.PropertyChanged -= CellPropertyChanged;
        }
        base.Dispose(disposing);
    }
}

Android

public class CellBaseRenderer<TnativeCell> : CellRenderer where TnativeCell : NativeCellBase
{
    internal static class InstanceCreator<T1, T2, TInstance>
    {
        public static Func<T1, T2, TInstance> Create { get; } = CreateInstance();

        private static Func<T1, T2, TInstance> CreateInstance()
        {
            var argsTypes = new[] { typeof(T1), typeof(T2) };
            var constructor = typeof(TInstance).GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, Type.DefaultBinder,
                   argsTypes, null);
            var args = argsTypes.Select(Expression.Parameter).ToArray();
            return Expression.Lambda<Func<T1, T2, TInstance>>(Expression.New(constructor, args), args).Compile();
        }
    }

    protected override Android.Views.View GetCellCore(Xamarin.Forms.Cell item, Android.Views.View convertView, Android.Views.ViewGroup parent, Android.Content.Context context)
    {
        TnativeCell nativeCell = convertView as TnativeCell;
        if (nativeCell == null)
        {
            nativeCell = InstanceCreator<Context, Xamarin.Forms.Cell, TnativeCell>.Create(context, item);
        }

        nativeCell.Cell.PropertyChanged -= nativeCell.CellPropertyChanged;

        nativeCell.Cell = item;

        item.PropertyChanged += nativeCell.CellPropertyChanged;

        nativeCell.UpdateCell();

        return nativeCell;
    }
}

public abstract class NativeCellBase : Android.Widget.RelativeLayout, INativeElementView
{
    public Cell Cell { get; set; }
    public Element Element => Cell;

    public NativeCellBase(Context context, Cell formCell) : base(context)
    {
        Cell = formCell;
    }

    public abstract void CellPropertyChanged(object sender, PropertyChangedEventArgs e);

    public abstract void UpdateCell();

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            Cell.PropertyChanged -= CellPropertyChanged;
            Cell = null;
        }
        base.Dispose(disposing);
    }
}

使用例(iOS)

[assembly: ExportRenderer(typeof(CustomCell2), typeof(NativeCell2Renderer))]
namespace Sample.iOS.Cells
{
    public class NativeCell2Renderer : CellBaseRenderer<NativeCell2> { }

    public class NativeCell2 : NativeCellBase
    {

        public NativeCell2(Cell formsCell) : base(formsCell)
        {
            //レイアウトの処理
        }

        public override void CellPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            //PropertyChangedの処理
        }

        public override void UpdateCell()
        {
            //セル更新処理
        }
    }
}

Rendererに関しては継承するだけで中身は空で動作するようになります。

終わりに

NativeCellを使う理由はパフォーマンス以外にはないです。特にAndroidで顕著ですがViewCellと動きが全然違います。iOSに関してはViewCellでもサクサク動くのであまり必要性はないかもしれません。
今回はセルだけがNativeで後はFormsの仕組みを利用しているので、よりパフォーマンスを向上させたい場合はCellはFormsを介さずにListViewごとNativeで処理すると良いと思います。

多分ちょっとアプリを作り込んでいくとパフォーマンス悩む場面が出てくると思いますので、その時はこの記事がお役に立てると幸いです。

今回の方法で作ったCellを利用して設定画面を楽に作ることができるSettingsView というライブラリを公開中です。こちらも良かったら参考にしてください。

参考