MyGo
List

Collections

List

Rows of any number and height, built only while in view.

List

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:

  • Key returns 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.
  • KeepOffset keeps a list with a Key at 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.
  • FollowEnd starts 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. FollowAnimation makes 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 reads FollowEnd as it is then, and HoldPlace keeps 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.
  • Header names 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.
  • Label returns 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

  • Selected lets 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's Changed reports a new choice, and Submitted a double click or Enter. -1 is no row chosen. With a Key, the choice follows its item as rows are added, removed or sorted, and becomes -1 when 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 -1 as none. Without a Key, the choice stays at its index.
  • Selection lets the user choose several rows, held in a ui.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 alone

DragSelect, 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.

For rows in columns, see Table; for a tree, Outline.