Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~20 мин +50 XP

Таблицы и деревья

Список из десятого урока умеет одну колонку, а прайс-лист, журнал событий и файловый менеджер живут в двух измерениях: строки и колонки. Запрос «fyne table» — это widget.NewTable: та же виртуализация, что у List, только длина теперь пара (rows, cols), а идентификатор ячейки — TableCellID с Row и Col. Таблицы fyne наследуют и главное правило пула: один шаблон на все ячейки, update обязан сбрасывать состояние. Рядом стоит Tree — виджет для иерархий, где строки прячут другие строки. Сегодня соберём таблицу с колонками и заголовками, дерево папок и разберём, какие данные к какому из трёх коллекционных виджетов просятся.

Содержание урока · 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 таблица встанет в проект за десять минут. Начнём с минимального примера — каталог городов с населением.

Минимальная таблица городов

GO
package 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 — это индекс в твоих данных, не паспорт записи; вставка или сортировка среза сдвигает все последующие строки. Восстанавливай выделение по ключу из своих структур, если данные переупорядочиваются, — тот же приём, что и со списками. Клик по ячейке — обычное событие: если нужно реагировать только на правую кнопку или двойной клик, хук событий из шестого урока никуда не девался.

Прайс-лист со статусами и полным сбросом

GO
package 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): карта «родитель — список детей», где ключ с пустой строкой задаёт верхний уровень, а узел без детей автоматически становится листом.

Дерево проекта

GO
package 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»?

Собери таблицу прайса

Восстанови четыре пропуска: конструктор таблицы, тип идентификатора ячейки, поле строки и закрепление ширины.

table := widget.( func() (int, int) { return len(items), 3 }, func() fyne.CanvasObject { return widget.NewLabel("шаблон") }, func(id widget., cell fyne.CanvasObject) { label := cell.(*widget.Label) it := items[id.] if id.Col == 0 { label.SetText(it.Title) } }, ) table.(0, 180) // первая колонка — 180 пикселей

Жизнь одной ячейки на экране

Расставь по порядку, что происходит с ячейкой таблицы при отрисовке и прокрутке.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Каталог 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раскрытие и сворачивание узлов деревапрограммное управление иерархией

Какие данные в какой виджет#

Выбор между List, Table и Tree делай по форме данных, а не по привычке. Одна колонка сущностей — список: лента задач, журнал сообщений, очередь. Двумерная таблица значений, где у каждой записи одинаковый набор полей, — Table: прайсы, счётчики, экспорт CSV на экран. Иерархия «родитель — дети» любой глубины — Tree: файлы, категории, вложенные комментарии. Смешивать можно и нужно: файловый менеджер — это Tree слева и Table справа; адресная книга — список групп и таблица людей в группе. Производительные правила те же, что для списков: update лёгкий, разбор данных вне шаблона, срез подготовленных строк как источник length и update, массовые правки одним Refresh. Тяжёлую ячейку делай из минимума объектов и помни: таблица — не электронная таблица из офисного пакета; сотни тысяч ячеек она покажет, а вот формулы и слияния колонок придётся строить самому, вложенными контейнерами. Для финального менеджера задач из двадцать пятого урока хватит и списка, но панель статистики по тегам там логично показать именно таблицей.

Что дальше: навигация между экранами#

Коллекции собраны: список — десятый урок, таблица и дерево — этот. Под них уже есть всё нужное: базовые виджеты вроде Label и Importance — третий урок, компоновка с Border и Split — четвёртый, события кликов — шестой, а Entry для поиска по данным — девятый. Следующая тема — переходы между экранами: приложения не живут в одном окне-списке, им нужны вкладки разделов и документные панели. Двенадцатый урок отдаёт под это AppTabs, DocTabs и SplitContainer — на них встанет и наш прайс, и финальный проект. Полный маршрут — на странице курса Fyne. Домашнее задание соберёт склад со статусами и категориями: таблица с заголовками и закреплённой колонкой плюс дерево групп — готовый скелет для инвентаризации.

Термины урока

Термин

widget.NewTable

нажмите, чтобы перевернуть
Определение

Три колбэка: length func() (rows, cols int), create func() fyne.CanvasObject, update func(id TableCellID, cell fyne.CanvasObject). Виртуализация как у List, только двумерная.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный экран

HTML-модель виджет-дерева fyne
Таблицыfyne · мок

Table: 4 строки × 3 столбца

NewTable — вне эмуляции
  • вне эмуляции: 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 и прокрути таблицу — красные остатки расползутся по списку; почини и запомни цену пропущенной ветки.

Личные заметкиПусто
Хранятся только в вашем браузере (Fyne · Таблицы и деревья)

Вопросы и ответы по уроку

Чем Table отличается от List и когда брать каждый?

List — одна колонка записей, у строки один id. Table — двумерная сетка одинаковых по набору полей записей, у ячейки пара Row и Col, и один шаблон на все ячейки. Если данные описываются срезом структур с фиксированными полями — Table; лента произвольных строк — List.

Почему колонки дёргаются при прокрутке?

Дефолтная ширина выводится из максимального MinSize видимых ячеек, а состав видимых ячеек меняется с прокруткой. Закрепи ширины через SetColumnWidth для каждой колонки сразу после создания таблицы — раскладка перестанет дышать.

Можно ли сделать строки разной высоты?

В Table нельзя: высота одна на всю таблицу, это часть контракта виртуализации. Строки произвольной высоты собираются в List — там каждая строка сама контейнер, а таблица хороша ровными сетками данных.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Cobra25 уроков~10 чпрактика25 уроков уже доступны

Cobra: CLI-приложения на Go

Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.

  • Создавать CLI-приложения с деревом команд на Cobra
  • Работать с аргументами, флагами и их привязкой к конфигу
  • Управлять конфигурацией через Viper: файлы, env, приоритеты
  • Писать хуки PreRun/PostRun и корректно обрабатывать ошибки