In Fyne v2.4.0 the Table widget got an upgrade, adding built-in headers for the rows and columns (which could be turned on or off). In this post we explore how these headers can be used to control, and display, sort order that a user can control – let’s see how!

We will first set up a simple data set – the row type allows us to keep track of our data and we use the excellent loremipsum library to provide some dummy data. With the following code we fill 500 structs ready to be displayed and sorted.
type row struct {
id int
name, description string
}
var rows []row
func init() {
l := loremipsum.New()
for i := 0; i < 500; i++ {
name := l.Word() + " " + l.Word()
desc := l.Word() + " " + l.Word() + " " + l.Word() + " " + l.Word() + " " + l.Word()
r := row{i, name, desc}
rows = append(rows, r)
}
}
Now we have some data we need to think about sorting. Because we want any of the columns to be able to sort we will need to track which of them is active, and in which order. Tapping the heading will toggle between ascend / descend / no sort, so we create a type and 3 consts accordingly (sortOff, sortAsc, sortDesc). Then an array of sort directions is created so we can track which of the columns is controlling sort.
The last thing for our sorting code is to actually apply a sort to our data. To do this we make a new function, applySort, which takes the column index to sort on, and the table which we are operating on (to refresh when we are done). The first thing the function does is rotate the sort direction for the given column (and to reset any other sorting as well – we only want one column at a time to sort). Then we use the sort package to sort the rows slice, varying the comparison depending on which column, and direction, the sort is set to:
type dir int
const (
sortOff dir = iota
sortAsc
sortDesc
)
var sorts = [3]dir{}
func applySort(col int, t *widget.Table) {
order := sorts[col]
order++
if order > sortDesc {
order = sortOff
}
// reset all and assign tapped sort
for i := 0; i < 3; i++ {
sorts[i] = sortOff
}
sorts[col] = order
sort.Slice(rows, func(i, j int) bool {
a := rows[i]
b := rows[j]
// re-sort with no sort selected
if order == sortOff {
return a.id < b.id
}
switch col {
case 1:
if order == sortAsc {
return strings.Compare(a.name, b.name) < 0
}
return strings.Compare(a.name, b.name) > 0
case 2:
if order == sortAsc {
return strings.Compare(a.description, b.description) < 0
}
return strings.Compare(a.description, b.description) > 0
default:
if order == sortDesc {
return a.id > b.id
}
return a.id < b.id
}
})
t.Refresh()
}
That’s all the sort code out of the way – all we need to do now is actually set up a table! We start with the recent NewTableWithHeaders constructor and specify some column widths for our data. The main work is in setting up the custom headers – using CreateHeader we change from Labels to Buttons, then in UpdateHeader we set the content as required. For row headers (where the column is -1) we disable the button after setting the row ID as the content. The column headers will be buttons that impact sort, so here we want to call applySort from the button OnTapped, but also we want to update the button content and use the theme arrows to show which is sorting (and in which direction). We do this by setting the Icon field for each button before refreshing it.
t := widget.NewTableWithHeaders(func() (int, int) {
return len(rows), 3
},
func() fyne.CanvasObject {
l := widget.NewLabel("John Smith")
return l
},
func(id widget.TableCellID, o fyne.CanvasObject) {
l := o.(*widget.Label)
l.Truncation = fyne.TextTruncateEllipsis
switch id.Col {
case 0:
l.Truncation = fyne.TextTruncateOff
l.SetText(strconv.Itoa(rows[id.Row].id))
case 1:
l.SetText(rows[id.Row].name)
case 2:
l.SetText(rows[id.Row].description)
}
})
t.SetColumnWidth(0, 40)
t.SetColumnWidth(1, 125)
t.SetColumnWidth(2, 450)
t.CreateHeader = func() fyne.CanvasObject {
return widget.NewButton("000", func() {})
}
t.UpdateHeader = func(id widget.TableCellID, o fyne.CanvasObject) {
b := o.(*widget.Button)
if id.Col == -1 {
b.SetText(strconv.Itoa(id.Row))
b.Importance = widget.LowImportance
b.Disable()
} else {
switch id.Col {
case 0:
b.SetText("ID")
switch sorts[0] {
case sortAsc:
b.Icon = theme.MoveUpIcon()
case sortDesc:
b.Icon = theme.MoveDownIcon()
default:
b.Icon = nil
}
case 1:
b.SetText("Name")
switch sorts[1] {
case sortAsc:
b.Icon = theme.MoveUpIcon()
case sortDesc:
b.Icon = theme.MoveDownIcon()
default:
b.Icon = nil
}
case 2:
b.SetText("Description")
switch sorts[2] {
case sortAsc:
b.Icon = theme.MoveUpIcon()
case sortDesc:
b.Icon = theme.MoveDownIcon()
default:
b.Icon = nil
}
}
b.Importance = widget.MediumImportance
b.OnTapped = func() {
applySort(id.Col, t)
}
b.Enable()
b.Refresh()
}
}
That’s it – a complete sortable table based on adding buttons into the table header. Full listing below if you just want to copy and paste the whole app ;).
package main
import (
"sort"
"strconv"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/go-loremipsum/loremipsum"
)
type row struct {
id int
name, description string
}
var rows []row
func init() {
l := loremipsum.New()
for i := 0; i < 500; i++ {
name := l.Word() + " " + l.Word()
desc := l.Word() + " " + l.Word() + " " + l.Word() + " " + l.Word() + " " + l.Word()
r := row{i, name, desc}
rows = append(rows, r)
}
}
type dir int
const (
sortOff dir = iota
sortAsc
sortDesc
)
var sorts = [3]dir{}
func main() {
a := app.New()
w := a.NewWindow("Hello")
t := widget.NewTableWithHeaders(func() (int, int) {
return len(rows), 3
},
func() fyne.CanvasObject {
l := widget.NewLabel("John Smith")
return l
},
func(id widget.TableCellID, o fyne.CanvasObject) {
l := o.(*widget.Label)
l.Truncation = fyne.TextTruncateEllipsis
switch id.Col {
case 0:
l.Truncation = fyne.TextTruncateOff
l.SetText(strconv.Itoa(rows[id.Row].id))
case 1:
l.SetText(rows[id.Row].name)
case 2:
l.SetText(rows[id.Row].description)
}
})
t.SetColumnWidth(0, 40)
t.SetColumnWidth(1, 125)
t.SetColumnWidth(2, 450)
t.CreateHeader = func() fyne.CanvasObject {
return widget.NewButton("000", func() {})
}
t.UpdateHeader = func(id widget.TableCellID, o fyne.CanvasObject) {
b := o.(*widget.Button)
if id.Col == -1 {
b.SetText(strconv.Itoa(id.Row))
b.Importance = widget.LowImportance
b.Disable()
} else {
switch id.Col {
case 0:
b.SetText("ID")
switch sorts[0] {
case sortAsc:
b.Icon = theme.MoveUpIcon()
case sortDesc:
b.Icon = theme.MoveDownIcon()
default:
b.Icon = nil
}
case 1:
b.SetText("Name")
switch sorts[1] {
case sortAsc:
b.Icon = theme.MoveUpIcon()
case sortDesc:
b.Icon = theme.MoveDownIcon()
default:
b.Icon = nil
}
case 2:
b.SetText("Description")
switch sorts[2] {
case sortAsc:
b.Icon = theme.MoveUpIcon()
case sortDesc:
b.Icon = theme.MoveDownIcon()
default:
b.Icon = nil
}
}
b.Importance = widget.MediumImportance
b.OnTapped = func() {
applySort(id.Col, t)
}
b.Enable()
b.Refresh()
}
}
w.SetContent(t)
w.ShowAndRun()
}
func applySort(col int, t *widget.Table) {
order := sorts[col]
order++
if order > sortDesc {
order = sortOff
}
// reset all and assign tapped sort
for i := 0; i < 3; i++ {
sorts[i] = sortOff
}
sorts[col] = order
sort.Slice(rows, func(i, j int) bool {
a := rows[i]
b := rows[j]
// re-sort with no sort selected
if order == sortOff {
return a.id < b.id
}
switch col {
case 1:
if order == sortAsc {
return strings.Compare(a.name, b.name) < 0
}
return strings.Compare(a.name, b.name) > 0
case 2:
if order == sortAsc {
return strings.Compare(a.description, b.description) < 0
}
return strings.Compare(a.description, b.description) > 0
default:
if order == sortDesc {
return a.id > b.id
}
return a.id < b.id
}
})
t.Refresh()
}

Row headers – can you change their width? I want to move the first column of datetime string to the row header so it remains static as I scroll.
Yes, row header size is set by the template that is used to set them up – so if you provide a replacement `CreateHeader` function that returns a label/button with wider content as a template then the whole column of header rows will be wider as required to fit that content.
In next version following two things should be added
1. CreateHeader should accept the col id, so that we can return appropriate UI component for the header cell
2. CreateCell callback (for the Table) should accept row and col id so that appropriate UI component can be returned.
As of now, we have to grab all the possible UI components (a label, a checkbox, a button) put them into a container object and return in Create Cell, and then in Update Cell callback hide/ show appropriate UI component. Looks very inefficient.
That’s not how the caching mechanics of the collection widgets works – each cell is the same structurally with different data applied.
If we do make them non-homogenous in the future it will be by having a “type” hint rather than asking for a row/col ID – taking the latter approach would make the cache massive.
As much as the current method looks inefficient, it is very much optimised for non-trivial amounts of data and leads to performance that is impossible if you create a new cell for every item on screen.
The Table widget lacks the ability to drag and drop columns to change their order.