Stepper Flutter: Thầy Creyt giải mã hành trình từng bước cho Gen Z
Flutter

Stepper Flutter: Thầy Creyt giải mã hành trình từng bước cho Gen Z

Author

Admin System

@root

Ngày xuất bản

21 Mar, 2026

Lượt xem

1 Lượt

"Stepper"

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:

  1. _currentStep: Đây là biến int quan trọng nhất, nó lưu trữ chỉ số của bước hiện tại. Khi _currentStep thay đổi, UI của Stepper sẽ tự động cập nhật.
  2. Stepper Widget: Widget chính.
    • type: Có thể là StepperType.vertical (mặc định, các bước xếp dọc) hoặc StepperType.horizontal (các bước xếp ngang, thường dùng cho ít bước).
    • currentStep: Gán bằng _currentStep của StatefulWidget củ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ột List<Step> chứa tất cả các bước của quy trình.
  3. Step Widget: Mỗi Step đạ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ụ: Column chứa RadioListTile hoặc TextFormField).
    • isActive: Boolean. Nếu true, 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úp Stepper hiển thị icon tương ứng (số, bút chì, dấu tích, dấu chấm than).
  4. 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ành ElevatedButtonOutlinedButton để trông "xịn" hơn và thay đổi text tùy theo bước cuối cùng.
  5. Validation (Bước 2): Thầy Creyt đã tích hợp FormTextFormField với validatorGlobalKey để đả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".
Illustration

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 isActivestate để 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ủa Stepper hơi "cổ điển". Hãy tận dụng controlsBuilder để "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), horizontal có 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 onStepContinue kích hoạt một API call, hãy hiển thị CircularProgressIndicator hoặ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:

  1. Bước 1: Thông tin cá nhân (Tên, email, SĐT)
  2. Bước 2: Lựa chọn tour (Điểm đến, ngày khởi hành)
  3. Bước 3: Tùy chọn nâng cao (Xe đưa đón, khách sạn, yêu cầu ăn uống)
  4. 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é!

#tech #cyberpunk #laravel
Chỉnh sửa bài viết

Bình luận (0)

Vui lòng Đăng Nhập để Bình luận

Hỗ trợ Markdown cơ bản
Nguyễn Văn A
1 ngày trước

Tính năng này đỉnh quá ad ơi, chờ mãi mới thấy một blog Tiếng Việt có UI/UX xịn như vầy!