Flutter动画学习之实践

在上一篇文章Flutter动画学习之简介中了解了Animation、Curve、Controller、Tween在Flutter中动画中最主要的四个角色。本篇文章就开始实践。

官方学习文档

本篇文章Demo下载

基础匀速版

创建一个AnimationController,指定时间3秒。使用Tween指定范围100到300。通过controller.forward()启动动画,通过controller.reset()重置动画可重新再次启动动画。

///线性缩放大小
class ScaleAnimationDemo1 extends StatefulWidget {
  const ScaleAnimationDemo1({Key? key}) : super(key: key);

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

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationDemoState1 extends State<ScaleAnimationDemo1>
    with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);
    //没有指定Curve,过程是线性的,从100变到300
    animation = Tween(begin: 100.0, end: 300.0).animate(controller)
      ..addListener(() {
        setState(() {});
      });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(padding: EdgeInsets.only(top: 120)),
        TextButton(
          child: const Text(
            '线性缩放大小',
            style: TextStyle(color: Colors.blueAccent),
          ),
          onPressed: () => setState(() {
            //重置动画
            controller.reset();
            //启动动画(正向执行)
            controller.forward();
          }),
        ),
        Icon(Icons.access_alarm, size: animation.value)
      ],
    );
  }

  @override
  void dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

addListener()函数调用了setState(),所以每次动画生成一个新的数字时,当前帧被标记为脏(dirty),这会导致widget的build()方法再次被调用,而在build()中,Icon的size使用的是animation.value ,所以就会逐渐放大。值得注意的是动画完成时要释放控制器(调用dispose()方法)以防止内存泄漏。

Curve曲线版

上述例子由于没有指定Curve,所以放大的过程是线性的(匀速),下面指定一个Curve,来实现一个类似于弹簧效果的动画过程。

需要使用CurvedAnimation包装AnimationController和Curve生成一个新的动画对象。

///弹簧效果Curve缩放大小
class ScaleAnimationDemo2 extends StatefulWidget {
  const ScaleAnimationDemo2({Key? key}) : super(key: key);

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

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationDemoState2 extends State<ScaleAnimationDemo2>
    with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);
    //指定弹簧效果Curve
    animation = CurvedAnimation(parent: controller, curve: Curves.bounceOut);
    animation = Tween(begin: 100.0, end: 300.0).animate(animation)
      ..addListener(() {
        setState(() {});
      });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(padding: EdgeInsets.only(top: 120)),
        TextButton(
          child: const Text(
            '弹簧效果Curve缩放大小',
            style: TextStyle(color: Colors.blueAccent),
          ),
          onPressed: () => setState(() {
            //重置动画
            controller.reset();
            //启动动画(正向执行)
            controller.forward();
          }),
        ),
        Icon(Icons.access_alarm, size: animation.value)
      ],
    );
  }

  @override
  void dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

使用AnimatedWidget简化

可以发现更新UI都是通过addListener()和setState(),所有的动画都需要如此属实是重复性工作了。

AnimatedWidget类封装了调用setState()的细节,并允许将widget分离出来。利用 AnimatedWidget 创建一个可以重复使用运行动画的widget。

///AnimatedWidget类封装了调用setState()的细节,并允许将 widget 分离出来
class ScaleAnimationWidget extends AnimatedWidget {
  const ScaleAnimationWidget({Key? key, required Animation<double> animation})
      : super(key: key, listenable: animation);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Icon(Icons.access_alarm, size: animation.value);
  }
}

class ScaleAnimationDemo3 extends StatefulWidget {
  const ScaleAnimationDemo3({Key? key}) : super(key: key);

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

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationDemoState3 extends State<ScaleAnimationDemo3>
    with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);
    //指定弹簧效果Curve
    animation = CurvedAnimation(parent: controller, curve: Curves.bounceOut);
    animation = Tween(begin: 100.0, end: 300.0).animate(animation);
  }

  @override
  Widget build(BuildContext context) {
    return
      Column(
        children: [
          const Padding(padding: EdgeInsets.only(top: 120)),
          TextButton(
            child: const Text(
              '弹簧效果Curve缩放大小',
              style: TextStyle(color: Colors.blueAccent),
            ),
            onPressed: () => setState(() {
              //重置动画
              controller.reset();
              //启动动画(正向执行)
              controller.forward();
            }),
          ),
          ScaleAnimationWidget(animation: animation)
        ],
      );
  }

  @override
  void dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

Flutter API 中的 AnimatedWidget:PositionedTransition, RotationTransition, ScaleTransition, SizeTransition, SlideTransition等。

动画状态监听

通过Animation的addStatusListener()方法来添加动画状态改变监听器。
Flutter中,有四种动画状态,在AnimationStatus枚举类中定义。

/// The status of an animation.
enum AnimationStatus {
  /// The animation is stopped at the beginning.
  dismissed,

  /// The animation is running from beginning to end.
  forward,

  /// The animation is running backwards, from end to beginning.
  reverse,

  /// The animation is stopped at the end.
  completed,
}

在上述例子上,通过监听动画状态,当动画执行结束时反向执行动画,当动画恢复到初始状态时正向执行动画:

class ScaleAnimationDemo4 extends StatefulWidget {
  const ScaleAnimationDemo4({Key? key}) : super(key: key);

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

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationDemoState4 extends State<ScaleAnimationDemo4>
    with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);
    animation = Tween(begin: 100.0, end: 300.0).animate(controller)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          controller.forward();
        }
      });
  }

  @override
  Widget build(BuildContext context) {
    return
      Column(
        children: [
          const Padding(padding: EdgeInsets.only(top: 120)),
          TextButton(
            child: const Text(
              '缩放大小',
              style: TextStyle(color: Colors.blueAccent),
            ),
            onPressed: () => setState(() {
              //启动动画(正向执行)
              controller.forward();
            }),
          ),
          ScaleAnimationWidget(animation: animation)
        ],
      );
  }

  @override
  void dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

使用AnimatedBuilder重构

使用AnimatedWidget可以从动画中分离出 widget,而动画的渲染过程(即设置宽高)仍然在AnimatedWidget 中。
而AnimatedBuilder正是将渲染逻辑分离出来。AnimatedBuilder知道如何渲染过渡效果,但AnimatedBuilder不会渲染 widget,也不会控制动画对象。使用 AnimatedBuilder描述一个动画是其他 widget 构建方法的一部分。
AnimatedBuilder 作为渲染树的一个单独类。像 AnimatedWidget,AnimatedBuilder 自动监听动画对象提示,并在必要时在 widget 树中标出,所以这时不需要调用 addListener()。

class ScaleAnimationDemo5 extends StatefulWidget {
  const ScaleAnimationDemo5({Key? key}) : super(key: key);

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

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationDemoState5 extends State<ScaleAnimationDemo5>
    with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);
    animation = Tween(begin: 100.0, end: 300.0).animate(controller);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(padding: EdgeInsets.only(top: 120)),
        TextButton(
          child: const Text(
            '缩放大小',
            style: TextStyle(color: Colors.blueAccent),
          ),
          onPressed: () => setState(() {
            //重置动画
            controller.reset();
            //启动动画(正向执行)
            controller.forward();
          }),
        ),
        AnimatedBuilder(
          animation: animation,
          builder: (BuildContext ctx, child) {
            return Icon(Icons.access_alarm, size: animation.value);
          },
        )
      ],
    );
  }

  @override
  void dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

Flutter API 中 AnimatedBuilders:BottomSheet, ExpansionTile, PopupMenu, ProgressIndicator, RefreshIndicator, Scaffold, SnackBar, TabBar, TextField等。

复合补间动画

在同一个动画控制器中使用复合补间动画可以达到多个动画效果,每个补间动画控制一个动画的不同效果。
由于AnimatedWidget和AnimatedBuilder都只能读取单一的 Animation 对象,因此每一个动画效果都创建一个Tween对象并计算确切值Tween.evaluate()。

class ScaleAnimationDemo6 extends StatefulWidget {
  const ScaleAnimationDemo6({Key? key}) : super(key: key);

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

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationDemoState6 extends State<ScaleAnimationDemo6>
    with SingleTickerProviderStateMixin {
  late Tween<double> sizeTween;
  late Tween<double> opacityTween;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);
    sizeTween = Tween(begin: 100.0, end: 300.0);
    opacityTween = Tween(begin: 0.1, end: 1.0);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(padding: EdgeInsets.only(top: 120)),
        TextButton(
          child: const Text(
            '缩放大小同时透明度增加',
            style: TextStyle(color: Colors.blueAccent),
          ),
          onPressed: () => setState(() {
            //重置动画
            controller.reset();
            //启动动画(正向执行)
            controller.forward();
          }),
        ),
        AnimatedBuilder(
          animation: controller,
          builder: (BuildContext ctx, child) {
            return Opacity(
              opacity: opacityTween.evaluate(controller),
              child: Icon(Icons.access_alarm,
                  size: sizeTween.evaluate(controller)),
            );
          },
        )
      ],
    );
  }

  @override
  void dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

