Dropdown menu, gợi ý autocomplete, tooltip, popover, context menu — hầu như app nào rồi cũng cần một mảnh UI nổi trên phần còn lại của layout và bám chặt vào một widget khác. Phần khó không bao giờ là vẽ cái panel nổi đó. Phần khó là giữ cho nó ghim đúng vào anchor khi anchor scroll, khi màn hình xoay, hay khi layout thay đổi.
Flutter có sẵn một cặp widget sinh ra để làm đúng việc này: CompositedTransformTarget và CompositedTransformFollower, được kết nối với nhau qua một LayerLink. Chúng cho phép bạn đánh dấu một widget làm anchor và để một widget khác tự động bám theo vị trí chính xác trên màn hình — dù follower nằm ở một nhánh hoàn toàn khác của widget tree (thường là trong Overlay).
Bài này sẽ đi qua cách chúng hoạt động, lý do chúng tốt hơn việc tự tính toán vị trí, và cách xây dựng một anchored popover có thể tái sử dụng thực sự.

Vấn đề khi tự đo vị trí
Cách tiếp cận ngây thơ với floating UI trông như thế này: lấy RenderBox của anchor, gọi localToGlobal, tính toán vị trí panel cần đặt, rồi nhét vào OverlayEntry với một Positioned widget.
final box = context.findRenderObject() as RenderBox;
final offset = box.localToGlobal(Offset.zero);
// place the panel at offset.dx, offset.dy + box.size.height ...
Cách này chỉ đúng đúng một frame. Ngay khi anchor di chuyển — một parent scroll, bàn phím xuất hiện, thiết bị xoay — offset đã cache trở nên lỗi thời và panel trôi khỏi anchor. Bạn sẽ phải lắng nghe scroll notification, layout callback, và orientation change chỉ để tính lại một con số mà Flutter đã biết rồi.
CompositedTransformFollower né tránh tất cả những điều đó. Thay vì copy vị trí một lần, nó bám theo layer của anchor mỗi frame ở giai đoạn compositing. Khi anchor di chuyển, follower tự động di chuyển theo.
Ba thành phần
Chỉ có ba thứ cần nắm.
LayerLink là một handle nhẹ, có thể chia sẻ, kết nối target với các follower của nó. Bạn tạo một cái, giữ lại (thường là một field trong State), và truyền cho cả hai widget. Nó không mang bất kỳ UI nào.
final LayerLink _link = LayerLink();
CompositedTransformTarget bọc widget anchor. Trong quá trình compositing, nó đăng ký một leader layer với link, ghi lại transform và kích thước hiện tại của anchor.
CompositedTransformTarget(
link: _link,
child: const Icon(Icons.more_vert),
)
CompositedTransformFollower bọc widget nổi. Nó tạo ra một follower layer copy transform của leader mỗi frame, vì vậy child của nó được render tương đối với target dù target đang ở đâu trên màn hình.
CompositedTransformFollower(
link: _link,
child: myFloatingPanel,
)
Một LayerLink kết nối với một target tại một thời điểm, nhưng nhiều follower có thể theo dõi cùng một target — tiện lợi nếu, chẳng hạn, một vòng highlight và một tooltip đều cần trỏ vào cùng một button.
Căn chỉnh follower với target
Mặc định, góc trên-trái của follower nằm trên góc trên-trái của target, vậy là panel che mất anchor. Ba property kiểm soát vị trí thực sự của nội dung nổi.
targetAnchor là điểm trên target* bạn muốn gắn vào. followerAnchor là điểm *trên follower được đặt tại đó. Cả hai nhận Alignment. Để treo một menu ngay bên dưới một button, gắn cạnh trên của follower vào cạnh dưới của target:
CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.bottomLeft, // bottom-left of the button
followerAnchor: Alignment.topLeft, // top-left of the menu
child: menu,
)
offset thêm một khoảng dịch chuyển cuối cùng theo logical pixel sau khi hai anchor đã thẳng hàng — hoàn hảo để tạo khoảng cách nhỏ giữa button và menu:
offset: const Offset(0, 8), // 8px of breathing room below
Tư duy theo hai anchor này mạnh mẽ hơn nhiều so với việc tính tọa độ. "Top-center của panel gắn vào bottom-center của chip" đọc rõ ràng và sống sót qua mọi thay đổi layout.
Một vài cặp phổ biến:
targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLefttargetAnchor: Alignment.topCenter, followerAnchor: Alignment.bottomCentertargetAnchor: Alignment.topRight, followerAnchor: Alignment.topLeftshowWhenUnlinked
CompositedTransformFollower có thêm một property đáng biết: showWhenUnlinked. Khi follower không tìm thấy leader của mình — vì target chưa được layout hoặc đã bị xóa khỏi tree — flag này quyết định follower có paint tại vị trí không được anchor của chính nó hay ẩn đi hoàn toàn.
Với anchored popover, hầu như bạn luôn muốn showWhenUnlinked: false. Nếu không, trong một hoặc hai frame, panel có thể flash ở vị trí sai (thường là góc trên-trái màn hình) trước khi link được thiết lập.
Ghép lại: một anchored popover có thể tái sử dụng
Đây là một widget hoàn chỉnh, tự chứa. Nhấn vào button mở một menu treo bên dưới nó, nhấn ra ngoài hoặc chọn một item đóng nó lại, và menu vẫn bám vào button kể cả khi trang scroll.
import 'package:flutter/material.dart';
class AnchoredMenuButton extends StatefulWidget {
const AnchoredMenuButton({super.key});
@override
State<AnchoredMenuButton> createState() => _AnchoredMenuButtonState();
}
class _AnchoredMenuButtonState extends State<AnchoredMenuButton> {
final LayerLink _link = LayerLink();
OverlayEntry? _entry;
bool _isOpen = false;
void _toggle() => _isOpen ? _close() : _open();
void _open() {
_entry = _buildEntry();
Overlay.of(context).insert(_entry!);
setState(() => _isOpen = true);
}
void _close() {
_entry?.remove();
_entry = null;
if (mounted) setState(() => _isOpen = false);
}
OverlayEntry _buildEntry() {
return OverlayEntry(
builder: (context) {
return Stack(
children: [
// Full-screen barrier: a tap anywhere outside the menu closes it.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _close,
),
),
CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 8),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 220,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_item(Icons.person_outline, 'Profile'),
_item(Icons.settings_outlined, 'Settings'),
_item(Icons.logout, 'Sign out'),
],
),
),
),
),
],
);
},
);
}
Widget _item(IconData icon, String label) {
return ListTile(
leading: Icon(icon),
title: Text(label),
onTap: _close,
);
}
@override
void dispose() {
_entry?.remove(); // never leak an overlay entry
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _link,
child: ElevatedButton.icon(
onPressed: _toggle,
icon: const Icon(Icons.menu),
label: Text(_isOpen ? 'Close' : 'Menu'),
),
);
}
}
Một vài điểm làm cho cái này hoạt động gọn gàng:
Follower nằm bên trong một OverlayEntry, không phải cạnh button trong tree. Đó là toàn bộ điểm mấu chốt — nội dung overlay paint trên tất cả mọi thứ và không bị clip bởi parent, trong khi LayerLink giữ nó gắn chặt về mặt thị giác với button xuyên qua ranh giới của tree.
Barrier GestureDetector với HitTestBehavior.translucent bắt tap ở vùng trống xung quanh menu để popover đóng lại tự nhiên, đúng như người dùng mong đợi.
dispose xóa entry. Một OverlayEntry không thuộc vòng đời widget thông thường, vì vậy nếu bạn quên điều này và widget bị hủy khi đang mở, bạn sẽ rò rỉ entry và có thể gặp lỗi "setState after dispose". Kiểm tra mounted trong _close bảo vệ khỏi cùng mối nguy đó.
Những điều hay gây nhầm lẫn
Overlay.of(context) cần có một overlay phía trên nó. MaterialApp và CupertinoApp tự thêm một cái, nên điều này hiếm khi là vấn đề — nhưng nếu bạn xây dựng app root tùy chỉnh không có overlay, lệnh lookup sẽ thất bại.
Child của follower nên tự định kích thước. CompositedTransformFollower nhận kích thước của child nó, vậy hãy cho nội dung nổi một width cụ thể (hoặc bọc nó để có bounded constraints). Một Column hoặc Row không bị ràng buộc bên trong nó sẽ hoạt động sai.
Nó anchor vị trí, không phải visibility hay animation. Link chỉ theo dõi transform của target. Logic mở và đóng, transition fade hay scale, và dismiss-on-tap đều do bạn thêm vào — bọc child của panel trong một AnimatedOpacity hoặc transition tùy ý.
Nó sẽ không giữ panel luôn nằm trong màn hình. Nếu anchor nằm gần cạnh dưới, một menu mở xuống có thể tràn ra ngoài màn hình. Các widget này không tự lật. Để làm điều đó, bạn cần đo không gian có sẵn (qua RenderBox của target hoặc LayoutBuilder) và chọn anchor phù hợp, hoặc dùng package như flutter_portal — thứ xây thêm sự tiện lợi trên nền cùng cơ chế này.
Khi nào dùng built-in thay thế
Nếu bạn chỉ cần một dropdown chuẩn, DropdownButton, MenuAnchor, PopupMenuButton, hay Autocomplete đã gói sẵn pattern này và xử lý việc lật cạnh màn hình lẫn điều hướng bàn phím cho bạn. Nội bộ, một số trong chúng dùng CompositedTransformTarget/Follower — bạn đang dùng cùng engine, chỉ ở cấp độ cao hơn.
Xuống dùng raw widget khi bạn cần toàn quyền kiểm soát nội dung, giao diện và hành vi của panel — popover tùy chỉnh, coach mark, floating editor, kết quả tìm kiếm có anchor — bất cứ thứ gì các component có sẵn không đáp ứng được.
Tổng kết
CompositedTransformTarget và CompositedTransformFollower biến "giữ panel này dính vào button kia" từ một công việc đo lường từng frame thành một quan hệ declarative. Bạn đặt tên anchor, đặt tên follower, mô tả các cạnh của chúng thẳng hàng như thế nào, và compositor của Flutter giữ chúng căn chỉnh miễn phí. Một khi pattern này ngấm vào, nó trở thành nền tảng tự nhiên cho mọi floating element trong app của bạn.
Happy coding : )
Loading…