Unity UI(uGUI)でリストビューをAndroidのListView/iOSのUITableViewっぽく実装できるスクリプトを作ってみた

さて、今年のAdvent Calendarも13日目。
ちょうど折り返し地点ですね。

~~担当日を忘れて電子回路の勉強ばっかりしてたので、以前作ったプログラムを引っ張り出してきました。~~
ゲフンゲフン。えーと。
今回は電子工作の遊び記事から一転、Unityの実用的なネタでいこうと思います。

# Unity UIでリストビューを作ろうとした時、みんなハマる落とし穴
Unity UI(uGUI)では、ScrollRectを使うとリストビュー的なものが作れます。

ただ、ScrollRectはあくまでスクロールを制御するscriptですので、普通に使うとリスト内の要素（今回はセルと呼びます）は全部先に生成して放り込んでおく形になります。
そのため、セルが増えるとパフォーマンスに思い切り影響を及ぼします。

セル数百個くらいになると初期化（Instantiate）は激遅、スクロール操作もカクついちゃったりするわけで。

# どうやって解決する？
かのテラシュールウェア様が正解を記事にしてくださっています。（いつもお世話になっておりますm(_ _)m）
http://tsubakit1.hateblo.jp/entry/2015/01/21/233000

画面外に出たセルを再利用する形。
ウン、これですよこれ。

~~ということで、今回の記事はおしまい。~~
ということで、今回は↑のscriptをカスタムしてみました。
（ほぼ原型を留めていません。。ｗ）

# 汎用的かつ使いやすい形にカスタムしてみた
イメージとしては、AndroidのListView/iOSのUITableViewを目指しました。
どっちかって言うとAndroidに近いかも？

#ソースはこんな感じ
↓はScrollRectのContentに紐付けるscript。

外部からコールできるのは初期化用のInitializeメソッドと、データが変わった時の再描画用Refleshメソッドのみです。
あとはPaddingとかスクロール方向とかの基本的な設定のみ。

```C#:RecyclableItemsScrollContent.cs
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using System.Linq;
using UnityEngine.EventSystems;
using System;

public class RecyclableItemsScrollContent : UIBehaviour
{
    public Padding padding;
    public int spacing;
    public Direction direction;

    [SerializeField, Range(0, 20)]
    int instantateItemCount = 7;

    List<RectTransform> items = new List<RectTransform>();
    float diffPreFramePosition = 0f;
    int currentItemNo = 0;
    List<float> positionCaches = new List<float>();
    IRecyclableItemsScrollContentDataProvider dataProvider;

    public enum Direction
    {
        Vertical,
        Horizontal,
    }

    RectTransform rectTransform;

    protected RectTransform RectTransform
    {
        get
        {
            if (rectTransform == null)
            {
                rectTransform = GetComponent<RectTransform>();
            }
            return rectTransform;
        }
    }

    float AnchoredPosition
    {
        get
        {
            return direction == Direction.Vertical ? 
                -RectTransform.anchoredPosition.y :
                RectTransform.anchoredPosition.x;
        }
    }

    protected override void Start()
    {
        var scrollRect = GetComponentInParent<ScrollRect>();
        scrollRect.horizontal = direction == Direction.Horizontal;
        scrollRect.vertical = direction == Direction.Vertical;
        scrollRect.content = RectTransform;
    }

    void Update()
    {
        if (null == dataProvider)
        {
            return;
        }

        while (true)
        {
            var itemScale = GetItemScale(currentItemNo);
            if (itemScale <= 0 || AnchoredPosition - diffPreFramePosition >= -(itemScale + spacing) * 2)
            {
                break;
            }

            var item = items[0];
            items.RemoveAt(0);
            diffPreFramePosition -= itemScale + spacing;
            items.Add(GetItem(currentItemNo + instantateItemCount, item));

            currentItemNo++;
        }

        while (true)
        {
            var itemScale = GetItemScale(currentItemNo + instantateItemCount - 1);
            if (itemScale <= 0 || AnchoredPosition - diffPreFramePosition <= -(itemScale + spacing) * 1)
            {
                break;
            }

            var item = items[items.Count - 1];
            items.RemoveAt(items.Count - 1);

            currentItemNo--;

            diffPreFramePosition += GetItemScale(currentItemNo) + spacing;
            items.Insert(0, GetItem(currentItemNo, item));
        }
    }

    public void Initialize(IRecyclableItemsScrollContentDataProvider dataProvider)
    {
        this.dataProvider = dataProvider;

        if (items.Count == 0)
        {
            for (var i = 0; i < instantateItemCount; i++)
            {
                items.Add(GetItem(i, null));
            }
        }
        else
        {
            positionCaches.Clear();
            for (var i = 0; i < instantateItemCount; i++)
            {
                var item = items[0];
                items.RemoveAt(0);
                items.Add(GetItem(currentItemNo + i, item));
            }
        }

        var rectTransform = GetComponent<RectTransform>();
        var delta = rectTransform.sizeDelta;
        if (direction == Direction.Vertical)
        {
            delta.y = padding.top + padding.bottom;
            for (var i = 0; i < dataProvider.DataCount; i++)
            {
                delta.y += GetItemScale(i) + spacing;
            }
        }
        else
        {
            delta.x = padding.left + padding.right;
            for (var i = 0; i < dataProvider.DataCount; i++)
            {
                delta.x += GetItemScale(i) + spacing;
            }
        }
        rectTransform.sizeDelta = delta;
    }

    float GetItemScale(int index)
    {
        if (null == dataProvider || dataProvider.DataCount == 0)
        {
            return 0;
        }
        return dataProvider.GetItemScale(Math.Max(0, Math.Min(index, dataProvider.DataCount - 1)));
    }

    RectTransform GetItem(int index, RectTransform recyclableItem)
    {
        if (null == dataProvider || index < 0 || dataProvider.DataCount <= index)
        {
            if (null != recyclableItem)
            {
                recyclableItem.gameObject.SetActive(false);
            }
            return recyclableItem;
        }
        var item = dataProvider.GetItem(index, recyclableItem);
        if (item != recyclableItem)
        {
            item.SetParent(transform, false);
        }
        item.anchoredPosition = GetPosition(index);
        item.gameObject.SetActive(true);
        return item;
    }

    public void Reflesh()
    {
        Initialize(dataProvider);
    }

    float GetPositionCache(int index)
    {
        for (var i = positionCaches.Count; i <= index; i++)
        {
            positionCaches.Add(i == 0 ? (direction == Direction.Vertical ? padding.top : padding.left) : (positionCaches[i - 1] + GetItemScale(i - 1) + spacing));
        }
        return positionCaches[index];
    }

    Vector2 GetPosition(int index)
    {
        if (index < 0)
        {
            return Vector2.zero;
        }
        return direction == Direction.Vertical ? new Vector2(0, -GetPositionCache(index)) : new Vector2(GetPositionCache(index), 0);
    }

    [System.Serializable]
    public class Padding
    {
        public int top = 0;
        public int right = 0;
        public int bottom = 0;
        public int left = 0;
    }
}
```

