Collections
List
Rows of any number and height, built only while in view.
ui.List shows rows of a collection of any length: it builds only the rows
in view, and a few beyond, so a list of millions of rows is as fast as one
of ten. Rows take the height of their content, which may differ from row to
row: the list measures rows as they show and estimates the others from
them.
ui.List(c, nil, len(app.files), func(i int) {
ui.Text(c, app.files[i].Name).Padding(6, 12)
}).Grow(1)Give a list a size, or grow it in its parent. A list without a size, or
with only a MaxHeight, is as high as its rows.
Layout
Gap spaces the rows, Padding pads the content and scrolls with it, and
Justify(ui.End) puts rows that do not fill the list at its bottom, as a
chat's first messages. What else you build in a list shows while it has no
rows, as a message that it is empty:
ui.List(c, nil, len(results), func(i int) {
ui.Text(c, results[i].Title).Padding(6, 12)
}).Grow(1).Children(func() {
if len(results) == 0 {
ui.Text(c, "No results").TextColor(c.Theme().TextMuted).Padding(12)
}
})Rows wider than the list
RowWidth(w) makes the rows at least w DIPs wide, as the columns of a
table ask: where they are wider than the list, it scrolls sideways too, and
where they are not, they grow to its width. A header above the rows goes
with them through ScrollWith; clip it, so that what scrolls out of it
does not show. ui.Table does this for its columns.
head := ui.Row(c).Clip().Children(app.titles)
list := ui.List(c, &app.files, len(app.files)).Grow(1).RowWidth(app.columnsWidth())
list.Rows(app.row)
head.ScrollWith(list)List state
A list keeps its place by a row rather than by an offset, so the rows in
view stay where they are while the rows around them are measured, grow,
come and go, and its offsets count millions of rows to a fraction of a DIP.
Give it a ui.ListState in your state, in place of nil, to say how it
behaves and to move it:
Keyreturns an identity for the item of a row, such as its ID: the state of a row (focus, text being edited, …) follows its item, and the list keeps its place, and its choice, when rows are added or removed above them, as when older messages load.KeepOffsetkeeps a list with aKeyat its offset as its rows move, rather than with the item at its top, for rows sorted again as they change, as a process monitor's: the rows' state and the choice still follow their items.FollowEndstarts the list at its end, and keeps the end in view as rows come or grow, until the user scrolls away from it, as a chat or a log does; scrolling back to the end follows it again.FollowAnimationmakes it glide there, as a spring (&ui.Smooth) or a curve, rather than show the end at once: what is added comes up from below, as the chat apps scroll, and the user scrolling stops it. Each layout readsFollowEndas it is then, andHoldPlacekeeps the list where it is for the next layout alone: call it in the frame a click opens a fold in the last message, and the line clicked stays under the pointer rather than following the end as the row grows.Headernames the rows heading sections: the header of the section at the top stays pinned there while its rows scroll under it, until the next header pushes it away. Give headers a background.Labelreturns the text of a row: typing its first letters while the list has the focus chooses it, as in Finder or Explorer, and assistive technology reads it as the row's name.
Give each list a ListState of its own, and set its fields before
building the list.
Choosing rows
Selectedlets a click, or Up, Down, Home and End while the list has the keyboard focus (and Page Up and Page Down on Linux and Windows), choose a row, which shows in the accent color; the list'sChangedreports a new choice, andSubmitteda double click or Enter.-1is no row chosen. With aKey, the choice follows its item as rows are added, removed or sorted, and becomes-1when its item leaves the rows. The list writes that as it builds, in the same pass, so what you build after the list (a detail panel reading the chosen row) must take-1as none. Without aKey, the choice stays at its index.Selectionlets the user choose several rows, held in aui.Selection[K]by the keys of their items (Key), or by their indices without one: a click chooses one, Cmd-click (Ctrl-click on Linux and Windows) adds a row or takes it out, and Shift-click or Shift with the keys chooses the rows from the one last chosen; Cmd+A chooses all. On Linux and Windows, Ctrl with the keys moves without choosing, and Ctrl+Space adds the row there or takes it out.Selected, set as well, is the row last chosen.
A table and the editor of its chosen row below it:
app.list.Key = func(i int) any { return app.cols[i].Name }
app.list.Selected = &app.colRow
ui.List(c, &app.list, len(app.cols), func(i int) {
ui.Text(c, app.cols[i].Name).Padding(6, 12)
}).Grow(1)
if app.colRow >= 0 { // -1 once another table's columns replaced the chosen one
columnEditor(c, &app.cols[app.colRow])
}When the same list shows another set of rows, as another table's, start
its state afresh (app.list = ui.ListState{}) so that it forgets the item
chosen before and the place it scrolled to.
Files to choose several of, by their paths:
type fileList struct {
files []File
chosen ui.Selection[string]
list ui.ListState
}
func (app *fileList) view(c *ui.Context) {
app.list.Key = func(i int) any { return app.files[i].Path }
app.list.Label = func(i int) string { return app.files[i].Name }
app.list.Selection = &app.chosen
ui.List(c, &app.list, len(app.files), func(i int) {
ui.Text(c, app.files[i].Name).Padding(6, 12)
}).Grow(1)
if ui.Button(c, fmt.Sprintf("Delete %d", app.chosen.Len())).Clicked() {
for path := range app.chosen.All() {
app.delete(path)
}
app.chosen.Clear()
}
}The keys of items gone from the list stay in the selection until you take
them out. Element.ClickModifiers gives the modifier keys of any click, for
widgets of your own that do the same.
In a list with Selection, a right-click on a row outside the choice
chooses it before its context menu opens, as Finder and Qt do, so the menu
acts on the row it was opened on; on a chosen row it leaves the choice as it
is. MenuKeepsSelection turns this off: the menu opens on the row clicked,
whose builder knows its row, and the choice stays. A list with Selected
alone never chooses on a right-click.
The menu key and Shift+F10 open the context menu of the row chosen, below
the row, while the list has the focus: the outermost element with a
ContextMenu in the row, or the list's own when no row is chosen or the
row has none.
app.files.Selection = &app.chosen
app.files.MenuKeepsSelection = true // the menu acts on the row clicked aloneDragSelect, with Selection, chooses rows as the pointer drags over them
from the row it pressed, as a Shift-click on the row under the pointer
would (with Cmd, or Ctrl on Linux and Windows, held as the press began,
besides the rows chosen), scrolling the list as the pointer nears or passes
its top or bottom. A press on a row not chosen chooses it as the pointer goes
down, as a spreadsheet does, rather than at the release; a press on a row
chosen waits for the release, so a drag of the choice keeps it.
DragSelecting reports a drag choosing rows while the pointer holds it, for
work that follows the choice, as a sum of the rows chosen, to wait until it
ends. A list cannot both DragSelect and Reorder (or DragRows).
ExtendTo(row) chooses the rows from the one Shift extends from to row,
as a Shift-click on it does, for keys of your own, as one that extends the
choice to the last row:
if app.extendToEnd {
app.list.ExtendTo(len(app.files) - 1) // after the list is built
}What a row holds can tell which part of it a double click landed on without
taking the click from the row, which goes on choosing: RowDoubleClicked
reports a double click on the row with the pointer over the element.
DoubleClicked on the element would take the click, and the row would not
choose.
ui.List(c, &app.list, len(app.files), func(i int) {
name := ui.Text(c, app.files[i].Name).Padding(6, 12)
if name.RowDoubleClicked() {
app.rename(i)
}
})RowClicked reports a single click the same way, for rows that act on one,
as a file list opening a file or a sidebar its page, with no button inside
the row taking the click: the row takes it, so its Label names what takes
the click, for typing its first letters, screen readers and Tester.Click
alike. It reports each click of the primary button without modifiers, on the
row already chosen too, where Changed reports nothing new. The keys moving
the choice report none, nor does a Shift- or Cmd-click extending it. A double
click reports its first click with RowClicked and its second with
RowDoubleClicked, as the systems' lists do. Assistive technology pressing
the row reports it to the first element of the row asking, and in a list
that does not choose its rows, asking makes the row take the click.
ui.List(c, &app.sidebar, len(app.pages), func(i int) {
if ui.Text(c, app.pages[i].Title).Padding(6, 12).FillWidth().RowClicked() {
app.show(i)
}
})Rows that draw their own hover and press, as a chat list's, ask the row
built with Rows rather than an element of their own: ListRow's
Hovered, Pressed and PointerPosition report the row's, as the
row's element would. Pressed or Hovered on an element inside the row
would make that element take the press, and the row would not choose,
nor would Tester.Click on the row's Label reach it. A press starts a
ripple where the pointer went down:
ui.List(c, &app.chats, len(app.items)).Grow(1).Rows(func(row ui.ListRow) {
bg := ui.Transparent
switch {
case row.Pressed():
bg = row.Context.Theme().SurfacePressed
if app.ripple.Row != row.Index {
x, y, _ := row.PointerPosition()
app.ripple = Ripple{Row: row.Index, X: x, Y: y, At: time.Now()}
}
case row.Hovered():
bg = row.Context.Theme().SurfaceHover
}
ui.Box(row.Context).Background(bg).Padding(8, 12).Children(func() {
ui.Text(row.Context, app.items[row.Index].Title)
})
})In a list that chooses no rows, Pressed makes the row take the press.
Scrolling
ScrollTo(row, align) shows a row at the ui.Start, ui.Center or
ui.End of the list, ScrollIntoView(row) scrolls as little as shows it,
and ScrollToEnd scrolls to the end, rows not built yet included: the list
scrolls as the frame is laid out, to where the row is once measured.
Visible returns the first and last rows in view and AtEnd whether the
end shows, to load more as the list nears the end of what was loaded.
A chat:
app.chat.Key = func(i int) any { return app.messages[i].ID }
app.chat.FollowEnd = true
app.chat.FollowAnimation = &ui.Smooth
if first, _ := app.chat.Visible(); first < 5 && app.more {
app.loadOlder() // above the messages in view, which stay put
}
if !app.chat.AtEnd() && ui.Button(c, "Jump to latest").Clicked() {
app.chat.ScrollToEnd()
}
ui.List(c, &app.chat, len(app.messages), func(i int) {
message(c, app.messages[i])
}).Grow(1).Justify(ui.End)Reordering
Reorder lets the user drag rows to another place in the list: the rows
chosen, when the row dragged is one of them, else that row, which the list
passes in order, and the row they go before, the number of rows for the
end. Move them there: the list shows where they would go as they are
dragged, and scrolls near its edges.
app.list.Reorder = func(rows []int, to int) {
app.items = move(app.items, rows, to)
}Rows that are buttons, which open what they show as they are clicked, reorder too: a press on a row's button drags the row once the pointer moves, and clicks the button if it does not.
Dragging rows out
DragRows lets the user drag rows out of the list, to drop them elsewhere:
the rows chosen, when the row dragged is one of them, else that row, drag
the value DragRows returns, which ui.Drop and ui.DragOver take as
they take any value dragged. The list itself takes nothing and shows no
line between its rows, so a target inside it, as a folder in a list of
files, or one around it, gets the value wherever the pointer is:
app.files.DragRows = func(rows []int) any {
return app.pathsOf(rows) // []string
}
// In the row of a folder:
if paths, ok := ui.Drop[[]string](row); ok {
app.move(paths, folder)
}Return nil for rows that do not drag. A list cannot both Reorder and
DragRows.
See drag and drop.
The focus
A list keeps its control identity in ListState.Handle. Focus queries work
before or after constructing the list, and a focus request waits while the
list is hidden:
app.list.Handle.OnShortcut(c, ui.Cmd, ui.KeyK, app.openCurrentFile)
ui.List(c.Key("files"), &app.list, len(app.files)).Grow(1).
Rows(func(row ui.ListRow) {
text := ui.Text(row.Context, app.files[row.Index].Name).Padding(6, 12)
if row.Selected() && row.ListFocused() {
text.TextColor(c.Theme().Accent)
}
})Call app.list.Handle.Focus() on the UI thread to request focus, or
Focus(c) to select a window explicitly. Focused(c) reports focus on the
list and FocusWithin(c) includes its descendants. A handle's shortcut
action runs after construction only if its enabled control is present.
ListRow supplies the current Context, row index, selection and list focus,
and the row's hover and press (see choosing rows).
ListState.Selected or Selection enables selection. ItemKey gives each
item a stable key. Configure them before calling Rows.
For custom row builders using List(c, state, n, func(i int)), the
ListState polling helpers resolve this pass's list after construction or
inside its row callback. Persistent focus and command handling use Handle.
A Table shares this identity through its ListState, and an Outline through
its List state.
The row holding keyboard focus stays built when it scrolls out of view, so editing continues and Tab can scroll the next focused row into view.
Accessibility
Assistive technology sees a list, named by its Label, of list items named
by the text inside them or the state's Label, each saying which of all
the rows it is, "5 of 10,000", although the list builds only those in view.
A list choosing its rows gives assistive technology the focus on the row
chosen: Up and Down are read as they move the choice, and focusing a row
chooses it. A screen reader moving out of view asks the list to scroll
there, which builds the rows it reaches. A list of rows of cells, as a
spreadsheet's, is a grid with
Role(ui.RoleGrid): its rows are then rows of a table.