TableView 在 app 的開發中可以說是最常使用到的元件了,不論是純遊戲或是應用工具類型都缺少不了它,甚至不少 app 開發的第一課就是嘗試製作一個簡易的 TableView。在 Unity 中雖然有提供 UGUI,但使用起來總是覺得不順手,既然如此何不嘗試自己來封裝一個好用的 TableView!在以往的開發經驗中,iOS 的 TableView 算是使用起來覺得比較容易上手並且介面定義的非常清楚,所以就決定拿他來當做我們模仿的對象!


定義接口

如果各位有有開發過 iOS 的經驗,應該很熟悉 TableView 在使用時會有兩個重要的接口需要實做,分別是 UITableviewDelegate 、 UITableViewDataSource ,為了讓我們可以順暢的使用,所以這邊也定義相似的介面來作為TableView 的實際使用時的資料來源

public interface FDScrollRectTableViewDataSource {

    int NumberOfRowsInSection (FDScrollRectTableView tableView, int section);

    int NumberOfSectionsInTableView (FDScrollRectTableView tableView);
}

public interface FDScrollRectTableViewDelegate {

    float HeightForRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath);

    FDScrollRectTableViewCell CellForRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath);

    float HeightForHeaderViewInSection (FDScrollRectTableView tableView, int section);

    FDScrollRectTableAnnotationView HeaderViewInSection (FDScrollRectTableView tableView, int section);

    bool ShowSectionHeaderView (FDScrollRectTableView tableView);

    float HeightForFooterViewInSection (FDScrollRectTableView tableView, int section);

    FDScrollRectTableAnnotationView FooterViewInSection (FDScrollRectTableView tableView, int section);

    bool ShowSectionFooterView (FDScrollRectTableView tableView);

    void DidSelectRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath);

    void DidDeselectRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath);

    void DidScroll (FDScrollRectTableView tableView, float contentOffset);
}

稍微解釋一下的這兩個介面的作用
FDScrollRectTableViewDataSource
—  提供 TableView 目前的資料分成幾個 section 並且每個 section 中包含多少筆資料
FDScrollRectTableViewDelegate
—  提供 TableView 要呈現的 content ,包含 row cell, section header, section footer ,以及處理 TableView 的 click event , scroll event

