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 Label
s to Button
s, 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.