Skip to content
<

Flutter 实战

详细代码可参见 仓库 folder 40

lib/model 目录下缺少 news_response.g.dart,

lib/components/Home/ListItem.dart 中部分 UI 缺少.

一些需要注意的点

  • Scaffold: 实现基本的 Material Design 可视布局结构
  • SafeArea: 一种小部件,它在其子部件中插入足够的填充,以避免操作系统的入侵(适配异形屏)
  • Column/Row: 按列/行布局
  • SideBox/Container 都可以设置 width/height,但用 Container 只设置 width, height 的话 Android Studio 会提示换为 SideBox
  • Swiper: 轮播图,使用时父组件要设置 width 与 height
  • Expanded: 展开 Row、Column 或 Flex 的子级的小部件 这样孩子就能填满可用的空间(作为 Row/Column/Flex 的 children 使用)
  • GestureDetector/InkWell: 为 child 添加点击事件的容器,InkWell 会在点击时出现水波纹

设置宽高和 margin/padding

dart
Container(
    margin: EdgeInsets.only(left: 10.h, right: 10.h, top: 5.w, bottom: 5.w),
    padding: EdgeInsets.only(left: 10.h, right: 10.h, top: 10.w, bottom: 10.w),
    decoration: BoxDecoration(
        border: Border.all(color: Colors.black12, width: 2.r),
        borderRadius: BorderRadius.all(Radius.circular(5.r)),
    ),
    child: ,//
)

使用 Swiper

dart
SizedBox(
    width: double.infinity,
    height: 150.h,
    child: Swiper(
        indicatorLayout: PageIndicatorLayout.NONE,
        autoplay: true,
        pagination: const SwiperPagination(),
        control: const SwiperControl(),
        itemCount: 3,
        itemBuilder: (context, index) {
            return Container(
                width: double.infinity,
                margin: EdgeInsets.all(15.r),
                height: 150.h,
                color: Colors.lightBlue,
            );
        },
    ),
)

ListView: 创建列表视图

dart
ListView.builder(
    itemBuilder: (context, index) {
        return _listItemView(context, index);
    },
    itemCount: 10,
)

点击时路由跳转

封装路由管理后,要用navigatorKey: RouteUtils.navigatorKey,onGenerateRoute: Routes.generateRoute,initialRoute: RoutePath.home,应用

dart
GestureDetector(
    onTap: () {
        Navigator.pushNamed(context, RoutePath.webViewPage);
        // Navigator.push(context, MaterialPageRoute(builder: (context) {
        //     return WebViewPage(title: "首页跳转来的");
        // }));
    },
    child: _listItemView(context, index),
)

封装路由管理

dart
class Routes {
  static Route<dynamic> generateRoute(RouteSettings settings) {
    switch (settings.name) {
      case RoutePath.home:
        return pageRoute(HomePage());
      case RoutePath.webViewPage:
        return pageRoute(WebViewPage(title: "跳转"));
      default:
        return pageRoute(
          Scaffold(
            body: SafeArea(
              child: Center(child: Text("路由 ${settings.name} 不存在")),
            ),
          ),
        );
    }
  }

  static MaterialPageRoute pageRoute(
    Widget page, {
    RouteSettings? settings,
    bool? fullscreenDialog,
    bool? maintainState,
    bool? allowSnapshotting,
  }) {
    return MaterialPageRoute(
      builder: (context) {
        return page;
      },
      settings: settings,
      fullscreenDialog: fullscreenDialog ?? false,
      maintainState: maintainState ?? true,
      allowSnapshotting: allowSnapshotting ?? true,
    );
  }
}

// 路由地址
class RoutePath {
  // 首页
  static const String home = "/";

  // 网页页面
  static const String webViewPage = "/web_view_page";
}

封装路由工具类

dart
import 'package:flutter/material.dart';

class RouteUtils {
  RouteUtils._();

  static final navigatorKey = GlobalKey<NavigatorState>();

  // App 根节点 Context
  static BuildContext get context => navigatorKey.currentContext!;

  static NavigatorState get navigator => navigatorKey.currentState!;

  // 普通动态跳转 --> page
  static Future push(
    BuildContext context,
    Widget page, {
    bool fullscreenDialog = false,
    RouteSettings? settings,
    bool maintainState = true,
  }) {
    return Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => page,
        fullscreenDialog: fullscreenDialog,
        settings: settings,
        maintainState: maintainState,
      ),
    );
  }

  // 根据路由路径跳转
  static Future pushForNamed(
    BuildContext context,
    String name, {
    Object? arguments,
  }) {
    return Navigator.pushNamed(context, name, arguments: arguments);
  }

  // 自定义 route 动态跳转
  static Future pushForPageRoute(BuildContext context, Route route) {
    return Navigator.push(context, route);
  }

  // 清空栈,只留目标页面
  static Future pushNamedAndRemoveUntil(
    BuildContext context,
    String name, {
    Object? arguments,
  }) {
    return Navigator.pushNamedAndRemoveUntil(
      context,
      name,
      (route) => false,
      arguments: arguments,
    );
  }

  static Future pushAndRemoveUntil(
    BuildContext context,
    Widget page, {
    bool fullscreenDialog = false,
    RouteSettings? settings,
    bool maintainState = true,
  }) {
    return Navigator.pushAndRemoveUntil(
      context,
      MaterialPageRoute(
        builder: (_) => page,
        fullscreenDialog: fullscreenDialog,
        settings: settings,
        maintainState: maintainState,
      ),
      (route) => false,
    );
  }

  // 用新的路由替换当前路由
  static Future pushReplacement(
    BuildContext context,
    Route route, {
    Object? result,
  }) {
    return Navigator.pushReplacement(context, route, result: result);
  }

  // 用新的路由替换当前路由
  static Future pushReplacementNamed(
    BuildContext context,
    String name, {
    Object? result,
    Object? arguments,
  }) {
    return Navigator.pushReplacementNamed(
      context,
      name,
      arguments: arguments,
      result: result,
    );
  }

  // 关闭当前页面
  static void pop(BuildContext context) {
    Navigator.pop(context);
  }

  // 关闭当前页面:包含返回值
  static void popOfData<T extends Object?>(BuildContext context, {T? data}) {
    Navigator.of(context).pop(data);
  }
}

ListViewshrinkWrapphysics

在 Flutter 中,ListViewshrinkWrap 属性非常有用,尤其是在嵌套列表视图或者在滚动视图不固定高度的情况下。

shrinkWrap 属性的类型为 bool,默认值为 false。以下是关于 shrinkWrap 的详细解释:

  • shrinkWrapfalseListView 会尽可能多的占用垂直空间,这意味着 ListView 会尝试填充其父级提供的所有可用空间。这种情况下,ListView 必须放在一个有固定高度的容器内,否则会报错,因为 ListView 需要知道其高度才能正确地进行布局。
  • shrinkWraptrueListView 会根据其子项的总高度来调整自己的高度,而不是尽可能多的占用可用空间。使用 shrinkWraptrue 可以让 ListView 根据其内容的实际高度来决定其高度,这样就不需要固定高度的外部容器了。 以下是一些使用 shrinkWrap: true 的场景:
  1. 嵌套 ListView:如果你在一个 ListView 中嵌套了另一个 ListView,内层的 ListView 必须设置 shrinkWrap: true,否则会报错,因为外层的 ListView 无法确定其高度。
  2. 动态列表高度:当你的 ListView 中的项目数量或内容会动态变化时,设置 shrinkWrap: true 可以确保 ListView 的高度会根据内容的变化而调整。

需要注意的是,设置 shrinkWrap: true 可能会降低 ListView 的性能,因为它需要遍历所有子项来确定其高度,而不是仅仅使用一个滚动容器。因此,只有在你确实需要的时候才应该使用 shrinkWrap: true

在 Flutter 中,ListViewphysics 属性用于指定列表视图应如何响应用户的滚动行为。physics 属性的类型是 ScrollPhysics,它定义了滚动视图的滚动行为,例如滚动时的动量和弹跳效果。

以下是 physics 属性的一些常见用途和默认值:

  • 默认情况下,ListViewphysics 属性被设置为 AlwaysScrollableScrollPhysics,这意味着无论列表的内容是否足以滚动,用户都可以尝试滚动列表视图。
  • 如果你想要禁用滚动,可以将 physics 属性设置为 NeverScrollableScrollPhysics。这将阻止用户滚动列表视图,即使内容超出了视图的范围。
  • 另一个常用的 ScrollPhysicsClampingScrollPhysics,它是 AlwaysScrollableScrollPhysics 的一个子类,用于确保滚动不会超出内容的边界。
  • BouncingScrollPhysics 允许滚动超出内容边界,并在用户释放时产生弹跳效果,这在 iOS 设备上比较常见。 以下是一些 physics 属性的示例:
dart
// 允许滚动,并且在滚动超出边界时有弹跳效果
ListView(
  physics: BouncingScrollPhysics(),
  children: [...],
)
// 允许滚动,但是没有弹跳效果,滚动会被限制在内容边界内
ListView(
  physics: ClampingScrollPhysics(),
  children: [...],
)
// 禁用滚动
ListView(
  physics: NeverScrollableScrollPhysics(),
  children: [...],
)

自定义 ScrollPhysics 可以让你更细致地控制滚动行为,例如改变滚动速度、动量、阻尼等。

总之,physics 属性是用于定制 ListView 的滚动体验的,它决定了列表视图如何响应用户的滚动输入。

让 banner 和 ListView 一起滑动

dart
class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: SingleChildScrollView( // 让它们共同包裹在 SingleChildScrollView,相当于 Android 中的 ScrollView,但是需要子组件计算高度啥的
          child: Column(
            children: [
              _banner(),
              // 取消 Expanded 布局
              ListView.builder(
                itemBuilder: (context, index) {
                  return InkWell(
                    onTap: () {
                      RouteUtils.pushForNamed(
                        context,
                        RoutePath.webViewPage,
                        arguments: {"name": "使用路由传值"},
                      );
                    },
                    child: _listItemView(context, index),
                  );
                },
                itemCount: 10,
                shrinkWrap: true, // 这个属性可以让 ListView 内部计算所有子组件的高度
                physics: NeverScrollableScrollPhysics(), // 禁用 ListView 本身的滑动事件
              ),
            ],
          ),
        ),
      ),
    );
  }
}

路由传参接收

传参

dart
RouteUtils.pushForNamed(
  context,
  RoutePath.webViewPage,
  arguments: {"name": "使用路由传值"},
);

接收

dart
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

class WebViewPage extends StatefulWidget {
  const WebViewPage({super.key});

  @override
  State<StatefulWidget> createState() {
    return _WebViewPageState();
  }
}

class _WebViewPageState extends State<WebViewPage> {
  String? name;

  @override
  void initState() {
    super.initState();
    // 组件初始化完成后获取路由参数
    WidgetsBinding.instance.addPostFrameCallback((timestamp) {
      var map = ModalRoute.of(context)?.settings.arguments;
      if (map is Map) {
        name = map["name"];
      }
      setState(() {}); // setState 方法会刷新整个组件,后面用状态管理代替
    });
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // var map = ModalRoute.of(context)?.settings.arguments;
    // if (map is Map) {
    //   name = map["name"];
    // }
    // setState(() {}); // setState 方法会刷新整个组件,后面用状态管理代替
  }

  @override
  Widget build(BuildContext context) {
    // var map = ModalRoute.of(context)?.settings.arguments;
    // var name = "";
    // if (map is Map) {
    //   name = map["name"];
    // }
    return Scaffold(
      appBar: AppBar(title: Text("WebView ${name}")),
      body: SafeArea(
        child: Container(
          child: InkWell(
            onTap: () {
              Navigator.pop(context);
            },
            child: SizedBox(width: 200.w, height: 200.h, child: Text("返回")),
          ),
        ),
      ),
    );
  }
}

Dio 网络请求 && Provider 状态管理刷新组件

使用 JsonToDart(JSON To Dart) 插件做 json 数据转 dart 的类文件

home_vm.dart

dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:my_bilibili/datas/home_banner_data.dart';
import 'package:my_bilibili/datas/home_list_data.dart';

class HomeViewModel with ChangeNotifier {
  static Duration timeout = Duration(seconds: 30);
  Dio dio = Dio();

  List<BannerItemData>? bannerList;
  List<HomeListItemData>? listData;

  HomeViewModel() {
    dio.options = BaseOptions(
      method: "GET",
      baseUrl: "https://www.wanandroid.com",
      connectTimeout: timeout,
      receiveTimeout: timeout,
      sendTimeout: timeout,
    );
  }

  // 获取首页 banner 数据
  Future getBanner() async {
    Response resp = await dio.get("/banner/json");
    HomeBannerData bannerData = HomeBannerData.fromJson(resp.data);
    bannerList = bannerData.data ?? [];
    notifyListeners();
  }

  // 获取首页文章列表
  Future getHomeList() async {
    Response resp = await dio.get("/article/list/1/json");
    HomeData homeData = HomeData.fromJson(resp.data);
    listData = homeData.data?.datas ?? [];
    notifyListeners();
  }
}

home_page.dart

dart
Widget build(BuildContext context) {
  return ChangeNotifierProvider<HomeViewModel>(
    create: (context) {
      return viewModel;
    },
    child: Scaffold(
      body: SafeArea(
        child: SingleChildScrollView(
          child: Column(
            children: [
              Consumer<HomeViewModel>(
                builder: (context, vm, child) {
                  return _banner(vm);
                },
              ),
              Consumer<HomeViewModel>(
                builder: (context, vm, child) {
                  return _listView(vm);
                },
              ),
            ],
          ),
        ),
      ),
    ),
  );
}

封装 Dio 网络请求工具

dart
void main() {
  // 开始运行 App 的时候调用初始化
  DioInstance.instance().initDio(baseUrl: "https://www.wanandroid.com");
  runApp(const MyApp());
}
dart
import 'package:dio/dio.dart';

import 'http_method.dart';

class DioInstance {
  static DioInstance? _instance;
  static const _defaultTimeout = Duration(seconds: 30);
  final Dio _dio = Dio();

  DioInstance._();

  static DioInstance instance() {
    return _instance ??= DioInstance._();
  }

  void initDio({
    required String baseUrl,
    String? method = HttpMethod.GET,
    Duration connectTimeout = _defaultTimeout,
    Duration receiveTimeout = _defaultTimeout,
    Duration sendTimeout = _defaultTimeout,
    ResponseType? responseType = ResponseType.json,
    String? contentType,
  }) {
    _dio.options = BaseOptions(
      method: method,
      baseUrl: baseUrl,
      connectTimeout: _defaultTimeout,
      receiveTimeout: _defaultTimeout,
      sendTimeout: _defaultTimeout,
      responseType: responseType,
      contentType: contentType,
    );
  }

  // get 请求
  Future<Response> get({
    required String path,
    Map<String, dynamic>? param,
    Options? options,
    CancelToken? cancelToken,
  }) {
    return _dio.get(
      path,
      queryParameters: param,
      options:
          options ??
          Options(
            method: HttpMethod.GET,
            receiveTimeout: _defaultTimeout,
            sendTimeout: _defaultTimeout,
          ),
      cancelToken: cancelToken,
    );
  }

  // post 请求
  Future<Response> post({
    required String path,
    Object? data,
    Map<String, dynamic>? queryParameters,
    Options? options,
    CancelToken? cancelToken,
  }) {
    return _dio.post(
      path,
      data: data,
      queryParameters: queryParameters,
      options:
          options ??
          Options(
            method: HttpMethod.GET,
            receiveTimeout: _defaultTimeout,
            sendTimeout: _defaultTimeout,
          ),
      cancelToken: cancelToken,
    );
  }
}

Dio 拦截器

使用拦截器
dart
import 'package:dio/dio.dart';
import 'package:my_bilibili/http/print_log_interceptor.dart';
import 'package:my_bilibili/http/resp_interceptor.dart';

import 'http_method.dart';

class DioInstance {
  static DioInstance? _instance;
  static const _defaultTimeout = Duration(seconds: 30);
  final Dio _dio = Dio();

  DioInstance._();

  static DioInstance instance() {
    return _instance ??= DioInstance._();
  }

  void initDio({
    required String baseUrl,
    String? method = HttpMethod.GET,
    Duration connectTimeout = _defaultTimeout,
    Duration receiveTimeout = _defaultTimeout,
    Duration sendTimeout = _defaultTimeout,
    ResponseType? responseType = ResponseType.json,
    String? contentType,
  }) {
    _dio.options = BaseOptions(
      method: method,
      baseUrl: baseUrl,
      connectTimeout: _defaultTimeout,
      receiveTimeout: _defaultTimeout,
      sendTimeout: _defaultTimeout,
      responseType: responseType,
      contentType: contentType,
    );
    // 添加打印请求拦截器
    _dio.interceptors.add(PrintLogInterceptor());
    // 添加统一返回值处理拦截器
    _dio.interceptors.add(ResponseInterceptor());
  }

  // get 请求
  Future<Response> get({
    required String path,
    Map<String, dynamic>? param,
    Options? options,
    CancelToken? cancelToken,
  }) {
    return _dio.get(
      path,
      queryParameters: param,
      options:
          options ??
          Options(
            method: HttpMethod.GET,
            receiveTimeout: _defaultTimeout,
            sendTimeout: _defaultTimeout,
          ),
      cancelToken: cancelToken,
    );
  }

  // post 请求
  Future<Response> post({
    required String path,
    Object? data,
    Map<String, dynamic>? queryParameters,
    Options? options,
    CancelToken? cancelToken,
  }) {
    return _dio.post(
      path,
      data: data,
      queryParameters: queryParameters,
      options:
          options ??
          Options(
            method: HttpMethod.GET,
            receiveTimeout: _defaultTimeout,
            sendTimeout: _defaultTimeout,
          ),
      cancelToken: cancelToken,
    );
  }
}
继承 InterceptorsWrapper 的拦截器

super 里面实际上调用了 handler.next

dart
import 'dart:developer';

import 'package:dio/dio.dart';

class PrintLogInterceptor extends InterceptorsWrapper {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    log("\nrequest----------------------->");
    options.headers.forEach((key, value) {
      log("请求头信息: key = $key value = ${value.toString()}");
    });
    log("path: ${options.uri}");
    log("method: ${options.method}");
    log("data: ${options.data}");
    log("queryParameters: ${options.queryParameters.toString()}");
    log("<-----------------------request\n");
    super.onRequest(options, handler);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    log("\nresponse----------------------->");
    log("path: ${response.realUri}");
    log("headers: ${response.headers.toString()}");
    log("statusMessage: ${response.statusMessage}");
    log("statusCode: ${response.statusCode}");
    log("extra: ${response.extra.toString()}");
    log("data: ${response.data}");
    log("<-----------------------response\n");
    super.onResponse(response, handler);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    log("\nonError----------------------->");
    log("error: ${err.toString()}");
    log("<-----------------------onError\n");
    super.onError(err, handler);
  }
}
继承 Interceptor 的拦截器

使用 handler.next 衔接下一个

dart
import 'package:dio/dio.dart';
import 'package:my_bilibili/http/base_model.dart';
import 'package:oktoast/oktoast.dart';

class ResponseInterceptor extends Interceptor {
  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    if (response.statusCode == 200) {
      try {
        // errorCode = 0 代表执行成功,不建议依赖任何非0的 errorCode.
        // errorCode = -1001 代表登录失效,需要重新登录。
        var resp = BaseModel.fromJson(response.data);
        if (resp.errorCode == 0) {
          if (resp.data == null) {
            handler.next(
              Response(requestOptions: response.requestOptions, data: true),
            );
          } else {
            handler.next(
              Response(
                requestOptions: response.requestOptions,
                data: resp.data,
              ),
            );
          }
        } else if (resp.errorCode == -1001) {
          // 需要登录
          handler.reject(
            DioException(
              requestOptions: response.requestOptions,
              message: "未登录",
            ),
          );
          showToast("请先登录");
        }
      } catch (e) {
        handler.reject(DioException(requestOptions: response.requestOptions, message: "$e"));
      }
    } else {
      handler.reject(DioException(requestOptions: response.requestOptions));
    }
  }
}

下拉刷新上拉加载组件

yaml
dependencies:
  # 下拉刷新 上拉加载
  pull_to_refresh: ^2.0.0
dart
SmartRefresher(
  controller: refreshController,
  enablePullUp: true,
  enablePullDown: true,
  header: ClassicHeader(),
  footer: ClassicFooter(),
  onLoading: () {
    // 上拉加载回调
    viewModel.LoadMoreListData(valueChanged: (_) {
      refreshController.loadComplete();
    });
  },
  onRefresh: () {
    // 下拉刷新回调
    Future.wait([viewModel.getBanner(), viewModel.initListData()]).then((value) {
      refreshController.refreshCompleted();
    });
  },
  child: SingleChildScrollView(
    child: Column(
      children: [
        Consumer<HomeViewModel>(
          builder: (context, vm, child) {
            return _banner(vm);
          },
        ),
        Consumer<HomeViewModel>(
          builder: (context, vm, child) {
            return _listView(vm);
          },
        ),
      ],
    ),
  ),
)

dart
EasyRefresh(
  child: CustomScrollView(
    controller: _scrollController,
    slivers: <Widget>[
      buildHeader(),
      SliverToBoxAdapter(
        child: Padding(
          padding:
              const EdgeInsets.only(top: 15, bottom: 8, left: 14),
          child: Obx(
            () => Text(
              '共${_controller.favListInfo.value.data?.info?.mediaCount}条视频',
              style: TextStyle(
                  fontSize: Theme.of(context)
                      .textTheme
                      .labelMedium!
                      .fontSize,
                  color: Theme.of(context).colorScheme.outline,
                  letterSpacing: 1),
            ),
          ),
        ),
      ),
      buildVideoList()
    ],
  ),
  onLoad: () async {
    _page++;
    await _controller.loadFavList(
        mediaId: widget.oid, pn: _page, ps: _pageSize);
  }
)

BottomNavigationBar+IndexedStack 实现底部导航栏((IndexedStack 的话 build 所有,切换不 build))

dart
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:my_bilibili/pages/home/home_page.dart';
import 'package:my_bilibili/pages/hot_key/hot_key_page.dart';
import 'package:my_bilibili/pages/knowledge/knowledge_page.dart';
import 'package:my_bilibili/pages/personal/personal_page.dart';

class TabPage extends StatefulWidget {
  const TabPage({super.key});

  @override
  State<StatefulWidget> createState() {
    return _TabPageState();
  }
}

class _TabPageState extends State<TabPage> {
  int currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: IndexedStack(
          index: currentIndex,
          children: [HomePage(), HotKeyPage(), KnowledgePage(), PersonalPage()],
        ),
      ),
      bottomNavigationBar: Theme( // 移除水波纹效果
        data: Theme.of(context).copyWith(
          splashColor: Colors.transparent,
          highlightColor: Colors.transparent,
        ),
        child: BottomNavigationBar(
          items: _barItemList(),
          currentIndex: currentIndex,
          type: BottomNavigationBarType.fixed,
          selectedLabelStyle: TextStyle(fontSize: 14.sp, color: Colors.black),
          unselectedLabelStyle: TextStyle(fontSize: 12.sp, color: Colors.blueGrey),
          onTap: (index) {
            // 点击切换页面
            setState(() {
              currentIndex = index;
            });
          },
        ),
      ),
    );
  }

  List<BottomNavigationBarItem> _barItemList() {
    return [
      BottomNavigationBarItem(
        // activeIcon: Icon(Icons.home_filled),
        icon: Icon(Icons.home_filled),
        label: "首页",
      ),
      BottomNavigationBarItem(icon: Icon(Icons.play_circle_fill), label: "热点"),
      BottomNavigationBarItem(icon: Icon(Icons.line_axis_rounded), label: "体系"),
      BottomNavigationBarItem(icon: Icon(Icons.person), label: "我的"),
    ];
  }
}

BottomNavigationBar+PageView 实现底部导航栏(PageView 的话每次切换都会重新 build)

dart
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class NavInfo {
  final String title;
  final IconData icon;
  final IconData selectedIcon;

  NavInfo({
    required this.title,
    required this.icon,
    required this.selectedIcon,
  });
}

class _MyHomePageState extends State<MyHomePage> {
  // 控制器
  late PageController _pageController;
  late List<NavInfo> _navList;
  late List<NavigationDestination> navigationItem;
  late List<NavigationRailDestination> navRailItem;

  int _currentPage = 0;

  @override
  void initState() {
    super.initState();
    _navList = [
      NavInfo(
        title: '音频',
        icon: Icons.video_collection_outlined,
        selectedIcon: Icons.video_collection,
      ),
      NavInfo(
        title: '音乐',
        icon: Icons.library_music_outlined,
        selectedIcon: Icons.library_music,
      ),
      NavInfo(
        title: '我的',
        icon: Icons.account_box_outlined,
        selectedIcon: Icons.account_box,
      ),
    ];
    navigationItem = [
      for (var itemData in _navList)
        NavigationDestination(
          icon: Icon(itemData.icon),
          selectedIcon: Icon(itemData.selectedIcon),
          label: itemData.title,
          tooltip: itemData.title,
        ),
    ];
    navRailItem = [
      for (var itemData in _navList)
        NavigationRailDestination(
          icon: Icon(itemData.icon),
          selectedIcon: Icon(itemData.selectedIcon),
          label: Text(itemData.title),
        ),
    ];
    _pageController = PageController();
  }

  @override
  Widget build(BuildContext context) {
    double wth = ScreenSize.getWindowsWidth(context);
    print('windowsWidth ================> $wth');
    return Scaffold(
      body: PageView(
        controller: _pageController,
        onPageChanged: (int index) {
          setState(() {
            _currentPage = index;
          });
        },
        scrollDirection:
            wth > ScreenSize.normal ? Axis.vertical : Axis.horizontal,
        children: const [VideoMusicPage(), BiliMusicPage(), UserInfoPage()],
      ),
      bottomNavigationBar: NavigationBar(
        destinations: navigationItem,
        selectedIndex: _currentPage,
        onDestinationSelected: (index) {
          setState(() {
            _currentPage = index;
            _pageController.jumpToPage(index);
          });
        },
      ),
    );
  }
}

