2D virtualization

Do you need to develop a UI grid component displaying column-aligned cells for rows bound to specific items that the client would provide? Do yourself a favor and prepare for performance improvements when you design it.

I mean: don’t just expose row item and column definition arrays that the client could populate (or data bind) with what he or she wants displayed in your grid.

I know it’s generally not a very good practice to focus on optimizing your software in its early stages, but I think this is an exception: do think about virtualization as early as possible. Both for UI itself and at the data loading level!

UI virtualization means that you’d draw just what’s visible on screen (which is good too, but many times not enough.) Data virtualization would instead go a lot further: you would simply not require the client developers to pass the entire item and column arrays that they would eventually want scrollable, and ask them instead (through a delegate protocol, event, or whatever concept the platform supports) to pass you only those items and columns that are actually needed upon drawing, given the current viewport (e.g. based on the scroll view that you have on the client side).

Of course, you can ask them for a little more than just the visible data (better in background, to just have the data handy when the end user starts to scroll towards top, bottom, left, or right) and you can cache the data for specific viewport areas until the client lets you know that data changed (again, through a delegate protocol or event), but anyway having the cache configurable (as much as possible) by the caller, to allow him or her to easily balance between runtime performance and memory usage. (But this is surely another topic, now let’s focus only on data virtualization itself.)

While data virtualization is sometimes indeed provided for item collections to be vertically scrollable in a grid (1D virtualization), I’ve rarely seen it also implemented for the horizontal direction too (i.e. 2D virtualization). So here below is a small Swift code example that could – maybe – get you started.

(This is for an infinite grid, but of course, you can define total row count and total column count values in the provider delegate protocol, and then ensure the viewport is within the total size of the output at all times in its didSet callback.)

class GridView {
    // Defines the current area actually displayed.
    var viewport = Rectangle(left: 0, top: 0, right: 0, bottom: 0) {
       didSet { updateDataProvider() }
    }

    // Configure the default row height and column width values.
    var rowHeight = 10.0 { didSet { updateDataProvider() } }
    var columnWidth = 50.0 { didSet { updateDataProvider() } }

    // Provides data for the view.
    weak var dataProvider: GridDataProvider? {
       didSet { updateDataProvider() }
    }

    // Prepare the row/column ranges that the data provider should filter on.
    private func updateDataProvider() {
        guard let dataProvider = dataProvider else { return }
        dataProvider.rowRange = 
            Range(from: Int(viewport.top/rowHeight), 
                  to: Int(viewport.bottom/rowHeight))
        dataProvider.columnRange =
            Range(from: Int(viewport.left/columnWidth), 
                  to: Int(viewport.right/columnWidth))
    }

    // "Draws" visible (filtered) cells in viewport.
    func draw() {
        guard let dataProvider = dataProvider else { return }
        for cell in dataProvider.filteredCells {
            print(cell)
        }
    }
}

struct Rectangle {
    var left, top, right, bottom: Double
}
struct Range {
    var from, to: Int
}

protocol GridDataProvider: class {
    // Input
    var rowRange: Range { get set }
    var columnRange: Range { get set }

    // Output
    var filteredCells: [GridDataCell] { get }
}

struct GridDataCell {
    var row, column: Int
    var content: String
}

// The client will act as a data provider.
class Client: GridDataProvider {
    init() { }

    // These will be updated before the view needs filtered data.
    var rowRange = Range(from: 0, to: 0)
    var columnRange = Range(from: 0, to: 0)

    // This will be called by view when it needs to display data, 
    // and we'll filter it based on input row and column ranges.
    // We'd only need to read visible data from the "database".
    var filteredCells: [GridDataCell] {
        var cells = [GridDataCell]()
        for row in rowRange.from...rowRange.to {
            for column in columnRange.from...columnRange.to {
                cells.append(GridDataCell(row: row, column: column, 
                                          content: "..."))
            }
        }
        return cells
    }

    func run() {
        // Create a grid view and tell it that we will provide data for it.
        let gridView = GridView()
        gridView.dataProvider = self
        
        // Set the viewport to the space we'd actually want to "see",
        // supposedly from a client's scroll view.
        gridView.viewport = 
            Rectangle(left: 100, top: 100, right: 160, bottom: 125)

        // Display the visible cells.
        gridView.draw()
    }
}

Client().run()

The expected output would be like this (only rows 10-12 and columns 2-3 are visible):

GridDataCell(row: 10, column: 2, content: "…")
GridDataCell(row: 10, column: 3, content: "…")
GridDataCell(row: 11, column: 2, content: "…")
GridDataCell(row: 11, column: 3, content: "…")
GridDataCell(row: 12, column: 2, content: "…")
GridDataCell(row: 12, column: 3, content: "…")

But we’re not done! Some client developers would comply because they know for sure they don’t have much data, and instead they would still want to provide all cells (for all rows and columns) at once, and would like that out “fancy” grid to display it whenever needed, without the hassle of implementing any protocol.

For them we will need to also build a good helper class (to get back their smiles):

class GridDataSource: GridDataProvider {
    init(cells: [GridDataCell]) {
        self.cells = cells
    }

    // Cache all data in memory if the client developer uses this class.
    var cells: [GridDataCell]

    var rowRange = Range(from: 0, to: 0)
    var columnRange = Range(from: 0, to: 0)

    // We'll filter the fully available data set.
    var filteredCells: [GridDataCell] {
        return cells.filter { cell in
            cell.row >= rowRange.from && cell.row <= rowRange.to &&
            cell.column >= columnRange.from && cell.column <= columnRange.to 
        }
    }    
}

// The client is way more simple when it would use a data source
// (as if it were if we didn't support virtualization!)
class Client {
    init() { }

    func run() {
        // Create a full data source.
        let dataSource = GridDataSource(cells: [
            GridDataCell(row: 11, column: 2, content: "..."),
            GridDataCell(row: 11, column: 1, content: "..."),
            GridDataCell(row: 11, column: 4, content: "..."),
            GridDataCell(row: 15, column: 3, content: "...")])

        // Then create a grid view and use that data.
        let gridView = GridView()
        gridView.dataProvider = dataSource
        
        gridView.viewport = 
            Rectangle(left: 100, top: 100, right: 160, bottom: 125)
        gridView.draw()
    }
}

Client().run()

Here is the secondary output – only one cell is displayed (as expected in our case) – as the others are on invisible rows or columns. Whoa!

GridDataCell(row: 11, column: 2, content: "…")

About Sorin Dolha

My passion is software development, but I also like physics.
This entry was posted in Architecture, Development, Swift and tagged , , . Bookmark the permalink.

Add a reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s