User data sorting with a Fyne Table widget

by | Oct 5, 2023 | Code | 2 comments

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!

Table with sort headers

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()
}
2 Comments
  1. hippodribble

    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.

    Reply
  2. Andy Williams

    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.

    Reply

Leave a Reply

%d bloggers like this: