Table View – Completed (Part 6 of 6)

It’s finally time to see how everything works together. In this post we will review the “Table View’s” code. This complex component will utilize the TableViewCell, Spacer, Flow, and Container elements we have already discussed to complete a very flexible component which accomplishes every goal I set out to achieve.

Table View Cell

You can open and review the completed “TableView.cs” script from the project, but I will point out and comment on several code snippets here.

public Func<TableView, int, int> sizeForCellInTableView;
public Func<TableView, int> cellCountForTableView;
public Action<TableView, TableViewCell, int> willShowCellAtIndex;
public Action<TableView, TableViewCell> willHideCell;

We will start with the Delegates. The table view has four special delegates. The first two are a “Func” type which means that they return a value (the last generic type in their definition). For example, “sizeForCellInTableView” is a delegate which passes an instance of a “TableView” (itself) and an “int” (an index of a cell) as parameters and expects to get back an “int” which is the “size” of the indicated cell it had asked for. This delegate is optional, and if you don’t subscribe to it, then it will use a constant size for all of its cells.

The second delegate is similar. It passes itself as a parameter and expects a return value of the number of cells which it should display. This delegate is required – if you don’t implement it then the TableView will not know how to load itself.

The last two delegates are optional. The TableView doesn’t care if you take any action or not when a cell is displayed or hidden. In most use cases you should still expect to implement the first. The “willShowCellAtIndex” is the perfect time for a controller to configure a cell. At this time you will know the TableView you are working with, the GameObject instance which holds the TableViewCell component, and the index of the cell. It should be very easy to grab some data and use that data to make the cell display itself in some sort of special way.

public int cellSize = 100;
public Point visibleRange { get; private set; }

ScrollRect scrollRect;
IntKeyedPooler cellPooler;
ISpacer spacer;
IContainer container;

The TableView is able to be implemented with only a handful of fields and properties. Only one field will appear in the Inspector – “cellSize”. This is the default size of cells which the TableView will use when you choose not to implement the “sizeForCellInTableView” delegate.

The “visibleRange” property won’t appear in the inspector but can be queried by another script in case you want to know which cells should be visible. The “Point” structure is a two “int” field data type, and I use the “x” to represent the index of the first visible cell and the “y” to represent the index of the last visible cell (I treat it as inclusive).

The rest of the dependencies which the TableView needs to work are obtained automatically. It specifies a “RequireComponent” tag for both a “ScrollRect” and a “IntKeyedPooler”. The “ScrollRect” will be responsible for scrolling the list of cells. Our “TableView” will automatically subscribe itself to the scroll event so it can know when to update the cells which should be displayed or hidden.

The reference to an “IntKeyedPooler” is used to be able to get cells by an index, but because this type of pooler is based on a “Dictionary”, I don’t need to be concerned with the “order” of the indices and I don’t have to have “all” of the indices.

The “spacer” and “container” references are not components. They are normal C# objects which will be created when the TableView is told to load itself, and the kinds it will create will be based on the settings of the “ScrollRect” and its “RectTransform” content.

void OnEnable ()
{
	scrollRect = GetComponent<ScrollRect>();
	cellPooler = GetComponent<IntKeyedPooler>();
	scrollRect.onValueChanged.AddListener(OnScroll);
	cellPooler.didDequeueForKey = OnDidDequeueForKey;
	cellPooler.willEnqueue = OnWillEnqueue;
}

void OnDisable ()
{
	scrollRect.onValueChanged.RemoveListener(OnScroll);
	cellPooler.didDequeueForKey = null;
	cellPooler.willEnqueue = null;
}

We use “OnEnable” to automatically cache the references to the Components which we will need. We can be guaranteed that they will exist because I used the “RequireComponent” tag. Then we subscribe to the “onValueChanged” event of the “ScrollRect” which lets us know when the scroll position has changed. Finally, we subscribe to events from the Pooler so that we can know when a cell has been “Dequeued” or “Enqueued”.

It is worth pointing out that I register for the “Dequeue” by “Key”, because I will want to know which index is relevant to loading the cell. However, I register for the “Enqueue” which does not include a key. This is because I will actually have several cells on screen which are technically occupying the same index. For example, if I have 10 cells and remove the 5th, then the 6th cell’s index is changed to be the 5th, but the original 5th cell has not been returned to the pool since it needs to animate off screen first. I can’t treat both cells with the same index because Dictionary keys must be unique.

