---
title: "Списки и коллекции"
description: "Списки в Fyne: widget.NewList с length, createItem и updateItem, виртуализация и пул элементов, Select и ScrollTo, RefreshItem и живой поиск по данным."
course: "Fyne: десктопные и мобильные GUI на Go"
url: "https://golangmaster.ru/courses/fyne/lessons/10-spiski/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Списки и коллекции

*Курс «Fyne: десктопные и мобильные GUI на Go», урок 10 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fyne/lessons/10-spiski/*

Десять записей можно показать VBox-ом, сто — терпеть, а тысяча убьёт и память, и плавность прокрутки. Запрос «fyne list» — это widget.NewList: список с виртуализацией, в котором существует только то, что видно на экране. Три колбэка — length, createItem, updateItem — описывают данные, шаблон и заполнение, а пул элементов делает перечни любой длины дешёвыми. Списки fyne — фундамент почтовых клиентов, файловых браузеров и менеджера задач из итогового проекта: сегодня разберём контракт по косточкам и научимся не наступать на главную граблю виртуализации — состояние, которое переезжает между строками.

Каждый видел интерфейс, который тормозит от прокрутки: тысяча строк — тысяча виджетов, и каждый живёт в памяти, даже когда давно уехал за экран. widget.List решает это радикально: в дереве интерфейса существуют только видимые строки и пара про запас. Ты описываешь не тысячу виджетов, а три функции: length() сообщает, сколько всего элементов; createItem() создаёт шаблон одной строки; updateItem(id, item) наполняет шаблон данными конкретного элемента. Всё вместе — один вызов widget.NewList. Запрос «fyne list» ведёт именно сюда, и экономика такая: прокрутили список — тулкит не создаёт новые виджеты, а переиспользует освободившиеся из пула, меняя лишь их наполнение. Отсюда и главное правило урока, к которому мы придём через два примера: updateItem обязан полностью сбрасывать состояние строки, а не только включать нужное. Сначала — минимальный список, чтобы контракт стал привычным.

### Минимальный список городов

```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("Города")

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

### Пул элементов: кто на самом деле живёт в списке

Виртуализация работает так. При отрисовке список спрашивает length(), вычисляет диапазон видимых строк для текущей прокрутки и для каждой достаёт объект из пула; пул пуст — зовётся createItem. Прокрутка уводит строку за экран — объект не умирает, а возвращается в пул; новая строка с другого конца — объект достаётся из пула и прогоняется через updateItem уже с новым id. Поэтому createItem вызывается считанные разы — примерно по числу видимых строк, — а updateItem крутится на каждом кадре прокрутки. Из этого следует всё остальное. Шаблон из createItem один и тот же и для строки «Москва», и для строки «Сочи»: любое состояние, которое ты не сбросил в updateItem, переедет от одного элемента к другому. Считай updateItem функцией, которая получает чужой объект и обязана привести его к полному соответствию данным id: текст, цвет, галочка, шрифт, важность — каждый атрибут задаётся в обеих ветках, если и да, и нет. Это единственная цена виртуализации — и её платят один раз, но осознанно.

### Сколько раз зовётся createItem?

```go
// список на 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 при открытии окна?
```

**Что выведет программа?**

- 10 000 — по объекту на каждую строку данных
- ✅ Около 20-30 — по числу видимых строк плюс запас пула
- Один — шаблон переиспользуется единственный
- Ни одного до первой прокрутки

Виртуализация: createItem зовётся, только когда пул опустел — то есть примерно по числу видимых строк с небольшим запасом. Остальные девять с лишним тысяч строк в памяти не существуют вовсе: их отрисуют из того же пула по мере прокрутки.

### Состояние строки и обновление данных

Классический баг виртуальных списков выглядит так: в updateItem строка с выполненной задачей красится зелёным, а для невыполненной ветка иначе просто не написана. Прокрутка — и зелёная строка прилипает к другим задачам: объект из пула пришёл с чужим цветом, а новый хозяин его не перекрасил. Лечение — всегда писать обе ветки: устанавливать и сбрасывать. Вторая тема — данные. Список не хранит элементы: он читает твой срез через length и updateItem, поэтому обновление выглядит всегда одинаково — меняешь срез, зовёшь list.Refresh(), и все видимые строки перечитываются. Точечная версия — list.RefreshItem(id): перечитать одну строку после правки, не трогая остальных; это дёшево и не сбивает прокрутку. Важно понимать природу id: это индекс в твоих данных, а не постоянный паспорт строки. После вставки в середину среза все id после точки вставки сдвинутся — и это нормально: при Refresh строки честно перечитаются и покажут актуальные данные. Хочешь устойчивость к перестановкам — храни в самих данных ключ и ищи по нему.

### Задачи с чекбоксами и полный сброс

```go
package 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 — и зачёркивание начнёт переезжать между строками на прокрутке: лучший способ прочувствовать пул.

**Вопрос:** После прокрутки зелёная подсветка «выполнено» показывается и на невыполненных задачах. Почему?

- ✅ Пул переиспользует объекты строк: updateItem красит только выполненные и не сбрасывает цвет в ветке иначе
- Список кеширует скриншоты старых строк
- OnSelected не вызывается при прокрутке
- Это баг видеодрайвера, помогает Resize окна

Объект строки один и тот же для разных id: пришёл от «выполненной» строки зелёным — и остался зелёным, потому что updateItem не привёл его к данным нового id. Правило пула: каждый атрибут устанавливается в обеих ветках — и для да, и для нет.

### Управление списком: выделение и прокрутка

У List есть и «руль» для программного управления. Select(id) выделяет строку — с теми же последствиями, что и клик: сработает OnSelected; UnselectAll() снимает выделение со всех строк. Колбэки OnSelected и OnUnselected получают ListItemID — по нему ты достаёшь данные и заполняешь панель деталей; повторный клик по выделенной строке снимает выбор. Прокрутка программируется тройкой: ScrollTo(id) открывает список на конкретной записи — типичный кейс после фильтрации или поиска, ScrollToTop() и ScrollToBottom() уводят к краям. RefreshItem(id) перечитывает одну строку, полный Refresh — весь список целиком. Поле HideSeparators прячет линии между строками — когда строки и так различимы, например обрамлены карточками. И ещё важное: список — обычный CanvasObject, он кладётся в любую компоновку четвёртого урока: в Border как центр, в HSplit рядом с деталями, в остаток VBox. Единственный запрет — не оборачивать список в container.NewScroll: прокрутка у него своя, и две прокрутки будут спорить за колесо мыши.

### Живой поиск по списку

```go
package 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)?

- Выделяет строку с индексом 42
- ✅ Прокручивает список так, чтобы строка 42 была видна на экране
- Удаляет все строки до 42-й
- Возвращает 42-й элемент данных

ScrollTo — про позицию прокрутки, не про выделение и не про данные: список сместится так, чтобы строка с id 42 попала в видимую область. Выделение — отдельная операция Select(42), данные — всегда твой срез.

### Собери список задач

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

```go
list := widget.NewList(
        func() int { return ___(tasks) },
        func() fyne.CanvasObject { return widget.___("шаблон") },
        func(id widget.___, item fyne.CanvasObject) {
                item.(*widget.Label).SetText(tasks[id].Title)
        },
)
list.___(5) // прокрутить к шестой задаче
```

Подсказка: Длину среза даёт len, шаблон строки — widget.NewLabel, идентификатор строки — widget.ListItemID, а прокрутка к записи — ScrollTo.

Правильные ответы в пропусках: `len`, `NewLabel`, `ListItemID`, `ScrollTo`

### Жизнь одного элемента на экране

Расставь по порядку, что происходит с элементом списка при отрисовке и прокрутке.

Правильный порядок строк:

1. `список вызывает length() — сколько всего элементов в данных`
2. `вычисляется диапазон id видимых строк для текущей прокрутки`
3. `для каждой строки берётся объект из пула, при пустом пуле зовётся createItem`
4. `updateItem(id, item) приводит объект к данным строки — сбрасывая всё состояние`
5. `строка рисуется и отвечает на клики через OnSelected`
6. `прокрутка уводит строку за экран — объект возвращается в пул и ждёт новый id`

### Каталог 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. Именно так устроен финальный менеджер задач из двадцать пятого урока: список задач, панель деталей и точечные обновления вместо перерисовки всего подряд.

### Что дальше: таблицы, деревья и большой проект

Компоновку под список давал [четвёртый урок](/courses/fyne/lessons/04-box/), Entry с OnChanged для живого поиска — [девятый](/courses/fyne/lessons/09-text-entry/), а каталог виджетов с Check и Label — [третий](/courses/fyne/lessons/03-vidzhety/); события кликов по строкам продолжают [шестой урок](/courses/fyne/lessons/06-sobytiya/). Когда строки становятся двумерными — колонки, заголовки, дерево — наступает территория Table и Tree из одиннадцатого урока, он продолжает тему коллекций. Полный маршрут из двадцати пяти уроков — [на странице курса Fyne](/courses/fyne/). Домашнее задание соберёт адресную книгу с поиском, избранным и точечным обновлением — готовый скелет для почтового клиента или, после семнадцатого урока с binding, живого справочника.

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

- **widget.NewList** — Три колбэка: length func() int, createItem func() fyne.CanvasObject, updateItem func(id ListItemID, item fyne.CanvasObject). Виртуализация: в дереве живёт только видимое.
- **ListItemID** — Тип-алиас для int: индекс элемента в твоих данных, а не постоянный паспорт строки. После вставки в середину среза id после точки вставки сдвигаются.
- **Правило пула** — updateItem обязан полностью сбрасывать состояние строки: каждый атрибут задаётся в обеих ветках — и для да, и для нет. Иначе цвета и галочки переезжают от чужих строк.
- **Обновление данных** — Меняешь срез — list.Refresh() перечитывает все видимые строки; RefreshItem(id) точечно перечитает одну, не сбивая прокрутку.
- **Выделение и прокрутка** — Select(id) и UnselectAll управляют выбором, OnSelected/OnUnselected сообщают о нём; ScrollTo(id), ScrollToTop, ScrollToBottom — программная прокрутка.
- **Экономика createItem** — Вызывается лишь по числу видимых строк плюс запас: объекты из пула переиспользуются, поэтому тяжёлое создание оплачивается один раз, а updateItem должен быть лёгким.

## Коротко: что запомнить

- 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; прокрутка содержимого строки решается внутри шаблона, а не снаружи.