需要更詳細的資料可以參考 Apple Developer  Documentation [#UITableViewDataSource] [#UITableViewDelegate]

定義資料類別

開始實作之前我們先來定義接下來會需要使用到的資料類別

  • FDIndexPath — 用來表示 Cell 在 TableView 中的座標

public struct FDIndexPath {

    public int Row;
    public int Section;

    public bool EqualsIndexPath (FDIndexPath obj) {
        return this.Row == ((FDIndexPath)obj).Row && this.Section == ((FDIndexPath)obj).Section;
    }
}

  • FDTableViewCellContainer — 用來儲存 Cell 在 Tableview 中的資料

public class FDTableViewCellContainer {

    public FDIndexPath IndexPath;
    public FDScrollRectTableViewCell Cell;
    public float Height;
}

  • FDTableViewSectionAnnotationContainer — 用來儲存 Section Header 和 Section Footer 在 Tableview 中的資料

public class FDTableViewSectionAnnotationContainer {

    public FDScrollRectTableAnnotationView Annotation;
    public int Section;
    public float Height;
}

  • FDScrollRectTableViewCell — 定義 Cell 的基礎類別,之後使用的 Cell 都必須繼承這個類別去實作,比較值得注意的是 CellIdentifier 這個屬性,稍後會利用他來實現 Cell Reuse的功能,另外我們也利用實作 Unity EventSystem 的介面來接收點擊的事件

public class  FDScrollRectTableViewCell : MonoBehaviour, IPointerClickHandler{

    public string CellIdentifier = "default";
    public Text TitleText;
    public Text DescriptionText;
    public FDTableViewCellDelegate TableViewCellDelegate;

    virtual public void OnPointerClick (PointerEventData eventData) {

        if (TableViewCellDelegate != null) {
            TableViewCellDelegate.OnCellPointerClick (this, eventData);
        }
    }
}

public interface FDTableViewCellDelegate {

    void OnCellPointerClick (FDScrollRectTableViewCell cell, PointerEventData eventData);

}

  • FDScrollRectTableAnnotationView — Section Header 和 Section Footer 的基礎類別, 一樣定義了 Identifier 來幫助 Reuse

public class FDScrollRectTableAnnotationView : MonoBehaviour {

    public Text SectionTitleView;
    public Image IconImage;
    public string Identifier = "default";
}


方法實作

經過一長串的準備動作後,我們終於可以開始實做 TableView 啦 !!! 為了避免說明太過複雜,在這篇文章中我們就只先介紹最最基本及核心的幾個函式

  • ReloadData — 當 TableView 呈現的資料出現變動時, 呼叫這個方法來更新
  • DequeueUnusedCell — 取出已建立好的cell使用 , 避免每次都需要建立新的cell
  • ScrollToOffset — 滑動 TableView 到指定的位置
  • LayoutSubViews — 排列 TableView 的 content
  • RecycleCell — 當 cell 超過顯示範圍時回收等待下次使用

預想中目前的程式碼應該會是下面這個樣子:

[RequireComponent (typeof(ScrollRect))]
public class FDScrollRectTableView : MonoBehaviour, FDTableViewCellDelegate {

    public FDScrollRectTableViewDataSource TableViewDataSource;
    public FDScrollRectTableViewDelegate TableViewDelegate;

    ScrollRect m_ScrollRect;

    void Start () {
        m_ScrollRect = this.GetComponent ();
        m_ScrollRect.onValueChanged.AddListener (OnScrollChanged);
    }

    #region Public Method
    public void ReloadData() {
        // TODO: 
    }

    public void ScrollToOffset (float offset) {
        // TODO:
    }

    public void DequeueUnusedCell (string identifier = "Default") {
        // TODO:
    }
    #endregion

    #region Private Method

    void LayoutSubViews() {
        // TODO:
    }

    void RecycleCell(FDScrollRectTableViewCell cell) {
        // TODO:
    }

    #endregion

    #region FDTableViewCellDelegate
    public void OnCellPointerClick (FDScrollRectTableViewCell cell, PointerEventData eventData) {

        // TODO:
    }
    #endregion

    void OnScrollChanged (Vector2 position) {

        // TODO: Update Layout
    }
}

眼尖的人可能會發現這邊偷偷多出了 ScrollRect,這是因為我們利用 Unity ScrollRect 來實現滑動的功能,並且通過監聽 onValueChanged event 來適時更新 TableView 需要顯示的內容接下來只要一步步把這些 Function 完成,我們就順利的打造出一個簡易可重複利用的 TableView 啦!!


畫面排列

初始化 TableView 或是資料有變動時都會呼叫 Reload 來進行畫面的更新,在這個 Function 主要是整理 Cell 、 Header 、 Footer 的高度及位置資訊並封裝成 Container 方便 Layout 時使用:

public void ReloadData () {

    if (TableViewDelegate == null || TableViewDelegate == null) {
        return;
    }

    ClearCellAndInfo();

    int numberOfSections = TableViewDataSource.NumberOfSectionsInTableView(this);
    int numberOfRows = 0;

    float estimatedHeight = 0;
    float sectionStartOffset = 0;
    float headerHeight = 0, footerHeight = 0;

    bool showSectionHeaders = TableViewDelegate.ShowSectionHeaderView(this);
    bool showSectionFooters = TableViewDelegate.ShowSectionFooterView(this);


    for (int s = 0; s < numberOfSections; s++) {

        sectionStartOffset = estimatedHeight;

        // Section Header 
        if (showSectionHeaders) {


            headerHeight = TableViewDelegate.HeightForHeaderViewInSection(this, s);

            m_SectionHeaders.Add(new FDTableViewSectionAnnotationContainer() {

                Section = s,
                Height = headerHeight
            });
            estimatedHeight += headerHeight;
        }

        // Cells
        numberOfRows = TableViewDataSource.NumberOfRowsInSection(this, s);

        FDTableViewCellContainer[] cellContainers = new FDTableViewCellContainer[numberOfRows];

        List<float> cellPositionList = new List<float>();


        for (int i = 0; i < numberOfRows; i++) {

            FDIndexPath indexPath = new FDIndexPath() {
                Section = s,
                Row = i
            };
            FDTableViewCellContainer container = new FDTableViewCellContainer();
            container.IndexPath = indexPath;
            container.Height = TableViewDelegate.HeightForRowAtIndexPath(this, indexPath);

            cellContainers[i] = container;

            if (i % m_BlockNum == 0) {
                cellPositionList.Add(estimatedHeight);
            }
            estimatedHeight += container.Height;
        }

        m_CellContainers.Add(cellContainers);
        m_CellPositionInfo[s.ToString()] = cellPositionList;

        // Section Footer 
        if (showSectionFooters) {

            footerHeight = TableViewDelegate.HeightForFooterViewInSection(this, s);

            m_SectionFooters.Add(new FDTableViewSectionAnnotationContainer() {
                Section = s,
                Height = footerHeight
            });
            estimatedHeight += footerHeight;
        }

        // Save Section Position Info
        String startKey = String.Format("{0}-StartOffset", s);
        String heightKey = String.Format("{0}-TotalHeight", s);
        m_SectionPositionInfo[startKey] = sectionStartOffset;
        m_SectionPositionInfo[heightKey] = estimatedHeight - sectionStartOffset;

    }


    ScrollRect().content.sizeDelta = new Vector2(m_ScrollRect.content.sizeDelta.x, estimatedHeight);


    LayoutSubViews();

}

Layout 的重點大致上來說有二項:

  1. 避免不必要的更新 — 確認可視區域需要顯示的物件有無變動,沒有變動時則不需要重新 Layout
  2. 重複使用物件 — 只有當 container 包含的 cell 或是 header 為 null 才會跟 DataSource 要求新物件及更新內容,其餘時候只更新位置:

void LayoutSubViews () {


    if (m_CellContainers == null)
        return;

    if (!needUpdate ()) 
        return;

    bool showSectionHeaders = TableViewDelegate.ShowSectionHeaderView (this);
    bool showSectionFooters = TableViewDelegate.ShowSectionFooterView (this);

    for (int s = 0; s < m_CellContainers.Count; s++) {

        // Find Visible Section 
        String startKey = String.Format ("{0}-StartOffset", s);
        String heightKey = String.Format ("{0}-TotalHeight", s);
        float sectionStart = m_SectionPositionInfo [startKey];
        float sectionEnd = sectionStart + m_SectionPositionInfo [heightKey];

        // Section out of visible region
        if (sectionStart > visibleEnd)
            break;

        bool visibleRegion = sectionEnd > visibleStart;

        if ( !visibleRegion ) {
            localOffset -= m_SectionPositionInfo [heightKey];
            continue;
        }

        offset = sectionStart;

        if (showSectionHeaders) {

            var sectionHeader = m_SectionHeaders [s];
            bool visible = checkSectionVisible(offset, sectionHeader.Height);

            if (visible) {

                if (sectionHeader.Annotation == null) {

                    sectionHeader.Annotation = AnnotaionFromDelegate(s);
                }

                SetupRect(sectionHeader.Annotation, localOffset, sectionHeader.Height);

            }

            localOffset -= sectionHeader.Height;
            offset += sectionHeader.Height;
        }

        // Find Nearest Cell Block 
        int startCellRow = 0;
        List<float> cellPositionList = m_CellPositionInfo [s.ToString ()];

        for (int i = 0; i < cellPositionList.Count - 1; i++) {

            bool visible = checkCellVisible(s, i);

            offset = cellPositionList [i];
            localOffset = -m_TableHeaderHeight - offset;

            if (visible)
                break;

            if (i == cellPositionList.Count - 2) {

                offset = cellPositionList [i + 1];
                localOffset = -m_TableHeaderHeight - offset;
            }

            startCellRow += m_BlockNum;
        }

        FDTableViewCellContainer [] containers = m_CellContainers [s];

        for (int i = startCellRow; i < containers.Length; i++) {

            FDTableViewCellContainer container = containers [i];

            if (offset > visibleEnd)
                break;

            bool visible = (offset + container.Height) > visibleStart;

            if (visible) {

                if (container.Cell == null) {

                    container.Cell = CellFromDelegate(container.IndexPath);

                }

                SetupRect(container.Cell, localOffset, container.Height);
            }

            localOffset -= container.Height;
            offset += container.Height;
        }


        if (showSectionFooters) {

            << Same as Section Header >>
        }

    }


    << Recycle Unused Cell & Annotations >>

}

FDScrollRectTableAnnotationView AnnotaionFromDelegate (int section) {

    var annotation = TableViewDelegate.HeaderViewInSection (this, section);
    annotation.transform.SetParent (m_ScrollRect.content.transform, false);
    annotation.transform.localScale = Vector3.one;
    annotation.transform.SetSiblingIndex (1);

    return annotation;
}

FDScrollRectTableViewCell CellFromDelegate(FDIndexPath indexPath) {

    var cell = TableViewDelegate.CellForRowAtIndexPath (this, indexPath);
    cell.transform.SetParent (m_ScrollRect.content.transform, false);
    cell.transform.localScale = Vector3.one;
    cell.TableViewCellDelegate = this;

    return cell;
}

void SetupRect(object rectObj, float offset, float height) {

    RectTransform rect = rectObj.GetComponent<RectTransform> ();
    rect.anchoredPosition = new Vector3 (0f, offset);
    rect.sizeDelta = new Vector2 (rect.sizeDelta.x, height);
    rect.offsetMax = new Vector2 (0f, rect.offsetMax.y);
    rect.offsetMin = new Vector2 (0f, rect.offsetMin.y);
}


物件重複使用

因為 Unity 中 Instantiate 的動作很吃資源,為了避免過於頻繁的重複這個動作, 當某些 Cell 暫時不用呈現在畫面中,我們將它根據不同的 Cell identifier 放入不同的序列中儲存並將 render 的透明度設為0, 當需要時再取出來使用,而 Section Header 與 Section Footer 也是類似的寫法,這邊就不重複說明了。

void RecycleCell (FDScrollRectTableViewCell cell) {

    var renderers = cell.gameObject.GetComponentsInChildren<CanvasRenderer>();
        foreach (var render in renderers) {
            render.SetAlpha(0);
    }

    if (m_UnusedCells.ContainsKey (cell.CellIdentifier)) {

        m_UnusedCells [cell.CellIdentifier].Enqueue (cell);

    } else {

        Queue<FDScrollRectTableViewCell> unusedCells = new Queue<FDScrollRectTableViewCell> ();
        unusedCells.Enqueue (cell);
        m_UnusedCells.Add (cell.CellIdentifier, unusedCells);
    }

}

public FDScrollRectTableViewCell DequeueUnusedCell (string identifier = "default") {

    if ( ! m_UnusedCells.ContainsKey (identifier))
        return null;

    Queue<FDScrollRectTableViewCell> unusedCells = m_UnusedCells [identifier];

    if(unusedCells.Count == 0)
        return null;

    var cell = unusedCells.Dequeue () ;

    var renderers = cell.gameObject.GetComponentsInChildren<CanvasRenderer>();
    foreach (var render in renderers) {
        render.SetAlpha(1);
    }

    return cell;

}


滑動及點擊控制

接收到 ScrollRect 回傳的 event 後,呼叫 Delegate 處理滑動時的變化並更新需要顯示的內容:

void OnScrollChanged (Vector2 position) {

    m_ContentOffset = (1 - position.y) * m_ScrollHeight;

    if (TableViewDelegate != null) {
        TableViewDelegate.DidScroll (this, m_ContentOffset);
    }

    LayoutSubViews ();
}

控制 ScrollRect 滑動到指定的位置,這邊需要特別注意一下我們使用的座標係是由上到下( 0 -> 1 ),跟 Unity 本身的座標系是相反的:

public void ScrollToOffset (float offset) {

    if (offset >= m_ScrollHeight)
        offset = m_ScrollHeight;

    m_ScrollRect.verticalNormalizedPosition = 1 - (offset / m_ScrollHeight);

}

更新目前選中的 IndexPath, 並呼叫 Delegate 處理 Select 、 Deselect event

public void OnCellPointerClick (FDScrollRectTableViewCell cell, PointerEventData eventData) {

    FDIndexPath indexPath = IndexPathOfVisibleCell (cell);
    var selectedCell = CellAtIndexPath (m_Selected);

    cell.SetSelected (true, animated);

    if (selectedCell != null)
        selectedCell.SetSelected (false, animated);

    if (TableViewDelegate != null) {
        TableViewDelegate.DidDeselectRowAtIndexPath (this, m_Selected);
        TableViewDelegate.DidSelectRowAtIndexPath (this, indexPath);
    }

    m_Selected = indexPath;
}


使用範例

最後示範一下如何使用我們封裝好的 TableView,以下的範例為顯示公司產品的清單, 只要實作我們剛剛定義好的 DataSource 及 Delegate 介面就可以輕輕鬆鬆在五分鐘內完成一個 TableView:

public class SimpleTableView : MonoBehaviour, FDScrollRectTableViewDataSource, FDScrollRectTableViewDelegate {

    public FDScrollRectTableView TableView;

    List<string> m_AppList = new List<string> () {"Plant Nanny",
                                                    "Walker",
                                                    "Fortune City"};

    protected override void Start () {
        base.Start ();

        TableView.TableViewDelegate = this;
        TableView.TableViewDataSource = this;

        TableView.ReloadData ();
    }

    #region FDScrollRectTableViewDelegate implementation

    public float HeightForRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath) {
        return 96f;
    }

    public FDScrollRectTableViewCell CellForRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath) {


        var cell = tableView.DequeueUnusedCell ("SimpleCell") as FDScrollRectTableViewCell;

        if (cell == null) {
            cell = GameObject.Instantiate (Resources.Load<GameObject> ("prefabs/simple_cell")).GetComponent<FDScrollRectTableViewCell> ();
        }

        cell.TitleView.text = m_AppList[indexpath.Row];

        return cell;
    }

    public float HeightForHeaderViewInSection (FDScrollRectTableView tableView, int section) {

        return 0;
    }

    public FDScrollRectTableAnnotationView HeaderViewInSection (FDScrollRectTableView tableView, int section) {

        return null;
    }

    public bool ShowSectionHeaderView (FDScrollRectTableView tableView) {

        return false;
    }

    public float HeightForFooterViewInSection (FDScrollRectTableView tableView, int section) {

        return 0;
    }

    public FDScrollRectTableAnnotationView FooterViewInSection (FDScrollRectTableView tableView, int section) {

        return null;
    }

    public bool ShowSectionFooterView (FDScrollRectTableView tableView) {

        return false;
    }

    public void DidSelectRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath) {

    }

    public void DidDeselectRowAtIndexPath (FDScrollRectTableView tableView, FDIndexPath indexPath) {

    }

    public void DidScroll (FDScrollRectTableView tableView, float contentOffset) {

    }

    #endregion

    #region FDScrollRectTableViewDataSource implementation

    public int NumberOfRowsInSection (FDScrollRectTableView tableView, int section) {

        return m_AppList.Count;
    }

    public int NumberOfSectionsInTableView (FDScrollRectTableView tableView) {

        return 1;
    }

    #endregion

}


結語

本篇文章中只介紹了最簡易的 TableView 封裝概念,其他像是 TableView Header, Empty Status, Multiple Seclection … 等其他功能,讀著有興趣的話可以自行擴充,未來也有可能在進階篇中介紹。

References

[1] Image via Kārlis Dambrāns, CC Licensed.