从头学 Dart 第七集

103 阅读8分钟

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

  1. 滑动删除组件 移动端常见的交互效果,例如滑动屏幕之后删除某个 item 这个效果就是通过名为 Dismissible 组件实现的。

这是一个包裹组件,被包裹的组件才是真正要显示出来的内容,通常 Dismissible 组件会结合 ListView 组件一起使用。

final List<Expense> expenses;

@override
Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: expenses.length,
        itemBuilder: (ctx, index) => Dismissible(
            key: ValueKey(expenses[index]),
            child: ExpenseItem(expenses[index]),
        )
    );
}

先不用去管 key 这是 ListView 要求的,并且会在后面详细说。这样来看 Dissmissible 本质上就是一个简单的包裹组件。

Attention!

必须注意,Dismissible 的删除只是在 ui 上删除,也就是 element 层面的删除,而不会涉及 widget 层面,如果组件刷新还是会出来的。所以开发者必须保证两者的统一,即手动删除背后的渲染数据!

  1. 数组的删除元素的方法 dart 中提供了删除数组元素的方法,和 js 中不同,并不是通过 index 和 splice 完成的,而是直接提供了 remove 方法,如果具有待删元素的引用则删除起来非常简单:
void _removeExpense(Expense expense) {
    setState((){
        _registeredExpenses.remove(expense);
    })
}

然后我们将删除元素的回调设置在 Dismissible 组件配置项里面就可以了:

final List<Expense> expenses;

@override
Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: expenses.length,
        itemBuilder: (ctx, index) => Dismissible(
            key: ValueKey(expenses[index]),
            onDismissed: (direction) {
                onRemoveExpense(expenses[index]);
            },
            child: ExpenseItem(expenses[index]),
        )
    );
}

所以 Dismissible 的核心配置就是 child 和 onDismissed 两个配置项。

  1. 页面效果:没有元素的时候显示占位符 字符串中提供了 trim 方法 和 isEmpty 属性,当这个字符串是关键数据的时候,我们可以根据其值的合理性选择性的渲染真实的组件或者是占位符:
import 'package:flutter/material.dart';

class YourWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 假设 registeredExpenses 是一个包含费用项的列表
    final List<Expense> registeredExpenses = [];

    // 当列表中没有元素时显示占位符文本
    Widget mainContent = const Center(
      child: Text('No expenses found. Start adding some!'),
    );

    // 如果列表中有元素,则显示费用列表
    if (registeredExpenses.isNotEmpty) {
      mainContent = ExpensesList(
        expenses: registeredExpenses,
        onRemoveExpense: removeExpense,
      );
    }

    return mainContent;
  }

  void removeExpense(Expense expense) {
    // 实现移除费用项的逻辑
  }
}
  1. 页面效果:删除元素之后弹出 snackbar 并提供撤销操作的按钮 与此相关有四个知识点:
  • 数组删除某个元素 (remove)
  • 数组添加某个元素,在特定位置 (insert)
  • 弹出 snackbar 组件 ScaffoldMessenger.of(context).showSnackBar
  • 移除 snackBar 组件 ScaffoldMessenger.of(context).clearSnackBars
void removeExpense(Expense expense) {
  final expenseIndex = registeredExpenses.indexOf(expense);
  setState(() {
    registeredExpenses.remove(expense);
  });
  ScaffoldMessenger.of(context).clearSnackBars();
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      duration: const Duration(seconds: 3),
      content: const Text('Expense deleted.'),
      action: SnackBarAction(
        label: 'undo',
        onPressed: () {
          setState(() {
            registeredExpenses.insert(expenseIndex, expense);
          });
        },
      ),
    ),
  );
}

这里有一个很重要的顺序,那就是先清除 snackbar 然后再弹出新的。

其中,duration 是 snackbar 的持续时间,content 是内容,action 表示按钮。

  1. 配置自定义主题 在 main runApp MaterialApp 的结构中,我们挨着 home 配置 theme
import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      theme: ThemeData().copyWith(
        useMaterial3: true,
        scaffoldBackgroundColor: Color.fromARGB(255, 220, 189, 252),
      ),
      home: const Expenses(),
    ),
  );
}

使用 ThemeData().copyWith() 可以提供比直接使用 useMaterial3: true 这种配置更完整的配置。如果不用 copywith 则主题中只有前景色和背景色生效。

  1. 使用颜色种子
var kColorScheme = ColorScheme.fromSeed(
    seedColor: const Color.fromARGB(255, 96, 59, 181),
);

然后在主题配置的时候使用这个颜色种子:

void main() {
    runApp(
        MaterialApp(
            theme: ThemeData().copyWith(
                useMaterial3: truem
                colorScheme: kColorScheme,
                appBarTheme: AppBarTheme().copyWith(
                    backgroundColor: kColorScheme.onPrimaryContainer,
                    foregroundColor: kColorScheme.primaryContainer,
                )
            ),
            home: const Expenses(),
        )
    )
}

不难看出来,颜色种子既可以作为一个集合设置一大片,也可以通过属性的方式精确设置。上面代码的 onPrimaryContainer 和 primaryContainer 表示的是平常状态下的颜色和按下激活的颜色。

  1. 更多示例
// 定义应用的主题数据
final ThemeData themeData = ThemeData()
  // 设置颜色方案
  .copyWith(
    colorScheme: kColorScheme,
    // 自定义AppBar的主题
    appBarTheme: const AppBarTheme().copyWith(
      // AppBar的背景颜色
      backgroundColor: kColorScheme.onPrimaryContainer,
      // AppBar的前景色
      foregroundColor: kColorScheme.primaryContainer,
    ),
    // 自定义卡片的主题
    cardTheme: const CardTheme().copyWith(
      // 卡片的背景颜色
      color: kColorScheme.secondaryContainer,
      // 卡片的外边距
      margin: const EdgeInsets.symmetric(
        horizontal: 16,
        vertical: 8,
      ),
    ),
    // 自定义凸起按钮的主题
    elevatedButtonTheme: ElevatedButtonThemeData(
      // 凸起按钮的样式
      style: ElevatedButton.styleFrom(
        // 按钮的背景颜色
        backgroundColor: kColorScheme.primaryContainer,
      ),
    ),
    // 自定义文本主题
    textTheme: ThemeData().textTheme.copyWith(
      // 标题大号文本样式
      titleLarge: TextStyle(
        // 字体的粗细
        fontWeight: FontWeight.normal,
        // 文本的颜色
        color: kColorScheme.onSecondaryContainer,
        // 文本的字体大小
        fontSize: 14,
      ),
    ),
  );

// 注意:kColorScheme 应该是一个已经定义好的ColorScheme对象,这里没有给出其定义。
  1. 精确使用设置的主题 以 Text 组件为例
Text(
    expense.title,
    style: Theme.of(context).textTheme.titleLarge,
)

我们使用的 Theme 就是全局配置的主题,而 Theme.of(context) 表示的是在设置主题的大前提下适用此组件的的主题分类,然后在此分类之下又有几层分类。

设置 Dissmissible 组件移除的时候露出来的背景色

// itemBuilder 用于构建列表中的每个项目
itemBuilder: (ctx, index) => Dismissible(
  // 使用 ValueKey 来唯一标识每个 Dismissible widget
  key: ValueKey(expenses[index]),
  // 设置滑动删除时的背景色
  background: Container(
    color: Theme.of(context).colorScheme.error, // 使用主题中的错误颜色
    margin: EdgeInsets.symmetric(horizontal: Theme.of(context).cardTheme.margin!.horizontal) // 使用卡片主题的外边距
  ),
  // 当 Dismissible 被滑动删除时调用的回调
  onDismissed: (direction) {
    // 调用 removeExpense 方法来处理删除逻辑
    onRemoveExpense(expenses[index]);
  },
  // Dismissible 的子组件,即列表项内容
  child: ExpenseItem(
    expense: expenses[index],
  ),
),

主题不仅包括颜色还有内外边距等内容。

  1. 设置颜色的透明度,使用 .withOpacity(0.75)
background: Container(
    color: Theme.of(context).colorScheme.error.withOpacity(0.75),
    margin: EdgeInsets.symmetric(
        horizontal: Theme.of(context).cardTheme.margin!.horizontal,
    ),
)
  1. 使用暗黑颜色种子设置暗黑主题
// 定义暗色主题的ColorScheme
var kDarkColorScheme = ColorScheme.fromSeed(
  // 使用Color.fromARGB定义一个深紫色作为种子颜色
  seedColor: const Color.fromARGB(255, 5, 99, 125),
  // 设置暗色主题的背景色、表面色、错误色等
  brightness: Brightness.dark,
);

void main() {
  runApp(
    MaterialApp(
      // 使用暗色主题
      darkTheme: ThemeData.dark(
        // 复制暗色主题并启用Material 3风格
       .copyWith(
         useMaterial3: true,
         colorScheme: kDarkColorScheme,
       ),
      ),
      // 使用自定义的主题
      theme: ThemeData().copyWith(
        // 复制默认主题并启用Material 3风格
        useMaterial3: true,
        // 使用自定义的ColorScheme
        colorScheme: kDarkColorScheme,
      ),
      home: const Expenses(), // 假设Expenses是你的主页组件
    ),
  );
}

和 theme 配置项并列的是名为 darkTheme 的配置项,在此配置即可。在配置暗色颜色种子的时候,我们一般会给一个 brightness 设置明暗程度 brightness: Brightness.dark,

  1. 亮色 暗色的切换准则 在 theme 配置项的同一级使用名为 themeMode: ThemeMode.x 配置亮色暗色切换规则,其中 x 的取值可以有:
  • dark
  • light
  • system
  1. 一个比较完整的配色方案
import 'package:flutter/material.dart';
import 'package:expense_tracker/widgets/expenses.dart'; // 确保路径正确

// 定义亮色主题的ColorScheme
var kColorScheme = ColorScheme.fromSeed(
  seedColor: const Color.fromARGB(255, 96, 59, 181),
);

// 定义暗色主题的ColorScheme
var kDarkColorScheme = ColorScheme.fromSeed(
  seedColor: const Color.fromARGB(255, 5, 99, 125),
);

void main() {
  runApp(
    MaterialApp(
      // 使用暗色主题
      darkTheme: ThemeData.dark().copyWith(
        useMaterial3: true,
        colorScheme: kDarkColorScheme,
        cardTheme: const CardTheme().copyWith(
          color: kColorScheme.secondaryContainer, // 使用亮色主题的secondaryContainer颜色
          margin: const EdgeInsets.symmetric(
            horizontal: 16,
            vertical: 8,
          ),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
            // 凸起按钮的样式
            style: ElevatedButton.styleFrom(
                // 按钮的背景颜色
                backgroundColor: kColorScheme.primaryContainer,
                foregroundColor: kColorScheme.onPrimaryContainer,
            ),
        ),
      ),
      // 使用亮色主题
      theme: ThemeData().copyWith(
        useMaterial3: true,
        colorScheme: kColorScheme,
        cardTheme: const CardTheme().copyWith(
          color: kColorScheme.secondaryContainer, // 使用亮色主题的secondaryContainer颜色
          margin: const EdgeInsets.symmetric(
            horizontal: 16,
            vertical: 8,
          ),
        ),
      ),
      home: const Expenses(), // 假设Expenses是你的主页组件
    ),
  );
}
  1. for...in 遍历 如下代码,我们有时候需要在 dart 中完成一些遍历,例如计算数组的和,这个时候使用 for in 循环再合适不过
double get totalExpenses {
    double sum = 0;
    for (final expense in expenses) {
        sum += expense.amount;
    }

    return sum;
}

到这里,大致可以将 final 理解成 js 中的 let 了。

下面是使用数组上面的 fold 方法,类似于 js 中的 reduce:

  double get totalExpenses {
    return expenses.fold<double>(
      0,
      (sum, expense) => sum + expense.amount,
    );
  }
  1. 构造函数重载和 where 在其中的使用 将两个知识点结合起来。
class ExpenseBucket {
  // 定义一个构造函数,接收分类和费用列表
  const ExpenseBucket({
    required this.category,
    required this.expenses,
  });

  // 构造函数重载,根据分类从所有费用中筛选出当前分类的费用
  ExpenseBucket.forCategory(this.category, List<Expense> allExpenses)
      : expenses = allExpenses.where((expense) => expense.category == category).toList();

  final Category category; // 分类
  final List<Expense> expenses; // 费用列表

  // 计算总费用
  double get totalExpenses {
    return expenses.fold<double>(
      0,
      (sum, expense) => sum + expense.amount,
    );
  }
}
  1. for in 循环代替 map 使用数组的 map 渲染列表的时候在最后要调一下 toList 方法,但是如果使用 for in 循环就不用,但是 for in 循环外层需要一对中括号包裹:
children: [
    for(final bucket in buckets) ChartBar(fill: bucket.totalExpenses / maxTotalExpense,)
]

在 Dart 语言中,当 for 循环是列表构造函数(如 [] 或 {})或集合构造函数(如 Set() 或 Map())参数列表中的单一语句时,可以省略花括号。这是 Dart 提供的一种语法糖,旨在使代码更加简洁。

  1. 通过媒体查询获取当前是暗黑还是亮色主题 如下所示,依据就是通过比较当前颜色种子的亮度完成的。
final isDarkMode = MediaQuery.of(context).platformBrightness == Brightness.dark;

然后通过此布尔值可以进行条件渲染或者进行更加精细的设置:

color: isDarkMode ? Theme.of(context).colorScheme.secondary : Theme.of(context).colorScheme.rpimary.withOpacity(0.7),

或者,

child: FractionallySizedBox(
  heightFactor: fill, // 0 <= fill <= 1
  child: DecoratedBox(
    decoration: BoxDecoration(
      shape: BoxShape.rectangle,
      borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
      color: isDarkMode
          ? Theme.of(context).colorScheme.secondary
          : Theme.of(context).colorScheme.primary.withOpacity(0.65),
    ), // BoxDecoration
  ), // DecoratedBox
), // FractionallySizedBox
  • FractionallySizedBox 通过百分比设置尺寸的组件
  • DecoratedBox 可以设置边框,圆角,背景色的组件