
Chào các "chiến thần code" tương lai! Hôm nay, Thầy Creyt sẽ dẫn các bạn đi "farm" một con boss tên là Stepper trong thế giới Flutter. Nghe tên đã thấy mùi "từng bước, từng bước" rồi đúng không? Chính xác! Thằng này sinh ra để giúp chúng ta chia nhỏ những nhiệm vụ phức tạp thành các bước nhỏ hơn, dễ thở hơn, giống như cách các bạn chia nhỏ bài tập lớn thành từng phần để đỡ bị "overload" vậy.
Stepper là gì và để làm gì?
Thử tưởng tượng thế này: Bạn đang order trà sữa online. Bạn sẽ không bao giờ thấy một cái form dài dằng dặc yêu cầu bạn điền thông tin địa chỉ, chọn topping, chọn size, chọn thanh toán... tất cả trên cùng một màn hình đúng không? Mà nó sẽ chia ra thành: "Bước 1: Chọn món", "Bước 2: Điền thông tin giao hàng", "Bước 3: Thanh toán". Đó chính là Stepper trong thực tế!
Trong Flutter, Stepper là một widget mạnh mẽ giúp bạn tạo ra các quy trình từng bước (stepped process). Nó giống như một "bản đồ kho báu" chỉ dẫn người dùng đi từng chặng một để hoàn thành một nhiệm vụ nào đó. Thay vì bắt người dùng "bơi" trong một biển thông tin, Stepper giúp họ "nhảy cóc" qua từng hòn đảo nhỏ, mỗi hòn đảo là một bước, một nhiệm vụ con.
Để làm gì ư? Đơn giản là để:
- Cải thiện UX (User Experience): Người dùng không bị choáng ngợp, biết mình đang ở đâu và còn bao nhiêu bước nữa. Giảm thiểu "friction" (sự khó chịu).
- Quản lý quy trình phức tạp: Chia nhỏ các form đăng ký, quy trình thanh toán, hướng dẫn sử dụng (onboarding) thành các phần logic.
- Dễ dàng validation: Bạn có thể kiểm tra dữ liệu của từng bước trước khi cho phép người dùng qua bước tiếp theo.
Code Ví Dụ Minh Họa: "Order Trà Sữa" phiên bản Flutter
Chúng ta sẽ xây dựng một Stepper đơn giản với 3 bước: Chọn món, Thông tin giao hàng, và Thanh toá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: 'Flutter Stepper Demo',
theme: ThemeData(
primarySwatch: Colors.teal,
),
home: const StepperHomePage(),
);
}
}
class StepperHomePage extends StatefulWidget {
const StepperHomePage({super.key});
@override
State<StepperHomePage> createState() => _StepperHomePageState();
}
class _StepperHomePageState extends State<StepperHomePage> {
int _currentStep = 0; // Biến này sẽ theo dõi bước hiện tại
// Dữ liệu giả định cho các bước
String _selectedTea = 'Trà Sữa Trân Châu Đường Đen';
String _customerName = '';
String _customerAddress = '';
// GlobalKey để truy cập trạng thái của form (nếu có)
final GlobalKey<FormState> _formKeyStep2 = GlobalKey<FormState>();
List<Step> get _steps => [
Step(
title: const Text('Chọn Món'),
content: Column(
children: <Widget>[
RadioListTile<String>(
title: const Text('Trà Sữa Trân Châu Đường Đen'),
value: 'Trà Sữa Trân Châu Đường Đen',
groupValue: _selectedTea,
onChanged: (String? value) {
setState(() {
_selectedTea = value!;
});
},
),
RadioListTile<String>(
title: const Text('Trà Xanh Kem Cheese'),
value: 'Trà Xanh Kem Cheese',
groupValue: _selectedTea,
onChanged: (String? value) {
setState(() {
_selectedTea = value!;
});
},
),
RadioListTile<String>(
title: const Text('Hồng Trà Sữa'),
value: 'Hồng Trà Sữa',
groupValue: _selectedTea,
onChanged: (String? value) {
setState(() {
_selectedTea = value!;
});
},
),
const SizedBox(height: 16),
Text('Bạn đã chọn: $_selectedTea'),
],
),
isActive: _currentStep >= 0,
state: _currentStep > 0 ? StepState.complete : StepState.indexed,
),
Step(
title: const Text('Thông Tin Giao Hàng'),
content: Form(
key: _formKeyStep2,
child: Column(
children: <Widget>[
TextFormField(
decoration: const InputDecoration(labelText: 'Tên của bạn'),
onSaved: (value) => _customerName = value!,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập tên';
}
return null;
},
),
TextFormField(
decoration: const InputDecoration(labelText: 'Địa chỉ giao hàng'),
onSaved: (value) => _customerAddress = value!,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập địa chỉ';
}
return null;
},
),
],
),
),
isActive: _currentStep >= 1,
state: _currentStep > 1 ? StepState.complete : StepState.indexed,
),
Step(
title: const Text('Thanh Toán'),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Món đã chọn: $_selectedTea'),
Text('Người nhận: $_customerName'),
Text('Địa chỉ: $_customerAddress'),
const SizedBox(height: 16),
const Text('Phương thức thanh toán: Tiền mặt khi nhận hàng'),
const Text('Tổng cộng: 50.000 VNĐ (ví dụ)'),
],
),
isActive: _currentStep >= 2,
state: _currentStep == 2 ? StepState.editing : StepState.indexed,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Order Trà Sữa Cùng Thầy Creyt'),
),
body: Stepper(
type: StepperType.vertical, // Có thể là .horizontal
currentStep: _currentStep,
onStepContinue: () {
// Logic khi nhấn nút 'Tiếp tục'
final isLastStep = _currentStep == _steps.length - 1;
if (isLastStep) {
// Xử lý hoàn tất đơn hàng
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Đơn hàng của bạn đã được đặt!')),
);
// Reset về bước đầu tiên hoặc chuyển sang màn hình khác
setState(() {
_currentStep = 0;
_customerName = '';
_customerAddress = '';
_selectedTea = 'Trà Sữa Trân Châu Đường Đen';
});
} else {
// Kiểm tra validation cho bước 2 trước khi qua bước tiếp theo
if (_currentStep == 1) {
if (_formKeyStep2.currentState!.validate()) {
_formKeyStep2.currentState!.save();
setState(() => _currentStep += 1);
} else {
// Nếu validation thất bại, không chuyển bước
}
} else {
setState(() => _currentStep += 1);
}
}
},
onStepCancel: () {
// Logic khi nhấn nút 'Quay lại'
if (_currentStep == 0) return; // Không lùi được nữa
setState(() => _currentStep -= 1);
},
onStepTapped: (step) {
// Logic khi người dùng chạm vào một bước bất kỳ
setState(() => _currentStep = step);
},
// Tùy chỉnh các nút điều khiển
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Row(
children: <Widget>[
Expanded(
child: ElevatedButton(
onPressed: details.onStepContinue,
child: Text(details.currentStep == _steps.length - 1 ? 'Hoàn Tất' : 'Tiếp Tục'),
),
),
const SizedBox(width: 10),
if (details.currentStep != 0)
Expanded(
child: OutlinedButton(
onPressed: details.onStepCancel,
child: const Text('Quay Lại'),
),
),
],
),
);
},
steps: _steps,
),
);
}
}
Giải thích Code:
_currentStep: Đây là biếnintquan trọng nhất, nó lưu trữ chỉ số của bước hiện tại. Khi_currentStepthay đổi, UI củaSteppersẽ tự động cập nhật.StepperWidget: Widget chính.type: Có thể làStepperType.vertical(mặc định, các bước xếp dọc) hoặcStepperType.horizontal(các bước xếp ngang, thường dùng cho ít bước).currentStep: Gán bằng_currentStepcủaStatefulWidgetcủa chúng ta.onStepContinue: Hàm được gọi khi người dùng nhấn nút "Tiếp tục". Đây là nơi bạn xử lý logic chuyển bước, kiểm tra dữ liệu, hoặc gửi dữ liệu lên server.onStepCancel: Hàm được gọi khi người dùng nhấn nút "Quay lại".onStepTapped: Hàm được gọi khi người dùng chạm vào tiêu đề của một bước bất kỳ để nhảy đến bước đó. Thầy Creyt thường dùng để cho phép người dùng quay lại các bước trước để chỉnh sửa.steps: MộtList<Step>chứa tất cả các bước của quy trình.
StepWidget: MỗiStepđại diện cho một bước trong quy trình.title: Tiêu đề của bước (ví dụ:const Text('Chọn Món')).content: Nội dung chính của bước, có thể là bất kỳ widget nào (ví dụ:ColumnchứaRadioListTilehoặcTextFormField).isActive: Boolean. Nếutrue, bước đó được đánh dấu là đang hoạt động hoặc đã hoàn thành. Thường là_currentStep >= index_của_bước.state: Trạng thái của bước. Có các giá trị nhưStepState.indexed(mặc định),StepState.editing(đang chỉnh sửa),StepState.complete(đã hoàn thành),StepState.error(có lỗi),StepState.disabled(bị vô hiệu hóa). Việc này giúpStepperhiển thị icon tương ứng (số, bút chì, dấu tích, dấu chấm than).
controlsBuilder: Đây là một callback cho phép bạn tùy chỉnh hoàn toàn giao diện của các nút "Tiếp tục" và "Quay lại". Trong ví dụ, Thầy Creyt đã biến chúng thànhElevatedButtonvàOutlinedButtonđể trông "xịn" hơn và thay đổi text tùy theo bước cuối cùng.- Validation (Bước 2): Thầy Creyt đã tích hợp
FormvàTextFormFieldvớivalidatorvàGlobalKeyđể đảm bảo người dùng nhập đủ thông tin trước khi chuyển sang bước thanh toán. Đây là một "chiêu" cực kỳ quan trọng để dữ liệu không bị "rác" và trải nghiệm người dùng không bị "hụt hẫng".

