Hệ thống Layout chuyên sâu trong Flutter

1. BoxConstraints và quy tắc Layout

1.1. Khái niệm BoxConstraints

Trong Flutter, BoxConstraints là cơ chế xác định phạm vi kích thước mà một Widget được phép sử dụng khi thực hiện quá trình layout. Thay vì mỗi Widget tự quyết định kích thước theo ý muốn, Flutter sử dụng các ràng buộc được truyền từ Widget cha xuống Widget con để xác định kích thước cuối cùng.

BoxConstraints được sử dụng phổ biến trong hệ thống layout một chiều và hai chiều của Flutter, đặc biệt với những Widget như Container, SizedBox, Padding, Center, Row, Column, Expanded và Flexible.

Một đối tượng BoxConstraints bao gồm bốn thuộc tính chính:

  • minWidth: Chiều rộng tối thiểu mà Widget phải đáp ứng.

  • maxWidth: Chiều rộng tối đa mà Widget được phép sử dụng.

  • minHeight: Chiều cao tối thiểu mà Widget phải đáp ứng.

  • maxHeight: Chiều cao tối đa mà Widget được phép sử dụng.

Ví dụ, một Widget nhận constraints với chiều rộng tối thiểu là 100 và tối đa là 300 thì chiều rộng cuối cùng phải nằm trong khoảng từ 100 đến 300 logical pixels.

BoxConstraints(
  minWidth: 100,
  maxWidth: 300,
  minHeight: 50,
  maxHeight: 200,
)

Trong ví dụ trên, Widget không nhất thiết phải có kích thước 300 × 200. Nó có thể nhỏ hơn, miễn là kích thước cuối cùng tuân thủ các giới hạn mà hệ thống layout quy định.

Cần phân biệt BoxConstraints với các thuộc tính như width và height. Hai thuộc tính này thể hiện kích thước mong muốn được khai báo cho một Widget, còn BoxConstraints quy định phạm vi kích thước mà Widget thực sự được phép sử dụng.

1.2. Các dạng constraints phổ biến

Trong Flutter, constraints thường được chia thành những dạng sau.

Loose constraints

Loose constraints cho phép Widget lựa chọn kích thước nhỏ hơn giới hạn tối đa. Giá trị tối thiểu thường bằng 0.

BoxConstraints(
  minWidth: 0,
  maxWidth: 300,
  minHeight: 0,
  maxHeight: 200,
)

Widget có thể lựa chọn chiều rộng 100, 200 hoặc 300 tùy theo nội dung và cách triển khai layout.

Một số Widget như Center có thể làm cho constraints truyền xuống con trở nên lỏng hơn, giúp Widget con không cần chiếm toàn bộ không gian của Widget cha.

Tight constraints

Tight constraints yêu cầu Widget sử dụng một kích thước cụ thể. Trong trường hợp này, giá trị tối thiểu bằng giá trị tối đa.

BoxConstraints.tight(
  const Size(200, 100),
)

Widget phải có chiều rộng 200 và chiều cao 100 logical pixels nếu các constraints này được áp dụng trực tiếp.

Unbounded constraints

Unbounded constraints là constraints không có giới hạn hữu hạn theo một trục. Ví dụ, maxHeight có thể bằng double.infinity.

BoxConstraints(
  minHeight: 0,
  maxHeight: double.infinity,
)

Dạng constraints này thường xuất hiện trong các vùng cuộn hoặc khi một Widget được bố trí bên trong một cấu trúc cho phép phát triển theo chiều dài nội dung.

Unbounded constraints không phải lúc nào cũng là lỗi. Vấn đề chỉ xảy ra khi một Widget cần giới hạn hữu hạn để xác định kích thước nhưng lại nhận được một giới hạn không hữu hạn.

1.3. Quy trình xác định kích thước Widget

Flutter áp dụng quy trình layout dựa trên constraints, kích thước và vị trí. Quy tắc quan trọng thường được diễn đạt như sau:

Constraints go down, sizes go up, parent sets position.

Có thể hiểu quy trình này qua ba bước:

  1. Widget cha truyền constraints xuống Widget con.

  2. Widget con xác định kích thước phù hợp với constraints và nội dung.

  3. Widget cha nhận kích thước của con và quyết định vị trí đặt con trong vùng layout của mình.

Ví dụ:

Center(
  child: Container(
    width: 200,
    height: 100,
    color: Colors.blue,
  ),
)

Trong trường hợp này, Center nhận constraints từ Widget cha và bố trí Container ở giữa vùng không gian mà nó có thể sử dụng. Container mong muốn có kích thước 200 × 100, miễn là constraints cho phép.

Nếu Container được đặt trong một vùng có chiều rộng tối đa chỉ 150 pixels và nhận constraints tương ứng, nó không thể mặc nhiên giữ chiều rộng 200. Kích thước thực tế còn phụ thuộc vào cách constraints được truyền và xử lý qua từng Widget.

1.4. Mối quan hệ giữa width, height và constraints

Khai báo width và height không đồng nghĩa với việc Widget luôn có được kích thước đó. Kích thước thực tế phụ thuộc vào constraints do Widget cha truyền xuống.

Ví dụ:

SizedBox(
  width: 300,
  height: 100,
  child: Container(
    width: 500,
    height: 200,
    color: Colors.red,
  ),
)

Trong ví dụ này, SizedBox áp dụng constraints chặt chẽ cho phần tử con. Vì vậy, Container không thể tự mở rộng thành 500 × 200 chỉ bằng cách khai báo các thuộc tính width và height.

Đây là nguyên tắc quan trọng khi thiết kế giao diện: thuộc tính kích thước thể hiện mong muốn của Widget, nhưng constraints mới xác định giới hạn layout thực tế.

1.5. Lỗi RenderFlex overflowed

RenderFlex overflowed là lỗi phổ biến khi sử dụng Row, Column hoặc Flex. Lỗi xuất hiện khi tổng kích thước của các phần tử con vượt quá không gian mà Flex được phép sử dụng theo trục chính.

Ví dụ:

Row(
  children: [
    Container(width: 200, height: 100, color: Colors.red),
    Container(width: 200, height: 100, color: Colors.blue),
    Container(width: 200, height: 100, color: Colors.green),
  ],
)

Nếu chiều rộng khả dụng của màn hình nhỏ hơn tổng chiều rộng 600 pixels của ba Container, Row có thể bị overflow.

Một cách khắc phục là sử dụng Expanded để phân chia không gian:

Row(
  children: [
    Expanded(
      child: Container(height: 100, color: Colors.red),
    ),
    Expanded(
      child: Container(height: 100, color: Colors.blue),
    ),
    Expanded(
      child: Container(height: 100, color: Colors.green),
    ),
  ],
)

Trong trường hợp này, ba phần tử được phân chia không gian chiều rộng khả dụng theo tỷ lệ bằng nhau.

Tuy nhiên, Expanded không phải giải pháp cho mọi tình huống. Nếu nội dung bên trong không thể co lại, cần cân nhắc Wrap, Flexible, cuộn ngang hoặc điều chỉnh cách bố trí giao diện.

1.6. Lỗi BoxConstraints forces an infinite width

Lỗi BoxConstraints forces an infinite width thường liên quan đến việc một Widget bị yêu cầu sử dụng chiều rộng vô hạn, trong khi hệ thống layout không thể xác định kích thước hữu hạn phù hợp.

Ví dụ về cấu trúc dễ gây ra vấn đề:

SingleChildScrollView(
  scrollDirection: Axis.horizontal,
  child: Container(
    width: double.infinity,
    height: 100,
    color: Colors.blue,
  ),
)

Vì cuộn theo chiều ngang cho phép nội dung mở rộng theo trục ngang, Widget con có thể nhận constraints không giới hạn hữu hạn theo chiều rộng. Trong khi đó, double.infinity yêu cầu Container sử dụng chiều rộng vô hạn.

Một cách sửa phù hợp là xác định chiều rộng cụ thể:

SingleChildScrollView(
  scrollDirection: Axis.horizontal,
  child: Container(
    width: 800,
    height: 100,
    color: Colors.blue,
  ),
)

Nếu mục tiêu là hiển thị nội dung có chiều rộng linh hoạt, cần chọn một cấu trúc layout bảo đảm Widget nhận được constraints hữu hạn khi cần thiết.

1.7. Ứng dụng BoxConstraints trong dự án thực tế

Trong ứng dụng bán hàng, BoxConstraints giúp kiểm soát kích thước thẻ sản phẩm, hình ảnh, khu vực mô tả và các thành phần giao diện trên nhiều thiết bị.

Ví dụ:

ConstrainedBox(
  constraints: const BoxConstraints(
    minWidth: 120,
    maxWidth: 280,
    minHeight: 180,
  ),
  child: Card(
    child: Column(
      children: [
        const Icon(Icons.phone_android, size: 64),
        const Text('Điện thoại thông minh'),
        const Text('Giá: 5.990.000đ'),
      ],
    ),
  ),
)

Widget trên giới hạn chiều rộng của thẻ sản phẩm trong khoảng 120–280 pixels và đặt chiều cao tối thiểu 180 pixels. Chiều cao thực tế có thể lớn hơn tùy nội dung và constraints mà Widget cha cung cấp.

Nhờ đó, giao diện có thể linh hoạt hơn so với việc gán cứng mọi kích thước.

2. Constraint Propagation

2.1. Khái niệm Constraint Propagation

Constraint Propagation là quá trình truyền và biến đổi các ràng buộc kích thước qua nhiều tầng Widget trong cây giao diện Flutter.

Khi một màn hình được xây dựng từ nhiều Widget lồng nhau, constraints không chỉ được truyền trực tiếp từ màn hình đến Widget cuối cùng. Mỗi Widget cha có thể giữ nguyên, thu hẹp hoặc thay đổi constraints trước khi truyền xuống Widget con.

Ví dụ về cấu trúc:

Scaffold
└── Center
    └── Padding
        └── SizedBox
            └── Container
                └── Text

Trong cấu trúc trên:

  • Scaffold cung cấp không gian layout cho phần nội dung.

  • Center bố trí nội dung trong vùng có thể sử dụng.

  • Padding dành một phần không gian cho khoảng đệm.

  • SizedBox giới hạn kích thước của khu vực con.

  • Container xử lý các thuộc tính kích thước, trang trí và nội dung.

  • Text tính toán kích thước phù hợp với nội dung và giới hạn nhận được.

Quá trình này giúp Flutter duy trì một hệ thống layout thống nhất, thay vì cho phép mỗi Widget tự chọn kích thước hoàn toàn độc lập.

2.2. Quá trình truyền constraints từ cha xuống con

Mỗi RenderObject trong hệ thống layout có trách nhiệm thực hiện thuật toán bố trí riêng. Với các RenderBox, constraints thường được truyền qua phương thức layout đến RenderObject con.

Một Widget trung gian có thể biến đổi constraints trước khi truyền xuống. Chẳng hạn, Padding phải dành không gian cho phần đệm nên kích thước khả dụng của nội dung con sẽ giảm đi.

Ví dụ:

Container(
  width: 300,
  height: 200,
  padding: const EdgeInsets.all(20),
  color: Colors.blue,
  child: Container(
    color: Colors.orange,
    child: const Text('Flutter Layout'),
  ),
)

Nếu Container bên ngoài thực sự được bố trí với kích thước 300 × 200 và không có các yếu tố khác làm thay đổi cách layout, phần nội dung bên trong sẽ có tối đa khoảng 260 × 160 pixels vì phần đệm chiếm 20 pixels ở mỗi cạnh.

Đây là ví dụ về việc constraints bị điều chỉnh trước khi đến Widget con.

2.3. Vai trò của Widget trung gian

Widget trung gian không chỉ dùng để trang trí giao diện. Chúng có thể đóng vai trò quan trọng trong việc điều chỉnh constraints.

Một số ví dụ tiêu biểu:

  • Center: Có thể cho phép con chọn kích thước nhỏ hơn vùng chứa.

  • Padding: Giảm không gian dành cho con theo các cạnh được khai báo.

  • SizedBox: Đặt giới hạn kích thước hoặc yêu cầu kích thước cụ thể.

  • ConstrainedBox: Áp dụng thêm giới hạn kích thước.

  • AspectRatio: Tính toán kích thước dựa trên tỷ lệ khung hình.

  • Expanded: Yêu cầu con chiếm phần không gian còn lại trong Flex theo tỷ lệ flex.

  • Flexible: Cho phép con sử dụng không gian trong Flex theo quy tắc phù hợp với FlexFit.

Việc hiểu vai trò của các Widget trung gian giúp lập trình viên tránh sử dụng quá nhiều Container chỉ để cố gắng điều chỉnh kích thước.

2.4. Thứ tự Widget ảnh hưởng đến kết quả layout

Hai cấu trúc có vẻ tương tự nhau nhưng có thể tạo ra kết quả khác nhau do cách constraints được truyền qua từng Widget.

Ví dụ thứ nhất:

Center(
  child: SizedBox(
    width: 200,
    height: 100,
    child: Container(color: Colors.blue),
  ),
)

Ví dụ thứ hai:

SizedBox(
  width: 200,
  height: 100,
  child: Center(
    child: Container(
      width: 100,
      height: 50,
      color: Colors.blue,
    ),
  ),
)

Ở ví dụ thứ nhất, SizedBox xác định khu vực 200 × 100 và Center bố trí khu vực đó trong không gian của nó.

Ở ví dụ thứ hai, vùng bên ngoài có kích thước 200 × 100. Center cho phép Container bên trong sử dụng kích thước nhỏ hơn, nên Container có thể giữ kích thước 100 × 50 và được căn giữa.

Sự khác biệt này cho thấy thứ tự kết hợp Widget có thể ảnh hưởng trực tiếp đến constraints và kết quả hiển thị.

2.5. Quan hệ giữa Constraint Propagation và RenderObject

Flutter có ba cấu trúc quan trọng: Widget Tree, Element Tree và RenderObject Tree.

Widget mô tả cấu hình giao diện; Element quản lý vị trí của cấu hình trong cây và liên kết với vòng đời; RenderObject thực hiện các nhiệm vụ layout, painting và hit testing tùy loại.

Trong hệ thống RenderBox, constraints được truyền trong quá trình layout và kích thước của RenderObject được xác định dựa trên các ràng buộc này.

Khi dữ liệu hoặc trạng thái thay đổi, Flutter có thể cập nhật cấu hình Widget và đánh dấu các phần cần layout lại. Những thay đổi không nhất thiết khiến toàn bộ giao diện phải thực hiện lại mọi công việc, bởi hệ thống sử dụng cơ chế dirty marking và tối ưu quá trình cập nhật.

2.6. Phương pháp kiểm tra Constraint Propagation

Khi Widget không hiển thị đúng kích thước mong muốn, có thể sử dụng LayoutBuilder để quan sát constraints tại một vị trí trong cây Widget.

LayoutBuilder(
  builder: (context, constraints) {
    return Text(
      'Max width: ${constraints.maxWidth}\n'
      'Max height: ${constraints.maxHeight}',
    );
  },
)

Đoạn code trên hiển thị giới hạn chiều rộng và chiều cao mà LayoutBuilder nhận được tại vị trí đó.

Ngoài ra, Flutter DevTools và Flutter Inspector giúp kiểm tra cây Widget, kích thước, vị trí và các vấn đề layout. Kết hợp các công cụ này giúp xác định chính xác tầng Widget nào đang áp đặt giới hạn không phù hợp.

3. Flex Layout Internals

3.1. Tổng quan hệ thống Flex

Flex là hệ thống layout trong Flutter dùng để sắp xếp các Widget con theo một trục chính. Hai Widget phổ biến được xây dựng trên cơ chế này là Row và Column.

  • Row: Sắp xếp các Widget con theo chiều ngang.

  • Column: Sắp xếp các Widget con theo chiều dọc.

  • Flex: Cho phép lựa chọn trục bố trí thông qua thuộc tính direction.

Flex không chỉ đặt các phần tử cạnh nhau. Nó còn phải tính toán kích thước của từng phần tử, phân phối không gian còn lại, căn chỉnh vị trí và xử lý các trường hợp thiếu không gian.

Ví dụ:

Row(
  children: const [
    Icon(Icons.home),
    SizedBox(width: 8),
    Text('Trang chủ'),
    Spacer(),
    Icon(Icons.notifications),
  ],
)

Trong ví dụ này, các phần tử được sắp xếp theo chiều ngang. Spacer chiếm phần không gian còn lại theo cơ chế flex, giúp biểu tượng thông báo được đẩy về phía bên phải nếu Row nhận đủ chiều rộng hữu hạn.

3.2. Trục chính và trục phụ

Mỗi Flex có hai trục quan trọng.

Main Axis – Trục chính

Đây là trục mà các phần tử được sắp xếp nối tiếp nhau.

  • Với Row, trục chính là chiều ngang.

  • Với Column, trục chính là chiều dọc.

Các thuộc tính như mainAxisAlignment và mainAxisSize điều khiển cách Flex sử dụng không gian trên trục chính.

Cross Axis – Trục phụ

Đây là trục vuông góc với trục chính.

  • Với Row, trục phụ là chiều dọc.

  • Với Column, trục phụ là chiều ngang.

crossAxisAlignment quyết định cách căn chỉnh các phần tử trên trục phụ.

Ví dụ:

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: const [
    Text('Tên sản phẩm'),
    Text('Điện thoại thông minh'),
    Text('Giá bán: 5.990.000đ'),
  ],
)

Ba dòng văn bản được căn theo phía bắt đầu của trục ngang. Trong giao diện có hướng đọc từ trái sang phải, điều này thường tương ứng với việc căn trái.

3.3. Quy trình layout bên trong Flex

Trong cách triển khai RenderFlex, quá trình layout có thể được khái quát qua các bước sau:

  1. Xác định constraints mà Flex nhận được.

  2. Layout các phần tử không có flex trước, theo quy tắc constraints của trục chính.

  3. Tính phần không gian còn lại trên trục chính.

  4. Phân phối không gian còn lại cho các phần tử có flex dựa trên hệ số flex.

  5. Layout các phần tử flex với constraints tương ứng.

  6. Xác định kích thước tổng thể của Flex.

  7. Phân phối khoảng trống và tính vị trí từng phần tử theo các thuộc tính căn chỉnh.

Quy trình cụ thể có thể có những khác biệt tùy constraints, mainAxisSize, crossAxisAlignment và loại FlexFit được sử dụng.

Ví dụ, với một Row rộng 600 pixels, nếu các phần tử cố định sử dụng tổng cộng 200 pixels thì còn 400 pixels để phân phối. Hai phần tử Expanded có hệ số flex lần lượt là 1 và 3 sẽ nhận phần không gian còn lại theo tỷ lệ 1:3, tương ứng 100 và 300 pixels.

3.4. Expanded và Flexible

Expanded và Flexible đều là Widget được sử dụng bên trong Row, Column hoặc Flex để kiểm soát việc sử dụng không gian theo trục chính.

Expanded

Expanded tương đương với việc sử dụng Flexible cùng FlexFit.tight. Widget con được yêu cầu sử dụng hết phần không gian đã phân bổ cho nó.

Row(
  children: [
    Expanded(
      child: Container(
        height: 100,
        color: Colors.blue,
      ),
    ),
    Expanded(
      child: Container(
        height: 100,
        color: Colors.orange,
      ),
    ),
  ],
)

Hai Container được chia đều phần chiều rộng khả dụng trong Row, nếu Row có không gian hữu hạn để phân phối.

Flexible

Flexible cho phép Widget con nằm trong phần không gian được phân bổ nhưng không nhất thiết phải chiếm hết phần đó khi sử dụng FlexFit.loose.

Row(
  children: [
    Flexible(
      child: Container(
        width: 100,
        height: 100,
        color: Colors.blue,
      ),
    ),
    Flexible(
      child: Container(
        width: 100,
        height: 100,
        color: Colors.orange,
      ),
    ),
  ],
)

Với FlexFit.loose, mỗi Container có thể sử dụng kích thước nhỏ hơn không gian tối đa được phân bổ. Kích thước cuối cùng vẫn phụ thuộc vào constraints và cách Widget con thực hiện layout.

Bảng so sánh:

Tiêu chí Expanded Flexible
Cơ chế Dùng FlexFit.tight Mặc định dùng FlexFit.loose
Không gian được phân bổ Phải sử dụng hết Có thể sử dụng ít hơn
Kích thước con Bị ràng buộc chặt theo trục chính Có thể linh hoạt theo trục chính
Trường hợp sử dụng Chia đều vùng nội dung, tạo vùng chiếm phần còn lại Nội dung có thể co giãn và không cần lấp đầy toàn bộ vùng

Cả hai đều cần được đặt trong một Flex phù hợp. Sử dụng chúng trong một cấu trúc không có giới hạn hữu hạn theo trục chính có thể gây lỗi layout.

3.5. FlexFit

FlexFit xác định cách phần không gian được phân bổ cho một Widget con có flex được áp dụng.

Có hai giá trị chính:

  • FlexFit.tight: Widget con phải sử dụng toàn bộ không gian được phân bổ trên trục chính.

  • FlexFit.loose: Widget con được phép sử dụng ít hơn không gian tối đa được phân bổ.

Ví dụ:

Row(
  children: [
    Flexible(
      fit: FlexFit.tight,
      flex: 2,
      child: Container(height: 80, color: Colors.blue),
    ),
    Flexible(
      fit: FlexFit.loose,
      flex: 1,
      child: Container(
        width: 80,
        height: 80,
        color: Colors.orange,
      ),
    ),
  ],
)

Phần tử đầu tiên nhận phần không gian được phân bổ với hệ số flex 2 và phải sử dụng hết không gian đó. Phần tử thứ hai có hệ số flex 1, nhưng được phép chọn kích thước nhỏ hơn giới hạn đã phân bổ.

3.6. MainAxisSize

MainAxisSize quyết định Flex cố gắng sử dụng bao nhiêu không gian theo trục chính.

Hai giá trị:

  • MainAxisSize.max: Flex cố gắng chiếm tối đa không gian khả dụng theo trục chính.

  • MainAxisSize.min: Flex cố gắng thu gọn kích thước theo tổng kích thước các phần tử con, trong phạm vi constraints cho phép.

Ví dụ:

Column(
  mainAxisSize: MainAxisSize.min,
  children: const [
    Text('Tên người dùng'),
    Text('Vũ Minh Tiến'),
  ],
)

Column có xu hướng chỉ sử dụng chiều cao cần thiết cho hai dòng văn bản thay vì chiếm toàn bộ chiều cao khả dụng.

Tuy nhiên, MainAxisSize.min không có nghĩa Column luôn nhỏ nhất trong mọi trường hợp. Constraints từ Widget cha vẫn có thể bắt buộc nó phải sử dụng một kích thước cụ thể.

3.7. MainAxisAlignment

MainAxisAlignment điều khiển cách phân phối khoảng trống giữa và xung quanh các phần tử theo trục chính.

Các giá trị phổ biến:

  • start: Đặt các phần tử về phía bắt đầu.

  • end: Đặt các phần tử về phía kết thúc.

  • center: Đặt nhóm phần tử ở giữa.

  • spaceBetween: Phân phối khoảng trống giữa các phần tử.

  • spaceAround: Phân phối khoảng trống xung quanh từng phần tử.

  • spaceEvenly: Phân phối khoảng trống đồng đều giữa các phần tử và hai đầu.

Ví dụ:

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: const [
    Icon(Icons.home),
    Icon(Icons.search),
    Icon(Icons.person),
  ],
)

Nếu Row có không gian dư, ba biểu tượng sẽ được bố trí với khoảng cách phân phối giữa chúng. Nếu không còn khoảng trống dư, các giá trị spaceBetween, spaceAround và spaceEvenly không thể tạo thêm khoảng cách như mong muốn.

3.8. CrossAxisAlignment

CrossAxisAlignment điều khiển cách căn chỉnh các phần tử theo trục phụ.

Các giá trị phổ biến:

  • start: Căn về phía bắt đầu của trục phụ.

  • end: Căn về phía kết thúc.

  • center: Căn giữa.

  • stretch: Kéo giãn phần tử theo trục phụ trong giới hạn constraints.

  • baseline: Căn theo đường cơ sở của văn bản khi được hỗ trợ và cấu hình phù hợp.

Ví dụ:

Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    ElevatedButton(
      onPressed: () {},
      child: const Text('Đăng nhập'),
    ),
    ElevatedButton(
      onPressed: () {},
      child: const Text('Đăng ký'),
    ),
  ],
)

Các nút được kéo giãn theo chiều ngang trong phạm vi constraints cho phép, phù hợp với giao diện biểu mẫu.

3.9. Ứng dụng Flex trong giao diện thực tế

Flex đặc biệt hữu ích trong thanh điều hướng, biểu mẫu đăng nhập, thẻ sản phẩm, danh sách thông tin và bố cục trang chi tiết.

Ví dụ bố trí thông tin sản phẩm:

Row(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Image.network(
      'https://example.com/product.jpg',
      width: 100,
      height: 100,
      fit: BoxFit.cover,
    ),
    const SizedBox(width: 12),
    Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: const [
          Text(
            'Điện thoại thông minh',
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
          ),
          SizedBox(height: 8),
          Text('Giá: 5.990.000đ'),
        ],
      ),
    ),
  ],
)

Trong cấu trúc này, hình ảnh sử dụng kích thước xác định, còn phần thông tin chiếm không gian còn lại. Expanded giúp phần văn bản không vượt quá chiều rộng khả dụng của Row.

Lưu ý rằng đường dẫn hình ảnh chỉ là ví dụ minh họa. Trong dự án thực tế, cần sử dụng URL hợp lệ hoặc ảnh từ tài nguyên ứng dụng.

4. Intrinsic Dimensions và Intrinsic Layout

4.1. Khái niệm Intrinsic Dimensions

Intrinsic Dimensions là các phép đo mô tả kích thước nội tại của một Widget dựa trên nội dung và cách Widget đó triển khai layout.

Trong hệ thống RenderBox, một số phép đo intrinsic phổ biến gồm:

  • Chiều rộng nội tại tối thiểu.

  • Chiều rộng nội tại tối đa.

  • Chiều cao nội tại tối thiểu.

  • Chiều cao nội tại tối đa.

Các phép đo này hỗ trợ xác định kích thước phù hợp với nội dung trong một số tình huống mà kích thước cuối cùng chưa được quyết định hoàn toàn từ constraints.

Ví dụ, một dòng văn bản ngắn có thể chỉ cần chiều rộng nhỏ, trong khi một dòng văn bản dài có thể cần chiều rộng lớn hơn nếu không được phép xuống dòng.

Intrinsic dimensions không đồng nghĩa với kích thước thực tế cuối cùng của Widget. Kích thước cuối cùng vẫn được xác định thông qua constraints và thuật toán layout.

4.2. IntrinsicWidth

IntrinsicWidth là Widget yêu cầu hệ thống xem xét chiều rộng nội tại của phần tử con để bố trí nó theo chiều rộng phù hợp.

Ví dụ:

IntrinsicWidth(
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      ElevatedButton(
        onPressed: () {},
        child: const Text('Đăng nhập'),
      ),
      ElevatedButton(
        onPressed: () {},
        child: const Text('Đăng ký tài khoản'),
      ),
    ],
  ),
)

Trong ví dụ này, IntrinsicWidth giúp Column sử dụng chiều rộng dựa trên phép đo nội tại của nội dung. Hai nút sau đó được kéo giãn để có chiều rộng đồng nhất trong Column.

Tuy nhiên, nếu màn hình quá hẹp hoặc Widget cha áp đặt giới hạn khác, kết quả vẫn chịu ảnh hưởng của constraints bên ngoài.

4.3. IntrinsicHeight

IntrinsicHeight yêu cầu hệ thống xem xét chiều cao nội tại của phần tử con nhằm xác định chiều cao phù hợp.

Ví dụ:

IntrinsicHeight(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      Container(
        width: 100,
        color: Colors.blue,
      ),
      const VerticalDivider(width: 1),
      const Expanded(
        child: Padding(
          padding: EdgeInsets.all(12),
          child: Text(
            'Mô tả sản phẩm có thể dài hoặc ngắn '
            'tùy thuộc vào nội dung.',
          ),
        ),
      ),
    ],
  ),
)

Trong ví dụ, IntrinsicHeight có thể giúp các phần tử trong Row được bố trí với chiều cao dựa trên nhu cầu nội tại, làm cho đường phân cách dọc có thể kéo dài theo chiều cao của hàng.

Cần lưu ý rằng phép đo intrinsic không thể giải quyết mọi cấu trúc layout. Hiệu quả còn phụ thuộc vào cách các Widget con triển khai phép đo intrinsic và constraints mà chúng nhận được.

4.4. Chi phí hiệu năng của Intrinsic Layout

Intrinsic layout có thể làm tăng chi phí tính toán vì hệ thống phải thực hiện thêm các phép đo trước hoặc trong quá trình xác định kích thước cuối cùng.

Trong một số trường hợp, RenderObject phải đo nội tại của các phần tử con trước khi thực hiện layout thông thường. Với cây Widget phức tạp, việc đo lặp lại ở nhiều tầng có thể làm tăng đáng kể công việc cần thực hiện.

Ví dụ, nếu một danh sách có hàng trăm phần tử và mỗi hàng đều sử dụng IntrinsicHeight, tổng chi phí layout có thể lớn hơn nhiều so với phương án xác định kích thước trực tiếp.

Không phải mọi trường hợp dùng Intrinsic đều chậm. Vấn đề là sử dụng chúng không cần thiết hoặc đặt chúng trong những cây Widget lớn, được layout thường xuyên.

4.5. Giải pháp thay thế Intrinsic Layout

Trước khi sử dụng IntrinsicWidth hoặc IntrinsicHeight, nên xem xét các phương án khác:

  • Dùng SizedBox khi kích thước có thể xác định trước.

  • Dùng ConstrainedBox khi cần giới hạn kích thước.

  • Dùng Expanded hoặc Flexible khi các phần tử cần chia sẻ không gian.

  • Dùng CrossAxisAlignment.stretch khi cần kéo giãn các phần tử theo trục phụ.

  • Dùng LayoutBuilder khi muốn lựa chọn bố cục dựa trên constraints.

  • Dùng Table hoặc một cấu trúc layout phù hợp khi cần các cột hoặc hàng đồng bộ kích thước.

Ví dụ thay thế bằng kích thước rõ ràng:

Row(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    SizedBox(
      width: 120,
      child: Container(color: Colors.blue),
    ),
    const SizedBox(width: 12),
    Expanded(
      child: Container(
        padding: const EdgeInsets.all(12),
        color: Colors.orange,
        child: const Text('Thông tin sản phẩm'),
      ),
    ),
  ],
)

Cách tiếp cận này không yêu cầu đo intrinsic để xác định chiều rộng cột trái. Nó thường dễ dự đoán và dễ kiểm soát hơn.

4.6. Nguyên tắc sử dụng Intrinsic Dimensions

Intrinsic dimensions phù hợp với những tình huống mà kích thước cần phụ thuộc vào nội dung và không thể giải quyết đơn giản bằng constraints thông thường.

Tuy nhiên, trong ứng dụng thực tế, nên ưu tiên hệ thống layout có kích thước và constraints rõ ràng. Điều này giúp giảm công việc tính toán, tránh các lỗi khó phát hiện và cải thiện khả năng mở rộng của giao diện.

5. Unbounded Constraints và lỗi Layout

5.1. Khái niệm Unbounded Constraints

Unbounded constraints xuất hiện khi một Widget nhận được giới hạn không hữu hạn theo chiều rộng hoặc chiều cao. Trong Flutter, điều này thường được thể hiện bằng double.infinity ở một giới hạn tối đa.

Unbounded constraints không mặc định là lỗi. Chúng là một phần cần thiết của các cấu trúc layout cho phép nội dung phát triển theo chiều dài, đặc biệt là các vùng cuộn.

Ví dụ, SingleChildScrollView theo chiều dọc cần cho phép nội dung phát triển vượt quá chiều cao của vùng nhìn thấy. Vì vậy, phần tử con có thể nhận constraints không giới hạn hữu hạn theo chiều cao.

Vấn đề phát sinh khi một Widget cần biết giới hạn hữu hạn để phân phối không gian nhưng lại được đặt trong môi trường không có giới hạn đó.

5.2. Expanded bên trong SingleChildScrollView

Đây là một lỗi thường gặp khi mới học Flutter.

Ví dụ:

SingleChildScrollView(
  child: Column(
    children: [
      const Text('Danh sách sản phẩm'),
      Expanded(
        child: ListView(
          children: const [
            Text('Sản phẩm 1'),
            Text('Sản phẩm 2'),
          ],
        ),
      ),
    ],
  ),
)

Cấu trúc trên không phù hợp vì SingleChildScrollView cho phép nội dung phát triển theo chiều dọc. Column bên trong không có một chiều cao tối đa hữu hạn để xác định phần không gian còn lại.

Trong khi đó, Expanded yêu cầu chiếm phần không gian còn lại theo trục chính của Column. Khi không gian còn lại không thể xác định hữu hạn, hệ thống không thể thực hiện phép phân phối theo cách thông thường.

5.3. Phương án khắc phục bằng shrinkWrap

Nếu danh sách ngắn và cần cuộn cùng toàn bộ nội dung trang, có thể để danh sách tự xác định chiều cao dựa trên các phần tử:

SingleChildScrollView(
  child: Column(
    children: [
      const Text('Danh sách sản phẩm'),
      ListView(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        children: const [
          ListTile(title: Text('Sản phẩm 1')),
          ListTile(title: Text('Sản phẩm 2')),
          ListTile(title: Text('Sản phẩm 3')),
        ],
      ),
    ],
  ),
)

shrinkWrap: true yêu cầu vùng cuộn tính kích thước dựa trên nội dung theo trục cuộn. NeverScrollableScrollPhysics ngăn danh sách tự cuộn độc lập, để vùng cuộn bên ngoài điều khiển thao tác cuộn.

Cách này có thể phù hợp với danh sách ngắn. Tuy nhiên, danh sách lớn có thể gây chi phí layout đáng kể do việc xác định kích thước theo nội dung.

5.4. Phương án sử dụng ListView độc lập

Với danh sách dài, thông thường nên để ListView quản lý việc cuộn và sử dụng phần không gian hữu hạn từ Widget cha.

Column(
  children: [
    const Padding(
      padding: EdgeInsets.all(16),
      child: Text('Danh sách sản phẩm'),
    ),
    Expanded(
      child: ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Sản phẩm ${index + 1}'),
          );
        },
      ),
    ),
  ],
)

Trong ví dụ này, Column nhận chiều cao hữu hạn từ Widget cha, còn Expanded phân phối phần chiều cao còn lại cho ListView. ListView có thể quản lý vùng nhìn thấy và tạo các phần tử theo nhu cầu.

Đây thường là phương án phù hợp hơn cho màn hình danh sách sản phẩm, lịch sử đơn hàng hoặc danh sách thông báo.

5.5. Lỗi Row và Column trong môi trường không giới hạn

Unbounded constraints còn có thể xuất hiện khi sử dụng Flex bên trong một cấu trúc không cung cấp giới hạn hữu hạn theo trục chính.

Ví dụ, một Row nằm trong vùng cuộn ngang có thể nhận chiều rộng không hữu hạn. Nếu bên trong Row có Expanded, hệ thống không thể xác định phần không gian còn lại theo chiều ngang như trong một Row có chiều rộng cố định.

Trong những trường hợp này, cần xem xét:

  • Xác định chiều rộng cụ thể cho vùng chứa.

  • Loại bỏ Expanded nếu không cần chia sẻ không gian.

  • Sử dụng Flexible trong cấu trúc có constraints phù hợp.

  • Thiết kế lại cấu trúc cuộn để tránh lồng nhiều vùng cuộn không cần thiết.

  • Dùng SizedBox hoặc ConstrainedBox để thiết lập giới hạn khi thích hợp.

5.6. Phân biệt lỗi layout và hành vi layout hợp lệ

Không phải cứ xuất hiện double.infinity là ứng dụng bị lỗi. Một số Widget sử dụng giá trị này để yêu cầu chiếm tối đa không gian mà constraints cho phép.

Ví dụ:

Container(
  width: double.infinity,
  height: 100,
  color: Colors.blue,
)

Nếu Container nhận constraints có chiều rộng tối đa hữu hạn, nó có thể mở rộng để chiếm toàn bộ chiều rộng khả dụng.

Ngược lại, nếu Widget nằm trong môi trường không có giới hạn chiều rộng hữu hạn, yêu cầu chiều rộng vô hạn có thể dẫn đến lỗi.

Do đó, khi gặp lỗi layout, cần xác định cả thuộc tính của Widget lẫn constraints được truyền từ các Widget cha.

5.7. Quy trình xử lý lỗi Unbounded Constraints

Có thể áp dụng quy trình kiểm tra sau:

  1. Xác định Widget gây lỗi trong thông báo Flutter.

  2. Xác định trục chính của Widget đó.

  3. Kiểm tra Widget cha có cung cấp giới hạn hữu hạn trên trục tương ứng hay không.

  4. Kiểm tra các Widget Expanded, Flexible, ListView và SingleChildScrollView.

  5. Xác định vùng nào cần cuộn và vùng nào cần chiếm không gian còn lại.

  6. Điều chỉnh cấu trúc bằng constraints rõ ràng hoặc thay đổi cách tổ chức vùng cuộn.

  7. Chạy lại ứng dụng để xác nhận lỗi đã được giải quyết.

Quy trình này giúp xử lý nguyên nhân gốc thay vì chỉ sửa tạm thời bằng cách gán kích thước tùy ý.

6. Viewport, Sliver và hệ thống cuộn

6.1. Tổng quan hệ thống cuộn Flutter

Flutter cung cấp nhiều Widget hỗ trợ cuộn nội dung, chẳng hạn như SingleChildScrollView, ListView, GridView, CustomScrollView và các Widget Sliver.

Đối với nội dung ngắn, SingleChildScrollView thường phù hợp. Với danh sách dài, ListView.builder hoặc GridView.builder thường hiệu quả hơn. Khi cần kết hợp nhiều khu vực cuộn có cấu trúc phức tạp, CustomScrollView và Sliver mang lại khả năng kiểm soát tốt hơn.

Hệ thống cuộn không chỉ dịch chuyển toàn bộ nội dung trên màn hình. Nó còn phải xác định vùng nhìn thấy, quản lý vị trí cuộn, tính toán phần tử cần layout và phối hợp với cơ chế vẽ.

6.2. Viewport

Viewport là vùng hiển thị một phần nội dung của cấu trúc cuộn. Nội dung có thể dài hơn kích thước viewport, nhưng tại một thời điểm, người dùng chỉ nhìn thấy phần nằm trong vùng này.

Ví dụ, một danh sách có 100 sản phẩm nhưng màn hình chỉ hiển thị được 7 sản phẩm. Viewport xác định khu vực hiển thị, còn hệ thống cuộn quản lý phần nội dung đang nằm trong vùng nhìn thấy và vị trí cuộn hiện tại.

Các thành phần liên quan có thể bao gồm:

  • ScrollController: Điều khiển và theo dõi vị trí cuộn.

  • ScrollPosition: Quản lý vị trí và trạng thái cuộn.

  • Scrollable: Xử lý tương tác cuộn.

  • Viewport: Xác định vùng hiển thị nội dung cuộn.

  • Sliver: Cung cấp giao diện layout để nội dung cuộn có thể được bố trí linh hoạt trong viewport.

Trong Flutter, ListView và GridView thường sử dụng cơ chế cuộn và viewport ở bên dưới, dù lập trình viên không cần trực tiếp cấu hình toàn bộ các thành phần này.

6.3. Sliver

Sliver là một thành phần layout được thiết kế để hoạt động trong vùng cuộn dựa trên viewport. Thay vì luôn bố trí toàn bộ nội dung theo cách thông thường, Sliver có thể tham gia vào quá trình xác định và bố trí các phần tử phù hợp với vùng hiển thị.

Một số Sliver phổ biến:

  • SliverList: Hiển thị danh sách theo chiều dọc hoặc trục cuộn được cấu hình.

  • SliverGrid: Hiển thị nội dung dạng lưới.

  • SliverAppBar: Thanh ứng dụng có thể thay đổi khi cuộn.

  • SliverToBoxAdapter: Đưa một Widget thông thường vào hệ thống Sliver.

  • SliverPadding: Thêm khoảng đệm cho Sliver.

  • SliverFillRemaining: Cho phép phần tử chiếm vùng còn lại của viewport trong những điều kiện phù hợp.

Sliver hữu ích khi một màn hình cần nhiều kiểu nội dung nhưng vẫn muốn sử dụng chung một vùng cuộn.

6.4. SliverList

SliverList hiển thị các phần tử theo dạng danh sách. Các phần tử được tạo thông qua delegate, thường là SliverChildBuilderDelegate hoặc SliverChildListDelegate.

Ví dụ:

CustomScrollView(
  slivers: [
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(
            title: Text('Sản phẩm ${index + 1}'),
          );
        },
        childCount: 50,
      ),
    ),
  ],
)

Trong ví dụ, SliverList xây dựng danh sách gồm 50 phần tử. Với delegate dạng builder, Flutter có thể tạo các phần tử theo nhu cầu layout thay vì bắt buộc tạo toàn bộ Widget con ngay từ đầu.

Cơ chế này đặc biệt hữu ích khi danh sách chứa nhiều dữ liệu. Tuy nhiên, hiệu quả thực tế còn phụ thuộc vào cách xây dựng item, trạng thái cần giữ lại và các thuộc tính của danh sách.

6.5. SliverGrid

SliverGrid hiển thị các phần tử theo dạng lưới và cho phép cấu hình cách xác định kích thước từng ô.

Ví dụ:

CustomScrollView(
  slivers: [
    SliverGrid(
      gridDelegate:
          const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
        childAspectRatio: 0.75,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return Card(
            child: Center(
              child: Text('Sản phẩm ${index + 1}'),
            ),
          );
        },
        childCount: 20,
      ),
    ),
  ],
)

Trong ví dụ trên:

  • crossAxisCount: 2 tạo hai cột trong điều kiện layout phù hợp.

  • crossAxisSpacing: 12 xác định khoảng cách giữa các cột.

  • mainAxisSpacing: 12 xác định khoảng cách giữa các hàng.

  • childAspectRatio: 0.75 xác định tỷ lệ giữa chiều rộng và chiều cao của ô.

SliverGrid phù hợp với trang danh mục sản phẩm, thư viện hình ảnh hoặc màn hình hiển thị nhiều mục theo dạng lưới.

6.6. CustomScrollView

CustomScrollView cho phép kết hợp nhiều Sliver trong cùng một vùng cuộn. Đây là lựa chọn hữu ích khi một màn hình có thanh tiêu đề co giãn, khu vực danh mục, banner, danh sách và lưới sản phẩm.

Ví dụ:

CustomScrollView(
  slivers: [
    const SliverAppBar(
      title: Text('Cửa hàng'),
      floating: true,
      expandedHeight: 160,
    ),
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          'Sản phẩm nổi bật',
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    ),
    SliverGrid(
      gridDelegate:
          const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return Card(
            child: Center(
              child: Text('Sản phẩm ${index + 1}'),
            ),
          );
        },
        childCount: 20,
      ),
    ),
  ],
)

Trong cấu trúc này, thanh ứng dụng, tiêu đề và lưới sản phẩm cùng sử dụng một vùng cuộn. Người dùng không cần quản lý nhiều vùng cuộn lồng nhau để di chuyển qua toàn bộ trang.

6.7. Khác biệt giữa cuộn thông thường và cuộn dựa trên Sliver

Tiêu chí SingleChildScrollView ListView CustomScrollView và Sliver
Mục đích chính Cuộn một Widget con Hiển thị danh sách Kết hợp nhiều thành phần cuộn
Số lượng nội dung phù hợp Thường là nội dung ngắn Danh sách vừa đến dài Trang phức tạp, nhiều vùng nội dung
Tạo phần tử theo nhu cầu Không tự động tối ưu toàn bộ cây con Có với builder Có thể với Sliver delegate
Thanh ứng dụng co giãn Cần cấu trúc bổ sung Có giới hạn tùy cấu trúc Hỗ trợ tốt với SliverAppBar
Độ phức tạp Thấp Trung bình Cao hơn nhưng linh hoạt

SingleChildScrollView không tự động biến một cây Widget lớn thành danh sách lazy. Nếu đặt một Column chứa hàng trăm phần tử bên trong, nhiều Widget có thể được xây dựng và layout cùng lúc.

Ngược lại, các cấu trúc dùng builder và Sliver có thể tạo phần tử theo nhu cầu. Điều này giúp kiểm soát chi phí xử lý khi nội dung dài.

6.8. Ứng dụng hệ thống Sliver trong Smart Commerce

Một trang thương mại điện tử có thể kết hợp:

  • Thanh tìm kiếm và tiêu đề có khả năng thay đổi khi cuộn.

  • Banner khuyến mãi.

  • Danh mục sản phẩm.

  • Khu vực sản phẩm nổi bật.

  • Danh sách hoặc lưới sản phẩm.

  • Các phần nội dung bổ sung như thương hiệu và chương trình ưu đãi.

CustomScrollView cho phép xây dựng các khu vực trên trong một hệ thống cuộn thống nhất. Mỗi khu vực có thể sử dụng Sliver phù hợp với mục đích hiển thị.

Khi thiết kế giao diện thật, cần cân nhắc cách quản lý trạng thái cuộn, khóa phần tử, tải thêm dữ liệu, xử lý phân trang và giữ vị trí cuộn khi người dùng mở trang chi tiết rồi quay lại.

7. Responsive Layout và Adaptive Layout

7.1. Tổng quan thiết kế giao diện thích ứng

Responsive Layout và Adaptive Layout là hai cách tiếp cận nhằm giúp giao diện hoạt động tốt trên nhiều kích thước màn hình, hướng hiển thị và môi trường sử dụng khác nhau.

Trong ứng dụng Flutter, giao diện không nên chỉ được thiết kế cho một kích thước điện thoại cụ thể. Ứng dụng có thể chạy trên điện thoại nhỏ, điện thoại lớn, máy tính bảng, cửa sổ desktop hoặc cửa sổ trình duyệt có kích thước thay đổi.

Nếu giao diện phụ thuộc quá nhiều vào các giá trị cố định, những vấn đề có thể xuất hiện gồm:

  • Nội dung bị tràn khỏi màn hình.

  • Văn bản bị cắt hoặc khó đọc.

  • Các nút bấm quá nhỏ hoặc quá rộng.

  • Hình ảnh bị kéo méo.

  • Bố cục mất cân đối trên màn hình lớn.

  • Các phần tử chồng lấn khi cửa sổ bị thu hẹp.

Responsive và adaptive layout giúp giảm những vấn đề này bằng cách tổ chức giao diện dựa trên không gian thực tế và khả năng sử dụng của từng môi trường.

7.2. Responsive Layout

Responsive Layout là cách thiết kế giao diện có khả năng co giãn và điều chỉnh theo kích thước vùng hiển thị.

Một bố cục responsive thường sử dụng các thành phần linh hoạt như Expanded, Flexible, Wrap, LayoutBuilder, MediaQuery, FractionallySizedBox và constraints.

Ví dụ:

Scaffold(
  body: Padding(
    padding: const EdgeInsets.all(16),
    child: Column(
      children: [
        const Text('Danh sách sản phẩm'),
        const SizedBox(height: 16),
        Expanded(
          child: GridView.builder(
            gridDelegate:
                const SliverGridDelegateWithMaxCrossAxisExtent(
              maxCrossAxisExtent: 240,
              crossAxisSpacing: 12,
              mainAxisSpacing: 12,
              childAspectRatio: 0.75,
            ),
            itemCount: 30,
            itemBuilder: (context, index) {
              return Card(
                child: Center(
                  child: Text('Sản phẩm ${index + 1}'),
                ),
              );
            },
          ),
        ),
      ],
    ),
  ),
)

SliverGridDelegateWithMaxCrossAxisExtent cho phép Flutter xác định số cột dựa trên chiều rộng khả dụng và giới hạn kích thước tối đa của mỗi ô. Vì vậy, số cột có thể thay đổi khi không gian màn hình thay đổi.

Đây là ví dụ về responsive layout mà không cần tự xác định từng kích thước màn hình.

7.3. Adaptive Layout

Adaptive Layout tập trung vào việc lựa chọn cấu trúc giao diện phù hợp với môi trường sử dụng, thay vì chỉ co giãn kích thước của cùng một bố cục.

Ví dụ, trên điện thoại, ứng dụng có thể sử dụng thanh điều hướng dưới cùng. Trên máy tính bảng hoặc desktop, ứng dụng có thể chuyển sang thanh điều hướng bên trái và vùng nội dung rộng hơn.

Trong trường hợp này, giao diện không chỉ thay đổi kích thước mà còn thay đổi cách tổ chức các thành phần.

Một số ví dụ:

  • Điện thoại: Hiển thị một cột sản phẩm.

  • Máy tính bảng: Hiển thị hai hoặc ba cột.

  • Desktop: Hiển thị lưới sản phẩm và bộ lọc ở bên cạnh.

  • Màn hình rộng: Hiển thị thêm khu vực thông tin hoặc bảng điều khiển.

  • Màn hình nhỏ: Thu gọn điều hướng và giảm số lượng thành phần hiển thị đồng thời.

7.4. So sánh Responsive và Adaptive Layout

Tiêu chí Responsive Layout Adaptive Layout
Mục tiêu Điều chỉnh bố cục theo không gian Chọn cấu trúc phù hợp với môi trường
Cách tiếp cận Co giãn và phân phối không gian linh hoạt Chuyển đổi giữa các kiểu bố cục
Thành phần thường dùng Flexible, Expanded, Wrap, LayoutBuilder LayoutBuilder, MediaQuery, điều kiện breakpoint
Ví dụ Thẻ sản phẩm tự co giãn Chuyển từ BottomNavigationBar sang NavigationRail
Phạm vi áp dụng Nhiều kích thước trong cùng cấu trúc Nhiều cấu trúc giao diện khác nhau

Hai cách tiếp cận không loại trừ nhau. Một ứng dụng thực tế thường kết hợp cả responsive và adaptive layout để vừa tận dụng không gian hiệu quả, vừa cung cấp trải nghiệm phù hợp cho từng thiết bị.

7.5. MediaQuery

MediaQuery cung cấp thông tin về môi trường hiển thị, chẳng hạn kích thước cửa sổ, hướng màn hình, các phần đệm an toàn và một số thiết lập hiển thị của người dùng.

Ví dụ:

Widget build(BuildContext context) {
  final screenSize = MediaQuery.sizeOf(context);
  final screenWidth = screenSize.width;

  return Scaffold(
    body: Center(
      child: Text(
        'Chiều rộng màn hình: '
        '${screenWidth.toStringAsFixed(0)}',
      ),
    ),
  );
}

MediaQuery.sizeOf(context) cung cấp kích thước của vùng hiển thị được báo cáo bởi MediaQuery gần nhất.

Thông tin này hữu ích khi cần đưa ra quyết định dựa trên kích thước tổng thể của vùng hiển thị.

Tuy nhiên, không nên mặc định rằng chiều rộng MediaQuery luôn bằng chiều rộng thực tế mà một Widget cụ thể được phép sử dụng. Ví dụ, một Widget nằm trong cột hẹp của giao diện desktop có thể nhận ít không gian hơn rất nhiều so với toàn bộ cửa sổ.

Trong trường hợp đó, LayoutBuilder thường phù hợp hơn.

7.6. LayoutBuilder

LayoutBuilder cho phép xây dựng giao diện dựa trên BoxConstraints mà Widget nhận được từ cha.

Ví dụ:

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth < 600) {
      return const Column(
        children: [
          Text('Thông tin sản phẩm'),
          Text('Giá bán: 5.990.000đ'),
        ],
      );
    }

    return const Row(
      children: [
        Expanded(
          child: Text('Thông tin sản phẩm'),
        ),
        Expanded(
          child: Text('Giá bán: 5.990.000đ'),
        ),
      ],
    );
  },
)

Khi chiều rộng tối đa nhỏ hơn 600 pixels, nội dung được bố trí theo chiều dọc. Khi chiều rộng lớn hơn hoặc bằng 600 pixels, nội dung được bố trí theo chiều ngang.

Con số 600 chỉ là breakpoint minh họa, không phải tiêu chuẩn bắt buộc của Flutter. Breakpoint phù hợp cần dựa trên cách nội dung hoạt động và thiết kế của ứng dụng.

7.7. Breakpoint

Breakpoint là ngưỡng kích thước được sử dụng để quyết định thời điểm giao diện chuyển từ bố cục này sang bố cục khác.

Ví dụ, một ứng dụng thương mại điện tử có thể lựa chọn các ngưỡng ban đầu như sau:

Chiều rộng vùng hiển thị Bố cục minh họa
Dưới 600 px Điện thoại, một cột hoặc lưới ít cột
Từ 600 đến dưới 900 px Máy tính bảng nhỏ hoặc cửa sổ trung bình
Từ 900 đến dưới 1200 px Máy tính bảng lớn hoặc cửa sổ rộng
Từ 1200 px trở lên Desktop hoặc màn hình rộng

Các giá trị trên chỉ là ví dụ để minh họa chiến lược thiết kế. Breakpoint thực tế cần được điều chỉnh dựa trên chiều rộng tối thiểu mà nội dung cần có, số lượng cột, chiều rộng thanh điều hướng và yêu cầu trải nghiệm người dùng.

Không nên chọn breakpoint chỉ vì một thiết bị cụ thể có kích thước màn hình nhất định. Cách tốt hơn là xác định thời điểm nội dung không còn phù hợp với bố cục hiện tại.

7.8. Xây dựng giao diện thích ứng với breakpoint

Có thể tổ chức breakpoint thành các điều kiện rõ ràng thay vì rải nhiều giá trị số trong toàn bộ mã nguồn.

Ví dụ:

class ResponsivePage extends StatelessWidget {
  const ResponsivePage({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 600) {
          return const MobileLayout();
        }

        if (constraints.maxWidth < 1000) {
          return const TabletLayout();
        }

        return const DesktopLayout();
      },
    );
  }
}

class MobileLayout extends StatelessWidget {
  const MobileLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Giao diện điện thoại'),
    );
  }
}

class TabletLayout extends StatelessWidget {
  const TabletLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Giao diện máy tính bảng'),
    );
  }
}

class DesktopLayout extends StatelessWidget {
  const DesktopLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Giao diện desktop'),
    );
  }
}

Ví dụ trên tách mỗi kiểu giao diện thành một Widget riêng. Khi dự án phát triển, các lớp MobileLayout, TabletLayout và DesktopLayout có thể được mở rộng thành các bố cục đầy đủ với thanh điều hướng, danh sách sản phẩm và vùng nội dung riêng.

Cách tổ chức này giúp giảm độ phức tạp của một phương thức build() quá lớn và thuận tiện cho việc bảo trì.

7.9. Tránh phụ thuộc cứng vào kích thước màn hình

Một lỗi thường gặp khi thiết kế giao diện là sử dụng kích thước cố định cho mọi thành phần.

Ví dụ:

Container(
  width: 400,
  height: 300,
  color: Colors.blue,
)

Kích thước này có thể phù hợp trong một số trường hợp nhưng không bảo đảm hiển thị tốt trên mọi thiết bị. Nếu vùng chứa chỉ rộng 320 pixels, Container có thể vượt khỏi khu vực cho phép.

Thay vì đặt kích thước cố định cho mọi trường hợp, có thể sử dụng constraints:

ConstrainedBox(
  constraints: const BoxConstraints(
    maxWidth: 600,
  ),
  child: SizedBox(
    width: double.infinity,
    child: Container(
      padding: const EdgeInsets.all(16),
      color: Colors.blue,
      child: const Text(
        'Nội dung giao diện thích ứng',
        style: TextStyle(color: Colors.white),
      ),
    ),
  ),
)

Trong trường hợp Widget được đặt trong một vùng có chiều rộng hữu hạn, SizedBox yêu cầu sử dụng toàn bộ chiều rộng khả dụng, nhưng không vượt quá giới hạn mà ConstrainedBox và Widget cha cho phép.

Ngoài ra, cần chú ý đến:

  • Văn bản dài và khả năng xuống dòng.

  • Cỡ chữ do người dùng điều chỉnh.

  • Khoảng đệm an toàn của thiết bị.

  • Hướng màn hình dọc và ngang.

  • Khả năng thay đổi kích thước cửa sổ desktop.

  • Hình ảnh có tỷ lệ khung hình phù hợp.

  • Các nút có kích thước đủ để thao tác.

7.10. Kết hợp Responsive và Adaptive trong dự án Smart Commerce

Trong ứng dụng bán hàng, giao diện thích ứng có thể được triển khai theo nhiều cấp độ.

Trên điện thoại

  • Thanh điều hướng phía dưới.

  • Thanh tìm kiếm nằm phía trên.

  • Danh sách sản phẩm theo một hoặc hai cột tùy chiều rộng.

  • Bộ lọc được mở trong một màn hình hoặc bottom sheet riêng.

Trên máy tính bảng

  • Tăng số lượng cột sản phẩm.

  • Hiển thị thêm danh mục hoặc bộ lọc.

  • Tăng không gian dành cho thông tin sản phẩm.

  • Sử dụng điều hướng phù hợp với màn hình rộng hơn.

Trên desktop

  • Sử dụng thanh điều hướng bên trái.

  • Hiển thị bộ lọc cạnh danh sách sản phẩm.

  • Bố trí nhiều cột sản phẩm.

  • Dành thêm vùng hiển thị cho thống kê, thông tin đơn hàng và các thành phần quản trị.

Để triển khai hiệu quả, nên kết hợp LayoutBuilder để lựa chọn cấu trúc theo constraints, MediaQuery để đọc thông tin môi trường hiển thị và các Widget linh hoạt để phân phối không gian bên trong từng bố cục.

7.11. Kiểm thử giao diện thích ứng

Sau khi xây dựng responsive và adaptive layout, cần kiểm tra giao diện trong nhiều điều kiện khác nhau.

Các nội dung nên kiểm thử gồm:

  1. Màn hình điện thoại có chiều rộng nhỏ.

  2. Màn hình điện thoại ở hướng ngang.

  3. Máy tính bảng với nhiều kích thước khác nhau.

  4. Cửa sổ desktop bị thu hẹp.

  5. Văn bản dài hơn dự kiến.

  6. Người dùng tăng kích thước chữ.

  7. Nội dung có số lượng phần tử lớn.

  8. Các trường hợp loading, dữ liệu trống và lỗi tải dữ liệu.

  9. Giao diện có bàn phím ảo hoặc các phần đệm an toàn.

  10. Các phần tử cần cuộn và các vùng cuộn lồng nhau.

Có thể sử dụng Flutter DevTools, Flutter Inspector và các chế độ giả lập thiết bị để kiểm tra layout. Việc kiểm thử trên nhiều kích thước giúp phát hiện lỗi sớm hơn so với chỉ chạy ứng dụng trên một thiết bị.

Bài viết khác

REST API & HTTP trong Flutter

REST API & HTTP trong Flutter 1. REST API và HTTP 1.1. Khái niệm REST API REST API là một phương thức thiết kế giao tiếp giữa các hệ thống phần mềm thông qua giao thức HTTP. REST là viết tắt của Representational State Transfer, còn API là viết tắt của Application Programming Interface. REST […]

Dio & HTTP Client trong Flutter

Dio & HTTP Client trong Flutter 1. Tổng quan về Dio và HTTP Client Trong quá trình phát triển ứng dụng Flutter, việc giao tiếp với máy chủ (Server) là một phần quan trọng để ứng dụng có thể lấy dữ liệu, gửi thông tin, đăng nhập, đăng ký tài khoản, tải hình ảnh, cập […]

Flutter Widget Fundamentals – Tối ưu hiệu năng và kiểm thử Widget

Tối ưu hiệu năng và kiểm thử Widget trong Flutter 1. Rebuild Optimization – Tối ưu quá trình xây dựng lại giao diện 1.1. Khái niệm Rebuild Optimization Rebuild Optimization là quá trình tối ưu việc xây dựng lại giao diện trong Flutter nhằm giảm những lần thực thi build() không cần thiết, hạn chế […]

Equivalence Partitioning(Phân vùng tương đương)

1.Equivalence Partitioning (Phân vùng tương đương) là gì? Equivalence Partitioning (Phân vùng tương đương) là một trong những kỹ thuật thiết kế kiểm thử hộp đen (Black-box Testing Techniques) cơ bản và quan trọng nhất trong kỹ nghệ phần mềm. Kỹ thuật này được áp dụng nhằm giảm thiểu số lượng ca kiểm thử (Test […]

Flutter Widget Fundamentals – Các cơ chế UI nâng cao trong Flutter

Các cơ chế UI nâng cao trong Flutter 1. Gesture System và Hit Testing 1.1. Tổng quan về Gesture System Gesture System là hệ thống xử lý cử chỉ tương tác trong Flutter, cho phép ứng dụng nhận biết và phản hồi những hành động của người dùng như chạm, nhấn giữ, kéo thả, vuốt, […]

Flutter Widget Fundamentals – Vòng đời và quản lý trạng thái của Widget

Vòng đời và quản lý trạng thái của Widget trong Flutter 1. StatefulWidget Lifecycle chuyên sâu 1.1. Khái niệm vòng đời của StatefulWidget Trong Flutter, vòng đời của StatefulWidget là chuỗi các giai đoạn mà một Widget trải qua từ khi được tạo, khởi tạo trạng thái, hiển thị giao diện, nhận các thay đổi […]

Leave a Reply

Your email address will not be published. Required fields are marked *