Loading...
Searching...
No Matches
Tutorial: The TableListBox class

Incorporate tables into your JUCE user interfaces. Display data loaded from an XML file and customise the format of your table.

Level: Intermediate

Platforms: Windows, macOS, Linux

Classes: TableListBox, TableListBoxModel, ListBox, ListBoxModel, TableHeaderComponent, XmlDocument, XmlElement

Getting started

Download the demo project for this tutorial here: PIP | ZIP. Unzip the project and open the first header file in the Projucer.

Warning
If using the PIP version of this project, please make sure to copy the Resources folder into the generated Projucer project.

If you need help with this step, see Tutorial: Projucer Part 1: Getting started with the Projucer.

The demo project

The demo project displays a table loaded from an XML file containing information about JUCE modules. The table can be sorted according to the column of your choice, certain entries can be edited and columns can be hidden.

The app window

The ListBox class

The base Component class that allows us to display tables in JUCE is called the ListBox class. The ListBox behaviour is managed by a ListBoxModel class which describes the data model to display. This is useful to display a list of items in a scrollable viewport but in order to turn this into a proper table with headers describing the columns, we can use respectively the TableListBox and TableListBoxModel classes. These classes encapsulate the same behaviour as their counterparts but instead incorporate the functionalities of a TableHeaderComponent to display column headers.

When implementing a TableListBox and inheriting the TableListBoxModel, there are several functions to override namely:

  • getNumRows(): Needs to return the current number of rows in the table.
  • paintRowBackground(): Using the Graphics context provided, we must draw the background of the row specified by the row number.
  • paintCell(): Using the Graphics context provided, we must draw the cell specified by the row and column number.
  • refreshComponentForCell(): We can optionally override this method to create and update custom components in the table.
  • getColumnAutoSizeWidth(): When using auto size for the width of the columns, this method can optionally specify how the column resizes itself.
  • sortOrderChanged(): If using a custom sorting order, we can optionally specify how the column gets reordered.

There are other functions that you can optionally override for additional functionality but in this tutorial we will implement the ones presented here.

Reading Data from XML

Let's start by loading the data that we want to present in the table from an XML document.

In the Resources folder of the project, you can find sample data to use with this tutorial in a file called TableData.xml that contains information in the following format:

<TABLE_DATA>
<HEADERS>
<COLUMN columnId="1" name="ID" width="50"/>
//...
</HEADERS>
<DATA>
<ITEM ID="01" Module="juce_module" Name="JUCE example classes" Version="5.2.0" License="ISC" Groups="2" Dependencies="1" Description="..." Select="0"/>
//...
</DATA>
</TABLE_DATA>

Here we encapsulate the whole file with the TABLE_DATA tag and the table headers and the actual data is separated using the HEADERS and DATA tags respectively. The columns are defined with individual COLUMN tags and the rows are defined with ITEM tags using attributes for the content in each column of that entry.

The implementation of the code in this project is tailored for this file structure but you can modify the XML tags as you wish.

In the TableTutorialComponent class, we define a pointer to temporarily store the content of the file in a single XmlElement member variable and also define two additional XmlElements for the column content and the row content as shown here:

class TableTutorialComponent : public juce::Component,
public juce::TableListBoxModel
{
std::unique_ptr<juce::XmlElement> tutorialData;
juce::XmlElement* columnList = nullptr;
juce::XmlElement* dataList = nullptr;

In the class constructor, we launch a FileChooser to select the data file to display. When the FileChooser completes, it will call the callback lambda function, to load the file content into our XmlElement objects [1]. With our data loaded from the XML file, we can iterate through the column headers using a for() loop and XmlElement::getChildIterator to assign the table headers using the addColumn() function [2] like so:

TableTutorialComponent()
{
const auto callback = [this] (const juce::FileChooser& chooser)
{
loadData (chooser.getResult()); // [1]
if (columnList != nullptr)
{
for (auto* columnXml : columnList->getChildIterator())
{
table.getHeader().addColumn (columnXml->getStringAttribute ("name"), // [2]
columnXml->getIntAttribute ("columnId"),
columnXml->getIntAttribute ("width"),
50,
400,
juce::TableHeaderComponent::defaultFlags);
}
}

This function specifies the name, width and ID of the column along with property flags that define sortability and resizability of columns.

In the loadData() helper function called in the constructor, we first find the Resources directory and the XML file to load and parse this File object using the parse() function [3]. We can then retrieve the rows and columns from the temporary XmlElement by traversing the XML structure and finding the corresponding tags with the getChildByName() functions [4]. This is a good place to set the number of rows from the data XmlElement by calling getNumChildElements() on it [5] as follows:

void loadData (juce::File tableFile)
{
if (tableFile == juce::File() || ! tableFile.exists())
return;
tutorialData = juce::XmlDocument::parse (tableFile); // [3]
dataList = tutorialData->getChildByName ("DATA");
columnList = tutorialData->getChildByName ("HEADERS"); // [4]
numRows = dataList->getNumChildElements(); // [5]
}
Warning
Make sure to select the "TableData.xml" file provided in the resources for this tutorial.

Let's also define a helper function called getAttributeNameForColumnId() that returns the name of the column based on its ID which will become handy later on:

juce::String getAttributeNameForColumnId (const int columnId) const
{
for (auto* columnXml : columnList->getChildIterator())
{
if (columnXml->getIntAttribute ("columnId") == columnId)
return columnXml->getStringAttribute ("name");
}
return {};
}

Here we iterate through the child XML elements and find the matching column ID in order to return its name attribute.

Custom Cell Components

A TableListBox can hold custom components in its cells other than just text. In the following sections we explore how to incorporate a ToggleButton into one of the columns and an editable Label that listens to user input.

Editable Labels

In the EditableTextCustomComponent class, we first inherit from the Label class in order to set it as editable when the user double-clicks on it:

class EditableTextCustomComponent : public juce::Label
{
public:
EditableTextCustomComponent (TableTutorialComponent& td)
: owner (td)
{
setEditable (false, true, false);
}
private:
TableTutorialComponent& owner;
int row, columnId;
juce::Colour textColour;
};

Here we also keep track in which row and column this object is displayed and a reference to the actual table.

When the user interacts with the Label, we have to extend the usual mouseDown() functionalities to account for multiple selections in the table. This is achieved by calling selectRowsBasedOnModifierKeys() on the table and passing the modifier keys as an argument. Notice here that we still need to call the base class function to keep the original behaviour as well:

void mouseDown (const juce::MouseEvent& event) override
{
owner.table.selectRowsBasedOnModifierKeys (row, event.mods, false);
}

When the user edits the text in the Label, we receive a callback from the textWasEdited() function and we call the helper function setText() defined later in the TableTutorialComponent class to save the changes in the corresponding XmlElement object like so:

void textWasEdited() override
{
owner.setText (columnId, row, getText());
}

The following function is called by the TableListBoxModel when creating or updating an EditableTextCustomComponent object and sets the row, column and the displayed text from the XmlElement using the getText() helper function defined after:

void setRowAndColumn (const int newRow, const int newColumn)
{
row = newRow;
columnId = newColumn;
setText (owner.getText(columnId, row), juce::dontSendNotification);
}

The getText() and setText() helper functions are defined as follows:

juce::String getText (const int columnNumber, const int rowNumber) const
{
return dataList->getChildElement (rowNumber)->getStringAttribute (getAttributeNameForColumnId (columnNumber));
}

Here we find the text from the row and column number in the child element contained within the XmlElement.

void setText (const int columnNumber, const int rowNumber, const juce::String& newText)
{
const auto& columnName = table.getHeader().getColumnName (columnNumber);
dataList->getChildElement (rowNumber)->setAttribute (columnName, newText);
}

Here we store the text in the child element contained within the XmlElement from the row and column number.

Selectable Button

In the SelectionColumnCustomComponent class, we first inherit from the Component class in order to set a ToggleButton as a child Component to it and assign the callback function to be called when the user interacts with it:

class SelectionColumnCustomComponent : public Component
{
public:
SelectionColumnCustomComponent (TableTutorialComponent& td)
: owner (td)
{
addAndMakeVisible (toggleButton);
toggleButton.onClick = [this] { owner.setSelection (row, (int) toggleButton.getToggleState()); };
}
private:
TableTutorialComponent& owner;
juce::ToggleButton toggleButton;
int row, columnId;
};

Here we also keep track in which row and column this object is displayed and a reference to the actual table. The lambda function calls the setSelection() helper function defined later in the TableTutorialComponent that sets the toggle state of the button.

void resized() override
{
toggleButton.setBoundsInset (juce::BorderSize<int> (2));
}

The resized() function sets the bounds of the ToggleButton object.

The following function is called by the TableListBoxModel when creating or updating a SelectionColumnCustomComponent object and sets the row, column and the toggle state from the XmlElement using the getSelection() helper function defined after:

void setRowAndColumn (int newRow, int newColumn)
{
row = newRow;
columnId = newColumn;
toggleButton.setToggleState ((bool) owner.getSelection (row), juce::dontSendNotification);
}

The getSelection() and setSelection() helper functions are defined as follows:

int getSelection (const int rowNumber) const
{
return dataList->getChildElement (rowNumber)->getIntAttribute ("Select");
}

Here we find the toggle state from the row and column number in the child element contained within the XmlElement.

void setSelection (const int rowNumber, const int newSelection)
{
dataList->getChildElement (rowNumber)->setAttribute ("Select", newSelection);
}

Here we store the toggle state in the child element contained within the XmlElement from the row and column number.

Exercise
Create additional custom cell components that incorporate ComboBox, TextButton or even Slider components.

Sorting the Data

In order for the table to sort elements based on a chosen column, we have to define a comparator class that can be passed as a template class to be used by the sortChildElements() function of the XmlElement object.

We name this class TutorialDataSorter that keeps track of the name of the XmlElement attribute to sort and the ascending or descending direction of the sorting algorithm as follows:

class TutorialDataSorter
{
public:
TutorialDataSorter (const juce::String& attributeToSortBy, bool forwards)
: attributeToSort (attributeToSortBy),
direction (forwards ? 1 : -1)
{}
private:
juce::String attributeToSort;
int direction;
};

For the sortChildElements() function to recognise the TutorialDataSorter as a valid comparator, we have to declare a function named compareElements() that takes two XmlElement objects and returns the order as an int.

The function is required to return:

  • a negative value if the first comes before the second.
  • a value of 0 if the two objects are equivalent.
  • a positive value if the second comes before the first.
int compareElements (juce::XmlElement* first, juce::XmlElement* second) const
{
auto result = first->getStringAttribute (attributeToSort)
.compareNatural (second->getStringAttribute (attributeToSort)); // [1]
if (result == 0)
result = first->getStringAttribute ("ID")
.compareNatural (second->getStringAttribute ("ID")); // [2]
return direction * result; // [3]
}

Therefore, in the above function we compare the string attributes of both XmlElements by using the compareNatural() method of the String class which returns an int with the same set of rules [1]. If the two strings are equivalent for the attribute in question then we compare the ID column of these two elements [2]. Finally we need to invert the result if the direction of sorting is reversed [3].

Setting the Model

Let's assemble all the pieces together by implementing the TableListBoxModel.

We first start by inheriting from the TableListBoxModel class in the TableTutorialComponent class as shown here:

class TableTutorialComponent : public juce::Component,
public juce::TableListBoxModel
{
private:
juce::TableListBox table { {}, this };
juce::Font font { 14.0f };
std::unique_ptr<juce::XmlElement> tutorialData;
juce::XmlElement* columnList = nullptr;
juce::XmlElement* dataList = nullptr;
int numRows = 0;

Here we define a TableListBox member variable and set this class as its TableListBoxModel which means that our model class actually holds the table itself in this scenario. We also keep track of the number of rows in the table as required by the model.

In the class constructor we add the TableListBox as a child Component [1]. We can also specify properties for the appearance of the table such as the outline colour and its thickness [2].

addAndMakeVisible (table); // [1]
table.setColour (juce::ListBox::outlineColourId, juce::Colours::grey); // [2]
table.setOutlineThickness (1);
table.getHeader().setSortColumnId (1, true); // [3]
table.setMultipleSelectionEnabled (true); // [4]

The sorting column and the column visibility is set by calling corresponding functions on the TableHeaderComponent of the table [3] and we also allow multiple selections on the table [4].

The first function to override is the getNumRows() function that returns the member variable holding the number of rows. This function is necessary for the model to update the table properly:

int getNumRows() override
{
return numRows;
}

The paintRowBackground() function is implemented by first finding an alternate colour that complements the default background colour for the table:

void paintRowBackground (juce::Graphics& g, int rowNumber, int /*width*/, int /*height*/, bool rowIsSelected) override
{
auto alternateColour = getLookAndFeel().findColour (juce::ListBox::backgroundColourId)
.interpolatedWith (getLookAndFeel().findColour (juce::ListBox::textColourId), 0.03f);
if (rowIsSelected)
g.fillAll (juce::Colours::lightblue);
else if (rowNumber % 2)
g.fillAll (alternateColour);
}

The row is then filled in light blue if the row is selected, otherwise we paint every other row with this alternate colour.

In order to fill the cells with content we override the paintCell() function as follows:

void paintCell (juce::Graphics& g, int rowNumber, int columnId,
int width, int height, bool rowIsSelected) override
{
g.setColour (rowIsSelected ? juce::Colours::darkblue : getLookAndFeel().findColour (juce::ListBox::textColourId)); // [5]
g.setFont (font);
if (auto* rowElement = dataList->getChildElement (rowNumber))
{
auto text = rowElement->getStringAttribute (getAttributeNameForColumnId (columnId));
g.drawText (text, 2, 0, width - 4, height, juce::Justification::centredLeft, true); // [6]
}
g.setColour (getLookAndFeel().findColour (juce::ListBox::backgroundColourId));
g.fillRect (width - 1, 0, 1, height); // [7]
}
  • [5]: First we select an appropriate colour for the text depending on whether the row is selected or not and set the font size.
  • [6]: If the child row element exists in the XmlElement object, we retrieve the right column from the row and fill the cell with corresponding text from the XmlElement.
  • [7]: Finally, we draw the separation line on the right side of the cell with the default background colour.
void sortOrderChanged (int newSortColumnId, bool isForwards) override
{
if (newSortColumnId != 0)
{
TutorialDataSorter sorter (getAttributeNameForColumnId (newSortColumnId), isForwards);
dataList->sortChildElements (sorter);
table.updateContent();
}
}

When a sort order change is requested by the user, the sortOrderChanged() callback function is called and if the sort column is valid, we instantiate a TutorialDataSorter object with the correct attribute and direction. We can then pass that object to the sortChildElements() function of the XmlElement and force a table refresh by calling updateContent() on the table.

The refreshComponentForCell() function is where the custom cell components can be instantiated and updated as follows:

Component* refreshComponentForCell (int rowNumber, int columnId, bool /*isRowSelected*/,
Component* existingComponentToUpdate) override
{
if (columnId == 9) // [8]
{
auto* selectionBox = static_cast<SelectionColumnCustomComponent*> (existingComponentToUpdate);
if (selectionBox == nullptr)
selectionBox = new SelectionColumnCustomComponent (*this);
selectionBox->setRowAndColumn (rowNumber, columnId);
return selectionBox;
}
if (columnId == 8) // [9]
{
auto* textLabel = static_cast<EditableTextCustomComponent*> (existingComponentToUpdate);
if (textLabel == nullptr)
textLabel = new EditableTextCustomComponent (*this);
textLabel->setRowAndColumn (rowNumber, columnId);
return textLabel;
}
jassert (existingComponentToUpdate == nullptr);
return nullptr; // [10]
}
  • [8]: If the function is called on the correct "Select" column for the selection cell, then we check whether the SelectionColumnCustomComponent already exists in the cell. If not we instantiate a new instance and update its content by calling the setRowAndColumn() function on it and return the Component.
  • [9]: If the function is called on the correct "Description" column for the text editor cell, then we check whether the EditableTextCustomComponent already exists in the cell. If not we instantiate a new instance and update its content by calling the setRowAndColumn() function on it and return the Component.
  • [10]: Otherwise, it means that the function was called on a regular column and the custom Component object for that cell should be non-existant.

Finally, the TableListBox offers a nice feature where the columns can be resized according to an automatic behaviour defined hereafter in the getColumnAutoSizeWidth() function:

int getColumnAutoSizeWidth (int columnId) override
{
if (columnId == 9)
return 50;
int widest = 32;
for (auto i = getNumRows(); --i >= 0;)
{
if (auto* rowElement = dataList->getChildElement (i))
{
auto text = rowElement->getStringAttribute (getAttributeNameForColumnId (columnId));
widest = juce::jmax (widest, font.getStringWidth (text));
}
}
return widest + 8;
}

Here we decide to inspect all the elements in a certain column and retrieve the widest text in a cell. We then return the width with some added padding or a fixed width if the column happens to be the "Select" column with a custom ToggleButton.

Exercise
Modify the content of the XML document by adding additional columns and/or data and changing the implementation accordingly.

Summary

In this tutorial, we have learnt how to display information in a table. In particular, we have:

  • Loaded data into a table using XML.
  • Incorporated custom components into our table cells.
  • Sorted the table data according to a custom sorting behaviour.

See also

linkedin facebook pinterest youtube rss twitter instagram facebook-blank rss-blank linkedin-blank pinterest youtube twitter instagram