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'),
        ),
      ),
    );
  }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值