続いて、リストビューのデータプロバイダインターフェース。
セルの個数を返すメソッド、セルの高さor幅（ScrollRectのスクロール方向による）を返すメソッド、セルを返すメソッドだけで、とってもシンプルです。

```C#:IRecyclableItemsScrollContentDataProvider.cs
using UnityEngine;
using System.Collections;

public interface IRecyclableItemsScrollContentDataProvider
{
    int DataCount { get; }

    float GetItemScale(int index);

    RectTransform GetItem(int index, RectTransform recyclableItem);
}
```

#実装してみる
↑のscriptを使った実装サンプルです。

ContentとCellの設定にちょいとクセがある（スクロールの向きに応じて、AnchorsとPivotを正しく指定しないと表示がおかしくなる）ので、サンプルプロジェクトを用意しました。
https://github.com/akako/recyclable-items-scroll-view-sample

実装は上記プロジェクトの SampleSimple シーンをご覧ください。

動かしてみるとこんな感じ。
![scrollview.gif](https://qiita-image-store.s3.amazonaws.com/0/74903/3c922415-048c-71d7-3da9-f2000e4dd5b9.gif)

#応用編：ひとつのリスト内で複数種類のセルを再利用してみる
例えば、リスト内にキャラ情報のセルとアイテム情報のセルを混在させたい場合などを考えてみましょう。

セル内のごく一部が変わるだけであれば良いのですが、↑の例ようにセルの内容が全然異なる場合、ひとつのscript上に無理やり詰め込むと非常に残念な事になります。

そんな時は、データプロバイダ側のクラスでセルオブジェクトをプールしておくようにすれば、セルのクラスを分けつつInstantiateの回数も抑えた形で実現することが出来ます。
（サンプルプロジェクトにある SampleMultipleTypeCell シーンで実装しています）

動かしてみるとこんな感じ。
![scrollview2.gif](https://qiita-image-store.s3.amazonaws.com/0/74903/fceb5037-1b29-aaa9-b96c-d73b0c3ddc90.gif)


ちと荒削りですが、良ければ使ってみてくださいまし！