Responsive nâng cao trong Flutter
1. Responsive Columns và Grid System
1.1. Khái niệm Responsive Columns
Responsive Columns là phương pháp chia vùng hiển thị thành các cột để sắp xếp nội dung theo một hệ thống thống nhất. Thay vì đặt từng Widget tại các vị trí cố định, giao diện được tổ chức thành những vùng có tỷ lệ và khoảng cách rõ ràng, sau đó điều chỉnh số lượng cột dựa trên chiều rộng thực tế.
Trong Flutter, Responsive Columns thường được sử dụng để xây dựng Dashboard, trang danh mục sản phẩm, thư viện hình ảnh, danh sách bài viết và các giao diện quản trị. Cách tổ chức này giúp ứng dụng duy trì sự nhất quán khi chạy trên điện thoại, máy tính bảng, laptop và màn hình desktop.
Ví dụ về cách phân chia cột theo kích thước màn hình:
-
Điện thoại: Hiển thị một cột, mỗi Card chiếm gần như toàn bộ chiều rộng khả dụng.
-
Máy tính bảng: Hiển thị hai hoặc ba cột tùy theo độ rộng và nội dung.
-
Desktop: Hiển thị ba, bốn hoặc nhiều cột hơn nếu không gian cho phép.
-
Màn hình lớn: Giới hạn chiều rộng nội dung và căn giữa thay vì kéo giãn tất cả Card.
1.2. Các thành phần của Grid System
Một Grid System thường bao gồm ba thành phần chính:
Columns: Các cột dùng để xác định cấu trúc bố cục. Nội dung được đặt trong một hoặc nhiều cột tùy theo kích thước của từng thành phần.
Gutter: Khoảng cách giữa các cột. Gutter giúp các Card không dính sát vào nhau, đồng thời tạo ra khoảng trống để người dùng dễ phân biệt từng vùng nội dung.
Margin: Khoảng cách giữa nội dung và cạnh ngoài của vùng hiển thị. Margin giúp giao diện không bị sát mép màn hình, đặc biệt trên điện thoại hoặc cửa sổ có chiều rộng nhỏ.
Một hệ thống Grid hợp lý cần cân bằng giữa chiều rộng Card, số lượng cột và khoảng cách. Nếu có quá nhiều cột, nội dung bên trong mỗi Card sẽ bị thu hẹp. Ngược lại, nếu có quá ít cột trên màn hình lớn, giao diện có thể lãng phí không gian.
1.3. Xây dựng Grid bằng GridView
Flutter cung cấp GridView để hiển thị các phần tử theo dạng lưới. Trong trường hợp cần thay đổi số lượng cột dựa trên chiều rộng, có thể sử dụng LayoutBuilder kết hợp với SliverGridDelegateWithFixedCrossAxisCount.
import 'package:flutter/material.dart';
class ResponsiveProductGrid extends StatelessWidget {
const ResponsiveProductGrid({super.key});
@override
Widget build(BuildContext context) {
final products = List.generate(
12,
(index) => 'Sản phẩm ${index + 1}',
);
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final crossAxisCount = width < 600
? 1
: width < 900
? 2
: width < 1200
? 3
: 4;
return GridView.builder(
padding: const EdgeInsets.all(16),
itemCount: products.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemBuilder: (context, index) {
return Card(
child: Center(
child: Text(products[index]),
),
);
},
);
},
);
}
}
Trong ví dụ trên, LayoutBuilder cung cấp chiều rộng mà Widget hiện tại có thể sử dụng. Dựa vào giá trị constraints.maxWidth, ứng dụng lựa chọn số cột phù hợp.
Các thuộc tính quan trọng:
-
crossAxisCount: Số cột hiển thị. -
crossAxisSpacing: Khoảng cách giữa các cột. -
mainAxisSpacing: Khoảng cách giữa các hàng. -
childAspectRatio: Tỷ lệ giữa chiều rộng và chiều cao của mỗi phần tử. -
padding: Khoảng cách giữa nội dung và cạnh ngoài của Grid.
Cách tiếp cận này phù hợp khi số lượng cột được xác định bằng các ngưỡng chiều rộng cụ thể.
1.4. Tự động xác định số cột bằng Max Cross Axis Extent
Trong nhiều giao diện, không nhất thiết phải quy định rõ màn hình 600 px có hai cột hay màn hình 900 px có ba cột. Thay vào đó, có thể xác định chiều rộng tối đa mong muốn của mỗi Card, sau đó để Flutter tự tính số cột.
GridView.builder(
padding: const EdgeInsets.all(16),
itemCount: products.length,
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 280,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.8,
),
itemBuilder: (context, index) {
return ProductCard(product: products[index]);
},
)
maxCrossAxisExtent xác định kích thước tối đa theo trục ngang của mỗi phần tử trong Grid. Flutter dựa trên không gian khả dụng để xác định số cột có thể bố trí.
Phương pháp này phù hợp với danh sách sản phẩm, thư viện hình ảnh và các giao diện có Card tương đối đồng nhất. Tuy nhiên, cần chú ý rằng maxCrossAxisExtent là giới hạn tối đa, không phải lúc nào cũng bảo đảm mỗi Card có đúng chiều rộng được chỉ định.
1.5. Nguyên tắc thiết kế Grid trong Flutter
Khi xây dựng Grid System, cần xem xét đồng thời kích thước phần tử và khả năng đọc nội dung.
-
Không nên tăng số cột chỉ để tận dụng toàn bộ chiều rộng nếu văn bản hoặc hình ảnh trở nên quá nhỏ.
-
Nên sử dụng khoảng cách nhất quán giữa các Card.
-
Cần kiểm tra những nội dung có chiều cao thay đổi, chẳng hạn tên sản phẩm dài hoặc mô tả nhiều dòng.
-
Nên dùng
GridView.buildercho danh sách lớn để xây dựng phần tử theo nhu cầu. -
Cần giới hạn chiều rộng nội dung khi Grid nằm trong màn hình desktop rất rộng.
-
Không nên phụ thuộc hoàn toàn vào tên thiết bị để quyết định số cột; chiều rộng khả dụng mới là cơ sở quan trọng.
Responsive Columns và Grid System giúp giao diện có cấu trúc rõ ràng, dễ mở rộng và dễ bảo trì. Khi kết hợp với LayoutBuilder, GridView và các giới hạn kích thước hợp lý, ứng dụng có thể tự điều chỉnh bố cục mà không cần tạo một giao diện riêng cho từng thiết bị.
2. ConstrainedBox và BoxConstraints
2.1. Khái niệm ConstrainedBox
ConstrainedBox là Widget dùng để áp đặt các giới hạn kích thước lên Widget con. Những giới hạn này có thể quy định chiều rộng tối thiểu, chiều rộng tối đa, chiều cao tối thiểu và chiều cao tối đa.
Trong Flutter, việc xác định kích thước không đơn giản chỉ là đặt một con số cho Widget. Kích thước cuối cùng còn phụ thuộc vào các ràng buộc từ Widget cha, yêu cầu của Widget con và quy tắc bố cục của hệ thống RenderObject.
ConstrainedBox đặc biệt hữu ích trong Responsive Design vì nó giúp tránh hai tình trạng phổ biến:
-
Nội dung mở rộng quá mức trên màn hình desktop.
-
Thành phần giao diện bị thu nhỏ đến mức khó sử dụng trên màn hình hẹp.
Ví dụ, một biểu mẫu đăng nhập có thể chiếm gần hết chiều rộng trên điện thoại nhưng chỉ sử dụng chiều rộng tối đa 420 logical pixels trên desktop.
2.2. Cơ chế hoạt động của BoxConstraints
BoxConstraints mô tả giới hạn chiều rộng và chiều cao mà một Widget được phép sử dụng. Các giới hạn này được thể hiện thông qua bốn thuộc tính:
| Thuộc tính | Ý nghĩa |
|---|---|
minWidth |
Chiều rộng tối thiểu |
maxWidth |
Chiều rộng tối đa |
minHeight |
Chiều cao tối thiểu |
maxHeight |
Chiều cao tối đa |
Chiều rộng và chiều cao của Widget phải nằm trong phạm vi các giới hạn áp dụng, trừ khi bố cục chứa các tình huống đặc biệt như ràng buộc vô hạn hoặc cách xử lý riêng của RenderObject.
Trong quá trình layout, Widget cha truyền constraints xuống Widget con. Widget con xác định kích thước phù hợp trong phạm vi cho phép, sau đó hệ thống bố trí vị trí và kích thước của phần tử trên màn hình.
Cần phân biệt giữa kích thước mong muốn và kích thước được phép. Một Widget có thể yêu cầu chiều rộng 500 pixels, nhưng nếu Widget cha chỉ cho phép tối đa 300 pixels thì kích thước thực tế phải tuân theo các ràng buộc có hiệu lực.
2.3. Sử dụng ConstrainedBox để giới hạn chiều rộng
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 280,
maxWidth: 420,
),
child: TextField(
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
),
)
Ví dụ này đặt giới hạn chiều rộng cho TextField. Tuy nhiên, ConstrainedBox không tự bảo đảm phần tử luôn rộng tối thiểu 280 pixels nếu constraints từ Widget cha không cho phép điều đó. Nếu vùng hiển thị hẹp hơn giới hạn tối thiểu, các ràng buộc có thể xung đột và gây lỗi layout.
Vì vậy, khi áp dụng giới hạn kích thước cho màn hình nhỏ, cần bảo đảm các giá trị tối thiểu tương thích với không gian thực tế.
2.4. Kết hợp ConstrainedBox với Center
Một mẫu bố cục phổ biến là giới hạn chiều rộng nội dung rồi căn giữa trên màn hình lớn.
Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 480,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Đăng nhập',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
const TextField(
decoration: InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const TextField(
obscureText: true,
decoration: InputDecoration(
labelText: 'Mật khẩu',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
child: const Text('Đăng nhập'),
),
),
],
),
),
),
),
)
Trong trường hợp này, Center giúp căn giữa nội dung trong vùng có không gian dư thừa, còn ConstrainedBox giới hạn chiều rộng tối đa. Padding tạo khoảng cách giữa biểu mẫu và vùng bên ngoài.
Mẫu bố cục này thích hợp cho màn hình đăng nhập, đăng ký, đặt lại mật khẩu, nhập mã xác thực và các biểu mẫu cần duy trì chiều rộng dễ đọc.
2.5. Phân biệt ConstrainedBox, SizedBox và Container
| Widget | Công dụng chính |
|---|---|
ConstrainedBox |
Áp đặt giới hạn kích thước tối thiểu và tối đa |
SizedBox |
Đặt kích thước cụ thể hoặc tạo khoảng trống |
Container |
Kết hợp nhiều chức năng như padding, màu nền, trang trí, alignment và constraints |
Không nên sử dụng SizedBox với kích thước cố định cho mọi thiết bị nếu điều đó có thể làm nội dung tràn khỏi vùng hiển thị. Trong những trường hợp cần linh hoạt theo kích thước màn hình, ConstrainedBox hoặc LayoutBuilder thường thích hợp hơn.
2.6. Những lỗi thường gặp khi sử dụng BoxConstraints
Một số lỗi thường xuất hiện khi xây dựng bố cục phức tạp:
-
Đặt
minWidthlớn hơn chiều rộng mà Widget cha có thể cung cấp. -
Sử dụng
Expandedbên trong một vùng có chiều rộng hoặc chiều cao không giới hạn theo trục tương ứng. -
Đặt kích thước cố định cho nội dung có văn bản thay đổi theo ngôn ngữ.
-
Kết hợp nhiều lớp constraints mà không xác định rõ Widget nào đang kiểm soát kích thước.
-
Giả định rằng
maxWidthsẽ luôn làm Widget rộng đến mức tối đa, trong khi nó chỉ xác định giới hạn trên.
Hiểu rõ ConstrainedBox và BoxConstraints giúp lập trình viên kiểm soát bố cục chính xác hơn, hạn chế lỗi tràn giao diện và xây dựng các thành phần có thể tái sử dụng trên nhiều loại màn hình.
3. Center và Max-Width Content
3.1. Căn giữa nội dung trong giao diện Responsive
Center là Widget dùng để căn giữa Widget con trong không gian mà Widget cha cung cấp. Trong Responsive Design, Center thường được kết hợp với ConstrainedBox để tạo ra những vùng nội dung có chiều rộng giới hạn và vị trí cân đối.
Trên điện thoại, nội dung thường chiếm gần hết chiều rộng khả dụng sau khi trừ khoảng cách hai bên. Trên desktop, nếu kéo dài nội dung qua toàn bộ màn hình, văn bản có thể trở nên khó đọc, biểu mẫu mất cân đối và các thành phần giao diện bị phân tán.
Ví dụ, một màn hình desktop có chiều rộng 1920 logical pixels không nhất thiết phải hiển thị biểu mẫu với chiều rộng tương đương 1920 pixels. Thay vào đó, biểu mẫu có thể được giới hạn trong khoảng 400–600 pixels và đặt ở chính giữa màn hình.
3.2. Khái niệm Max-Width Content
Max-Width Content là nguyên tắc giới hạn chiều rộng tối đa của vùng nội dung. Đây là kỹ thuật phổ biến trong thiết kế website, Dashboard, ứng dụng thương mại điện tử và ứng dụng quản lý dữ liệu.
Mục tiêu của Max-Width Content không phải thu nhỏ toàn bộ giao diện mà là kiểm soát chiều rộng của những vùng cần duy trì tính dễ đọc.
Ví dụ:
-
Bài viết có thể giới hạn chiều rộng để tránh mỗi dòng chứa quá nhiều ký tự.
-
Biểu mẫu có thể giới hạn chiều rộng để các trường nhập liệu không quá dài.
-
Trang sản phẩm có thể sử dụng vùng nội dung rộng hơn biểu mẫu đăng nhập.
-
Dashboard có thể sử dụng nhiều cột nhưng vẫn giới hạn tổng chiều rộng của vùng hiển thị.
-
Trang cài đặt có thể đặt nội dung ở giữa màn hình, trong khi thanh điều hướng vẫn chiếm toàn bộ chiều cao hoặc chiều rộng cần thiết.
Giá trị maxWidth nên được lựa chọn theo đặc điểm nội dung, không nên sử dụng một con số cố định cho tất cả các thành phần trong ứng dụng.
3.3. Xây dựng Max-Width Content bằng Flutter
import 'package:flutter/material.dart';
class ResponsiveContentPage extends StatelessWidget {
const ResponsiveContentPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Trang nội dung'),
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 1100,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 32,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tổng quan hệ thống',
style: Theme.of(context)
.textTheme
.headlineMedium,
),
const SizedBox(height: 16),
const Text(
'Khu vực này chứa thông tin tổng quan, '
'các chỉ số và dữ liệu quan trọng của ứng dụng.',
),
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Nội dung Dashboard',
),
),
),
],
),
),
),
),
);
}
}
Trong ví dụ này, ConstrainedBox giới hạn chiều rộng tối đa của vùng nội dung ở mức 1100 pixels. Center căn giữa vùng đó khi màn hình rộng hơn giới hạn. Padding tạo khoảng cách bên trong để nội dung không áp sát cạnh.
Trên màn hình nhỏ, nội dung vẫn có thể co lại theo constraints của Widget cha. Tuy nhiên, khi xây dựng ứng dụng thực tế, cần bảo đảm phần padding không chiếm quá nhiều không gian khả dụng trên điện thoại.
3.4. Phân biệt chiều rộng màn hình và chiều rộng nội dung
Hai giá trị này có vai trò khác nhau:
-
Screen Width: Chiều rộng của màn hình hoặc cửa sổ ứng dụng.
-
Available Width: Chiều rộng thực tế mà một Widget được phép sử dụng sau khi xét đến bố cục cha, padding và các vùng lân cận.
-
Content Max Width: Chiều rộng tối đa mà vùng nội dung được phép đạt tới.
Trong một Dashboard có thanh điều hướng bên trái rộng 250 pixels, chiều rộng khả dụng cho nội dung chính không còn bằng toàn bộ chiều rộng cửa sổ. Vì vậy, không nên luôn lấy kích thước màn hình làm cơ sở duy nhất để xác định kích thước Widget.
LayoutBuilder có thể được sử dụng để đọc constraints tại vị trí cụ thể trong Widget Tree, từ đó tính toán chiều rộng phù hợp với không gian thực tế.
3.5. Nguyên tắc thiết kế Max-Width Content
Khi triển khai kỹ thuật này, cần chú ý:
-
Giới hạn chiều rộng dựa trên mục đích của từng vùng nội dung.
-
Sử dụng
Centerkhi vùng nội dung cần nằm giữa màn hình. -
Giữ khoảng cách hai bên phù hợp với màn hình nhỏ.
-
Không giới hạn quá chặt những giao diện cần hiển thị bảng dữ liệu rộng.
-
Có thể sử dụng các giới hạn khác nhau cho bài viết, biểu mẫu, danh sách và Dashboard.
-
Kết hợp với
SingleChildScrollViewkhi nội dung có thể vượt quá chiều cao khả dụng. -
Tránh đặt chiều rộng cố định cho toàn bộ trang nếu ứng dụng cần hỗ trợ nhiều kích thước cửa sổ.
Center và Max-Width Content giúp giao diện desktop cân đối hơn, đồng thời duy trì khả năng sử dụng trên điện thoại và máy tính bảng.
4. Orientation-Aware Layout
4.1. Khái niệm Orientation-Aware Layout
Orientation-Aware Layout là phương pháp điều chỉnh bố cục dựa trên hướng hiển thị của vùng giao diện. Hai hướng phổ biến là:
-
Portrait: Hướng dọc, chiều cao lớn hơn chiều rộng.
-
Landscape: Hướng ngang, chiều rộng lớn hơn chiều cao.
Trong Flutter, có thể sử dụng OrientationBuilder để nhận biết hướng của vùng bố cục hoặc sử dụng LayoutBuilder để đọc chiều rộng và chiều cao khả dụng.
Orientation-Aware Layout thường được sử dụng trong ứng dụng xem video, đọc tài liệu, chỉnh sửa hình ảnh, chơi game, Dashboard và ứng dụng có nội dung cần thay đổi cách sắp xếp theo hướng màn hình.
4.2. Cơ chế hoạt động của OrientationBuilder
OrientationBuilder cung cấp thông tin về hướng của vùng bố cục thông qua tham số orientation trong callback builder.
OrientationBuilder(
builder: (context, orientation) {
if (orientation == Orientation.portrait) {
return const Column(
children: [
Expanded(
child: ColoredBox(
color: Colors.blue,
child: Center(
child: Text('Nội dung'),
),
),
),
Expanded(
child: ColoredBox(
color: Colors.orange,
child: Center(
child: Text('Chi tiết'),
),
),
),
],
);
}
return const Row(
children: [
Expanded(
child: ColoredBox(
color: Colors.blue,
child: Center(
child: Text('Nội dung'),
),
),
),
Expanded(
child: ColoredBox(
color: Colors.orange,
child: Center(
child: Text('Chi tiết'),
),
),
),
],
);
},
)
Trong ví dụ trên, giao diện sử dụng Column ở hướng dọc và Row ở hướng ngang. Hai vùng nội dung được sắp xếp theo chiều khác nhau để tận dụng không gian hiển thị.
Expanded chia phần không gian khả dụng cho các thành phần con. Tuy nhiên, các Widget này phải nằm trong bố cục có giới hạn phù hợp theo trục mở rộng; nếu không, Flutter có thể báo lỗi về constraints.
4.3. OrientationBuilder và LayoutBuilder
Hai Widget có mục đích liên quan nhưng không hoàn toàn giống nhau.
| Tiêu chí | OrientationBuilder | LayoutBuilder |
|---|---|---|
| Thông tin chính | Hướng dọc hoặc ngang | Constraints tại vị trí bố cục |
| Dữ liệu cung cấp | Orientation |
BoxConstraints |
| Phù hợp với | Thay đổi bố cục theo hướng | Thay đổi bố cục theo kích thước |
| Độ linh hoạt | Dựa trên hướng | Dựa trên chiều rộng và chiều cao khả dụng |
Một màn hình ngang không nhất thiết phải có bố cục dành riêng cho desktop. Ví dụ, điện thoại xoay ngang có thể chỉ rộng 850 pixels, trong khi cửa sổ desktop có thể rộng 1400 pixels.
Vì vậy, trong các giao diện cần thích nghi chính xác, nên xem xét cả hướng và kích thước thực tế. Nếu chỉ cần thay đổi số cột, chiều rộng Card hoặc cấu trúc thanh điều hướng, LayoutBuilder thường là lựa chọn phù hợp hơn.
4.4. Ví dụ thiết kế giao diện thích nghi theo hướng và chiều rộng
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 700;
final image = Container(
height: isWide ? 280 : 200,
color: Colors.blueGrey,
child: const Center(
child: Icon(
Icons.image,
size: 64,
color: Colors.white,
),
),
);
final details = const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Thông tin sản phẩm',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 12),
Text(
'Tên sản phẩm, mô tả, giá bán '
'và các thông tin liên quan.',
),
],
),
);
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: image),
Expanded(child: details),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
image,
details,
],
);
},
)
Đây là một ví dụ về việc thay đổi cấu trúc bố cục dựa trên chiều rộng thay vì chỉ dựa vào hướng màn hình. Khi vùng hiển thị đủ rộng, hình ảnh và thông tin sản phẩm nằm cạnh nhau. Khi không gian bị giới hạn, các thành phần được xếp theo chiều dọc.
4.5. Những nguyên tắc cần tuân thủ
-
Không giả định thiết bị có một hướng hiển thị cố định.
-
Không xem hướng ngang đồng nghĩa với tablet hoặc desktop.
-
Kiểm tra khả năng hiển thị khi bàn phím ảo xuất hiện.
-
Tránh bố cục có chiều cao cố định nếu nội dung có thể tăng theo kích thước chữ.
-
Đảm bảo các nút, trường nhập liệu và thao tác chính vẫn dễ sử dụng ở cả hai hướng.
-
Kiểm tra trên thiết bị có tỷ lệ màn hình khác nhau.
Orientation-Aware Layout giúp ứng dụng tận dụng không gian tốt hơn, nhưng nên được kết hợp với Responsive Design dựa trên constraints để tránh phụ thuộc quá nhiều vào hướng hiển thị.
5. Dynamic Window Resizing
5.1. Khái niệm Dynamic Window Resizing
Dynamic Window Resizing là khả năng thích nghi với việc thay đổi kích thước cửa sổ trong lúc ứng dụng đang chạy.
Kỹ thuật này đặc biệt quan trọng đối với Flutter trên desktop và web, nơi người dùng có thể kéo cạnh cửa sổ, thay đổi kích thước trình duyệt, bật chế độ toàn màn hình hoặc đặt nhiều ứng dụng cạnh nhau.
Trong môi trường desktop, kích thước vùng làm việc có thể thay đổi liên tục. Một ứng dụng ban đầu có chiều rộng 1400 pixels có thể bị thu nhỏ còn 900 pixels khi người dùng mở thêm một cửa sổ. Nếu bố cục chỉ được thiết kế cho kích thước ban đầu, nội dung có thể bị tràn hoặc trở nên khó sử dụng.
5.2. Cơ chế cập nhật bố cục
Flutter sử dụng hệ thống layout để tính toán lại kích thước và vị trí của các thành phần khi constraints thay đổi.
Các Widget như LayoutBuilder, Expanded, Flexible, Wrap và GridView có thể hỗ trợ xây dựng bố cục thích nghi.
Ví dụ, một Dashboard có thể thay đổi từ bốn cột thành hai cột khi cửa sổ bị thu hẹp. Thanh điều hướng có thể chuyển từ dạng cố định sang dạng thu gọn, còn các Card có thể giảm số lượng hiển thị trên mỗi hàng.
Cần phân biệt giữa thay đổi kích thước cửa sổ và thay đổi dữ liệu. Việc cửa sổ rộng hơn không đồng nghĩa dữ liệu cần tải lại từ máy chủ. Thông thường, chỉ bố cục cần được tính toán lại; dữ liệu nên được quản lý độc lập để tránh các yêu cầu mạng không cần thiết.
5.3. Ví dụ Dashboard tự điều chỉnh theo chiều rộng
import 'package:flutter/material.dart';
class ResponsiveDashboard extends StatelessWidget {
const ResponsiveDashboard({super.key});
@override
Widget build(BuildContext context) {
final items = [
'Doanh thu',
'Đơn hàng',
'Khách hàng',
'Sản phẩm',
];
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
),
body: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final columns = width < 600
? 1
: width < 1000
? 2
: 4;
return GridView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.4,
),
itemBuilder: (context, index) {
return Card(
child: Center(
child: Text(
items[index],
style: const TextStyle(
fontSize: 18,
),
),
),
);
},
);
},
),
);
}
}
Khi cửa sổ thay đổi kích thước, LayoutBuilder có thể nhận constraints mới và lựa chọn số cột tương ứng. Flutter sẽ bố trí lại Grid theo kích thước mới.
Các ngưỡng trong ví dụ chỉ mang tính minh họa. Khi triển khai ứng dụng thực tế, cần thử nghiệm với kích thước cửa sổ nhỏ nhất mà ứng dụng hỗ trợ, độ dài nội dung và yêu cầu sử dụng của từng loại màn hình.
5.4. Quản lý trạng thái khi cửa sổ thay đổi kích thước
Responsive Layout không chỉ liên quan đến việc sắp xếp Widget. Một ứng dụng hoàn chỉnh còn cần bảo toàn trạng thái người dùng trong quá trình bố cục thay đổi.
Ví dụ:
-
Nội dung đang nhập trong biểu mẫu không bị mất khi đổi kích thước.
-
Vị trí cuộn của danh sách không bị đặt lại ngoài ý muốn.
-
Tab đang mở được giữ nguyên.
-
Dữ liệu Dashboard không bị tải lại không cần thiết.
-
Các thao tác đang thực hiện không bị gián đoạn chỉ vì bố cục chuyển từ một cột sang hai cột.
Một nguyên tắc quan trọng là tách logic dữ liệu và trạng thái khỏi logic hiển thị. Có thể sử dụng StatefulWidget, Provider, Riverpod hoặc BLoC tùy theo kiến trúc ứng dụng.
Không nên khởi tạo lại toàn bộ trạng thái bên trong callback builder chỉ vì constraints thay đổi. Những đối tượng có vòng đời riêng như TextEditingController, FocusNode và ScrollController cần được quản lý đúng cách.
5.5. Kiểm thử Dynamic Window Resizing
Khi kiểm thử, cần thay đổi kích thước cửa sổ qua nhiều mức, thay vì chỉ chạy ứng dụng ở kích thước mặc định.
Các tình huống nên kiểm tra:
-
Cửa sổ rộng và cao.
-
Cửa sổ hẹp nhưng vẫn đủ không gian cho các thao tác chính.
-
Cửa sổ có chiều cao thấp.
-
Cửa sổ bị thu nhỏ khi thanh điều hướng vẫn đang mở.
-
Biểu mẫu có nội dung dài.
-
Bảng dữ liệu có nhiều cột.
-
Văn bản tăng kích thước theo cài đặt trợ năng.
Ngoài việc kiểm tra giao diện có bị tràn hay không, cần xác nhận các thao tác vẫn hoạt động chính xác. Một giao diện có thể không xuất hiện lỗi overflow nhưng vẫn khó sử dụng nếu nút quan trọng bị đẩy ra ngoài vùng nhìn thấy.
Dynamic Window Resizing giúp ứng dụng Flutter trở nên linh hoạt hơn trong môi trường đa cửa sổ và nhiều kích thước hiển thị.
6. Split View và Multi-Window Layout
6.1. Khái niệm Split View
Split View là cách tổ chức giao diện thành nhiều vùng hiển thị đồng thời trong cùng một cửa sổ hoặc vùng làm việc. Thay vì chuyển đổi qua lại giữa các màn hình, người dùng có thể quan sát danh sách, nội dung chi tiết và các thao tác liên quan cùng lúc.
Ví dụ phổ biến là giao diện email với danh sách thư ở bên trái và nội dung thư ở bên phải. Những ứng dụng quản lý sản phẩm có thể hiển thị danh mục sản phẩm ở một vùng và thông tin chi tiết ở vùng còn lại.
Trong Flutter, Split View thường được xây dựng bằng Row, Column, Expanded, Flexible và LayoutBuilder. Với các ứng dụng desktop phức tạp, có thể bổ sung khả năng điều chỉnh kích thước từng vùng, quản lý trạng thái lựa chọn và lưu cấu hình bố cục.
6.2. Khái niệm Multi-Window Layout
Multi-Window Layout là cách thiết kế ứng dụng có thể hoạt động trong môi trường nhiều cửa sổ hoặc nhiều vùng ứng dụng cùng tồn tại.
Cần phân biệt hai khái niệm:
-
Split View: Chia không gian hiển thị của một giao diện thành nhiều vùng nội dung.
-
Multi-Window: Cho phép ứng dụng hoặc nền tảng làm việc với nhiều cửa sổ riêng biệt, tùy theo hệ điều hành và khả năng triển khai.
Việc sắp xếp hai bảng nội dung bằng Row không tự động tạo ra hai cửa sổ độc lập. Nó chỉ tạo ra hai vùng trong cùng một cây Widget.
Khả năng quản lý cửa sổ độc lập phụ thuộc vào nền tảng, cấu hình ứng dụng và các API hoặc giải pháp hỗ trợ tương ứng. Vì vậy, khi xây dựng ứng dụng Flutter đa nền tảng, cần xác định rõ yêu cầu: chỉ cần chia vùng giao diện hay thực sự cần mở nhiều cửa sổ riêng biệt.
6.3. Xác định không gian ứng dụng thực tế
Khi triển khai Split View, không nên chỉ dựa vào kích thước tổng của thiết bị. Chiều rộng khả dụng có thể bị giảm bởi:
-
Thanh điều hướng cố định.
-
Sidebar.
-
Thanh công cụ.
-
Vùng hiển thị của một ứng dụng khác.
-
Cửa sổ trình duyệt hoặc cửa sổ desktop đã bị thu hẹp.
-
Padding và các khoảng cách nội bộ.
Một ứng dụng chạy trên màn hình rộng có thể vẫn cần bố cục một cột nếu cửa sổ của ứng dụng chỉ chiếm một phần nhỏ màn hình.
Do đó, LayoutBuilder là công cụ hữu ích để quyết định khi nào nên sử dụng Split View. Khi không gian không đủ, giao diện có thể quay về bố cục một cột hoặc chỉ hiển thị một vùng tại một thời điểm.
6.4. Xây dựng giao diện Master-Detail
Master-Detail là một dạng Split View trong đó vùng Master chứa danh sách các đối tượng, còn vùng Detail hiển thị thông tin chi tiết của đối tượng được chọn.
import 'package:flutter/material.dart';
class MasterDetailPage extends StatefulWidget {
const MasterDetailPage({super.key});
@override
State<MasterDetailPage> createState() =>
_MasterDetailPageState();
}
class _MasterDetailPageState
extends State<MasterDetailPage> {
int selectedIndex = 0;
final items = List.generate(
10,
(index) => 'Sản phẩm ${index + 1}',
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Quản lý sản phẩm'),
),
body: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 800;
final listPanel = ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
selected: selectedIndex == index,
title: Text(items[index]),
onTap: () {
setState(() {
selectedIndex = index;
});
},
);
},
);
final detailPanel = Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Chi tiết: ${items[selectedIndex]}',
style: Theme.of(context)
.textTheme
.headlineSmall,
),
),
);
if (isWide) {
return Row(
children: [
SizedBox(
width: 300,
child: listPanel,
),
const VerticalDivider(width: 1),
Expanded(
child: detailPanel,
),
],
);
}
return listPanel;
},
),
);
}
}
Trong ví dụ này, giao diện sử dụng ngưỡng 800 pixels để chuyển đổi giữa hai cách bố trí.
Trên màn hình rộng, danh sách sản phẩm nằm bên trái và thông tin chi tiết nằm bên phải. Trên màn hình hẹp, ứng dụng chỉ hiển thị danh sách để tránh làm mỗi vùng trở nên quá nhỏ.
Ví dụ tập trung minh họa cấu trúc bố cục. Trong ứng dụng thực tế, phiên bản dành cho màn hình nhỏ thường cần điều hướng sang màn hình chi tiết khi người dùng chọn một sản phẩm, đồng thời cung cấp thao tác quay lại danh sách.
6.5. Quản lý trạng thái lựa chọn giữa các vùng
Split View cần duy trì mối liên hệ giữa các vùng nội dung. Khi người dùng chọn một đối tượng trong danh sách, vùng chi tiết phải cập nhật tương ứng.
Những trạng thái thường cần quản lý gồm:
-
Đối tượng hiện đang được chọn.
-
Vị trí cuộn của danh sách.
-
Bộ lọc và từ khóa tìm kiếm.
-
Trạng thái mở rộng hoặc thu gọn của sidebar.
-
Dữ liệu đã tải của vùng chi tiết.
-
Trạng thái chỉnh sửa chưa lưu.
Đối với ứng dụng lớn, nên tách dữ liệu và trạng thái nghiệp vụ khỏi Widget hiển thị. Điều này giúp tránh việc mất dữ liệu khi thay đổi cấu trúc giao diện hoặc chuyển giữa chế độ Split View và bố cục một cột.
6.6. Khả năng thích nghi với không gian hạn chế
Một lỗi phổ biến là cố gắng hiển thị đồng thời tất cả vùng nội dung trên màn hình nhỏ. Nếu danh sách, bộ lọc và chi tiết đều cần chiều rộng lớn, việc đặt chúng cạnh nhau có thể làm giảm khả năng đọc và thao tác.
Giải pháp phù hợp là xác định các chức năng quan trọng nhất tại từng kích thước:
-
Trên màn hình nhỏ, ưu tiên nội dung chính và điều hướng rõ ràng.
-
Trên màn hình trung bình, có thể hiển thị danh sách và chi tiết nếu đủ không gian.
-
Trên màn hình lớn, có thể bổ sung sidebar, vùng chi tiết và bảng công cụ.
-
Trên cửa sổ rất hẹp, cần bảo đảm các nút thao tác và nội dung không bị cắt.
6.7. Kiểm thử Split View và Multi-Window
Quá trình kiểm thử cần xác nhận cả tính đúng đắn của bố cục lẫn khả năng tương tác.
Các tình huống nên kiểm tra gồm:
-
Chuyển đổi giữa màn hình hẹp và rộng.
-
Chọn một đối tượng khác trong danh sách.
-
Thu nhỏ cửa sổ khi vùng chi tiết đang mở.
-
Mở bàn phím ảo khi chỉnh sửa dữ liệu.
-
Cuộn danh sách dài.
-
Giữ trạng thái chỉnh sửa khi bố cục thay đổi.
-
Kiểm tra điều hướng trên điện thoại và máy tính bảng.
-
Xác minh hành vi cửa sổ riêng biệt nếu ứng dụng có yêu cầu Multi-Window thực sự.
Split View và Multi-Window Layout giúp ứng dụng Flutter hỗ trợ những quy trình làm việc phức tạp hơn. Khi kết hợp với Responsive Layout và quản lý trạng thái phù hợp, ứng dụng có thể cung cấp trải nghiệm nhất quán trên nhiều loại thiết bị và môi trường làm việc.
7. Tổng kết Responsive nâng cao trong Flutter
Responsive nâng cao tập trung vào khả năng tổ chức và điều chỉnh giao diện theo không gian hiển thị thực tế. Các kỹ thuật trong nhóm này bổ sung cho những kiến thức nền tảng về Row, Column, Expanded, Flexible, Padding, Container và LayoutBuilder.
-
Responsive Columns và Grid System: Tổ chức nội dung thành hệ thống cột, kiểm soát gutter và số lượng Card.
-
ConstrainedBox và BoxConstraints: Giới hạn kích thước để tránh nội dung quá rộng hoặc không phù hợp với vùng hiển thị.
-
Center và Max-Width Content: Căn giữa nội dung và duy trì chiều rộng dễ đọc trên màn hình lớn.
-
Orientation-Aware Layout: Điều chỉnh cách sắp xếp theo hướng hiển thị, kết hợp với chiều rộng thực tế.
-
Dynamic Window Resizing: Xử lý việc cửa sổ thay đổi kích thước trong khi ứng dụng đang hoạt động.
-
Split View và Multi-Window Layout: Tổ chức nhiều vùng nội dung và hỗ trợ quy trình làm việc đa vùng.
Nguyên tắc quan trọng nhất là thiết kế dựa trên không gian khả dụng và nhu cầu của nội dung, thay vì chỉ phân loại giao diện theo tên thiết bị. Khi áp dụng đúng các nguyên tắc này, lập trình viên có thể xây dựng giao diện Flutter linh hoạt, dễ bảo trì và hạn chế lỗi bố cục trên điện thoại, máy tính bảng, desktop và web.








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