Mẹo (Best Practices) từ Thầy Creyt để "hack" Stepper hiệu quả:
- Giữ các bước ngắn gọn, súc tích: Đừng biến một bước thành một "cuộc marathon" thông tin. Mỗi bước nên có một mục tiêu rõ ràng, duy nhất.
- Phản hồi rõ ràng: Luôn dùng
isActivevàstateđể người dùng biết họ đang ở đâu, bước nào đã xong, bước nào đang lỗi. "Feedback is king" trong UX. - Validation là bạn: Luôn kiểm tra dữ liệu đầu vào ở mỗi bước trước khi cho phép người dùng
onStepContinue. Không ai muốn điền xong 5 bước rồi mới biết bước 1 sai chính tả tên mình. - Tùy biến
controlsBuilder: Các nút mặc định củaStepperhơi "cổ điển". Hãy tận dụngcontrolsBuilderđể "phù phép" cho chúng trông hiện đại và phù hợp với design system của app bạn hơn. - Thử nghiệm
StepperType.horizontal: Đối với các quy trình ít bước (2-3 bước),horizontalcó thể hiệu quả hơn, tiết kiệm không gian và trực quan hơn trên các màn hình rộng. - Xử lý "Loading States": Nếu
onStepContinuekích hoạt một API call, hãy hiển thịCircularProgressIndicatorhoặc disable nút "Tiếp tục" để tránh người dùng nhấn liên tục và tạo ra các request không cần thiết.
Ví dụ thực tế các ứng dụng/website đã ứng dụng (hoặc concept tương tự):
- E-commerce Checkout: Các trang như Tiki, Shopee, Lazada đều có quy trình checkout từng bước: Giỏ hàng -> Địa chỉ -> Thanh toán -> Xác nhận. Đây là ứng dụng kinh điển của Stepper.
- Onboarding Flows: Khi bạn cài đặt một ứng dụng mới lần đầu, thường có các màn hình hướng dẫn sử dụng từng tính năng chính. Đó chính là Stepper được "phù phép" dưới dạng các trang giới thiệu.
- Form Đăng Ký/Thiết Lập Hồ Sơ: Các trang mạng xã hội, dịch vụ email khi bạn đăng ký tài khoản mới, thường yêu cầu bạn điền thông tin qua nhiều bước (tên, email, mật khẩu, ảnh đại diện, sở thích...).
- Wizard Installer: Các phần mềm máy tính khi cài đặt cũng dùng cơ chế "Next > Next > Finish" tương tự.
Thử nghiệm của Thầy Creyt và Hướng dẫn nên dùng cho case nào:
Thầy Creyt đã từng "đau đầu" với một dự án làm một cái form đăng ký tour du lịch dài dằng dặc, đủ các loại thông tin từ cá nhân, lịch trình, yêu cầu đặc biệt, thanh toán... Ban đầu, cứ nhét hết vào một ScrollView và kết quả là "thảm họa" UX. Người dùng nhìn vào là "bỏ chạy" ngay.
Sau đó, Thầy đã quyết định "đập đi xây lại" với Stepper. Chia nhỏ thành:
- Bước 1: Thông tin cá nhân (Tên, email, SĐT)
- Bước 2: Lựa chọn tour (Điểm đến, ngày khởi hành)
- Bước 3: Tùy chọn nâng cao (Xe đưa đón, khách sạn, yêu cầu ăn uống)
- Bước 4: Thanh toán và xác nhận
Kết quả là tỉ lệ hoàn thành form tăng vọt! Khách hàng cảm thấy "nhẹ nhàng" hơn rất nhiều. Việc này chứng minh rằng Stepper không chỉ là một widget, mà là một chiến lược thiết kế UX.
Nên dùng Stepper khi nào?
- Khi bạn có một quy trình có thứ tự rõ ràng, mà bước sau phụ thuộc vào bước trước.
- Khi một nhiệm vụ có nhiều thông tin cần nhập hoặc nhiều quyết định cần đưa ra.
- Khi bạn muốn giảm tải nhận thức (cognitive load) cho người dùng, giúp họ tập trung vào từng phần nhỏ của nhiệm vụ.
- Để tạo ra một trải nghiệm người dùng chuyên nghiệp và có cấu trúc cho các tác vụ quan trọng như mua hàng, đăng ký, thiết lập.
Tuyệt đối tránh dùng Stepper cho những tác vụ đơn giản, chỉ cần một vài trường nhập liệu. Đừng "làm màu" quá mức cần thiết, vì đôi khi, sự đơn giản lại là đỉnh cao của thiết kế.
Vậy đó, các bạn trẻ! Stepper không phải là một con boss khó nhằn nếu bạn biết cách "farm" nó đúng kỹ thuật. Hãy thực hành, mày mò và biến những quy trình phức tạp thành những trải nghiệm "smooth như kem" cho người dùng 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é!