In the “OnDisable” method we take responsibility for ourselves and remove the listeners we had subscribed with.

void OnScroll (Vector2 pos)
{
	Refresh();
}

Here is the method we used to listen to the “ScrollRect’s” scroll event. Any time this value has changed we tell the TableView to “Refresh” itself, which means it will hide and “Enqueue” cells that are not visible, and then “Dequeue” and show cells that should be.

void OnDidDequeueForKey (Poolable item, int key)
{
	TableViewCell cell = item.GetComponent<TableViewCell>();
	cell.transform.SetParent(scrollRect.content);
	cell.transform.localScale = Vector3.one;
	cell.gameObject.SetActive(true);

	container.Flow.ConfigureCell(cell, key);
	Vector2 offset = container.Flow.GetCellOffset(key);
	cell.Show(offset);

	if (willShowCellAtIndex != null)
		willShowCellAtIndex(this, cell, key);
}

Here is the method we used to listen for the “Pooler’s” dequeue event. We grab a copy of the “TableViewCell” component (which it expects to be found on whatever GameObject was used) and then begins to configure it. It begins by setting the cell’s parent to be the “content” of the “ScrollRect” so that it can inherit any motion from the scrolling. Since I set the parent, I make sure to reset the scale of the cell just in case the parent hierarchy had also been scaled (this could happen when you use CanvasScaler components). Next I make sure to set the object as “Active” since it had been “Disabled” in the pool.

The Flow will continue the configuration of the cell with anchor and pivot settings, and a width or height, so we pass the cell and index along. Then we get the position for the cell and tell it to “Show” itself at that location. Now that all the configuration is complete, we post a notification that the cell is about to be displayed so that other scripts can also have a chance to configure the cell. For example the data that the cell displays like titles and descriptions could be set at this point.

void OnWillEnqueue (Poolable item)
{
	TableViewCell cell = item.GetComponent<TableViewCell>();
	cell.Hide();

	if (willHideCell != null)
		willHideCell(this, cell);
}

When a cell is about to be Enqueued, we have an opportunity to take action here. We get a reference to the cell and let it snap to its Hidden panel position. This will stop any animation that might have been playing. We also post a potentially helpful action to any subscriber.

public void Reload ()
{
	cellPooler.EnqueueAll();
	visibleRange = new Point(int.MaxValue, int.MinValue);

	if (cellCountForTableView == null)
		return;
 	int rowCount = cellCountForTableView(this);

	if (sizeForCellInTableView != null)
	{
		spacer = new NonUniformSpacer((int index) => 
			{ 
				return sizeForCellInTableView(this, index); 
			}, rowCount);
	}
	else
	{
		spacer = new UniformSpacer(cellSize, rowCount);
	}

	if (scrollRect.horizontal)
		container = new HorizontalContainer(scrollRect, spacer);
	else
		container = new VerticalContainer(scrollRect, spacer);
	
	container.AutoSize ();
	scrollRect.content.anchoredPosition = Vector2.zero;
	Refresh();
}

This public method is one of the first that a “consumer” or “controller” script would call on the TableView. It lets the TableView know that all data has been loaded and is ready to be displayed. The first step we take in this method is to begin resetting everything. Just in case the TableView already had been displaying cells, we “Enqueue” them. The “visibleRange” property is then set to an invalid range indicating that no cells are visible.

Next we check to make sure that the required “cellCountForTableView” Func is available. If not we simply exit early. Otherwise we invoke the delegate and store the number of cells which need to be loaded in a local variable.

Next the TableView decides what kind of Spacer it will use. If the consumer has implemented the “sizeForCellInTableView” delegate then it will indicate to the TableView that the cell’s sizes are not uniform. Therefore it will create a “NonUniformSpacer” to handle this portion of logic. If that delegate had not been implemented then it will create a “UniformSpacer” based on whatever value is held by the TableView’s “cellSize” field.

Next the TableView decides what kind of Container it will use. If the “ScrollRect” is able to scroll horizontally, then it will assume the container will be a “HorizontalContainer”, otherwise it will implement a “VerticalContainer”.

