编写你的第一个 Flutter 网页应用

The web app that you'll be building

本教程可以帮助你你完成第一个 Flutter Web 应用,如果你熟悉面对对象、变量、循环以及条件判断等概念,就可以完成本教程,而无需要 Dart、移动开发和 Web 开发经验。

内容概览

你将实现一个只显示登录页面的简单 Web 应用,这个页面包含了三个文本输入框:名字、姓氏和用户名。当用户向输入框输入内容时,在登录区域顶部显示一个进度条动画效果。当用户完成输入时,绿色的进度条将会跟随着充满整个登录区域的顶部,而且 Sign up 按钮状态变成可点击,点击 Sign up 按钮从屏幕下方弹出一个欢迎页面。

右侧的动图展示了完成该教程后程序的运行效果。

第 0 步: 创建初始化 Web 应用

你将从我们为你提供的简单 Web 应用开始学习。

  1. Enable web development.
    启用 Web 开发。

    在命令行观察输出内容,你应该可以看到如下类似的内容,说明 Flutter 安装的没问题:

    $ flutter doctor
    Doctor summary (to see all details, run flutter doctor -v):
    [✓] Flutter (Channel master, 3.4.0-19.0.pre.254, on macOS 12.6 21G115
        darwin-arm64, locale en)
    [✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
    [✓] Xcode - develop for iOS and macOS (Xcode 14.0)
    [✓] Chrome - develop for the web
    [✓] Android Studio (version 2021.2)
    [✓] VS Code (version 1.71.1)
    [✓] Connected device (4 available)
    [✓] HTTP Host Availability
    
    • No issues found!
    

    如果你看到提示是 “flutter: command not found”,那么就需要确保 Flutter SDK 已经正确地安装,并且在环境变量中做好了配置。

    如上所示,显示我们缺少 Android 工具、Android Studio 和 Xcode,如果我们只用于 Web 开发,这些都不是必要的。后续如果你想用于移动端开发,你将需要安装配置这些工具。

  2. 查询设备列表。
    通过查询设备列表来验证已支持 Web 开发。你将看到如下的类似内容:

    $ flutter devices
    4 connected devices:
    
    sdk gphone64 arm64 (mobile) • emulator-5554                        •
    android-arm64  • Android 13 (API 33) (emulator)
    iPhone 14 Pro Max (mobile)  • 45A72BE1-2D4E-4202-9BB3-D6AE2601BEF8 • ios
    • com.apple.CoreSimulator.SimRuntime.iOS-16-0 (simulator)
    macOS (desktop)             • macos                                •
    darwin-arm64   • macOS 12.6 21G115 darwin-arm64
    Chrome (web)                • chrome                               •
    web-javascript • Google Chrome 105.0.5195.125
    
    

    Chrome 浏览器会自动启动并启用 Flutter 开发者工具。

  3. 运行程序将在 DartPad 中显示。
    import 'package:flutter/material.dart';
    
    void main() => runApp(const SignUpApp());
    
    class SignUpApp extends StatelessWidget {
      const SignUpApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          routes: {
            '/': (context) => const SignUpScreen(),
          },
        );
      }
    }
    
    class SignUpScreen extends StatelessWidget {
      const SignUpScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.grey[200],
          body: const Center(
            child: SizedBox(
              width: 400,
              child: Card(
                child: SignUpForm(),
              ),
            ),
          ),
        );
      }
    }
    
    class SignUpForm extends StatefulWidget {
      const SignUpForm({super.key});
    
      @override
      State<SignUpForm> createState() => _SignUpFormState();
    }
    
    class _SignUpFormState extends State<SignUpForm> {
      final _firstNameTextController = TextEditingController();
      final _lastNameTextController = TextEditingController();
      final _usernameTextController = TextEditingController();
    
      double _formProgress = 0;
    
      @override
      Widget build(BuildContext context) {
        return Form(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              LinearProgressIndicator(value: _formProgress),
              Text('Sign up', style: Theme.of(context).textTheme.headlineMedium),
              Padding(
                padding: const EdgeInsets.all(8),
                child: TextFormField(
                  controller: _firstNameTextController,
                  decoration: const InputDecoration(hintText: 'First name'),
                ),
              ),
              Padding(
                padding: const EdgeInsets.all(8),
                child: TextFormField(
                  controller: _lastNameTextController,
                  decoration: const InputDecoration(hintText: 'Last name'),
                ),
              ),
              Padding(
                padding: const EdgeInsets.all(8),
                child: TextFormField(
                  controller: _usernameTextController,
                  decoration: const InputDecoration(hintText: 'Username'),
                ),
              ),
              TextButton(
                style: ButtonStyle(
                  foregroundColor: MaterialStateProperty.resolveWith((states) {
                    return states.contains(MaterialState.disabled)
                        ? null
                        : Colors.white;
                  }),
                  backgroundColor: MaterialStateProperty.resolveWith((states) {
                    return states.contains(MaterialState.disabled)
                        ? null
                        : Colors.blue;
                  }),
                ),
                onPressed: null,
                child: const Text('Sign up'),
              ),
            ],
          ),
        );
      }
    }
  4. 运行代码示例。
    点击 Run 按钮来运行示例代码。你就可以在文本框中输入内容,但是 Sign up 按钮是禁用状态的。

  5. 复制代码。
    点击代码区域右上角的复制图标复制 Dart 代码。

  6. 创建一个新的 Flutter 工程。
    使用 IDE、编辑器或者命令行,创建一个名称为 signin_example 的新项目,更多内容可以参考文档 Flutter 开发体验初探

  7. 使用上面我们复制的内容替换 lib/main.dart 文件的内容。

观察和分析

  • 完整的示例代码都位于 lib/main.dart 文件中。

  • 如果你了解 Java ,那 Dart 也会给你一种熟悉的感觉。

  • 应用程序的所有的 UI 的都是通过 Dart 构建的。你可以通过文档 声明式 UI 介绍 了解到更多的信息。

  • 应用的 UI 遵循 Material Design 的设计规范,这是一种在任何设备和平台都可以运行的可视化设计语言。而且你也有其他选择,Flutter 也提供了一款 iOS 设计风格的 Cupertino widget 库。当然你也可以创建自己的自定义 widget 库。

  • 在 Flutter 的世界,万物皆 Widget,甚至连应用本身都是 widget。应用的 UI 可以看作为 widget 树。

第 1 步:显示欢迎页面

SignUpForm 类是一个 Stateful widget。这代表着 widget 的存储信息可动态改变,例如用户输入,或者传递的数据。由于 widget 本身是不可变的(一旦创建不可修改),所有 Flutter 的状态信息存储在一种叫 State 的附加类中。在这个代码示例中,所有的编辑将在一个 _SignUpFormState 的私有类中实现。

首先,在 lib/main.dart 文件中,在 SignUpScreen 类后面添加下面 WelcomeScreen widget 的定义类:

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          'Welcome!',
          style: Theme.of(context).textTheme.displayMedium,
        ),
      ),
    );
  }
}

接下来,你需要创建一个显示方法,然后使用按钮通过方法控制页面的显示。

  1. 找到 _SignUpFormState 类的 build() 方法。这部分代码是用来构建注册按钮的。注意,按钮是如何定义:它是一个背景为蓝色, Sign up 文本为白色的 TextButton 按钮,当我们点击它时,并未执行任何操作。

  2. 修改按钮的 onPressed 属性。
    将按钮的 onPressed 属性改为调用显示欢迎页面的方法(该方法在下一步创建)。

    onPressed: null 改为以下内容:

    onPressed: _showWelcomeScreen,
  3. 新增 _showWelcomeScreen 方法。
    修复上述代码导致的编译器提示错误: _showWelcomeScreen is not defined. (未定义 _showWelcomeScreen)。在 build() 方法上方添加下面的方法:

    void _showWelcomeScreen() {
      Navigator.of(context).pushNamed('/welcome');
    }
  4. 添加 /welcome 页面路由。
    为新的页面添加跳转路由。在 SignUpApp 类的 build() 方法中,在 '/' 下面添加如下路由:

    '/welcome': (context) => const WelcomeScreen(),
  5. 运行该应用程序。
    Sign up 按钮现在应该可以点击了。单击注册按钮跳转到欢迎页面。注意,欢迎页面显示是有一个从底部弹出的动画。你可以很简单的实现它。

观察和分析

  • _showWelcomeScreen() 函数被当成回调函数在 build() 方法中被调用。在 Dart 中你会经常使用回调函数,在这里意味着“点击按钮时调用该方法”。

  • 构造函数前面的 const 关键字至关重要,当 Flutter 遇到一个静态 widget 时,它就会缩短引擎下的大部分重建工作,从而提高渲染效率。

  • Flutter 中仅存在一个 Navigator 对象。这个 widget 用来管理 Flutter 堆栈中的页面(也可以被称为路由 (routes) 或者页面管理器 (pages))。当前显示的页面是堆栈中最上面的页面,通过往堆栈中 push 新的页面来切换新的页面。这也是 _showWelcomeScreen 函数向 Navigator 堆栈中添加 WelcomeScreen 页面的原因。用户点击按钮,然后出现欢迎页面。同样,可以通过调用 Navigatorpop() 方法来返回上一个页面。因为 Flutter 的 navigation 已经集成到浏览器的导航中,所以当点击浏览器的返回箭头也会返回到上一个页面。

第 2 步:实现输入进度监听

在这个页面有三个文本框。下一步,我们将实现监听用户输入表单的进度,并且在表单完成后更新应用的 UI 。

  1. 添加一个用于更新进度 _formProgress 属性的方法。在 _SignUpFormState 类,添加一个名为 _updateFormProgress() 的新方法:

    void _updateFormProgress() {
      var progress = 0.0;
      final controllers = [
        _firstNameTextController,
        _lastNameTextController,
        _usernameTextController
      ];
    
      for (final controller in controllers) {
        if (controller.value.text.isNotEmpty) {
          progress += 1 / controllers.length;
        }
      }
    
      setState(() {
        _formProgress = progress;
      });
    }

    这个方法根据非空输入框的数量来更新 _formProgress 属性。

  2. 表单改变时调用 _updateFormProgress 方法。
    _SignUpFormState 类的 build() 方法中,为 Form widget 的 onChanged 参数添加回调函数。注意注释为 NEW 的那行新添加的代码:

    return Form(
      onChanged: _updateFormProgress, // NEW
      child: Column(
  3. 再次更改按钮的 onPressed 属性。
    还记得我们在第一步中,我们通过修改 onPressed 属性实现了点击 Sign up 按钮跳转到欢迎页面吗?现在,将它改成只有完成表单输入时才可以点击按钮跳转到欢迎页面。

    TextButton(
      style: ButtonStyle(
        foregroundColor: MaterialStateProperty.resolveWith((states) {
          return states.contains(MaterialState.disabled)
              ? null
              : Colors.white;
        }),
        backgroundColor: MaterialStateProperty.resolveWith((states) {
          return states.contains(MaterialState.disabled)
              ? null
              : Colors.blue;
        }),
      ),
      onPressed:
          _formProgress == 1 ? _showWelcomeScreen : null, // UPDATED
      child: const Text('Sign up'),
    ),
  4. 运行应用。
    刚打开页面时 Sign up 按钮是禁用状态,当为三个字段输入内容(任意内容)时将会变成可点击状态。

观察和分析

  • 调用 widget 的 setState() 方法通知 Flutter 页面上的 widget 需要重新构建。框架将销毁之前的不可变 widget (上面说过 widget 一旦创建不可更改)(包含它的子级 widget),然后创建一个新的 widget (包含他的子级 widget 树)并将新的 widget 渲染到页面上。为了使应用运行顺畅, Flutter 需要快速的销毁和创建 widget。新创建的 widget 必须在不到 1/60 秒的时间渲染到页面上,才能创建一个流畅的动画效果。幸运的是 Flutter 就是这么快。当然如果你愿意的话,也可以使用文本编辑器。

  • progress 属性定义为浮点值,并在 _updateFormProgress 方法中更新。当三个输入框都被输入后, _formProgress 设置为 1.0 。当 _formProgress 设置为 1.0 后, onPressed 的回调函数将设置为 _showWelcomeScreen 方法。当 onPressed 参数变为非空时按钮将会变成可点击。所有的 TextButtononPressedonLongPress 回调为空时,默认也是无法点击的,与 Flutter 中其他 Material Design 的按钮一致。

  • 请注意, _updateFormProgress 是通过传递一个函数调用 setState() 。这种被称为匿名函数,语法如下所示:

    methodName(() {...});
    

    名为 methodName 的函数把匿名回调函数作为参数。

  • 最后一步显示欢迎页面的 Dart 语法如下所示:

    _formProgress == 1 ? _showWelcomeScreen : null

    Dart 三目运算语法如下: condition ? expression1 : expression2 。如果 _formProgress == 1 是正确的,则会取 : 左侧的值,在这个示例中会取 _showWelcomeScreen 方法。

第 2.5 步:启动 Dart 开发者工具

如何调试 Flutter Web 应用?所有的 Flutter 应用调试方法没有很大的区别。你应该使用 Dart DevTools!(不要和 Chrome 开发者工具搞混淆了)

虽然我们的应用现在没有 bug ,但是我们依然来验证一下。下面的指引讲明了 DevTools 使用的场景,如果你使用的是 IntelliJ 编辑器则会有更好的方式。可以通过查看文档末尾的提示信息获取更多的信息。

  1. 运行应用。
    如果应用未启动,启动应用。从下拉选项中选择 Chrome 设备然后使用 IDE 启动,或者在命令行中使用 flutter run -d chrome

  2. 获取开发者工具(DevTools)的 socket 信息。
    在命令行或者 IDE 中你应该可以看下如下所示内容的信息:

    Launching lib/main.dart on Chrome in debug mode...
    Building application for the web...                                11.7s
    Attempting to connect to browser instance..
    Debug service listening on ws://127.0.0.1:54998/pJqWWxNv92s=
    

    复制粗体显示的调试服务的地址,你可以用这个地址启动 DevTools 。

  3. 确认开发工具已被安装。
    你是否 已经安装 DevTools 了呢?如果你使用的是编辑器 (IDE) ,先确认已经用 VS CodeAndroid Studio and IntelliJ 文档描述的方式安装 Flutter 和 Dart 插件。如果你使用的是命令行的方式,用 DevTools command line 文档说明的方式启动开发者工具服务(DevTools server)。

  4. 连接到 DevTools。
    当 DevTools 启动时,你应该会看到如下类似的内容:

    Serving DevTools at http://127.0.0.1:9100
    

    在 Chrome 浏览器中打开上面 URL,你应该可以看到 DevTools 运行页面。如下所示:

    Screenshot of the DevTools launch screen

  5. 连接到运行的应用。
    Connect to a running site 下面粘贴你在上面第 2 步中复制的 ws 地址,然后点击连接。现在你应该可以看到 Dart DevTools 成功的运行在你的 Chrome 浏览器中,如下所示:

    Screenshot of DevTools running screen

    恭喜,你已经成功运行 Dart 开发者工具!

  1. 设置断点。
    现在你以前启动了开发者工具,在上面的蓝色工具栏中选择 Debugger 选项。在左下角出现调试面板,可以查看示例中使用的类库。选择 lib/main.dart 将在页面中间显示 Dart 代码。

    Screenshot of the DevTools debugger

  2. 设置断点。
    在 Dart 代码中,向下拉找到被修改的 progress,如下所示:

    for (final controller in controllers) {
      if (controller.value.text.isNotEmpty) {
        progress += 1 / controllers.length;
      }
    }

    在 for 循环行的行数前面单击设置断点。这个断点将显示在窗口左侧的 Breakpoints 栏中。

  3. 触发断点。
    在正在运行的应用中,点击任意一个输入框获取焦点。应用会遇到断点并暂停。在开发者工具页面,你可以在左侧看到 progress 的值是 0 。这是正常的,因为你没有输入任何内容,遍历 for 循环观察应用的运行。

  4. 恢复应用程序。
    在开发者工具窗口点击绿色的 Resume 按钮来恢复应用程序。

  5. 删除断点。
    再次点击断点来删除断点和恢复程序。

这里只是粗略的介绍开发者工具的使用方式,还有更多没有讲到。请参考 DevTools 文档 学习更多的内容。

第3步:为输入进度添加动画效果

是时候添加动画效果了!在最后一步,我们将在登录区域上方创建一个进度条动画,特效如下所述:

  • 刚启动时,登录区域的顶部显示一条红色的进度条。

  • 当一个文本框被键入内容时,进度条从红色变成橙色,并且进度条前进到距登录区域顶部 1/3 的位置。

  • 当第二个文本框被键入内容时,进度条从橙色变为黄色,并且进度条前进到距登录区域顶部 2/3 的位置。

  • 当三个文本框全部被输入内容时,进度条从橙色变成绿色,并且逐渐充满整个登录区域顶部。除此之外, Sign up 按钮的状态也变成可点击。

  1. 添加进度条动画效果 (AnimatedProgressIndicator)
    在文件的下面,添加下面的 widget:

    class AnimatedProgressIndicator extends StatefulWidget {
      final double value;
    
      const AnimatedProgressIndicator({
        super.key,
        required this.value,
      });
    
      @override
      State<AnimatedProgressIndicator> createState() {
        return _AnimatedProgressIndicatorState();
      }
    }
    
    class _AnimatedProgressIndicatorState extends State<AnimatedProgressIndicator>
        with SingleTickerProviderStateMixin {
      late AnimationController _controller;
      late Animation<Color?> _colorAnimation;
      late Animation<double> _curveAnimation;
    
      @override
      void initState() {
        super.initState();
        _controller = AnimationController(
          duration: const Duration(milliseconds: 1200),
          vsync: this,
        );
    
        final colorTween = TweenSequence([
          TweenSequenceItem(
            tween: ColorTween(begin: Colors.red, end: Colors.orange),
            weight: 1,
          ),
          TweenSequenceItem(
            tween: ColorTween(begin: Colors.orange, end: Colors.yellow),
            weight: 1,
          ),
          TweenSequenceItem(
            tween: ColorTween(begin: Colors.yellow, end: Colors.green),
            weight: 1,
          ),
        ]);
    
        _colorAnimation = _controller.drive(colorTween);
        _curveAnimation = _controller.drive(CurveTween(curve: Curves.easeIn));
      }
    
      @override
      void didUpdateWidget(oldWidget) {
        super.didUpdateWidget(oldWidget);
        _controller.animateTo(widget.value);
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(
          animation: _controller,
          builder: (context, child) => LinearProgressIndicator(
            value: _curveAnimation.value,
            valueColor: _colorAnimation,
            backgroundColor: _colorAnimation.value?.withOpacity(0.4),
          ),
        );
      }
    }

    didUpdateWidget 方法会在 AnimatedProgressIndicator 变化时更新 AnimatedProgressIndicatorState

  2. 使用新的进度条。
    然后,使用新的 AnimatedProgressIndicator widget 替换表单中的 LinearProgressIndicator widget,如下所示:

    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        AnimatedProgressIndicator(value: _formProgress), // NEW
        Text('Sign up', style: Theme.of(context).textTheme.headlineMedium),
        Padding(

    该 widget 使用 AnimatedBuilder 为最新值实现了进度的动画显示。

  3. 运行应用。
    在三个输入框中输入任意值来验证动画效果是否正常显示,然后点击 Sign up 按钮将弹出欢迎页面。

完整的示例

import 'package:flutter/material.dart';

void main() => runApp(const SignUpApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      routes: {
        '/': (context) => const SignUpScreen(),
        '/welcome': (context) => const WelcomeScreen(),
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[200],
      body: const Center(
        child: SizedBox(
          width: 400,
          child: Card(
            child: SignUpForm(),
          ),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          'Welcome!',
          style: Theme.of(context).textTheme.displayMedium,
        ),
      ),
    );
  }
}

class SignUpForm extends StatefulWidget {
  const SignUpForm({super.key});

  @override
  State<SignUpForm> createState() => _SignUpFormState();
}

class _SignUpFormState extends State<SignUpForm> {
  final _firstNameTextController = TextEditingController();
  final _lastNameTextController = TextEditingController();
  final _usernameTextController = TextEditingController();

  double _formProgress = 0;

  void _updateFormProgress() {
    var progress = 0.0;
    final controllers = [
      _firstNameTextController,
      _lastNameTextController,
      _usernameTextController
    ];

    for (final controller in controllers) {
      if (controller.value.text.isNotEmpty) {
        progress += 1 / controllers.length;
      }
    }

    setState(() {
      _formProgress = progress;
    });
  }

  void _showWelcomeScreen() {
    Navigator.of(context).pushNamed('/welcome');
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      onChanged: _updateFormProgress,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          AnimatedProgressIndicator(value: _formProgress),
          Text('Sign up', style: Theme.of(context).textTheme.headlineMedium),
          Padding(
            padding: const EdgeInsets.all(8),
            child: TextFormField(
              controller: _firstNameTextController,
              decoration: const InputDecoration(hintText: 'First name'),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: TextFormField(
              controller: _lastNameTextController,
              decoration: const InputDecoration(hintText: 'Last name'),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: TextFormField(
              controller: _usernameTextController,
              decoration: const InputDecoration(hintText: 'Username'),
            ),
          ),
          TextButton(
            style: ButtonStyle(
              foregroundColor: MaterialStateProperty.resolveWith((states) {
                return states.contains(MaterialState.disabled)
                    ? null
                    : Colors.white;
              }),
              backgroundColor: MaterialStateProperty.resolveWith((states) {
                return states.contains(MaterialState.disabled)
                    ? null
                    : Colors.blue;
              }),
            ),
            onPressed: _formProgress == 1 ? _showWelcomeScreen : null,
            child: const Text('Sign up'),
          ),
        ],
      ),
    );
  }
}

class AnimatedProgressIndicator extends StatefulWidget {
  final double value;

  const AnimatedProgressIndicator({
    super.key,
    required this.value,
  });

  @override
  State<AnimatedProgressIndicator> createState() {
    return _AnimatedProgressIndicatorState();
  }
}

class _AnimatedProgressIndicatorState extends State<AnimatedProgressIndicator>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Color?> _colorAnimation;
  late Animation<double> _curveAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 1200),
      vsync: this,
    );

    final colorTween = TweenSequence([
      TweenSequenceItem(
        tween: ColorTween(begin: Colors.red, end: Colors.orange),
        weight: 1,
      ),
      TweenSequenceItem(
        tween: ColorTween(begin: Colors.orange, end: Colors.yellow),
        weight: 1,
      ),
      TweenSequenceItem(
        tween: ColorTween(begin: Colors.yellow, end: Colors.green),
        weight: 1,
      ),
    ]);

    _colorAnimation = _controller.drive(colorTween);
    _curveAnimation = _controller.drive(CurveTween(curve: Curves.easeIn));
  }

  @override
  void didUpdateWidget(oldWidget) {
    super.didUpdateWidget(oldWidget);
    _controller.animateTo(widget.value);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) => LinearProgressIndicator(
        value: _curveAnimation.value,
        valueColor: _colorAnimation,
        backgroundColor: _colorAnimation.value?.withOpacity(0.4),
      ),
    );
  }
}

观察和分析

  • 你可以使用 AnimationController 控制任何动画效果。

  • Animation 的值改变时 AnimatedBuilder 将重新构建 widget 树。

  • 使用动画 Tween ,你还可以使用很多值,像这个示例中的 Color

下一步,我们该做什么?

恭喜!你已经使用 Flutter 创建了第一个 Web 应用!

如果你想继续完善这个示例,或许你可以添加表单验证。如何继续的建议,请参考 Flutter cookbook 中的 Building a form with validation

有关 Web 应用、Dart 开发者工具以及 Flutter 动画的更多信息,请参考下面文档: