Xamarin.Formsで高さがバラバラの項目をグリッド表示する【Android編】

はじめまして、広告システム開発部の松島です。主にネイティブアプリの開発を担当しております。

アプリの開発と言ったら、AndroidならJava、iOSならSwiftやObjective-Cで行なうことが多いと思いますが、medibaではXamarinでの開発も行っています。

さて、今回は、Xamarin.Formsで高さがバラバラの項目をグリッド表示するサンプルを作成してみましたので、その解説を行います。

サンプル

こちらです。

イメージ

方針

Xamarin.Formsの標準のコントロールでは、表題のようなことはできないので、カスタムレンダラーを使用して独自のコントロールを作成します。カスタムレンダラーでは、プラットフォーム毎で用意されているコントロールを使用して実装していくことになりますが、Androidでは、この独自コントロールの実現のためにRecyclerViewとStaggeredGridLayoutManagerを用いることにします。

今回、独自コントロールは2つ作成します。StaggeredGridViewとStaggeredGridCellです。StaggeredGridViewは、グリッド表示を行うコントロールで、StaggeredGridCellはそのグリッドの項目を表すセルとなります。

実装

まずは、PCLプロジェクトにStaggeredGridViewとStaggeredGridCellを作成します。

StaggeredGridView

using Xamarin.Forms;

namespace StaggeredGridSample
{
    public class StaggeredGridView : ListView
    {
        public static readonly BindableProperty RowSpacingProperty = BindableProperty.Create("RowSpacing", typeof(double), typeof(StaggeredGridView), 0.0);

        public double RowSpacing
        {
            get
            {
                return (double)GetValue(RowSpacingProperty);
            }
            set
            {
                SetValue(RowSpacingProperty, value);
            }
        }

        public static readonly BindableProperty ColumnSpacingProperty = BindableProperty.Create("ColumnSpacing", typeof(double), typeof(StaggeredGridView), 0.0);

        public double ColumnSpacing
        {
            get
            {
                return (double)GetValue(ColumnSpacingProperty);
            }
            set
            {
                SetValue(ColumnSpacingProperty, value);
            }
        }

        public static readonly BindableProperty SpanCountProperty = BindableProperty.Create("SpanCount", typeof(int), typeof(StaggeredGridView), 2);

        public int SpanCount
        {
            get
            {
                return (int)GetValue(SpanCountProperty);
            }
            set
            {
                SetValue(SpanCountProperty, value);
            }
        }
    }
}

StaggeredGridViewは、ListViewを継承します。バインディング可能なプロパティとして、行間のスペースの設定のRowSpacing、列間のスペースの設定のColumnSpacing、列数の設定のSpanCountを用意しています。

StaggeredGridCell

using Xamarin.Forms;

namespace StaggeredGridSample
{
    public class StaggeredGridCell : ViewCell
    {
        public static readonly BindableProperty RatioProperty = BindableProperty.Create("Ratio", typeof(double), typeof(StaggeredGridCell), 1.0);

        public double Ratio
        {
            get
            {
                return (double)GetValue(RatioProperty);
            }
            set
            {
                SetValue(RatioProperty, value);
            }
        }
    }
}

StaggeredGridCellは、ViewCellを継承します。バインディング可能なプロパティとして、横幅に対する高さの割合の設定のRatioを用意しています。

次に、AndroidのプロジェクトにStaggeredGridViewとStaggeredGridCellのカスタムレンダラーを作成していきます。最初にStaggeredGridViewのカスタムレンダラーであるStaggeredGridViewRendererからです。

StaggeredGridViewRenderer

using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using Android.Support.V7.Widget;
using StaggeredGridSample;
using StaggeredGridSample.Droid;

[assembly: ExportRenderer(typeof(StaggeredGridView), typeof(StaggeredGridViewRenderer))]
namespace StaggeredGridSample.Droid
{
    public class StaggeredGridViewRenderer : ViewRenderer<StaggeredGridView, RecyclerView>
    {
        protected override void OnElementChanged(ElementChangedEventArgs<StaggeredGridView> e)
        {
            base.OnElementChanged(e);

            if (Control == null)
            {
                var contex = Forms.Context;
                var nativeControl = new RecyclerView(contex);

                int padding = (int)Element.ColumnSpacing / 2;
                nativeControl.SetPadding(padding, 0, padding, 0);

                var sglm = new StaggeredGridLayoutManager(Element.SpanCount, StaggeredGridLayoutManager.Vertical);
                var adapter = new StaggeredGridAdapter(contex, Element);
                var decoration = new SpacesItemDecoration((int)Element.RowSpacing, padding);

                nativeControl.SetLayoutManager(sglm);
                nativeControl.SetAdapter(adapter);
                nativeControl.AddItemDecoration(decoration);

                SetNativeControl(nativeControl);
            }
        }
    }
}

Androidで使用するコントロールのインスタンス生成は、OnElementChangedで行います。今回は、前述の通りRecyclerViewを生成します。そして、RecyclerViewのLayoutManagerには、StaggeredGridLayoutManagerをセットしてます。これで、高さがバラバラの項目のグリッド表示ができるようになります。

また、RecyclerViewのAdapterと各項目間にスペース空けるためのItemDecorationであるSpacesItemDecorationもここでセットしています。

次に、RecyclerViewのAdapterであるStaggeredGridAdapter、ViewHolderであるStaggeredGridCellHolderを作成します。

StaggeredGridAdapter

using Android.Support.V7.Widget;
using Android.Content;
using Android.Views;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;

namespace StaggeredGridSample.Droid
{
    public class StaggeredGridAdapter : RecyclerView.Adapter
    {
        Context _context;
        StaggeredGridView _gridView;

        ITemplatedItemsView TemplatedItemsView => _gridView;

        public StaggeredGridAdapter(Context context, StaggeredGridView gridView)
        {
            _context = context;
            _gridView = gridView;
        }

        public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType)
        {
            var cell = _gridView.ItemTemplate.CreateContent() as Cell;
            var view = CellFactory.GetCell(cell, null, parent, _context, _gridView);

            return new StaggeredGridCellHolder(view);
        }

        public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position)
        {
            if (holder is StaggeredGridCellHolder)
            {
                Cell cell = ((StaggeredGridCellHolder)holder).Cell;
                TemplatedItemsView.TemplatedItems.UpdateContent(cell, position);
            }
        }

        public override int ItemCount
        {
            get
            {
                return TemplatedItemsView.TemplatedItems.Count;
            }
        }
    }
}

StaggeredGridCellHolder

using Android.Support.V7.Widget;
using Xamarin.Forms;
using AView = Android.Views.View;

namespace StaggeredGridSample.Droid
{
    public class StaggeredGridCellHolder : RecyclerView.ViewHolder
    {
        Cell _cell;

        public StaggeredGridCellHolder(AView view) : base(view)
        {
            _cell = (view as INativeElementView).Element as Cell;
        }

        public Cell Cell
        {
            get
            {
                return _cell;
            }
        }
    }
}

StaggeredGridAdapterのOnCreateViewHolderで、StaggeredGridCellHolderの生成、OnBindViewHolderで、StaggeredGridCellHolderへの値の設定を行います。書き方こそはC#ですが、作りはJavaと全く同じですね。

OnCreateViewHolderでのポイントは、CellFactory.GetCellで、StaggeredGridViewで使用するAndroidでのセルを取得しているところです。こうすることで、StaggeredGridCell以外のセルも使えるようにできちゃいます。

OnBindViewHolderでのポイントは、TemplatedItemsのUpdateContentで、値の設定を行っているところです。実を言うと、このメソッドを使わないと、アプリがクラッシュしてしまうことがあります。StaggeredGridViewが、わざわざListViewを継承しているのは、このメソッドを使いたいためだったりもします。

続いて、ItemDecorationのSpacesItemDecorationです。

SpacesItemDecoration

using Android.Support.V7.Widget;
using Android.Graphics;
using Android.Views;

namespace StaggeredGridSample.Droid
{
    public class SpacesItemDecoration : RecyclerView.ItemDecoration
    {
        int _rowSpace;
        int _colSpace;

        public SpacesItemDecoration(int rowSpace, int colSpace)
        {
            _rowSpace = rowSpace;
            _colSpace = colSpace;
        }

        public override void GetItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)
        {
            outRect.Left = _colSpace;
            outRect.Right = _colSpace;
            outRect.Bottom = _rowSpace;
        }
    }
}

RecyclerViewにおける各項目間のスペースの設定は、ItemDecorationのGetItemOffsetsで行います。SpacesItemDecorationでは、コンストラクで与えた値を、引数のoutRectに設定しています。

最後に、StaggeredGridCellのカスタムレンダラーであるStaggeredGridCellContainerを作成します。

StaggeredGridCellRenderer

using Android.Content;
using Android.Views;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using StaggeredGridSample;
using StaggeredGridSample.Droid;
using AView = Android.Views.View;

[assembly: ExportRenderer(typeof(StaggeredGridCell), typeof(StaggeredGridCellRenderer))]
namespace StaggeredGridSample.Droid
{
    public class StaggeredGridCellRenderer : CellRenderer
    {
        protected override AView GetCellCore(Cell item, AView convertView, ViewGroup parent, Context context)
        {
            var cell = (StaggeredGridCell)item;
            IVisualElementRenderer renderer = Platform.CreateRenderer(cell.View);
            Platform.SetRenderer(cell.View, renderer);
            return new StaggeredGridCellContainer(context, renderer, cell, (StaggeredGridView)ParentView);
        }
    }

    class StaggeredGridCellContainer : ViewGroup, INativeElementView
    {
        StaggeredGridView _parent;
        IVisualElementRenderer _view;
        StaggeredGridCell _cell;

        public StaggeredGridCellContainer(Context context, IVisualElementRenderer view, StaggeredGridCell cell, StaggeredGridView parent) : base(context)
        {
            _view = view;
            _cell = cell;
            _parent = parent;
            AddView(view.ViewGroup);
        }

        public Element Element
        {
            get { return _cell; }
        }

        protected override void OnLayout(bool changed, int l, int t, int r, int b)
        {
            double width = Context.FromPixels(r - l);
            double height = Context.FromPixels(b - t);

            Xamarin.Forms.Layout.LayoutChildIntoBoundingRegion(_view.Element, new Rectangle(0, 0, width, height));

            _view.UpdateLayout();
        }

        protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec)
        {
            int width = MeasureSpec.GetSize(widthMeasureSpec);
            int height = (int)(width * _cell.Ratio);

            SetMeasuredDimension(width, height);
        }
    }
}

Androidのセルのインスタンス生成は、GetCellCoreで行います。StaggeredGridCellRendererでは、StaggeredGridCellContainerを生成しています。このメソッドが、前述のStaggeredGridAdapterのOnCreateViewHolderで使っていたCellFactory.GetCellで呼ばれています。

セルのサイズは、StaggeredGridCellContainerのOnMeasureで設定します。MeasureSpec.GetSizeで取得した横幅からStaggeredGridCellに設定されたRatioをかけて、高さを求めています。

まとめ

独自のコントローラを作るといったら、少し敷居が高く感じられるかもしれませんが、今回のようにAndroidでの実装方法が分かっていれば、割と簡単に作れてしまうかと思います。

次回の投稿では、iOS編をお届けします。お楽しみに!