---
title: "Привязка данных (binding)"
description: "Привязка данных в Fyne: binding.String, Float и Bool, метод Bind у Entry и Label, DataItem и DataListener, слушатели изменений и внешние значения."
course: "Fyne: десктопные и мобильные GUI на Go"
url: "https://golangmaster.ru/courses/fyne/lessons/17-binding/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Привязка данных (binding)

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

Шестнадцатый урок упаковал иконки и шрифты в ресурсы — интерфейс стал законченным, но данные по-прежнему таскались в виджеты руками: SetText в обработчике, Refresh после правки среза. Пакет fyne.io/fyne/v2/data/binding переворачивает схему: значение становится объектом-источником, который сам сообщает подписчикам об изменениях, а виджеты подписываются методом Bind. Запрос «fyne binding» — это binding.String и его братья Float, Bool, Int, DataItem как общий интерфейс и DataListener как механизм уведомлений; «привязка данных fyne» целиком стоит на этих трёх опорах. Урок ведёт от первого двустороннего Entry через слушателей и преобразования к правилу одной истины: данные живут в источнике, виджеты только отображают. Заодно закладывается фундамент следующего урока — валидация ввода начинается с честного вопроса о том, куда и как течёт значение из поля.

### От ручной синхронизации к источникам данных

Восемнадцать уроков подряд ты доставал данные в интерфейс по личному распоряжению: SetText в обработчике кнопки, RefreshItem после удаления записи, обновление метки после каждого клика. Пока источник один и обработчиков два, схема живёт; но когда значений становится десятки, а менять их нужно из разных мест — из меню четырнадцатого урока, из диалогов тринадцатого, из фоновых задач — ручная синхронизация начинает пропускать обновления, и интерфейс врёт. Пакет fyne.io/fyne/v2/data/binding решает задачу архитектурно. Запрос «fyne binding» — это источники данных, которые сами сообщают подписчикам об изменениях: значение перестаёт быть безликой переменной и становится объектом — DataItem с методами AddListener и RemoveListener. Над DataItem построены типизированные интерфейсы: binding.String с парой Get() (string, error) и Set(string) error, а рядом binding.Float, binding.Bool и binding.Int. Источник создаётся фабрикой: binding.NewString() даёт пустую строку, NewFloat(), NewBool() и NewInt() — нули; начального значения в конструкторе нет, заполнение делается через Set. Заметь контракт ошибок: и Get, и Set возвращают error. У фабричного источника она всегда nil — таково правило игры; но у источников, привязанных к внешнему миру, ошибка настоящая, и код честно получает пару (v, err), а не падает на месте. Третья опора — уведомление: после каждого Set источник рассылает слушателям сигнал DataChanged, и каждый подписчик перечитывает значение через Get. Виджеты уже умеют быть подписчиками: метод Bind есть у Entry, Label, Check, Slider, ProgressBar и Select. Разберём первый пример.

### Первый источник: Entry и Label на одном значении

```go
package main

import (
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/data/binding"
	"fyne.io/fyne/v2/widget"
)

func main() {
	myApp := app.New()
	window := myApp.NewWindow("Знакомство")

	note := binding.NewString()
	note.Set("Первая привязка")

	entry := widget.NewEntry()
	entry.Bind(note)

	mirror := widget.NewLabel("")
	mirror.Bind(note)

	window.SetContent(container.NewVBox(entry, mirror))
	window.ShowAndRun()
}
```

note — источник binding.String со значением, заданным до создания виджетов. Entry.Bind подключает поле двусторонне: печать пользователя вызывает Set, и источник уведомляет всех. Label.Bind подключает подпись в одну сторону: она перечитывает Get при каждом изменении, и текст зеркалит поле без единого обработчика OnChanged.

**Вопрос:** Пользователь печатает в Entry, привязанном через Bind к binding.String. Что происходит со значением источника?

- Ничего: Bind только показывает данные, писать нужно вручную через Set
- ✅ Entry сам вызывает Set с новым текстом — источник меняется и рассылает уведомления
- Значение меняется только после вызова Refresh() у источника
- Bind копирует значение один раз и дальше данные расходятся

Привязка Entry двусторонняя: каждая правка текста превращается в Set, источник меняется и рассылает DataChanged всем слушателям. Поэтому второй виджет на том же источнике обновляется сам — именно это и видел в примере с зеркальной подписью.

### Скаляры и виджеты: кто с чем дружит

Семейство источников покрывает все базовые типы интерфейса, и у каждого виджета есть свой естественный напарник. Текстовые — Entry и MultiLineEntry, а также Label — работают с binding.String. widget.Check и widget.Select нуждаются в булевом и строковом значении: Check привязывается к binding.Bool, галочка включена или выключена; Select хранит выбранный вариант как binding.String. Числа — царство binding.Float: Slider и ProgressBar обоих направлений привязываются к нему, поэтому громкость из ползунка и её отражение в прогресс-баре — это один источник и два Bind. Для целых счётчиков есть binding.Int, хотя на практике Float часто удобнее из-за прогресс-баров. Метод Bind принимает источник подходящего типа, компилятор не даст привязать Bool к Slider — типы защищают на этапе сборки. Противоположность — Unbind(): отвязывает виджет от источника и сбрасывает подписку; он обязателен при переиспользовании шаблонных элементов, о чём будет отдельный разговор ближе к концу урока. В англоязычной документации весь этот подход зовут fyne data binding: данные — единственный источник истины, виджеты — только отображение. Отсюда практическое правило перехода: если в коде появилась связка «обработчик меняет переменную и вручную обновляет метку», это кандидат на источник плюс Bind. Пять лет назад такие связки были нормой — в этом курсе они жили до девятого урока включительно; сегодня замени их привязкой, и класс рассинхронизации исчезнет как вид. Осталось показать связку Check-Slider-ProgressBar в сборе.

### Экран громкости: один Float на три виджета

```go
package main

import (
	"fmt"

	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/data/binding"
	"fyne.io/fyne/v2/widget"
)

func main() {
	myApp := app.New()
	window := myApp.NewWindow("Громкость")

	volume := binding.NewFloat()
	volume.Set(0.4)
	muted := binding.NewBool()

	slider := widget.NewSlider(0, 1)
	slider.Bind(volume)

	bar := widget.NewProgressBar()
	bar.Bind(volume)

	label := widget.NewLabel("")
	label.Bind(binding.FloatToString(func(f float64) string {
		return fmt.Sprintf("Громкость: %.0f%%", f*100)
	}))

	mute := widget.NewCheck("Без звука", nil)
	mute.Bind(muted)

	window.SetContent(container.NewVBox(slider, bar, label, mute))
	window.ShowAndRun()
}
```

Три виджета на одном источнике: пользователь двигает слайдер — Set уходит в volume, прогресс-бар и подпись обновляются подпиской. Подпись показывает преобразование: FloatToString оборачивает Float в String с функцией форматирования, и Label.Bind получает уже готовый текст. Check на отдельном источнике Bool — булево состояние не смешивается с числом.

**Вопрос:** Какой источник нужен widget.Check для привязки через Bind?

- binding.String — галочка хранит подпись состояния
- binding.Float — состояние как число от 0 до 1
- ✅ binding.Bool — включено или выключено
- У Check нет метода Bind, только поле Checked

Check работает с binding.Bool: снятие и установка галочки превращаются в Set(false) и Set(true), а изменение источника из кода двигает галочку. Поле Checked никуда не делось, но при привязке менять его вручную — путь к рассинхрону.

### Два подписчика на одном источнике

```go
name := binding.NewString()
name.Set("Том")

entry := widget.NewEntry()
entry.Bind(name)

label := widget.NewLabel("")
label.Bind(name)

// пользователь дописывает в поле: «Мария»
// что покажет label?
```

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

- «Том» — Label получил значение один раз при Bind и дальше не следит
- ✅ «Мария» — Entry вызвал Set, источник уведомил всех, Label перечитала Get
- Пустоту — Label не показывает значения без SetText
- «ТомМария» — оба значения склеились в источнике

Bind — это живая подписка, а не разовое копирование. Правка поля вызывает Set, источник рассылает DataChanged, каждая подписка перечитывает значение. Ровно так работает зеркальная подпись из первого примера: сколько бы виджетов ни висело на источнике, все увидят новое значение.

### Слушатель подключается — и что происходит сразу

```go
score := binding.NewFloat()
score.Set(3.5)

listener := binding.NewDataListener(func() {
        v, _ := score.Get()
        fmt.Println("счёт:", v)
})
score.AddListener(listener)

score.Set(4.0)
// сколько строк напечатается к этому моменту?
```

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

- Одна: «счёт: 4» — печать идёт только на изменения после подписки
- ✅ Две: «счёт: 3.5» при AddListener и «счёт: 4» после Set
- Ни одной: NewDataListener собирает объект, но его ещё нужно включить
- Три: по одной на Set, AddListener и создание слушателя

AddListener вызывает DataChanged немедленно: подписчик синхронизируется с текущим значением ещё до всяких изменений. Это же правило объясняет, почему привязанные виджеты показывают значение сразу после Bind — они тоже слушатели. Затем каждое Set приносит новый вызов.

### Слушатели: DataListener в чистом виде

Механизм подписки доступен и без виджетов — иногда нужно реагировать на изменение значением, а не картинкой: писать лог, пересчитывать итоги, дёргать сетевой запрос. Для этого есть интерфейс DataListener с единственным методом DataChanged и удобный конструктор binding.NewDataListener(func()) — он заворачивает твой колбэк в готового слушателя. Подключение — AddListener на источнике, отключение — RemoveListener. Помни две детали. Первая: подписка немедленно синхронизируется — предсказание выше показывало, что колбэк срабатывает уже при AddListener, поэтому не полагайся в нём на то, что «изменения ещё не было». Вторая: снимай слушателя, когда он больше не нужен. Источник держит ссылку на каждого подписчика; слушатель, привязанный к источнику с долгой жизнью, переживёт свой экран и будет выполняться впустую — классическая утечка замыканий. Практическое правило: кто создал слушателя для экрана, тот и снимает его при закрытии; в простых приложениях с одним окном этим можно пренебречь, в многоэкранных — нет. Порядок доставки не гарантирован: источник обходит подписчиков в порядке подписки, но строить логику на строгой очередности — хрупкое решение; если один слушатель должен увидеть результат другого, объединяй их в один колбэк. И последнее: колбэк выполняется в той горутине, где случился Set. Об этом — жёлтая рамка ниже и двадцатый урок.

### Слушатель как живой статус

```go
package main

import (
	"fmt"

	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/data/binding"
	"fyne.io/fyne/v2/widget"
)

func main() {
	myApp := app.New()
	window := myApp.NewWindow("Слушатель")

	temperature := binding.NewFloat()
	temperature.Set(20)

	status := widget.NewLabel("")

	listener := binding.NewDataListener(func() {
		v, err := temperature.Get()
		if err != nil {
			status.SetText("ошибка чтения")
			return
		}
		status.SetText(fmt.Sprintf("Температура: %.1f°", v))
	})
	temperature.AddListener(listener)

	up := widget.NewButton("+0.5", func() {
		v, _ := temperature.Get()
		temperature.Set(v + 0.5)
	})

	window.SetContent(container.NewVBox(status, up))
	window.ShowAndRun()
}
```

Слушатель читает значение через Get с честной проверкой err и пишет текст в метку — без Bind, чистой подпиской. Заметь: метка заполнится ещё до первого клика, потому что AddListener вызывает DataChanged сразу. Кнопка читает текущее значение и пишет обратно — источник остаётся единственным местом хранения.

**Вопрос:** Почему слушатель, ставший ненужным, стоит снимать через RemoveListener?

- Без снятия источник перестаёт работать и Set ломается
- ✅ Источник держит ссылку на слушателя — колбэк живёт и вызывается, пока жив источник
- RemoveListener ускоряет Get за счёт чистки кеша значений
- Снятие обязательно: DataChanged можно подключать только один раз

Источник хранит подписчиков, пока сам жив. Слушатель с коротким жизненным циклом на долгоживущем источнике — утечка: замыкание держит свои переменные и выполняется на каждый Set. Снимай подписку там, где закончился сценарий, для которого она заводилась.

### Внешние значения и преобразования

Не всегда значение рождается внутри источника: иногда оно уже живёт в поле твоей структуры, и хочется привязать интерфейс к нему, не переписывая модель. Для этого есть семейство Bind: binding.BindString(&myField) возвращает ExternalString — источник, у которого Set пишет и в хранилище, и в твою переменную, а метод Reload() перечитывает внешнее значение и рассылает уведомления, если данные поменялись извне. Аналогично устроены BindFloat, BindBool и BindInt с указателями на float64, bool и int. Правило одно: переменная обязана жить достаточно долго — указатель на локальную переменную функции приведёт к висячей привязке. Второй инструмент — преобразования. binding.StringToString(func(s string) string) даёт источник-надстройку: он слушает исходный и выдаёт преобразованное значение; пример с громкостью показывал FloatToString для процентов. Если преобразование должно работать в обе стороны — из отображения в данные при правке поля — бери FloatToStringWithReverse и братьев: конструктор принимает две функции, прямую и обратную, и привязанный Entry пишет в данные уже разобранное значение. Классика — температура в обе стороны или форматирование дат. Третий инструмент — коллекции: binding.NewUntypedList() создаёт источник списка, Append и Remove меняют состав, а widget.List.Bind подключает виртуализированный список из десятого урока к данным — элементы списка получают свои мини-источники, и RefreshItem становится не нужен: изменение элемента обновляет только его строку. Для таблиц одиннадцатого урока есть табличные привязки — механика та же, детали — в документации пакета.

### Каталог API привязки

| API | Что делает | Когда звать |
| --- | --- | --- |
| binding.NewString() / NewFloat() / NewBool() / NewInt() | создать пустой источник-скаляр | значение состояния рождается в приложении |
| item.Set(v) и v, err := item.Get() | записать и прочитать значение; оба возвращают error | в обработчиках и слушателях |
| widget.Bind(item) | привязать виджет к источнику; Entry — двусторонне | вместо ручных SetText и OnChanged |
| widget.Unbind() | отвязать виджет и снять подписку | переиспользование шаблона элемента |
| binding.BindString(&ext) | внешнее значение: Set пишет в переменную, Reload перечитывает | данные живут в своей структуре |
| binding.NewDataListener(func()) | слушатель из обычного колбэка | реакция на изменение без виджета |
| AddListener / RemoveListener | подписка и её снятие; подписка срабатывает сразу | не забывай снимать лишних |
| binding.StringToString(f) | надстройка-преобразование для отображения | формат вывода без правки данных |
| binding.FloatToStringWithReverse(f, g) | преобразование в обе стороны | поле правит данные через конвертер |
| binding.NewUntypedList() | источник динамического списка | List.Bind вместо ручного RefreshItem |
| binding.BindPreferenceString(key, prefs) | источник прямо из хранилища настроек | мост к девятнадцатому уроку |

### Собери привязку со слушателем

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

```go
name := binding.___()
name.Set("Гость")

entry := widget.NewEntry()
entry.___(name)

listener := binding.___(func() {
        v, ___ := name.Get()
        fmt.Println("имя:", v)
})
name.___(listener)
```

Подсказка: Источник строки — binding.NewString(), виджет подключается методом Bind, слушатель собирает NewDataListener, Get возвращает пару (v, err), а подключает его AddListener.

Правильные ответы в пропусках: `NewString()`, `Bind`, `NewDataListener`, `err`, `AddListener`

### Путь значения от Set до экрана

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

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

1. `код вызывает Set у источника — значение изменилось в одном месте`
2. `источник рассылает DataChanged всем слушателям в порядке подписки`
3. `привязанный Entry перечитывает Get и перерисовывает текст поля`
4. `Label через конвертер получает готовую строку и обновляет подпись`
5. `ручной слушатель NewDataListener печатает значение в лог`
6. `интерфейс обновлён без единого SetText — истина осталась в источнике`

> **Честно о запуске кода.** Привязкам нужен настоящий экран — в браузерной песочнице курса GUI не исполняется. Собери примеры по второму уроку и обязательно проверь два сценария: печать в привязанное поле меняет второй виджет, а слушатель печатает стартовое значение ещё до первого изменения.

> **Set из чужой горутины.** Колбэк слушателя выполняется в той горутине, где случился Set. Если значение пишет фоновая задача, то и все виджеты-подписчики обновятся из неё — а трогать интерфейс вне главного потока канон запрещает с шестого урока. Мост к данным оставь, но запись обёртывай fyne.Do — полный разбор потоков и правило «одно окно, один поток» ждут в двадцатом уроке.

### Архитектура: одна истина

Главный подарок привязки — не экономия на SetText, а дисциплина состояния. Одна истина: значение живёт в источнике, и виджет — лишь окно в него; два места хранения одного значения гарантированно разъедутся, вопрос только когда. Практика: собирай источники в структуру состояния приложения — App struct с полями-привязками создаётся при старте и передаётся экранам; обработчики меняют источники, экраны подписаны, и новая фича не требует ревизии всех старых обработчиков. Не дублируй: если текст поля уже в binding.String, не храни копию в переменной «на всякий случай» — читай Get в момент нужды. Следи за переиспользованием: в шаблоне элемента List из десятого урока виджет один, а значений много — перед привязкой нового значения зови Unbind, иначе подписка от прошлой строки продолжит писать в свежие данные, и список начнёт путать строки под прокруткой. Это самый частый баг новичка в коллекциях с привязкой. Разумно делить: не превращай всё подряд в источники — локальная переменная внутри обработчика не заслуживает DataItem; привязка окупается там, где значение видно более чем в одном месте или меняется более чем из одного места. Помни про ошибки: Get возвращает пару, и источники, привязанные к внешнему миру, вправе ответить не-nil — обрабатывай хотя бы до уровня «показать ShowError из тринадцатого урока». Наконец, мости уровни: доменная модель пусть остаётся обычными структурами, а источники — тонким слоем между моделью и экраном; BindString-семейство как раз для этого.

### Что дальше: валидация ввода

Теперь данные текут: поле пишет в источник, подписчики обновляются сами, слушатели логируют каждое изменение. Но поток бесполезен, если в нём мусор — «abc» в поле возраста доедет до источника так же честно, как «42». Опора урока — [девятый](/courses/fyne/lessons/09-text-entry/) с Entry и его событиями, [десятый](/courses/fyne/lessons/10-spiski/) с шаблонами списка и Unbind, [второй](/courses/fyne/lessons/02-pervoe-okno/) с каркасом запуска. Следующая тема — валидация ввода: встроенные валидаторы Entry, свои правила func(string) error, красная рамка и блокировка Submit, пока данные не стали съедобными; привязка и валидация работают в паре — проверенное значение заслуживает права попасть в источник. Домашнее задание соберёт панель счётчика на одном Float с тремя виджетами и слушателем-логгером. Полный маршрут — [на странице курса Fyne](/courses/fyne/).

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

- **binding.String и братья** — Типизированные источники данных: Get() (T, error) и Set(T) error. Создаются NewString/NewFloat/NewBool/NewInt; после Set рассылают DataChanged подписчикам.
- **DataItem** — Общий интерфейс источника: AddListener и RemoveListener. Типизированные String/Float/Bool/Int встраивают его, поэтому любой источник можно слушать одинаково.
- **widget.Bind и Unbind** — Bind подключает виджет к источнику: Entry двусторонне, Label и ProgressBar только на показ. Unbind снимает подписку — обязателен при переиспользовании шаблонов списка.
- **binding.NewDataListener** — Слушатель из колбэка func(). AddListener вызывает DataChanged немедленно — подписчик сразу синхронизируется с текущим значением.
- **BindString и ExternalString** — Привязка к внешней переменной по указателю: Set пишет в неё, Reload перечитывает и рассылает уведомления. Аналоги для Float, Bool и Int.
- **Преобразования** — StringToString и FloatToString надстраиваются над источником для отображения; WithReverse-версии принимают прямую и обратную функции и работают в обе стороны.

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

- Пакет fyne.io/fyne/v2/data/binding делает значение объектом-источником: DataItem с подпиской, типизированные String, Float, Bool, Int с парой Get/Set и честным error.
- widget.Bind подключает виджет к источнику: Entry и Slider пишут в данные сами, Label и ProgressBar только показывают; Unbind снимает привязку.
- После каждого Set источник рассылает DataChanged: сколько бы подписчиков ни висело, все перечитают значение — ручной SetText больше не нужен.
- AddListener срабатывает немедленно: слушатель и привязанный виджет видят текущее значение сразу; ненужных слушателей снимай RemoveListener.
- BindString подключает внешнюю переменную, StringToString и WithReverse-семейство преобразуют значения для отображения и обратно.
- NewUntypedList с List.Bind переводит коллекции на привязку — изменение элемента обновляет только его строку без RefreshItem.
- Правило одной истины: данные в источниках, виджеты — отображение; Set из чужой горутины — только через fyne.Do, детали в двадцатом уроке.

## Домашнее задание

Собери панель счётчика объёма на одном источнике: binding.NewFloat, Slider и ProgressBar привязаны к нему, Label показывает проценты через FloatToString, кнопка «Сброс» пишет Set(0). Добавь ручного слушателя через NewDataListener, который печатает каждое значение в терминал, — и проверь предсказание урока: строка со стартовым значением должна появиться ещё до первого клика. Сделай второй экран на том же Float: переключение вкладкой из двенадцатого урока, и убедись, что оба экрана показывают одно значение без единого обработчика синхронизации. Смоделируй список: NewUntypedList, кнопка добавления Append, List.Bind с шаблоном из десятого урока — и проверь, что удаление через Remove обновляет только нужную строку. Ловушка для самопроверки: попробуй переиспользовать один Entry в шаблоне без Unbind и добейся путаницы строк; найди минимальный сценарий, где она проявляется. Если захочешь писать в источник из горутины — остановись и перечитай жёлтую рамку: правильный инструмент появится в двадцатом уроке.

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

### Чем binding лучше привычных OnChanged и SetText?

OnChanged ловит только правки пользователя, а источнику без разницы, кто вызвал Set: код, слушатель или другой виджет. Привязка убирает целый класс рассинхронов: не нужно помнить, какие метки обновлять после каждого изменения, — подписчики перечитывают значение сами.

### Можно ли вызывать Set и Get из горутин?

Сам источник потокобезопасен, но колбэки слушателей выполняются в горутине вызова, и обновление виджетов из чужого потока запрещено каноном GUI. Пиши в источники через fyne.Do из двадцатого урока — тогда уведомления разойдутся по правилам.

### Как привязать целую структуру, а не отдельное поле?

Готовых структурных источников нет: держи модель обычными структурами и подключай поля по одному через BindString-семейство, либо сериализуй структуру в строку и привязывай её. Для коллекций — NewUntypedList и табличные привязки из документации пакета.
