Chuyên mục

Flutter

Flutter tutolrial

174 bài viết
FlowDelegate: Nghệ Thuật Sắp Đặt Widget Ngoạn Mục trong Flutter
18/03/2026

FlowDelegate: Nghệ Thuật Sắp Đặt Widget Ngoạn Mục trong Flutter

Chào các đồng chí lập trình viên, anh Creyt lại lên sóng đây! Hôm nay, chúng ta sẽ cùng nhau 'mổ xẻ' một gã khá 'lạnh lùng' nhưng lại cực kỳ quyền năng trong thế giới Flutter: FlowDelegate. Nghe tên có vẻ khô khan, nhưng tin anh đi, nó chính là tay biên đạo múa bậc thầy cho các widget của bạn đấy! FlowDelegate là gì và tại sao chúng ta cần đến nó? Cứ hình dung thế này, trong Flutter, chúng ta thường dùng Row, Column, Stack, Wrap để sắp xếp các widget. Chúng nó giống như những 'đội trưởng' chỉ đạo đội hình vậy: ông A đứng đây, bà B đứng cạnh ông A, đứa C nằm chồng lên ông A... Rất tiện lợi, đúng không? Nhưng đời không như mơ, đôi khi bạn cần một màn trình diễn phức tạp hơn, nơi các widget không chỉ đứng yên một chỗ mà còn phải 'nhảy nhót', 'xoay vòng', hay 'tụm năm tụm ba' theo một quy luật rất riêng, và quan trọng nhất là phải mượt mà như bơ dù có hàng trăm 'vũ công' trên sân khấu. Đó chính là lúc Flow và người cộng sự đắc lực của nó, FlowDelegate, bước ra ánh sáng. Flow là một widget cấp thấp, sinh ra để xử lý các bố cục phức tạp, đặc biệt là khi các phần tử con cần được sắp xếp theo một logic tùy chỉnh cao độ và có thể thay đổi vị trí một cách linh hoạt mà không cần phải 'đập đi xây lại' toàn bộ cây widget. Còn FlowDelegate ư? Nó chính là bản thiết kế chi tiết của màn trình diễn đó. Nó định nghĩa chính xác cách thức các widget con của Flow được vẽ lên màn hình, từ vị trí, góc xoay cho đến kích thước. FlowDelegate cho phép bạn kiểm soát từng pixel mà không phải trả giá bằng hiệu năng, bởi vì nó chỉ tập trung vào việc vẽ lại vị trí của các widget con, chứ không phải xây lại chúng. Cơ chế hoạt động: Khi bạn là "Tổng Đạo Diễn" Khi sử dụng Flow, bạn sẽ cần cung cấp một FlowDelegate tùy chỉnh. Về cơ bản, bạn sẽ phải 'chấp bút' cho ba phương thức chính trong FlowDelegate: paintChildren(FlowPaintingContext context): Đây là trái tim của FlowDelegate. Nó giống như bạn đang đứng trên sân khấu và chỉ đạo từng vũ công một: "Anh A, ra giữa sân khấu, xoay 45 độ. Chị B, lùi về phía sau một chút, cao hơn anh A 10 pixel." Bạn sẽ dùng context.paintChild(index) và áp dụng các Matrix4 để di chuyển, xoay, scale từng widget con. Đây là nơi bạn định nghĩa toàn bộ logic bố cục. getSize(BoxConstraints constraints): Phương thức này trả về kích thước tổng thể của Flow widget. Nó giống như bạn nói với nhà sản xuất: "Sân khấu của tôi cần rộng chừng này, cao chừng kia để chứa hết các vũ công." Nó sẽ cho Flow biết nó nên chiếm bao nhiêu không gian trên màn hình. shouldRepaint(covariant FlowDelegate oldDelegate): Đây là "người gác cổng hiệu năng". Nó quyết định liệu Flow có cần phải vẽ lại các widget con của nó hay không khi FlowDelegate thay đổi. Nếu bạn thay đổi một thuộc tính nào đó trong FlowDelegate (ví dụ: góc xoay, khoảng cách), phương thức này sẽ kiểm tra xem sự thay đổi đó có đủ lớn để yêu cầu vẽ lại không. Trả về true nếu cần vẽ lại, false nếu không. Đây là chìa khóa để giữ cho ứng dụng của bạn mượt mà. Code Ví Dụ: Tạo một Radial Menu "siêu ngầu" Chúng ta hãy cùng tạo một menu hình tròn (Radial Menu) đơn giản. Khi bạn nhấn vào nút trung tâm, các nút chức năng khác sẽ 'bung' ra xung quanh nó như những cánh hoa. Đầu tiên, chúng ta cần một FlowDelegate để định nghĩa cách các nút con sẽ được sắp xếp: import 'dart:math' as math; import 'package:flutter/material.dart'; class RadialMenuDelegate extends FlowDelegate { final Animation<double> animation; RadialMenuDelegate({required this.animation}) : super(repaint: animation); @override void paintChildren(FlowPaintingContext context) { // Kích thước của widget con đầu tiên (nút trung tâm) final double buttonSize = context.getChildSize(0)!.width; // Bán kính đường tròn các nút con sẽ bung ra final double radius = buttonSize * 1.5; // Vẽ nút trung tâm context.paintChild(0, transform: Matrix4.translationValues( (context.size.width - buttonSize) / 2, (context.size.height - buttonSize) / 2, 0, )); // Vẽ các nút con còn lại for (int i = 1; i < context.childCount; i++) { final double theta = i * (math.pi / (context.childCount - 2)) * animation.value; // Góc xoay final double x = (context.size.width / 2) - (buttonSize / 2) + (radius * math.cos(theta)); final double y = (context.size.height / 2) - (buttonSize / 2) + (radius * math.sin(theta)); context.paintChild(i, transform: Matrix4.translationValues(x, y, 0)); } } @override Size getSize(BoxConstraints constraints) { // Đảm bảo Flow có đủ không gian cho menu bung ra return Size.square(constraints.maxWidth); } @override bool shouldRepaint(covariant RadialMenuDelegate oldDelegate) { return animation != oldDelegate.animation; } } Và đây là cách chúng ta sử dụng RadialMenuDelegate với một Flow widget: import 'package:flutter/material.dart'; // Import RadialMenuDelegate từ file trên class RadialMenu extends StatefulWidget { const RadialMenu({super.key}); @override State<RadialMenu> createState() => _RadialMenuState(); } class _RadialMenuState extends State<RadialMenu> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); } @override void dispose() { _controller.dispose(); super.dispose(); } void _toggleMenu() { if (_controller.isDismissed) { _controller.forward(); } else { _controller.reverse(); } } Widget _buildFab(IconData icon, VoidCallback onPressed) { return RawMaterialButton( onPressed: onPressed, shape: const CircleBorder(), padding: const EdgeInsets.all(16.0), fillColor: Colors.blue, child: Icon(icon, color: Colors.white), ); } @override Widget build(BuildContext context) { return Flow( delegate: RadialMenuDelegate(animation: _controller), children: <Widget>[ // Nút trung tâm _buildFab(Icons.menu, _toggleMenu), // Các nút con _buildFab(Icons.edit, () => print('Edit')), _buildFab(Icons.share, () => print('Share')), _buildFab(Icons.add, () => print('Add')), _buildFab(Icons.delete, () => print('Delete')), ], ); } } // Để chạy thử, bạn có thể đặt RadialMenu vào Scaffold: /* void main() { runApp(MaterialApp( home: Scaffold( appBar: AppBar(title: const Text('FlowDelegate Example')), body: Center( child: SizedBox( width: 300, height: 300, child: RadialMenu(), ), ), ), )); } */ Trong ví dụ trên, RadialMenuDelegate dùng animation.value để tính toán góc xoay cho từng nút con, tạo hiệu ứng 'bung' ra hoặc 'thu vào' mượt mà. Matrix4.translationValues là công cụ để di chuyển widget con đến vị trí mong muốn. Mẹo và Best Practices từ "Lão Làng" Creyt Khi nào thì dùng, khi nào thì "thôi đi ông"? Dùng khi: Bạn cần bố cục tùy chỉnh cao độ, đặc biệt là các bố cục động, hoạt ảnh mà vị trí các phần tử thay đổi liên tục nhưng bản thân các phần tử không thay đổi cấu trúc bên trong. Flow tối ưu cho hiệu năng trong những trường hợp này vì nó chỉ vẽ lại, không xây lại. Ví dụ: menu hình tròn, tag cloud phức tạp, hiệu ứng xếp chồng card động. Thôi đi ông khi: Các bố cục đơn giản, tĩnh, hoặc chỉ cần Row, Column, Stack, Wrap là đủ. Đừng "vác dao mổ trâu đi giết gà" nhé. Flow phức tạp hơn để debug và duy trì. Hiệu năng là vàng: Nhớ kỹ, ưu điểm lớn nhất của Flow là hiệu năng. Nó tránh được việc tái tạo (rebuild) toàn bộ cây widget con khi chỉ vị trí của chúng thay đổi. Hãy tận dụng shouldRepaint một cách thông minh để chỉ vẽ lại khi thực sự cần thiết. Matrix4 là "người bạn thân": Hầu hết các phép biến đổi trong paintChildren sẽ liên quan đến Matrix4. Hãy làm quen với các phương thức như translationValues, rotationZ, scale để điều khiển vị trí, xoay, và kích thước của các widget con. Debugging Flow có thể "lú": Vì Flow hoạt động ở cấp độ thấp, nó không cung cấp các cơ chế ràng buộc bố cục tự động như Row hay Column. Khi có lỗi về vị trí, bạn sẽ phải tự tính toán và kiểm tra các giá trị x, y, theta của mình. Hãy dùng print hoặc debug mode để xem các giá trị tính toán được. Caching calculations: Nếu logic tính toán vị trí của bạn phức tạp, hãy cân nhắc cache các giá trị trung gian để tránh tính toán lại không cần thiết trong mỗi frame. Ứng dụng thực tế: "Cuộc sống là một sân khấu lớn" FlowDelegate không phải là một ngôi sao thường xuyên xuất hiện trên các ứng dụng phổ thông, nhưng nó là một "ngôi sao thầm lặng" trong các trường hợp đặc biệt cần đến sự tinh tế và hiệu năng: Radial Action Buttons: Giống như ví dụ chúng ta vừa làm, nhiều ứng dụng có một nút hành động nổi (FAB) ở góc màn hình, khi nhấn vào, nó bung ra một loạt các tùy chọn nhỏ hơn theo hình quạt. Đây chính là mảnh đất màu mỡ cho FlowDelegate. Tag Clouds/Dynamic Tag Layouts: Trong các ứng dụng có nhiều thẻ (tag) cần hiển thị một cách linh hoạt, có thể chồng chéo hoặc sắp xếp ngẫu nhiên nhưng vẫn đảm bảo tính thẩm mỹ và hiệu năng cao. Custom Loading Animations: Các hiệu ứng loading phức tạp, nơi các phần tử nhỏ di chuyển theo quỹ đạo đặc biệt (ví dụ: xoay quanh một điểm, sắp xếp lại theo hình dạng động). Interactive Galleries/Image Viewers: Trong một số trường hợp đặc biệt, khi bạn muốn tạo hiệu ứng xem ảnh độc đáo, nơi các ảnh con có thể xoay, phóng to, thu nhỏ và di chuyển theo cử chỉ người dùng, FlowDelegate có thể là một công cụ mạnh mẽ. Nhớ nhé, FlowDelegate không phải là công cụ bạn dùng hàng ngày, nhưng khi bạn cần một màn trình diễn bố cục "đỉnh cao", mượt mà và hiệu quả, nó chính là bí kíp cuối cùng trong túi đồ nghề của bạn. Hãy luyện tập và làm chủ nó để nâng tầm kỹ năng Flutter của mình lên một đẳng cấp mới! Chúc các bạn code vui vẻ! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

81 Đọc tiếp
FlexibleSpaceBar: Bí kíp tạo AppBar biến hình trong Flutter
18/03/2026

FlexibleSpaceBar: Bí kíp tạo AppBar biến hình trong Flutter

Chào các lập trình viên tương lai, hoặc những người đã "lăn lộn" với code đủ để hiểu rằng UI/UX không chỉ là màu mè mà là linh hồn của ứng dụng! Tôi là Creyt, và hôm nay chúng ta sẽ mổ xẻ một "phù thủy" trong thế giới Flutter: FlexibleSpaceBar. 1. FlexibleSpaceBar là gì và tại sao chúng ta cần nó? Hãy hình dung thế này: Bạn có một chiếc xe thể thao siêu ngầu, nhưng đôi khi bạn muốn nó biến hình thành một chiếc SUV đa dụng để chở đồ, rồi lại thu gọn thành xe đua khi cần tốc độ. Trong thế giới Flutter, FlexibleSpaceBar chính là cái "bộ phận biến hình" đó cho AppBar của bạn. Nói một cách hàn lâm hơn, FlexibleSpaceBar là một widget được thiết kế để hoạt động bên trong SliverAppBar. Nhiệm vụ chính của nó là tạo ra một vùng không gian linh hoạt ở phần đầu ứng dụng, cho phép AppBar của bạn co giãn một cách mượt mà khi người dùng cuộn (scroll). Khi bạn cuộn xuống, FlexibleSpaceBar có thể mở rộng ra, tiết lộ thêm nội dung như một hình ảnh nền lớn, một tiêu đề ấn tượng, hoặc bất kỳ widget nào bạn muốn. Ngược lại, khi bạn cuộn lên, nó sẽ thu gọn lại, để lại một AppBar nhỏ gọn, tinh tế. Tại sao chúng ta cần nó ư? Đơn giản thôi. Trong thời đại mà người dùng đòi hỏi trải nghiệm mượt mà, trực quan, thì một AppBar tĩnh như "tượng đài" là không đủ. FlexibleSpaceBar giúp ứng dụng của bạn trở nên sống động, tương tác hơn, tạo hiệu ứng parallax (hiệu ứng thị sai) cực kỳ hút mắt, khiến người dùng cảm thấy như đang lướt trên một trang web cao cấp chứ không phải một ứng dụng di động thông thường. Nó là chìa khóa để biến những giao diện "đơn thuần" thành "đẳng cấp". 2. Code Ví Dụ Minh Họa: Biến hình cùng FlexibleSpaceBar Để FlexibleSpaceBar phát huy sức mạnh, chúng ta cần đặt nó vào đúng "khung sườn" của nó, đó chính là SliverAppBar, và tất cả sẽ nằm trong một CustomScrollView. Nghe có vẻ phức tạp, nhưng hãy xem ví dụ này, nó đơn giản hơn bạn nghĩ nhiều: import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'FlexibleSpaceBar Demo', theme: ThemeData( primarySwatch: Colors.blue, appBarTheme: const AppBarTheme( backgroundColor: Colors.deepPurple, // Màu nền mặc định cho AppBar ), ), home: const FlexibleSpaceBarExample(), ); } } class FlexibleSpaceBarExample extends StatelessWidget { const FlexibleSpaceBarExample({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: <Widget>[ SliverAppBar( expandedHeight: 250.0, // Chiều cao tối đa khi App Bar mở rộng floating: false, // Không trôi nổi khi cuộn xuống một chút pinned: true, // Luôn ghim App Bar ở trên cùng khi thu gọn flexibleSpace: FlexibleSpaceBar( centerTitle: true, // Căn giữa tiêu đề khi App Bar thu gọn title: const Text( 'Lớp Học Flutter Của Thầy Creyt', style: TextStyle( color: Colors.white, fontSize: 18.0, fontWeight: FontWeight.bold, ), ), background: Image.network( 'https://picsum.photos/id/1043/800/400', // Hình nền sẽ co giãn và tạo hiệu ứng parallax fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ); }, errorBuilder: (context, error, stackTrace) => const Center(child: Icon(Icons.error)), ), ), ), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return Container( color: index.isOdd ? Colors.white : Colors.grey[100], height: 120.0, child: Center( child: Text( 'Bài học số ${index + 1}: Khái niệm Flutter nâng cao', style: const TextStyle(fontSize: 18, color: Colors.black87), ), ), ); }, childCount: 25, // Tạo 25 mục để có thể cuộn ), ), ], ), ); } } Trong ví dụ trên: CustomScrollView: Là "con đường" chứa tất cả các Sliver (các phần có thể cuộn được). SliverAppBar: Là "chiếc xe" của chúng ta. expandedHeight: Xác định chiều cao tối đa của AppBar khi nó được mở rộng hoàn toàn. Hãy coi nó là "chiều cao khi xe biến hình thành SUV". pinned: true: Đảm bảo rằng khi bạn cuộn lên, AppBar sẽ thu gọn lại và "ghim" ở đầu màn hình, không biến mất hoàn toàn. flexibleSpace: FlexibleSpaceBar(...): Đây chính là "bộ phận biến hình" mà chúng ta đang nói đến. title: Widget này (thường là Text) sẽ xuất hiện khi AppBar thu gọn và sẽ di chuyển, mờ dần/hiện ra khi AppBar co giãn. background: Đây là nơi bạn đặt hình ảnh hoặc bất kỳ widget nào mà bạn muốn nó xuất hiện ở phần nền của AppBar khi nó mở rộng. Nó sẽ tự động tạo hiệu ứng parallax khi cuộn. centerTitle: true: Khi AppBar thu gọn, tiêu đề sẽ được căn giữa. 3. Mẹo Vặt & Best Practices Từ Lão Làng Creyt Để dùng FlexibleSpaceBar một cách "thượng thừa", đây là vài mẹo nhỏ mà tôi đã đúc kết được sau bao năm "xông pha trận mạc": Hiệu ứng Parallax đỉnh cao: Hãy luôn đặt một Image.network hoặc Image.asset vào thuộc tính background của FlexibleSpaceBar. Flutter sẽ tự động xử lý hiệu ứng parallax (hình nền di chuyển chậm hơn nội dung) một cách mượt mà, tạo cảm giác chiều sâu rất ấn tượng. Kết hợp SliverAppBar: pinned: true: Gần như luôn luôn nên dùng true. Nó giữ cho AppBar thu gọn ở trên cùng, mang lại trải nghiệm người dùng quen thuộc và tiện lợi. floating: true và snap: true: Nếu bạn muốn AppBar tự động hiện lại ngay lập tức khi người dùng cuộn xuống một chút (thay vì phải cuộn hết lên đầu), hãy dùng floating: true. Kết hợp với snap: true, nó sẽ tự động "bật" ra hoặc "thu" lại hoàn toàn thay vì dừng ở lưng chừng. expandedHeight: Đừng quá ham hố chiều cao lớn. Hãy chọn một giá trị vừa phải (ví dụ: 150-250px) để không chiếm quá nhiều không gian màn hình trên các thiết bị nhỏ. Nội dung title: Tiêu đề trong FlexibleSpaceBar sẽ tự động thay đổi kích thước và vị trí. Hãy giữ nó ngắn gọn, súc tích để dễ đọc khi AppBar thu gọn. Nếu cần nhiều thông tin hơn, hãy cân nhắc dùng một Stack trong background để chồng các widget lên nhau. Tránh "nhồi nhét": Mặc dù bạn có thể đặt bất kỳ widget nào vào background, đừng nhồi nhét quá nhiều logic phức tạp hoặc widget nặng nề vào đó. Mục đích chính là tạo hiệu ứng thị giác, không phải là nơi chứa đựng toàn bộ giao diện. 4. Ứng Dụng Thực Tế: Ai đang dùng "phép thuật" này? Bạn có thể không nhận ra, nhưng hiệu ứng của FlexibleSpaceBar đã và đang được rất nhiều ứng dụng lớn sử dụng để nâng tầm trải nghiệm người dùng: Ứng dụng Mạng xã hội: Hãy mở profile của bạn trên LinkedIn, Facebook, hoặc Instagram. Bạn sẽ thấy hình ảnh bìa (cover photo) thường chiếm một diện tích lớn ở trên cùng, và khi bạn cuộn xuống xem các bài đăng, hình ảnh đó sẽ thu nhỏ dần, hoặc biến mất, để lại một thanh tiêu đề nhỏ gọn. Đó chính là hiệu ứng tương tự mà FlexibleSpaceBar mang lại. Ứng dụng Đọc tin tức/Blog: Khi bạn đọc một bài báo trên các ứng dụng như Google News hay các trang blog lớn, phần tiêu đề hoặc hình ảnh đại diện của bài viết thường rất lớn ở đầu trang, sau đó co lại khi bạn cuộn xuống nội dung. Trang sản phẩm E-commerce: Các ứng dụng mua sắm như Shopee, Lazada (hoặc các phiên bản quốc tế như Amazon) thường có trang chi tiết sản phẩm với một carousel hình ảnh lớn ở trên cùng. Khi cuộn, phần này cũng sẽ thu gọn lại. Ứng dụng Âm nhạc/Video: Các trang thông tin về album, nghệ sĩ trên Spotify, YouTube Music hay trang kênh trên YouTube cũng thường dùng hiệu ứng này để hiển thị ảnh bìa album/kênh và thông tin, sau đó thu gọn khi người dùng cuộn qua danh sách bài hát/video. Tóm lại, FlexibleSpaceBar không chỉ là một widget đẹp mắt, mà còn là một công cụ mạnh mẽ để tạo ra các giao diện động, tương tác và chuyên nghiệp trong ứng dụng Flutter của bạn. Hãy thử nghiệm và sáng tạo với nó nhé! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

66 Đọc tiếp
Mở Một Lần Một Thôi! Flutter ExpansionPanelListRadio
18/03/2026

Mở Một Lần Một Thôi! Flutter ExpansionPanelListRadio

ExpansionPanelListRadio: Ông Chủ Của Sự Ngăn Nắp Chào các bạn, lại là Creyt đây! Hôm nay chúng ta sẽ "giải mã" một widget mà thoạt nghe có vẻ phức tạp nhưng thực ra lại là "trợ thủ đắc lực" cho sự gọn gàng và tập trung trong giao diện người dùng của chúng ta: ExpansionPanelListRadio. Bạn cứ hình dung thế này: trong thế giới lập trình, đôi khi chúng ta cần hiển thị một danh sách các lựa chọn hoặc thông tin chi tiết, nhưng nếu cứ "phanh phui" tất cả ra cùng lúc thì màn hình của bạn sẽ trông như một bãi chiến trường vậy. ExpansionPanelListRadio sinh ra để giải quyết vấn đề đó. Nó giống như một cái tủ quần áo thần kỳ của Doraemon, bạn có nhiều ngăn kéo (các panel), nhưng tại một thời điểm, chỉ được phép mở một ngăn duy nhất để lấy đồ thôi. Rất tiện lợi, phải không? Về cơ bản, nó là gì và để làm gì? ExpansionPanelListRadio là một widget trong Flutter cho phép bạn tạo một danh sách các bảng điều khiển (panels) có thể mở rộng. Điều đặc biệt ở đây, như cái tên "Radio" đã gợi ý, là nó sẽ tự động đảm bảo rằng chỉ một panel duy nhất có thể được mở rộng tại bất kỳ thời điểm nào. Khi bạn mở một panel khác, panel đang mở trước đó sẽ tự động đóng lại. Nó cực kỳ hữu ích trong các tình huống sau: Các câu hỏi thường gặp (FAQ): Người dùng chỉ cần mở câu trả lời cho câu hỏi họ quan tâm, tránh việc phải cuộn qua một danh sách dài các câu trả lời. Lựa chọn cấu hình sản phẩm: Ví dụ, khi bạn chọn "Màu sắc", panel chọn màu sẽ mở ra, và khi bạn chọn "Kích cỡ", panel màu sẽ đóng lại và panel kích cỡ mở ra. Hướng dẫn từng bước: Chỉ hiển thị chi tiết cho bước hiện tại. Các bộ lọc (filters) trong ứng dụng thương mại điện tử: Khi bạn chọn một danh mục lọc, các tùy chọn chi tiết của danh mục đó hiện ra, và khi bạn chọn danh mục khác, danh mục cũ sẽ ẩn đi. Code Ví Dụ Minh Họa: "Tủ Đồ Thông Minh" Để ExpansionPanelListRadio hoạt động, chúng ta cần một StatefulWidget để quản lý trạng thái của panel đang mở. Hãy xem ví dụ dưới đây: import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'ExpansionPanelListRadio Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage(), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key}); @override State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { // Biến để lưu trữ giá trị của panel đang mở. // Null nghĩa là không có panel nào mở. // Đây là "chìa khóa" để ExpansionPanelListRadio biết panel nào đang active. Object? _currentOpenPanelValue; final List<Item> _data = generateItems(5); // Tạo 5 item mẫu @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Tủ Đồ Thông Minh Của Creyt'), ), body: SingleChildScrollView( child: ExpansionPanelList.radio( // Giá trị của panel được mở ban đầu. // Nếu không set, mặc định sẽ không có panel nào mở. initialOpenPanelValue: _currentOpenPanelValue, // Callback khi trạng thái mở/đóng của panel thay đổi. // `value` là giá trị của panel vừa được mở/đóng. // `isExpanded` là trạng thái mới của panel đó. onExpansionChanged: (Object value, bool isExpanded) { setState(() { // Nếu panel được mở, lưu giá trị của nó. // Nếu panel đóng (do người dùng click lại hoặc mở panel khác), // thì _currentOpenPanelValue sẽ được set thành null hoặc giá trị của panel mới. _currentOpenPanelValue = isExpanded ? value : null; }); print('Panel with value $value is now expanded: $isExpanded'); }, // Danh sách các ExpansionPanelRadio con. children: _data.map<ExpansionPanelRadio>((Item item) { return ExpansionPanelRadio( value: item.id, // Giá trị duy nhất cho mỗi panel. RẤT QUAN TRỌNG! headerBuilder: (BuildContext context, bool isExpanded) { return ListTile( title: Text(item.headerValue), leading: Icon(isExpanded ? Icons.folder_open : Icons.folder), ); }, body: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(item.expandedValue), const SizedBox(height: 10), ElevatedButton( onPressed: () { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Bạn vừa chọn: ${item.headerValue}')), ); }, child: const Text('Chọn mục này'), ), ], ), ), // Cho phép người dùng nhấn vào header để mở/đóng panel. canTapOnHeader: true, ); }).toList(), ), ), ); } } // Lớp mẫu để chứa dữ liệu cho mỗi panel class Item { Item({ required this.id, required this.headerValue, required this.expandedValue, }); Object id; // Dùng Object để linh hoạt, thường là int hoặc String String headerValue; String expandedValue; } // Hàm tạo dữ liệu mẫu List<Item> generateItems(int numberOfItems) { return List<Item>.generate(numberOfItems, (int index) { return Item( id: index, headerValue: 'Ngăn Kéo Số ${index + 1}', expandedValue: 'Đây là nội dung chi tiết của Ngăn Kéo Số ${index + 1}. Bạn có thể đặt bất cứ widget nào vào đây.', ); }); } Trong ví dụ trên: Chúng ta tạo một List<Item> để mô phỏng dữ liệu cho các panel. _currentOpenPanelValue là biến Object? quản lý panel nào đang được mở. Khi onExpansionChanged được gọi, chúng ta cập nhật biến này để ExpansionPanelListRadio biết trạng thái mới. Mỗi ExpansionPanelRadio cần một value duy nhất. Đây là "định danh" để widget biết panel nào đang được thao tác. headerBuilder xây dựng phần tiêu đề của panel, và body là nội dung sẽ hiển thị khi panel được mở. canTapOnHeader: true là một chi tiết nhỏ nhưng quan trọng, giúp người dùng có thể chạm vào tiêu đề để mở/đóng, thay vì chỉ mũi tên. Mẹo Hay Từ Giảng Viên Creyt (Best Practices) Luôn dùng StatefulWidget: ExpansionPanelListRadio cần một biến trạng thái để theo dõi panel nào đang mở (initialOpenPanelValue). Nếu bạn dùng StatelessWidget mà không có cơ chế quản lý trạng thái bên ngoài (như Provider, BLoC, Riverpod), nó sẽ không hoạt động như mong đợi. value phải DUY NHẤT: Đây là "chìa khóa" để ExpansionPanelListRadio xác định các panel. Nếu các value bị trùng lặp, hành vi của widget sẽ không đúng. Tốt nhất là dùng int hoặc String làm ID duy nhất. Quản lý initialOpenPanelValue: Biến này không chỉ dùng để thiết lập panel mở ban đầu mà còn được ExpansionPanelListRadio sử dụng nội bộ để biết panel nào đang mở. Luôn cập nhật nó trong onExpansionChanged để đồng bộ trạng thái. canTapOnHeader là bạn của người dùng: Mặc định, chỉ có mũi tên nhỏ ở cuối header mới có thể mở/đóng panel. Bật canTapOnHeader: true sẽ giúp trải nghiệm người dùng mượt mà hơn rất nhiều. Nội dung body linh hoạt: Bạn có thể đặt bất kỳ widget phức tạp nào vào phần body của ExpansionPanelRadio, từ Column, Row, Form cho đến các ListView lồng nhau. Hãy tận dụng sự linh hoạt này! Ứng Dụng Thực Tế: "Ai Đã Dùng Nó?" Bạn có thể thấy ExpansionPanelListRadio (hoặc các biến thể của nó) được sử dụng rộng rãi trong nhiều ứng dụng và trang web hàng ngày: Các ứng dụng ngân hàng/tài chính: Phần FAQ, hoặc mục "Hỗ trợ" nơi bạn có thể mở các câu hỏi về tài khoản, thẻ tín dụng, v.v., nhưng chỉ một câu trả lời hiện ra mỗi lần. Ứng dụng mua sắm (E-commerce): Trong phần bộ lọc sản phẩm, bạn có thể thấy các mục như "Thương hiệu", "Kích cỡ", "Màu sắc". Khi bạn mở "Thương hiệu", các lựa chọn về thương hiệu hiện ra, và nếu bạn mở "Kích cỡ", phần thương hiệu sẽ tự động đóng lại. Ứng dụng học tập/khóa học online: Các mục lục bài giảng hoặc FAQ về khóa học. Các trang cài đặt (Settings): Đôi khi các cài đặt được nhóm lại thành các panel, và bạn chỉ có thể mở một nhóm cài đặt tại một thời điểm để điều chỉnh. Tóm lại, ExpansionPanelListRadio không chỉ là một widget đẹp mắt mà còn là một công cụ mạnh mẽ để tạo ra giao diện người dùng gọn gàng, có tổ chức và tập trung. Hãy thử nghiệm và biến nó thành "đồ nghề" của bạn nhé! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

47 Đọc tiếp
Flutter ExpansionPanel: Mở khóa UI linh hoạt, tối ưu trải nghiệm
18/03/2026

Flutter ExpansionPanel: Mở khóa UI linh hoạt, tối ưu trải nghiệm

Chào mừng các bạn đến với buổi học hôm nay cùng Giảng viên Creyt! Hôm nay, chúng ta sẽ cùng nhau "bóc tách" một widget cực kỳ hữu ích trong Flutter, đó là ExpansionPanel. ExpansionPanel là gì? Để làm gì? Để dễ hình dung, các bạn cứ tưởng tượng thế này: ExpansionPanel giống như một "chiếc rèm sân khấu mini" trên ứng dụng của bạn vậy. Ban đầu, nó chỉ là một cái tiêu đề nhỏ gọn, kín đáo – cái "rèm" đang buông xuống. Nhưng khi người dùng "kéo rèm lên" (tức là chạm vào tiêu đề), "vở diễn" (nội dung chi tiết) bên trong sẽ từ từ hiện ra, bung nở đầy đủ. Khi không cần nữa, "rèm" lại buông xuống, trả lại không gian gọn gàng cho sân khấu màn hình. Vậy nó để làm gì? Đơn giản là để giải quyết bài toán không gian và sự tập trung của người dùng. Trong một thế giới di động mà diện tích màn hình là vàng, việc nhồi nhét mọi thứ vào cùng một lúc sẽ khiến người dùng "ngộp thở". ExpansionPanel giúp bạn: Tiết kiệm không gian: Chỉ hiển thị những gì cần thiết ngay lập tức (tiêu đề), giữ cho giao diện luôn thoáng đãng. Tổ chức nội dung: Nhóm các thông tin liên quan lại với nhau một cách logic, dễ quản lý. Cải thiện trải nghiệm người dùng (UX): Giảm tải nhận thức (cognitive load). Người dùng chỉ cần tập trung vào thông tin họ muốn xem, và có thể "ẩn" nó đi khi không cần nữa. Đây là một nguyên tắc thiết kế UI/UX cốt lõi, giúp người dùng cảm thấy kiểm soát được ứng dụng. Nói theo kiểu Harvard một chút, ExpansionPanel là một ví dụ điển hình của "progressive disclosure" (tiết lộ dần dần) – một kỹ thuật thiết kế giao diện giúp giảm độ phức tạp bằng cách chỉ hiển thị thông tin khi người dùng yêu cầu. Nó giúp duy trì sự đơn giản ở cấp độ bề mặt, nhưng vẫn cung cấp chiều sâu khi cần thiết. Code Ví Dụ Minh Hoạ Rõ Ràng Để sử dụng ExpansionPanel, chúng ta thường dùng nó trong một danh sách gọi là ExpansionPanelList. Widget này sẽ quản lý nhiều ExpansionPanel con. Chúng ta cần một StatefulWidget để quản lý trạng thái mở/đóng của từng panel. Đây là một ví dụ đơn giản: import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'ExpansionPanel Demo của Creyt', theme: ThemeData(primarySwatch: Colors.blueGrey), home: const ExpansionPanelScreen(), ); } } // Lớp dữ liệu cho mỗi item trong ExpansionPanelList class Item { Item({ required this.expandedValue, required this.headerValue, this.isExpanded = false, }); String expandedValue; String headerValue; bool isExpanded; } List<Item> generateItems(int numberOfItems) { return List<Item>.generate(numberOfItems, (int index) { return Item( headerValue: 'Tiêu đề Panel ${index + 1}', expandedValue: 'Đây là nội dung chi tiết của Panel ${index + 1}. Bạn có thể đặt bất kỳ widget nào vào đây, từ văn bản đến hình ảnh hay các form nhập liệu phức tạp.', ); }); } class ExpansionPanelScreen extends StatefulWidget { const ExpansionPanelScreen({super.key}); @override State<ExpansionPanelScreen> createState() => _ExpansionPanelScreenState(); } class _ExpansionPanelScreenState extends State<ExpansionPanelScreen> { final List<Item> _data = generateItems(3); // Tạo 3 panel mẫu @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('ExpansionPanel của Creyt'), ), body: SingleChildScrollView( child: ExpansionPanelList( expansionCallback: (int index, bool isExpanded) { setState(() { _data[index].isExpanded = !isExpanded; }); }, children: _data.map<ExpansionPanel>((Item item) { return ExpansionPanel( headerBuilder: (BuildContext context, bool isExpanded) { return ListTile( title: Text(item.headerValue), leading: Icon(isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down), ); }, body: Padding( padding: const EdgeInsets.all(16.0), child: Text(item.expandedValue), ), isExpanded: item.isExpanded, ); }).toList(), ), ), ); } } Trong đoạn code trên: Chúng ta tạo một lớp Item để quản lý dữ liệu cho mỗi panel, bao gồm tiêu đề (headerValue), nội dung (expandedValue) và trạng thái mở/đóng (isExpanded). ExpansionPanelList là widget chính chứa các ExpansionPanel. expansionCallback là hàm được gọi khi người dùng chạm vào tiêu đề của một panel. Tại đây, chúng ta setState để cập nhật trạng thái isExpanded của item tương ứng, khiến panel đóng/mở. Mỗi ExpansionPanel có headerBuilder (xây dựng phần tiêu đề) và body (nội dung khi mở). isExpanded là thuộc tính quan trọng để điều khiển trạng thái mở hay đóng của panel. Mẹo (Best Practices) từ Giảng viên Creyt Quản lý trạng thái là chìa khóa: Đừng bao giờ quên isExpanded! Nó là "tay lái" điều khiển "chiếc rèm sân khấu" của bạn. Luôn setState đúng cách khi trạng thái thay đổi để UI được cập nhật. Đừng lạm dụng: Mặc dù ExpansionPanel rất tiện lợi, nhưng nếu bạn có quá nhiều panel với nội dung cực kỳ dài hoặc phức tạp, hãy cân nhắc giải pháp khác như chuyển sang một màn hình riêng hoặc sử dụng tab. Performance có thể bị ảnh hưởng nếu bạn render quá nhiều widget ẩn. Nội dung header phải rõ ràng: Tiêu đề của mỗi panel phải đủ súc tích và mô tả được nội dung bên trong, để người dùng không cần mở ra cũng biết đại khái nó nói về cái gì. "Quy tắc 3 giây" – người dùng nên hiểu ngay lập tức. Tùy biến linh hoạt: headerBuilder và body đều nhận vào Widget, nghĩa là bạn có thể đặt bất cứ thứ gì vào đó – từ text đơn giản đến các form phức tạp, hình ảnh, hay thậm chí là một ListView con. Hãy sáng tạo! ExpansionPanelList.radio: Nếu bạn chỉ muốn một panel được mở tại một thời điểm (kiểu "radio button"), hãy dùng ExpansionPanelList.radio thay vì ExpansionPanelList thông thường. Nó tự động quản lý việc đóng các panel khác khi một panel mới được mở. Ví Dụ Thực Tế Các Ứng Dụng/Website Đã Ứng Dụng ExpansionPanel, hay các biến thể của nó (còn gọi là Accordion trong web design), được sử dụng rộng rãi đến mức bạn có thể thấy nó ở khắp mọi nơi: Trang FAQ (Câu hỏi thường gặp): Đây là ứng dụng kinh điển nhất. Một danh sách các câu hỏi, mỗi câu khi click vào sẽ hiện câu trả lời chi tiết. Tiết kiệm không gian cực hiệu quả. Trang Cài đặt (Settings/Preferences): Trong các ứng dụng di động, các nhóm cài đặt thường được gom lại thành các panel có thể mở rộng. Ví dụ: "Cài đặt tài khoản", "Cài đặt thông báo", "Cài đặt bảo mật". Trang Chi tiết sản phẩm trên E-commerce: Các phần như "Thông số kỹ thuật", "Mô tả sản phẩm", "Đánh giá" thường được đặt trong các panel có thể mở rộng để trang sản phẩm không quá dài. Các bước hướng dẫn (Tutorials/Onboarding): Khi bạn cần hướng dẫn người dùng qua nhiều bước, mỗi bước có thể là một panel, mở ra từng bước một. Menu điều hướng phức tạp: Trong một số trường hợp, menu có nhiều cấp con cũng có thể sử dụng ExpansionPanel để tổ chức. Như vậy, ExpansionPanel không chỉ là một widget đơn thuần, mà nó là một công cụ mạnh mẽ giúp bạn thiết kế giao diện thông minh, thân thiện và hiệu quả. Hãy vận dụng nó thật khéo léo vào các dự án của mình nhé! Hẹn gặp lại trong bài học tiếp theo! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

61 Đọc tiếp
EdgeInsetsDirectional: Định Hướng Khoảng Trắng Chuẩn Flutter
18/03/2026

EdgeInsetsDirectional: Định Hướng Khoảng Trắng Chuẩn Flutter

Chào các lập trình viên tương lai! Anh Creyt đây, và hôm nay chúng ta sẽ cùng mổ xẻ một khái niệm tưởng chừng đơn giản nhưng lại cực kỳ quan trọng trong việc xây dựng giao diện người dùng (UI) chuyên nghiệp và toàn cầu hóa với Flutter: EdgeInsetsDirectional. 1. EdgeInsetsDirectional là gì và để làm gì? Bạn thấy đấy, trong thế giới lập trình, đôi khi những chi tiết nhỏ lại là những người hùng thầm lặng, và EdgeInsetsDirectional chính là một trong số đó. Hãy hình dung bạn là một kiến trúc sư tài ba, thiết kế một ngôi nhà. Bạn không chỉ đặt gạch mà còn phải tính toán khoảng cách, lối đi để ngôi nhà có không gian thở, đúng không? Trong Flutter, Padding và Margin là những 'khoảng thở' đó, và chúng ta thường dùng EdgeInsets để định nghĩa chúng. Nhưng có một vấn đề: thế giới không chỉ có tiếng Anh! Có những ngôn ngữ đọc từ trái sang phải (Left-to-Right - LTR) như tiếng Việt, tiếng Anh, nhưng cũng có những ngôn ngữ đọc từ phải sang trái (Right-to-Left - RTL) như tiếng Ả Rập, tiếng Do Thái. Nếu bạn cứ cứng nhắc dùng EdgeInsets.only(left: 10.0, right: 20.0), thì khi giao diện của bạn chuyển sang chế độ RTL, cái 'padding bên trái' vẫn nằm nguyên bên trái, trong khi lẽ ra nó phải chuyển sang bên phải để phù hợp với hướng đọc mới. UI của bạn sẽ trông như bị 'lật ngược' một cách ngớ ngẩn. Đó là lúc EdgeInsetsDirectional bước ra ánh sáng! Nó là một phiên bản thông minh hơn của EdgeInsets, được thiết kế để tự động thích nghi với hướng văn bản hiện tại của ứng dụng. Thay vì dùng left và right, bạn sẽ dùng start và end. start: Tương ứng với left khi hướng văn bản là LTR, và right khi hướng văn bản là RTL. end: Tương ứng với right khi hướng văn bản là LTR, và left khi hướng văn bản là RTL. Nói cách khác, EdgeInsetsDirectional giúp UI của bạn 'biết đọc xuôi hay đọc ngược', đảm bảo khoảng cách luôn được áp dụng đúng vị trí, bất kể ngôn ngữ nào. Nó là nền tảng cho việc quốc tế hóa (Internationalization - i18n) một cách mượt mà. 2. Code Ví Dụ Minh Họa Rõ Ràng Để bạn thấy rõ sự khác biệt, anh Creyt sẽ trình diễn một ví dụ nhỏ. Chúng ta sẽ tạo ra hai chiếc Card, một chiếc trong môi trường LTR và một chiếc trong môi trường RTL, và xem EdgeInsetsDirectional hoạt động như thế nào. import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'EdgeInsetsDirectional Demo', theme: ThemeData(primarySwatch: Colors.blue), home: Scaffold( appBar: AppBar(title: const Text('EdgeInsetsDirectional Demo')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Ví dụ LTR (Left-to-Right) Directionality( textDirection: TextDirection.ltr, // Đặt hướng văn bản là LTR child: MyDirectionalCard(title: 'LTR Card (Start=30, End=10)', color: Colors.blue.shade100), ), const SizedBox(height: 20), // Ví dụ RTL (Right-to-Left) Directionality( textDirection: TextDirection.rtl, // Đặt hướng văn bản là RTL child: MyDirectionalCard(title: 'RTL Card (Start=30, End=10)', color: Colors.green.shade100), ), const SizedBox(height: 20), // Ví dụ với EdgeInsets.only (để so sánh) Card( color: Colors.red.shade100, margin: const EdgeInsets.only(left: 30.0, right: 10.0, top: 10.0, bottom: 10.0), // Cố định left/right child: const Padding( padding: EdgeInsets.symmetric(horizontal: 20.0, vertical: 15.0), child: Text( 'Fixed Left/Right Padding', style: TextStyle(fontSize: 16), ), ), ), ], ), ), ), ); } } class MyDirectionalCard extends StatelessWidget { final String title; final Color color; const MyDirectionalCard({Key? key, required this.title, required this.color}) : super(key: key); @override Widget build(BuildContext context) { return Card( color: color, // Đây là lúc EdgeInsetsDirectional thể hiện sức mạnh! // 'start' sẽ là bên trái trong LTR, và bên phải trong RTL. // 'end' sẽ là bên phải trong LTR, và bên trái trong RTL. margin: const EdgeInsetsDirectional.only(start: 30.0, end: 10.0, top: 10.0, bottom: 10.0), child: Padding( padding: const EdgeInsetsDirectional.symmetric(horizontal: 20.0, vertical: 15.0), child: Text( title, style: const TextStyle(fontSize: 16), ), ), ); } } Trong ví dụ trên, hãy chú ý cách margin của MyDirectionalCard được định nghĩa bằng EdgeInsetsDirectional.only(start: 30.0, end: 10.0). Khi bạn chạy ứng dụng: Với textDirection: TextDirection.ltr, Card sẽ có 30.0 padding ở bên trái (start) và 10.0 ở bên phải (end). Với textDirection: TextDirection.rtl, Card sẽ có 30.0 padding ở bên phải (start) và 10.0 ở bên trái (end). Còn chiếc Card cuối cùng sử dụng EdgeInsets.only(left: 30.0, right: 10.0) thì sao? Nó sẽ luôn có padding 30.0 ở bên trái vật lý và 10.0 ở bên phải vật lý, bất kể hướng văn bản là gì. Bạn sẽ thấy ngay sự khác biệt về trực quan! 3. Mẹo (Best Practices) để ghi nhớ hoặc dùng thực tế Luôn ưu tiên EdgeInsetsDirectional cho khoảng cách ngang: Bất cứ khi nào bạn cần định nghĩa padding hoặc margin theo chiều ngang (trái/phải), hãy nghĩ ngay đến EdgeInsetsDirectional. Nó là lựa chọn an toàn và linh hoạt nhất cho các ứng dụng đa ngôn ngữ. start và end, không phải left và right: Hãy tập thói quen dùng start (nơi văn bản bắt đầu) và end (nơi văn bản kết thúc). Đây là tư duy quan trọng cho UI quốc tế hóa. top và bottom là bất biến: Đối với khoảng cách theo chiều dọc (top, bottom), bạn vẫn có thể an tâm sử dụng EdgeInsets.only(top: ..., bottom: ...) vì chúng không bị ảnh hưởng bởi hướng văn bản. Kiểm tra với Directionality: Khi phát triển, hãy chủ động dùng Directionality widget để kiểm tra giao diện của bạn trong cả hai chế độ LTR và RTL. Đừng đợi đến khi deploy mới phát hiện lỗi. Hiểu rõ ngữ cảnh: EdgeInsetsDirectional hoạt động dựa trên TextDirection của BuildContext hiện tại. Thường thì MaterialApp sẽ cung cấp TextDirection mặc định dựa trên ngôn ngữ thiết bị, nhưng bạn có thể ghi đè bằng Directionality. 4. Ứng dụng/Website đã ứng dụng Thực tế, hầu hết các ứng dụng và website lớn, có phạm vi toàn cầu đều phải sử dụng các cơ chế tương tự EdgeInsetsDirectional để đảm bảo trải nghiệm người dùng liền mạch. Bạn có thể thấy điều này ở: Facebook, Twitter, Instagram: Các ứng dụng mạng xã hội này phục vụ hàng tỷ người dùng trên toàn thế giới với vô số ngôn ngữ, bao gồm cả tiếng Ả Rập và tiếng Do Thái. Các phần tử UI như avatar, nút like, comment, hay các biểu tượng điều hướng đều phải 'lật' vị trí một cách thông minh để phù hợp với hướng đọc của người dùng. Google Maps, Google Search: Các sản phẩm của Google nổi tiếng về khả năng quốc tế hóa. Các thanh tìm kiếm, kết quả hiển thị, hay các chi tiết trên bản đồ đều điều chỉnh khoảng cách và vị trí để phù hợp với ngữ cảnh ngôn ngữ. WhatsApp, Telegram: Ứng dụng nhắn tin cũng là một ví dụ điển hình. Các bong bóng chat, hình ảnh đại diện, hay các biểu tượng trạng thái tin nhắn đều cần phải có padding/margin linh hoạt để hiển thị đúng trong cả LTR và RTL. Tóm lại, EdgeInsetsDirectional không chỉ là một công cụ tiện lợi, mà nó còn là một tư duy, một triết lý thiết kế UI hướng tới sự toàn cầu hóa và trải nghiệm người dùng tối ưu. Hãy biến nó thành một phần không thể thiếu trong bộ công cụ của bạn nhé! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

63 Đọc tiếp
Giải Mã EditableTextState: Trái Tim Bí Ẩn Của Nhập Liệu Flutter
18/03/2026

Giải Mã EditableTextState: Trái Tim Bí Ẩn Của Nhập Liệu Flutter

Chào mừng các "đệ tử" đến với buổi học hôm nay! Anh Creyt sẽ "mổ xẻ" một khái niệm mà nhiều bạn có thể đã dùng hàng ngày mà không hay biết, đó là EditableTextState trong Flutter. Nghe cái tên có vẻ "học thuật" và "khó nhằn" đúng không? Đừng lo, anh sẽ biến nó thành "món khai vị" dễ nuốt nhất.### 1. EditableTextState là gì và để làm gì?Hãy hình dung thế này: Khi bạn gõ phím vào một ô văn bản trên màn hình điện thoại, đó không chỉ là việc các ký tự hiện lên "phép thuật" đâu. Phía sau cánh gà là cả một "nhà hát" đang vận hành. Trong Flutter, EditableText chính là cái sân khấu trần trụi nhất, nơi các ký tự của bạn sẽ "biểu diễn". Nó là nền tảng cơ bản nhất cho mọi widget nhập liệu, từ TextField quen thuộc cho đến TextFormField "tinh vi" hơn.Vậy còn EditableTextState? À ha, đây chính là "người quản lý hậu trường" tài ba của cái sân khấu EditableText đó. Nó không phải là một widget mà bạn trực tiếp nhìn thấy hay tương tác, mà là một đối tượng State nội bộ, "nắm giữ vận mệnh" của mọi thứ liên quan đến việc nhập liệu:Giá trị văn bản hiện tại: Nó biết bạn đang gõ cái gì.Vị trí con trỏ (cursor): Nó luôn theo dõi "ngón tay chỉ huy" đang ở đâu.Phạm vi chọn (selection): Nó biết bạn đang "khoanh vùng" đoạn văn bản nào.Trạng thái focus: Nó quyết định khi nào thì sân khấu sáng đèn (trường nhập liệu được focus) và khi nào thì tắt đèn.Xử lý input: Nó lắng nghe từng nhịp gõ phím, từng cử chỉ vuốt chạm để cập nhật nội dung.Tóm lại, nếu EditableText là "khung xương" của một trường nhập liệu, thì EditableTextState chính là "hệ thần kinh" điều khiển mọi hoạt động của nó. Mặc dù bạn thường dùng TextField (một widget "cao cấp" hơn đã "đóng gói" sẵn EditableText và quản lý EditableTextState giúp bạn), việc hiểu về EditableTextState sẽ giúp bạn "can thiệp sâu" hơn khi cần tạo ra những trải nghiệm nhập liệu "độc nhất vô nhị" mà TextField không thể đáp ứng.### 2. Code Ví Dụ Minh Họa Rõ RàngNhư anh đã nói, EditableTextState là internal, nên ta không trực tiếp tạo ra nó. Thay vào đó, ta sẽ làm việc với EditableText và các "công cụ" đi kèm để thấy nó vận hành. Hãy xem một ví dụ cơ bản nhất của EditableText:dart<br>import 'package:flutter/material.dart';<br><br>class EditableTextDemo extends StatefulWidget {<br> const EditableTextDemo({Key? key}) : super(key: key);<br><br> @override<br> State<EditableTextDemo> createState() => _EditableTextDemoState();<br>}<br><br>class _EditableTextDemoState extends State<EditableTextDemo> {<br> // 1. TextEditingController: "Tay điều khiển" chính của nội dung văn bản.<br> final TextEditingController _textController = TextEditingController();<br><br> // 2. FocusNode: "Bộ não" quản lý trạng thái focus của trường nhập liệu.<br> final FocusNode _focusNode = FocusNode();<br><br> @override<br> void initState() {<br> super.initState();<br> // Lắng nghe sự thay đổi của văn bản thông qua controller<br> _textController.addListener(() {<br> print('Text changed: ${_textController.text}');<br> });<br> // Lắng nghe sự thay đổi của focus<br> _focusNode.addListener(() {<br> print('Focus changed: ${_focusNode.hasFocus}');<br> });<br> }<br><br> @override<br> void dispose() {<br> // Rất quan trọng: Luôn "giải phóng" controller và focus node để tránh rò rỉ bộ nhớ.<br> _textController.dispose();<br> _focusNode.dispose();<br> super.dispose();<br> }<br><br> @override<br> Widget build(BuildContext context) {<br> return Scaffold(<br> appBar: AppBar(<br> title: const Text('EditableText Demo by Creyt'),<br> ),<br> body: Center(<br> child: Padding(<br> padding: const EdgeInsets.all(16.0),<br> child: Column(<br> mainAxisAlignment: MainAxisAlignment.center,<br> children: [<br> Container(<br> padding: const EdgeInsets.all(8.0),<br> decoration: BoxDecoration(<br> border: Border.all(color: Colors.blueAccent),<br> borderRadius: BorderRadius.circular(5.0),<br> ),<br> child: EditableText(<br> controller: _textController, // Gắn controller vào EditableText<br> focusNode: _focusNode, // Gắn focus node vào EditableText<br> style: const TextStyle(<br> color: Colors.black,<br> fontSize: 18.0,<br> ),<br> cursorColor: Colors.blue, // Màu con trỏ<br> backgroundCursorColor: Colors.grey, // Màu nền con trỏ khi không focus (thường không thấy rõ)<br> autofocus: true, // Tự động focus khi widget được tạo<br> maxLines: 1, // Cho phép nhập một dòng<br> keyboardType: TextInputType.text, // Loại bàn phím<br> onChanged: (text) {<br> // Callback khi văn bản thay đổi (cũng có thể dùng listener của controller)<br> print('OnChanged: $text');<br> },<br> onSubmitted: (text) {<br> // Callback khi người dùng nhấn Enter/Done<br> print('OnSubmitted: $text');<br> _focusNode.unfocus(); // Hủy focus sau khi submit<br> },<br> ),<br> ),<br> const SizedBox(height: 20),<br> ElevatedButton(<br> onPressed: () {<br> // Đặt text programmatically thông qua controller<br> _textController.text = 'Hello Creyt!';<br> // Đặt con trỏ về cuối<br> _textController.selection = TextSelection.fromPosition(<br> TextPosition(offset: _textController.text.length),<br> );<br> _focusNode.requestFocus(); // Yêu cầu focus lại<br> },<br> child: const Text('Set Text & Focus'),<br> ),<br> ElevatedButton(<br> onPressed: () {<br> _focusNode.unfocus(); // Hủy focus<br> },<br> child: const Text('Unfocus'),<br> ),<br> ],<br> ),<br> ),<br> ),<br> );<br> }<br>}<br>Trong ví dụ trên, _textController và _focusNode chính là "cánh tay nối dài" của bạn để điều khiển EditableTextState một cách gián tiếp. Mọi thao tác như gõ chữ, chọn văn bản, di chuyển con trỏ đều được EditableTextState xử lý bên trong, và bạn "giao tiếp" với nó thông qua các đối tượng này.### 3. Mẹo (Best Practices) từ "Lão Làng" Creyt"Đường tắt" hay "Đường mòn": Hầu hết thời gian, bạn nên dùng TextField hoặc TextFormField. Chúng là những "con đường cao tốc" đã được "trải nhựa" sẵn, cung cấp đủ tính năng và xử lý hầu hết các trường hợp thông thường. Chỉ khi nào bạn cần "đi rừng", tức là cần tùy chỉnh cực kỳ sâu mà TextField không cho phép, thì mới "lội suối" dùng EditableText.TextEditingController là "Đại Sứ": Luôn coi TextEditingController là "đại sứ" của bạn trong việc giao tiếp với nội dung của trường nhập liệu. Muốn đọc, muốn ghi, muốn thay đổi con trỏ hay vùng chọn? Cứ "gọi điện" cho "đại sứ" này.FocusNode là "Người Gác Cổng": FocusNode giúp bạn kiểm soát ai được "vào cửa" (trường nhập liệu nào được focus) và ai phải "ra ngoài". Luôn dùng nó để quản lý luồng focus trong ứng dụng, đặc biệt khi có nhiều trường nhập liệu."Dọn dẹp" sau khi dùng: Đây là "kim chỉ nam" của người lập trình chuyên nghiệp. Luôn nhớ dispose() TextEditingController và FocusNode trong phương thức dispose() của StatefulWidget để tránh rò rỉ bộ nhớ. Coi như "tắt đèn, đóng cửa" sau khi rời đi vậy.Khi nào thì dùng EditableText? Khi bạn đang xây dựng một "siêu phẩm" như trình soạn thảo mã nguồn (code editor), trình soạn thảo văn bản phong phú (rich text editor) với nhiều định dạng, hoặc một trường nhập liệu có giao diện và hành vi hoàn toàn khác biệt so với mặc định. Lúc đó, EditableText sẽ là "bãi đất trống" để bạn thỏa sức "xây dựng lâu đài" của riêng mình.### 4. Ứng dụng Thực TếNghe có vẻ "hàn lâm" nhưng EditableText và EditableTextState thực sự là xương sống của nhiều thứ bạn dùng hàng ngày:Trình soạn thảo mã nguồn (Code Editors) trên di động: Các ứng dụng như "Dcoder", "AIDE" hay các trình soạn thảo Markdown trên di động. Để có thể tô màu cú pháp (syntax highlighting), hiển thị số dòng, xử lý các phím tắt phức tạp, họ phải xây dựng trên nền tảng thấp như EditableText để kiểm soát từng pixel và từng sự kiện nhập liệu.Trình soạn thảo văn bản phong phú (Rich Text Editors): Các ứng dụng ghi chú như "Notion", "Google Keep" hoặc các trình soạn thảo email. Khi bạn muốn in đậm, in nghiêng, chèn hình ảnh vào giữa đoạn văn, đó là lúc EditableText được tùy chỉnh để hỗ trợ nhiều kiểu định dạng khác nhau.Các trường nhập liệu đặc biệt: Ví dụ, một trường nhập liệu cho công thức toán học, nơi bạn gõ ký hiệu và nó tự động hiển thị dưới dạng công thức đẹp mắt. Hoặc một trường nhập liệu "tag" nơi mỗi tag là một "viên thuốc" độc lập có thể xóa riêng lẻ.Vậy đó, "đệ tử" thấy không? Ngay cả những khái niệm tưởng chừng phức tạp nhất cũng có thể được "mổ xẻ" và hiểu rõ. EditableTextState không phải là "quái vật" mà là "người thợ xây" thầm lặng, tạo nên những trải nghiệm nhập liệu mượt mà mà chúng ta vẫn thường thưởng thức. Hãy nắm vững nó để "nâng tầm" khả năng lập trình của mình nhé! Hẹn gặp lại trong bài học tiếp theo! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

94 Đọc tiếp
EditableText: "Cái ruột" của mọi khung nhập liệu trong Flutter
18/03/2026

EditableText: "Cái ruột" của mọi khung nhập liệu trong Flutter

Chào mừng các "đệ tử" đến với bài học hôm nay! Giảng viên Creyt sẽ cùng các bạn "mổ xẻ" một khái niệm mà nhiều khi chúng ta bỏ qua, nhưng nó lại là "trái tim" của mọi thứ liên quan đến nhập liệu văn bản trong Flutter: EditableText. 1. EditableText là gì và để làm gì? – "Cái ruột" trần trụi Các bạn cứ hình dung thế này: Nếu TextField là một chiếc xe hơi đã hoàn thiện, bóng loáng, có đầy đủ ghế da, điều hòa mát lạnh, thì EditableText chính là cái khung sườn (chassis) trần trụi, khối động cơ và hệ thống lái cơ bản của chiếc xe đó. Nó là widget cấp thấp nhất trong Flutter chịu trách nhiệm xử lý việc nhập liệu, chọn văn bản, và di chuyển con trỏ mà không hề có bất kỳ trang trí (decoration) hay hiệu ứng hình ảnh mặc định nào. Mục đích sinh ra của nó? Đơn giản là để bạn có toàn quyền kiểm soát! Khi bạn cần một trường nhập liệu có giao diện "độc lạ Bình Dương", hoặc một hành vi tương tác mà TextField không thể đáp ứng được (ví dụ, một trình soạn thảo code, một editor rich-text với đủ thứ định dạng), thì EditableText chính là "công cụ" bạn cần để "đẽo gọt" từ đầu. 2. Tại sao không dùng TextField luôn cho rồi? Câu hỏi hay! TextField là "người anh em" phổ biến hơn nhiều, và trong 99% trường hợp, bạn nên dùng TextField. Nó đã "đóng gói" sẵn EditableText bên trong và thêm vào hàng tá tiện ích như InputDecoration (viền, label, hint text, icon), errorText, padding, scrollPhysics... Nó giống như việc bạn mua một căn nhà đã xây sẵn, đầy đủ tiện nghi, chỉ việc dọn vào ở. Nhưng đôi khi, bạn không muốn căn nhà xây sẵn đó. Bạn muốn tự mình "thiết kế kiến trúc" từng viên gạch, từng đường dây điện để tạo ra một "kiệt tác" có một không hai. Khi đó, EditableText là "viên gạch" cơ bản nhất để bạn bắt đầu xây dựng. 3. Code Ví Dụ Minh Hoạ – "Mổ xẻ" cái ruột Để các bạn dễ hình dung, chúng ta hãy cùng xem EditableText hoạt động như thế nào. Các bạn sẽ thấy nó "trần trụi" đến mức nào! import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'EditableText Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const EditableTextScreen(), ); } } class EditableTextScreen extends StatefulWidget { const EditableTextScreen({super.key}); @override State<EditableTextScreen> createState() => _EditableTextScreenState(); } class _EditableTextScreenState extends State<EditableTextScreen> { // 1. Controller: "Người quản lý" nội dung văn bản late final TextEditingController _textController; // 2. FocusNode: "Người gác cổng" cho trạng thái tập trung (focus) late final FocusNode _focusNode; @override void initState() { super.initState(); _textController = TextEditingController(text: 'Hello Giảng viên Creyt!'); _focusNode = FocusNode(); } @override void dispose() { _textController.dispose(); // Luôn nhớ "dọn dẹp" controller _focusNode.dispose(); // Luôn nhớ "dọn dẹp" focus node super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('EditableText Demo của Creyt'), ), body: Center( child: Padding( padding: const EdgeInsets.all(20.0), child: Container( padding: const EdgeInsets.all(12.0), decoration: BoxDecoration( color: Colors.grey[200], border: Border.all(color: Colors.blueAccent, width: 2), borderRadius: BorderRadius.circular(8.0), ), child: EditableText( controller: _textController, focusNode: _focusNode, style: const TextStyle( fontSize: 20, color: Colors.black, fontWeight: FontWeight.bold, ), cursorColor: Colors.red, // Màu con trỏ backgroundCursorColor: Colors.blue, // Màu con trỏ khi không focus (ít dùng) selectionColor: Colors.lightBlue.withOpacity(0.5), // Màu vùng chọn readOnly: false, // Có cho phép sửa đổi không? maxLines: 1, // Số dòng tối đa keyboardType: TextInputType.text, autofocus: true, // Tự động focus khi widget được tạo onChanged: (text) { // Bất cứ khi nào văn bản thay đổi print('Văn bản đã thay đổi: $text'); }, onSubmitted: (text) { // Khi người dùng nhấn Enter/Done print('Người dùng đã submit: $text'); _focusNode.unfocus(); // Bỏ focus sau khi submit }, ), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () { // Thao tác với văn bản từ bên ngoài _textController.text = 'Creyt đã thay đổi nội dung!'; _focusNode.requestFocus(); // Yêu cầu focus lại }, child: const Icon(Icons.edit), ), ); } } Trong ví dụ trên, các bạn thấy EditableText chỉ cung cấp những thứ cốt lõi nhất: controller, focusNode, style, cursorColor, selectionColor và các callback như onChanged, onSubmitted. Mọi thứ về "khung viền", "nền", "padding" đều phải do bạn tự "đắp" bên ngoài bằng các widget như Container, Padding, BoxDecoration. 4. Mẹo (Best Practices) từ Giảng viên Creyt – "Bí kíp" để không "đi vào vết xe đổ" "Đừng đụng vào nó nếu không cần!": Đây là quy tắc vàng! Luôn bắt đầu với TextField. Chỉ khi nào bạn gặp phải một yêu cầu UI/UX quá đặc biệt mà TextField không thể đáp ứng, hoặc bạn cần tối ưu hiệu năng cực đoan cho một lượng lớn input, thì mới nghĩ đến EditableText. Nó giống như việc bạn chỉ nên tự xây nhà khi bạn là kiến trúc sư và thợ xây lành nghề, chứ không phải chỉ vì muốn "thử cho biết". Quản lý TextEditingController và FocusNode: Hai "anh bạn" này cực kỳ quan trọng và hay bị quên. Luôn nhớ khai báo chúng bằng late final hoặc khởi tạo trong initState và phải dispose() chúng trong phương thức dispose() của StatefulWidget. Nếu không, chúng sẽ gây ra rò rỉ bộ nhớ (memory leak), làm ứng dụng của bạn "nặng nề" và "chậm chạp" dần theo thời gian. Đây là "nghiệp vụ" cơ bản mà một lập trình viên "có tâm" phải làm. Tùy biến "đến tận chân răng": EditableText cho phép bạn kiểm soát màu con trỏ (cursorColor), màu vùng chọn (selectionColor), và thậm chí cả màu con trỏ khi không focus (backgroundCursorColor). Tận dụng điều này để tạo ra những trải nghiệm nhập liệu độc đáo, phù hợp với branding của ứng dụng bạn. Hiệu năng (Performance): Vì EditableText ít "phụ kiện" hơn TextField, trong những trường hợp cực đoan (ví dụ: một màn hình có hàng trăm ô nhập liệu nhỏ), nó có thể mang lại hiệu năng tốt hơn một chút. Tuy nhiên, đừng "mù quáng" mà hãy luôn dùng công cụ Profile của Flutter để kiểm tra trước khi quyết định "hy sinh" sự tiện lợi của TextField để đổi lấy EditableText. 5. Ứng dụng thực tế – "EditableText" đang ở đâu ngoài kia? "Thầy ơi, có ai dùng cái này không hay chỉ mình em học?" – Chắc chắn rồi! EditableText là nền tảng cho nhiều ứng dụng phức tạp mà bạn thấy hàng ngày: Trình soạn thảo mã nguồn (Code Editors): Các ứng dụng như VS Code (phiên bản web), hoặc các editor trên di động thường cần hiển thị cú pháp highlight, đánh số dòng, và các tính năng chỉnh sửa phức tạp. EditableText cung cấp cơ chế nhập liệu cơ bản, sau đó các lớp logic khác sẽ "vẽ" thêm các hiệu ứng đó lên trên. Trình soạn thảo văn bản đa định dạng (Rich Text Editors): Tưởng tượng các ứng dụng như Google Docs, Notion, hay thậm chí là phần soạn thảo tin nhắn trên các mạng xã hội cho phép bạn in đậm, in nghiêng, chèn link... EditableText xử lý phần nhập liệu thô, còn việc áp dụng các định dạng là do các lớp cao hơn quản lý. Các thanh tìm kiếm tùy chỉnh (Custom Search Bars): Đôi khi, một thanh tìm kiếm không chỉ đơn thuần là nhập text. Nó có thể có gợi ý đặc biệt, hiệu ứng chuyển động riêng, hoặc tích hợp trực tiếp vào một phần của UI game. EditableText là "viên gạch" lý tưởng để xây dựng những thanh tìm kiếm như vậy từ đầu. Ứng dụng game hoặc tương tác cao: Trong một số game, bạn có thể cần một ô nhập tên người chơi hoặc mật khẩu mà nó phải hòa quyện hoàn hảo vào phong cách đồ họa của game, không hề có một chút "mùi" của widget hệ thống. EditableText là lựa chọn tuyệt vời cho các tình huống này. Vậy là các bạn đã "thấm" được phần nào về EditableText rồi chứ? Hãy nhớ, nó là một "công cụ" mạnh mẽ, nhưng hãy sử dụng nó một cách thông minh và có mục đích. Đừng bao giờ ngại "mổ xẻ" các khái niệm cơ bản để hiểu sâu hơn về cách Flutter vận hành nhé! Hẹn gặp lại trong bài học tiếp theo! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

66 Đọc tiếp
DraggableScrollableSheet: Kéo Thả Sheet, Nâng Tầm UI Flutter!
18/03/2026

DraggableScrollableSheet: Kéo Thả Sheet, Nâng Tầm UI Flutter!

Chào mừng các bạn đến với buổi học hôm nay cùng Creyt! Chủ đề của chúng ta là một “phù thủy” trong việc tạo ra những trải nghiệm UI động và mượt mà: DraggableScrollableSheet. Nghe cái tên đã thấy 'ngầu' rồi đúng không? Đừng lo, Creyt sẽ bóc tách nó dễ hiểu như bóc vỏ chuối vậy. DraggableScrollableSheet là gì và để làm gì? Để dễ hình dung, các bạn cứ tưởng tượng thế này: Bạn có một tấm rèm cửa sổ. Tấm rèm này không phải loại cố định mà là loại bạn có thể kéo lên, kéo xuống để lộ ra hoặc che đi phần khung cảnh bên ngoài. Bạn có thể kéo nó chỉ hé một chút, hoặc kéo lên nửa chừng, hay thậm chí là kéo gần hết để nhìn rõ mọi thứ. DraggableScrollableSheet chính là tấm rèm cửa sổ thông minh đó trong Flutter. Nói một cách chính xác hơn, DraggableScrollableSheet là một widget cho phép bạn hiển thị một phần nội dung (một “sheet” hay “panel”) mà người dùng có thể kéo lên và kéo xuống để điều chỉnh kích thước hiển thị của nó. Nó không chỉ là một BottomSheet đơn thuần, mà còn “thông minh” hơn nhiều vì nó có thể nhớ vị trí, điều chỉnh kích thước theo tỷ lệ và quan trọng nhất là hòa mình vào luồng cuộn của nội dung bên trong. Vậy tại sao chúng ta cần nó? Đơn giản thôi! Trong kỷ nguyên di động, không gian màn hình là vàng. DraggableScrollableSheet giúp chúng ta: Tiết kiệm không gian: Hiển thị thông tin bổ sung chỉ khi người dùng cần, thay vì chiếm hết màn hình ngay từ đầu. Cung cấp ngữ cảnh: Hiện thị chi tiết hơn về một mục nào đó mà không cần chuyển sang màn hình mới, giữ người dùng ở lại ngữ cảnh hiện tại. Tạo trải nghiệm hiện đại: Mang lại cảm giác tương tác trực quan, mượt mà như các ứng dụng bản đồ, ứng dụng giao đồ ăn mà bạn vẫn dùng hàng ngày. Cấu trúc và Hoạt động của "Chiếc Rèm Thông Minh" DraggableScrollableSheet về cơ bản là một widget con của Stack (hoặc các widget có thể xếp chồng lên nhau) và nó sẽ hiển thị ở phía dưới màn hình, trượt lên trên. Điểm mấu chốt để nó hoạt động “thông minh” là nó cần một ScrollableWidget (như ListView, GridView, SingleChildScrollView) làm con của nó. Và đây là lúc chúng ta nói về builder và ScrollController. builder: Đây là một hàm mà bạn truyền vào DraggableScrollableSheet. Nó nhận hai tham số: BuildContext và một ScrollController. Cái ScrollController này chính là sợi dây thần kinh kết nối tấm rèm với nội dung bên trong. Bạn bắt buộc phải gán ScrollController này cho widget cuộn con của bạn (ví dụ: ListView hoặc SingleChildScrollView) để DraggableScrollableSheet biết khi nào thì kéo chính nó, khi nào thì cho phép nội dung bên trong cuộn. initialChildSize: Kích thước ban đầu của sheet khi nó xuất hiện (tỷ lệ từ 0.0 đến 1.0). Ví dụ, 0.3 nghĩa là sheet chiếm 30% chiều cao màn hình. minChildSize: Kích thước nhỏ nhất mà sheet có thể thu lại khi người dùng kéo xuống (tỷ lệ). Nếu kéo xuống thấp hơn giá trị này, sheet sẽ biến mất hoặc trở về minChildSize tùy thuộc vào cấu hình. maxChildSize: Kích thước lớn nhất mà sheet có thể mở rộng khi người dùng kéo lên (tỷ lệ). 1.0 có nghĩa là nó có thể chiếm toàn bộ màn hình. expand: Mặc định là false. Nếu bạn đặt true, sheet sẽ mở rộng để lấp đầy không gian còn lại theo chiều cao, thường là toàn bộ màn hình nếu maxChildSize là 1.0. Điều này hữu ích khi bạn muốn sheet tự động chiếm không gian tối đa có thể. Code Ví Dụ Minh Họa Rõ Ràng Không nói suông, giờ chúng ta cùng xem một ví dụ thực tế về cách Creyt sử dụng DraggableScrollableSheet để tạo một sheet thông tin có thể kéo thả trên một màn hình nền đơn giản. import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'DraggableScrollableSheet Demo', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), home: const DraggableSheetExample(), ); } } class DraggableSheetExample extends StatelessWidget { const DraggableSheetExample({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Sheet Kéo Thả Của Creyt'), backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, ), body: Stack( children: <Widget>[ // Nội dung chính bên dưới sheet (ví dụ: một hình ảnh nền) Positioned.fill( child: Image.network( 'https://picsum.photos/id/1025/800/600', // Một ảnh nền minh họa fit: BoxFit.cover, colorBlendMode: BlendMode.darken, color: Colors.black.withOpacity(0.3), // Làm tối ảnh nền ), ), // Đây chính là "chiếc rèm cửa thông minh" của chúng ta! DraggableScrollableSheet( initialChildSize: 0.3, // Ban đầu chiếm 30% chiều cao màn hình minChildSize: 0.1, // Thu nhỏ tối thiểu còn 10% maxChildSize: 0.8, // Mở rộng tối đa 80% expand: true, // Sheet sẽ lấp đầy không gian còn lại theo chiều cao builder: (BuildContext context, ScrollController scrollController) { return Container( decoration: const BoxDecoration( color: Colors.white, // Màu nền của sheet borderRadius: BorderRadius.vertical(top: Radius.circular(20)), boxShadow: [ BoxShadow( color: Colors.black26, blurRadius: 10, offset: Offset(0, -5), // Tạo bóng đổ nhẹ phía trên ), ], ), child: Column( children: [ // "Tay nắm" để người dùng biết là có thể kéo được Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), child: Container( width: 40, height: 5, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: BorderRadius.circular(10), ), ), ), Expanded( child: ListView.builder( controller: scrollController, // RẤT QUAN TRỌNG: Gán controller này! itemCount: 50, itemBuilder: (BuildContext context, int index) { return ListTile( leading: Icon(Icons.article, color: Colors.deepPurpleAccent), title: Text( 'Mục thông tin số ${index + 1}', style: TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text('Chi tiết hơn về mục này, số liệu, mô tả...'), ); }, ), ), ], ), ); }, ), ], ), ); } } Trong ví dụ trên, các bạn thấy DraggableScrollableSheet được đặt trong một Stack để nó có thể nằm trên một nội dung khác (ở đây là một hình ảnh nền). Điểm nhấn chính là cách chúng ta sử dụng scrollController được cung cấp bởi builder và gán nó cho ListView.builder. Đây là chìa khóa để Flutter biết khi nào thì kéo cả sheet, khi nào thì cuộn nội dung bên trong sheet. Mẹo Vặt (Best Practices) từ Giảng viên Creyt Để sử dụng DraggableScrollableSheet một cách hiệu quả và mượt mà nhất, Creyt có vài "mẹo vặt" muốn chia sẻ với các bạn: "Đừng quên sợi dây liên kết!": Luôn, luôn và luôn truyền scrollController từ hàm builder vào ScrollableWidget con của bạn (ví dụ: ListView, GridView, SingleChildScrollView). Đây là sợi dây thần kinh giúp DraggableScrollableSheet phân biệt giữa cử chỉ kéo sheet và cử chỉ cuộn nội dung. Nếu quên, bạn sẽ thấy hành vi cuộn rất "lạ" hoặc không hoạt động đúng. "Tối ưu hóa tầm nhìn": Thiết lập minChildSize và maxChildSize một cách hợp lý. minChildSize quá nhỏ có thể khiến người dùng khó kéo lên, hoặc không nhận ra sheet đang tồn tại. Ngược lại, maxChildSize quá lớn (ví dụ 1.0) mà nội dung ít thì sẽ tạo ra không gian trống không cần thiết. Hãy cân nhắc kỹ về trải nghiệm người dùng mong muốn. "Cho người dùng biết họ đang ở đâu": Thêm một "handle" (một thanh kéo nhỏ) ở phía trên cùng của sheet (như ví dụ code đã làm). Điều này giúp người dùng dễ dàng nhận biết rằng đây là một thành phần có thể kéo được và cung cấp một điểm neo trực quan để tương tác. "Hiệu suất là vàng": Nếu nội dung bên trong sheet của bạn rất nhiều hoặc phức tạp, hãy ưu tiên sử dụng các widget cuộn "xây dựng theo yêu cầu" như ListView.builder hoặc GridView.builder. Chúng chỉ render các item khi chúng hiển thị trên màn hình, giúp tối ưu hiệu suất và tránh lãng phí tài nguyên. "Thân thiện với bàn phím": Nếu sheet của bạn chứa các trường nhập liệu (TextField), hãy cân nhắc cách nó tương tác với bàn phím ảo. DraggableScrollableSheet có thể tự điều chỉnh khi bàn phím xuất hiện, nhưng đôi khi bạn cần tinh chỉnh thêm với MediaQuery.of(context).viewInsets.bottom để có trải nghiệm hoàn hảo. Ứng Dụng Thực Tế DraggableScrollableSheet không phải là một thứ gì đó xa vời, mà nó đang hiện diện khắp nơi trong các ứng dụng bạn dùng hàng ngày: Google Maps / Apple Maps: Khi bạn tìm kiếm một địa điểm, một sheet thông tin sẽ trượt lên từ dưới. Bạn có thể kéo nó lên để xem chi tiết địa điểm, lộ trình, đánh giá, hoặc kéo xuống để ẩn bớt thông tin và tập trung vào bản đồ. Grab / Uber: Sau khi chọn điểm đến, một sheet nhỏ hiện ra với thông tin tài xế, giá cả. Bạn có thể kéo sheet này lên để xem thêm các lựa chọn xe, chi tiết chuyến đi. Spotify / Apple Music: Khi bạn đang nghe nhạc, thanh phát nhạc ở dưới cùng màn hình có thể được kéo lên thành một sheet lớn hơn để hiển thị lời bài hát, danh sách phát hoặc các điều khiển nâng cao. Ứng dụng quản lý tác vụ / Ghi chú: Một số ứng dụng cho phép bạn kéo một sheet từ dưới lên để nhanh chóng thêm tác vụ mới hoặc xem chi tiết một ghi chú mà không cần rời khỏi danh sách chính. Kết Luận DraggableScrollableSheet là một công cụ mạnh mẽ và linh hoạt trong bộ công cụ Flutter, giúp bạn tạo ra các giao diện người dùng động, trực quan và hiệu quả. Nắm vững cách sử dụng nó, đặc biệt là mối liên kết giữa builder và ScrollController, bạn sẽ có thể "phù phép" ra những trải nghiệm người dùng mượt mà và hiện đại. Hãy thực hành thật nhiều để biến "tấm rèm thông minh" này thành của riêng bạn nhé! Hẹn gặp lại trong bài học tiếp theo của Creyt! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

62 Đọc tiếp
DropdownMenu: Mở Khóa Kho Báu Lựa Chọn trong Flutter
18/03/2026

DropdownMenu: Mở Khóa Kho Báu Lựa Chọn trong Flutter

Chào mừng các bạn đến với buổi học hôm nay! Creyt đây, và chúng ta sẽ cùng nhau 'khai quật' một viên ngọc quý trong kho tàng widget của Flutter: DropdownMenu. Nghe tên có vẻ đơn giản, nhưng tin tôi đi, sức mạnh của nó ẩn chứa những điều kỳ diệu. DropdownMenu: Chiếc Menu Ẩn Giấu Sức Mạnh Bạn cứ hình dung thế này: DropdownMenu giống như một chiếc hộp thần kỳ trên giao diện người dùng của bạn. Bình thường, nó chỉ là một cái nút nhỏ xinh, không chiếm nhiều không gian. Nhưng khi người dùng chạm vào, 'phù phép' một cái, một danh sách các lựa chọn sẽ hiện ra như một tấm bản đồ kho báu, cho phép họ chọn đúng món đồ mình cần. Sau khi chọn xong, danh sách lại biến mất, trả lại sự gọn gàng cho màn hình. Vậy nó để làm gì? Đơn giản là để: Tiết kiệm không gian: Thay vì bày la liệt các lựa chọn ra màn hình, DropdownMenu gói gọn chúng lại. Cung cấp lựa chọn định sẵn: Hữu ích khi bạn muốn người dùng chọn một giá trị từ một tập hợp cố định (ví dụ: quốc gia, tỉnh thành, loại sản phẩm, đơn vị đo lường). Tăng tính thẩm mỹ: Một DropdownMenu được thiết kế tốt sẽ làm giao diện của bạn trông chuyên nghiệp và hiện đại hơn. Trong Flutter, chúng ta có hai 'người anh em' chính để tạo ra trải nghiệm này: DropdownButton (ông anh cả, cổ điển) và DropdownMenu (cậu em út, hiện đại hơn, ra đời cùng Material 3 và được khuyến khích sử dụng vì tính linh hoạt). Hôm nay, chúng ta sẽ tập trung vào DropdownMenu - 'cậu em' đầy tiềm năng này. Giải Phẫu DropdownMenu trong Flutter DropdownMenu trong Flutter là một widget Material Design 3, cung cấp một cách đẹp đẽ và hiệu quả để hiển thị danh sách các lựa chọn. Nó bao gồm: Một trường nhập liệu (input field) hiển thị lựa chọn hiện tại. Một biểu tượng mũi tên chỉ xuống để báo hiệu đây là một menu thả xuống. Một danh sách các DropdownMenuEntry xuất hiện khi người dùng tương tác. Các thuộc tính chính mà bạn sẽ 'làm việc' với nó: dropdownMenuEntries: Đây là 'danh sách kho báu' của bạn, chứa các DropdownMenuEntry - mỗi entry đại diện cho một lựa chọn. initialSelection: 'Món đồ' đầu tiên được chọn khi chiếc hộp thần kỳ này xuất hiện. onSelected: 'Phép thuật' sẽ xảy ra khi người dùng chọn một món đồ từ danh sách. Đây là một hàm callback sẽ nhận về giá trị của lựa chọn. label: Một nhãn hiển thị bên trên hoặc bên trong trường nhập liệu, giúp người dùng hiểu rõ hơn về nội dung của menu. Code Ví Dụ Minh Họa: Chọn Món Ăn Yêu Thích Chúng ta hãy cùng nhau xây dựng một DropdownMenu đơn giản để chọn món ăn yêu thích nhé. Hãy tưởng tượng bạn đang xây dựng một ứng dụng đặt món ăn. import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Creyt\'s Food Menu', theme: ThemeData(useMaterial3: true), home: const FoodSelectionScreen(), ); } } class FoodSelectionScreen extends StatefulWidget { const FoodSelectionScreen({super.key}); @override State<FoodSelectionScreen> createState() => _FoodSelectionScreenState(); } class _FoodSelectionScreenState extends State<FoodSelectionScreen> { // Danh sách các món ăn có sẵn final List<String> _foodOptions = <String>[ 'Phở Bò', 'Bún Chả', 'Cơm Tấm', 'Mì Quảng', 'Gỏi Cuốn' ]; // Món ăn được chọn mặc định String? _selectedFood; @override void initState() { super.initState(); _selectedFood = _foodOptions.first; // Chọn món đầu tiên làm mặc định } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Chọn Món Ăn Yêu Thích'), backgroundColor: Colors.teal, foregroundColor: Colors.white, ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text( 'Món ăn bạn chọn là:', style: TextStyle(fontSize: 18), ), const SizedBox(height: 10), Text( _selectedFood ?? 'Chưa chọn', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.teal), ), const SizedBox(height: 30), DropdownMenu<String>( initialSelection: _selectedFood, // Thiết lập lựa chọn ban đầu label: const Text('Chọn món ăn'), // Nhãn cho DropdownMenu width: 250, // Chiều rộng của DropdownMenu dropdownMenuEntries: _foodOptions.map<DropdownMenuEntry<String>>( (String food) { return DropdownMenuEntry<String>( value: food, label: food, leadingIcon: Icon(Icons.restaurant_menu), // Thêm icon cho mỗi món ); }, ).toList(), onSelected: (String? newValue) { // Xử lý khi người dùng chọn một món mới setState(() { _selectedFood = newValue; // Cập nhật món ăn được chọn }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Bạn đã chọn: $newValue')), ); }, ), const SizedBox(height: 20), ElevatedButton( onPressed: () { // Một hành động nào đó với món ăn đã chọn print('Món ăn cuối cùng được chọn: $_selectedFood'); }, child: const Text('Xác nhận lựa chọn'), ), ], ), ), ); } } Trong ví dụ trên: Chúng ta định nghĩa một danh sách _foodOptions chứa các món ăn. _selectedFood giữ trạng thái của món ăn hiện tại được chọn. DropdownMenu được khởi tạo với initialSelection là món ăn đầu tiên. dropdownMenuEntries được tạo ra bằng cách map danh sách _foodOptions thành các DropdownMenuEntry, mỗi entry có value và label là tên món ăn. onSelected là nơi chúng ta cập nhật _selectedFood bằng setState mỗi khi người dùng chọn một món mới, đồng thời hiển thị một SnackBar thông báo. Creyt's Best Practices: Những Mẹo Vặt Từ 'Lão Làng' Để sử dụng DropdownMenu hiệu quả như một lập trình viên 'lão làng', bạn cần nhớ vài điều sau: Đừng 'Tham Lam': DropdownMenu sinh ra để chọn từ một danh sách nhỏ đến vừa (khoảng dưới 10-15 lựa chọn). Nếu danh sách của bạn quá dài (ví dụ: hàng trăm quốc gia), hãy nghĩ đến các giải pháp khác như Autocomplete hoặc một trang riêng có chức năng tìm kiếm. Việc cuộn quá nhiều trong một DropdownMenu là một trải nghiệm tồi tệ. Nhãn Mác Rõ Ràng: Mỗi DropdownMenuEntry cần có một label dễ hiểu, ngắn gọn. Đừng dùng những từ viết tắt khó hiểu hay các thuật ngữ chuyên ngành mà người dùng phổ thông không biết. Lựa Chọn Ban Đầu 'Hợp Lý': Luôn cung cấp một initialSelection có ý nghĩa. Điều này giúp người dùng không bị bối rối và cung cấp một giá trị mặc định hợp lệ nếu họ không chọn gì cả. Ví dụ, nếu là quốc gia, hãy mặc định là quốc gia của người dùng. Quản Lý Trạng Thái 'Tinh Tế': DropdownMenu là một widget StatefulWidget. Đảm bảo rằng bạn cập nhật trạng thái của ứng dụng (biến _selectedFood trong ví dụ) trong onSelected bằng setState() để giao diện được làm mới và hiển thị lựa chọn hiện tại. Cân Nhắc width: Thuộc tính width giúp bạn kiểm soát kích thước của DropdownMenu. Hãy đặt một giá trị hợp lý để nó không quá nhỏ làm mất chữ, cũng không quá lớn làm phá vỡ bố cục. Accessibility (Khả Năng Tiếp Cận): Đừng quên rằng không phải ai cũng dùng chuột hoặc ngón tay. Đảm bảo DropdownMenu của bạn hoạt động tốt với bàn phím và các công cụ hỗ trợ đọc màn hình. Flutter đã làm rất tốt điều này, nhưng bạn vẫn cần kiểm tra. Ứng Dụng Thực Tế: DropdownMenu Hiện Diện Khắp Nơi Bạn có thể thấy DropdownMenu ở khắp mọi nơi trong thế giới số: Shopee/Lazada/Tiki: Khi bạn chọn kích cỡ, màu sắc, loại sản phẩm. Đó chính là những DropdownMenu. Các trang web đăng ký/đăng nhập: Chọn quốc gia, tỉnh/thành phố, giới tính. Chuẩn rồi, DropdownMenu đấy. Ứng dụng cài đặt (Settings): Chọn ngôn ngữ giao diện, chủ đề sáng/tối. Lại là nó! Google Sheets/Excel Online: Các ô dữ liệu có danh sách thả xuống để chọn giá trị định sẵn. Hệ thống lọc/sắp xếp dữ liệu: Khi bạn muốn lọc sản phẩm theo mức giá, sắp xếp theo tên, v.v. Đấy, thấy chưa? DropdownMenu không chỉ là một widget, nó là một người bạn đồng hành đáng tin cậy giúp bạn xây dựng những giao diện người dùng gọn gàng, hiệu quả và thân thiện. Hãy luyện tập và làm chủ nó, bạn sẽ thấy ứng dụng của mình 'lên một tầm cao mới' đấy! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

93 Đọc tiếp
DrawerController: Nắm Quyền Điều Khiển Ngăn Kéo Ứng Dụng Flutter
18/03/2026

DrawerController: Nắm Quyền Điều Khiển Ngăn Kéo Ứng Dụng Flutter

Chào các bạn, Creyt đây! Hôm nay chúng ta sẽ mổ xẻ một khái niệm mà nhiều bạn hay nhầm lẫn hoặc chưa khai thác hết sức mạnh của nó: cái gọi là 'DrawerController' trong Flutter. Nghe tên thì hoành tráng, nhưng thực chất, nó là cách chúng ta nắm quyền điều khiển cái 'ngăn kéo bí mật' của ứng dụng – cái Drawer thần thánh đó. Tưởng tượng ứng dụng của bạn là một cái bàn làm việc. Cái Drawer chính là hộc tủ kéo ra kéo vào, chứa đủ thứ đồ nghề, các tùy chọn điều hướng quan trọng. Bình thường, bạn chỉ cần gạt tay (vuốt từ mép màn hình) là nó tự mở. Nhưng đôi khi, bạn muốn có một cái 'điều khiển từ xa', bấm nút là hộc tủ tự động mở ra, hoặc tự động đóng lại, thay vì phải tự tay kéo. Đó chính là lúc 'DrawerController' (mà thực chất là cơ chế điều khiển Drawer thông qua ScaffoldState) phát huy tác dụng. Nói cách khác, nó giúp bạn mở/đóng Drawer bằng mã lệnh, không chỉ dựa vào thao tác vuốt của người dùng. Điều Khiển Ngăn Kéo: Chìa Khóa Nằm Ở Đâu? Trong Flutter, Drawer là một widget được đặt trong Scaffold. Scaffold chính là khung sườn chính của ứng dụng bạn, nó quản lý AppBar, BottomNavigationBar, và cả cái Drawer này nữa. Để điều khiển được Drawer, chúng ta cần 'nói chuyện' trực tiếp với Scaffold đang chứa nó. Và cách để làm điều đó chính là thông qua ScaffoldState. ScaffoldState là một đối tượng chứa trạng thái hiện tại của Scaffold, và nó cung cấp các phương thức như openDrawer() hay closeDrawer(). Vấn đề là làm sao để có được ScaffoldState này từ bất kỳ đâu trong cây widget của bạn? Có hai cách chính, và cách phổ biến nhất, 'chuẩn chỉ Harvard' nhất, là dùng GlobalKey<ScaffoldState>. Đây giống như việc bạn dán một cái 'mã số định danh' duy nhất lên cái Scaffold của mình, sau đó từ bất cứ đâu, bạn chỉ cần gọi cái mã số đó là có thể 'gọi điện' cho Scaffold và ra lệnh cho nó. Cách thứ hai là dùng Scaffold.of(context). Cách này tiện hơn nếu bạn đang ở sâu bên trong cây widget và biết chắc chắn có một Scaffold ở phía trên. Tuy nhiên, nó yêu cầu context phải là con cháu của Scaffold đó, nếu không sẽ báo lỗi. Hôm nay, chúng ta sẽ tập trung vào GlobalKey vì nó linh hoạt hơn và cho phép bạn điều khiển Drawer từ bất kỳ đâu, kể cả từ một widget không phải là con trực tiếp của Scaffold. Code Ví Dụ Minh Họa: Nắm Quyền Điều Khiển Để minh họa, chúng ta sẽ tạo một ứng dụng Flutter đơn giản với một Drawer và các nút để mở/đóng nó bằng mã lệnh. import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'DrawerController Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const HomePage(), ); } } class HomePage extends StatefulWidget { const HomePage({super.key}); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { // Bước 1: Khai báo một GlobalKey cho ScaffoldState // Đây là "mã số định danh" duy nhất của Scaffold của chúng ta final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); @override Widget build(BuildContext context) { return Scaffold( // Bước 2: Gán GlobalKey này vào Scaffold // Giờ thì Scaffold này đã có một "điều khiển từ xa" key: _scaffoldKey, appBar: AppBar( title: const Text('Điều khiển Ngăn kéo (Drawer)'), leading: IconButton( icon: const Icon(Icons.menu), onPressed: () { // Bước 3: Sử dụng GlobalKey để truy cập ScaffoldState // và gọi phương thức openDrawer() // Giống như bấm nút "mở hộc tủ" trên điều khiển từ xa if (_scaffoldKey.currentState != null && !_scaffoldKey.currentState!.isDrawerOpen) { _scaffoldKey.currentState!.openDrawer(); } else if (_scaffoldKey.currentState != null && _scaffoldKey.currentState!.isDrawerOpen) { _scaffoldKey.currentState!.closeDrawer(); } }, ), ), drawer: Drawer( child: ListView( padding: EdgeInsets.zero, children: <Widget>[ const DrawerHeader( decoration: BoxDecoration( color: Colors.blue, ), child: Text( 'Menu Chính', style: TextStyle( color: Colors.white, fontSize: 24, ), ), ), ListTile( leading: const Icon(Icons.home), title: const Text('Trang Chủ'), onTap: () { // Đóng Drawer sau khi chọn _scaffoldKey.currentState?.closeDrawer(); // Xử lý hành động Trang Chủ }, ), ListTile( leading: const Icon(Icons.settings), title: const Text('Cài Đặt'), onTap: () { // Đóng Drawer sau khi chọn _scaffoldKey.currentState?.closeDrawer(); // Xử lý hành động Cài Đặt }, ), ], ), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text( 'Bấm nút Menu trên AppBar hoặc nút dưới đây để điều khiển Drawer.', textAlign: TextAlign.center, ), const SizedBox(height: 20), ElevatedButton( onPressed: () { // Bạn cũng có thể mở/đóng Drawer từ body // Kiểm tra trạng thái hiện tại để quyết định mở hay đóng if (_scaffoldKey.currentState != null) { if (_scaffoldKey.currentState!.isDrawerOpen) { _scaffoldKey.currentState!.closeDrawer(); } else { _scaffoldKey.currentState!.openDrawer(); } } }, child: const Text('Mở/Đóng Drawer'), ), ], ), ), ); } } Mẹo (Best Practices) Để Ghi Nhớ và Ứng Dụng Thực Tế Khi nào dùng? Khi bạn muốn mở Drawer từ một nút bấm không phải nút mặc định trên AppBar, hoặc muốn tự động đóng Drawer sau một hành động nào đó (ví dụ, sau khi chọn một mục trong menu). Hoặc thậm chí tự động mở Drawer khi người dùng lần đầu vào ứng dụng để hướng dẫn. Tránh lạm dụng GlobalKey: GlobalKey mạnh mẽ nhưng cũng có thể gây khó hiểu nếu dùng quá nhiều. Hãy dùng nó khi thực sự cần truy cập vào trạng thái của một widget từ xa, không phải là con trực tiếp của nó. Kiểm tra currentState: Luôn luôn kiểm tra _scaffoldKey.currentState != null trước khi gọi các phương thức như openDrawer() hoặc closeDrawer(). Đôi khi, widget chưa được gắn vào cây widget hoặc đã bị hủy, việc truy cập currentState trực tiếp có thể gây lỗi. Dùng Scaffold.of(context) khi có thể: Nếu bạn đang ở trong một widget là con của Scaffold và chỉ cần truy cập ScaffoldState từ đó, Scaffold.of(context) sẽ gọn gàng và dễ đọc hơn GlobalKey. Ví dụ, trong onTap của một ListTile trong Drawer, bạn có thể dùng Navigator.pop(context) (thực chất là đóng Drawer) hoặc Scaffold.of(context).closeDrawer(). Ứng Dụng Thực Tế Các Website/Ứng Dụng Đã Ứng Dụng Hầu hết các ứng dụng có Drawer đều sử dụng cơ chế này để điều khiển nó. Ví dụ điển hình: Gmail: Khi bạn bấm vào biểu tượng menu ba gạch ở góc trên bên trái, Drawer sẽ mở ra. Đây chính là openDrawer() được gọi từ IconButton trên AppBar. Facebook/LinkedIn: Các ứng dụng này thường có Drawer hoặc một dạng navigation panel tương tự, cho phép bạn truy cập các phần khác nhau của ứng dụng. Việc đóng Drawer sau khi chọn một mục là một ví dụ của closeDrawer(). Các ứng dụng quản lý tác vụ (Todoist, Trello): Thường có một menu bên cạnh để chuyển đổi giữa các dự án hoặc danh sách, và cơ chế điều khiển Drawer giúp quản lý trạng thái hiển thị của menu đó. Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

62 Đọc tiếp
DismissiblePane: Vuốt nhẹ, bay sạch - Nắm quyền kiểm soát danh sách!
18/03/2026

DismissiblePane: Vuốt nhẹ, bay sạch - Nắm quyền kiểm soát danh sách!

Chào các lập trình viên tương lai! Hôm nay, Giảng viên Creyt sẽ dẫn các bạn đi khám phá một "công cụ" cực kỳ lợi hại trong kho vũ khí UI/UX của Flutter: Dismissible (hay chính xác hơn là Dismissible widget, vì DismissiblePane là một concept mở rộng từ nó, nhưng ý tưởng cốt lõi thì như nhau). Đừng lo, tôi sẽ giải thích cặn kẽ như khi bạn đang nhâm nhi ly cà phê sáng, dễ hiểu đến mức bà bạn cũng gật gù! 1. Dismissible là gì và để làm gì? (Vuốt phát là bay!) Bạn đã bao giờ dùng ứng dụng email hay ứng dụng quản lý công việc chưa? Cái cảm giác vuốt một email sang trái để xóa, hay vuốt sang phải để đánh dấu đã đọc ấy, đó chính là Dismissible đang "làm trò" đó. Hãy tưởng tượng thế này: bạn có một chồng giấy tờ lộn xộn trên bàn, mỗi tờ là một nhiệm vụ cần làm. Với Dismissible, bạn như có một "cái thùng rác ma thuật" ngay bên cạnh. Chỉ cần vuốt nhẹ tờ giấy nào không cần nữa, "phụt!" nó biến mất. Hoặc vuốt sang hướng khác, "tách!" nó được chuyển vào mục lưu trữ. Nói một cách hàn lâm hơn, Dismissible trong Flutter là một widget cho phép bạn loại bỏ (dismiss) một widget con bằng cách vuốt nó sang một hướng cụ thể. Nó cực kỳ hữu ích để: Xóa/Lưu trữ các mục trong danh sách: Điển hình nhất là các mục trong ListView (email, tin nhắn, ghi chú, sản phẩm trong giỏ hàng). Cung cấp tương tác trực quan: Nâng cao trải nghiệm người dùng bằng cách cho phép họ thao tác trực tiếp trên các phần tử UI. Giảm bớt nút bấm: Thay vì phải nhấn vào một icon xóa, vuốt luôn tiện lợi hơn nhiều. 2. Code Ví Dụ Minh Họa: Danh sách nhiệm vụ "vuốt là bay" Để các bạn dễ hình dung, chúng ta sẽ xây dựng một danh sách các nhiệm vụ đơn giản. Khi vuốt một nhiệm vụ sang trái, nó sẽ bị xóa. Khi vuốt sang phải, nó sẽ được đánh dấu là "đã hoàn thành". Các thành phần chính của Dismissible: key: Cực kỳ quan trọng! Flutter dùng key để nhận diện duy nhất từng Dismissible widget. Nếu không có key hoặc key không duy nhất, bạn sẽ gặp lỗi hoặc hành vi không mong muốn. Thường dùng UniqueKey() hoặc ValueKey() với ID của đối tượng. child: Widget mà bạn muốn cho phép người dùng vuốt đi (ví dụ: một ListTile). background: Widget sẽ hiển thị phía sau child khi bạn vuốt từ trái sang phải (hoặc từ trên xuống dưới, tùy direction). secondaryBackground: Tương tự background, nhưng hiển thị khi bạn vuốt từ phải sang trái (hoặc từ dưới lên trên). onDismissed: Hàm callback được gọi khi widget đã được vuốt hoàn toàn và bị loại bỏ. Đây là nơi bạn cập nhật dữ liệu của mình. direction: Chỉ định các hướng vuốt được phép (DismissDirection.horizontal, DismissDirection.startToEnd, DismissDirection.endToStart, v.v.). import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Dismissible Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const DismissibleTasksScreen(), ); } } class DismissibleTasksScreen extends StatefulWidget { const DismissibleTasksScreen({super.key}); @override State<DismissibleTasksScreen> createState() => _DismissibleTasksScreenState(); } class _DismissibleTasksScreenState extends State<DismissibleTasksScreen> { final List<String> _tasks = List.generate( 10, (index) => 'Nhiệm vụ số ${index + 1}', ); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Danh Sách Nhiệm Vụ (Vuốt là bay!)'), ), body: ListView.builder( itemCount: _tasks.length, itemBuilder: (context, index) { final item = _tasks[index]; return Dismissible( // 1. **Key là bắt buộc và phải duy nhất!** key: Key(item), // Sử dụng item làm key, đảm bảo duy nhất direction: DismissDirection.horizontal, // Cho phép vuốt ngang // 2. Background khi vuốt từ trái sang phải (đánh dấu hoàn thành) background: Container( color: Colors.green, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 20.0), child: const Icon(Icons.check, color: Colors.white), ), // 3. Background khi vuốt từ phải sang trái (xóa) secondaryBackground: Container( color: Colors.red, alignment: Alignment.centerRight, padding: const EdgeInsets.symmetric(horizontal: 20.0), child: const Icon(Icons.delete, color: Colors.white), ), // 4. Widget con được vuốt child: ListTile( title: Text(item), subtitle: const Text('Trạng thái: Đang chờ'), ), // 5. Hàm callback khi widget bị loại bỏ onDismissed: (direction) { // Xóa item khỏi danh sách dữ liệu setState(() { _tasks.removeAt(index); }); // Hiển thị Snackbar thông báo hành động ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( direction == DismissDirection.startToEnd ? '$item đã hoàn thành!' : '$item đã bị xóa.', ), action: SnackBarAction( label: 'Hoàn tác', onPressed: () { // Thường thì bạn sẽ không hoàn tác xóa ngay lập tức // mà có thể lưu vào một danh sách 'đã xóa' tạm thời. // Trong ví dụ này, chúng ta đơn giản là thêm lại. setState(() { _tasks.insert(index, item); }); }, ), ), ); }, ); }, ), ); } } Giải thích nhanh: Chúng ta có một List<String> _tasks để lưu trữ dữ liệu. Mỗi ListTile được bọc trong một Dismissible. Key(item) đảm bảo mỗi Dismissible có một khóa duy nhất. background và secondaryBackground cung cấp phản hồi trực quan khi người dùng vuốt. Trong onDismissed, chúng ta xóa mục khỏi _tasks và dùng setState để cập nhật UI. Một SnackBar cũng được dùng để thông báo và cung cấp tùy chọn hoàn tác (dù cho xóa thực sự thì việc hoàn tác phức tạp hơn nhiều). 3. Mẹo (Best Practices) từ "lão làng" Creyt Key là Vua: Tôi nhắc lại lần nữa, Key là linh hồn của Dismissible. Luôn đảm bảo nó là duy nhất cho mỗi item. Nếu không, Flutter sẽ không biết item nào đang bị vuốt, dẫn đến những hành vi khó chịu như item sai bị xóa hoặc lỗi. Mẹo nhỏ: Nếu dữ liệu của bạn có ID duy nhất (như từ database), hãy dùng ValueKey(yourItem.id). Nếu không, UniqueKey() là lựa chọn an toàn cho các widget động. Phản hồi Trực quan là Tiền: Luôn cung cấp background và secondaryBackground rõ ràng. Người dùng cần biết hành động của họ sẽ dẫn đến kết quả gì trước khi họ hoàn thành thao tác vuốt. Xử lý dữ liệu cẩn thận: Hàm onDismissed là nơi bạn thực sự xóa hoặc cập nhật dữ liệu. Luôn gọi setState sau khi thay đổi danh sách dữ liệu để UI được cập nhật. Hoàn tác (Undo) là ân huệ: Đối với các hành động phá hủy (như xóa), hãy cân nhắc cung cấp một cơ chế hoàn tác ngắn hạn (thường là qua SnackBar). Điều này giúp người dùng sửa chữa sai lầm và tăng sự tự tin khi sử dụng ứng dụng của bạn. Xác nhận cho hành động "nghiêm trọng": Nếu việc xóa có thể gây mất mát dữ liệu lớn, hãy cân nhắc hiển thị một AlertDialog để xác nhận trước khi thực sự loại bỏ item. Dismissible có một callback confirmDismiss cho mục đích này. 4. Ứng dụng thực tế: Bạn thấy Dismissible ở đâu? Dismissible không phải là một công nghệ mới lạ mà nó đã trở thành một chuẩn mực trong thiết kế UI/UX hiện đại. Bạn sẽ thấy nó ở khắp mọi nơi: Gmail / Outlook / Apple Mail: Vuốt email để lưu trữ, xóa, đánh dấu đã đọc. Đây là ví dụ kinh điển nhất! Todoist / Google Tasks: Vuốt nhiệm vụ để đánh dấu hoàn thành hoặc xóa bỏ. WhatsApp / Telegram: Vuốt cuộc trò chuyện để lưu trữ hoặc xóa. Spotify / Apple Music: Vuốt bài hát trong danh sách phát để xóa khỏi danh sách. Ứng dụng mua sắm (Shopping Cart): Vuốt sản phẩm trong giỏ hàng để xóa khỏi giỏ. Nhìn chung, bất cứ khi nào bạn có một danh sách các mục và muốn người dùng có thể nhanh chóng loại bỏ hoặc thực hiện một hành động nhanh trên từng mục mà không cần phải nhấn vào nhiều nút, Dismissible chính là "người hùng" bạn cần triệu hồi. Chúc mừng bạn đã nắm vững một trong những kỹ thuật tương tác người dùng hiệu quả nhất trong Flutter! Giờ thì hãy tự tin áp dụng nó vào các dự án của mình nhé. Hẹn gặp lại trong bài học tiếp theo! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

74 Đọc tiếp
DismissDirection: 'Bouncer' UI Đích Thực Cho Trải Nghiệm Vuốt Thả Flutter
18/03/2026

DismissDirection: 'Bouncer' UI Đích Thực Cho Trải Nghiệm Vuốt Thả Flutter

Chào các 'chiến binh' lập trình tương lai của tôi, Creyt đây! Hôm nay chúng ta sẽ cùng mổ xẻ một khái niệm tưởng chừng đơn giản nhưng lại cực kỳ quyền năng trong Flutter: DismissDirection. Hãy hình dung thế này, mỗi khi bạn vuốt một email để xóa, vuốt một task để hoàn thành, hay vuốt một tin nhắn để trả lời... đó chính là lúc DismissDirection đang làm nhiệm vụ của một 'người gác cổng' chuyên nghiệp, quyết định xem 'cánh cửa' nào sẽ mở ra cho hành động của bạn. DismissDirection Là Gì? 'Người Gác Cổng' Của Cử Chỉ Vuốt Thả Trong thế giới lập trình giao diện người dùng (UI), DismissDirection là một enum (kiểu liệt kê) được sử dụng để xác định hướng mà một widget có thể bị loại bỏ (dismiss) thông qua cử chỉ vuốt (swipe gesture). Nó hoạt động như một bộ lọc, chỉ cho phép hành động dismiss xảy ra nếu hướng vuốt của người dùng trùng khớp với một trong các hướng đã được cho phép. Mục đích chính của nó là gì? Đơn giản là để mang lại trải nghiệm tương tác trực quan, mượt mà và hiệu quả cho người dùng. Thay vì phải nhấn một nút nhỏ xíu để xóa hay lưu trữ, họ có thể thực hiện hành động đó bằng một cử chỉ tự nhiên hơn rất nhiều. Nó biến các hành động phức tạp thành một thao tác vuốt đơn giản, giảm thiểu số lần chạm và tăng tốc độ tương tác. DismissDirection thường được sử dụng cùng với widget Dismissible – một 'cánh cửa thần kỳ' trong Flutter cho phép bạn dễ dàng thêm khả năng vuốt để loại bỏ (swipe-to-dismiss) cho bất kỳ widget con nào. Các giá trị phổ biến của DismissDirection bao gồm: DismissDirection.horizontal: Cho phép vuốt theo chiều ngang (sang trái hoặc sang phải). DismissDirection.vertical: Cho phép vuốt theo chiều dọc (lên hoặc xuống). DismissDirection.endToStart: Cho phép vuốt từ cuối đến đầu (ví dụ: từ phải sang trái trong ngôn ngữ đọc từ trái sang phải). DismissDirection.startToEnd: Cho phép vuốt từ đầu đến cuối (ví dụ: từ trái sang phải trong ngôn ngữ đọc từ trái sang phải). DismissDirection.up: Chỉ cho phép vuốt lên trên. DismissDirection.down: Chỉ cho phép vuốt xuống dưới. DismissDirection.none: Không cho phép dismiss theo bất kỳ hướng nào. (Thực ra là tắt chức năng dismiss). Code Ví Dụ Minh Họa: Biến Danh Sách Thành Sân Chơi Vuốt Thả Để minh họa rõ ràng, chúng ta sẽ xây dựng một danh sách đơn giản mà mỗi item có thể được vuốt để loại bỏ. Tôi sẽ chỉ cho bạn cách dùng các DismissDirection khác nhau, cùng với background và secondaryBackground để cung cấp phản hồi hình ảnh cho người dùng – yếu tố cực kỳ quan trọng trong UX. import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'DismissDirection Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const DismissDirectionScreen(), ); } } class DismissDirectionScreen extends StatefulWidget { const DismissDirectionScreen({super.key}); @override State<DismissDirectionScreen> createState() => _DismissDirectionScreenState(); } class _DismissDirectionScreenState extends State<DismissDirectionScreen> { final List<String> _items = List<String>.generate(10, (i) => 'Item ${i + 1}'); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Vuốt Thả Cùng DismissDirection'), ), body: ListView.builder( itemCount: _items.length, itemBuilder: (context, index) { final String item = _items[index]; DismissDirection allowedDirection; // Logic để gán các hướng dismiss khác nhau cho từng item if (index % 3 == 0) { allowedDirection = DismissDirection.endToStart; // Vuốt phải sang trái để xóa } else if (index % 3 == 1) { allowedDirection = DismissDirection.startToEnd; // Vuốt trái sang phải để lưu trữ } else { allowedDirection = DismissDirection.horizontal; // Vuốt cả 2 chiều } return Dismissible( key: Key(item), // Key là bắt buộc để Flutter xác định widget duy nhất direction: allowedDirection, background: Container( color: Colors.green, // Màu nền khi vuốt từ trái sang phải alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 20), child: const Icon(Icons.archive, color: Colors.white), ), secondaryBackground: Container( color: Colors.red, // Màu nền khi vuốt từ phải sang trái alignment: Alignment.centerRight, padding: const EdgeInsets.symmetric(horizontal: 20), child: const Icon(Icons.delete, color: Colors.white), ), onDismissed: (direction) { // Xóa item khỏi danh sách và hiển thị SnackBar setState(() { _items.removeAt(index); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( direction == DismissDirection.endToStart ? 'Đã xóa "$item"' : 'Đã lưu trữ "$item"', ), action: SnackBarAction( label: 'Hoàn tác', onPressed: () { // Trong ứng dụng thực tế, bạn sẽ cần logic để thêm lại item vào đúng vị trí // Ở đây, đơn giản là đưa ra ví dụ về cách dùng SnackBarAction ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Đã hoàn tác! (Chưa triển khai lại item)')), ); }, ), ), ); }, child: Card( margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: ListTile( title: Text(item), subtitle: Text('Vuốt ${allowedDirection.toString().split('.').last}'), leading: const Icon(Icons.check_circle_outline), ), ), ); }, ), ); } } Trong ví dụ trên: Chúng ta dùng ListView.builder để tạo một danh sách các item. Mỗi ListTile được bọc trong một Dismissible widget. key là thuộc tính bắt buộc của Dismissible để Flutter có thể theo dõi và xử lý các widget một cách hiệu quả khi chúng bị xóa hoặc thay đổi vị trí. direction được gán các giá trị DismissDirection khác nhau tùy theo chỉ mục của item, giúp bạn thấy rõ sự khác biệt. background và secondaryBackground cung cấp phản hồi hình ảnh khi người dùng vuốt. background hiển thị khi vuốt theo hướng startToEnd (trái sang phải), còn secondaryBackground hiển thị khi vuốt theo hướng endToStart (phải sang trái). onDismissed là callback được gọi khi item đã được loại bỏ thành công. Ở đây, chúng ta xóa item khỏi danh sách và hiển thị một SnackBar để thông báo và cung cấp tùy chọn hoàn tác. Mẹo 'Nằm Lòng' Từ Giảng Viên Creyt (Best Practices) Với kinh nghiệm 'xương máu' trên chiến trường code, tôi có vài lời khuyên vàng để các bạn dùng DismissDirection cho hiệu quả: Phản Hồi Trực Quan Là 'Vàng': Luôn luôn, tôi nhấn mạnh là luôn luôn cung cấp background và secondaryBackground cho Dismissible. Người dùng cần biết rõ họ đang làm gì và hành động đó sẽ dẫn đến kết quả gì. Một màu nền thay đổi, một icon xuất hiện sẽ giúp trải nghiệm trở nên trực quan hơn rất nhiều. Thiếu cái này là thất bại về UX đấy! Hành Động Phải Rõ Ràng: Chọn hướng DismissDirection một cách có ý nghĩa. Vuốt sang phải thường mang ý nghĩa tích cực (lưu trữ, hoàn thành), vuốt sang trái thường là tiêu cực (xóa, loại bỏ). Đừng để người dùng phải đoán mò. Hãy nghĩ về các ứng dụng lớn mà bạn sử dụng hàng ngày, họ làm điều đó rất nhất quán. Cẩn Trọng Với Hành Động 'Hủy Diệt': Nếu hành động dismiss là xóa vĩnh viễn dữ liệu, hãy cân nhắc thêm một bước xác nhận (confirm dialog) hoặc ít nhất là một SnackBar với nút 'Hoàn tác' (Undo). Không ai muốn vô tình xóa mất email quan trọng cả, đúng không? Giới Hạn Lựa Chọn: Đừng cho phép DismissDirection.horizontal nếu bạn chỉ muốn một hành động cụ thể (ví dụ: chỉ xóa, không lưu trữ). Việc giới hạn hướng vuốt sẽ làm cho giao diện người dùng đơn giản và dễ hiểu hơn. Quá nhiều lựa chọn đôi khi lại gây bối rối. Key Là Quan Trọng: Nhớ rằng Key là bắt buộc cho Dismissible. Nó giúp Flutter nhận diện duy nhất từng widget trong danh sách, đặc biệt khi các item bị thêm/xóa/sắp xếp lại. Dùng ValueKey hoặc ObjectKey nếu dữ liệu của bạn có ID duy nhất. Ứng Dụng Thực Tế: Từ Hộp Thư Đến Mạng Xã Hội DismissDirection và widget Dismissible không phải là thứ gì đó 'trên trời rơi xuống' mà nó đã được ứng dụng rộng rãi trong rất nhiều ứng dụng bạn dùng hàng ngày: Gmail/Outlook (Email Clients): Đây là ví dụ kinh điển nhất. Vuốt một email sang trái để xóa hoặc sang phải để lưu trữ/đánh dấu đã đọc. Tùy chỉnh được cả các hành động này nữa chứ! Todoist/Any.do (Task Management Apps): Vuốt một nhiệm vụ sang phải để đánh dấu hoàn thành, hoặc sang trái để xóa. Giúp việc quản lý công việc trở nên nhanh chóng và ít 'ma sát' hơn. WhatsApp/Telegram (Messaging Apps): Vuốt một tin nhắn sang phải để trả lời (reply) tin nhắn đó, tạo ra một luồng hội thoại rõ ràng và tiện lợi. iOS Mail App: Tương tự như Gmail, ứng dụng Mail mặc định của iOS cho phép vuốt để truy cập các tùy chọn nhanh như xóa, gắn cờ, hoặc lưu trữ. Qua bài này, tôi tin rằng bạn đã nắm vững DismissDirection không chỉ là một enum khô khan mà là một công cụ mạnh mẽ để tạo ra trải nghiệm người dùng mượt mà và trực quan. Hãy vận dụng nó thật thông minh để những ứng dụng của bạn trở nên 'mượt mà' hơn bao giờ hết nhé! Hẹn gặp lại trong bài học tiếp theo! Thuộc Series: Flutter Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!

64 Đọc tiếp