封装底部导航栏

dart
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:my_bilibili/common_ui/navigation/navigation_bar_item.dart';

class NavigationBarWidget extends StatefulWidget {
  NavigationBarWidget({
    super.key,
    required this.pages,
    required this.labels,
    required this.icons,
    required this.activeIcons,
    required this.onTabChange,
    this.currentIndex,
  }) {
    if (pages.length != labels.length ||
        pages.length != icons.length ||
        pages.length != activeIcons.length) {
      throw Exception("数组长度必须一致!");
    }
  }

  // 页面数组
  final List<Widget> pages;

  // 底部标题
  final List<String> labels;

  // 导航栏的 icon 数组,切换前
  final List<Icon> icons;

  // 导航栏的 icon 数组,切换后
  List<Icon> activeIcons;

  final ValueChanged<int> onTabChange;

  int? currentIndex;

  @override
  State<StatefulWidget> createState() {
    return _NavigationBarWidgetState();
  }
}

class _NavigationBarWidgetState extends State<NavigationBarWidget> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: IndexedStack(
          index: widget.currentIndex ?? 0,
          children: widget.pages,
        ),
      ),
      bottomNavigationBar: Theme(
        data: Theme.of(context).copyWith(
          splashColor: Colors.transparent,
          highlightColor: Colors.transparent,
        ),
        child: BottomNavigationBar(
          items: _barItemList(),
          currentIndex: widget.currentIndex ?? 0,
          type: BottomNavigationBarType.fixed,
          selectedLabelStyle: TextStyle(fontSize: 14.sp, color: Colors.black),
          unselectedLabelStyle: TextStyle(
            fontSize: 12.sp,
            color: Colors.blueGrey,
          ),
          onTap: (index) {
            widget.onTabChange.call(index);
            // 点击切换页面
            setState(() {
              widget.currentIndex = index;
            });
          },
        ),
      ),
    );
  }

  List<BottomNavigationBarItem> _barItemList() {
    List<BottomNavigationBarItem> list = [];
    for (int i = 0; i < widget.pages.length; i++) {
      list.add(
        BottomNavigationBarItem(
          activeIcon: NavigationBarItem(
            builder: (context) {
              return widget.activeIcons[i];
            },
          ),
          icon: widget.icons[i],
          label: widget.labels[i],
        ),
      );
    }
    return list;
  }
}
NavigationBarItem 实现图标放大
dart
import 'package:flutter/material.dart';

// 用以实现图标放大的效果
class NavigationBarItem extends StatefulWidget {
  const NavigationBarItem({super.key, required this.builder});

  final WidgetBuilder builder;

  @override
  State<StatefulWidget> createState() => _NavigationBarItemState();
}

class _NavigationBarItemState extends State<NavigationBarItem> with TickerProviderStateMixin {
  late AnimationController controller;
  late Animation<double> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(duration: const Duration(milliseconds: 300), vsync: this);
    controller.forward();
    animation = Tween<double>(begin: 0.7, end: 1).animate(controller);
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(scale: animation, child: widget.builder(context),);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}
使用封装的组件
dart
Widget build(BuildContext context) {
  return NavigationBarWidget(
    pages: pages,
    labels: labels,
    icons: icons,
    activeIcons: activeIcons,
    currentIndex: 0,
    onTabChange: (int index) {
      print(index);
    },
  );
}

使用 GridView

dart
Widget _gridView({
  required NullableIndexedWidgetBuilder builder,
  required int itemCount,
}) {
  return Container(
    margin: EdgeInsets.only(top: 20.h),
    padding: EdgeInsets.symmetric(horizontal: 20.w),
    child: GridView.builder(
      // 把滑动事件交给 SingleChildScrollView
      physics: NeverScrollableScrollPhysics(),
      shrinkWrap: true,
      gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
        // 最大横轴范围
        maxCrossAxisExtent: 120.w,
        // 主轴的间隔
        mainAxisSpacing: 8.r,
        // 宽高比
        childAspectRatio: 3,
        // 横轴间隔
        crossAxisSpacing: 8.r,
      ),
      itemBuilder: builder,
      itemCount: itemCount,
    ),
  );
}

使用 SharedPreferences 本地化存储

yaml
# 本地化存储
shared_preferences: ^2.0.15
本地持久化存储工具类
dart
import 'package:shared_preferences/shared_preferences.dart';

// 本地持久化存储工具类
class SpUtils {
  SpUtils._();

  static Future<bool> saveStringList(String key, List<String> values) async {
    final sp = await SharedPreferences.getInstance();
    return sp.setStringList(key, values);
  }

  static Future<List<String>?> getStringList(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.getStringList(key);
  }

  static Future<bool> saveBool(String key, bool value) async {
    final sp = await SharedPreferences.getInstance();
    return sp.setBool(key, value);
  }

  static Future<bool?> getBool(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.getBool(key);
  }

  static Future<bool> saveInt(String key, int value) async {
    final sp = await SharedPreferences.getInstance();
    return sp.setInt(key, value);
  }

  static Future<int?> getInt(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.getInt(key);
  }

  static Future<bool> saveString(String key, String value) async {
    final sp = await SharedPreferences.getInstance();
    return sp.setString(key, value);
  }

  static Future<String?> getString(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.getString(key);
  }

  static Future<bool> saveDouble(String key, double value) async {
    final sp = await SharedPreferences.getInstance();
    return sp.setDouble(key, value);
  }

  static Future<double?> getDouble(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.getDouble(key);
  }

  static Future<bool> remove(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.remove(key);
  }

  static Future<bool> removeAll(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.clear();
  }

  static Future<dynamic> getDynamic(String key) async {
    final sp = await SharedPreferences.getInstance();
    return sp.get(key);
  }
}

更好的实现

dart
Future<void> initCookieJar() async {

  final Directory appDocumentsDir = await getApplicationDocumentsDirectory();
  final mCookieJar = PersistCookieJar(
    storage: FileStorage("${appDocumentsDir.path}/user/cookies"),
    ignoreExpires: true,
  );

  // WEB需要移除,Web交由浏览器自行管理Cookie
  if(!kIsWeb){
    dioClient.interceptors.add(CookieManager(mCookieJar));
    _cookieJar = mCookieJar;
  }

}
Details
dart
import 'dart:developer';
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:my_bilibili/contants.dart';
import 'package:my_bilibili/utils/sp_utils.dart';

class CookieInterceptor extends Interceptor {

  bool isLogin(RequestOptions options) => options.path.contains("user/login");

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    if (!isLogin(options)) {
      SpUtils.getStringList(Constants.spCookieList).then((cookieList) {
        options.headers[HttpHeaders.cookieHeader] = cookieList;
        handler.next(options);
      });
    } else {
      handler.next(options);
    }
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    if (isLogin(response.requestOptions)) {
      // 取出 cookie 信息
      dynamic list = response.headers[HttpHeaders.setCookieHeader];
      List<String> cookieList = [];
      if (list is List) {
        for (String? cookie in list) {
          log("CookieInterceptor cookie= ${cookie.toString()}");
          cookieList.add(cookie ?? "");
        }
        SpUtils.saveStringList(Constants.spCookieList, cookieList);
      }
    }
    super.onResponse(response, handler);
  }
}

TabBar 和 TabBarView 示例

dart
import 'package:flutter/material.dart';
import 'package:my_bilibili/pages/knowledge/detail/knowledge_detail_child_tab.dart';
import 'package:my_bilibili/pages/knowledge/detail/knowledge_detail_vm.dart';
import 'package:my_bilibili/repository/datas/knowledge_list_data.dart';
import 'package:provider/provider.dart';

class KnowledgeDetailTabPage extends StatefulWidget {
  const KnowledgeDetailTabPage({super.key, this.tabList});

  final List<KnowledgeChildren>? tabList;

  @override
  State<StatefulWidget> createState() {
    return _KnowledgeDetailTabPageState();
  }
}

class _KnowledgeDetailTabPageState extends State<KnowledgeDetailTabPage>
    with SingleTickerProviderStateMixin {
  KnowledgeDetailViewModel viewModel = KnowledgeDetailViewModel();
  late TabController tabController;

  @override
  void initState() {
    super.initState();
    viewModel.initTabs(widget.tabList);
    tabController = TabController(
      length: widget.tabList?.length ?? 0,
      vsync: this,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) {
        return viewModel;
      },
      child: SafeArea(
        child: Scaffold(
          appBar: AppBar(
            title: TabBar(
              tabs: viewModel.tabs,
              controller: tabController,
              labelColor: Colors.blue,
              indicatorColor: Colors.blue,
              isScrollable: true,
            ),
          ),
          body: TabBarView(controller: tabController, children: _children()),
        ),
      ),
    );
  }

  List<Widget> _children() {
    return widget.tabList?.map((e) {
      return KnowledgeDetailChildTab(cid: "${e.id}");
    }).toList() ?? [];
  }
}