Since we have a new collection of cells, we tell the Container to “AutoSize” itself so that the bounds of the ScrollRect are large enough to see everything. We also tell the scrollRect to scroll back to the beginning, and finally we call “Refresh” so that the visible cell range can be updated.

public void InsertCell (int index)
{
	InsertCellData(index);
	ApplyNewPositions();
}

public void InsertCells (HashSet<int> indexSet)
{
	List<int> indices = indexSet.ToSortedList();
	for (int i = 0; i < indices.Count; ++i)
		InsertCellData(indices[i]);
	ApplyNewPositions();
}

public void RemoveCells (int index)
{
	RemoveCellData(index);
	ApplyNewPositions();
}

public void RemoveCells (HashSet<int> indexSet)
{
	List<int> indices = indexSet.ToSortedList();
	for (int i = indices.Count - 1; i >= 0; --i)
		RemoveCellData(indices[i]);
	ApplyNewPositions();
}

The table view has the ability to dynamically insert or remove one or more cells at a time. In each case, it is a multi-step process where we must update the data in our model and then update any cells which had been on screen but are now in a “wrong” position based on their updated index.

When we Insert or Remove more than one cell at a time, I force the user to pass a “HashSet” of indices. This is because I want to make sure that the indices I use are “unique”. Adding the same index multiple times could have unexpected results. I also need to be able to sort the indices, because inserting cells out of order can cause other indices to change and again could result in unexpected results. I don’t trust the “consumer” to be responsible with unique keys or sorted keys, so I have forced a solution.

void Refresh ()
{
	Point range = container.Flow.GetVisibleCellRange();
	if (visibleRange == range)
		return;

	// Step 1: Reclaim any cells that are out of bounds
	for (int i = visibleRange.x; i <= visibleRange.y; ++i)
	{
		if (i < range.x || i > range.y)
			cellPooler.EnqueueByKey(i);
	}

	// Step 2: Load any cells that are missing
	for (int i = range.x; i <= range.y; ++i)
	{
		cellPooler.DequeueByKey(i);
	}

	visibleRange = range;
}

Here is the private implementation for “Refresh” which updates the visible range of cells in our table view. First things first, I ask the container to tell me the range of cells which should be visible based on the current scroll position. It is entirely possible that even though we have scrolled that no changes need to be made. When this is the case we simply exit early.

Otherwise, we update in a two step process. The first thing I do is attempt to reclaim any cells which have scrolled out of view. I want to do this first because if I return the cells now, they can be available when I need to request the next cell to display. It helps make sure that I never make more cells that I actually need.

The next step is to add any cells which should be visible but have not been displayed yet. The cell pooler is smart enough to only “Dequeue” a new cell, if it didn’t already have a cell for that key.

void InsertCellView (int index)
{
	TableViewCell cell = cellPooler.DequeueScriptByKey<TableViewCell>(index);
	Vector2 offset = container.Flow.GetCellOffset(index);
	cell.Insert(offset);
}

void RemoveCellView (int index)
{
	TableViewCell cell = cellPooler.GetScript<TableViewCell>(index);
	cellPooler.Collection.Remove(index);
	Tweener tweener = cell.Remove();
	tweener.completedEvent += (object sender, EventArgs e) => 
	{
		cellPooler.EnqueueScript(cell);
	};
}

These two methods are used with the dynamic insertion and removal of cell views from the TableView. These actions are animated. Note that when I remove a cell, a new cell will likely inherit its index. Since the pooler can’t hold two cells by the same key, the TableView takes responsibility for returning the animating cell back to the pool by using an animation completed event handler.

void InsertCellData (int index)
{
	List<int> keys = SortedKeys();
	for (int i = keys.Count - 1; i >= 0; --i)
	{
		int key = keys[i];
		if (key < index)
			break;

		Poolable item = cellPooler.Collection[key];
		cellPooler.Collection.Remove(key);
		cellPooler.Collection.Add(key+1, item);
		Vector2 offset = container.Flow.GetCellOffset(key);
		item.GetComponent<TableViewCell>().Pin(offset);
	}

	spacer.Insert(index);

	visibleRange = container.Flow.GetVisibleCellRange();
	if (index >= visibleRange.x && index <= visibleRange.y)
		InsertCellView(index);
}

