Лучше всего мы учимся на практике, поэтому сегодня создадим окно фильтра, которое будет появляться рядом с кнопкой при нажатии.
Конечно, можно использовать готовые решения — например, PopupMenuButton с кастомным PopupMenuEntry или диалог. Но если вам нужно больше контроля над позиционированием и поведением окна, лучше реализовать его вручную.
Для этого используем три компонента:
1️⃣ Overlay — для отрисовки поверх текущего интерфейса
2️⃣ CompositedTransformTarget — якорь для привязки
3️⃣ CompositedTransformFollower — виджет, который будет следовать за якорем
Немного теории
Overlay — специальный стек, поверх которого можно отрисовывать виджеты вне основного дерева. Элементы добавляются в Overlay через OverlayEntry.
Чтобы связать два виджета в пространстве, используем:
✔️ CompositedTransformTarget — задает якорь
✔️ CompositedTransformFollower — следует за якорем, даже если его позиция меняется
CompositedTransformTarget(
link: _layerLink,
child: ..., // ваш виджет
)
CompositedTransformFollower(
link: _layerLink,
offset: Offset.zero,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
child: ..., // всплывающее окно
)
Где:
🔵 link — объект LayerLink, соединяющий Target и Follower
🔵 showWhenUnlinked — показывает Follower, если связь потеряна
🔵 offset — смещение относительно якоря
🔵 targetAnchor и followerAnchor — точки привязки
Реализация
Для начала создадим StatefulWidget для управления окном. В State определим:
➡️ LayerLink — для связи кнопки и окна
➡️ OverlayEntry — элемент, который добавим в Overlay
➡️ OverlayState — текущий Overlay из контекста
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
OverlayState? _overlayState;
В методе build оборачиваем кнопку в CompositedTransformTarget:
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: LocalizationFilledIconButton(
icon: widget.icon ?? const FilterIcon(),
onPressed: _toggleDialog,
),
);
}
Добавим логику открытия/закрытия:
void _toggleDialog() {
// Если оверлей ещё не создан – создаем его
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry(); // Создаём OverlayEntry
_overlayState = Overlay.of(context); // Получаем текущее состояние Overlay
_overlayState?.insert(_overlayEntry!); // Вставляем OverlayEntry в Overlay
} else {
// Если оверлей уже открыт – удаляем его
_overlayEntry?.remove(); // Удаляем OverlayEntry из Overlay
_overlayEntry = null; // Обнуляем ссылку на OverlayEntry
}
}Создаем само окно через CompositedTransformFollower:
OverlayEntry _createOverlayEntry() {
return OverlayEntry(
builder: (context) {
return GestureDetector(
behavior: HitTestBehavior.translucent, // Позволяет "ловить" тап вне окна
onTap: _toggleDialog, // Закрываем окно при тапе вне его области
child: Stack(
children: [
Positioned(
width: MediaQuery.sizeOf(context).width / 3, // Ширина окна – треть экрана
child: CompositedTransformFollower(
link: _layerLink, // Связь с CompositedTransformTarget
showWhenUnlinked: false, // Скрываем окно, если связь потеряна
targetAnchor: Alignment.bottomLeft, // Привязываемся к нижнему левому углу кнопки
child: FilterDialog(toggleDialog: _toggleDialog), // Само окно фильтра
),
),
],
),
);
},
);
}Не забываем удалить OverlayEntry при закрытии:
@override
void dispose() {
_overlayEntry?.remove();
super.dispose();
}
Готово!
Теперь у нас есть кастомное окно с фильтрами, которое:
✔️ Открывается по нажатию на кнопку
✔️ Позиционируется рядом с ней
✔️ Закрывается при клике вне области
❤️ — если было полезно
