从头学 Dart 第九集

147 阅读23分钟

最近打算自己做一个电商 App 调研之后选择技术栈为 Flutter 因此打算梳理一下 Flutter 的所有知识点,做个预热。

  1. flutter 中的单页应用 涉及到的知识点有:
  • 页面跳转即页面栈
  • tab bars
  • side drawers
  1. 安装谷歌字体
flutter pub add google_fonts

然后引入并在主题中覆盖原来的字体

import 'package:google_fonts/google_fonts.dart';
...

final theme = ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
        brightness: Brightness.dark,
        seedColor: const Color.fromARGB(255,131,57,0),
    ),
    textTheme: GoogleFonts.latoTextTheme(),
);
  1. 使用 GridView 组件进行网格布局
Scaffold(
    ...
    body: GridView( // GridView是一个用于展示网格布局的组件,可以水平或垂直滚动。
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( // SliverGridDelegateWithFixedCrossAxisCount是一个SliverGridDelegate的实现,用于创建固定列数的网格布局。
            crossAxisCount: 2, // 这个属性定义了网格布局中的列数,这里是2列。
            childAspectRatio: 3 / 2, // 这个属性定义了子组件的宽高比,这里的比值是3:2,意味着每个子组件的宽度是高度的1.5倍。
            crossAxisSpacing: 20, // 这个属性定义了子组件在交叉轴(这里是水平方向)上的间距,这里是20像素。
            mainAxisSpacing: 20, // 这个属性定义了子组件在主轴(这里是垂直方向)上的间距,这里是20像素。
        ),
        children: [
            Text('1'),
            Text('2'),
            Text('3'),
            Text('4'),
            Text('5'),
            Text('6'),
        ], // 这个属性定义了GridView中的子组件列表,这里是一个空列表,意味着GridView中没有子组件。
    ),
)

布局结果为两列,每一列有三个元素,从左到右从上到下分别为 1-6 使用此 Scaffold ,

@override
Widget build(BuildContext context) {
    return MaterialApp(
        theme: theme,
        home: const CategoriesScreen(),
    )
}
  1. 使用 Color 类型定义颜色变量
class Category {
    final String id;
    final String title;
    final Color color;
}
  1. 使用 Container 组件包裹目标组件之后就能够设置其背景色了
@override
Widget build(BuildContext context) {
    return Container(
        decoration: BoxDecoration(
            gradient: LinearGradient(
                colors: [
                    category.color.withOpacity(0.55),
                    category.color.withOpacity(0.9),
                ],
                begin: Alignment.topLeft,
                end: Alignment.bottomRightm
            ),
        ),
        child: ...,
    );
}

其中颜色的设置路径是这样的: Contianer -> decoration(BoxDecoration) -> gradient(LinearGradient) -> color,begin,end.

category 来自一个单独的类,可以看出来:Color 类型的变量直接调用 withOpacity 方法就可以得到另一个 Color 类型的值

  1. 可点击的组件 如果你想要某一个组件可以被点击,那么就使用名为 GestureDetector 的组件包裹之,并将原来的组件作为此包裹组件的 child 属性的值;除了 child 之外,此组件还提供名为 onTap onTapCancel onTapDown 三个钩子函数。

除了 GestureDetector 组件之外,还可以使用 InkWell 组件,更为强大,提供更多配置和信息,同样常用的是 child 和 onTap 配置。

  1. InkWell 和圆角 我们可以通过 splashColor 和 borderRadius 设置 InkWell 组件被按下激活时候的样式,但是注意这里设置的仅是外部包裹组件 InkWell 的圆角,想要预期的效果还需要配合内部包裹组件的样式,例如我们同样需要给内部包裹的组件以同样的圆角和适配的颜色。

如果内部包裹的组件无法直接设置圆角或其它,则在其外层再包裹一个 Container 组件并在 decoration 配置中设置 borderRadius 如下所示:

@override
Widget build(BuildContext context) {
  return InkWell(
    onTap: () {},
    splashColor: Theme.of(context).primaryColor,
    borderRadius: BorderRadius.circular(16),
    child: Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(16),
        gradient: LinearGradient(
          colors: [
            category.color.withOpacity(0.55),
            category.color.withOpacity(0.9),
          ],
        ),
      ),
    ),
  );
}
  1. 下面的代码是一种常见的纵向布局 包含如下的知识点:
  • 纵向排布
  • 居中对齐
  • 最小尺寸
  • 设置 margin
  • 条件渲染
if (meals.isEmpty) {
  content = Center(
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        const Text('Uh oh ... nothing here!'),
        const SizedBox(height: 16),
        Text(
          'Try selecting a different category!',
          style: Theme.of(context).textTheme.bodyLarge!.copyWith(
            color: Theme.of(context).colorScheme.onBackground,
          ),
        ),
      ],
    ),
  );
}
  1. 条件渲染和组件的 placeholder 如下代码展示了这种效果的一般解决方案:
@override
Widget build(BuildContext context) {
  Widget content = ListView.builder(
    itemBuilder: (ctx, index) => Text(
      meals[index].title,
      // Text
    ),
  ); // ListView.builder

  if (meals.isEmpty) {
    content = Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          const Text('Uh oh... nothing here!'),
          const SizedBox(height: 16),
          Text('Try selecting a different category!'),
          // Column
        ],
      ),
    ); // Center
  }

  return Scaffold(
    appBar: AppBar(
      title: Text(title),
      // AppBar
    ),
    body: content,
    // Scaffold
  );
}

在实现条件渲染的时候,我们说不仅可以使用数组的 isEmpty 属性还可以使用名为 isNotEmpty 的属性。

  1. ListView 组件的使用 在使用 ListView 组件的时候我们需要指定两个配置项,一个名为 itemCount 表示的是列表需要渲染的数目;一个名为 itemBuilder 表示如何渲染每一个列表元素
if(meals.isNotEmpty){
    content = ListView.builder(
        itemCount: meals.length,
        itemBuilder: (ctx, index) => Text(
            meals[index].title,
        ),
    ),
}
  1. 使用 Navigator 进行路由跳转 使用 Flutter 开发的单页面应用,经常需要使用 Navigator 进行路由的跳转,其一般格式为:
Navigator.of(context).push(route);

其中的 route 本质上是一个 Material

当调用 Navigator.of(context).push(route); 这行代码时,context 的作用如下:

  • 定位context 帮助 Navigator 确定当前的路由栈。Flutter 中的 Navigator 是一个路由管理器,它管理着一个或多个路由栈。通过 contextNavigator 能够找到当前组件在组件树中的位置,并确定应该操作哪个路由栈。

  • 上下文环境context 提供了访问当前组件环境的能力,包括主题、本地化信息、媒体查询等。虽然在 Navigator.of(context).push(route); 这行代码中,context 主要是用来定位路由栈,但在其他情况下,context 还可以用来获取这些环境信息。

  • 导航:在 Flutter 中,导航操作(如 pushpop)需要知道它们应该影响哪个路由栈。通过传递 context,你可以确保导航操作是在正确的路由栈上执行的。

简而言之,context 在这里是用来告诉 Navigator 应该在哪个路由栈上执行 push 操作。如果你没有提供 context 或者 context 不正确,Navigator 可能找不到正确的路由栈,从而导致导航操作失败。通常,context 是通过 build 方法的参数获得的,或者通过 InheritedWidget 树中的其他方式获得。

flutter 中的路由和导航

在许多前端框架中,路由确实通常是通过字符串路径来管理的,这种方式简单直观,适用于那些路由结构相对简单且固定的应用。然而,Flutter 采取了一种更灵活和功能丰富的路由系统,这使得它能够更好地适应复杂和动态的路由需求。以下是为什么 Flutter 中路由是一个 MaterialPageRoute 包裹的组件的几个原因:

  1. 页面构建的灵活性:在 Flutter 中,路由不仅仅是一个路径,它还负责页面的构建。通过使用 MaterialPageRoute(或其他类型的路由),Flutter 允许你在运行时动态地构建页面,这意味着你可以根据不同的条件或用户交互来决定显示哪个页面。

  2. 页面转换效果MaterialPageRoute 允许你自定义页面之间的过渡效果。默认情况下,它提供了 Material Design 的过渡效果,但你也可以自定义这些效果,以符合你的设计需求。

  3. 状态管理:在 Flutter 中,路由可以携带状态。例如,当你使用 Navigator.push 推送一个新的页面时,你可以传递一个 RouteSettings 对象,其中可以包含页面标题、所需的状态信息等。这使得页面间的通信变得更加灵活。

  4. 导航的控制:Flutter 的路由系统提供了丰富的导航控制选项,如 Navigator.popNavigator.popUntil 等,允许开发者精确控制导航堆栈的行为。

  5. 多路由支持:Flutter 支持多个导航堆栈,这意味着你可以在同一个应用中使用多个不同的导航路径,这对于复杂的应用结构非常有用。

  6. 平台适配性:Flutter 的路由系统设计考虑了不同平台的导航习惯,例如,iOS 和 Android 的导航栏样式和行为可能不同,Flutter 允许你通过不同的路由设置来适配这些差异。

  7. 组件化:Flutter 的设计理念是“一切皆组件”,路由也不例外。通过将路由封装在组件中,你可以重用路由逻辑,使得代码更加模块化和可维护。

总的来说,Flutter 的路由系统提供了更多的控制和灵活性,这使得开发者能够构建更加复杂和动态的应用程序。虽然这可能需要更多的代码和理解,但它也为构建高质量的应用提供了强大的工具。

  1. 返回按钮 使用 Scaffold 组件,会自动在 AppBar 的最左侧添加一个返回按钮,用来实现和 Navigator.of(context).pop() 同样的效果.

  2. 更多数组方法

  • 数组使用 where 方法进行过滤之后如果想要得到一个新的数组,必须调用 toList 方法。
  • 检查数组中的元素,使用的是数组上面的 contains 方法而不是 js 中的 includes 方法。
.where((item)=>item.ids.contains(currentId)).toList();
  1. 传递函数时增加更多入参 使用函数包裹的方式进行就可以了。将所在组件的上下文传递下去,用于之后的路由跳转。
for (final category in availableCategories) {
  CategoryGridItem(
    category: category,
    onSelectCategory: () {
      selectCategory(context, category);
    },
  );
}
  1. Flutter 中的 Stack 组件

此组件的作用类似于一个规定了基准尺寸和位置的盒子,其中的元素自动都是绝对定位的,各个子组件之间使用特定的属性规定 z 轴的叠加顺序。如果没有特殊设置,那么定义在 Stack 组件 children 数组中的子组件,序列号大的放在上面。

  • Stack组件用于将多个小部件(widgets)沿着 Z 轴堆叠在一起,这意味着它们可以相互覆盖。

  • 展示了Column组件,它将小部件沿 Y 轴(垂直方向)排列,例如将文本(Text())放在文本框(TextField())的上方。

  • 强调了在Stack中,小部件是沿着 Z 轴(深度方向)堆叠的,可以创建出一种小部件位于其他小部件之上的效果。

  • 举例说明,可以在一个图像(Image())上面放置文本(Text()),这是通过Stack实现的层叠效果。

Stack 组件和 Positioned 组件搭配使用

Positioned 组件放在 Stack 组件 children 中就可以为其包裹的子组件提供绝对定位:

@override
Widget build(BuildContext context) {
  return Card(
    child: InkWell(
      onTap: () {},
      child: Stack(
        children: [
          ...,
          Positioned(
            bottom: 0,
            left: 0,
            right: 0,
            child: Container(
                color: Colors.black54,
            ),
          ),
        ],
      ),
    ),
  );
}
  1. 透明图像组件

先安装,

dart pub add transparent_image

再使用,

import 'package:transparent_image/transparent_image.dart'

我们以其提供的 FadeInImage 组件为例,结合 Stack 组件完成一次布局:

@override
Widget build(BuildContext context) {
  return Card(
    child: Inkwell(
      onTap: () {},
      child: Stack(
        children: [
          FadeInImage(
            placeholder: MemoryImage(kTransparentImage),
            image: NetworkImage(meal.imageUrl),
          ),
          Positioned(
            bottom: 0,
            left: 0,
            right: 0,
            child: Container(
                color: Colors.black54,
            ),
          ),
        ],
      ),
    ),
  );
}

上面的代码使用 FadeInImage 组件,设置了网路图片没有加载出来的时候的占位图片。展位图片是内存提供的,注意 MemoryImage 和 NetworkImage 的路由系统设计考虑了不同。

FadeInImage 提供了两个常见的配置项,一个叫做 placeholder 一个叫做 image, 都比较好理解。

除了显示图片和显示占位符之外,我们还可以设置图片的排布方式,例如像 css 中的 cover 等,这个可以通过 fit 属性实现之:

FadeInImage(
    placeholder: MemoryImage(kTransparentImage),
    iamge: NetworkImage(meal.imageUrl),
    fit: BoxFit.cover,
)

当然还可以设置图片的宽和高,搭配 fit 更加适合:

FadeInImage(
    placeholder: MemoryImage(kTransparentImage),
    image: NetworkImage(meal.imageUrl),
    fit: BoxFit.cover,
    height: 200,
    width: double.infinity,
)
  1. 设置多行文字及省略号以及其它文字样式
child: Column(
    // Column是一个布局组件,用于将子组件垂直排列。
    children: [
        Text(
            meal.title,
            // Text组件用于显示文本。
            maxLines: 2,
            // maxLines属性限制文本显示的最大行数,这里是2行。
            textAlign: TextAlign.center,
            // textAlign属性设置文本的对齐方式,这里是居中对齐。
            softWrap: true,
            // softWrap属性决定文本是否需要软换行,这里设置为true,表示需要软换行。
            overflow: TextOverflow.ellipsis,
            // overflow属性处理文本溢出的情况,这里使用ellipsis表示超出部分显示省略号。
            style: const TextStyle(
                fontSize: 20,
                // TextStyle的fontSize属性设置文本的字体大小,这里是20。
                fontWeight: FontWeight.bold,
                // TextStyle的fontWeight属性设置文本的字体粗细,这里是加粗。
                color: Colors.white,
                // TextStyle的color属性设置文本的颜色,这里是白色。
            ),
        ),
        const SizedBox(height: 32),
        // SizedBox是一个占位符组件,用于在布局中占据指定的空间,这里是高度为32像素的垂直空间。
        Row(children: [],),
        // Row是一个布局组件,用于将子组件水平排列,这里的children列表为空,表示没有子组件。
    ],
)

需要额外注意的是上面代码中的 Text 组件的配置项展示了如何将位置传参和名称传参结合起来使用的过程。再深入一些,不难看出来,在 dart 中是没有将 {label: label} 省略成 {label} 的语法的。

为此再举一例:

body: Image.network(
    meal.imageUrl,
    height: 300,
    width: double.infinity,
    fit: BoxFit.cover,
)
  1. Card 组件的样式

有的时候我们需要用到 Card 组件,这是因为其不仅可以方便的设置 margin 并且可以通过 shape 组件设置圆角等形状。

Card(
    // Card组件用于创建一个类似卡片的容器,常用于展示信息。
    margin: const EdgeInsets.all(8),
    // margin属性设置卡片四周的间距,这里设置为8像素,意味着卡片四周都有8像素的空白。
    shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
    ),
    // shape属性定义卡片的形状,这里使用StadiumBorder表示两端半圆形状。
    // borderRadius属性设置圆角的大小,这里设置为8像素,表示卡片的四个角都是半径为8像素的圆角。
    clipBehavior: Clip.hardEdge,
    // clipBehavior属性控制卡片内容的裁剪行为,Clip.hardEdge表示使用硬边缘裁剪,这可以避免在某些情况下出现光晕效果。
    elevation: 2,
    // elevation属性设置卡片的阴影高度,这里设置为2像素,表示卡片的阴影高度为2像素,从而产生一种立体效果。
    child: ...
    // child属性用于设置卡片的子组件,这里使用...表示子组件的内容被省略。
)
  1. 一个常见的左 icon 右文字的布局,并且它们中间有一定的间隔
Widget build(BuildContext context) {
    return Row(
        children: [
            Icon(
                icon,
                size: 17,
                color: Colors.white,
            ),
            const SizedBox(width: 6),
            Text(
                label,
                style: const TextStyle(
                    color: Colors.white,
                )
            )
        ],
    );
}
  1. 再谈 flutter 中的模板字符串

flutter 中的模板字符串也具有将其他类型例如 number 等转成字符串的隐式功能,如下面的例子所示:

SomeWidget(icon: Icons.schedule, label:'${meal.duration}')

同时在 flutter 中,加号也可以直接将两个字符串连接起来;如果连接 + 的一端不是 String 类型的,那么就是隐式的调用这个非 String 类型的数据结构的 toString 方法将其先转成字符串然后在连接。

  1. 字符串相关的方法
  • 在 dart 中,同样有名为 toUpperCase 的方法,可以将字母转成大写。
  • 在 dart 中字符串切片使用的方法不是 slice 而是 subString 但是入参是相同的。
  • dart 中的 includes 在 Dart 中,与 JavaScript 的 includes 方法功能相似的方法是 contains。这个方法用于检查字符串是否包含另一个指定的字符串,并返回一个布尔值(truefalse)。

以下是使用 contains 方法的示例:

void main() {
  String text = 'Hello, world!';
  bool containsHello = text.contains('Hello'); // 返回 true
  bool containsBye = text.contains('Bye'); // 返回 false

  print(containsHello); // 输出: true
  print(containsBye); // 输出: false
}

在这个例子中,text.contains('Hello') 检查字符串 text 是否包含子字符串 'Hello',并返回 true。类似地,text.contains('Bye') 检查 text 是否包含 'Bye',由于 'Bye' 不是 text 的一部分,所以返回 false

  1. 再谈 Navigator.of 中 context 的作用如下

先看代码:

void selectMeal(BuildContext context, Meal meal) {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (ctx) => MealDetailsScreen(
        meal: meal,
      ),
    ),
  );
}

在 Flutter 中,context 对象代表了组件的上下文环境,它包含了组件的配置信息,如主题(theme)、访问局部状态(local state)、媒体查询(media query)等。在路由导航中,context 对象尤为重要,因为它帮助 Navigator 确定当前的导航栈(navigation stack)。

在上述代码中,context 对象被传递给 Navigator.of(context).push(...) 方法,这样做的原因和 context 的角色如下:

  • 确定当前的导航栈Navigator.of(context) 调用会根据提供的 context 对象找到与之关联的最近的 Navigator 组件。这意味着 context 帮助 Navigator 确定应该在哪个导航栈上执行 push 操作。
  • 正确的路由层级:在复杂的应用中,可能有多个 Navigator 组件嵌套使用。context 对象确保了 push 操作是在正确的路由层级上执行的,而不是在全局的导航栈上。
  • 避免硬编码:使用 context 而不是硬编码的 Navigator 实例,可以让代码更加灵活,适应不同的组件结构和导航需求。

如果这里使用当前组件的 context,通常指的是在 build 方法中直接调用 Navigator.of(context)。这样做的区别通常取决于组件的层级和 Navigator 的放置位置:

  • 正确的层级:如果 selectMeal 函数是在组件的 build 方法中被调用的,那么使用 context 是正确的,因为它指向的是当前组件的上下文,这通常就是你需要导航的层级。

  • 错误的层级:如果 selectMeal 函数不是在 build 方法中被调用的,或者你在调用 Navigator.of(context) 时没有正确传递 context,那么可能会导致导航操作在错误的 Navigator 实例上执行,这可能会导致路由不按预期工作。

总之,contextNavigator 执行过程中扮演了定位和确定路由层级的角色,确保导航操作发生在正确的导航栈上。正确使用 context 对象是 Flutter 路由导航中的一个重要方面。

也就是说,再非 build 函数中获取不到 context 对象,所以才需要在能获取 context 的地方将其传递一下

onSelectMeal: (meal){
    selectMeal(context, meal);
}
  1. 区分直接入栈新的页面还是先出栈再入栈 -- 针对二级页面横向跳转的情况
void selectMeal(BuildContext context, Meal meal) {
    Navigator.of(context).pop();
    Navigator.of(context).push(
        MaterialPageRoute(
            builder: (ctx) => MealDetailsScreen(
                meal: meal,
            ),
        ),
    ),
}

在 Flutter 应用中进行页面导航时,理解何时直接将新页面压入导航栈(入栈),以及何时先从导航栈中弹出当前页面再压入新页面(先出栈再入栈),对于控制应用的导航流程至关重要。这直接影响到用户的体验和应用的逻辑流程。

在上述代码示例中,selectMeal 函数首先调用 Navigator.of(context).pop(),这会导致当前页面从导航栈中弹出,然后通过 Navigator.of(context).push() 将一个新的页面 MealDetailsScreen 压入导航栈。这种操作通常用于处理特定的导航场景,尤其是当您需要从二级页面横向跳转到另一个页面时。

直接入栈新的页面

直接入栈新的页面通常发生在以下情况:

  • 用户从首页导航到一个详情页。
  • 用户在应用的不同功能模块间导航,而这些模块之间没有直接的返回关系。

在这种情况下,使用 Navigator.push() 直接将新页面加入导航栈,用户可以通过按下物理返回键或调用 Navigator.pop() 来返回到上一个页面。

先出栈再入栈

先出栈再入栈的操作,如您的代码所示,通常用于以下情况:

  • 用户在应用的深层页面中,需要返回到特定页面而不是最初的首页。
  • 当您希望用户在完成某个操作或查看某个页面后,跳转到另一个平行或相关的页面,而不是返回到他们最初来的地方。

在您的代码中,Navigator.of(context).pop() 首先被调用,这会将当前页面从导航栈中移除。紧接着,Navigator.push() 被用来将 MealDetailsScreen 页面加入导航栈。这样做的结果是,用户将直接从他们当前的位置跳转到 MealDetailsScreen,而不会回到他们之前访问的页面。

为什么这样做

这种导航方式有几个优点:

  • 清晰的导航路径:用户可以清楚地知道他们是从哪里来,将要去哪里,而不会被导航回他们已经离开的页面。
  • 避免复杂的导航栈:通过先出栈再入栈,可以避免导航栈变得过于复杂,这有助于维护清晰的导航结构。
  • 用户体验:这种导航方式可以提供更直观的用户体验,因为它允许用户在不同的页面间直接跳转,而不是被迫返回到他们已经离开的页面。

总之,选择直接入栈还是先出栈再入栈,取决于您的应用逻辑和用户期望的导航流程。正确地管理这些导航操作,可以极大地提升应用的可用性和用户满意度。

  1. 兼具主题整体应用以及局部细节修改的文字样式示例
for(final _i in meal.ingredients)
    Text(
        _i,
        style: Theme.of(context).textTheme.bodyMedium!.copyWidth(
            color: Theme.of(context).colorScheme.onBackground,
        ),
    ),
  1. Scaffold 组件上的配置项
  • appBar:用于设置页面顶部的 AppBar,通常包含标题、返回按钮、动作按钮等。它为页面提供结构化和一致的顶部导航区域。
  • body:这是 Scaffold 的主要内容区域。通常,你会在这里放置页面的主要 Widget,比如 ListView、Column 或其他自定义布局。
  • bottomNavigationBar:设置页面底部的导航栏,通常用于在不同的页面或功能模块之间进行切换。它提供了一个固定的底部位置,方便用户访问不同的页面。
  • drawer:配置页面侧边的抽屉菜单,通常用于导航菜单或设置选项。用户可以通过滑动手势打开和关闭抽屉,或者通过点击 AppBar 上的菜单图标来触发。
  1. 关于 bottomNavigationBar 属性的配置 此属性的值一般会配置成 BottomNavigationBar 组件,这个组件需要配置点击的回调函数和 items 数组。
  • 点击事件回调函数的作用就是点击底部的按钮之后需要做什么
  • items 数组中的每一个元素表示的就是一个按钮,这个按钮一般是由 BottomNavigationBarItem 组件渲染的
    • BottomNavigationBarItem 接受两个配置项,一个是 icon 表示底部按钮的图标,一个是 label 表示此按钮上显示的文字,这个按钮一般来说就是上图下文的布局结构:
appBar: AppBar(
    title: Text('dynamic...'),
),
body: ...,
bottomNavigationBar: BottomNavigationBar(
    onTap: (index){},
    items: const [
        BottomNavigationBarItem(icon: Icon(Icons.set_meal), label: 'Categories',),
        BottomNavigationBarItem(icon: Icon(Icons.star), label: 'Favorites',),
    ],
)

在点击底部页面 bar 的图标的时候,我们希望切换当前页面到另外一个,通常我们使用一个页面 index 来完成这项工作:

class _TabsScreenState extends State<TabsScreen> {
    int _selectedPageIndex = 0;
    void _selectPage(int index) {
        setState((){
            _selectedPageIndex = index;
        });
    }
}

这样当我们调用 _selectPage 函数的时候我们能够刷新 _selectedPageIndex 值并重新渲染组件,而在组件的 build 方法中会根据 _selectedPageIndex 的值进行条件渲染。

bottomNavigationBar: BottomNavigationBar(
    onTap: _selectPage,
    items: const [
        BottomNavigationBarItem(icon: Icon(Icons.set_meal), label: 'Categories',),
        BottomNavigationBarItem(icon: Icon(Icons.star), label: 'Favorites',),
    ],
)

以及,

@override
Widget build(BuildContext context) {
  Widget activePage = const CategoriesScreen();
  if (selectedPageIndex == 1) {
    activePage = MealsScreen(title: 'Favorites', meals: [l]);
    activePageTitle = 'Your Favorites';
  }

  ...
}
  1. Scaffold 的叠加性 在 flutter 中,Scaffold 页面是可以叠加使用的,也就是说可能会存在有两个 appBar 或者两个 title 的情况,为此我们仍然需要条件渲染来去掉其中一个,在某些情况下。

在 Flutter 中,Scaffold 是一个用于创建 Material Design 布局的基础组件,它提供了一个灵活的方式来组织应用的布局结构。通常情况下,一个页面中只使用一个 Scaffold,因为它定义了整个页面的结构,包括 appBar、body、bottomNavigationBar 等。

当尝试叠加使用多个 Scaffold 时,Flutter 会将它们合并为一个 Scaffold。合并的规则如下:

  • 最外层的 Scaffold 的属性会覆盖内层的同名属性:例如,如果内层 Scaffold 设置了 appBar,而外层 Scaffold 也设置了 appBar,那么显示的将是外层的 appBar

  • 非冲突的属性可以共存:如果外层和内层 Scaffold 设置了不同的属性,比如外层设置了 appBar,而内层设置了 body,那么这两个属性都可以保留,外层的 appBar 和内层的 body 都会显示。

  • 尺寸规定Scaffold 的尺寸是由其父组件决定的。通常情况下,Scaffold 作为根组件使用时,会占据整个屏幕。如果 Scaffold 被放置在另一个组件内部,那么它的尺寸将由该父组件决定。

叠加使用 Scaffold 并不是 Flutter 推荐的做法,因为它可能会导致布局复杂和难以维护。通常,一个页面中只应该有一个 Scaffold,并通过改变 body 属性来切换不同的页面内容。

如果你需要在应用中实现复杂的布局结构,建议使用其他布局组件(如 StackColumnRow 等)来组织页面,而不是叠加使用多个 Scaffold。这样可以保持布局的清晰和可维护性。

  1. 再看条件渲染

这次是关于嵌套的 Scaffold 组件。

if(title == null) {
    return content;
}

return Scaffold(
    appBar: AppBar(
        title: Text(title!),
    ),
    body: content,
)
  1. 收藏按钮 在 flutter 中我们可以方便的使用组件库完成右上角收藏图标的渲染。这用到的是 AppBar 组件中的 actions 配置,actions 配置的值是一个数组,一般我们使用 IconButton 组件作为其元素。
return Scaffold(
    appBar: AppBar(
        title: Text(meal.title),
        actions: [
            IconButton(
                onPressed:(){},
                icon: const Icon(Icons.star),
            )
        ],
    )
)

在 actions 中定义的按钮会自动的跑到 appBar 的右侧,而文字则是自动到左侧去。

  1. 实现收藏功能 首先我们在父组件中定义一个数组保存被收藏的 item 然后当 star 图标被点击的时候将新的 item 添加或者移除此数组。
final List<Meal> _favoriteMeals = [];

void _toggleMealFavoriteStatus(Meal meal) {
    final isExisting = _favoriteMeals.contains(meal);

    if(isExisting) {
        setState((){_favoriteMeals.remove(meal);}); // 这里更新组件是必要的,因为这确保不会出现任何潜在的问题,尽管这个页面不直接展示这些数据,但是这毕竟是其状态之一,改变了就需要刷新此组件
    } else {
       setState((){_favoriteMeals.add(meal);});
    }
}

接下来,我们只需要通过 onToggleFavorite: _toggleMealFavoriteStatus 将此方法传递给子组件,并在子组件中使用 required this.onToggleFavorite ... final void Function(Meal meal)onToggleFavorite; 接受.

最后再绑定到我们的收藏五角星按钮上即可:

return Scaffold(
    appBar: AppBar(
        title: Text(meal.title),
        actions: [
            IconButton(
                onPressed:(){
                    onToggleFavorite(meal);
                },
                icon: const Icon(Icons.star),
            )
        ],
    ),
    body: ...,
)

在刚学的时候,我们确实只能一层一层的传递参数或者值,但是之后我们会学习到类似于 React 的 Provider 和 Customer 组件,实现跨组件的信息传递。

  1. snackBar 的使用 在用户点击收藏按钮之后,事实上用户并不知道当前是收藏了还是取消了收藏,因为此时我们并没有在五角星上通过颜色显示出来其状态。在此之前,我们可以通过 snack bar 以文字的形式通知用户。
if (isExisting) {
  setState(() {
    favoriteMeals.remove(meal);
  });
  showInfoMessage('Meal is no longer a favorite.');
} else {
  setState(() {
    favoriteMeals.add(meal);
  });
  showInfoMessage('Marked as a favorite!');
}

而对应的 showInfoMessage 函数则为:

void showInfoMessage(String message) {
    ScaffoldMessenger.of(context).clearSnackBar();
    ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
            content: Text(message),
        ),
    ),
}

我们总是在新的弹框出现之前清除页面上已有的弹窗信息。