学习 Flutter (三):玩安卓项目实战 - 上

学习 Flutter(三)

在上一章节,我们对 Flutter 中常用的组件和架构有了一定的了解,那么现在我们既有轮子(基础UI),又有框架了,是时候开始造车了,那么本章将开始进行Android项目实战练习,具体实战什么看作者想要实战什么(无规划,难易不定)...遇到啥就针对的去实战,篇幅会比较长,尽量保证原生,不使用第三方插件,跟着一篇文章可以实现一个项目的完整运行!!!

在此声明,感谢大佬提供的开发API,玩Android 开放API-玩Android - wanandroid.com

一、项目准备

  • 搭建项目结构

    cpp 复制代码
    lib/
     └─ app /// 应用配置层
     └─ base /// 基础抽象层
     └─ models /// 数据结构层
     └─ pages /// UI 层
     └─ providers /// 状态管理层
     └─ services /// 业务接口层
     └─ utils /// 工具类库层
     └─ widgets /// 通用组件层
    └─ main.dart /// 入口文件
  • 创建资源文件夹

    lib 同级目录下创建 assets/images 文件夹,并且在 pubspec.yaml 中进行添加

    yaml 复制代码
    flutter:
      uses-material-design: true
    
      assets:
        - assets/images/
  • 添加 http 相关

    首先在 android 包中找到 AndroidManifest.xml 文件并声明网络请求权限,如果是要有 ioslinuxwindows (作者不是很懂),也都声明一下对应包下的权限,并且我们要在 pubspec.yaml 中进行添加 http 相关包

    yaml 复制代码
    dependencies:
      flutter:
        sdk: flutter
      http: ^0.13.5

    由于作者的 Flutter 版本只能支持到这个 http 版本,懒的去升级版本了,希望读者们能自行进行调整哈,主要是公司项目的版本就是这么低,作者也懒得去升级,还要更新一堆依赖,太麻烦了,又不是不能用!

  • 添加 provider 相关

    pubspec.yaml 中进行添加 provider 相关包

    yaml 复制代码
    dependencies:
      flutter:
        sdk: flutter
      provider: ^6.1.1

    provider 是 Flutter 官方推荐的状态管理方案之一,是基于 InheritedWidget 封装的简单、轻量级依赖注入和状态管理工具。它的设计理念是提供一种优雅且高效的手段,让 Widget 树中的各个组件能够访问和响应状态的变化,避免手动传递数据(避免"Prop Drilling"),满足中小型及大型项目的状态管理需求。简单点可以理解为 Android 中的 LiveData, 当前不是完全相同的还是有些差别的。

至此我们基础项目架构已经搭建完成了,我们接下来将逐步完成项目的实现。

二、app包

  • constant.dart

    常量配置,如接口地址、主题色等

    dart 复制代码
    class ApiConstants {
      static const String baseUrl = "https://www.wanandroid.com/";
    
      /// 首页文章
      static const String homePageArticle = "article/list/";
    
      /// 置顶文章
      static const String topArticle = "article/top/json";
    
      /// 获取banner
      static const String banner = "banner/json";
    
      /// 登录
      static const String login = "user/login";
    
      /// 注册
      static const String register = "user/register";
    
      /// 退出登录
      static const String logout = "user/logout/json";
    
      /// 项目分类
      static const String projectCategory = "project/tree/json";
    
      /// 项目列表
      static const String projectList = "project/list/";
    
      /// 搜索
      static const String searchForKeyword = "article/query/";
    
      /// 广场页列表
      static const String plazaArticleList = "user_article/list/";
    
      /// 点击收藏
      static const String collectArticle = "lg/collect/";
    
      /// 取消收藏
      static const String uncollectArticel = "lg/uncollect_originId/";
    
      /// 获取搜索热词
      static const String hotKeywords = "hotkey/json";
    
      /// 获取收藏文章列表
      static const String collectList = "lg/collect/list/";
    
      /// 收藏网站列表
      static const String collectWebaddressList = "lg/collect/usertools/json";
    
      /// 我的分享
      static const String sharedList = "user/lg/private_articles/";
    
      /// 分享文章 post
      static const String shareArticle = "lg/user_article/add/json";
    
      /// todoList
      static const String todoList = "lg/todo/v2/list/";
    }
    class RoutesConstants {
     /// 登录注册界面
     static const String login = "/login_register";
     /// 首页
     static const String home = "/home";
     }
  • routes.dart

    路由表及跳转管理

    dart 复制代码
    class AppRoutes{
      static final routes = <String, WidgetBuilder>{
        RoutesConstants.login: (_) => LoginRegisterPage(),
        RoutesConstants.home: (_) => HomePage(),
      };
    }

三、base 包

  • base.dart

    BasePage 是一个 Mixin,可用于所有继承 State 的类(例如 StatefulWidget 页面)

    dart 复制代码
    mixin BasePage<T extends StatefulWidget> on State<T> {
      /// 是否正在显示loading弹窗
      bool showingLoading = false;
    
      /// 显示加载中弹窗(如果已经显示过了,就不重复显示)
      Future<void> showLoadingDialog() async {
        if (showingLoading) {
          return;
        }
    
        /// 清除焦点,隐藏键盘
        FocusManager.instance.primaryFocus?.unfocus();
    
        showingLoading = true;
        await showDialog<int>(
            context: context,
            barrierDismissible: true, // 允许点击背景关闭弹窗
            builder: (context) {
              return const AlertDialog(
                content: Column(
                  mainAxisSize: MainAxisSize.min, // 内容高度根据子内容压缩
                  children: [
                    CircularProgressIndicator(), // 加载进度条
                    Padding(
                      padding: EdgeInsets.only(top: 24),
                      child: Text("请稍等...."), // 加载提示文字
                    )
                  ],
                ),
              );
            });
        showingLoading = false; // 弹窗关闭后,恢复状态
      }
    
      dismissLoading() {
        if (showingLoading) {
    
          /// 清除焦点,隐藏键盘
          FocusManager.instance.primaryFocus?.unfocus();
    
          showingLoading = false;
          Navigator.of(context).pop(); // 关闭当前弹窗
        }
      }
    }
    
    /// 用于显示加载失败,并提供"点击重试"的组件
    class RetryWidget extends StatelessWidget {
      const RetryWidget({super.key, required this.onTapRetry});
      /// 点击"重试"的回调函数(由外部传入)
      final void Function() onTapRetry;
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
            behavior: HitTestBehavior.opaque, // 允许点击透明区域
            onTap: onTapRetry, // 用户点击整个区域触发重试
            child: const SizedBox(
              width: double.infinity,
              height: double.infinity,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center, // 居中显示
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  Padding(
                      padding: EdgeInsets.only(bottom: 16),
                      child: Icon(Icons.refresh)), // 刷新图标
                  Text("加载失败,点击重试")  // 文本提示
                ],
              ),
            ));
      }
    }
    
    /// 用于显示"无数据"的提示组件
    class EmptyWidget extends StatelessWidget {
      const EmptyWidget({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const SizedBox(
          width: double.infinity,
          height: double.infinity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center, // 内容垂直居中
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Padding(
                  padding: EdgeInsets.only(bottom: 16), child: Icon(Icons.book)),
              Text("无数据")
            ],
          ),
        );
      }
    }

四、services 包

  • api_service.dart

    dart 复制代码
    class ApiService {
    
      /// 登录
      static Future<Map<String, dynamic>> login({
        required String username,
        required String password,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.login);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
          },
        );
        return _handleResponse(response);
      }
    
      /// 注册
      static Future<Map<String, dynamic>> register({
        required String username,
        required String password,
        required String repassword,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.register);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
            'repassword': repassword,
          },
        );
        return _handleResponse(response);
      }
    
      /// 通用处理响应
      static Map<String, dynamic> _handleResponse(http.Response response) {
        if (response.statusCode == 200) {
          print("请求结果为: ${response.body}");
          return jsonDecode(response.body);
        } else {
          throw Exception('请求失败:${response.statusCode}');
        }
      }
    }

五、utils 包

toast_utils.dart

工具类:用于显示原生风格的 Toast 弹窗

dart 复制代码
class ToastUtils {
  // 当前显示的 OverlayEntry (移除时引用)
  static OverlayEntry? _overlayEntry;
  // 标记是否正在显示 Toast ,避免重复弹出
  static bool _isShowing = false;

  /// 显示 Toast 弹窗
  /// [context]: 上下文弹窗
  /// [message]: 要显示的提示文字
  /// [duration]: 持续显示的时间(默认为 2 秒)
  static void showToast(BuildContext context, String message,
      {Duration duration = const Duration(seconds: 2)}) {
    // 如果已经有 Toast 正在显示, 直接返回
    if (_isShowing) return;

    _isShowing = true;

    // 创建 OverlayEntry(悬浮层)
    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        bottom: MediaQuery.of(context).size.height * 0.5, // 离底部距离
        left: MediaQuery.of(context).size.width * 0.2, // 离左边距离
        width: MediaQuery.of(context).size.width * 0.6, // 离右边距离
        child: _ToastWidget(message: message), // 自定义 Toast 样式
      ),
    );

    // 插入到 Overlay 中显示
    Overlay.of(context).insert(_overlayEntry!);

    // 延时关闭 Toast
    Future.delayed(duration, () {
      _overlayEntry?.remove();
      _overlayEntry = null;
      _isShowing = false;
    });
  }
}
/// 私有 Toast 组件,用于实现带动画的样式
class _ToastWidget extends StatefulWidget {
  final String message;

  const _ToastWidget({Key? key, required this.message}) : super(key: key);

  @override
  State<_ToastWidget> createState() => _ToastWidgetState();
}

class _ToastWidgetState extends State<_ToastWidget>
    with SingleTickerProviderStateMixin {
  // 控制透明度动画的控制器
  late AnimationController _controller;
  // 透明度动画
  late Animation<double> _opacityAnimation;

  @override
  void initState() {
    super.initState();
    // 初始化动画控制器
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300), // 动画时长 300ms
      vsync: this,
    );
    // 使用 CurvedAnimation 包裹,使用 easeInOut 曲线
    _opacityAnimation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
    // 播放动画
    _controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _opacityAnimation, // 使用透明度动画包裹整个 Toast
      child: Material(
        color: Colors.transparent, // 背景透明
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), // 内边距
          margin: const EdgeInsets.symmetric(horizontal: 16), // 外边距
          decoration: BoxDecoration(
            color: Colors.black.withOpacity(0.8), // 半透明黑背景
            borderRadius: BorderRadius.circular(20), // 圆角
          ),
          child: Text(
            widget.message,
            textAlign: TextAlign.center,
            style: const TextStyle(color: Colors.white, fontSize: 14),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    // 释放动画资源
    _controller.dispose();
    super.dispose();
  }
}

六、主入口

main.dart

dart 复制代码
void main() {
  // 保证 Flutter 与平台(Android/iOS)进行绑定初始化,确保调用平台通道、使用插件前初始化完毕
  WidgetsFlutterBinding.ensureInitialized();

  // 隐藏系统状态栏和底部导航栏(全屏模式)
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);

  // 启动应用,并注册全局的 Provider 状态管理
  runApp(
    MultiProvider(
      providers: [
        // 注册 LoginResponseProvider 到全局,可以在整个应用中访问该登录状态
        ChangeNotifierProvider(create: (_) => LoginResponseProvider()),
      ],
      child: MyApp(), // 根组件
    ),
  );
}

/// 应用根组件
class MyApp extends StatelessWidget {
  const MyApp();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '玩安卓 Flutter 版', // 应用标题
      debugShowCheckedModeBanner: false, // 关闭右上角 Debug 标签
      theme: ThemeData(
        primarySwatch: Colors.blue, // 设置主题颜色为蓝色
      ),
      initialRoute: RoutesConstants.login, // 应用启动时的初始路由(跳转登录页)
      routes: AppRoutes.routes, // 注册路由表,定义页面跳转路径
    );
  }
}

七、登录界面相关

  • models/login_register_response 登录注册相关数据类,由于 API 接口登录注册数据相同,所以就共用一个

    dart 复制代码
    class LoginRegisterResponse {
      final int errorCode;
      final String errorMsg;
      final UserInfo? data;
    
      LoginRegisterResponse({
        required this.errorCode,
        required this.errorMsg,
        required this.data,
      });
      
      /// 将 JSON 转换为对象
      factory LoginRegisterResponse.fromJson(Map<String, dynamic> json) {
        return LoginRegisterResponse(
          errorCode: json['errorCode'],
          errorMsg: json['errorMsg'] ?? '',
          data: json['data'] != null ? UserInfo.fromJson(json['data']) : null,
        );
      }
      
      /// 将对象转换为 JSON
      Map<String, dynamic> toJson() {
        return {
          'errorCode': errorCode,
          'errorMsg': errorMsg,
          'data': data?.toJson(), // 注意 data 可能为空
        };
      }
    
    }
    
    class UserInfo {
      final bool admin;
      final List<dynamic> chapterTops;
      final int coinCount;
      final List<int> collectIds;
      final String email;
      final String icon;
      final int id;
      final String nickname;
      final String password;
      final String publicName;
      final String token;
      final int type;
      final String username;
    
      UserInfo({
        required this.admin,
        required this.chapterTops,
        required this.coinCount,
        required this.collectIds,
        required this.email,
        required this.icon,
        required this.id,
        required this.nickname,
        required this.password,
        required this.publicName,
        required this.token,
        required this.type,
        required this.username,
      });
    
      /// 将 JSON 转换为对象
      factory UserInfo.fromJson(Map<String, dynamic> json) {
        return UserInfo(
          admin: json['admin'],
          chapterTops: json['chapterTops'] ?? [],
          coinCount: json['coinCount'],
          collectIds: List<int>.from(json['collectIds']),
          email: json['email'] ?? '',
          icon: json['icon'] ?? '',
          id: json['id'],
          nickname: json['nickname'] ?? '',
          password: json['password'] ?? '',
          publicName: json['publicName'] ?? '',
          token: json['token'] ?? '',
          type: json['type'],
          username: json['username'] ?? '',
        );
      }
      
      /// 将对象转换为 JSON
      Map<String, dynamic> toJson() {
        return {
          'admin': admin,
          'chapterTops': chapterTops,
          'coinCount': coinCount,
          'collectIds': collectIds,
          'email': email,
          'icon': icon,
          'id': id,
          'nickname': nickname,
          'password': password,
          'publicName': publicName,
          'token': token,
          'type': type,
          'username': username,
        };
      }
    }
  • services/api_service.dart

    dart 复制代码
    /// 登录
      static Future<Map<String, dynamic>> login({
        required String username,
        required String password,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.login);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
          },
        );
        return _handleResponse(response);
      }
    
      /// 注册
      static Future<Map<String, dynamic>> register({
        required String username,
        required String password,
        required String repassword,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.register);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
            'repassword': repassword,
          },
        );
        return _handleResponse(response);
      }
  • providers/login_response_provider.dart

    dart 复制代码
    class LoginResponseProvider extends ChangeNotifier {
      LoginRegisterResponse? _user;
    
      LoginRegisterResponse? get user => _user;
    
      bool get isLoggedIn => _user != null;
    
      void login(LoginRegisterResponse user) {
        _user = user;
        notifyListeners();
      }
    
      void logout() {
        _user = null;
        notifyListeners();
      }
    }

    LoginResponseProvider 承自 ChangeNotifier,这使得该类可以用于 Provider 框架中进行状态监听和刷新界面。

    • 私有属性 _user:用于存储登录成功后的用户数据。

    • 使用 LoginRegisterResponse 类型,结构清晰,可访问完整的登录响应(如 user.data?.usernameerrorMsg 等)。

  • pages/login_register/login_register_view_model.dart

    dart 复制代码
    class LoginViewModel extends ChangeNotifier {
      // 用于用户名输入框的控制器,管理文本内容
      final usernameController = TextEditingController();
      // 用于密码输入框的控制器,管理文本内容
      final passwordController = TextEditingController();
      // 用于确认密码输入框的控制器,管理文本内容(注册模式时使用)
      final repasswordController = TextEditingController();
    
      // 当前是否处于登录模式,true表示登录,false表示注册
      bool _isLogin = true;
      bool get isLogin => _isLogin;
    
      // 当前是否处于加载状态(显示loading)
      bool _loading = false;
      bool get isLoading => _loading;
    
      // 切换登录/注册模式,切换时通知监听者刷新UI
      void toggleMode() {
        _isLogin = !_isLogin;
        notifyListeners();
      }
    
      // 调用登录接口,发送用户名和密码,等待响应并转换为模型对象
      Future<LoginRegisterResponse> login() async {
        _setLoading(true); // 设置loading状态为true
        final response = await ApiService.login(
          username: usernameController.text.trim(),
          password: passwordController.text.trim(),
        );
        _setLoading(false); // 加载完成,设置loading状态为false
        return LoginRegisterResponse.fromJson(response);
      }
    
      // 调用注册接口,发送用户名、密码和确认密码,等待响应并转换为模型对象
      Future<LoginRegisterResponse> register() async {
        _setLoading(true); // 设置loading状态为true
        final response = await ApiService.register(
          username: usernameController.text.trim(),
          password: passwordController.text.trim(),
          repassword: repasswordController.text.trim(),
        );
        _setLoading(false); // 加载完成,设置loading状态为false
        return LoginRegisterResponse.fromJson(response);
      }
    
      // 私有方法,更新加载状态并通知监听者刷新UI
      void _setLoading(bool value) {
        _loading = value;
        notifyListeners();
      }
    
      // 释放文本控制器资源,防止内存泄漏,建议在页面销毁时调用
      void disposeControllers() {
        usernameController.dispose();
        passwordController.dispose();
        repasswordController.dispose();
      }
    }
  • pages/login_register/login_register_page.dart

    dart 复制代码
    class LoginRegisterPage extends StatelessWidget {
      const LoginRegisterPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        // 使用 ChangeNotifierProvider 提供 LoginViewModel 给子组件使用
        return ChangeNotifierProvider<LoginViewModel>(
          create: (_) => LoginViewModel(),
          child: const _LoginRegisterBody(),
        );
      }
    }
    
    class _LoginRegisterBody extends StatefulWidget {
      const _LoginRegisterBody({super.key});
    
      @override
      State<_LoginRegisterBody> createState() => _LoginRegisterBodyState();
    }
    
    class _LoginRegisterBodyState extends State<_LoginRegisterBody>
        with BasePage<_LoginRegisterBody> {
      @override
      void dispose() {
        // 页面销毁时释放 LoginViewModel 中的控制器资源,防止内存泄漏
        context.read<LoginViewModel>().disposeControllers();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        // 监听 LoginViewModel 的变化,刷新UI
        final vm = context.watch<LoginViewModel>();
        final isLogin = vm.isLogin; // 判断当前是登录模式还是注册模式
    
        return Scaffold(
          appBar: AppBar(
            title: const Text("登录/注册"),
          ),
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                // 用户名输入框,绑定到 ViewModel 的控制器
                TextField(
                  controller: vm.usernameController,
                  decoration: const InputDecoration(hintText: "用户名"),
                ),
                const SizedBox(height: 12),
                // 密码输入框,绑定到 ViewModel 的控制器,且密码隐藏
                TextField(
                  obscureText: true,
                  controller: vm.passwordController,
                  decoration: const InputDecoration(hintText: "密码"),
                ),
                // 如果是注册模式,显示确认密码输入框
                if (!isLogin) ...[ /// ... 是 Dart 语言中的 扩展操作符(spread operator)。
                  const SizedBox(height: 12),
                  TextField(
                    obscureText: true,
                    controller: vm.repasswordController,
                    decoration: const InputDecoration(hintText: "确认密码"),
                  ),
                ],
                const SizedBox(height: 24),
                // 登录或注册按钮,点击触发提交事件
                ElevatedButton(
                  onPressed: () => _onSubmit(context),
                  child: Text(isLogin ? "登录" : "注册"),
                ),
                // 登录或注册按钮,点击触发提交事件
                TextButton(
                  onPressed: () => vm.toggleMode(),
                  child: Text(isLogin ? "没有账号?去注册" : "已有账号?去登录"),
                ),
              ],
            ),
          ),
        );
      }
    
      /// 点击提交按钮时的处理逻辑
      Future<void> _onSubmit(BuildContext context) async {
        FocusScope.of(context).unfocus();
        // 关闭软键盘
        final vm = context.read<LoginViewModel>();
    
        final username = vm.usernameController.text.trim();
        final password = vm.passwordController.text.trim();
        final repassword = vm.repasswordController.text.trim();
    
        // 简单校验用户名和密码是否为空
        if (username.isEmpty || password.isEmpty) {
          ToastUtils.showToast(context, '请输入用户名和密码');
          return;
        }
    
        showLoadingDialog(); // 显示加载弹窗
    
        // 根据当前模式调用登录或注册接口
        final response = vm.isLogin
            ? await vm.login()
            : await (password == repassword
                ? vm.register()
                : Future.error("两次密码不一致"));
    
        dismissLoading(); // 关闭加载弹窗
    
        if (response.errorCode == 0) {
    
          ToastUtils.showToast(
              context, vm.isLogin ? "登录成功 ${response.data?.username}" : "注册成功,请登录");
          if (vm.isLogin) { // 登录成功
            /// 保存用户数据
            context.read<LoginResponseProvider>().login(response);
            /// 跳转至首页
            Navigator.pushNamed(context, RoutesConstants.home);
          }
        } else {
          ToastUtils.showToast(context, "失败: ${response.errorMsg}");
        }
      }
    }

    ... 是 Dart 语言中的 扩展操作符(spread operator)

    它的作用是把一个集合(比如 List)中的所有元素"展开"放到另一个集合中

    在我们的代码中

    • if (!isLogin) 判断是否是注册模式(不是登录)。

    • ...[someList] 就是把这个列表里的所有 widget 展开,作为父级 Column 的直接子元素。

    如果没有 ...,你只能写一个单独的 widget,不能写一个列表。

至此,我们登录注册界面和功能都已完成,如下所示


八、首页相关

首先我们要在 pages 包下创建 home、navi、project、top、tree、personal 包,分别对应的主页面、导航、项目、首页、体系、个人中心模块,考虑到篇幅问题,这里先各自创建一个 helloworld 界面,之后在逐步实现,我们先看主页面设计

  • app/constants.dart

    在配置信息中 RoutesConstants 添加 home_page 相关

    dart 复制代码
    class RoutesConstants {
      /// 登录注册界面
      static const String login = "/login_register";
      /// 首页
      static const String home = "/home";
    }
  • app/routes.dart

    添加路由表及跳转管理

    dart 复制代码
    class AppRoutes{
      static final routes = <String, WidgetBuilder>{
        RoutesConstants.login: (_) => LoginRegisterPage(),
        RoutesConstants.home: (_) => HomePage(),
      };
    }
  • widgets/custom_appbar.dart

    dart 复制代码
    // 自定义 AppBar 组件,实现带标题和可选搜索图标的 AppBar
    class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
      final String title; // 标题文字
      final bool showSearchIcon; // 是否显示右侧的搜索图标
    
      const CustomAppBar({
        Key? key,
        required this.title,
        this.showSearchIcon = true, // 默认显示搜索图标
      }) : super(key: key);
    
      @override
      State<CustomAppBar> createState() => _CustomAppBarState();
    
      // 指定 AppBar 的高度
      @override
      Size get preferredSize => const Size.fromHeight(kToolbarHeight);
    }
    
    class _CustomAppBarState extends State<CustomAppBar> {
      @override
      Widget build(BuildContext context) {
        return AppBar(
          // 设置 AppBar 标题
          title: Text(widget.title),
    
          // 关闭默认的返回按钮(返回箭头),适用于首页等不需要返回的场景
          automaticallyImplyLeading: false,
    
          // 设置 AppBar 背景颜色
          backgroundColor: Colors.blue,
    
          // 自定义标题文字样式
          titleTextStyle: TextStyle(color: Colors.white, fontSize: 20),
    
          // 如果需要显示搜索图标,则构建 IconButton;否则不显示 actions
          actions: widget.showSearchIcon
              ? [
                  IconButton(
                    icon: const Icon(
                      Icons.search,
                      color: Colors.white,
                    ),
                    onPressed: () {
                      // 这里是搜索按钮点击后的回调,可根据需要添加跳转或弹窗逻辑
                      print("搜索图标点击");
                    },
                  )
                ]
              : null,
        );
      }
    }
  • page/home/home_page.dart

    dart 复制代码
    class HomePage extends StatefulWidget {
      @override
      _HomePageState createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      // 当前底部导航选中的索引
      int _currentIndex = 0;
    
      // 用于缓存页面实例,避免每次切换都重新创建
      List<Widget?> _pages = List<Widget?>.filled(5, null, growable: false);
    
      static const List<String> _labels = [
        "首页",
        "体系",
        "导航",
        "项目",
        "我的",
      ];
    
      // 根据索引构建对应页面
      Widget _buildPage(int index) {
        switch (index) {
          case 0:
            return const TopPage(); // 首页页面
          case 1:
            return const TreePage(); // 体系页面
          case 2:
            return const NaviPage(); // 导航页面
          case 3:
            return const ProjectPage(); // 项目页面
          case 4:
            return const PersonalPage(); // 个人中心页面
          default:
            return const SizedBox(); // 默认空白页面
        }
      }
    
      @override
      void initState() {
        super.initState();
    
        // 读取登录状态Provider,获取当前用户信息
        final loginProvider = context.read<LoginResponseProvider>();
        final user = loginProvider.user;
        if (user != null) {
          // 打印当前用户用户名,方便调试
          print('首页获取用户信息: ${user.data?.username}');
        }
      }
    
      @override
      Widget build(BuildContext context) {
        final showSearchIcon = _currentIndex != 4; // 除了"我的"页,其他页显示搜索图标
    
        return Scaffold(
          appBar: CustomAppBar(
            title: _labels[_currentIndex],
            showSearchIcon: showSearchIcon,
          ),
          // 使用 IndexedStack 保持所有页面状态,同时显示当前选中的页面
          body: IndexedStack(
            index: _currentIndex,
            children: List.generate(_pages.length, (index) {
              // 如果缓存中该页面为空,则创建并缓存
              if (_pages[index] == null) {
                _pages[index] = _buildPage(index);
              }
              // 返回缓存的页面实例
              return _pages[index]!;
            }),
          ),
          // 底部导航栏,切换时更新当前索引并刷新界面
          bottomNavigationBar: BottomNavigationBar(
            currentIndex: _currentIndex,
            // 当前选中索引
            onTap: (index) {
              setState(() {
                _currentIndex = index; // 更新选中索引,触发重建
              });
            },
            selectedItemColor: Colors.blue,
            // 选中项颜色
            unselectedItemColor: Colors.grey,
            // 未选中项颜色
            items: const [
              BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
              BottomNavigationBarItem(icon: Icon(Icons.account_tree), label: "体系"),
              BottomNavigationBarItem(icon: Icon(Icons.navigation), label: "导航"),
              BottomNavigationBarItem(icon: Icon(Icons.article), label: "项目"),
              BottomNavigationBarItem(icon: Icon(Icons.person), label: "我的"),
            ],
          ),
          backgroundColor: Colors.white60, // 整体背景色
        );
      }
    }

至此,我们首页已经搭建完成,我们自定义了一个标题栏组件,当在 '我的' 界面时,搜索按钮不进行显示,页面效果如下所示


相关推荐
一只柠檬新36 分钟前
Kotlin协程崩溃了,我也崩溃了-协程异常原理入门
android
你过来啊你43 分钟前
Android Room使用方法与底层原理详解
android
东京老树根1 小时前
SAP学习笔记 - 开发45 - RAP开发 Managed App New Service Definition,Metadata Extension
笔记·学习
EnzoRay1 小时前
Matrix
android
张风捷特烈1 小时前
Flutter 百题斩#15 | 列出 SDK 所有 StatelesWidget 组件
android·flutter
落羽的落羽2 小时前
【C++】神奇的AVL树
开发语言·数据结构·c++·学习
知识分享小能手2 小时前
Vue3 学习教程,从入门到精通,Vue 3 表单控件绑定详解与案例(7)
前端·javascript·vue.js·学习·前端框架·vue3·anti-design-vue
amazinging2 小时前
北京-4年功能测试2年空窗-报培训班学测开-第五十天
python·学习·面试
没有羊的王K2 小时前
SSM框架学习DI入门——day2
java·spring boot·学习
公子绝2 小时前
JAVA学习笔记 使用notepad++开发JAVA-003
java·学习·notepad++·java开发环境