Editable Grid in iOS

Rajdeep Kwatra
6 min readAug 11, 2022

--

Introduction

This article is focussed on using open-source library Proton. If you have not yet checked it out, you may read more about it in stories in this list.

Ever since I have started working on Proton, I have been asked if it is just as easy to create a Tabular structure in Proton Editor, as it is to create another type of content, like a Panel or an Expandable content. And my answer would always be, yes- of course! However, I wasn’t able to make time to try it out, until now.

This article talks about my experience of creating a tabular structure that can be hosted within the Editor. Each of the cells further contain the Editor itself, which makes it possible to put any type of content in a cell, including another table. Read on to get to know more about the implementation.

Initial exploration

My original idea was to use UICollectionView with custom layout. On the face of it, it seemed pretty achievable and it probably still is if we only consider the rendering part of the Table. However, since Tables are supposed to be editable as well, it was proven early on that that cell resizing, as content is typed, would be difficult if not impossible in a UICollectionView. Moreover, I also had in mind that eventually there needs to be support to merge/split cells as well as resize columns. Looking at all these requirements, I dropped the idea of using a UICollectionView after just a couple of spikes.

Final Implementation

The failed spikes using UICollectionView made it clear to me that the UI of Table needs to be as dumb as possible, and responsible only for rendering Table cells based on some data. The core logic of cells, sizes, merge/split concern etc. all need to be outside the Table UI. Hence, I created a simple data structure that works off a collection of columns widths and row heights, and generates the frames for each of the cell inside the table.

Table in Proton is called GridView to avoid confusion with any existing iOS component, particularly UITableView. GridView constitutes of following components:

  • Grid: Engine behind rendering of the GridView. Grid holds a collection of widths of each of the columns and heights for all the rows in separate array, ordered by respective indexes. It works off a CellStore that holds a collection of cell. GridView is responsible for logic of generating the frames of the cells, controlling the merging/splitting of cells and also additional logic/validating around adding/deleting rows and columns. This is internal to Proton.
  • GridCellStore: Holds a collection of GridCell and is responsible for actual deleting and addition of cells including logic to adjust the row/column indexes following addition or deletion of row/column. This is internal to Proton.
  • GridCell: A simple UIView that hosts a Proton EditorView and provides features like selection and customizing content like font, text color and background color. This is a public class.
  • GridContentView: A scrollable view that is uses Grid to generate and layout cells in the Table. It also provides the gesture support for selecting cells and resizing columns. This is internal to Proton.
  • GridView: Public class that encompasses all the aforementioned classes to provide a UIView that can then be used in an Attachment in Proton to be used within the Editor.

Implementation Overview

Generating cells:

One of the most interesting challenge was how to generate the cells such that they are able to be resized, merged and split back without involving complex logic or operations. There are multiple simple parts that come together to make one complex operation possible:

  • Each cell has an associated array of column and row indexes. Merging cells means concatenating the rows and/or columns in these arrays, whereas Splitting would mean creating new cells with individual indexes for rows and columns.
  • Information about Width and Height of each of the cell is not responsibility of the cell. In fact, cell only gets its frame whenever layout of grid takes place. A seperate object, Grid, holds the collection of widths for each of the columns and heights for each of the rows. This allows to calculate frame of the cell based on its index and column width configuration, which can be fixed or fractional (based on container).
  • When a new row or column is added, the new height/width is appended to the row or column collection at the assigned index.
  • Deleting a row or column deletes the item at given index from row heights or column widths.
  • Having the row heights and column widths separate allows to simplify relayout calculations by a great degree. Frames are always calculated based on these collections, and relayout only requires updating these collections and rest all happens automatically.

Rendering grid:

Rendering the grid on the UI is another place where a very simple approach is used. Each of the GridCell is a UIView. Typically, adjacent views have autolayout constraints amongst each other. This would have been relatively straightforward with a simple table without merge/split behaviour. However, with operations like delete/add of rows and columns and merging/splitting of cells, it would have been very complex to maintain.

In order to simplify the approach, each of the cells is anchored from top and leading edge of the GridContentView which is a scrollable container for the cells. Each of the cells is laid out in reference to these edges. This means that the layout is actually based on the frame of the cell as calculated while generating the cells and will always work irrespective of merged cells placements with respect to other cells around it.

Following illustration tries to highlight the approach taken to render the grid:

Cell layout in GridView

Merging/Splitting rows and columns:

As mentioned above, merging and splitting only requires adding column or row indexes in the collection information held by the cell. This does not affect the row height and column width collection held by the Grid.

Following are the operations that occur when a cell is merged with another:

  • Validate if the cells can be merged — for cells to be mergable, the aggregate frame of selected cells needs to be a rectangle without any holes in it.
  • First cell is taken and it’s rows and columns collection are updated to include rows and columns of all the other cells passed in. Merge operation can be performed on any number of cells as long as the validation passes.
  • The actual cell instances all cells other than first one are deleted from the CellStore which holds collection of all the cells.
  • Once the cell information is updated, layout is invalidated which invokes relayout of grid:
  • Frame of each of the cell is calculated based on container size.
  • Top and Leading anchor constraints are updated based on the new frame values for the cells.
  • Intrinsic content size is invalidated which updates the new information on the UI.

Split operation works pretty much the same but instead of adding row and column information in the cells, the cell’s row and column information is broken down to new cells with each having one index for row and columns. Also, newly generated cells are then added to the CellStore and new constraints are added for the top and leading anchors from container.

Relayout code for grid stays exactly the same when rendering grid for the first time, and also when merging/splitting and adding/deleting rows and columns.

Further exploration

To explore further, feel free to take a look at the Grid code in Proton. The implementation is pretty straightforward and hopefully, will be easy to follow. A good starting point would be this documentation.

--

--

Rajdeep Kwatra

Rajdeep is an iOS developer at Atlassian. He believes that a piece of code can always be improved, but the cost may not always be justified against the benefits