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
- Image via Kārlis Dambrāns, CC Licensed.