SPARKFUL
죄송합니다. 이 페이지는 해당 언어로 번역이 되어 있지 않습니다. 열심히 노력 중이니 조금만 기다려주시면 감사하겠습니다!

Blog

Insights from the SPARKFUL teams

Unity - iOS TableView 完美移植

Ken Kaodev-notes
name

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  cellPositionList = new List();


        for (int i = 0; i  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 cellPositionList = m_CellPositionInfo [s.ToString ()];

        for (int i = 0; i  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) {

            >
        }

    }


    >

}
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 ();
    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();
        foreach (var render in renderers) {
            render.SetAlpha(0);
    }

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

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

    } else {

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

}

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

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

    Queue unusedCells = m_UnusedCells [identifier];

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

    var cell = unusedCells.Dequeue () ;

    var renderers = cell.gameObject.GetComponentsInChildren();
    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 m_AppList = new List () {"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 ("prefabs/simple_cell")).GetComponent ();
        }

        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.
Ken Kao
Ken Kao
Senior Developer @ Fourdesire. Keep trying new things.
divider
관련 게시물
name

在 Unity 打造簡易的動作系統

在產品的介面中,適當的提供有意義並精細的動畫,實用性上能直覺的讓使用者理解目前系統的狀態、取得回饋、幫助探索產品,更能在情感上提供愉悅的體驗。此篇將介紹如何在 Unity 環境中建構一個簡易的動作系統,來幫助快速開發介面的動態效果。動...더 많은 기사 읽기
name

用 Particle System 實作模糊效果的圓形進度條

實現圓形進度條的方式有很多種,最簡易的方式就是調整 Image 的參數: Image Type - Filled 、Fill Method - Radial 360、Fill Origin - Top,這篇文章 裡有更詳細的介紹。但這個方式無法符合所有設計稿的需求,例如模糊效果、圓角、流動效果...더 많은 기사 읽기
name

在 Unity 裡做 TDD -- FDMonoBehav Framework 的誕生

這篇文章主要是在說明我們如何在 Unity 專案中導入 TDD 開發方法。開發環境主要使用 C# 語言在 Unity 2017.4.4f1 + Visual Studio for Mac Community 7.5.1,並使用 NUnit 和 NSubstitute 來作為開發單元測試專案...더 많은 기사 읽기