Содержание урока · 9 разделов
Каждый видел интерфейс, который тормозит от прокрутки: тысяча строк — тысяча виджетов, и каждый живёт в памяти, даже когда давно уехал за экран. widget.List решает это радикально: в дереве интерфейса существуют только видимые строки и пара про запас. Ты описываешь не тысячу виджетов, а три функции: length() сообщает, сколько всего элементов; createItem() создаёт шаблон одной строки; updateItem(id, item) наполняет шаблон данными конкретного элемента. Всё вместе — один вызов widget.NewList. Запрос «fyne list» ведёт именно сюда, и экономика такая: прокрутили список — тулкит не создаёт новые виджеты, а переиспользует освободившиеся из пула, меняя лишь их наполнение. Отсюда и главное правило урока, к которому мы придём через два примера: updateItem обязан полностью сбрасывать состояние строки, а не только включать нужное. Сначала — минимальный список, чтобы контракт стал привычным.
Минимальный список городов
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("Города")
cities := []string{"Москва", "Казань", "Сочи", "Тверь", "Пермь"}
details := widget.NewLabel("Выбери город")
list := widget.NewList(
func() int { return len(cities) },
func() fyne.CanvasObject {
return widget.NewLabel("шаблон")
},
func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(cities[id])
},
)
list.OnSelected = func(id widget.ListItemID) {
details.SetText("Выбрано: " + cities[id])
}
list.OnUnselected = func(id widget.ListItemID) {
details.SetText("Выбор снят")
}
window.SetContent(container.NewHSplit(list, details))
window.ShowAndRun()
}Три колбэка NewList в действии: length возвращает len(cities), createItem отдаёт шаблон — новый Label, updateItem пишет в шаблон данные строки id, приводя объект к *widget.Label. OnSelected и OnUnselected ловят клики по строкам; выделение хранит сам список — повторный клик по выбранной строке снимает выбор и вызывает OnUnselected. HSplit делит окно: слева список, справа детали выбранного элемента.
Что возвращает первый колбэк NewList — func() int?
Пул элементов: кто на самом деле живёт в списке#
Виртуализация работает так. При отрисовке список спрашивает length(), вычисляет диапазон видимых строк для текущей прокрутки и для каждой достаёт объект из пула; пул пуст — зовётся createItem. Прокрутка уводит строку за экран — объект не умирает, а возвращается в пул; новая строка с другого конца — объект достаётся из пула и прогоняется через updateItem уже с новым id. Поэтому createItem вызывается считанные разы — примерно по числу видимых строк, — а updateItem крутится на каждом кадре прокрутки. Из этого следует всё остальное. Шаблон из createItem один и тот же и для строки «Москва», и для строки «Сочи»: любое состояние, которое ты не сбросил в updateItem, переедет от одного элемента к другому. Считай updateItem функцией, которая получает чужой объект и обязана привести его к полному соответствию данным id: текст, цвет, галочка, шрифт, важность — каждый атрибут задаётся в обеих ветках, если и да, и нет. Это единственная цена виртуализации — и её платят один раз, но осознанно.
Сколько раз зовётся createItem?
Прочитайте код и выберите, что он выведет:
// список на 10 000 строк, на экране видно 20
list := widget.NewList(
func() int { return 10000 },
func() fyne.CanvasObject {
counter++
return widget.NewLabel("шаблон")
},
func(id widget.ListItemID, item fyne.CanvasObject) {
// наполнение
},
)
// сколько раз вызовется createItem при открытии окна?Состояние строки и обновление данных#
Классический баг виртуальных списков выглядит так: в updateItem строка с выполненной задачей красится зелёным, а для невыполненной ветка иначе просто не написана. Прокрутка — и зелёная строка прилипает к другим задачам: объект из пула пришёл с чужим цветом, а новый хозяин его не перекрасил. Лечение — всегда писать обе ветки: устанавливать и сбрасывать. Вторая тема — данные. Список не хранит элементы: он читает твой срез через length и updateItem, поэтому обновление выглядит всегда одинаково — меняешь срез, зовёшь list.Refresh(), и все видимые строки перечитываются. Точечная версия — list.RefreshItem(id): перечитать одну строку после правки, не трогая остальных; это дёшево и не сбивает прокрутку. Важно понимать природу id: это индекс в твоих данных, а не постоянный паспорт строки. После вставки в середину среза все id после точки вставки сдвинутся — и это нормально: при Refresh строки честно перечитаются и покажут актуальные данные. Хочешь устойчивость к перестановкам — храни в самих данных ключ и ищи по нему.
Задачи с чекбоксами и полный сброс
GOpackage main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
type task struct {
Title string
Done bool
}
func main() {
myApp := app.New()
window := myApp.NewWindow("Задачи")
tasks := []task{
{Title: "Купить молоко", Done: true},
{Title: "Написать отчёт", Done: false},
{Title: "Позвонить маме", Done: false},
{Title: "Прочитать главу", Done: true},
}
list := widget.NewList(
func() int { return len(tasks) },
func() fyne.CanvasObject {
return container.NewHBox(
widget.NewCheck("", nil),
widget.NewLabel("Шаблон задачи"),
)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container)
check := row.Objects[0].(*widget.Check)
label := row.Objects[1].(*widget.Label)
t := tasks[id]
check.Checked = t.Done // напрямую: без вызова OnChanged
check.Refresh()
check.OnChanged = func(on bool) {
tasks[id].Done = on
}
label.SetText(t.Title)
if t.Done {
label.TextStyle = fyne.TextStyle{Strikethrough: true}
} else {
label.TextStyle = fyne.TextStyle{} // сброс — обязателен!
}
label.Refresh()
},
)
window.SetContent(container.NewScroll(list))
window.ShowAndRun()
}Шаблон строки — HBox из Check и Label. updateItem делает полный сброс: check.Checked присваивается напрямую — вызов SetChecked дёрнул бы OnChanged и зациклил обработчик, — а стиль label задаётся в обеих ветках: зачёркнутый для выполненных и пустой для остальных. OnChanged навешивается после установки галочки и замыкает текущий id — клик правит срез. Убери пустую ветку TextStyle — и зачёркивание начнёт переезжать между строками на прокрутке: лучший способ прочувствовать пул.
После прокрутки зелёная подсветка «выполнено» показывается и на невыполненных задачах. Почему?
Управление списком: выделение и прокрутка#
У List есть и «руль» для программного управления. Select(id) выделяет строку — с теми же последствиями, что и клик: сработает OnSelected; UnselectAll() снимает выделение со всех строк. Колбэки OnSelected и OnUnselected получают ListItemID — по нему ты достаёшь данные и заполняешь панель деталей; повторный клик по выделенной строке снимает выбор. Прокрутка программируется тройкой: ScrollTo(id) открывает список на конкретной записи — типичный кейс после фильтрации или поиска, ScrollToTop() и ScrollToBottom() уводят к краям. RefreshItem(id) перечитывает одну строку, полный Refresh — весь список целиком. Поле HideSeparators прячет линии между строками — когда строки и так различимы, например обрамлены карточками. И ещё важное: список — обычный CanvasObject, он кладётся в любую компоновку четвёртого урока: в Border как центр, в HSplit рядом с деталями, в остаток VBox. Единственный запрет — не оборачивать список в container.NewScroll: прокрутка у него своя, и две прокрутки будут спорить за колесо мыши.
Живой поиск по списку
GOpackage main
import (
"fmt"
"strings"
"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("Поиск по контактам")
all := []string{
"Анна Сергеева", "Борис Ким", "Вера Литвинова",
"Глеб Морозов", "Дарья Осипова", "Егор Панин",
}
visible := all
search := widget.NewEntry()
search.PlaceHolder = "Начни вводить имя..."
status := widget.NewLabel(fmt.Sprintf("Найдено: %d", len(all)))
list := widget.NewList(
func() int { return len(visible) },
func() fyne.CanvasObject { return widget.NewLabel("шаблон") },
func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(visible[id])
},
)
search.OnChanged = func(q string) {
visible = nil
for _, name := range all {
if strings.Contains(strings.ToLower(name), strings.ToLower(q)) {
visible = append(visible, name)
}
}
status.SetText(fmt.Sprintf("Найдено: %d", len(visible)))
list.ScrollToTop()
list.Refresh()
}
reset := widget.NewButton("Сбросить фильтр", func() {
search.SetText("")
})
window.SetContent(container.NewBorder(
container.NewVBox(search, reset),
status, nil, nil, list,
))
window.ShowAndRun()
}Живой поиск из девятого урока на новых рельсах: OnChanged у Entry пересобирает срез visible, обновляет статус, зовёт ScrollToTop и list.Refresh() — length вернёт новую длину, updateItem перечитает видимые строки. SetText в кнопке сброса тоже дёргает OnChanged — фильтр снимается сам, без дублирования кода. Обрати внимание: список ни разу не пересоздан — меняются только данные и одна команда Refresh.
Что делает list.ScrollTo(42)?
Собери список задач
Восстанови четыре пропуска: длина данных, шаблон строки, тип идентификатора и прокрутка к строке.
Жизнь одного элемента на экране
Расставь по порядку, что происходит с элементом списка при отрисовке и прокрутке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Каталог API списков#
| API | Что делает | Когда звать |
|---|---|---|
| widget.NewList(length, createItem, updateItem) | создаёт виртуальный список из трёх колбэков | один раз при сборке интерфейса |
| length func() int | общее число элементов данных | список спрашивает при каждой перерисовке |
| createItem func() fyne.CanvasObject | шаблон одной строки | только когда пул опустел |
| updateItem(id, item) | наполнение шаблона данными строки id | на каждую видимую строку и прокрутку |
| OnSelected / OnUnselected | реакция на выбор и снятие выбора | клик по строке |
| Select / UnselectAll | выделить программно / снять выделение | по логике приложения |
| ScrollTo / ScrollToTop / ScrollToBottom | прокрутка к строке или краю | после фильтрации, открытие на записи |
| Refresh / RefreshItem(id) | перечитать все или одну строку | после изменения данных |
Спискам из этого урока нужен настоящий экран — в браузерной песочнице курса GUI не исполняется. Собери примеры по второму уроку и обязательно прокрути список до конца и обратно: баги пула проявляются только на прокрутке, при статичном виде всё выглядит правильно.
Производительность: updateItem под лупой#
Список прощает почти всё, кроме тяжёлого updateItem: он выполняется на каждый кадр прокрутки. Шаблон делай минимальным — Label дешевле RichText, а RichText дешевле панели из десятка объектов; иконки масштабируй заранее до нужного размера. Разбор данных — поиск подстроки, форматирование дат, сортировку — веди вне updateItem: срез уже подготовленных строк это правильный источник для length и updateItem, а не сырая база. Вложенный List внутри строки другого List работает, но это красный флаг архитектуры: почти всегда нужен Table из одиннадцатого урока или перегруппировка данных в плоский срез. Большие списки не любят частого полного Refresh: меняй точечно через RefreshItem, а массовые правки батчами с одним Refresh в конце. И помни про идентичность: не пытайся хранить соответствие «строка — объект», объектов у тебя нет — есть данные и id. Именно так устроен финальный менеджер задач из двадцать пятого урока: список задач, панель деталей и точечные обновления вместо перерисовки всего подряд.
Что дальше: таблицы, деревья и большой проект#
Компоновку под список давал четвёртый урок, Entry с OnChanged для живого поиска — девятый, а каталог виджетов с Check и Label — третий; события кликов по строкам продолжают шестой урок. Когда строки становятся двумерными — колонки, заголовки, дерево — наступает территория Table и Tree из одиннадцатого урока, он продолжает тему коллекций. Полный маршрут из двадцати пяти уроков — на странице курса Fyne. Домашнее задание соберёт адресную книгу с поиском, избранным и точечным обновлением — готовый скелет для почтового клиента или, после семнадцатого урока с binding, живого справочника.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyne10 задач — 1 виджет List
- вне эмуляции: widget.NewList
Экран собран: «Списки»
вне эмуляции: widget.NewList
Коротко: что запомнить
- widget.NewList описывает список тремя колбэками: length, createItem, updateItem — данные, шаблон и наполнение.
- Виртуализация: в дереве живут только видимые строки; createItem зовётся редко, updateItem — на каждый кадр прокрутки.
- Главное правило пула: updateItem сбрасывает всё состояние строки в обеих ветках — иначе чужие цвета переезжают по списку.
- Данные живут в твоём срезе: изменение плюс list.Refresh() или точечный RefreshItem(id).
- Выделение — Select и UnselectAll с колбэками OnSelected и OnUnselected; прокрутка — ScrollTo, ScrollToTop, ScrollToBottom.
- updateItem должен быть лёгким: подготовку данных веди в срезе, тяжёлое кешируй, полный Refresh заменяй точечным.
- Список кладётся в любую компоновку, но не в Scroll: у него своя прокрутка; двумерные данные — территория Table.
Домашнее задание
Собери адресную книгу: срез из тридцати структур с именем и телефоном, строка списка — HBox из двух Label, OnSelected выводит карточку контакта в правую панель через HSplit. Добавь поиск через Entry и OnChanged: фильтруй срез, обновляй счётчик найденных, вызывай ScrollToTop и list.Refresh, кнопка «Сбросить» очищает поле через SetText. Затем добавь флажок избранного в строку: Check с прямым присвоением Checked и OnChanged, замыкающим текущий id; проверь на прокрутке, что флажки не переезжают между людьми — если переезжают, ты забыл сброс в ветке иначе. В конце добавь кнопку «К последнему контакту» со ScrollToBottom и убедись, что изменение телефона первого контакта можно применить точечно через RefreshItem, не трогая остальные строки. Ловушка для самопроверки: намеренно убери пустую ветку стиля и прокрути список — узнай баг пула вживую, а потом почини.
Личные заметкиПусто
Вопросы и ответы по уроку
Сколько строк выдержит List?
Десятки тысяч без напряжения: в памяти существуют только видимые строки и пул про запас. Узкое место — updateItem: держи его лёгким, а данные готовь в срезе заранее, вне шаблона строки.
Почему при обновлении слетает выделение?
Список хранит выделение по id, а id — это индекс в данных: после пересортировки или вставки в середину среза прежний id указывает на другой элемент. Обновляй строки точечно через RefreshItem, а при перестройке данных восстанавливай Select по ключу из своих структур.
List внутри container.NewScroll — можно?
Нет: у списка своя прокрутка, и вложенные прокрутки спорят за колесо мыши. Списку просто отдают место компоновкой — центр Border, половина HSplit, остаток VBox; прокрутка содержимого строки решается внутри шаблона, а не снаружи.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки