Содержание урока · 11 разделов
Десятый урок закончился обещанием: когда строки становятся двумерными — колонки, заголовки, дерево — наступает территория Table. Разбираемся. Конструктор widget.NewTable принимает те же три колбэка, что и NewList, но с двумя изменениями. Первое: length теперь возвращает пару чисел — func() (rows int, cols int), количество строк и колонок; таблица перемножает их и по позиции прокрутки вычисляет, какие ячейки видны. Второе: update получает не ListItemID, а widget.TableCellID — структуру с полями Row и Col, оба int, оба с нуля. Всё остальное — экономика, пул, правило полного сброса — переезжает из списков дословно: createItem зовётся редко, по мере опустения пула, update крутится на каждый кадр прокрутки, и в памяти существует только то, что видно на экране. Так что если «fyne list» из прошлого урока ты освоил, запрос «fyne table» закроется одним конструктором, и fyne таблица встанет в проект за десять минут. Начнём с минимального примера — каталог городов с населением.
Минимальная таблица городов
GOpackage main
import (
"fmt"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
type city struct {
Name string
Region string
People int
}
func main() {
myApp := app.New()
window := myApp.NewWindow("Города")
cities := []city{
{"Москва", "Центральный", 13100000},
{"Казань", "Приволжский", 1310000},
{"Сочи", "Южный", 460000},
{"Пермь", "Приволжский", 1040000},
}
table := widget.NewTable(
func() (int, int) { return len(cities), 3 },
func() fyne.CanvasObject {
return widget.NewLabel("шаблон")
},
func(id widget.TableCellID, cell fyne.CanvasObject) {
c := cities[id.Row]
switch id.Col {
case 0:
cell.(*widget.Label).SetText(c.Name)
case 1:
cell.(*widget.Label).SetText(c.Region)
case 2:
cell.(*widget.Label).SetText(fmt.Sprintf("%d чел.", c.People))
}
},
)
table.SetColumnWidth(0, 140)
table.SetColumnWidth(1, 160)
table.SetColumnWidth(2, 110)
table.OnSelected = func(id widget.TableCellID) {
c := cities[id.Row]
window.SetTitle(fmt.Sprintf("%s — %s", c.Name, c.Region))
}
window.SetContent(container.NewBorder(nil, nil, nil, nil, table))
window.ShowAndRun()
}Length возвращает пару: len(cities) строк и 3 колонки. Create отдаёт один Label — он станет шаблоном всех ячеек, и заголовков тоже, если они появятся. Update читает структуру по id.Row и свитчем по id.Col выбирает, что показать, — форматирование числа делается прямо в ячейке. SetColumnWidth задаёт ширины колонкам, потому что дефолтные равные слишком узки для населения. OnSelected получает тот же TableCellID — клик по любой ячейке строки даёт её координаты.
Что возвращает length-колбэк у widget.NewTable?
Один шаблон на все ячейки#
Самая неожиданная деталь для новичка: create вызывается без указания колонки и строки — это один шаблон на все ячейки таблицы сразу. Ни «шаблон для заголовков», ни «шаблон для колонки с датой» — у Table их нет. Все различия между ячейками делаются в update: тот самый свитч по id.Col, который ты видел в примере. Отсюда прямое следствие: шаблон должен быть универсальным и дешёвым — обычно Label, иногда HBox из Label и маленькой иконки. А всё, что различает колонки, — текст, выравнивание, цвет, важность — задаётся в update каждый раз заново. Правило полного сброса из десятого урока звучит здесь строже, чем где-либо: одна и та же ячейка-объект за минуту побывает колонкой с ценой, колонкой со статусом и колонкой с датой. Любой атрибут, который ты установил для «красной» строки, обязан сбрасываться в ветке иначе — иначе красный цвет переедет по таблице на прокрутке. Проверь себя: запусти пример со статусами ниже, прокрути вверх-вниз десяток раз и посмотри, не перекрашиваются ли чужие строки.
Сколько объектов создаст таблица
Прочитайте код и выберите, что он выведет:
// таблица прайс-листа: 1000 строк, 4 колонки
// на экране видно около 20 строк
table := widget.NewTable(
func() (int, int) { return 1000, 4 },
func() fyne.CanvasObject {
counter++
return widget.NewLabel("шаблон")
},
func(id widget.TableCellID, cell fyne.CanvasObject) {
// наполнение ячейки
},
)
// сколько раз вызовется create при открытии окна?Ширины колонок и высоты строк#
По умолчанию таблица сама подбирает размер ячеек: она берёт максимальный MinSize среди видимых шаблонов и раскраивает колонки равномерно, а высоту строк делает единой для всей таблицы. Пока в шаблоне короткий Label, выглядит прилично; как только в одной колонке окажутся длинные строки, все колонки поедут и начнут дёргаться при прокрутке — размер пересчитывается по видимым ячейкам. Лечение — закрепить ширины руками: SetColumnWidth(колонка, ширина) фиксирует конкретную колонку в пикселях, вызови его для каждой колонки сразу после создания таблицы, и раскладка перестанет дышать. Высоту строк раздельно настроить нельзя — она одна на всю таблицу, это осознанное упрощение: пул переиспользует ячейки между строками, и разные высоты сломали бы прокрутку. Нужны строки разной высоты — это не Table, а List, где каждая строка сама себе контейнер. Отдельные линии-разделители между ячейками прячет поле HideSeparators, знакомое по спискам. И повтор канона десятого урока: у таблицы своя прокрутка — в container.NewScroll её не оборачивать, место ей отдаёт компоновка из четвёртого урока: центр Border, половина Split, остаток VBox.
Колонки таблицы скачут по ширине при прокрутке длинного прайса. Что сделать?
Выбор, навигация и точечное обновление#
Управление у Table списочное, только идентификатор теперь пара: Select(id TableCellID) выделяет ячейку — с теми же последствиями, что клик, — UnselectAll() снимает выделение, OnSelected и OnUnselected получают координаты. Программная прокрутка — ScrollTo(id TableCellID), ScrollToTop() и ScrollToBottom(): после фильтрации или поиска таблицу открывают на найденной записи, как делали со списком в живом поиске. Точечное обновление — RefreshItem(id TableCellID): поменяли цену в срезе — перечитайте одну ячейку, не трогая остальные и не сбивая прокрутку. Массовая правка — один общий Refresh() в конце, а не десять RefreshItem в цикле. И помни природу идентификатора: Row — это индекс в твоих данных, не паспорт записи; вставка или сортировка среза сдвигает все последующие строки. Восстанавливай выделение по ключу из своих структур, если данные переупорядочиваются, — тот же приём, что и со списками. Клик по ячейке — обычное событие: если нужно реагировать только на правую кнопку или двойной клик, хук событий из шестого урока никуда не девался.
Прайс-лист со статусами и полным сбросом
GOpackage main
import (
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/widget"
)
type item struct {
Title string
Price float64
Stock int
}
func main() {
myApp := app.New()
window := myApp.NewWindow("Склад")
items := []item{
{"Клавиатура", 2450.0, 12},
{"Мышь", 890.5, 0},
{"Монитор", 18990.0, 3},
{"Кабель USB-C", 350.0, 48},
{"Докстанция", 6700.0, 0},
{"Наушники", 4200.0, 7},
}
table := widget.NewTable(
func() (int, int) { return len(items), 3 },
func() fyne.CanvasObject {
return widget.NewLabel("шаблон")
},
func(id widget.TableCellID, cell fyne.CanvasObject) {
label := cell.(*widget.Label)
it := items[id.Row]
switch id.Col {
case 0:
label.SetText(it.Title)
label.Importance = widget.MediumImportance
case 1:
label.SetText(fmt.Sprintf("%.2f руб.", it.Price))
label.Importance = widget.MediumImportance
case 2:
if it.Stock == 0 {
label.SetText("нет на складе")
label.Importance = widget.DangerImportance
} else {
label.SetText(fmt.Sprintf("%d шт.", it.Stock))
label.Importance = widget.MediumImportance
}
}
label.Refresh()
},
)
table.SetColumnWidth(0, 160)
table.SetColumnWidth(1, 120)
table.SetColumnWidth(2, 130)
window.SetContent(table)
window.ShowAndRun()
}Ячейка красится через Importance — поле Label из третьего урока. Обрати внимание на колонику статуса: DangerImportance в ветке нетовара и MediumImportance в ветке иначе — обе ветки пишут оба атрибута, текст и важность, поэтому красный не переезжает по таблице на прокрутке. Убери ветку иначе и прокрути список — увидишь классический баг пула вживую. label.Refresh() в конце приводит смену важности в действие: SetText перерисовывает текст сам, а вот поле Importance без Refresh молчит.
После прокрутки прайса красная подсветка «нет на складе» показывается и на товарах в наличии. Причина?
Заголовки и закреплённые колонки#
С версии 2.4 таблица научилась двум вещам, без которых большие данные показывать было мучительно. Первое — заголовки: поля ShowHeaderRow и ShowHeaderColumn включают верхнюю строку и левую колонку заголовков, а содержимое описывают колбэки CreateHeader и UpdateHeader с той же сигнатурой, что у обычных ячеек: CreateHeader отдаёт шаблон, UpdateHeader(id, template) пишет в него текст колонки или строки. Заголовок — тот же пул: сбрасывай его содержимое в обеих ветках. Второе — закрепление: StickyColumnCount задаёт число колонок, прилипших к левому краю при горизонтальной прокрутке; классика — закрепить первую колонку с названиями, чтобы при листании вправо не терять, чья это строка. Чего у Table нет — сортировки из коробки: клик по заголовку ничего не сортирует. Сортировку делают поверх данных: обрабатываешь клик по заголовку, пересортировываешь срез, зовёшь Refresh — и рисуешь стрелочку направления в самом заголовке через UpdateHeader. Это осознанный минимум API: данные — твои, порядок — твой, виджет только показывает.
Tree: данные с иерархией#
Вторая половина урока — widget.Tree, виджет для вложенных структур: файловая панель, дерево категорий, оглавление документа. Четыре колбэка вместо трёх. childUIDs func(uid TreeNodeID) []string отдаёт детей узла; пустая строка означает верхний уровень — корень сам не рисуется. isBranch func(uid TreeNodeID) bool отвечает, есть ли у узла дети: у веток рисуется стрелка раскрытия, у листьев нет. create func(branch bool) fyne.CanvasObject знает, шаблон ветки или листа ты хочешь — иконка папки или файла делается здесь. update func(uid TreeNodeID, branch bool, node fyne.CanvasObject) наполняет шаблон по строковому идентификатору узла — TreeNodeID это псевдоним string. Идентификаторы придумываешь ты: путь файла, ключ категории, UUID записи — что угодно уникальное. Виртуализация та же, пул тот же, правило полного сброса — дословно из десятого урока: ветка, переехавшая в объект листа, обязана сбросить иконку и отступ. Для демо и тестов есть сокращение widget.NewTreeWithStrings(map[string][]string): карта «родитель — список детей», где ключ с пустой строкой задаёт верхний уровень, а узел без детей автоматически становится листом.
Дерево проекта
GOpackage main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Структура проекта")
files := map[string][]string{
"": {"main.go", "ui", "store"},
"main.go": nil,
"ui": {"layout.go", "widgets"},
"layout.go": nil,
"widgets": {"toolbar.go", "card.go"},
"toolbar.go": nil,
"card.go": nil,
"store": {"notes.go"},
"notes.go": nil,
}
tree := widget.NewTreeWithStrings(files)
details := widget.NewLabel("Выбери файл")
details.Wrapping = fyne.TextWrapWord
tree.OnSelected = func(uid widget.TreeNodeID) {
details.SetText("Открыт: " + uid)
}
window.SetContent(container.NewHSplit(tree, details))
window.ShowAndRun()
}NewTreeWithStrings собрал дерево из карты: ключ с пустой строкой — верхний уровень, узлы с непустым списком детей стали ветками со стрелками, остальные — листьями. Клик по ветке раскрывает её, повторный — сворачивает; OnSelected получает строковый идентификатор, по которому ты читаешь свои данные. HSplit делит окно: дерево слева, детали справа — тот же приём, что со списком в десятом уроке.
Лист или ветка?
Прочитайте код и выберите, что он выведет:
files := map[string][]string{
"": {"папка", "readme.txt"},
"папка": {"заметка.txt"},
"заметка.txt": nil,
"readme.txt": nil,
}
tree := widget.NewTreeWithStrings(files)
// что увидит пользователь у строки «заметка.txt»?Собери таблицу прайса
Восстанови четыре пропуска: конструктор таблицы, тип идентификатора ячейки, поле строки и закрепление ширины.
Жизнь одной ячейки на экране
Расставь по порядку, что происходит с ячейкой таблицы при отрисовке и прокрутке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Каталог API таблиц и деревьев#
| API | Что делает | Когда звать |
|---|---|---|
| widget.NewTable(length, create, update) | создаёт таблицу; length возвращает (rows, cols) | один раз при сборке интерфейса |
| widget.TableCellID | координаты ячейки: Row и Col | в update, OnSelected, ScrollTo, RefreshItem |
| SetColumnWidth(col, width) | фиксирует ширину колонки в пикселях | сразу после создания — для каждой колонки |
| ShowHeaderRow / ShowHeaderColumn | включает строку и колонку заголовков | вместе с CreateHeader и UpdateHeader (2.4+) |
| StickyColumnCount | число закреплённых слева колонок | первая колонка с названиями (2.4+) |
| ScrollTo / ScrollToTop / ScrollToBottom | программная прокрутка | после фильтрации или поиска |
| Select / UnselectAll / RefreshItem(id) | выделение и точечное обновление ячейки | по логике приложения |
| widget.NewTreeWithStrings(map) | дерево из карты «родитель — дети» | демо и прототипы; для боевого кода — NewTree |
| OpenBranch / CloseBranch / ToggleBranch | раскрытие и сворачивание узлов дерева | программное управление иерархией |
Таблицам и деревьям из этого урока нужен настоящий экран — в браузерной песочнице курса GUI не исполняется. Собери примеры по второму уроку и обязательно прокрути данные до конца и обратно: правила пула проверяются только на прокрутке, при статичном экране всё выглядит правильно даже с багом.
Какие данные в какой виджет#
Выбор между List, Table и Tree делай по форме данных, а не по привычке. Одна колонка сущностей — список: лента задач, журнал сообщений, очередь. Двумерная таблица значений, где у каждой записи одинаковый набор полей, — Table: прайсы, счётчики, экспорт CSV на экран. Иерархия «родитель — дети» любой глубины — Tree: файлы, категории, вложенные комментарии. Смешивать можно и нужно: файловый менеджер — это Tree слева и Table справа; адресная книга — список групп и таблица людей в группе. Производительные правила те же, что для списков: update лёгкий, разбор данных вне шаблона, срез подготовленных строк как источник length и update, массовые правки одним Refresh. Тяжёлую ячейку делай из минимума объектов и помни: таблица — не электронная таблица из офисного пакета; сотни тысяч ячеек она покажет, а вот формулы и слияния колонок придётся строить самому, вложенными контейнерами. Для финального менеджера задач из двадцать пятого урока хватит и списка, но панель статистики по тегам там логично показать именно таблицей.
Что дальше: навигация между экранами#
Коллекции собраны: список — десятый урок, таблица и дерево — этот. Под них уже есть всё нужное: базовые виджеты вроде Label и Importance — третий урок, компоновка с Border и Split — четвёртый, события кликов — шестой, а Entry для поиска по данным — девятый. Следующая тема — переходы между экранами: приложения не живут в одном окне-списке, им нужны вкладки разделов и документные панели. Двенадцатый урок отдаёт под это AppTabs, DocTabs и SplitContainer — на них встанет и наш прайс, и финальный проект. Полный маршрут — на странице курса Fyne. Домашнее задание соберёт склад со статусами и категориями: таблица с заголовками и закреплённой колонкой плюс дерево групп — готовый скелет для инвентаризации.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyneTable: 4 строки × 3 столбца
- вне эмуляции: widget.NewTable
Экран собран: «Таблицы»
вне эмуляции: widget.NewTable
Коротко: что запомнить
- widget.NewTable описывает таблицу тремя колбэками: length возвращает пару (rows, cols), create отдаёт один шаблон на все ячейки, update наполняет ячейку по TableCellID.
- Виртуализация двумерная: в памяти только видимые ячейки плюс пул; create зовётся редко, update — на каждый кадр прокрутки.
- Правило обеих веток из списков действует дословно: каждый атрибут ячейки — текст, важность, стиль — устанавливается и сбрасывается в update.
- Ширины колонок закрепляй SetColumnWidth сразу после создания; высота строк единая, строки разной высоты — территория List.
- Заголовки — ShowHeaderRow и ShowHeaderColumn с CreateHeader/UpdateHeader, закрепление — StickyColumnCount (2.4+); сортировку делаешь сам поверх среза.
- Tree описывается четырьмя колбэками с строковыми TreeNodeID; NewTreeWithStrings сокращает демо; раскрытие — OpenBranch и ToggleBranch.
- Выбор виджета: одна колонка — List, одинаковые поля — Table, иерархия — Tree; таблицу не оборачивать в Scroll.
Домашнее задание
Собери склад: срез из двадцати структур с названием, категорией, ценой и количеством. Таблица из четырёх колонок с фиксированными ширинами, колонка количества красит нулевые остатки через DangerImportance и обязательно сбрасывает важность в ветке иначе. Включи ShowHeaderRow и напиши CreateHeader/UpdateHeader с названиями колонок, закрепи первую колонку через StickyColumnCount — при горизонтальной прокрутке названия должны оставаться видимыми. Добавь Entry поиска из девятого урока: фильтруй срез, показывай счётчик найденного и вызывай ScrollToTop после каждой правки фильтра. Затем разбей товары на категории и добавь дерево категорий слева через HSplit: клик по категории фильтрует таблицу, клик по строке таблицы выводит карточку товара в панель снизу. Ловушка для самопроверки: убери сброс Importance и прокрути таблицу — красные остатки расползутся по списку; почини и запомни цену пропущенной ветки.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем Table отличается от List и когда брать каждый?
List — одна колонка записей, у строки один id. Table — двумерная сетка одинаковых по набору полей записей, у ячейки пара Row и Col, и один шаблон на все ячейки. Если данные описываются срезом структур с фиксированными полями — Table; лента произвольных строк — List.
Почему колонки дёргаются при прокрутке?
Дефолтная ширина выводится из максимального MinSize видимых ячеек, а состав видимых ячеек меняется с прокруткой. Закрепи ширины через SetColumnWidth для каждой колонки сразу после создания таблицы — раскладка перестанет дышать.
Можно ли сделать строки разной высоты?
В Table нельзя: высота одна на всю таблицу, это часть контракта виртуализации. Строки произвольной высоты собираются в List — там каждая строка сама контейнер, а таблица хороша ровными сетками данных.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки