Содержание урока · 11 разделов
- widget.BaseWidget: каркас поведения
- fyne.WidgetRenderer: контракт рендерера
- Контракт fyne.WidgetRenderer
- Полный рендерер: индикатор Gauge
- Поведение: клики, фокус и колбэки
- Гигиена кастомных виджетов
- Чек-лист перед публикацией виджета
- Что дальше: свои контролы в большом приложении
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Кнопки, формы и списки из первых уроков закрывают девять задач из десяти — но время от времени нужна деталь, которой в тулките нет: цветная плашка статуса, индикатор уровня, карточка со своим поведением, шкала с делениями. Именно за этим ты сегодня спустишься ещё на один уровень ниже седьмого: не просто рисовать примитивами, а упаковать рисунок в настоящий виджет — с минимальным размером, компоновкой, перерисовкой и кликами. Запрос «fyne кастомный виджет» в документации ведёт к паре widget.BaseWidget и fyne.WidgetRenderer: первый даёт каркас поведения, второй — контракт рисования. Механика везде одна: встраиваешь BaseWidget в структуру, описываешь CreateRenderer, наполняешь рендерер canvas-примитивами — и твой контрол без отличий участвует в любой компоновке четвёртого урока. В англоязычных источниках это называют custom widget, и готовые виджеты из третьего урока устроены ровно так же — после этого урока ты сможешь заглянуть в их исходники и прочитать их как родные.
widget.BaseWidget: каркас поведения#
Кастомный виджет — обычная структура со встроенным widget.BaseWidget: type Swatch struct { widget.BaseWidget; Color color.Color }. Встраивание даёт готовую реализацию fyne.CanvasObject: Resize, Move, MinSize, Show, Hide и Refresh уже работают — BaseWidget хранит размер, позицию и ссылку на рендерер и маршрутизирует все операции через них. Первое обязательное движение — самоссылка: в конструкторе зовут s.ExtendBaseWidget(s). Без неё каркас не знает адрес конкретной структуры, и переопределённые методы — тот же CreateRenderer или Tapped — не вызываются: базовые реализации работают вслепую, события не находят адресата, Refresh обновляет не то, что ты менял. Правило простое: любой конструктор кастомного виджета начинается с двух строк — создать структуру и отдать её саму себе в ExtendBaseWidget. Второе правило — про состояние: данные виджета (Color, Value, Text) живёт в экспортируемых полях, их читает и меняет пользователь твоего контрола; внутренние ссылки на canvas-объекты рендерера прячь в непубличные поля и наполняй их в CreateRenderer, когда тулкит впервые попросит отрисовку.
Swatch: минимальный виджет
GOpackage main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// Swatch — цветная плашка: минимальный кастомный виджет.
type Swatch struct {
widget.BaseWidget
Color color.Color
rect *canvas.Rectangle
}
// NewSwatch — конструктор: создать структуру и отдать её каркасу.
func NewSwatch(c color.Color) *Swatch {
s := &Swatch{Color: c}
s.ExtendBaseWidget(s)
return s
}
// CreateRenderer описывает, из чего виджет рисуется.
func (s *Swatch) CreateRenderer() fyne.WidgetRenderer {
s.rect = canvas.NewRectangle(s.Color)
return widget.NewSimpleRenderer(s.rect)
}
// SetColor меняет состояние и просит перерисовку.
func (s *Swatch) SetColor(c color.Color) {
s.Color = c
if s.rect != nil {
s.rect.FillColor = c
}
s.Refresh()
}
func main() {
myApp := app.New()
window := myApp.NewWindow("Swatch")
swatch := NewSwatch(color.NRGBA{R: 46, G: 204, B: 113, A: 255})
pick := widget.NewSelect([]string{"Зелёный", "Синий", "Красный"}, func(v string) {
switch v {
case "Зелёный":
swatch.SetColor(color.NRGBA{R: 46, G: 204, B: 113, A: 255})
case "Синий":
swatch.SetColor(color.NRGBA{R: 52, G: 152, B: 219, A: 255})
case "Красный":
swatch.SetColor(color.NRGBA{R: 231, G: 76, B: 60, A: 255})
}
})
window.SetContent(container.NewVBox(
container.NewGridWrap(fyne.NewSize(200, 64), swatch),
pick,
))
window.ShowAndRun()
}Классическая схема в одиннадцати строках: структура встраивает widget.BaseWidget, конструктор зовёт ExtendBaseWidget(s) — без этой самоссылки базовые методы не дошли бы до твоих переопределений. CreateRenderer возвращает widget.NewSimpleRenderer над canvas.Rectangle из седьмого урока: готовый рендерер сам растягивает объект на весь виджет и берёт его MinSize. Guard в SetColor страхует вызов до первой отрисовки — поле rect наполняется только в CreateRenderer. Select из третьего урока меняет цвет одним вызовом SetColor: пользователь виджета не знает про Refresh и FillColor — и не должен.
Зачем в конструкторе вызывать s.ExtendBaseWidget(s)?
fyne.WidgetRenderer: контракт рендерера#
Интерфейс fyne.WidgetRenderer — пять методов, и каждый отвечает за свою стадию жизни виджета. Objects() возвращает список canvas-объектов, которые painter рисует на слое виджета: эти же объекты принимают события и участвуют в отрисовке каждого кадра, поэтому возвращай одни и те же экземпляры при каждом вызове — свежесозданный слайс ломает и картинку, и события. Layout(size) — компоновка: тулкит сообщает финальный размер виджета, а ты раскладываешь детей вызовами Resize и Move; вызывается при каждом изменении размеров — окно тянут, контейнер пересчитался, тема сменила масштаб. MinSize() — твоё минимальное требование: родительская компоновка спросит его, строя строку VBox или ячейку грида; вернёшь ноль — виджет сожмут невидимо, ровно как примитивы в седьмом уроке, поэтому честный минимум считается от детей. Refresh() — перерисовка по новым данным: Refresh самого виджета проксирует сюда запрос. Destroy() — последняя воля рендерера: тулкит кеширует рендереры и освобождает их, когда виджет покидает дерево, — внешние ресурсы (буферы, подписки) освобождаются именно там.
Контракт fyne.WidgetRenderer#
| Метод | Назначение | Когда вызывается |
|---|---|---|
| Objects() []fyne.CanvasObject | стабильный список объектов для отрисовки и событий | каждый кадр отрисовки |
| Layout(size fyne.Size) | раскладка и Resize детей под новый размер | при каждом изменении размеров виджета |
| MinSize() fyne.Size | минимальный размер, который требует виджет | при компоновке родительского контейнера |
| Refresh() | привести объекты к актуальным данным | после смены состояния виджета |
| Destroy() | освобождение внешних ресурсов рендерера | когда рендерер выкидывается из кеша |
| widget.NewSimpleRenderer(obj) | готовый рендерер для виджета из одного объекта | в CreateRenderer простых виджетов |
Рендерер твоего виджета возвращает MinSize fyne.NewSize(0, 0). Что увидишь в VBox?
Полный рендерер: индикатор Gauge#
NewSimpleRenderer покрывает случаи «один объект на весь виджет», но настоящий рендерер собирает композицию: фон, заполнение, подпись. Схема такая: в CreateRenderer создаёшь canvas-объекты, складываешь их в поля структуры и возвращаешь собственный тип, реализующий все пять методов. Layout делает математику: ширина полосы заполнения — доля от ширины фона, умноженная на Value; подпись накрывает весь виджет и центрируется выравниванием из седьмого урока. Refresh — самое ответственное место: он вызывается после каждого SetValue и обязан привести объекты к данным заново — переписать текст, пересчитать размер заполнения и позвать Refresh у изменившихся объектов. Обрати внимание на разделение труда: SetValue меняет данные и зовёт Refresh у виджета; виджет проксирует запрос в рендерер; рендерер правит свои canvas-объекты. Ничего из этого не происходит само — за каждый кадр отвечает твоя цепочка из трёх звеньев, и пропустишь одно — на экране останется устаревшая картинка, как если бы ты забыл Refresh у примитива.
Gauge: свой рендерер целиком
GOpackage main
import (
"fmt"
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// Gauge — индикатор значения от 0 до 1.
type Gauge struct {
widget.BaseWidget
Value float64
bg *canvas.Rectangle
fill *canvas.Rectangle
label *canvas.Text
}
func NewGauge() *Gauge {
g := &Gauge{}
g.ExtendBaseWidget(g)
return g
}
// SetValue — данные меняются, экран просит кадр.
func (g *Gauge) SetValue(v float64) {
if v < 0 {
v = 0
}
if v > 1 {
v = 1
}
g.Value = v
g.Refresh()
}
func (g *Gauge) CreateRenderer() fyne.WidgetRenderer {
g.bg = canvas.NewRectangle(color.NRGBA{R: 230, G: 230, B: 230, A: 255})
g.fill = canvas.NewRectangle(color.NRGBA{R: 41, G: 128, B: 185, A: 255})
g.label = canvas.NewText("", theme.ForegroundColor())
g.label.Alignment = fyne.TextAlignCenter
return &gaugeRenderer{g: g}
}
// gaugeRenderer — полный рендерер: пять методов контракта.
type gaugeRenderer struct {
g *Gauge
}
func (r *gaugeRenderer) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.g.bg, r.g.fill, r.g.label}
}
func (r *gaugeRenderer) MinSize() fyne.Size {
return fyne.NewSize(180, 28)
}
func (r *gaugeRenderer) Layout(size fyne.Size) {
r.g.bg.Resize(size)
r.g.fill.Resize(fyne.NewSize(size.Width*float32(r.g.Value), size.Height))
r.g.label.Resize(size)
}
func (r *gaugeRenderer) Refresh() {
w := r.g.bg.Size().Width * float32(r.g.Value)
r.g.fill.Resize(fyne.NewSize(w, r.g.bg.Size().Height))
r.g.label.Text = fmt.Sprintf("%.0f%%", r.g.Value*100)
r.g.label.Color = theme.ForegroundColor()
r.g.bg.Refresh()
r.g.fill.Refresh()
r.g.label.Refresh()
}
func (r *gaugeRenderer) Destroy() {}
func main() {
myApp := app.New()
window := myApp.NewWindow("Gauge")
gauge := NewGauge()
slider := widget.NewSlider(0, 1)
slider.Step = 0.05
slider.OnChanged = func(v float64) {
gauge.SetValue(v)
}
window.SetContent(container.NewVBox(
container.NewGridWrap(fyne.NewSize(180, 28), gauge),
slider,
))
window.ShowAndRun()
}Полный контракт: gaugeRenderer возвращает из Objects() одни и те же три объекта, честный MinSize 180 на 28, Layout раскладывает детей по переданному размеру, а Refresh после каждого SetValue переписывает проценты и перерисовывает изменившиеся объекты. theme.ForegroundColor() берёт цвет текста из активной темы — как для widget.Label из третьего урока. Slider из каталога виджетов гонит значение через SetValue: пользователь слайдера и не подозревает, что правее — твой собственный контрол.
Gauge в VBox
Прочитайте код и выберите, что он выведет:
gauge := NewGauge() // рендерер возвращает MinSize 180 x 28
window.SetContent(container.NewVBox(
gauge,
widget.NewButton("Плюс", nil),
))Поведение: клики, фокус и колбэки#
Рисование — половина дела; поведение приходит из интерфейсов событий шестого урока. Реализуй на структуре метод Tapped(*fyne.PointEvent) — виджет становится fyne.Tappable, и клики мыши и тапы приходят сами; метод TappedSecondary добавляет fyne.SecondaryTappable: правую кнопку мыши и долгое нажатие на мобильных. Дальше вопрос дизайна API: наружу торчит не метод, а поле-колбэк OnTapped func() — пользователь твоего контрола подписывается на него, как на Button. Так устроен весь тулкит: Check с OnChanged, Select с OnChanged, List с OnSelected — и твой виджет должен говорить тем же языком, чтобы в чужом коде ничем не отличаться от штатного. Нужен ввод с клавиатуры — реализуй fyne.Focusable с методами FocusGained, FocusLost и TypedRune и получай фокус через canvas.Focus. Перетаскивание — интерфейс fyne.Draggable с Dragged и DragEnd. Каждый интерфейс — несколько строк на структуре, и контрол наращивает поведение слоями, оставаясь при этом обычным участником любой компоновки.
Card: кликабельная карточка
GOpackage main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// Card — кликабельная карточка с заголовком.
type Card struct {
widget.BaseWidget
Title string
OnTapped func()
OnTappedSecondary func()
bg *canvas.Rectangle
text *canvas.Text
}
func NewCard(title string) *Card {
c := &Card{Title: title}
c.ExtendBaseWidget(c)
return c
}
// Tapped реализует fyne.Tappable — левый клик и тап.
func (c *Card) Tapped(*fyne.PointEvent) {
if c.OnTapped != nil {
c.OnTapped()
}
}
// TappedSecondary реализует fyne.SecondaryTappable — правый клик.
func (c *Card) TappedSecondary(*fyne.PointEvent) {
if c.OnTappedSecondary != nil {
c.OnTappedSecondary()
}
}
func (c *Card) CreateRenderer() fyne.WidgetRenderer {
c.bg = canvas.NewRectangle(color.NRGBA{R: 245, G: 245, B: 245, A: 255})
c.text = canvas.NewText(c.Title, color.NRGBA{R: 30, G: 30, B: 30, A: 255})
c.text.Alignment = fyne.TextAlignCenter
c.text.TextStyle = fyne.TextStyle{Bold: true}
return widget.NewSimpleRenderer(container.NewStack(c.bg, c.text))
}
func main() {
myApp := app.New()
window := myApp.NewWindow("Карточки")
status := widget.NewLabel("Кликни карточку")
card := NewCard("Открыть отчёт")
card.OnTapped = func() {
status.SetText("Отчёт открыт")
}
second := NewCard("Настройки")
second.OnTapped = func() {
status.SetText("Настройки открыты")
}
second.OnTappedSecondary = func() {
status.SetText("Контекстное меню карточки")
}
window.SetContent(container.NewVBox(
container.NewGridWrap(fyne.NewSize(200, 64), card, second),
status,
))
window.ShowAndRun()
}Два метода — Tapped и TappedSecondary — делают Card реализацией fyne.Tappable и fyne.SecondaryTappable из шестого урока: события приходят сами, лишь бы структура была честным виджетом с BaseWidget. Наружу торчат поля OnTapped и OnTappedSecondary — так пользователь подписывается, не зная про интерфейсы. Внутри рендерера Stack из четвёртого урока: фон-Rectangle и подпись живут одним объектом, и NewSimpleRenderer растягивает стек целиком.
Почему Objects() должен возвращать одни и те же объекты при каждом вызове?
Гигиена кастомных виджетов#
Несколько привычек отличают надёжный контрол от хрупкого. Первая: не создавай canvas-объекты в Refresh — только в CreateRenderer; Refresh обязан обновлять существующие объекты, иначе получишь утечки и мерцание. Вторая: рендерер один на виджет — не вызывай CreateRenderer руками, тулкит сам решает, когда отрисовать и когда отдать рендерер под Destroy. Третья: MinSize считай от детей — label.MinSize() плюс отступы, а не из головы: с другим шрифтом или масштабом интерфейса жёсткие числа поломают раскладку; у Gauge фиксированный размер оставлен ради простоты примера. Четвёртая: темы. Цвета из theme-пакета запоминаются на момент создания и при смене темы сами не обновятся — та же ловушка примитивов из седьмого урока; либо фиксируй цвета сознательно, либо обновляй их в Refresh, как делает gaugeRenderer с theme.ForegroundColor(). Пятая: тяжёлые вычисления кешируй — Refresh может прилетать часто, и строить картинку заново на каждый чих не оплатит ни один пользователь. И шестая: не храни в виджете ссылок на чужие виджеты — общение только через колбэки, иначе свяжешь компоненты узлами, которые потом не развязать.
Чек-лист перед публикацией виджета#
- конструктор создаёт структуру и зовёт ExtendBaseWidget с ней же
- CreateRenderer создаёт canvas-объекты один раз и возвращает рендерер
- MinSize считается от детей, а не жёсткими числами
- Refresh обновляет существующие объекты и ничего не создаёт
- колбэки наружу — полями вида OnTapped, как у штатных виджетов тулкита
Собери конструктор Badge
Восстанови четыре пропуска: встраивание каркаса, самоссылка, готовый рендерер и команда перерисовки.
Путь кастомного виджета на экран
Расставь по порядку шаги от объявления структуры до перерисовки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Кастомные виджеты из этого урока не показать в браузерной песочнице курса: им нужен настоящий экран и графический рендер. Собери примеры локально по второму уроку — меняй MinSize, цвета и Layout прямо в коде и смотри, как реагирует компоновка после каждой правки.
Что дальше: свои контролы в большом приложении#
У тебя в руках полный цикл: примитивы седьмого урока — материал, события шестого — поведение, а контракт рендерера — упаковка. Прежде чем писать своё, перечитай каталог базовых виджетов — половина «кастомных» потребностей закрывается комбинацией контейнеров из четвёртого урока и сеток пятого; своё рисование оправдано, когда готовых деталей и правда нет. Ввод с клавиатуры и фокус из шестого урока превращают рисунок в настоящий контрол, а два следующих урока кормят контролы содержанием: тонкости ввода текста — в девятом, длинные перечни с виртуализацией — в десятом. Полный маршрут из двадцати пяти уроков — на странице курса Fyne. Домашнее задание соберёт кольцевой индикатор — и это будет твой первый контрол, который нельзя склеить из готовых виджетов.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyneRenderer().Objects(): заголовок и кнопка
Экран собран: «Кастомный виджет»
Коротко: что запомнить
- Кастомный виджет — структура со встроенным widget.BaseWidget: каркас даёт Resize, Move, MinSize, Show, Hide и Refresh из коробки.
- Конструктор обязан звать ExtendBaseWidget с самой структурой: без самоссылки переопределённые методы и события не работают.
- fyne.WidgetRenderer — пять методов: Objects, Layout, MinSize, Refresh, Destroy; объекты создаются один раз в CreateRenderer.
- widget.NewSimpleRenderer закрывает простые случаи: один объект, растянутый на весь виджет, с его MinSize.
- MinSize рендерера — минимум виджета для компоновки: ноль схлопывает контрол, честное значение считается от детей.
- Поведение наращивается интерфейсами шестого урока: Tapped, TappedSecondary, Focusable, Draggable; наружу — колбэки-поля.
- Гигиена: не создавать объекты в Refresh, считать MinSize от детей, кешировать тяжёлое, обновлять цвета темы руками.
Домашнее задание
Собери кольцевой индикатор уровня заряда: кастомный виджет Ring с полем Value от 0 до 1, рендерер — тёмный canvas.Circle-фон, дуга из двенадцати коротких canvas.Line, координаты концов посчитай через math.Sin и math.Cos, по центру — canvas.Text с процентами. Реализуй полный контракт рендерера: Layout масштабирует дугу под переданный размер, MinSize возвращает разумный минимум от подписи, Refresh пересчитывает длину заполненной дуги и перекрашивает сегменты — заполненные цветом темы Primary, пустые серым. Добавь метод SetValue с зажимом диапазона и кнопки «Плюс 10» и «Минус 10» через слайдер из примера. Затем сделай виджет кликабельным: Tapped ставит значение по позиции клика из fyne.PointEvent, TappedSecondary сбрасывает в ноль. В конце оберни Ring в GridWrap и в Border и проверь, что Layout честно отрабатывает оба случая, а MinSize не даёт кольцу схлопнуться.
Личные заметкиПусто
Вопросы и ответы по уроку
Виджет не реагирует на клики — что проверить сначала?
Первым делом самоссылку: ExtendBaseWidget(s) в конструкторе. Без неё события не маршрутизируются в твою структуру. Дальше — что метод Tapped реализован на самой структуре виджета, а не на рендерере, и что виджет не заблокирован Disable.
Можно ли унаследоваться от готового виджета вместо BaseWidget?
Да: встраивание widget.Button или widget.Entry даёт весь их рендер и поведение, а ты переопределяешь только нужное — например, перехватываешь Tapped. Это другой стиль расширения: не «рисую с нуля», а «дорисовываю поверх»; выбирай по объёму кастомизации.
Почему после смены темы мои цвета остались старыми?
Ты запомнил значение цвета на момент создания — примитив хранит цвет, а не подписку на тему. Бери цвета в Refresh через theme-пакет, как делает gaugeRenderer, тогда каждая перерисовка возьмёт актуальную палитру; системная тематизация всего приложения — пятнадцатый урок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки