Học viên: Phạm Ngọc Huyền
Lớp: Flutter04
Bài viết được dịch từ : Create a Sleek UI in Flutter - Wolt App Case Study

Flutter hứa hẹn sẽ cung cấp những ứng dụng tuyệt vời cho thiết bị di động và web từ một cơ sở mã duy nhất. Hôm nay, chúng ta sẽ kiểm tra xem nó có thực hiện đúng lời hứa hay không bằng cách tạo lại một phần của ứng dụng hiện có. Tôi chọn ứng dụng Wolt vì nhóm của họ đã làm rất tốt, tạo ra giao diện người dùng và trải nghiệm người dùng đẹp mắt với nhiều chi tiết tinh tế.

Mục tiêu của tôi trong loạt bài này là trình bày một quá trình suy nghĩ dẫn đến kết quả mong muốn thay vì cung cấp giải pháp sao chép - dán. Chúng tôi sẽ làm việc theo chu trình xây dựng - tái cấu trúc, kiểm tra các vấn đề tiềm ẩn và các hạn chế của framework. Bạn có thể tìm thấy toàn bộ mã cho loạt bài này trên github của chúng tôi

Hãy thực hiện nào!

Các nút và menu trên thanh ứng dụng

Hãy bắt đầu với một thứ có vẻ đơn giản - các nút trên thanh ứng dụng. Có hai trong số chúng - một được sử dụng để điều hướng quay lại và cái còn lại hiển thị menu có hai mục. Ý tưởng đầu tiên của tôi là sử dụng IconButton được tạo bằng Container có trang trí hình tròn:

class AppBarButton extends StatelessWidget {
 final IconData icon;
 final VoidCallback onPressed;
   
    const AppBarButton({Key key, this.icon, this.onPressed}) : super(key: key);

 @override
 Widget build(BuildContext context) {
   return Container(
     decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey[300]),
     child: IconButton(
       icon: Icon(icon),
       onPressed: onPressed,
     ),
   );
 }
}

Khi chúng tôi đặt các tiện ích đó vào AppBar:

SliverAppBar(
 leading: AppBarButton(
   icon: Icons.keyboard_backspace,
   onPressed: () => Navigator.pop(context),
 ),
 actions: [
   AppBarButton(
     icon: Icons.more_horiz,
     onPressed: () { /* TODO */ },
   ),
 ],

Kết quả đã khá giống với những gì chúng ta có trong ứng dụng gốc:

Appbar button and menu

Triển khai đầu tiên của chúng tôi không có padding và các nút quá lớn. Khi chúng ta nhấn nút, nó hiển thị phần nổi bật màu xám, sau đó là phần chuyển động, tối hơn thay vì thay đổi độ mờ.

Hãy thử triển khai những tính năng còn thiếu đó.

Kích thước và bố cục

Trên iPhone 8, các nút bấm gốc có kích thước 40x40 point, cách lề 16 point so với cạnh màn hình và 8 point phía dưới. Cho thanh ứng dụng có chiều cao 44 điểm trên iPhone này, điều đó có nghĩa là các nút đó phải phủ lên thanh trạng thái (thanh có cường độ tín hiệu, đồng hồ và trạng thái pin) và thực tế chúng là:

size and layout

Chúng ta có thể áp dụng những ràng buộc đó bằng cách gói các tiện ích nút của mình vào padding và align.

return Padding(
     padding: EdgeInsets.fromLTRB(
       position == AppBarPosition.leading ? 16 : 0, 0,
       position == AppBarPosition.trailing ? 16 : 0, 8),
     child: Align(
       alignment: Alignment.topCenter,
       child: Container(
         width: 40,
         height: 40,
(...)

add padding and align

Một điểm khác biệt là offset dưới cùng lớn hơn trong quá trình triển khai của chúng tôi và chúng tôi thậm chí không làm tràn thanh trạng thái. Lý do là chiều cao của thanh ứng dụng của chúng ta được tính dựa trên const double kToolbarHeight = 56.0; hằng số từ framework phát triển Flutter. Không có cách rõ ràng để đặt chiều cao thanh ứng dụng, vd. theo tham số hàm tạo và class chịu trách nhiệm về bố cục thanh ứng dụng, sử dụng hằng số này, là private (_SliverAppBarDelegate).

Điều này ngăn chúng ta sử dụng tính kế thừa để ghi đè mã chịu trách nhiệm tính toán chiều cao. Một lần nữa, delegate này không được thanh ứng dụng (SliverAppBar) hiển thị, vì vậy ngay cả khi chúng tôi tạo phiên bản của riêng mình, chúng tôi sẽ không thể sử dụng nó trừ khi chúng tôi cũng mở rộng SliverAppBar và ghi đè phương thức xây dựng từ trạng thái của nó.

Vì Flutter là nguồn mở nên việc này có thể được thực hiện trong vài phút, bằng cách sao chép-dán triển khai ban đầu và điều chỉnh các chi tiết đó, nhưng đó không phải là giải pháp khả thi vì chúng tôi sẽ phải duy trì phiên bản của mình và giữ cho nó đồng bộ với các cải tiến do nhóm Flutter về các class ban đầu.

Cần lưu ý rằng kToolbarHeight cũng được sử dụng để hạn chế độ rộng của leading widget (nút quay lại trong trường hợp của chúng tôi), buộc nó phải là hình vuông. Thanh ứng dụng của chúng tôi trông như thế này với biên độ tăng lên 25 point. Lưu ý rằng nút ở đầu được thu nhỏ lại, trong khi dấu ở cuối được đặt cách mép màn hình như mong đợi. Giới hạn này không có tác dụng trong trường hợp của chúng tôi, vì khoảng cách tới lề và kích thước nút được thiết kế hoàn toàn khớp với không gian có sẵn.

Làm nổi bật hành vi

Trong ứng dụng Wolt khi nút được làm nổi bật, nó sẽ thay đổi độ mờ của biểu tượng. Không có sự thay đổi màu sắc nổi bật hoặc chuyển động. Chúng ta có thể tạo lại hành vi như vậy bằng cách gói nút trong tiện ích Opacity. Để theo dõi trạng thái nổi bật, chúng tôi phải giới thiệu trạng thái bên trong, được biểu thị bằng thuộc tính boolean _isHighlighted. Điều đó có nghĩa là chúng ta phải cấu trúc lại widget của mình từ stateless sang stateful:

enum AppBarPosition {
 leading,
 trailing,
}

class AppBarButton extends StatefulWidget {
 final IconData icon;
 final VoidCallback onPressed;
 final AppBarPosition position;

 const AppBarButton({Key key, this.icon, this.onPressed, this.position}) : super(key: key);

 @override
 _AppBarButtonState createState() => _AppBarButtonState();
}

class _AppBarButtonState extends State {

 bool _isHighlighted = false;

 @override
 Widget build(BuildContext context) {
   return Padding(
     padding: EdgeInsets.fromLTRB(widget.position == AppBarPosition.leading ? 16 : 0, 0,
         widget.position == AppBarPosition.trailing ? 16 : 0, 8),
     child: Align(
       alignment: Alignment.topCenter,
       child: Container(
         width: 40,
         height: 40,
         decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey[300]),
         child: Opacity(
           opacity: _isHighlighted ? 0.3 : 1.0,
           child: IconButton(
             icon: Icon(
               widget.icon,
               color: Colors.black,
             ),
             onPressed: widget.onPressed,
           ),
         ),
       ),
     ),
   );
 }
}

Thật không may, IconButton mà chúng tôi đang sử dụng không hiển thị lệnh gọi lại onHighlightChanged - chỉ onPressed, điều này không đủ cho nhu cầu của chúng tôi. Chúng tôi phải cấu trúc lại mã của mình để sử dụng lớp nút chung hơn, như RawMaterialButton, nơi chúng tôi có nhiều quyền kiểm soát hơn đối với các lệnh gọi lại và cài đặt hình ảnh.

child: RawMaterialButton(
   highlightColor: Colors.transparent,
   splashColor: Colors.transparent,
   onHighlightChanged: (isHighlighted) => setState(() {
     _isHighlighted = isHighlighted;
   }),
   child: Icon(
     widget.icon,
     color: Colors.black,
   ),
   onPressed: widget.onPressed,
 ),

Hiển thị menu

Khi người dùng nhấn nút menu, có hai điều xảy ra - menu được hiển thị và biểu tượng nút thay đổi từ ba dấu chấm sang đóng chéo. Chúng tôi sẽ theo dõi trạng thái hiện tại trong thuộc tính boolean _isMenuShown ở trạng thái của screen-route. Nút menu được cập nhật:

AppBarButton(
     icon: _isMenuShown ? Icons.close : Icons.more_horiz,
     position: AppBarPosition.trailing,
     onPressed: () {
       Navigator.push(context, AppBarMenu())
           .then((_) => setState(() => _isMenuShown = false));
       setState(() => _isMenuShown = true);
     },
   ),

Chúng tôi sẽ xây dựng lớp AppBarMenu mở rộng PopupRoute, vì nó cho phép chúng tôi kiểm soát giao diện người dùng của menu nhiều hơn PopupMenuButton từ framework Flutter. Khi nhấn nút, tiện ích điều hướng có nhiệm vụ đẩy route của chúng tôi vào ngăn xếp. Phương thức đẩy trả về một tương lai hoàn thành sau khi route này bị loại bỏ - đó là lý do tại sao chúng tôi đặt _isMenuShown thành false trong lệnh gọi lại then.

Menu nhìn và cảm nhận

Nhiệm vụ cuối cùng của chúng ta là xây dựng một menu sẽ được hiển thị. Nó khá đơn giản - một danh sách có hai mục, những phần ít rõ ràng hơn có thể là cách đặt nó trên màn hình và cách đạt được hình dạng hình chữ nhật với các góc tròn và chỉ báo hình tam giác ở trên cùng. Chúng tôi sẽ sử dụng tiện ích ClipPath với một công cụ cắt tùy chỉnh để tạo hình dạng mong muốn.

Một giải pháp thay thế là tạo ClipRRect (RRect là viết tắt của hình chữ nhật bo tròn) với tiện ích Hình ảnh cho hình tam giác trên cùng. Tuy nhiên, một ý tưởng khác là có toàn bộ nền dưới dạng hình ảnh chín mảnh và chắc chắn có một số tùy chọn khả thi hơn để đạt được giao diện người dùng mong muốn. Do kiến trúc hướng đến widget của Flutter, thường có nhiều cách để chúng ta có thể kết hợp các cấu trúc đơn giản hiện có thành các cấu trúc phức tạp hơn - như menu có hình dạng lạ mắt này. Bạn có thể xem trước các nút và menu được cập nhật trên ảnh gif bên dưới:

menu look

Kết luận

Flutter cho phép chúng tôi tạo lại rất giống UI và UX phần giao diện của Wolt. Chúng tôi có thể nhanh chóng đạt được giao diện đẹp mắt bằng cách tạo các widget gốc và ngay cả khi có một số hạn chế nhất định, do tính chất nguồn mở của framework Flutter, vẫn có thể đạt được chất lượng pixel hoàn hảo khi cần. Các tính năng và khả năng phát triển dễ dàng của Flutter đã khiến nó trở thành sự lựa chọn phổ biến về công nghệ của nhiều công ty.