Содержание урока · 10 разделов
В каждом десктопном приложении рано или поздно наступает момент, когда VBox уже не справляется: экранов стало много. Fyne даёт три инструмента навигации, и у каждого своя роль. widget.AppTabs — классические вкладки-разделы: сверху или снизу полоса с заголовками, клик переключает содержимое; так делают экраны «Заметки / Архив / Настройки». widget.DocTabs — документные вкладки из редакторов кода: каждая вкладка с крестиком, вкладки можно открывать и закрывать на лету. Контейнер container.SplitContainer — это не вкладки, а делёж площади: две панели и перетаскиваемая разделительная полоса между ними, знакомая тебе по HSplit из десятого урока, где слева был список, справа — детали. Все три инструмента комбинируются: вкладки внутри половины Split, Split внутри вкладки. Поисковый запрос «fyne вкладки» и «fyne tabs» ведут ровно сюда — к паре AppTabs/DocTabs и TabItem, их элементу. Начнём с разделов: три вкладки для будущих экранов менеджера задач.
AppTabs: разделы приложения
GOpackage main
import (
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Менеджер задач")
inbox := widget.NewLabel("Список задач — из десятого урока")
editor := widget.NewMultiLineEntry()
editor.SetPlaceHolder("Новая задача...")
stats := widget.NewLabel("Таблица статистики — из одиннадцатого урока")
tabs := widget.NewAppTabs(
widget.NewTabItem("Задачи", inbox),
widget.NewTabItem("Быстрый ввод", editor),
widget.NewTabItem("Статистика", stats),
)
tabs.SetLocation(widget.TabLocationBottom)
tabs.OnChanged = func(item *widget.TabItem) {
window.SetTitle("Менеджер задач — " + item.Text)
}
window.SetContent(tabs)
window.ShowAndRun()
}widget.NewAppTabs принимает готовые TabItem: каждый — заголовок и содержимое, созданные через widget.NewTabItem. AppTabs сам является виджетом, поэтому его можно отдать SetContent напрямую — а можно вложить в Border или Split. SetLocation уводит полосу вкладок вниз, как в мобильных приложениях. OnChanged получает элемент вкладки — здесь он синхронизирует заголовок окна с открытым разделом.
Какой аргумент получает колбэк tabs.OnChanged?
TabItem: анатомия вкладки#
Элемент вкладки — маленькая структура из трёх полей: Text, Icon и Content. Фабрики widget.NewTabItem(заголовок, содержимое) и widget.NewTabItemWithIcon(заголовок, иконка, содержимое) создают её; иконка — это fyne.Resource, обычно theme.*Icon() или свой ресурс из шестнадцатого урока. Содержимое — обычный CanvasObject: один виджет или контейнер хоть со всей компоновкой курса внутри. Важное следствие: содержимое вкладки создаётся в момент сборки TabItem и живёт в списках Items всё время работы приложения — скрытая вкладка не удаляется, а просто не показывается. Для тяжёлых экранов это повод строить их лениво: создаешь вкладку с заглушкой, в OnChanged при первом открытии наполняешь содержимое данными. Заголовок меняется и после создания: item.Text = «Архив (12)» плюс tabs.Refresh() — полоса перерисуется. Пара DisableItem и EnableItem выключает и включает вкладку: неактивный раздел виден в полосе, но не кликается; есть и варианты по индексу — DisableIndex и EnableIndex. Программное переключение — Select(item) и SelectIndex(i): с точки зрения событий это тот же путь, что клик пользователя, до последнего колбэка.
Сработает ли OnChanged без клика?
Прочитайте код и выберите, что он выведет:
tabs := widget.NewAppTabs(
widget.NewTabItem("Задачи", inbox),
widget.NewTabItem("Настройки", settings),
)
tabs.OnChanged = func(item *widget.TabItem) {
fmt.Println("открыта вкладка: " + item.Text)
}
tabs.SelectIndex(1)
// что напечатается?TabLocation: где стоит полоса вкладок#
Полоса вкладок настраивается полем Location — значением типа widget.TabLocation. Их четыре: TabLocationTop — полоса сверху, значение по умолчанию; TabLocationBottom — снизу, приём из мобильных интерфейсов, где большим пальцем дотянуться до нижнего края проще; TabLocationLeading — боковая полоса слева с вертикальными заголовками; TabLocationRight и TabLocationLeft — устаревшие синонимы Leading, оставшиеся от старых версий: компилятор напомнит, что пользоваться ими не стоит. Задать можно двумя способами: полем tabs.Location = widget.TabLocationBottom с последующим Refresh или методом tabs.SetLocation(widget.TabLocationBottom), который сам дёргает перерисовку — каноничное правило сеттеров из второго и третьего уроков в действии. В боковой раскладке ведут себя вкладки с длинными заголовками: полоса забирает ширину под самый длинный заголовок, так что «Быстрый ввод» лучше сократить. Комбинируется Location свободно: боковые вкладки слева плюс Split справа — готовый скелет панели администратора; нижняя полоса плюс Border сверху — мобильный лэйаут, который почти без правок переживёт сборку под Android из двадцать второго урока.
Нужно перенести полосу вкладок вниз окна. Какой вызов правильный и почему?
DocTabs: документные вкладки#
DocTabs — родной брат AppTabs с одной судьбоносной разницей: у каждой вкладки появляется крестик, и пользователь может закрывать документы. API смены вкладок тот же — Items, Select, OnChanged, Location, — добавляются два колбэка на закрытие. OnClosed вызывается уже после того, как вкладка удалена из Items, — тут чистят за ней ресурсы. Главная кнопка — CloseIntercept: если он задан, крестик больше не удаляет вкладку сам, а вызывает твою функцию, и решение остаётся за тобой — удалить вкладку вызовом tabs.Remove(item), показать подтверждение или отказать. Это вкладочный близнец SetCloseIntercept у окна из второго урока: перехватчик полностью берёт ответственность на себя. Канонический сценарий — несохранённый документ: помечай изменённые вкладки точкой или звёздочкой в заголовке, а в перехватчике либо закрывай сохранённое, либо предлагай сохранить перед выходом; сам инструмент подтверждения — тема следующего урока, там же вернёмся к этому сценарию с диалогом. Важно: без CloseIntercept DocTabs удаляет вкладку сразу и безвозвратно — Content не сохраняется нигде, данные живут только в твоих структурах.
Перехват закрытия несохранённой вкладки
GOpackage main
import (
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Заметки")
status := widget.NewLabel("Открыто 2 документа")
docs := widget.NewDocTabs(
widget.NewTabItem("shopping.txt", widget.NewLabel("молоко, хлеб")),
widget.NewTabItem("ideas.txt", widget.NewLabel("свой виджет...")),
)
docs.CloseIntercept = func(item *widget.TabItem) {
if item.Text == "ideas.txt" {
item.Text = "ideas.txt ●"
docs.Refresh()
status.SetText("ideas.txt не сохранён — закрытие отклонено")
return
}
docs.Remove(item)
}
docs.OnClosed = func(item *widget.TabItem) {
status.SetText("Закрыто: " + item.Text)
}
window.SetContent(container.NewBorder(nil, status, nil, nil, docs))
window.ShowAndRun()
}CloseIntercept получил вкладку и решил её судьбу: ideas.txt помечен точкой в заголовке и остался открыт — без вызова docs.Remove вкладка не удаляется; shopping.txt удаляется явно. После закрытия сработает OnClosed и обновит статус. Заметь: смена item.Text требует docs.Refresh — поле заголовка само себя не перерисовывает.
CloseIntercept задан, но ничего не удаляет. Что произойдёт по клику на крестик?
SplitContainer: регулируемый делёж#
Вкладки переключают экраны, SplitContainer делит площадь. container.NewHSplit(левая, правая) и container.NewVSplit(верхняя, нижняя) возвращают контейнер *container.Split с полем Offset — относительной позицией разделителя от 0.0 до 1.0. Offset 0.25 означает: левая панель получает четверть площади, разделитель стоит там же, где ты его перетащил мышью. Пользователь двигает полосу сам, программа может задать стартовую позицию — присвой split.Offset = 0.3 до показа окна или вызови split.SetOffset(0.3); снова правило сеттеров: запись в поле без Refresh молчит, если окно уже показано. В каноничном сочетании с коллекциями из двух прошлых уроков Split — это рабочее место: слева дерево файлов или список задач, справа редактор; именно так устроен финальный проект. Смена панелей местами делается просто перестановкой аргументов. Вложение — свободное: DocTabs в правой половине, HSplit внутри вкладки — глубина дерева контейнеров ограничена только здравым смыслом; канон компоновки из четвёртого урока здесь работает без оговорок, потому что Split — обычный контейнер, требующий от детей MinSize и отдающий им весь остаток. Отдельный бонус — запоминание раскладки: пользователь передвинул разделитель — сохрани Offset в preferences из девятнадцатого урока и верни при следующем запуске, интерфейс станет «своим».
Рабочее место: дерево файлов и вкладки документов
GOpackage main
import (
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Редактор")
files := widget.NewTreeWithStrings(map[string][]string{
"": {"ui", "store", "main.go"},
"ui": {"tabs.go", "list.go"},
"tabs.go": nil,
"list.go": nil,
"store": {"notes.go"},
"notes.go": nil,
"main.go": nil,
})
editor := widget.NewMultiLineEntry()
editor.SetPlaceHolder("Текст файла...")
docs := widget.NewDocTabs(
widget.NewTabItem("main.go", editor),
)
docs.SetLocation(widget.TabLocationBottom)
split := container.NewHSplit(files, docs)
split.Offset = 0.25
window.SetContent(split)
window.ShowAndRun()
}Три инструмента урока в одном окне: дерево файлов из одиннадцатого урока слева, DocTabs с документами справа, полоса вкладок снизу, Split делит площадь — Offset 0.25 отдаёт четверть дереву. Пользователь может перетащить разделитель и растянуть любую половину: каркас переложит MinSize обоих детей и разделит остаток по Offset.
Куда встанет разделитель
Прочитайте код и выберите, что он выведет:
split := container.NewHSplit(leftPanel, rightPanel)
split.Offset = 0.9
window.SetContent(split)
window.ShowAndRun()
// где окажется разделительная полоса?Собери вкладки настроек
Восстанови четыре пропуска: конструктор вкладок, фабрика элемента, программное переключение и расположение полосы.
Путь переключения вкладки
Расставь по порядку, что происходит между кликом по вкладке и колбэком OnChanged.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Каталог API вкладок и навигации#
| API | Что делает | Когда звать |
|---|---|---|
| widget.NewAppTabs(items...) | вкладки-разделы приложения | один раз при сборке интерфейса |
| widget.NewDocTabs(items...) | документные вкладки с крестиками | панель открытых документов |
| NewTabItem(text, content) | элемент вкладки: заголовок и содержимое | при создании и Append |
| Items / Append / Remove / RemoveIndex | список вкладок и его правка | динамические документы |
| Select(item) / SelectIndex(i) | программное переключение, тот же путь, что клик | открытие раздела из логики |
| CurrentTab / CurrentTabIndex | что открыто сейчас | в колбэках и перед переключением |
| OnChanged func(*TabItem) | факт смены вкладки | статусная строка, ленивое наполнение |
| Location / SetLocation | Top, Bottom, Leading или Trailing | мобильный лэйаут и боковые панели |
| CloseIntercept / OnClosed (DocTabs) | перехват закрытия и факт после него | несохранённые документы |
| container.NewHSplit / NewVSplit + Offset | регулируемый делёж площади | рабочее место из двух панелей |
Вкладкам из этого урока нужен настоящий экран — в браузерной песочнице курса GUI не исполняется. Собери примеры по второму уроку и попробуй три вещи подряд: переключить все вкладки кликами, перетащить разделитель Split до упора и закрыть каждую документную вкладку крестиком — краевые случаи навигации проявляются только руками.
Сколько вкладок и что внутри#
Практика интерфейсов, а не API: AppTabs хорош на трёх-семи разделах — полоса из двадцати заголовков превращается во вторую навигацию и перестаёт помогать. Заголовки пиши короткими существительными и не пиши их заглавными — полоса сама выделит активную вкладку. Содержимое вкладок держи лёгким на старте: как сказано выше, всё живёт в памяти; таблица на сто тысяч строк, спрятанная в невидимой вкладке, всё равно отняла бы время сборки — наполняй такие экраны лениво при первом OnChanged. DocTabs любит динамические заголовки: имя файла плюс точка-маркер изменённости читается мгновенно. Offset подбирается под доминирующую панель: у редактора 0.75, у дерева файлов 0.2-0.3. И помни про минимальные размеры: спрячь в панель виджет без MinSize — пользователь сожмёт её в ноль; защита — MinSize собственного виджета из восьмого урока или отступ-минимум в контейнере. На мобильной сборке нижняя полоса вкладок и вертикальный Split дают почти нативное поведение — проверяется запуском go run -tags mobile из первого урока. Напоследок замечание про параллель: полоса вкладок — не замена поиску и не каталог: если разделов много, но все они узкие, дерево из одиннадцатого урока внутри боковой панели Leading покажет структуру глубже, чем плоский ряд заголовков, а вкладки оставь документам и настройкам.
Что дальше: диалоги и подтверждения#
Навигация собрана, но в сценарии DocTabs осталась дыра: перехватчик закрытия умеет только отклонять или помечать вкладку, а настоящий вопрос «документ не сохранён — закрыть?» задаёт диалог. Тринадцатый урок закрывает её: dialog.ShowConfirm, уведомления, файловый выбор и модальность — там же вернёмся к SetCloseIntercept окна из второго урока и сделаем честный выход с подтверждением. Материал этого урока опирается на компоновку четвёртого, списки десятого и дерево одиннадцатого. Полный маршрут из двадцати пяти уроков — на странице курса Fyne. Домашнее задание соберёт многоэкранное приложение с боковой навигацией и документными вкладками — скелет, в который лягут и диалоги следующего урока, и настройки из девятнадцатого.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyneПервая вкладка
Экран собран: «Вкладки»
Коротко: что запомнить
- AppTabs — вкладки-разделы: NewTabItem для элементов, Select и SelectIndex для программного переключения, OnChanged func(*TabItem) для реакции.
- Программное переключение вкладки проходит тот же путь, что и клик: OnChanged срабатывает и без пользователя.
- Полоса вкладок ставится Location или SetLocation: Top, Bottom, Leading, Trailing; Left и Right устарели.
- DocTabs добавляет крестики: CloseIntercept перехватывает закрытие и решает судьбу вкладки сам, OnClosed срабатывает после удаления.
- Смена item.Text требует tabs.Refresh — правило сеттеров действует и на заголовки вкладок.
- SplitContainer: NewHSplit и NewVSplit с Offset от 0.0 до 1.0 — регулируемый делёж площади под рабочее место.
- Содержимое всех вкладок живёт в памяти: тяжёлые экраны наполняй лениво при первом OnChanged.
Домашнее задание
Собери каркас «рабочего стола»: слева AppTabs с полосой TabLocationLeading и тремя разделами — «Задачи» со списком из десятого урока, «Статистика» с таблицей из одиннадцатого, «Настройки» с парой полей формы из пятого; OnChanged пишет открытый раздел в заголовок окна. Справа — DocTabs с тремя документами: два открываются крестиком без вопросов, у третьего CloseIntercept помечает вкладку точкой и отклоняет закрытие, повторный крестик на сохранённой версии закрывает её через Remove. Панели соедини в VSplit с Offset 0.7, чтобы полоса документов была снизу. Проверь руками: перетаскивание разделителя до упора, программное открытие каждого раздела кнопками в «Настройках» через SelectIndex, ленивое наполнение вкладки «Статистика» — данные считаются только при первом открытии. Ловушка: смени заголовок вкладки без Refresh и посмотри, сколько попыток понадобится, чтобы заметить, что текст не обновился.
Личные заметкиПусто
Вопросы и ответы по уроку
Сколько вкладок выдержит AppTabs и когда это уже перебор?
Технически — сколько угодно: полоса прокручивается. Практически — пять-семь заголовков: дальше полоса превращается в дополнительную навигацию, и разделы стоит сгруппировать в подэкраны или боковое меню с деревом из одиннадцатого урока.
Как показать диалог подтверждения при закрытии вкладки?
В CloseIntercept вызывай dialog.ShowConfirm из следующего урока: подтверждение — docs.Remove(item), отказ — просто вернись. Содержимое вкладки при Remove не сохраняется, данные держи в своих структурах.
SplitContainer — это layout или контейнер?
Контейнер, как NewMax или NewBorder: container.NewHSplit возвращает *container.Split, который сам компоновит двух детей и хранит Offset. Дополнительных layout-параметров у него нет, а детям он отдаёт всю площадь своих половин.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки