Flutter 秘籍(五)
十、状态管理
在构建 Flutter 应用时,您需要管理应用运行时的状态。状态可能会因用户交互或后台任务而改变。本章介绍了在 Flutter 中使用不同的状态管理解决方案的方法。
10.1 使用有状态小部件管理状态
问题
您希望有一种简单的方法来管理 UI 中的状态。
解决办法
创建自己的StatefulWidget子类。
讨论
StatefulWidget类是旋舞管理状态的基本方式。有状态小部件在其状态改变时会重建自身。如果要管理的状态很简单,使用有状态小部件通常就足够了。不需要使用其他菜谱中讨论的第三方库。
有状态小部件使用State对象来存储状态。当创建自己的StatefulWidget子类时,需要覆盖createState()方法来返回一个State对象。对于每个子类StatefulWidget,都会有一个相应的子类State class 来管理状态。createState()方法返回State的相应子类的一个对象。实际状态通常作为State子类的私有变量保存。
在State的子类中,需要实现build()方法来返回一个Widget对象。当状态改变时,将调用build()方法来获取新的小部件以更新 UI。要触发 UI 的重建,您需要显式调用setState()方法来通知框架。setState()方法的参数是一个VoidCallback函数,包含更新内部状态的逻辑。重建时,build()方法使用最新的状态来创建小部件配置。小部件不会更新,但会在必要时替换。
清单 10-1 中的小部件是有状态小部件的典型例子。_SelectColorState类是SelectColor小部件的State实现。_selectedColor是保持当前所选颜色的内部变量。_selectedColor的值由DropdownButton小部件用来确定要呈现的选定选项,由Text小部件用来确定要显示的文本。在DropdownButton的onChanged处理程序中,调用setState()方法更新_selectedColor变量的值,通知框架再次运行 _SelectColorState.build()方法获取新的 widget 配置来更新 UI。
class SelectColor extends StatefulWidget {
@override
_SelectColorState createState() => _SelectColorState();
}
class _SelectColorState extends State<SelectColor> {
final List<String> _colors = ['Red', 'Green', 'Blue'];
String _selectedColor;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
DropdownButton(
value: _selectedColor,
items: _colors.map((String color) {
return DropdownMenuItem(
value: color,
child: Text(color),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedColor = value;
});
},
),
Text('Selected: ${_selectedColor ?? "}'),
],
);
}
}
Listing 10-1Example of stateful widget
对象有自己的生命周期。您可以在State的子类中覆盖不同的生命周期方法,以在不同的阶段执行操作。表 10-1 显示了这些生命周期方法。
表 10-1
状态的生命周期方法
|名字
|
描述
|
| --- | --- |
| initState() | 当该对象被插入小部件树时调用。应该用于执行状态初始化。 |
| didChangeDependencies() | 当此对象的依赖项更改时调用。 |
| didUpdateWidget(T oldWidget) | 当这个对象的小部件改变时调用。旧部件作为参数传递。 |
| reassemble() | 在调试期间重新组装应用时调用。此方法仅在开发过程中调用。 |
| build(BuildContext context) | 当状态改变时调用。 |
| deactivate() | 当该对象从小部件树中移除时调用。 |
| dispose() | 当该对象从小部件树中永久删除时调用。这个方法在deactivate()之后调用。 |
在表 10-1 、initState()和dispose()所列的方法中,方法比较容易理解。这两个方法在生命周期中只会被调用一次。然而,也可以多次调用其他方法。
当状态对象使用继承的小部件时,通常使用didChangeDependencies()方法。当继承的小部件改变时,调用这个方法。大多数时候,你不需要覆盖这个方法,因为框架会在一个依赖关系改变后自动调用build()方法。有时,在依赖关系改变后,您可能需要执行一些昂贵的任务。在这种情况下,您应该将逻辑放到didChangeDependencies()方法中,而不是在build()方法中执行任务。
reassemble()方法仅在开发期间使用,例如,在热重装期间。在发布版本中不调用此方法。大多数情况下,您不需要重写此方法。
当状态的小部件改变时,调用didUpdateWidget()方法。如果您需要在旧部件上执行清理任务或重用旧部件的某些状态,您应该重写此方法。例如,TextField小部件的_TextFieldState类覆盖了didUpdateWidget()方法,根据旧小部件的值初始化TextEditingController对象。
当状态对象从部件树中移除时,调用deactivate()方法。该状态对象可以被插回到窗口小部件树的不同位置。如果构建逻辑依赖于小部件的位置,您应该重写此方法。例如,FormField小部件的FormFieldState类覆盖了deactivate()方法,从封闭表单中注销当前表单字段。
在清单 10-1 中,小部件的全部内容都构建在build()方法中,所以你可以简单地在DropdownButton的onPressed回调中调用setState()方法。如果小部件具有复杂的结构,您可以传递一个函数来更新子小部件的状态。在清单 10-2 中,RaisedButton的onPressed回调由CounterButton的构造函数参数设置。当CounterButton在Counter小部件中使用时,提供的处理函数使用setState()来更新状态。
class Counter extends StatefulWidget {
@override
_CounterState createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
CounterButton(() {
setState(() {
count++;
});
}),
CounterText(count),
],
);
}
}
class CounterText extends StatelessWidget {
CounterText(this.count);
final int count;
@override
Widget build(BuildContext context) {
return Text('Value: ${count ?? "}');
}
}
class CounterButton extends StatelessWidget {
CounterButton(this.onPressed);
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return RaisedButton(
child: Text('+'),
onPressed: onPressed,
);
}
}
Listing 10-2Pass state change function to descendant widget
10.2 使用继承的小部件管理状态
问题
您希望沿着部件树向下传播状态。
解决办法
创建你自己的 I nheritedWidget的子类。
讨论
当使用有状态小部件管理状态时,状态存储在State对象中。如果一个后代小部件需要访问状态,状态需要从子树的根传递给它,就像清单 10-2 中count状态是如何传递的一样。当小部件具有相对较深的子树结构时,添加构造函数参数来传递状态是不方便的。在这种情况下,使用InheritedWidget是更好的选择。
当使用 I nheritedWidget时,方法BuildContext.inheritFromWidgetOfExactType()可以从构建上下文中获得特定类型的继承小部件的最近实例。后代小部件可以轻松地访问存储在继承的小部件中的状态数据。当调用inheritFromWidgetOfExactType()方法时,构建上下文将自己注册到继承的小部件中。当继承的小部件发生变化时,构建上下文会自动重建,以便从继承的小部件中获取新值。这意味着使用继承部件状态的后代部件不需要手动更新。
清单 10-3 中的Config类表示状态。它有color和fontSize属性。Config类覆盖==操作符和hashCode属性来实现正确的相等检查。通过更新属性的一部分,copyWith()方法可以用来创建Config类的新实例。Config.fallback()构造函数用默认值创建一个Config对象。
class Config {
const Config({this.color, this.fontSize});
const Config.fallback()
: color = Colors.red,
fontSize = 12.0;
final Color color;
final double fontSize;
Config copyWith({Color color, double fontSize}) {
return Config(
color: color ?? this.color,
fontSize: fontSize ?? this.fontSize,
);
}
@override
bool operator ==(other) {
if (other.runtimeType != runtimeType) return false;
final Config typedOther = other;
return color == typedOther.color && fontSize == typedOther.fontSize;
}
@override
int get hashCode => hashValues(color, fontSize);
}
Listing 10-3Config class for inherited widget
清单 10-4 中的ConfigWidget是一个继承的小部件。它保持一个Config对象作为它的内部状态。调用updateShouldNotify()方法来检查在继承的小部件改变后是否应该通知注册的构建上下文。这是为了避免不必要的更新而进行的性能优化。静态of()方法是获取继承的小部件或与继承的小部件相关联的状态的常见做法。ConfigWidget的of()方法使用 i nheritFromWidgetOfExactType()从构建上下文中获取最近的封闭ConfigWidget实例,并从小部件中获取config属性。如果没有找到ConfigWidget对象,则返回默认的Config实例。
class ConfigWidget extends InheritedWidget {
const ConfigWidget({
Key key,
@required this.config,
@required Widget child,
}) : super(key: key, child: child);
final Config config;
static Config of(BuildContext context) {
final ConfigWidget configWidget =
context.inheritFromWidgetOfExactType(ConfigWidget);
return configWidget?.config ?? const Config.fallback();
}
@override
bool updateShouldNotify(ConfigWidget oldWidget) {
return config != oldWidget.config;
}
}
Listing 10-4ConfigWidget as inherited widget
在清单 10-5 中,ConfiguredText和ConfiguredBox小部件都使用ConfigWidget.of(context)来获取Config对象,并在构建 UI 时使用其属性。
class ConfiguredText extends StatelessWidget {
@override
Widget build(BuildContext context) {
Config config = ConfigWidget.of(context);
return Text(
'Font size: ${config.fontSize}',
style: TextStyle(
color: config.color,
fontSize: config.fontSize,
),
);
}
}
class ConfiguredBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
Config config = ConfigWidget.of(context);
return Container(
decoration: BoxDecoration(color: config.color),
child: Text('Background color: ${config.color}'),
);
}
}
Listing 10-5Use ConfigWidget to get the Config object
清单 10-6 中的ConfigUpdater小部件用于更新Config对象。它还使用ConfigWidget.of(context)来获得要更新的Config对象。onColorChanged和onFontSizeIncreased回调用于触发Config对象的更新。
typedef SetColorCallback = void Function(Color color);
class ConfigUpdater extends StatelessWidget {
const ConfigUpdater({this.onColorChanged, this.onFontSizeIncreased});
static const List<Color> _colors = [Colors.red, Colors.green, Colors.blue];
final SetColorCallback onColorChanged;
final VoidCallback onFontSizeIncreased;
@override
Widget build(BuildContext context) {
Config config = ConfigWidget.of(context);
return Column(
children: <Widget>[
DropdownButton(
value: config.color,
items: _colors.map((Color color) {
return DropdownMenuItem(
value: color,
child: Text(color.toString()),
);
}).toList(),
onChanged: onColorChanged,
),
RaisedButton(
child: Text('Increase font size'),
onPressed: onFontSizeIncreased,
)
],
);
}
}
Listing 10-6ConfigUpdater to update Config object
现在我们可以将这些小部件放在一起构建整个 UI。在清单 10-7 中,ConfiguredPage是一个有状态的小部件,其状态为Config对象。ConfigUpdater小部件是ConfiguredPage的子部件,用来更新Config对象。ConfiguredPage构造函数也有child参数来提供使用ConfigWidget.of(context)获得正确的Config对象的子部件。对于ConfigWidget的onColorChanged和onFontSizeIncreased回调,setState()方法用于更新ConfiguredPage widget 的状态并触发ConfigWidget的更新。框架通知ConfigUpdater和其他小部件用Config对象的最新值更新。
class ConfiguredPage extends StatefulWidget {
ConfiguredPage({Key key, this.child}) : super(key: key);
final Widget child;
@override
_ConfiguredPageState createState() => _ConfiguredPageState();
}
class _ConfiguredPageState extends State<ConfiguredPage> {
Config _config = Config(color: Colors.green, fontSize: 16);
@override
Widget build(BuildContext context) {
return ConfigWidget(
config: _config,
child: Column(
children: <Widget>[
ConfigUpdater(
onColorChanged: (Color color) {
setState(() {
_config = _config.copyWith(color: color);
});
},
onFontSizeIncreased: () {
setState(() {
_config = _config.copyWith(fontSize: _config.fontSize + 1.0);
});
},
),
Container(
decoration: BoxDecoration(border: Border.all()),
padding: EdgeInsets.all(8),
child: widget.child,
),
],
),
);
}
}
Listing 10-7ConfiguredPage to use ConfigWidget
在清单 10-8 中,ConfigWidgetPage小部件使用ConfiguredPage小部件包装ConfiguredText和ConfiguredBox小部件。
class ConfigWidgetPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Inherited Widget'),
),
body: ConfiguredPage(
child: Column(
children: <Widget>[
ConfiguredText(),
ConfiguredBox(),
],
),
),
);
}
}
Listing 10-8ConfigWidgetPage to build the UI
10.3 使用继承模型管理状态
问题
您希望得到通知并根据变化的各个方面重新构建 UI。
解决办法
创建自己的InheritedModel子类。
讨论
如果我们仔细查看菜谱 10-2 的清单 10-5 中的ConfiguredText和ConfiguredBox小部件,我们可以看到ConfiguredBox小部件只依赖于Config对象的color属性。如果fontSize属性改变,那么ConfiguredBox小部件不需要重新构建。这些不必要的重建可能会导致性能问题,尤其是在小部件很复杂的情况下。
InheritedModel widget 允许你将一个状态分成多个方面。构建上下文可以注册,以便仅针对特定方面获得通知。当InheritedModel小部件中的状态改变时,只有注册到匹配方面的依赖构建上下文会被通知。
InheritedModel类从InheritedWidget类扩展而来。它有一个类型参数来指定方面的类型。清单 10-9 中的ConfigModel类是Config对象的InheritedModel子类。方面的类型是String。当实现InheritedModel类时,您仍然需要覆盖updateShouldNotify()方法来决定是否应该通知依赖者。updateShouldNotifyDependent()方法根据依赖的方面集合确定是否应该通知依赖者。只有当updateShouldNotify()方法返回true时,才会调用updateShouldNotifyDependent()方法。对于ConfigModel,仅定义了“颜色”和“字体大小”方面。如果依赖项依赖于“颜色”方面,那么只有当Config对象的color属性改变时才会被通知。这也适用于fontSize属性的“fontSize”特征。
静态的of()方法有一个额外的aspect参数来指定构建上下文所依赖的方面。静态的InheritedModel.inheritFrom()方法用于使构建上下文依赖于指定的方面。当纵横比为null时,该方法与使用BuildContext.inheritFromWidgetOfExactType()方法相同。
class ConfigModel extends InheritedModel<String> {
const ConfigModel({
Key key,
@required this.config,
@required Widget child,
}) : super(key: key, child: child);
final Config config;
static Config of(BuildContext context, String aspect) {
ConfigModel configModel =
InheritedModel.inheritFrom(context, aspect: aspect);
return configModel?.config ?? Config.fallback();
}
@override
bool updateShouldNotify(ConfigModel oldWidget) {
return config != oldWidget.config;
}
@override
bool updateShouldNotifyDependent(
ConfigModel oldWidget, Set<String> dependencies) {
return (config.color != oldWidget.config.color &&
dependencies.contains('color')) ||
(config.fontSize != oldWidget.config.fontSize &&
dependencies.contains('fontSize'));
}
}
Listing 10-9ConfigModel as InheritedModel
在清单 10-10 中,ConfiguredModelText widget 使用null作为方面,因为它同时依赖于“color”和“fontSize”方面。ConfiguredModelBox widget 使用color作为方面。如果字体大小被更新,只有ConfiguredModelText部件被重建。
class ConfiguredModelText extends StatelessWidget {
@override
Widget build(BuildContext context) {
Config config = ConfigModel.of(context, null);
return Text(
'Font size: ${config.fontSize}',
style: TextStyle(
color: config.color,
fontSize: config.fontSize,
),
);
}
}
class ConfiguredModelBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
Config config = ConfigModel.of(context, 'color');
return Container(
decoration: BoxDecoration(color: config.color),
child: Text('Background color: ${config.color}'),
);
}
}
Listing 10-10Use ConfigModel to get Config object
10.4 使用继承的通知程序管理状态
问题
您希望依赖小部件基于来自Listenable对象的通知进行重建。
解决办法
创建自己的InheritedNotifier小部件的子类。
讨论
类通常用于管理监听器和通知客户端更新。您可以使用相同的模式来通知依赖者使用InheritedNotifier进行重建。InheritedNotifier widget 也从InheritedWidget类扩展而来。创建InheritedNotifier小部件时,需要提供Listenable对象。当Listenable对象发送通知时,这个InheritedNotifier小部件的依赖项被通知进行重建。
在清单 10-11 ,ConfigNotifier使用ValueNotifier<Config>作为Listenable的类型。静态的of()方法从ConfigNotifier对象中获取Config对象。
class ConfigNotifier extends InheritedNotifier<ValueNotifier<Config>> {
ConfigNotifier({
Key key,
@required notifier,
@required Widget child,
}) : super(key: key, notifier: notifier, child: child);
static Config of(BuildContext context) {
final ConfigNotifier configNotifier =
context.inheritFromWidgetOfExactType(ConfigNotifier);
return configNotifier?.notifier?.value ?? Config.fallback();
}
}
Listing 10-11ConfigNotifier as InheritedNotifier
要使用ConfigNotifier小部件,您需要创建一个新的ValueNotifier<Config>实例。要更新Config对象,只需将value属性设置为一个新值。ValueNotifier对象会发送通知,通知依赖的小部件重新构建。
class ConfiguredNotifierPage extends StatelessWidget {
ConfiguredNotifierPage({Key key, this.child}) : super(key: key);
final Widget child;
final ValueNotifier<Config> _notifier =
ValueNotifier(Config(color: Colors.green, fontSize: 16));
@override
Widget build(BuildContext context) {
return ConfigNotifier(
notifier: _notifier,
child: Column(
children: <Widget>[
ConfigUpdater(
onColorChanged: (Color color) {
_notifier.value = _notifier.value.copyWith(color: color);
},
onFontSizeIncreased: () {
Config oldConfig = _notifier.value;
_notifier.value =
oldConfig.copyWith(fontSize: oldConfig.fontSize + 1.0);
},
),
Container(
decoration: BoxDecoration(border: Border.all()),
padding: EdgeInsets.all(8),
child: child,
),
],
),
);
}
}
Listing 10-12ConfiguredNotifierPage to use ConfigNotifier
10.5 使用作用域模型管理状态
问题
您希望有一个简单的解决方案来处理模型变更。
解决办法
使用scoped_model包。
讨论
在菜谱 10-1、10-2、10-3 和 10-4 中,您已经看到了使用StatefulWidget、InheritedWidget、InheritedModel和InheritedNotifier小部件来管理状态。这些小部件是由 Flutter framework 提供的。这些小部件都是底层 API,所以不方便在复杂的 app 中使用。scoped_model包( https://pub.dev/packages/scoped_model )是一个库,允许轻松地将数据模型从父窗口小部件向下传递到其子窗口小部件。它构建在InheritedWidget之上,但是有一个易于使用的 API。要使用这个包,你需要将scoped_model: ¹.0.1添加到pubspec.yaml文件的dependencies中。我们将使用与配方 10-2 中相同的例子来演示scoped_model包的用法。
清单 10-13 显示了使用scoped_model包的Config模型。Config类从Model类扩展而来。它有私有字段来存储状态。setColor()和increaseFontSize()方法分别更新_color和_fontSize字段。这两个方法在内部使用notifyListeners()来通知后代小部件进行重建。
import 'package:scoped_model/scoped_model.dart';
class Config extends Model {
Color _color = Colors.red;
double _fontSize = 16.0;
Color get color => _color;
double get fontSize => _fontSize;
void setColor(Color color) {
_color = color;
notifyListeners();
}
void increaseFontSize() {
_fontSize += 1;
notifyListeners();
}
}
Listing 10-13Config model as scoped model
在清单 10-14 ,ScopedModelText小部件展示了如何在后代小部件中使用模型。ScopedModelDescendant widget 用于获取最近的封闭模型对象。type 参数确定要获取的模型对象。builder参数指定了构建小部件的构建函数。构建函数有三个参数。类型BuildContext的第一个参数对于构建函数是通用的。最后一个参数是模型对象。如果小部件 UI 的一部分不依赖于模型,并且不应该在模型改变时重新构建,您可以将其指定为ScopedModelDescendant小部件的child参数,并在构建函数的第二个参数中访问它。
class ScopedModelText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ScopedModelDescendant<Config>(
builder: (BuildContext context, Widget child, Config config) {
return Text(
'Font size: ${config.fontSize}',
style: TextStyle(
color: config.color,
fontSize: config.fontSize,
),
);
},
);
}
}
Listing 10-14ScopedModelText uses ScopedModelDescendant
在清单 10-15 中,ScopedModelUpdater小部件简单地使用setColor()和increaseFontSize()方法来更新状态。
class ScopedModelUpdater extends StatelessWidget {
static const List<Color> _colors = [Colors.red, Colors.green, Colors.blue];
@override
Widget build(BuildContext context) {
return ScopedModelDescendant<Config>(
builder: (BuildContext context, Widget child, Config config) {
return Column(
children: <Widget>[
DropdownButton(
value: config.color,
items: _colors.map((Color color) {
return DropdownMenuItem(
value: color,
child: Text(color.toString()),
);
}).toList(),
onChanged: (Color color) {
config.setColor(color);
},
),
RaisedButton(
child: Text('Increase font size'),
onPressed: () {
config.increaseFontSize();
},
)
],
);
},
);
}
}
Listing 10-15ScopedModelUpdater to update Config object
清单 10-16 中的小部件是将Model和ScopedModelDescendant放在一起的最后一块。model参数指定由ScopedModel对象管理的模型对象。ScopedModel对象下的所有ScopedModelDescendant小部件都获得相同的模型对象。
class ScopedModelPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Scoped Model'),
),
body: ScopedModel(
model: Config(),
child: Column(
children: <Widget>[
ScopedModelUpdater(),
ScopedModelText()
],
),
),
);
}
}
Listing 10-16ScopedModelPage uses ScopedModel
您还可以使用静态的ScopedModel.of()方法来获取ScopedModel对象,然后使用其model属性来获取模型对象。
10.6 使用 Bloc 管理状态
问题
您希望使用阻塞模式来管理状态。
解决办法
使用bloc和flutter_bloc包。
讨论
Bloc(业务逻辑组件)是一种将表示与业务逻辑分离架构模式。Bloc 被设计成简单、强大和可测试的。让我们从 Bloc 中的核心概念开始。
状态代表应用状态的一部分。当状态改变时,UI 小部件被通知根据最新的状态进行重建。每个应用都有自己定义状态的方式。通常,您将使用 Dart 类来描述状态。
事件是状态变化的来源。事件可以由用户交互或后台任务生成。例如,按下按钮可以生成描述预期动作的事件。当 HTTP 请求的响应就绪时,还可以生成一个事件来包含响应体。事件通常被描述为 Dart 类。事件也可能带有负载。
调度事件时,处理这些事件可能会导致当前状态转换到新状态。然后通知 UI 小部件使用新状态进行重建。事件转换由当前状态、事件和下一个状态组成。如果所有的状态转换都被记录下来,我们就可以很容易地跟踪所有的用户交互和状态变化。我们还可以实现时间旅行调试。
现在我们可以有一个集团的定义。阻塞组件将事件流转换成状态流。块具有初始状态,作为接收任何事件之前的状态。对于每个事件,Bloc 都有一个mapEventToState()函数,该函数接收一个接收到的事件并返回一个状态流供表示层使用。Bloc 还有一个向其分派事件的dispatch()方法。
在这个菜谱中,我们将使用 GitHub Jobs API ( https://jobs.github.com/api )来获取 GitHub 上的工作列表。用户可以输入关键字进行搜索并查看结果。为了消费这个,我们将使用 http 包( https://pub.dev/packages/http )。将这个包添加到您的 pubspec.yaml 文件中。
让我们从美国开始。清单 10-17 显示了不同状态的类。JobsState是所有状态类的抽象基类。JobsState类从equatable包中的Equatable类扩展而来。Equatable类用于为==操作符和hashCode属性提供移植。JobsEmpty是初始状态。JobsLoading表示作业列表数据仍在加载中。JobsLoaded表示加载了作业列表数据。JobsLoaded事件的有效载荷类型为List<Job>。JobsError表示取数据时出现错误。
import 'package:http/http.dart' as http;
abstract class JobsState extends Equatable {
JobsState([List props = const []]) : super(props);
}
class JobsEmpty extends JobsState {}
class GetJobsEvent extends JobsEvent {
GetJobsEvent({@required this.keyword})
: assert(keyword != null),
super([keyword]);
final String keyword;
}
class GitHubJobsClient {
Future<List<Job>> getJobs(keyword) async {
final response = await http.get('https://jobs.github.com/positions.json?description=${keyword}');
if (response.statusCode != 200) {
throw new Exception("Unable to fetch data");
}else{
var result = new List<Job>();
final rawResult = json.decode(response.body);
for(final jsonJob in rawResult){
result.add(Job.fromJson(jsonJob));
}
}
}
}
class JobsLoading extends JobsState {}
class JobsLoaded extends JobsState {
JobsLoaded({@required this.jobs})
: assert(jobs != null),
super([jobs]);
final List<Job> jobs;
}
class JobsError extends JobsState {}
Listing 10-17Bloc states
清单 10-18 显示了这些事件。JobsEvent是事件类的抽象基类。GetJobsEvent类表示获取作业数据的事件。
abstract class JobsEvent extends Equatable {
JobsEvent([List props = const []]) : super(props);
}
class GetJobsEvent extends JobsEvent {
GetJobsEvent({@required this.keyword})
: assert(keyword != null),
super([keyword]);
final String keyword;
}
Listing 10-18Bloc events
清单 10-19 显示了该块。JobsBloc类从Bloc<JobsEvent, JobsState>类扩展而来。Bloc的类型参数是事件和状态类。JobsEmpty是初始状态。在mapEventToState()方法中,如果事件是GetJobsEvent,则首先向流发出一个JobsLoading状态。然后使用GitHubJobsClient对象获取数据。如果数据提取成功,则发出一个带有加载数据的JobsLoaded状态。否则,发出一个JobsError状态。
class JobsBloc extends Bloc<JobsEvent, JobsState> {
JobsBloc({@required this.jobsClient}) : assert(jobsClient != null);
final GitHubJobsClient jobsClient;
@override
JobsState get initialState => JobsEmpty();
@override
Stream<JobsState> mapEventToState(JobsEvent event) async* {
if (event is GetJobsEvent) {
yield JobsLoading();
try {
List<Job> jobs = await jobsClient.getJobs(event.keyword);
yield JobsLoaded(jobs: jobs);
} catch (e) {
yield JobsError();
}
}
}
}
Listing 10-19Bloc
清单 10-20 中的GitHubJobs类是使用清单 10-19 中的JobsBloc类的小部件。JobsBloc对象在initState()方法中创建,在dispose()方法中处理。在KeywordInput小部件中,当用户在文本字段中输入关键字并按下搜索按钮时,一个GetJobsEvent被分派给JobsBloc对象。在JobsView小部件中,BlocBuilder小部件用于基于块中的状态构建 UI。这里我们检查了JobsState的实际类型并返回不同的小部件。
class GitHubJobs extends StatefulWidget {
GitHubJobs({Key key, @required this.jobsClient})
: assert(jobsClient != null),
super(key: key);
final GitHubJobsClient jobsClient;
@override
_GitHubJobsState createState() => _GitHubJobsState();
}
class _GitHubJobsState extends State<GitHubJobs> {
JobsBloc _jobsBloc;
@override
void initState() {
super.initState();
_jobsBloc = JobsBloc(jobsClient: widget.jobsClient);
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: KeywordInput(
jobsBloc: _jobsBloc,
),
),
Expanded(
child: JobsView(
jobsBloc: _jobsBloc,
),
),
],
);
}
@override
void dispose() {
_jobsBloc.dispose();
super.dispose();
}
}
class KeywordInput extends StatefulWidget {
KeywordInput({this.jobsBloc});
final JobsBloc jobsBloc;
@override
_KeywordInputState createState() => _KeywordInputState();
}
class _KeywordInputState extends State<KeywordInput> {
final GlobalKey<FormFieldState<String>> _keywordFormKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: TextFormField(
key: _keywordFormKey,
),
),
IconButton(
icon: Icon(Icons.search),
onPressed: () {
String keyword = _keywordFormKey.currentState?.value ?? ";
if (keyword.isNotEmpty) {
widget.jobsBloc.dispatch(GetJobsEvent(keyword: keyword));
}
},
),
],
);
}
}
class JobsView extends StatelessWidget {
JobsView({this.jobsBloc});
final JobsBloc jobsBloc;
@override
Widget build(BuildContext context) {
return BlocBuilder(
bloc: jobsBloc,
builder: (BuildContext context, JobsState state) {
if (state is JobsEmpty) {
return Center(
child: Text('Input keyword and search'),
);
} else if (state is JobsLoading) {
return Center(
child: CircularProgressIndicator(),
);
} else if (state is JobsError) {
return Center(
child: Text(
'Failed to get jobs',
style: TextStyle(color: Colors.red),
),
);
} else if (state is JobsLoaded) {
return JobsList(state.jobs);
}
},
);
}
}
Listing 10-20GitHub jobs widget using Bloc
10.7 使用 Redux 管理状态
问题
您希望使用 Redux 作为状态管理解决方案。
解决办法
使用redux和flux_redux包。
讨论
Redux ( https://redux.js.org/ )是一个管理应用状态的流行库。Redux 起源于 React,现已移植到不同语言。redux包是 Redux 的 Dart 实现。flux_redux包允许在构建 Flutter 小部件时使用 Redux store。如果你以前用过 Redux,那么在 Flutter 中也会用到相同的概念。
Redux 使用单个全局对象作为状态。这个对象是应用的唯一真实来源,它被称为商店。动作被分派给存储以更新状态。Reducer 函数接受当前状态和一个动作作为参数,并返回下一个状态。下一个状态成为 reducer 函数下一次运行的输入。UI 小部件可以从商店中选择部分数据来构建内容。
要使用flutter_redux包,需要将flutter_redux: ⁰.5.3添加到pubspec.yaml文件的dependencies中。我们将使用 GitHub 上列出作业的相同示例来演示 Redux 在 Flutter 中的用法。
先从状态说起。清单 10-21 中的JobsState类代表全局状态。状态有三个属性,loading表示数据是否仍在加载,error表示加载数据时是否出错,data表示数据列表。通过使用copyWith()方法,我们可以通过更新一些属性来创建新的JobsState对象。
class JobsState extends Equatable {
JobsState({bool loading, bool error, List<Job> data})
: _loading = loading,
_error = error,
_data = data,
super([loading, error, data]);
final bool _loading;
final bool _error;
final List<Job> _data;
bool get loading => _loading ?? false;
bool get error => _error ?? false;
List<Job> get data => _data ?? [];
bool get empty => _loading == null && _error == null && _data == null;
JobsState copyWith({bool loading, bool error, List<Job> data}) {
return JobsState(
loading: loading ?? this._loading,
error: error ?? this._error,
data: data ?? this._data,
);
}
}
Listing 10-21JobsState for Redux
清单 10-22 显示了这些动作。这些动作触发状态改变。
abstract class JobsAction extends Equatable {
JobsAction([List props = const []]) : super(props);
}
class LoadJobAction extends JobsAction {
LoadJobAction({@required this.keyword})
: assert(keyword != null),
super([keyword]);
final String keyword;
}
class JobLoadedAction extends JobsAction {
JobLoadedAction({@required this.jobs})
: assert(jobs != null),
super([jobs]);
final List<Job> jobs;
}
class JobLoadErrorAction extends JobsAction {}
Listing 10-22Actions for Redux
清单 10-23 显示了根据动作更新状态的 reducer 函数。
JobsState jobsReducers(JobsState state, dynamic action) {
if (action is LoadJobAction) {
return state.copyWith(loading: true);
} else if (action is JobLoadErrorAction) {
return state.copyWith(loading: false, error: true);
} else if (action is JobLoadedAction) {
return state.copyWith(loading: false, data: action.jobs);
}
return state;
}
Listing 10-23Reducer function for Redux
清单 10-22 中定义的动作只能用于同步操作。例如,如果您想要分派JobLoadedAction,您需要首先准备好List<Job>对象。但是,加载作业数据的操作是异步的。您需要使用 thunk 函数作为 Redux store 的中间件。thunk 函数将商店作为唯一的参数。它使用存储来分派动作。thunk 操作可以被分派到存储区,就像其他普通操作一样。
清单 10-24 中的getJobs()函数将一个GitHubJobsClient对象和一个搜索关键字作为参数。这个函数返回一个类型为ThunkAction<JobsState>的 thunk 函数。ThunkAction来自redux_thunk包。在 thunk 函数中,首先调度一个LoadJobAction。然后使用GitHubJobsClient对象获取作业数据。根据数据加载的结果,调度JobLoadedAction或JobLoadErrorAction。
ThunkAction<JobsState> getJobs(GitHubJobsClient jobsClient, String keyword) {
return (Store<JobsState> store) async {
store.dispatch(LoadJobAction(keyword: keyword));
try {
List<Job> jobs = await jobsClient.getJobs(keyword);
store.dispatch(JobLoadedAction(jobs: jobs));
} catch (e) {
store.dispatch(JobLoadErrorAction());
}
};
}
Listing 10-24Thunk function for Redux
现在我们可以使用 Redux 商店来构建小部件。您可以使用两个助手小部件来访问商店中的数据。在清单 10-25 ,StoreBuilder小部件用于提供对商店的直接访问。商店可以作为构建函数的第二个参数。StoreBuilder widget 通常用在需要调度动作的时候。StoreConnector widget 允许使用一个转换器函数先转换状态。当按下搜索图标时,首先调用清单 10-24 中的getJobs()函数来创建 thunk 函数,然后将 thunk 函数分派给商店。当使用StoreConnector小部件时,转换器函数只是从存储中获取当前状态。然后在构建函数中使用状态对象。
class GitHubJobs extends StatefulWidget {
GitHubJobs({
Key key,
@required this.store,
@required this.jobsClient,
}) : assert(store != null),
assert(jobsClient != null),
super(key: key);
final Store<JobsState> store;
final GitHubJobsClient jobsClient;
@override
_GitHubJobsState createState() => _GitHubJobsState();
}
class _GitHubJobsState extends State<GitHubJobs> {
@override
Widget build(BuildContext context) {
return StoreProvider<JobsState>(
store: widget.store,
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: KeywordInput(
jobsClient: widget.jobsClient,
),
),
Expanded(
child: JobsView(),
),
],
),
);
}
}
class KeywordInput extends StatefulWidget {
KeywordInput({this.jobsClient});
final GitHubJobsClient jobsClient;
@override
_KeywordInputState createState() => _KeywordInputState();
}
class _KeywordInputState extends State<KeywordInput> {
final GlobalKey<FormFieldState<String>> _keywordFormKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: TextFormField(
key: _keywordFormKey,
),
),
StoreBuilder<JobsState>(
builder: (BuildContext context, Store<JobsState> store) {
return IconButton(
icon: Icon(Icons.search),
onPressed: () {
String keyword = _keywordFormKey.currentState?.value ?? ";
if (keyword.isNotEmpty) {
store.dispatch(getJobs(widget.jobsClient, keyword));
}
},
);
},
),
],
);
}
}
class JobsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreConnector<JobsState, JobsState>(
converter: (Store<JobsState> store) => store.state,
builder: (BuildContext context, JobsState state) {
if (state.empty) {
return Center(
child: Text('Input keyword and search'),
);
} else if (state.loading) {
return Center(
child: CircularProgressIndicator(),
);
} else if (state.error) {
return Center(
child: Text(
'Failed to get jobs',
style: TextStyle(color: Colors.red),
),
);
} else {
return JobsList(state.data);
}
},
);
}
}
Listing 10-25GitHub jobs widget using Redux store
最后一步是创建商店。清单 10-26 中的商店是用 reducer 函数、初始状态和来自redux_thunk包的 thunk 中间件创建的。
final store = new Store<JobsState>(
jobsReducers,
initialState: JobsState(),
middleware: [thunkMiddleware],
);
Listing 10-26Create the store
10.8 使用 Mobx 管理状态
问题
您希望使用 Mobx 来管理状态。
解决办法
使用mobx和flutter_mobx包。
讨论
Mobx ( https://mobx.js.org )是一个状态管理库,将反应式数据与 UI 连接起来。MobX 起源于使用 JavaScript 开发 web 应用。也移植到 Dart ( https://mobx.pub )。在 Flutter 应用中,我们可以使用mobx和flutter_mobx包,用 Mobx 构建应用。Mobx for Flutter 使用build_runner包为商店生成代码。build_runner和mobx_codegen包需要作为dev_dependencies添加到pubspec.yaml文件中。
Mobx 使用 observables 来管理状态。应用的整体状态由核心状态和派生状态组成。派生状态是从核心状态计算出来的。动作使可观察对象变异以更新状态。反应是状态的观察者,只要它们跟踪的可观察对象发生变化,它们就会得到通知。在 Flutter 应用中,反应用于更新小部件。
与 Redux 的 Flutter 相比,Mobx 使用代码生成来简化存储的使用。您不需要编写样板代码来创建动作。Mobx 提供了几个注释。您只需用这些注释对代码进行注释。这与json_annotation和json_serialize包的工作方式类似。我们将使用在 GitHub 上显示工作列表的相同示例来演示 Mobx 的用法。将这个包添加到您的 pubspec.yaml 文件中,如果它还不存在的话。
清单 10-27 显示了 Mobx 商店的jobs_store.dart文件的基本代码。该文件使用生成的零件文件jobs_store.g.dart。_JobsStore是乔布斯的商店的抽象类。它实现了来自 Mobx 的Store类。这里,我们使用@observable注释定义了两个可观测量。第一个可观察到的keyword是一个管理当前搜索关键字的简单字符串。getJobsFuture observable 是一个ObservableFuture<List<Job>>对象,它管理异步操作以使用 API 获得作业。使用@computed注释标记的属性是派生的可观察值,用于检查数据加载的状态。我们还使用@action注释定义了两个动作。setKeyword()动作将getJobsFuture可观察值设置为空状态,将keyword可观察值设置为提供的值。getJobs()动作使用GitHubJobsClient.getJobs()方法加载数据。将getJobsFuture可观察对象更新为包装返回的未来的ObservableFuture对象。
import 'package:meta/meta.dart';
import 'package:mobx/mobx.dart';
part 'jobs_store.g.dart';
class JobsStore = _JobsStore with _$JobsStore;
abstract class _JobsStore implements Store {
_JobsStore({@required this.jobsClient}) : assert(jobsClient != null);
final GitHubJobsClient jobsClient;
@observable
String keyword = ";
@observable
ObservableFuture<List<Job>> getJobsFuture = emptyResponse;
@computed
bool get empty => getJobsFuture == emptyResponse;
@computed
bool get hasResults =>
getJobsFuture != emptyResponse &&
getJobsFuture.status == FutureStatus.fulfilled;
@computed
bool get loading =>
getJobsFuture != emptyResponse &&
getJobsFuture.status == FutureStatus.pending;
@computed
bool get hasError =>
getJobsFuture != emptyResponse &&
getJobsFuture.status == FutureStatus.rejected;
static ObservableFuture<List<Job>> emptyResponse = ObservableFuture.value([]);
List<Job> jobs = [];
@action
Future<List<Job>> getJobs() async {
jobs = [];
final future = jobsClient.getJobs(keyword);
getJobsFuture = ObservableFuture(future);
return jobs = await future;
}
@action
void setKeyword(String keyword) {
getJobsFuture = emptyResponse;
this.keyword = keyword;
}
}
Listing 10-27Mobx store
生成代码需要使用flutter packages pub run build_runner build命令。JobsStore类是商店使用。清单 10-28 显示了使用商店的小部件。在搜索按钮的onPressed回调中,首先调用setKeyword()方法更新关键字,然后调用getJobs()方法触发数据加载。Observer小部件使用一个构建函数来构建 UI,该函数使用JobsStore对象中计算出的观察值和字段。每当这些可观察到的东西改变时,Observer widget 就会重建来更新 UI。
class GitHubJobs extends StatefulWidget {
GitHubJobs({Key key, @required this.jobsStore})
: assert(jobsStore != null),
super(key: key);
final JobsStore jobsStore;
@override
_GitHubJobsState createState() => _GitHubJobsState();
}
class _GitHubJobsState extends State<GitHubJobs> {
@override
Widget build(BuildContext context) {
JobsStore jobsStore = widget.jobsStore;
return Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: KeywordInput(
jobsStore: jobsStore,
),
),
Expanded(
child: JobsView(
jobsStore: jobsStore,
),
),
],
);
}
}
class KeywordInput extends StatefulWidget {
KeywordInput({this.jobsStore});
final JobsStore jobsStore;
@override
_KeywordInputState createState() => _KeywordInputState();
}
class _KeywordInputState extends State<KeywordInput> {
final GlobalKey<FormFieldState<String>> _keywordFormKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: TextFormField(
key: _keywordFormKey,
),
),
IconButton(
icon: Icon(Icons.search),
onPressed: () {
String keyword = _keywordFormKey.currentState?.value ?? ";
if (keyword.isNotEmpty) {
widget.jobsStore.setKeyword(keyword);
widget.jobsStore.getJobs();
}
},
),
],
);
}
}
class JobsView extends StatelessWidget {
JobsView({this.jobsStore});
final JobsStore jobsStore;
@override
Widget build(BuildContext context) {
return Observer(
builder: (BuildContext context) {
if (jobsStore.empty) {
return Center(
child: Text('Input keyword and search'),
);
} else if (jobsStore.loading) {
return Center(
child: CircularProgressIndicator(),
);
} else if (jobsStore.hasError) {
return Center(
child: Text(
'Failed to get jobs',
style: TextStyle(color: Colors.red),
),
);
} else {
return JobsList(jobsStore.jobs);
}
},
);
}
}
Listing 10-28GitHub jobs widget using Mobx store
10.9 摘要
本章讨论了 Flutter 应用的不同状态管理解决方案。在这些解决方案中,StatefulWidget、InheritedWidget、InheritedModel和InheritedNotifier小部件由 Flutter 框架提供。作用域模型、Bloc、Redux 和 Mobx 库是第三方解决方案。您可以自由选择最适合您需求的解决方案。在下一章,我们将讨论 Flutter 中的动画。
十一、动画
动画在移动应用中扮演着重要的角色,为最终用户提供视觉反馈。本章涵盖了与 Flutter 中的动画相关的配方。
11.1 创建简单的动画
问题
你想要创建简单的动画。
解决办法
使用AnimationController类创建简单的动画。
讨论
Flutter 中的动画有一个值和一个状态。动画的价值可能会随着时间而改变。动画使用抽象的Animation<T>类来表示。Animation类从Listenable类扩展而来。您可以向Animation对象添加监听器,以便在值或状态发生变化时得到通知。
AnimationController类是Animation<double>类的子类。AnimationController类提供对它创建的动画的控制。要创建一个AnimationController对象,您可以提供一个下限、一个上限和一个持续时间。AnimationController对象的值随着持续时间从下限变化到上限。还需要一个TickerProvider对象。对于有状态的小部件,可以使用TickerProviderStateMixin或SingleTickerProviderStateMixin类作为 state 类的 mixin。如果只有一个AnimationController对象用于状态,使用SingleTickerProviderStateMixin会更有效。
清单 11-1 展示了一个在有状态小部件中使用AnimationController来动画显示图像大小的例子。在initState()方法的主体中创建AnimationController对象,并在dispose()方法中对其进行处理。这是典型的使用AnimationController的模式。_GrowingImageState类有SingleTickerProviderStateMixin mixin,所以AnimationController构造函数使用这个对象作为vsync参数。在AnimationController对象的监听器中,调用setState()方法来触发小部件的重建。forward()方法开始向前播放动画。在build()方法中,AnimationController对象的当前值用于控制SizedBox小部件的大小。在运行时,SizedBox widget 的大小在 10 秒内从0增长到400。
class GrowingImage extends StatefulWidget {
@override
_GrowingImageState createState() => _GrowingImageState();
}
class _GrowingImageState extends State<GrowingImage>
with SingleTickerProviderStateMixin {
AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
lowerBound: 0,
upperBound: 400,
duration: const Duration(seconds: 10),
vsync: this,
)
..addListener(() {
setState(() {});
})
..forward();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: controller.value,
height: controller.value,
child: Image.network('https://picsum.photos/400'),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
Listing 11-1Using AnimationController
表 11-1 显示了AnimationController控制动画进度的方法。
表 11-1
控制动画的方法
|名字
|
描述
|
| --- | --- |
| forward() | 开始向前播放动画。 |
| reverse() | 开始反向播放动画。 |
| stop() | 停止动画的运行。 |
| repeat() | 开始运行动画,并在动画完成时重新启动。 |
| reset() | 将值设置为下限并停止动画。 |
动画可能处于不同的状态。AnimationStatus enum 代表动画的不同状态。表 11-2 显示了该枚举的所有值。您可以使用addStatusListener()方法添加一个监听器,以便在状态改变时得到通知。
表 11-2
动画状态的价值
|名字
|
描述
|
| --- | --- |
| forward | 动画正向播放。 |
| reverse | 动画是反向播放的。 |
| dismissed | 动画在开始时停止。 |
| completed | 动画在结束时停止。 |
在清单 11-2 中,状态监听器被添加到AnimationController对象中。当动画处于completed状态时,它开始反向运行。
var controller = AnimationController(
lowerBound: 0,
upperBound: 300,
duration: const Duration(seconds: 10),
vsync: this,
)
..addListener(() {
setState(() {});
})
..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
controller.reverse();
}
})
..forward();
Listing 11-2
Status listener
清单 11-1 展示了一个使用有状态窗口小部件动画的典型模式。widget 让动画的使用变得更加简单。AnimatedWidget构造函数需要一个Listenable对象。每当Listenable对象发出一个值时,小部件就重新构建自己。清单 11-3 显示了一个使用AnimatedWidget的例子。虽然AnimatedWidget类通常与Animation对象一起使用,但是你仍然可以将它与任何Listenable对象一起使用。
class AnimatedImage extends AnimatedWidget {
AnimatedImage({Key key, this.animation})
: super(key: key, listenable: animation);
final Animation<double> animation;
@override
Widget build(BuildContext context) {
return SizedBox(
width: animation.value,
height: animation.value,
child: Image.network('https://picsum.photos/300'),
);
}
}
Listing 11-3Example of AnimatedWidget
11.2 使用线性插值创建动画
问题
您希望使用线性插值为其他数据类型创建动画。
解决办法
使用Tween类及其子类。
讨论
AnimationController类使用double作为它的值类型。双精度值对于有大小或位置的动画很有用。您可能仍然需要动画显示其他类型的数据。例如,您可以将背景颜色从红色变为绿色。对于这些场景,你可以使用Tween类及其子类。
Tween类表示开始值和结束值之间的线性插值。要创建一个Tween对象,您需要提供这两个值。Tween对象可以提供值给动画使用。通过对另一个Animation对象使用animate()方法,您可以创建一个新的Animation对象,它由提供的Animation对象驱动,但是使用来自Tween对象的值。Tween的子类需要实现lerp()方法,该方法获取动画值并返回插值。
在清单 11-4 中,AnimatedColor小部件使用Animation<Color>对象来更新背景颜色。ColorTween对象是用开始值Colors.red和结束值Colors.green创建的。
class AnimatedColorTween extends StatefulWidget {
@override
_AnimatedColorTweenState createState() => _AnimatedColorTweenState();
}
class _AnimatedColorTweenState extends State<AnimatedColorTween>
with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<Color> animation;
@override
void initState() {
super.initState();
controller = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
);
animation =
ColorTween(begin: Colors.red, end: Colors.green).animate(controller);
controller.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedColor(
animation: animation,
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
class AnimatedColor extends AnimatedWidget {
AnimatedColor({Key key, this.animation})
: super(key: key, listenable: animation);
final Animation<Color> animation;
@override
Widget build(BuildContext context) {
return Container(
width: 300,
height: 300,
decoration: BoxDecoration(color: animation.value),
);
}
}
Listing 11-4Example of ColorTween
对于不同的对象,Tween还有很多其他的子类,包括AlignmentTween、BorderTween、BoxConstraintsTween、DecorationTween、EdgeInsetsTween、SizeTween、TextStyleTween等等。
11.3 创建曲线动画
问题
你想创造曲线动画。
解决办法
使用CurvedAnimation或CurveTween类。
讨论
除了线性动画之外,您还可以创建使用曲线来调整变化率的曲线动画。曲线是一个单位区间到另一个单位区间的映射。Curve类及其子类是曲线的内置类型。Curve类的transform()方法返回给定点的曲线映射值。曲线必须将输入0.0映射到0.0并将1.0映射到1.0。表 11-3 显示了不同类型的曲线。
表 11-3
不同类型的曲线
|名字
|
描述
|
| --- | --- |
| Cubic | 由两个控制点定义的三次曲线。用四个 double 值作为这两个点的 x 和 y 坐标创建。 |
| ElasticInCurve | 振荡曲线,当超过其界限时,其幅度增加。随着振荡的持续时间而产生。 |
| ElasticOutCurve | 超越界限时幅度缩小的振荡曲线。随着振荡的持续时间而产生。 |
| ElasticInOutCurve | 振荡曲线增长,然后缩小幅度,同时超越其界限。随着振荡的持续时间而产生。 |
| Interval | 用begin、end和一个curve创建。其值在begin之前为 0.0,在end之后为 1.0。开始和结束之间的值由曲线定义。 |
| SawTooth | 重复给定次数的锯齿曲线。 |
| Threshold | 在阈值之前为 0.0,然后跳到 1.0 的曲线。 |
您可以使用表 11-3 中Curve子类的构造函数来创建新曲线,或者使用Curves类中的常量。对于大多数情况来说,Curves类中的常量已经足够好了。对于一个Curve对象,你可以使用flipped属性得到一条新的曲线,它是这条曲线的反转。
使用Curve对象,你可以使用CurvedAnimation类创建曲线动画。表 11-4 显示了CurvedAnimation构造器的参数。如果reverseCurve参数为空,指定的曲线用于两个方向。
表 11-4
曲线动画参数
|名字
|
类型
|
描述
|
| --- | --- | --- |
| parent | Animation<double> | 应用曲线的动画。 |
| curve | Curve | 向前使用的曲线。 |
| reverseCurve | Curve | 向后使用的曲线。 |
在清单 11-5 中,AnimatedBox小部件使用动画值来确定盒子的左边位置。CurvedAnimation物体是用Curves.easeInOut曲线创建的。
class CurvedPosition extends StatefulWidget {
@override
_CurvedPositionState createState() => _CurvedPositionState();
}
class _CurvedPositionState extends State<CurvedPosition>
with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
controller = AnimationController(
duration: const Duration(seconds: 5),
vsync: this,
)..forward();
animation = CurvedAnimation(parent: controller, curve: Curves.easeInOut);
}
@override
Widget build(BuildContext context) {
return AnimatedBox(
animation: animation,
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
class AnimatedBox extends AnimatedWidget {
AnimatedBox({Key key, this.animation})
: super(key: key, listenable: animation);
final Animation<double> animation;
final double _width = 400;
@override
Widget build(BuildContext context) {
return Container(
width: _width,
height: 20,
child: Stack(
children: <Widget>[
Positioned(
left: animation.value * _width,
bottom: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: Colors.red),
),
)
],
),
);
}
}
Listing 11-5CurvedAnimation
CurveTween类使用一个Curve对象来转换动画的值。当需要用另一个Tween对象链接曲线动画时,可以使用CurveTween对象。
11.4 链接补间动画
问题
你想连锁补间。
解决办法
使用Animatable类的chain()方法或者动画类的drive()方法。
讨论
Animatable是Tween、CurveTween和TweenSequence类的超类。给定一个Animatable对象,你可以用另一个Animatable对象作为父对象来使用chain()方法。对于给定的输入值,首先评估父Animatable对象,然后将结果用作当前Animatable对象的输入。你可以使用多种chain()方法来创建复杂的动画。
在清单 11-6 中,Tween对象与另一个CurveTween对象链接在一起。
var animation = Tween(begin: 0.0, end: 300.0)
.chain(CurveTween(curve: Curves.easeOut))
.animate(controller);
Listing 11-6Chain tweens
你也可以使用Animation类的drive()方法来链接一个Animatable对象。
11.5 创建补间序列
问题
您想要为不同的阶段创建补间序列。
解决办法
使用TweenSequence类。
讨论
通过使用TweenSequence类,你可以为动画的不同阶段使用不同的Animatable对象。一个TweenSequence对象由一列TweenSequenceItem对象定义。每个TweenSequenceItem对象都有一个Animatable对象和一个权重。权重定义了该TweenSequenceItem对象在其父TweenSequence对象的整个持续时间中的相对百分比。
在清单 11-7 中,动画是用 40%的线性补间和 60%的曲线补间创建的。
var animation = TweenSequence([
TweenSequenceItem(
tween: Tween(begin: 0.0, end: 100.0),
weight: 40,
),
TweenSequenceItem(
tween: Tween(begin: 100.0, end: 300.0)
.chain(CurveTween(curve: Curves.easeInOut)),
weight: 60,
)
]).animate(controller);
Listing 11-7Example of TweenSequence
11.6 运行同步动画
问题
您想要在AnimatedWidget中同时运行动画。
解决办法
使用Animatable类的evaluate()方法。
讨论
AnimatedWidget构造函数只支持一个Animation对象。如果你想在一个AnimatedWidget对象中使用多个动画,你需要在AnimatedWidget对象中创建多个Tween对象,并使用evaluate()方法获取Animation对象的值。
在清单 11-8 中,_leftTween和_bottomTween对象分别决定左边和底部的属性。
class AnimatedBox extends AnimatedWidget {
AnimatedBox({Key key, this.animation})
: super(key: key, listenable: animation);
final Animation<double> animation;
final double _width = 400;
final double _height = 300;
static final _leftTween = Tween(begin: 0, end: 1.0);
static final _bottomTween = CurveTween(curve: Curves.ease);
@override
Widget build(BuildContext context) {
return Container(
width: _width,
height: _height,
margin: EdgeInsets.all(10),
decoration: BoxDecoration(border: Border.all()),
child: Stack(
children: <Widget>[
Positioned(
left: _leftTween.evaluate(animation) * _width,
bottom: _bottomTween.evaluate(animation) * _height,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: Colors.red),
),
)
],
),
);
}
}
Listing 11-8Simultaneous animations
11.7 创建交错动画
问题
您想要创建连续或重叠的动画。
解决办法
使用Interval类。
讨论
使用TweenSequence类,你可以创建一个补间序列。然而,在TweenSequence对象中指定的补间不能重叠。要创建重叠动画,可以使用Interval曲线来指定动画的开始和结束时间。
在清单 11-9 中,三个Tween对象以Interval对象中指定的不同间隔进行动画。这些Tween对象由同一个Animation对象控制。
class AnimatedContainer extends StatelessWidget {
AnimatedContainer({Key key, this.animation})
: width = Tween(begin: 0.0, end: 300.0).animate(CurvedAnimation(
parent: animation,
curve: Interval(0.0, 0.5, curve: Curves.easeInOut))),
height = Tween(begin: 0.0, end: 200.0).animate(CurvedAnimation(
parent: animation,
curve: Interval(0.2, 0.7, curve: Curves.bounceInOut))),
backgroundColor = ColorTween(begin: Colors.red, end: Colors.green)
.animate(CurvedAnimation(
parent: animation,
curve: Interval(0.3, 1.0, curve: Curves.elasticInOut))),
super(key: key);
final Animation<double> animation;
final Animation<double> width;
final Animation<double> height;
final Animation<Color> backgroundColor;
Widget _build(BuildContext context, Widget child) {
return Container(
width: width.value,
height: height.value,
decoration: BoxDecoration(color: backgroundColor.value),
child: child,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: _build,
);
}
}
Listing 11-9Staggered animations
11.8 创建英雄动画
问题
您想要在两条路线上制作元素动画。
解决办法
使用Hero小工具。
讨论
当从当前路线切换到新路线时,最好在新路线中有一些元素来指示导航上下文。例如,当前路线显示项目列表。当用户点击一个项目来导航到详细路线时,新路线应该有一个小部件来显示所选项目的简要信息。
Hero微件在两条路线之间共享。用一个标签和一个子部件创建一个Hero部件。标签是Hero小部件的唯一标识符。如果源路线和目标路线都有一个带有相同标签的Hero小部件,那么在路线转换期间,源路线中的Hero小部件将被动画显示到目标路线中的位置。英雄小部件的标签在同一个小部件树中必须是唯一的。
在清单 11-10 中,ImageHero 类包装了一个在 SizedBox 小部件中显示图像的 Hero 小部件。标签被设置为图像的 URL。
class ImageHero extends StatelessWidget {
ImageHero({Key key, this.imageUrl, this.width, this.height})
: super(key: key);
final String imageUrl;
final double width;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: height,
child: Hero(
tag: imageUrl,
child: Image.network(imageUrl),
),
);
}
}
Listing 11-10Hero widget
清单 11-11 显示了显示图像列表的当前路线。ImageHero小部件被包装在一个GridTile小部件中。用ImageView小工具点击图像导航到新路线。
class ImagesPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Images'),
),
body: GridView.count(
crossAxisCount: 2,
children: List.generate(8, (int index) {
String imageUrl = 'https://picsum.photos/300?random&$index';
return GridTile(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (BuildContext context) {
return ImageView(imageUrl: imageUrl);
}),
);
},
child: ImageHero(
imageUrl: imageUrl,
width: 300,
height: 300,
),
),
);
}),
),
);
}
}
Listing 11-11Current route with ImageHero
清单 11-12 显示了ImageView小部件。它还有一个与所选图像标签相同的ImageHero小部件。这是动画工作所必需的。
class ImageView extends StatelessWidget {
ImageView({Key key, this.imageUrl}) : super(key: key);
final String imageUrl;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Image'),
),
body: Row(
children: <Widget>[
ImageHero(
width: 50,
height: 50,
imageUrl: imageUrl,
),
Expanded(
child: Text('Image Detail'),
),
],
),
);
}
}
Listing 11-12New route with ImageHero
11.9 使用常见转换
问题
你想有一个简单的方法来使用不同类型的Tween对象来制作动画。
解决办法
使用不同类型的过渡。
讨论
通常使用不同类型的Tween对象来为部件的不同方面制作动画。你可以使用AnimatedWidget或AnimatedBuilder类来处理Tween对象。Flutter SDK 提供了几个过渡小部件,使某些动画易于使用。
ScaleTransition微件动画显示微件的比例。要创建一个ScaleTransition对象,您需要提供一个Animation<double>对象作为标尺。alignment参数指定缩放坐标原点相对于框的对齐。清单 11-13 显示了一个ScaleTransition的例子。
class ScaleBox extends StatelessWidget {
ScaleBox({Key key, Animation<double> animation})
: _animation = CurveTween(curve: Curves.ease).animate(animation),
super(key: key);
final Animation<double> _animation;
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _animation,
alignment: Alignment.centerLeft,
child: Container(
height: 100,
decoration: BoxDecoration(color: Colors.red),
),
);
}
}
Listing 11-13Example of ScaleTransition
过渡小部件的另一个例子是制作不透明度动画的FadeTransition小部件。清单 11-14 显示了一个FadeTransition的例子。
class FadeBox extends StatelessWidget {
FadeBox({Key key, Animation<double> animation})
: _animation = CurveTween(curve: Curves.ease).animate(animation),
super(key: key);
final Animation<double> _animation;
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _animation,
child: Container(
height: 100,
decoration: BoxDecoration(color: Colors.red),
),
);
}
}
Listing 11-14Example of FadeTransition
11.10 创建物理模拟
问题
你想用物理模拟。
解决办法
使用physics库中的模拟。
讨论
animation库中的动画要么是线性的,要么是弯曲的。physics库提供物理模拟,包括弹簧、摩擦力和重力。Simulation class 是所有模拟的基类。模拟也随着时间而变化。对于一个时间点,方法x()返回位置,方法dx()返回速度,方法isDone()返回模拟是否完成。给定一个Simulation对象,你可以使用AnimationController类的animateWith()方法来驱动这个模拟动画。
SpringSimulation类代表一个附着在弹簧上的粒子的模拟。要创建一个SpringSimulation对象,你可以提供表 11-5 中列出的参数。
表 11-5
弹簧模拟参数
|名字
|
类型
|
描述
|
| --- | --- | --- |
| spring | SpringDescription | 对春天的描述。 |
| start | double | 开始距离。 |
| end | double | 终点距离。 |
| velocity | double | 初始速度。 |
| tolerance | Tolerance | 距离、持续时间和速度的差异幅度被视为相等。 |
要创建SpringDescription对象,您可以使用带有参数的SpringDescription()构造函数来指定质量、刚度和阻尼系数。SpringDescription.withDampingRatio()构造器用阻尼比代替阻尼系数。清单 11-15 显示了一个创建SpringSimulation对象的例子。
SpringSimulation _springSimulation = SpringSimulation(
SpringDescription.withDampingRatio(
mass: 1.0,
stiffness: 50,
ratio: 1.0,
),
0.0,
1.0,
1.0)
..tolerance = Tolerance(distance: 0.01, velocity: double.infinity);
Listing 11-15Spring simulation
使用弹簧模拟的一个更简单的方法是使用AnimationController类的fling()方法。该方法使用临界阻尼弹簧驱动动画。
代表一个遵循牛顿第二运动定律的粒子的模拟。表 11-6 显示了GravitySimulation构造器的参数。
表 11-6
重力模拟参数
|名字
|
类型
|
描述
|
| --- | --- | --- |
| acceleration | double | 粒子的加速度。 |
| distance | double | 初始距离。 |
| endDistance | double | 要进行模拟的结束距离。 |
| velocity | double | 初始速度。 |
在清单 11-16 中,SimulationController widget 使用一个模拟对象来驱动动画。
typedef BuilderFunc = Widget Function(BuildContext, Animation<double>);
class SimulationController extends StatefulWidget {
SimulationController({Key key, this.simulation, this.builder})
: super(key: key);
final Simulation simulation;
final BuilderFunc builder;
@override
_SimulationControllerState createState() => _SimulationControllerState();
}
class _SimulationControllerState extends State<SimulationController>
with SingleTickerProviderStateMixin {
AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
)..animateWith(widget.simulation);
}
@override
Widget build(BuildContext context) {
return widget.builder(context, controller.view);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
Listing 11-16Use simulation with animation
11.11 摘要
本章涵盖了与 Flutter 中的动画相关的配方。AnimationController类用于控制动画。Tween类的子类为不同类型的数据创建线性动画。AnimatedWidget和AnimatedBuilder是使用动画的有用小部件。在下一章,我们将讨论在 Flutter 中与本地平台的集成。