隐式动画

通过 Flutter 的 动画库,你可以为 UI 中的 widgets 添加动作并创造视觉效果。有些库包含各种各样可以帮你管理动画的 widget。这些 widgets 被统称为 隐式动画隐式动画 widget,其名字来源于它们所实现的 ImplicitlyAnimatedWidget 类。

使用隐式动画,可以通过设置一个目标值,驱动 widget 的属性进行动画变换;每当目标值发生变化时,属性会从旧值逐渐更新到新值。通过这种方式,隐式动画内部实现了动画控制,从而能够方便地使用隐式动画组件会管理动画效果,用户不需要再进行额外的处理。

推荐学习隐式动画教程

AnimatedOpacity

使用 AnimatedOpacity widget 对 opacity 属性进行动画。
AnimatedOpacity的构造方法如下

const AnimatedOpacity({
  Key? key,
  Widget? child,
  required double opacity,
  Curve curve = Curves.linear,
  required Duration duration,
  VoidCallback? onEnd,
  boolean alwaysIncludeSemantics = false,
}) 

对应的参数:

  • child:要控制透明度的子组件;
  • opacity:最终的透明度值,取值范围从 0.0(不可见)到 1.0(完全可见);
  • curve:动画曲线,默认是线性的Curves.linear,可以使用 Curves 来构建曲线效果;
  • duration:动画时长;
  • onEnd:动画结束回调方法;
  • alwaysIncludeSemantics:是否总是包含语义信息,默认是 false。这个主要是用于辅助访问的,如果是 true,则不管透明度是多少,都会显示语义信息(可以辅助朗读),这对于视障人员来说会更友好。
class FadeInDemo extends StatefulWidget {
  const FadeInDemo({Key? key}) : super(key: key);

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

class _FadeInDemoState extends State<FadeInDemo> {
  double opacity = 0.0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(padding: EdgeInsets.only(top: 120)),
        TextButton(
          child: const Text(
            '透明度变化',
            style: TextStyle(color: Colors.blueAccent),
          ),
          onPressed: () => setState(() {
            if (opacity == 0.0) {
              opacity = 1;
            } else {
              opacity = 0.0;
            }
          }),
        ),
        AnimatedOpacity(
          opacity: opacity,
          duration: const Duration(seconds: 2),
          child: const Icon(Icons.access_alarm, size: 200),
        )
      ],
    );
  }
}

AnimatedContainer

使用 AnimatedContainer widget 让多个不同类型(doubleColor)的属性(marginborderRadiuscolor)同时进行动画变换。

double randomBorderRadius() {
  return Random().nextDouble() * 64;
}

double randomMargin() {
  return Random().nextDouble() * 64;
}

Color randomColor() {
  return Color(0xFFFFFFFF & Random().nextInt(0xFFFFFFFF));
}

class AnimatedContainerDemo extends StatefulWidget {
  const AnimatedContainerDemo({Key? key}) : super(key: key);

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

class _AnimatedContainerDemoState extends State<AnimatedContainerDemo> {
  late Color color;
  late double borderRadius;
  late double margin;

  @override
  void initState() {
    super.initState();
    color = randomColor();
    borderRadius = randomBorderRadius();
    margin = randomMargin();
  }

  void change() {
    setState(() {
      color = randomColor();
      borderRadius = randomBorderRadius();
      margin = randomMargin();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(padding: EdgeInsets.only(top: 120)),
        TextButton(
            child: const Text(
              'change',
              style: TextStyle(color: Colors.blueAccent),
            ),
            onPressed: () => change()),
        SizedBox(
          width: 128,
          height: 128,
          child: AnimatedContainer(
            margin: EdgeInsets.all(margin),
            duration: const Duration(seconds: 2),
            decoration: BoxDecoration(
                color: color,
                borderRadius: BorderRadius.circular(borderRadius)),
            curve: Curves.easeInOutBack,
          ),
        )
      ],
    );
  }
}

Flutter API 中的 隐式动画:AnimatedAlign, AnimatedRotation, AnimatedScale, AnimatedPositioned, AnimatedSlide。

交织动画

交织动画是由一系列的小动画组成的动画。每个小动画可以是连续或间断的,也可以相互重叠。其关键点在于使用 Interval 给每个小动画设置一个时间间隔,以及为每个动画的设置一个取值范围 Tween,最后使用一个 AnimationController 控制总体的动画状态。

Interval 继承至 Curve 类,通过设置属性 begin 和 end 来确定这个小动画的运行范围。

class Interval extends Curve {
  /// 动画起始点
  final double begin;
  /// 动画结束点
  final double end;
  /// 动画缓动曲线
  final Curve curve;
}

下面看一个例子,实现一个柱状图增长的动画:

  1. 开始时高度从0增长到300像素,同时颜色由绿色渐变为红色;这个过程占据整个动画时间的60%。
  2. 高度增长到300后,开始沿X轴向右平移100像素;这个过程占用整个动画时间的40%。
class StaggerAnimationWidget extends StatelessWidget {
  StaggerAnimationWidget({Key? key, required this.controller})
      : super(key: key) {
    height = Tween(begin: 0.0, end: 300.0).animate(CurvedAnimation(
        parent: controller,
        curve: const Interval(
          0.0, 0.6, //间隔,前60%的动画时间
          curve: Curves.ease,
        )));
    padding = Tween(
            begin: const EdgeInsets.only(left: 0.0),
            end: const EdgeInsets.only(left: 100.0))
        .animate(CurvedAnimation(
            parent: controller,
            curve: const Interval(0.6, 1.0, //间隔,后40%的动画时间
                curve: Curves.ease)));
    color = ColorTween(begin: Colors.green, end: Colors.red).animate(
      CurvedAnimation(
        parent: controller,
        curve: const Interval(
          0.0, 0.6, //间隔,前60%的动画时间
          curve: Curves.ease,
        ),
      ),
    );
  }

  late final Animation<double> controller;
  late final Animation<double> height;
  late final Animation<EdgeInsets> padding;
  late final Animation<Color?> color;

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: controller,
        builder: (BuildContext context, child) {
          return Container(
              alignment: Alignment.bottomCenter,
              padding: padding.value,
              child: Container(
                color: color.value,
                width: 50,
                height: height.value,
              ));
        });
  }
}

class StaggerAnimationDemo extends StatefulWidget {
  const StaggerAnimationDemo({Key? key}) : super(key: key);

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

class _StaggerAnimationDemoState extends State<StaggerAnimationDemo>
    with TickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
  }

  void _playAnimation() async {
    try {
      //先正向执行动画
      await controller.forward().orCancel;
      //再反向执行动画
      await controller.reverse().orCancel;
    } on TickerCanceled {
      //捕获异常。可能发生在组件销毁时,计时器会被取消。
    }
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        children: [
          const Padding(padding: EdgeInsets.only(top: 120)),
          ElevatedButton(
            onPressed: () => _playAnimation(),
            child: const Text("start animation"),
          ),
          Container(
            width: 300.0,
            height: 300.0,
            decoration: BoxDecoration(
              color: Colors.black.withOpacity(0.1),
              border: Border.all(
                color: Colors.black.withOpacity(0.5),
              ),
            ),
            //调用我们定义的交错动画Widget
            child: StaggerAnimationWidget(controller: controller),
          ),
        ],
      ),
    );
  }
}

Hero(跨页面共享元素)动画

Hero 指的是在页面(路由)间飞跃的 widget。简单来说 Hero 动画就是在路由切换时,有一个共享的widget 可以在新旧路由间切换。由于共享的 widget 在新旧路由页面上的位置、外观可能有所差异,所以在路由切换时会从旧路逐渐过渡到新路由中的指定位置,这样就会产生一个 Hero 动画。

你可能经常遇到 hero 动画。比如,页面上显示的代售商品列表。选择一件商品后,应用会跳转至包含更多细节以及“购买”按钮的新页面。在 Flutter 中,图像从当前页面转到另一个页面称为 hero 动画,相同的动作有时也被称为 共享元素过渡。

class HeroAnimationRouteA extends StatelessWidget {
  const HeroAnimationRouteA({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.topCenter,
      child: Column(
        children: [
          const Padding(padding: EdgeInsets.only(top: 120)),
          InkWell(
            child: Hero(
              tag: "avatar",
              child: ClipOval(
                child: Image.asset(
                  "images/cat.jpeg",
                  width: 50,
                ),
              ),
            ),
            onTap: () {
              Navigator.push(
                  context,
                  MaterialPageRoute(
                      builder: (context) => const HeroAnimationRouteB()));
            },
          ),
        ],
      ),
    );
  }
}

class HeroAnimationRouteB extends StatelessWidget {
  const HeroAnimationRouteB({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Material(
          child: InkWell(
            child: Hero(
              tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
              child: Image.asset("images/cat.jpeg"),
            ),
            onTap: () {
              Navigator.pop(context);
            },
          ),
        ),
      ),
    );
  }
}

实现 Hero 动画只需要用Hero组件将要共享的 widget 包装起来,并提供一个相同的 tag 即可,中间的过渡帧都是 Flutter 框架自动完成的。必须要注意, 前后路由页的共享Hero的 tag 必须是相同的,Flutter 框架内部正是通过 tag 来确定新旧路由页widget的对应关系的。

页面转场动画

在不同路由(页面)之间进行切换的时候,许多设计语言,例如 Material 设计,都定义了一些标准行为。

Material组件库中提供了一个MaterialPageRoute组件,它可以使用和平台风格一致的路由切换动画,如在iOS上会左右滑动切换,而在Android上会上下滑动切换。如果在Android上也想使用左右切换风格,一个简单的作法是可以直接使用CupertinoPageRoute。

但有时自定义路由动画会让 app 看上去更加的独特。为了更好的完成这一点,PageRouteBuilder提供了一个Animation对象,能够通过结合Tween以及Curve对象来自定义路由转换动画。

PageRouteBuilder中跟页面转场动画相关的参数只要有3个,

  • pageBuilder:创建这个路由的内容
  • transitionsBuilder:创建路由转换器,也就是路由动画
  • transitionDuration:路由转换动画时长,默认是300毫秒

提示:transitionsBuilder 的 child 参数是通过 pageBuilder 方法来返回一个 transitionsBuilder widget,这个 pageBuilder 方法仅会在第一次构建路由的时候被调用。框架能够自动避免做额外的工作,因为整个过渡期间 child 保存了同一个实例。

下面看一个例子,使新页面从底部出来

class Page1 extends StatelessWidget {
  const Page1({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        Navigator.of(context).push(_createRoute());
      },
      child: const Text('Go!'),
    );
  }
}

Route _createRoute() {
  return PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const Page2(),
    transitionDuration: const Duration(seconds: 3),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      //页面从底部出来
      const begin = Offset(0.0, 1.0);
      const end = Offset.zero;
      const curve = Curves.ease;
      //结合两个 tween,请使用 chain()
      var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
      return SlideTransition(
        position: animation.drive(tween), //drive() 来创建一个新的 Animation<Offset>
        child: child,
      );
    },
  );
}

class Page2 extends StatelessWidget {
  const Page2({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: Colors.black26,
        child: const Center(
          child: Text('Page 2'),
        ),
      ),
    );
  }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,686评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,668评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,160评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,736评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,847评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,043评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,129评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,872评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,318评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,645评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,777评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,861评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,589评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,687评论 2 351

推荐阅读更多精彩内容

  • 邂逅FLutter 万物皆是Widget 一般缩进2个空格 文字居中 Widget Center() Materi...
    JackLeeVip阅读 3,139评论 0 4
  • Flutter的动画体系是怎么运作的,各组件之间的关联关系及原理什么,隐式动画、显式动画怎么区分,本文将会进行详细...
    whqfor阅读 1,983评论 0 6
  • 以下为对Flutter官网的学习总结,如果你想快速掌握知识点,或者想复习一下官网学习的内容,那么值得看看。转载请注...
    申国骏阅读 1,164评论 0 3
  • 设计良好的动画可以使UI感觉更加直观,有助于建立看起来丝滑、感觉起来优雅的APP,并且可以改善用户体验。Flutt...
    whqfor阅读 959评论 0 0
  • 1. 基本动画概念和相关类 Animation: flutter 动画库中的一个核心类,它生成指导动画的值; An...
    IAMCJ阅读 1,335评论 0 8