Responsive Navigation và bố cục ứng dụng trong Flutter
Responsive Navigation và Responsive Layout là hai thành phần quan trọng trong quá trình xây dựng ứng dụng Flutter có khả năng hoạt động tốt trên nhiều kích thước màn hình. Một ứng dụng hiện đại không chỉ cần hiển thị giao diện đẹp trên điện thoại mà còn phải thích nghi với tablet, laptop và desktop mà không làm mất đi tính nhất quán trong trải nghiệm sử dụng.
Trong Flutter, việc xây dựng giao diện thích nghi thường kết hợp các Widget như LayoutBuilder, MediaQuery, NavigationBar, NavigationRail, NavigationDrawer, Row, Column, Expanded, Flexible và GridView. Các Widget này giúp ứng dụng thay đổi cách tổ chức nội dung dựa trên không gian thực tế, thay vì chỉ phóng to hoặc thu nhỏ giao diện.
Nội dung dưới đây tập trung vào bốn kỹ thuật quan trọng: Navigation Drawer và Navigation Rail, Master-Detail Layout, Responsive Dashboard Layout và Responsive Data Table.
1. Navigation Drawer và Navigation Rail
1.1. Tổng quan về Responsive Navigation
Responsive Navigation là phương pháp thiết kế hệ thống điều hướng có khả năng thay đổi hình thức hiển thị tùy thuộc vào kích thước màn hình, số lượng chức năng và cách người dùng tương tác với ứng dụng.
Trong Flutter, điều hướng không chỉ giới hạn ở việc chuyển từ màn hình này sang màn hình khác. Một hệ thống điều hướng tốt còn phải giúp người dùng nhận biết vị trí hiện tại, truy cập nhanh các chức năng thường dùng và di chuyển giữa các khu vực chính mà không cần thực hiện quá nhiều thao tác.
Ví dụ, một ứng dụng quản lý bán hàng có thể có các chức năng:
-
Tổng quan doanh thu.
-
Quản lý sản phẩm.
-
Quản lý đơn hàng.
-
Quản lý khách hàng.
-
Báo cáo và thống kê.
-
Cài đặt hệ thống.
Trên điện thoại, việc đặt tất cả chức năng này thành một thanh điều hướng ngang có thể gây chật chội. Trên màn hình desktop, sử dụng thanh điều hướng dưới cùng lại lãng phí không gian theo chiều ngang và không phù hợp với bố cục quản trị.
Giải pháp là sử dụng các kiểu điều hướng khác nhau theo từng nhóm kích thước màn hình.
Điện thoại — NavigationBar
Hiển thị các chức năng chính ở cạnh dưới màn hình. Người dùng có thể chuyển đổi giữa những khu vực thường dùng bằng thao tác chạm.
Tablet — NavigationRail
Đặt các mục điều hướng theo chiều dọc ở cạnh trái, dành nhiều không gian hơn cho nội dung chính và hỗ trợ nhãn hoặc biểu tượng.
Desktop — NavigationDrawer
Hiển thị danh sách chức năng theo chiều dọc, có thể mở rộng để trình bày đầy đủ tên mục, nhóm chức năng và các lựa chọn phụ.
1.2. NavigationBar
NavigationBar là Widget thuộc Material Design 3, thường được sử dụng để điều hướng giữa các khu vực cấp cao của ứng dụng trên thiết bị có màn hình nhỏ.
NavigationBar thường xuất hiện ở cạnh dưới màn hình và chứa một số mục điều hướng. Mỗi mục có thể bao gồm biểu tượng, nhãn và trạng thái được chọn.
Các thuộc tính quan trọng:
| Thuộc tính | Chức năng |
|---|---|
destinations |
Danh sách các mục điều hướng |
selectedIndex |
Vị trí mục đang được chọn |
onDestinationSelected |
Callback khi người dùng chọn mục khác |
labelBehavior |
Điều chỉnh cách hiển thị nhãn |
backgroundColor |
Màu nền thanh điều hướng |
Ví dụ triển khai:
import 'package:flutter/material.dart';
class MobileNavigationPage extends StatefulWidget {
const MobileNavigationPage({super.key});
@override
State<MobileNavigationPage> createState() =>
_MobileNavigationPageState();
}
class _MobileNavigationPageState
extends State<MobileNavigationPage> {
int selectedIndex = 0;
final List<Widget> pages = const [
Center(child: Text('Tổng quan')),
Center(child: Text('Sản phẩm')),
Center(child: Text('Đơn hàng')),
Center(child: Text('Tài khoản')),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: pages[selectedIndex],
),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
setState(() {
selectedIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tổng quan',
),
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: 'Sản phẩm',
),
NavigationDestination(
icon: Icon(Icons.shopping_bag_outlined),
selectedIcon: Icon(Icons.shopping_bag),
label: 'Đơn hàng',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Tài khoản',
),
],
),
);
}
}
Trong ví dụ trên, selectedIndex lưu vị trí của mục điều hướng hiện tại. Khi người dùng chọn một mục khác, callback onDestinationSelected cập nhật trạng thái bằng setState(). Flutter xây dựng lại giao diện để hiển thị nội dung tương ứng.
Cần lưu ý rằng ví dụ này thay đổi nội dung trực tiếp trong cùng một màn hình. Nếu ứng dụng cần giữ lịch sử điều hướng, trạng thái của từng tab hoặc hỗ trợ deep link, nên kết hợp với hệ thống routing phù hợp thay vì chỉ sử dụng một danh sách Widget.
Nguyên tắc sử dụng NavigationBar
-
Chỉ đặt những khu vực chức năng cấp cao và thường xuyên sử dụng.
-
Giữ số lượng mục ở mức vừa phải để tránh giao diện chật chội.
-
Sử dụng biểu tượng dễ nhận biết và nhãn ngắn gọn.
-
Làm rõ trạng thái được chọn bằng màu sắc, biểu tượng hoặc kiểu hiển thị.
-
Tránh đưa quá nhiều thao tác phụ vào thanh điều hướng chính.
-
Kiểm tra khả năng hiển thị khi bàn phím ảo xuất hiện và khi thiết bị ở chế độ ngang.
NavigationBar phù hợp với ứng dụng mua sắm, mạng xã hội, ứng dụng cá nhân và các sản phẩm có khoảng ba đến năm khu vực chính cần truy cập thường xuyên.
1.3. NavigationRail
NavigationRail là Widget điều hướng theo chiều dọc, phù hợp với tablet, màn hình ngang và các ứng dụng có không gian hiển thị rộng hơn điện thoại.
Thay vì chiếm diện tích ở cạnh dưới, NavigationRail thường được đặt bên trái nội dung chính trong một Row.
Cấu trúc phổ biến:
Scaffold
└── Row
├── NavigationRail
│ ├── Destination 1
│ ├── Destination 2
│ └── Destination 3
└── Expanded
└── Main Content
Nhờ sử dụng Expanded, nội dung chính sẽ chiếm phần không gian còn lại sau khi dành một vùng cố định cho NavigationRail.
Ví dụ:
import 'package:flutter/material.dart';
class TabletNavigationPage extends StatefulWidget {
const TabletNavigationPage({super.key});
@override
State<TabletNavigationPage> createState() =>
_TabletNavigationPageState();
}
class _TabletNavigationPageState
extends State<TabletNavigationPage> {
int selectedIndex = 0;
final List<Widget> pages = const [
Center(child: Text('Tổng quan')),
Center(child: Text('Sản phẩm')),
Center(child: Text('Đơn hàng')),
Center(child: Text('Báo cáo')),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: (index) {
setState(() {
selectedIndex = index;
});
},
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text('Tổng quan'),
),
NavigationRailDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: Text('Sản phẩm'),
),
NavigationRailDestination(
icon: Icon(Icons.shopping_bag_outlined),
selectedIcon: Icon(Icons.shopping_bag),
label: Text('Đơn hàng'),
),
NavigationRailDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: Text('Báo cáo'),
),
],
),
const VerticalDivider(width: 1),
Expanded(
child: pages[selectedIndex],
),
],
),
),
);
}
}
Các thuộc tính quan trọng của NavigationRail
| Thuộc tính | Ý nghĩa |
|---|---|
selectedIndex |
Mục điều hướng đang được chọn |
destinations |
Danh sách mục điều hướng |
onDestinationSelected |
Xử lý thao tác chọn mục |
labelType |
Quy định cách hiển thị nhãn |
extended |
Mở rộng vùng điều hướng để hiển thị nhãn bên cạnh biểu tượng |
leading |
Widget đặt ở phía đầu thanh điều hướng |
trailing |
Widget đặt ở phía cuối thanh điều hướng |
minWidth |
Chiều rộng tối thiểu của rail ở chế độ không mở rộng |
backgroundColor |
Màu nền |
labelType có thể được sử dụng để điều chỉnh cách hiển thị nhãn của các mục. Trong những giao diện có nhiều chức năng, việc lựa chọn giữa chỉ hiển thị nhãn của mục được chọn và hiển thị toàn bộ nhãn ảnh hưởng đáng kể đến khả năng nhận biết.
NavigationRail đặc biệt hữu ích với ứng dụng quản lý công việc, ứng dụng học tập, phần mềm bán hàng và công cụ nội bộ dành cho nhân viên.
1.4. NavigationDrawer
NavigationDrawer là thành phần điều hướng dạng danh sách, thường được đặt ở cạnh trái màn hình. Thành phần này phù hợp khi ứng dụng có nhiều khu vực chức năng hoặc cần phân nhóm các mục điều hướng.
NavigationDrawer có thể được sử dụng theo hai hình thức:
-
Drawer tạm thời: mở ra khi người dùng nhấn nút menu và có thể đóng lại sau khi chọn mục hoặc nhấn ra ngoài.
-
Drawer cố định: được bố trí trực tiếp trong giao diện khi màn hình có đủ không gian.
Trong Flutter, NavigationDrawer là Widget hiển thị nội dung drawer. Để tạo drawer tạm thời theo cơ chế Material, có thể đặt nó trong thuộc tính drawer của Scaffold.
Ví dụ:
import 'package:flutter/material.dart';
class DrawerNavigationPage extends StatefulWidget {
const DrawerNavigationPage({super.key});
@override
State<DrawerNavigationPage> createState() =>
_DrawerNavigationPageState();
}
class _DrawerNavigationPageState
extends State<DrawerNavigationPage> {
int selectedIndex = 0;
final List<String> titles = const [
'Tổng quan',
'Sản phẩm',
'Đơn hàng',
'Khách hàng',
'Cài đặt',
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(titles[selectedIndex]),
),
drawer: NavigationDrawer(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
setState(() {
selectedIndex = index;
});
Navigator.pop(context);
},
children: const [
Padding(
padding: EdgeInsets.fromLTRB(28, 24, 16, 12),
child: Text(
'QUẢN LÝ CỬA HÀNG',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
NavigationDrawerDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text('Tổng quan'),
),
NavigationDrawerDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: Text('Sản phẩm'),
),
NavigationDrawerDestination(
icon: Icon(Icons.shopping_bag_outlined),
selectedIcon: Icon(Icons.shopping_bag),
label: Text('Đơn hàng'),
),
NavigationDrawerDestination(
icon: Icon(Icons.people_outline),
selectedIcon: Icon(Icons.people),
label: Text('Khách hàng'),
),
NavigationDrawerDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Cài đặt'),
),
],
),
body: Center(
child: Text('Nội dung: ${titles[selectedIndex]}'),
),
);
}
}
Trong ví dụ này, Navigator.pop(context) đóng drawer sau khi người dùng chọn một mục. Đây là thao tác đóng giao diện drawer đang được mở, không phải xóa một mục chức năng khỏi ứng dụng.
Với ứng dụng có nhiều nhóm chức năng, có thể bổ sung tiêu đề, khoảng cách, thông tin tài khoản và các mục phân nhóm. Tuy nhiên, cần tránh biến drawer thành một danh sách quá dài khiến người dùng khó tìm kiếm chức năng.
1.5. Kết hợp nhiều kiểu điều hướng theo kích thước màn hình
Một ứng dụng Flutter có thể lựa chọn NavigationBar, NavigationRail hoặc NavigationDrawer dựa trên chiều rộng khả dụng.
Không nên phụ thuộc hoàn toàn vào tên thiết bị như điện thoại hay tablet. Một cửa sổ ứng dụng trên desktop có thể bị thu hẹp, trong khi một chiếc điện thoại ở chế độ ngang có thể có chiều rộng lớn hơn thông thường.
LayoutBuilder cho phép đọc constraints của vùng giao diện hiện tại và lựa chọn cách bố trí phù hợp.
import 'package:flutter/material.dart';
class ResponsiveNavigationPage extends StatefulWidget {
const ResponsiveNavigationPage({super.key});
@override
State<ResponsiveNavigationPage> createState() =>
_ResponsiveNavigationPageState();
}
class _ResponsiveNavigationPageState
extends State<ResponsiveNavigationPage> {
int selectedIndex = 0;
final List<Widget> pages = const [
Center(child: Text('Tổng quan')),
Center(child: Text('Sản phẩm')),
Center(child: Text('Đơn hàng')),
Center(child: Text('Khách hàng')),
];
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
if (width < 600) {
return Scaffold(
body: pages[selectedIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
setState(() => selectedIndex = index);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
label: 'Tổng quan',
),
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
label: 'Sản phẩm',
),
NavigationDestination(
icon: Icon(Icons.shopping_bag_outlined),
label: 'Đơn hàng',
),
NavigationDestination(
icon: Icon(Icons.people_outline),
label: 'Khách hàng',
),
],
),
);
}
if (width < 1000) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: (index) {
setState(() => selectedIndex = index);
},
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
label: Text('Tổng quan'),
),
NavigationRailDestination(
icon: Icon(Icons.inventory_2_outlined),
label: Text('Sản phẩm'),
),
NavigationRailDestination(
icon: Icon(Icons.shopping_bag_outlined),
label: Text('Đơn hàng'),
),
NavigationRailDestination(
icon: Icon(Icons.people_outline),
label: Text('Khách hàng'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: pages[selectedIndex]),
],
),
);
}
return Scaffold(
body: Row(
children: [
SizedBox(
width: 260,
child: NavigationDrawer(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
setState(() => selectedIndex = index);
},
children: const [
Padding(
padding: EdgeInsets.all(24),
child: Text(
'QUẢN LÝ',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
NavigationDrawerDestination(
icon: Icon(Icons.dashboard_outlined),
label: Text('Tổng quan'),
),
NavigationDrawerDestination(
icon: Icon(Icons.inventory_2_outlined),
label: Text('Sản phẩm'),
),
NavigationDrawerDestination(
icon: Icon(Icons.shopping_bag_outlined),
label: Text('Đơn hàng'),
),
NavigationDrawerDestination(
icon: Icon(Icons.people_outline),
label: Text('Khách hàng'),
),
],
),
),
Expanded(child: pages[selectedIndex]),
],
),
);
},
);
}
}
Các ngưỡng 600 và 1000 pixel trong ví dụ là giá trị minh họa, không phải quy định bắt buộc của Flutter. Khi triển khai thực tế, cần lựa chọn breakpoint dựa trên chiều rộng tối thiểu mà nội dung chính cần có để hoạt động tốt.
Trong một ứng dụng hoàn chỉnh, nên tách danh sách destination, trạng thái điều hướng và phần nội dung thành những thành phần dùng chung để tránh lặp lại mã nguồn. Cũng cần cân nhắc khả năng giữ trạng thái của từng màn hình khi chuyển tab và xử lý nút Back trên Android.
1.6. Nguyên tắc thiết kế Responsive Navigation
Một hệ thống điều hướng tốt cần đáp ứng những nguyên tắc sau:
-
Nhất quán: Tên gọi, biểu tượng và thứ tự chức năng không nên thay đổi tùy tiện giữa các kích thước màn hình.
-
Ưu tiên chức năng chính: Những mục được sử dụng thường xuyên cần dễ tiếp cận.
-
Thích nghi theo không gian: Dùng thanh điều hướng dưới cùng khi chiều ngang hạn chế, chuyển sang thanh dọc hoặc drawer khi không gian cho phép.
-
Hiển thị trạng thái rõ ràng: Người dùng cần nhận biết được mục đang hoạt động.
-
Hỗ trợ khả năng tiếp cận: Không chỉ dựa vào màu sắc để thể hiện trạng thái; cần nhãn và vùng tương tác phù hợp.
-
Quản lý trạng thái đúng cách: Chuyển khu vực điều hướng không nên vô tình làm mất dữ liệu biểu mẫu hoặc trạng thái làm việc.
-
Tối ưu khả năng mở rộng: Khi thêm chức năng mới, cấu trúc điều hướng không nên phải viết lại hoàn toàn.
2. Master-Detail Layout
2.1. Khái niệm Master-Detail Layout
Master-Detail Layout là mô hình bố cục chia giao diện thành hai vùng có mối quan hệ với nhau:
-
Master: Khu vực hiển thị danh sách đối tượng hoặc các mục cần lựa chọn.
-
Detail: Khu vực trình bày thông tin chi tiết của đối tượng được chọn.
Mô hình này thường xuất hiện trong ứng dụng email, danh bạ, quản lý đơn hàng, trình quản lý tệp, phần mềm hỗ trợ khách hàng và các hệ thống quản trị dữ liệu.
Ví dụ, trong ứng dụng quản lý đơn hàng, vùng Master có thể hiển thị mã đơn, tên khách hàng, ngày đặt hàng và trạng thái. Khi người dùng chọn một đơn, vùng Detail hiển thị danh sách sản phẩm, địa chỉ giao hàng, phương thức thanh toán và lịch sử xử lý.
Điểm quan trọng của Master-Detail Layout là cách bố trí hai vùng thay đổi theo không gian, nhưng mối quan hệ giữa danh sách và dữ liệu chi tiết vẫn được duy trì.
2.2. Bố cục trên màn hình rộng và màn hình hẹp
Trên màn hình rộng, danh sách và nội dung chi tiết có thể xuất hiện đồng thời. Người dùng chọn một bản ghi ở bên trái và xem thông tin ở bên phải mà không phải rời khỏi màn hình hiện tại.
Trên màn hình hẹp, nếu vẫn chia đôi màn hình, mỗi vùng có thể không còn đủ chỗ để hiển thị nội dung. Vì vậy, ứng dụng thường chỉ hiển thị danh sách hoặc nội dung chi tiết tại một thời điểm.
| Đặc điểm | Màn hình hẹp | Màn hình rộng |
|---|---|---|
| Danh sách | Chiếm phần lớn hoặc toàn màn hình | Nằm ở một vùng riêng |
| Chi tiết | Mở thành màn hình riêng | Hiển thị cạnh danh sách |
| Chuyển đổi | Thường thông qua điều hướng | Chọn trực tiếp trong danh sách |
| Không gian nội dung | Tập trung vào một tác vụ | Có thể quan sát hai vùng đồng thời |
| Trải nghiệm | Phù hợp thao tác chạm | Phù hợp xử lý nhiều thông tin |
Breakpoint nên được xác định theo kích thước cần thiết của từng vùng. Ví dụ, nếu danh sách cần ít nhất 300 pixel và vùng chi tiết cần khoảng 500 pixel, giao diện chia đôi chỉ nên xuất hiện khi tổng chiều rộng đủ lớn, có tính cả khoảng cách và lề.
2.3. Thành phần thường dùng trong Flutter
Các Widget thường được sử dụng để xây dựng Master-Detail Layout gồm:
-
LayoutBuilder: kiểm tra không gian khả dụng. -
Row: đặt danh sách và nội dung chi tiết cạnh nhau. -
Expanded: phân phối phần không gian còn lại. -
ListView.builder: tạo danh sách theo nhu cầu. -
ListTile: hiển thị một mục trong danh sách. -
Card: nhóm thông tin thành các khối trực quan. -
Navigatorhoặc Router: điều hướng đến màn hình chi tiết trên thiết bị hẹp. -
AnimatedSwitcher: tạo chuyển đổi giao diện khi nội dung thay đổi.
Ngoài ra, ứng dụng có thể sử dụng NavigationSplitView trong những framework khác, nhưng với Flutter thuần, bố cục chia đôi thường được xây dựng bằng cách kết hợp các Widget layout.
2.4. Xây dựng Master-Detail Layout thích nghi
Ví dụ dưới đây mô phỏng danh sách đơn hàng. Trên màn hình rộng, danh sách được đặt bên trái và chi tiết đơn hàng nằm bên phải. Trên màn hình hẹp, danh sách được hiển thị riêng; người dùng có thể mở màn hình chi tiết bằng thao tác chọn đơn hàng.
import 'package:flutter/material.dart';
class Order {
final String id;
final String customer;
final double total;
final String status;
const Order({
required this.id,
required this.customer,
required this.total,
required this.status,
});
}
class ResponsiveOrdersPage extends StatefulWidget {
const ResponsiveOrdersPage({super.key});
@override
State<ResponsiveOrdersPage> createState() =>
_ResponsiveOrdersPageState();
}
class _ResponsiveOrdersPageState
extends State<ResponsiveOrdersPage> {
final List<Order> orders = const [
Order(
id: 'DH001',
customer: 'Nguyễn Văn An',
total: 1250000,
status: 'Đang xử lý',
),
Order(
id: 'DH002',
customer: 'Trần Minh Anh',
total: 850000,
status: 'Đang giao',
),
Order(
id: 'DH003',
customer: 'Lê Hoàng Nam',
total: 2100000,
status: 'Hoàn thành',
),
];
int selectedIndex = 0;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 800;
if (!isWide) {
return Scaffold(
appBar: AppBar(
title: const Text('Đơn hàng'),
),
body: buildOrderList(
onSelect: (index) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(
title: Text(orders[index].id),
),
body: OrderDetail(
order: orders[index],
),
),
),
);
},
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Quản lý đơn hàng'),
),
body: Row(
children: [
SizedBox(
width: 320,
child: buildOrderList(
onSelect: (index) {
setState(() {
selectedIndex = index;
});
},
),
),
const VerticalDivider(width: 1),
Expanded(
child: OrderDetail(
order: orders[selectedIndex],
),
),
],
),
);
},
);
}
Widget buildOrderList({
required ValueChanged<int> onSelect,
}) {
return ListView.builder(
itemCount: orders.length,
itemBuilder: (context, index) {
final order = orders[index];
return ListTile(
selected: index == selectedIndex,
title: Text(order.id),
subtitle: Text(order.customer),
trailing: const Icon(Icons.chevron_right),
onTap: () => onSelect(index),
);
},
);
}
}
class OrderDetail extends StatelessWidget {
final Order order;
const OrderDetail({
super.key,
required this.order,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Chi tiết đơn hàng ${order.id}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
Text('Khách hàng: ${order.customer}'),
const SizedBox(height: 12),
Text('Tổng tiền: ${order.total.toStringAsFixed(0)} đ'),
const SizedBox(height: 12),
Text('Trạng thái: ${order.status}'),
],
),
);
}
}
Ví dụ này minh họa cách thay đổi cấu trúc giao diện, không phải một hệ thống quản lý đơn hàng hoàn chỉnh. Dữ liệu đang được lưu trong danh sách cố định và màn hình chi tiết chưa kết nối API.
Khi phát triển thực tế, cần bổ sung xử lý tải dữ liệu, trạng thái loading, lỗi mạng, phân trang và cập nhật dữ liệu khi đơn hàng thay đổi.
2.5. Quản lý trạng thái lựa chọn
Trong Master-Detail Layout, trạng thái lựa chọn đóng vai trò trung tâm. Danh sách và nội dung chi tiết phải tham chiếu cùng một đối tượng đang được chọn.
Nếu lưu trạng thái lựa chọn bằng chỉ số danh sách, cần chú ý trường hợp danh sách bị sắp xếp lại, thêm mới hoặc xóa phần tử. Trong những hệ thống dữ liệu động, sử dụng mã định danh ổn định như orderId, userId hoặc productId thường đáng tin cậy hơn.
Ví dụ về mô hình dữ liệu:
class Product {
final String id;
final String name;
final double price;
final String description;
const Product({
required this.id,
required this.name,
required this.price,
required this.description,
});
}
Khi người dùng chọn sản phẩm, ứng dụng lưu ID của sản phẩm đó và truy xuất dữ liệu chi tiết từ nguồn dữ liệu tương ứng.
Cách tiếp cận này phù hợp với ứng dụng có dữ liệu được tải từ REST API, cơ sở dữ liệu cục bộ hoặc hệ thống quản lý trạng thái như Provider, Riverpod và BLoC.
2.6. Các nguyên tắc tối ưu Master-Detail
Thứ nhất, ưu tiên tính liên tục của thao tác. Trên màn hình rộng, người dùng nên xem được danh sách và nội dung chi tiết cùng lúc. Điều này đặc biệt hữu ích với những công việc cần so sánh nhiều bản ghi.
Thứ hai, duy trì trạng thái lựa chọn. Khi quay lại danh sách, ứng dụng nên giữ vị trí cuộn và trạng thái lựa chọn nếu điều đó phù hợp với quy trình làm việc.
Thứ ba, thiết kế vùng chi tiết có khả năng cuộn độc lập. Danh sách có thể dài hàng trăm phần tử, trong khi nội dung chi tiết cũng có thể bao gồm nhiều trường dữ liệu. Không nên để một vùng cuộn khiến toàn bộ bố cục hoạt động khó kiểm soát.
Thứ tư, xử lý trạng thái chưa chọn dữ liệu. Trên màn hình rộng, trước khi người dùng chọn một bản ghi, vùng chi tiết có thể hiển thị thông báo hướng dẫn hoặc nội dung tổng quan.
Thứ năm, tách biệt giao diện và nghiệp vụ. Widget danh sách không nên tự xử lý toàn bộ nghiệp vụ cập nhật, xóa hoặc đồng bộ dữ liệu. Các thao tác này cần được quản lý bởi lớp logic thích hợp.
Thứ sáu, hỗ trợ điều hướng trên thiết bị nhỏ. Màn hình chi tiết cần có tiêu đề rõ ràng, nút quay lại và khả năng khôi phục danh sách sau khi người dùng hoàn tất thao tác.
3. Responsive Dashboard Layout
3.1. Tổng quan về Dashboard
Dashboard là giao diện tổng hợp thông tin, được thiết kế để người dùng nhanh chóng nắm bắt tình trạng của một hệ thống thông qua các chỉ số, biểu đồ, bảng dữ liệu và thông báo.
Dashboard thường xuất hiện trong:
-
Hệ thống quản lý bán hàng.
-
Phần mềm quản lý nhân sự.
-
Hệ thống phân tích kinh doanh.
-
Ứng dụng quản lý tài chính.
-
Nền tảng thương mại điện tử.
-
Công cụ giám sát máy chủ.
-
Hệ thống theo dõi hoạt động người dùng.
Một Dashboard hiệu quả cần thể hiện thông tin quan trọng trước, cho phép người dùng tìm thấy dữ liệu liên quan nhanh chóng và không gây quá tải nhận thức.
Responsive Dashboard Layout là phương pháp điều chỉnh cấu trúc Dashboard để phù hợp với chiều rộng và chiều cao của vùng hiển thị. Việc điều chỉnh có thể bao gồm số lượng cột, kích thước thẻ thống kê, vị trí biểu đồ, cách hiển thị bảng và thứ tự ưu tiên của nội dung.
3.2. Các khu vực chính của Dashboard
Một Dashboard phổ biến thường gồm năm khu vực.
Khu vực tiêu đề
Khu vực tiêu đề cung cấp tên trang, thông tin thời gian, bộ lọc và các thao tác thường dùng.
Ví dụ:
-
Tên Dashboard.
-
Bộ lọc khoảng thời gian.
-
Bộ lọc chi nhánh.
-
Nút xuất báo cáo.
-
Nút làm mới dữ liệu.
Trên màn hình rộng, các thành phần có thể nằm trên cùng một hàng. Trên điện thoại, chúng nên được chia thành nhiều hàng hoặc đưa vào một khu vực bộ lọc riêng.
Khu vực thẻ thống kê
Thẻ thống kê, thường gọi là KPI Card, thể hiện những chỉ số tổng quan như doanh thu, số đơn hàng, số khách hàng mới và giá trị đơn hàng trung bình.
Một thẻ thường có:
-
Tên chỉ số.
-
Giá trị hiện tại.
-
Tỷ lệ thay đổi so với kỳ trước.
-
Biểu tượng minh họa.
-
Màu sắc hoặc dấu hiệu thể hiện xu hướng.
Khu vực biểu đồ
Biểu đồ giúp nhận diện xu hướng, so sánh dữ liệu và phát hiện biến động. Ví dụ, biểu đồ đường thể hiện doanh thu theo ngày, biểu đồ cột so sánh hiệu quả giữa các chi nhánh và biểu đồ tròn thể hiện cơ cấu sản phẩm.
Không nên đặt quá nhiều biểu đồ có giá trị thông tin thấp lên cùng một màn hình. Mỗi biểu đồ cần phục vụ một mục đích phân tích cụ thể.
Khu vực bảng dữ liệu
Bảng dữ liệu thường hiển thị những bản ghi cần được theo dõi hoặc xử lý, chẳng hạn đơn hàng gần đây, sản phẩm bán chạy và các giao dịch mới nhất.
Trên màn hình nhỏ, bảng cần được tối ưu riêng thay vì thu nhỏ toàn bộ nội dung xuống mức khó đọc.
Khu vực cảnh báo và hoạt động gần đây
Khu vực này hiển thị thông báo quan trọng, những sự kiện mới phát sinh hoặc các công việc cần được xử lý. Những nội dung khẩn cấp nên được ưu tiên theo mức độ ảnh hưởng, không chỉ dựa trên thời gian xuất hiện.
3.3. Nguyên tắc tổ chức lưới Dashboard
Dashboard thường sử dụng hệ thống lưới để tổ chức các thẻ và vùng nội dung. Trên màn hình rộng, một hàng có thể chứa nhiều thẻ. Trên màn hình hẹp, các thẻ chuyển thành hai cột hoặc một cột.
Ví dụ về cách tổ chức:
| Vùng hiển thị | Thẻ thống kê | Biểu đồ | Bảng dữ liệu |
|---|---|---|---|
| Điện thoại hẹp | Một cột | Một cột | Cuộn ngang hoặc Card |
| Điện thoại rộng | Hai cột nếu đủ chỗ | Một cột | Bố cục rút gọn |
| Tablet | Hai hoặc ba cột | Một hoặc hai cột | Bảng có chọn lọc |
| Desktop | Ba hoặc bốn cột | Nhiều vùng song song | Bảng nhiều thông tin |
Đây là cách tổ chức tham khảo. Số cột thực tế cần phụ thuộc vào độ rộng tối thiểu của từng thành phần, độ dài văn bản và yêu cầu nghiệp vụ.
Trong Flutter, GridView, SliverGridDelegateWithMaxCrossAxisExtent, Wrap và LayoutBuilder là những công cụ phù hợp để xây dựng lưới Dashboard linh hoạt.
3.4. Sử dụng GridView cho thẻ thống kê
GridView giúp tổ chức các thẻ thành nhiều cột và tự động phân phối không gian theo cấu hình lưới.
Một lựa chọn linh hoạt là SliverGridDelegateWithMaxCrossAxisExtent. Thay vì cố định số cột, cấu hình này giới hạn chiều rộng tối đa của mỗi ô để Flutter tính toán số cột có thể bố trí.
Ví dụ:
import 'package:flutter/material.dart';
class StatCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const StatCard({
super.key,
required this.title,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 12),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
),
);
}
}
class ResponsiveStatsGrid extends StatelessWidget {
const ResponsiveStatsGrid({super.key});
@override
Widget build(BuildContext context) {
return GridView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 280,
mainAxisExtent: 150,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
children: const [
StatCard(
title: 'Doanh thu',
value: '125.000.000 đ',
icon: Icons.payments_outlined,
color: Colors.green,
),
StatCard(
title: 'Đơn hàng',
value: '1.258',
icon: Icons.shopping_bag_outlined,
color: Colors.blue,
),
StatCard(
title: 'Khách hàng',
value: '3.420',
icon: Icons.people_outline,
color: Colors.orange,
),
StatCard(
title: 'Sản phẩm',
value: '856',
icon: Icons.inventory_2_outlined,
color: Colors.purple,
),
],
);
}
}
Trong ví dụ này, mỗi ô có chiều rộng tối đa 280 pixel. Khi không gian tăng lên, lưới có thể hiển thị nhiều cột hơn. Khi chiều rộng giảm, số cột giảm theo khả năng bố trí.
mainAxisExtent đặt chiều cao của mỗi ô, giúp các thẻ có kích thước nhất quán. Tuy nhiên, cần kiểm tra trường hợp người dùng tăng kích thước chữ hoặc nội dung dài hơn dự kiến. Trong những tình huống đó, chiều cao cố định có thể không còn phù hợp.
Lưu ý: shrinkWrap: true và NeverScrollableScrollPhysics trong ví dụ phù hợp khi lưới được nhúng trong một vùng cuộn khác và số lượng thẻ tương đối ít. Với Dashboard lớn, nên cân nhắc CustomScrollView, SliverGrid hoặc một cấu trúc cuộn thống nhất để tránh chi phí layout không cần thiết.
3.5. Bố trí Dashboard bằng LayoutBuilder
LayoutBuilder cho phép lựa chọn bố cục theo chiều rộng thực tế của vùng Dashboard.
Ví dụ, trên màn hình rộng, biểu đồ doanh thu có thể chiếm hai phần ba chiều ngang và bảng dữ liệu chiếm phần còn lại. Trên màn hình hẹp, các vùng được xếp thành một cột.
import 'package:flutter/material.dart';
class ResponsiveDashboardBody extends StatelessWidget {
const ResponsiveDashboardBody({super.key});
Widget buildPanel(String title, {double height = 260}) {
return Card(
child: SizedBox(
height: height,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 1000;
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Column(
children: [
buildPanel('Biểu đồ doanh thu'),
buildPanel('Hoạt động gần đây'),
],
),
),
Expanded(
flex: 1,
child: Column(
children: [
buildPanel('Tỷ lệ chuyển đổi'),
buildPanel('Đơn hàng mới'),
],
),
),
],
);
}
return Column(
children: [
buildPanel('Biểu đồ doanh thu'),
buildPanel('Tỷ lệ chuyển đổi'),
buildPanel('Đơn hàng mới'),
buildPanel('Hoạt động gần đây'),
],
);
},
);
}
}
Các vùng trong ví dụ đang dùng thẻ minh họa thay cho biểu đồ và dữ liệu thật. Trong ứng dụng thực tế, có thể thay thế chúng bằng thư viện biểu đồ phù hợp hoặc Widget chuyên biệt.
Một điểm cần lưu ý là LayoutBuilder chỉ đánh giá kích thước vùng mà nó nhận được. Nếu Dashboard nằm trong một vùng có chiều rộng hạn chế bên trong cửa sổ lớn, giá trị constraints có thể nhỏ hơn chiều rộng toàn màn hình. Đây thường là hành vi mong muốn vì bố cục phải thích nghi với không gian thực sự dành cho nó.
3.6. Tối ưu biểu đồ trong Dashboard
Biểu đồ là một trong những thành phần dễ gặp lỗi giao diện khi chuyển sang màn hình nhỏ. Một biểu đồ có nhiều nhãn trục, chú giải và chuỗi dữ liệu có thể bị chật hoặc làm chữ quá nhỏ.
Một số nguyên tắc quan trọng:
-
Điều chỉnh số lượng nhãn trục theo chiều rộng.
-
Cho phép chú giải chuyển xuống dưới biểu đồ khi không đủ chỗ.
-
Đảm bảo biểu đồ có chiều cao phù hợp.
-
Dùng màu sắc có độ tương phản tốt.
-
Cung cấp thông tin dạng văn bản hoặc bảng khi biểu đồ khó đọc.
-
Tránh biểu diễn quá nhiều chuỗi dữ liệu trong cùng một vùng nhỏ.
-
Cho phép người dùng chọn khoảng thời gian hoặc loại dữ liệu cần phân tích.
Không nên tự động thu nhỏ mọi biểu đồ theo cùng một tỷ lệ. Một biểu đồ đường theo thời gian, biểu đồ cột so sánh và biểu đồ thể hiện phân bố dữ liệu có những yêu cầu trình bày khác nhau.
3.7. Tách Dashboard thành các Widget độc lập
Một Dashboard thường có nhiều thành phần và nguồn dữ liệu khác nhau. Nếu toàn bộ giao diện được đặt trong một Widget quá lớn, mã nguồn sẽ khó bảo trì và những thay đổi nhỏ có thể khiến phần giao diện rộng hơn phải build lại.
Nên tách Dashboard thành những thành phần như:
DashboardPage
├── DashboardHeader
├── DashboardFilters
├── StatisticsGrid
│ ├── RevenueCard
│ ├── OrderCountCard
│ └── CustomerCountCard
├── RevenueChart
├── CategoryChart
├── RecentOrdersTable
└── RecentActivities
Mỗi Widget có nhiệm vụ rõ ràng và có thể nhận dữ liệu thông qua constructor hoặc trạng thái được quản lý bởi lớp logic bên ngoài.
Đối với Dashboard kết nối API, cần phân biệt các trạng thái:
-
Đang tải dữ liệu.
-
Tải thành công.
-
Không có dữ liệu.
-
Tải thất bại.
-
Dữ liệu đã cũ và đang chờ làm mới.
Các trạng thái này cần được thể hiện rõ ràng, thay vì luôn hiển thị số liệu giả hoặc chỉ để một vùng trắng khi API gặp lỗi.
3.8. Nguyên tắc thiết kế Responsive Dashboard
Một Dashboard tốt cần ưu tiên thông tin quan trọng, giữ được thứ bậc thị giác và giảm thiểu sự phân tán chú ý.
Trên màn hình rộng, có thể hiển thị nhiều vùng thông tin song song. Trên màn hình hẹp, nên đưa nội dung quan trọng lên đầu và chuyển những vùng ít ưu tiên xuống dưới.
Không phải mọi thành phần đều phải giữ nguyên vị trí trên tất cả thiết bị. Một số bảng có thể chuyển thành danh sách Card; các bộ lọc nâng cao có thể chuyển thành bottom sheet; các biểu đồ ít quan trọng có thể đặt phía dưới hoặc chỉ hiển thị khi người dùng mở rộng.
Đồng thời, cần chú ý hiệu năng. Dashboard có nhiều biểu đồ và danh sách lớn nên tránh tải toàn bộ dữ liệu không cần thiết ngay từ đầu. Phân trang, tải dữ liệu theo vùng, cache và giới hạn phạm vi truy vấn có thể giúp ứng dụng phản hồi nhanh hơn.
4. Responsive Data Table
4.1. Tổng quan về Responsive Data Table
Data Table là thành phần giao diện dùng để trình bày dữ liệu có cấu trúc thành hàng và cột. Mỗi hàng thường tương ứng với một bản ghi, còn mỗi cột biểu diễn một thuộc tính của bản ghi đó.
Ví dụ, bảng quản lý sản phẩm có thể bao gồm:
-
Mã sản phẩm.
-
Tên sản phẩm.
-
Danh mục.
-
Giá bán.
-
Số lượng tồn kho.
-
Trạng thái.
-
Thao tác.
Trên màn hình desktop, bảng nhiều cột giúp người dùng quan sát và so sánh dữ liệu nhanh chóng. Tuy nhiên, trên điện thoại, tổng chiều rộng của các cột có thể vượt quá không gian hiển thị, dẫn đến chữ bị cắt, các nút thao tác chồng lấn hoặc người dùng khó đọc dữ liệu.
Responsive Data Table là cách tổ chức lại bảng theo kích thước màn hình và mức độ quan trọng của dữ liệu, thay vì chỉ thu nhỏ toàn bộ bảng.
4.2. Những phương pháp xử lý bảng trên màn hình nhỏ
Có bốn phương pháp phổ biến để xử lý bảng dữ liệu khi chiều rộng bị hạn chế.
Phương pháp 1: Cuộn ngang
Giữ nguyên cấu trúc bảng và cho phép người dùng cuộn ngang để xem các cột nằm ngoài vùng hiển thị.
Phương pháp này phù hợp với bảng tài chính, báo cáo kỹ thuật và dữ liệu mà việc so sánh giữa các cột là quan trọng.
Ưu điểm:
-
Giữ nguyên cấu trúc hàng và cột.
-
Không làm mất thông tin.
-
Dễ triển khai với
SingleChildScrollView.
Nhược điểm:
-
Người dùng phải thực hiện thêm thao tác cuộn.
-
Khó so sánh các cột nằm xa nhau.
-
Cần tránh cuộn ngang không chủ ý hoặc làm mất dấu hiệu cho biết còn dữ liệu bên phải.
Phương pháp 2: Ẩn cột ít quan trọng
Giữ lại những cột cần thiết cho thao tác chính và ẩn các cột phụ trên màn hình hẹp.
Ví dụ, bảng sản phẩm trên điện thoại có thể giữ tên, giá bán và trạng thái; mã nội bộ, ngày tạo hoặc thông tin bổ sung có thể được chuyển sang màn hình chi tiết.
Ưu điểm là bảng gọn hơn, dễ đọc hơn và ít phải cuộn ngang.
Tuy nhiên, cần bảo đảm người dùng vẫn có cách truy cập những thông tin bị ẩn. Không nên loại bỏ dữ liệu quan trọng chỉ vì màn hình nhỏ.
Phương pháp 3: Chuyển hàng thành Card
Mỗi bản ghi được trình bày dưới dạng một Card với tên đối tượng, các thuộc tính chính và những nút thao tác.
Phương pháp này phù hợp với ứng dụng quản lý sản phẩm, đơn hàng, nhân sự và khách hàng.
Ưu điểm:
-
Dễ đọc trên màn hình hẹp.
-
Có thể sắp xếp thông tin theo mức độ quan trọng.
-
Dễ kết hợp các nút thao tác lớn và thân thiện với cảm ứng.
Nhược điểm là người dùng khó so sánh nhiều bản ghi theo cùng một cột như trong bảng truyền thống.
Phương pháp 4: Hiển thị bản tóm tắt và mở chi tiết
Bảng chỉ trình bày những thông tin cần thiết để nhận biết bản ghi. Khi người dùng chọn một hàng, ứng dụng mở trang chi tiết, dialog hoặc bottom sheet để hiển thị phần còn lại.
Phương pháp này phù hợp với những bản ghi có nhiều thuộc tính hoặc quy trình xử lý phức tạp.
4.3. DataTable trong Flutter
Flutter cung cấp Widget DataTable để hiển thị dữ liệu dạng hàng và cột.
Các thành phần chính:
| Thành phần | Chức năng |
|---|---|
DataTable |
Xây dựng bảng |
DataColumn |
Định nghĩa cột và tiêu đề |
DataRow |
Định nghĩa một hàng |
DataCell |
Nội dung của một ô |
SingleChildScrollView |
Cho phép cuộn nội dung |
LayoutBuilder |
Xác định cách hiển thị theo không gian |
PaginatedDataTable |
Hiển thị bảng theo từng trang |
DataTableSource |
Cung cấp dữ liệu cho PaginatedDataTable |
DataTable phù hợp với bảng có quy mô vừa phải. Khi bảng có hàng nghìn bản ghi, cần cân nhắc phân trang, tải dữ liệu theo nhu cầu hoặc sử dụng giải pháp bảng được tối ưu cho lượng dữ liệu lớn.
4.4. Xây dựng bảng có thể cuộn ngang
Ví dụ sau tạo bảng quản lý sản phẩm với các cột mã sản phẩm, tên sản phẩm, giá bán, tồn kho và trạng thái.
import 'package:flutter/material.dart';
class Product {
final String id;
final String name;
final int price;
final int stock;
final String status;
const Product({
required this.id,
required this.name,
required this.price,
required this.stock,
required this.status,
});
}
class ResponsiveProductTable extends StatelessWidget {
const ResponsiveProductTable({super.key});
static const products = [
Product(
id: 'SP001',
name: 'Áo thun basic',
price: 199000,
stock: 120,
status: 'Đang bán',
),
Product(
id: 'SP002',
name: 'Quần jean',
price: 450000,
stock: 35,
status: 'Đang bán',
),
Product(
id: 'SP003',
name: 'Áo khoác',
price: 650000,
stock: 0,
status: 'Hết hàng',
),
];
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: constraints.maxWidth,
),
child: DataTable(
columnSpacing: 24,
columns: const [
DataColumn(label: Text('Mã SP')),
DataColumn(label: Text('Tên sản phẩm')),
DataColumn(label: Text('Giá bán')),
DataColumn(label: Text('Tồn kho')),
DataColumn(label: Text('Trạng thái')),
],
rows: products.map((product) {
return DataRow(
cells: [
DataCell(Text(product.id)),
DataCell(Text(product.name)),
DataCell(
Text('${product.price} đ'),
),
DataCell(
Text('${product.stock}'),
),
DataCell(
Text(product.status),
),
],
);
}).toList(),
),
),
);
},
);
}
}
SingleChildScrollView với scrollDirection: Axis.horizontal cho phép người dùng cuộn bảng theo chiều ngang. ConstrainedBox đảm bảo vùng bảng có chiều rộng tối thiểu bằng chiều rộng khả dụng, nhờ đó bảng không bị co lại quá mức khi số lượng cột ít.
Cần chú ý rằng LayoutBuilder trong ví dụ nhận constraints của vùng chứa bảng. Khi sử dụng bảng trong Column, Expanded hoặc một vùng cuộn khác, cần bảo đảm các constraints theo cả chiều ngang và chiều dọc được xác định hợp lý.
4.5. Chuyển Data Table thành Card trên màn hình nhỏ
Đối với ứng dụng quản lý sản phẩm, người dùng trên điện thoại thường cần đọc tên sản phẩm, giá bán và số lượng tồn kho hơn là so sánh toàn bộ cột.
Vì vậy, có thể dùng LayoutBuilder để hiển thị DataTable trên màn hình rộng và danh sách Card trên màn hình hẹp.
import 'package:flutter/material.dart';
class AdaptiveProductList extends StatelessWidget {
const AdaptiveProductList({super.key});
static const products = [
Product(
id: 'SP001',
name: 'Áo thun basic',
price: 199000,
stock: 120,
status: 'Đang bán',
),
Product(
id: 'SP002',
name: 'Quần jean',
price: 450000,
stock: 35,
status: 'Đang bán',
),
Product(
id: 'SP003',
name: 'Áo khoác',
price: 650000,
stock: 0,
status: 'Hết hàng',
),
];
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
product.name,
style: Theme.of(context)
.textTheme
.titleMedium,
),
const SizedBox(height: 8),
Text('Mã: ${product.id}'),
Text('Giá: ${product.price} đ'),
Text('Tồn kho: ${product.stock}'),
Text('Trạng thái: ${product.status}'),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {},
child: const Text('Xem chi tiết'),
),
),
],
),
),
);
},
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: const [
DataColumn(label: Text('Mã SP')),
DataColumn(label: Text('Tên sản phẩm')),
DataColumn(label: Text('Giá bán')),
DataColumn(label: Text('Tồn kho')),
DataColumn(label: Text('Trạng thái')),
],
rows: products.map((product) {
return DataRow(
cells: [
DataCell(Text(product.id)),
DataCell(Text(product.name)),
DataCell(Text('${product.price} đ')),
DataCell(Text('${product.stock}')),
DataCell(Text(product.status)),
],
);
}).toList(),
),
);
},
);
}
}
Trong đoạn mã trên, lớp Product được tái sử dụng từ ví dụ trước. Khi đưa hai ví dụ vào cùng một dự án, cần giữ định nghĩa Product ở một vị trí duy nhất để tránh trùng lặp tên lớp.
Nút Xem chi tiết đang là phần minh họa và chưa thực hiện điều hướng. Trong ứng dụng thực tế, nên truyền callback hoặc sử dụng Router để mở màn hình chi tiết sản phẩm.
4.6. Ẩn cột theo mức độ ưu tiên
Không phải lúc nào cũng cần chuyển bảng thành Card. Với những bảng cần duy trì cấu trúc hàng và cột, có thể ẩn một số cột ít quan trọng khi chiều rộng giảm.
Ví dụ, bảng đơn hàng có thể được tổ chức theo mức độ ưu tiên như sau:
| Mức ưu tiên | Cột dữ liệu | Cách xử lý |
|---|---|---|
| Cao | Mã đơn hàng | Luôn hiển thị |
| Cao | Khách hàng | Luôn hiển thị nếu đủ chỗ |
| Cao | Trạng thái | Ưu tiên hiển thị |
| Trung bình | Tổng tiền | Giữ lại khi có thể |
| Thấp | Người phụ trách | Có thể chuyển sang chi tiết |
| Thấp | Thời gian cập nhật cuối | Có thể ẩn trên điện thoại |
Việc ẩn cột nên dựa trên quy trình nghiệp vụ. Ví dụ, trong hệ thống kiểm kê kho, số lượng tồn có thể quan trọng hơn tên người phụ trách. Trong bảng thanh toán, tổng tiền có thể là thông tin cần ưu tiên nhất.
Cần tránh ẩn cột chỉ vì chúng chiếm nhiều diện tích mà không cung cấp một phương án thay thế để xem dữ liệu.
4.7. Phân trang và hiệu năng bảng dữ liệu
Khi số lượng bản ghi tăng lên, việc dựng toàn bộ bảng cùng lúc có thể làm tăng thời gian xây dựng giao diện và chi phí layout.
Một số giải pháp thường được áp dụng:
-
Phân trang ở phía máy chủ hoặc máy khách.
-
Chỉ tải các bản ghi cần hiển thị.
-
Dùng
PaginatedDataTablecho những bảng phù hợp với mô hình phân trang. -
Tìm kiếm và lọc dữ liệu trước khi hiển thị.
-
Hạn chế các phép tính phức tạp bên trong hàm
build. -
Tách các ô có logic phức tạp thành Widget riêng khi cần thiết.
-
Sử dụng khóa ổn định khi xây dựng danh sách động.
-
Tránh gọi API trực tiếp trong
build, vì phương thức này có thể được gọi nhiều lần.
Đối với bảng có dữ liệu lớn, nên ưu tiên phân trang ở backend thay vì tải hàng chục nghìn bản ghi về thiết bị rồi mới lọc. Cách tiếp cận này giảm lưu lượng mạng và sử dụng bộ nhớ hiệu quả hơn.
4.8. Sắp xếp, tìm kiếm và lọc dữ liệu
Bảng dữ liệu trong ứng dụng quản trị thường cần hỗ trợ các thao tác:
-
Sắp xếp theo tên, giá, thời gian hoặc trạng thái.
-
Tìm kiếm theo mã và nội dung.
-
Lọc theo danh mục hoặc khoảng thời gian.
-
Chọn nhiều bản ghi.
-
Thực hiện thao tác hàng loạt.
-
Chuyển trang và thay đổi số lượng bản ghi mỗi trang.
Trên desktop, các bộ lọc có thể được bố trí ở phía trên bảng. Trên điện thoại, có thể sử dụng một hàng tìm kiếm ngắn và đưa các bộ lọc nâng cao vào showModalBottomSheet.
Khi triển khai sắp xếp, cần phân biệt thứ tự sắp xếp ở giao diện và thứ tự sắp xếp của nguồn dữ liệu. Với bảng được phân trang từ máy chủ, nếu chỉ sắp xếp dữ liệu của trang hiện tại thì kết quả toàn bộ bảng có thể không chính xác. Khi đó, thao tác sắp xếp cần được gửi lên backend để truy vấn lại dữ liệu theo thứ tự mới.
4.9. Nguyên tắc thiết kế Responsive Data Table
Một bảng dữ liệu thích nghi tốt cần đảm bảo:
Khả năng đọc: Văn bản, giá trị và nhãn cột phải đủ rõ ràng trên thiết bị nhỏ.
Khả năng so sánh: Những dữ liệu cần so sánh theo hàng hoặc cột nên được giữ trong cùng một cấu trúc khi có thể.
Khả năng truy cập: Các nút thao tác cần có vùng tương tác đủ lớn và nhãn có ý nghĩa.
Tính nhất quán: Cách biểu diễn trạng thái, tiền tệ, ngày tháng và số lượng phải thống nhất giữa bảng và màn hình chi tiết.
Khả năng mở rộng: Bảng cần xử lý tốt khi số lượng cột hoặc bản ghi tăng lên.
Không mất dữ liệu: Cột bị ẩn hoặc thông tin được chuyển thành Card vẫn cần có phương án để người dùng truy cập khi cần.
Hiệu năng: Tránh dựng quá nhiều hàng cùng lúc và không thực hiện tác vụ mạng trong quá trình build giao diện.
5. Kết hợp Responsive Navigation, Master-Detail, Dashboard và Data Table
Bốn kỹ thuật trên thường được sử dụng đồng thời trong một ứng dụng quản trị thực tế.
Ví dụ, một nền tảng thương mại điện tử có thể có hệ thống điều hướng chính để truy cập Dashboard, sản phẩm, đơn hàng và khách hàng. Dashboard tổng hợp doanh thu và biểu đồ. Trang đơn hàng sử dụng Master-Detail để xem danh sách và thông tin của từng đơn. Trang sản phẩm dùng Data Table trên desktop và Card trên điện thoại.
Cấu trúc tổng thể có thể được tổ chức như sau:
AdminApplication
└── ResponsiveShell
├── Navigation
│ ├── NavigationBar
│ ├── NavigationRail
│ └── NavigationDrawer
└── MainContent
├── DashboardPage
│ ├── StatisticsGrid
│ ├── RevenueChart
│ └── RecentOrders
├── ProductsPage
│ ├── ProductDataTable
│ └── ProductCards
└── OrdersPage
├── OrderList
└── OrderDetail
Trong kiến trúc này, ResponsiveShell chịu trách nhiệm bố trí điều hướng và vùng nội dung. Các trang con tập trung vào chức năng riêng, còn từng trang tự điều chỉnh bố cục khi cần thiết.
Một số nguyên tắc cần được giữ xuyên suốt:
-
Sử dụng breakpoint dựa trên yêu cầu thực tế của giao diện.
-
Không sao chép toàn bộ nghiệp vụ chỉ để tạo một phiên bản giao diện khác.
-
Tách dữ liệu và logic nghiệp vụ khỏi cách trình bày.
-
Giữ nhất quán giữa trạng thái được chọn, dữ liệu đang xem và URL nếu ứng dụng có định tuyến.
-
Kiểm tra các trạng thái loading, lỗi, dữ liệu trống và dữ liệu dài.
-
Hỗ trợ bàn phím, cảm ứng, trình đọc màn hình và việc tăng kích thước chữ.
-
Đánh giá hiệu năng với dữ liệu gần với quy mô sử dụng thật.
6. Tổng kết
Responsive Navigation, Master-Detail Layout, Responsive Dashboard Layout và Responsive Data Table là những kỹ thuật nền tảng khi phát triển ứng dụng Flutter trên nhiều kích thước màn hình.
NavigationBar, NavigationRail và NavigationDrawer giúp hệ thống điều hướng phù hợp với không gian và cách tương tác. Master-Detail Layout cải thiện khả năng xử lý dữ liệu bằng cách tổ chức danh sách và chi tiết theo mối quan hệ rõ ràng. Responsive Dashboard giúp thông tin tổng quan được trình bày theo mức độ ưu tiên. Responsive Data Table duy trì khả năng đọc, so sánh và thao tác với dữ liệu khi không gian hiển thị bị hạn chế.
Điểm cốt lõi không phải là tạo ra nhiều phiên bản giao diện riêng biệt, mà là tổ chức cấu trúc Widget, dữ liệu và trạng thái sao cho ứng dụng có thể thay đổi cách trình bày mà vẫn giữ nguyên chức năng và tính nhất quán.
Khi kết hợp LayoutBuilder, MediaQuery, Expanded, GridView, ListView, các Widget điều hướng và hệ thống quản lý trạng thái, lập trình viên có thể xây dựng ứng dụng Flutter thích nghi tốt hơn với điện thoại, tablet và desktop. Việc kiểm thử ở nhiều kích thước, xử lý dữ liệu thực tế và đảm bảo khả năng tiếp cận là những bước quan trọng để biến một giao diện responsive thành một ứng dụng hoàn chỉnh, dễ sử dụng và dễ bảo trì.








Khoá học lập trình game con rắn cho trẻ em