The dynamic insertion of a cell has a lot of responsibility attached to it. Initially, I loop over all of cells which the TableView currently has a reference to. Any cells with an index greater than or equal to the index which is being inserted must be incremented. Note that I must perform this loop in reverse, from greatest to least, so that I dont accidentally increment a smaller key to be the same as a larger one. Keys must be unique.

Normally I would not recommend that you should modify the collection of a Pooler. As a normal workflow you should allow the pooler to completely handle adding to and removing from its own collection through its own interface. But in this special case I don’t want to Dequeue and then Enqueue all of the visible cells just to get the Collection back in sync – it would be a lot of extra work which isn’t needed.

Any visible cell view which has had its index changed is “Pinned” so that it knows it is now in a “wrong” location and needs to slide into a correct one.

After the loop, I let the Spacer know it should insert a new index so future calls will be correct, I update the new visible cell range, and finally, if the index which has been added is within the visible range, I Dequeue a new cell and animate it sliding into place.

void RemoveCellData (int index)
{
	List<int> keys = SortedKeys();
	for (int i = 0; i < keys.Count; ++i)
	{
		int key = keys[i];
		if (key < index)
			continue;
		else if (key == index)
			RemoveCellView(key);
		else
		{
			Poolable item = cellPooler.Collection[key];
			cellPooler.Collection.Remove(key);
			cellPooler.Collection.Add(key-1, item);
			Vector2 offset = container.Flow.GetCellOffset(key);
			item.GetComponent<TableViewCell>().Pin(offset);
		}
	}

	int height = spacer.GetSize(index);
	spacer.Remove(index);

	visibleRange = container.Flow.GetVisibleCellRange();
	for (int i = visibleRange.x; i <= visibleRange.y; ++i)
	{
		if (cellPooler.HasKey(i))
			continue;
		TableViewCell cell = cellPooler.DequeueScriptByKey<TableViewCell>(i);
		Vector2 offset = container.Flow.GetCellOffset(i, height);
		cell.Pin(offset);
	}
}

Removing a cell has the same kinds of responsibilities as adding a cell had. Any cell which had an index greater than the removed cell will need to be decremented, and positions will need to be pinned and later animated to the correct location.

One difference is that because I have removed cells, I might need to dequeue more cells to fill the gap left behind. This is done in a second loop where I make sure that the new visible range is fully populated.

void ApplyNewPositions ()
{
	container.AutoSize ();
	foreach (int key in cellPooler.Collection.Keys)
	{
		int index = key;
		Vector2 offset = container.Flow.GetCellOffset(index);
		TableViewCell cell = cellPooler.GetScript<TableViewCell>(index);
		Tweener tweener = cell.Shift(offset);
		if (tweener != null)
		{
			tweener.completedEvent += (object sender, EventArgs e) => 
			{
				if (index < visibleRange.x || index > visibleRange.y)
					cellPooler.EnqueueByKey(index);
			};
		}
	}
}

This method is called after I have inserted or removed cells. It makes sure to keep the container a valid size for the new number of cells. Then it loops over all of the cells the pooler holds a reference to and tells them to Shift to their correct places. Only cells which had been pinned will be affected by this call. Cells which do shift will return a “Tweener” which indicates that they are being animated into position. When this animation completes it is entirely possible that the cells will now be out of the visible range. If that is the case I return them to the pool.

List<int> SortedKeys ()
{
	List<int> keys = new List<int>(cellPooler.Collection.Keys);
	keys.Sort();
	return keys;
}

This is just a convenient method which gets a List of all the Keys contained by our pooler. It sorts them by ascending order and returns them.

Summary

In Part 6 (of 6) we finally saw the completed implementation of a new reusable component – our table view. It is a complex script, but this is due to its great flexibility. Still, there is plenty of room for additional features. As a mobile iOS programmer I am used to working with a very nice table view which is able to have “Sections” where each section has its own group of cells. Each section can also have a unique header and footer. Cells can also be re-ordered. These are all complex features that require great foresight for architecting. Perhaps in the future I might add a few more, but I felt that the current features are a pretty nice starting point for now.

Leave a Reply

Your email address will not be published. Required fields are marked *