跳到主内容

致 UIKit 开发者的 Flutter 指南

了解在构建 Flutter 应用时如何应用 iOS 和 UIKit 开发知识。

拥有使用 UIKit 经验并希望使用 Flutter 编写移动应用的 iOS 开发者应当阅读本指南。它解释了如何将现有的 UIKit 知识应用到 Flutter 中。

Flutter 是一个使用 Dart 编程语言构建跨平台应用的框架。要了解使用 Dart 编程与使用 Swift 编程之间的一些差异,请查看 作为 Swift 开发者学习 Dart 以及 致 Swift 开发者的 Flutter 并发指南

你的 iOS 和 UIKit 知识与经验在进行 Flutter 开发时非常有价值。Flutter 在 iOS 上运行时也会对应用行为进行多项适配。要了解如何适配,请参阅 平台适配

请将此指南作为参考手册使用。你可以随意跳转并查找解决你最迫切需求的问题。

概述

#

作为入门,请观看以下视频。它概述了 Flutter 在 iOS 上的工作原理,以及如何使用 Flutter 构建 iOS 应用。

在主标签页中观看 YouTube 视频:"Flutter for iOS developers"

View vs. Widget

#

在 UIKit 中,你在 UI 中创建的大部分内容都是使用视图对象完成的,这些对象是 UIView 类的实例。它们可以作为其他 UIView 类的容器,从而构成你的布局。

在 Flutter 中,与 UIView 大致对应的概念是 Widget。Widget 并不完全等同于 iOS 的视图,但在你熟悉 Flutter 的工作原理时,可以将其视为“声明和构造 UI 的方式”。

然而,它们与 UIView 有一些区别。首先,Widget 具有不同的生命周期:它们是不可变的,仅在需要更改之前存在。每当 Widget 或其状态发生变化时,Flutter 框架都会创建一个新的 Widget 实例树。相比之下,UIKit 视图在更改时不会被重新创建,它是一个可变实体,绘制一次后除非使用 setNeedsDisplay() 使其失效,否则不会重新绘制。

此外,与 UIView 不同,Flutter 的 Widget 是轻量级的,部分原因是它们的不可变性。因为它们本身不是视图,也不直接绘制任何内容,而是对 UI 及其语义的描述,在底层被“充实”成真正的视图对象。

Flutter 包含了 Material Components 库。这些是实现了 Material Design 指南 的 Widget。Material Design 是一套灵活的设计系统,针对包括 iOS 在内的所有平台进行了优化

但 Flutter 足够灵活且具有表现力,可以实现任何设计语言。在 iOS 上,你可以使用 Cupertino widgets 库来生成看起来像 Apple iOS 设计语言 的界面。

更新 Widget

#

在 UIKit 中更新视图时,你会直接修改它们。在 Flutter 中,Widget 是不可变的,不能直接更新。相反,你必须操作 Widget 的状态(State)。

这就是有状态(Stateful)与无状态(Stateless)Widget 概念的来源。StatelessWidget 就像它的名字一样——是一个没有关联状态的 Widget。

当你描述的界面部分不依赖于 Widget 初始配置信息以外的任何内容时,StatelessWidget 非常有用。

例如,在 UIKit 中,这类似于放置一个以 logo 作为 imageUIImageView。如果 logo 在运行时不会改变,请在 Flutter 中使用 StatelessWidget

如果你想根据发起 HTTP 调用后接收到的数据动态更改 UI,请使用 StatefulWidget。HTTP 调用完成后,通知 Flutter 框架该 Widget 的 State 已更新,以便它可以更新 UI。

无状态和有状态 Widget 之间的重要区别在于,StatefulWidget 有一个 State 对象,该对象存储状态数据并在树重建过程中保留它,因此数据不会丢失。

如果你有疑问,请记住这条规则:如果一个 Widget 在 build 方法之外发生变化(例如由于运行时的用户交互),那么它就是有状态的。如果该 Widget 在构建后永远不会改变,那么它是无状态的。然而,即使一个 Widget 是有状态的,如果包含它的父 Widget 本身不对这些变化(或其他输入)做出反应,父 Widget 仍然可以是无状态的。

以下示例显示了如何使用 StatelessWidget。最常用的 StatelessWidgetText。如果你查看 Text 的实现,会发现它是 StatelessWidget 的子类。

dart
Text(
  'I like Flutter!',
  style: TextStyle(fontWeight: FontWeight.bold),
);

查看上面的代码,你可能会注意到 Text Widget 没有携带显式状态。它仅渲染其构造函数中传递的内容,此外别无他物。

但是,如果你想让 "I Like Flutter" 动态变化,例如在点击 FloatingActionButton 时,该怎么办?

为了实现这一点,将 Text Widget 包装在一个 StatefulWidget 中,并在用户点击按钮时更新它。

例如

dart

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  // Default placeholder text
  String textToShow = 'I Like Flutter';

  void _updateText() {
    setState(() {
      // Update the text
      textToShow = 'Flutter is Awesome!';
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: Center(child: Text(textToShow)),
      floatingActionButton: FloatingActionButton(
        onPressed: _updateText,
        tooltip: 'Update Text',
        child: const Icon(Icons.update),
      ),
    );
  }
}

Widget 布局

#

在 UIKit 中,你可能会使用 Storyboard 文件来组织视图并设置约束,或者在视图控制器中通过代码设置约束。在 Flutter 中,你通过组合 Widget 树在代码中声明布局。

以下示例展示了如何显示一个带填充的简单 Widget

dart
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('Sample App')),
    body: Center(
      child: CupertinoButton(
        onPressed: () {},
        padding: const EdgeInsets.only(left: 10, right: 10),
        child: const Text('Hello'),
      ),
    ),
  );
}

你可以为任何 Widget 添加内边距(Padding),这模仿了 iOS 中约束的功能。

你可以在 Widget 目录中查看 Flutter 提供的各种布局。

移除 Widget

#

在 UIKit 中,你在父视图上调用 addSubview() 或在子视图上调用 removeFromSuperview() 来动态添加或移除子视图。在 Flutter 中,由于 Widget 是不可变的,没有直接等同于 addSubview() 的操作。相反,你可以向父 Widget 传递一个返回 Widget 的函数,并通过一个布尔标记来控制该子 Widget 的创建。

以下示例展示了当用户点击 FloatingActionButton 时如何在两个 Widget 之间切换

dart
class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  // Default value for toggle.
  bool toggle = true;

  void _toggle() {
    setState(() {
      toggle = !toggle;
    });
  }

  Widget _getToggleChild() {
    if (toggle) {
      return const Text('Toggle One');
    }

    return CupertinoButton(onPressed: () {}, child: const Text('Toggle Two'));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: Center(child: _getToggleChild()),
      floatingActionButton: FloatingActionButton(
        onPressed: _toggle,
        tooltip: 'Update Text',
        child: const Icon(Icons.update),
      ),
    );
  }
}

动画

#

在 UIKit 中,你通过在视图上调用 animate(withDuration:animations:) 方法来创建动画。在 Flutter 中,使用动画库将 Widget 包装在动画 Widget 内部。

在 Flutter 中,使用 AnimationController,它是一个可以暂停、寻道、停止和反转动画的 Animation<double>。它需要一个 Ticker,用于在发生垂直同步(vsync)时发出信号,并在运行时每一帧产生一个 0 到 1 之间的线性插值。然后,你创建一个或多个 Animation 并将其附加到控制器上。

例如,您可以使用 `CurvedAnimation` 来实现沿着插值曲线的动画。从这个意义上说,控制器是动画进度的“主”源,而 `CurvedAnimation` 计算替换控制器默认线性运动的曲线。像组件一样,Flutter 中的动画也通过组合工作。

在构建 Widget 树时,你将 Animation 分配给 Widget 的动画属性,例如 FadeTransition 的不透明度,并通知控制器开始动画。

以下示例展示了如何编写一个 FadeTransition,当您按下 FloatingActionButton 时,该转换会将 Widget 淡入为徽标

dart
import 'package:flutter/material.dart';

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Fade Demo',
      home: MyFadeTest(title: 'Fade Demo'),
    );
  }
}

class MyFadeTest extends StatefulWidget {
  const MyFadeTest({super.key, required this.title});

  final String title;

  @override
  State<MyFadeTest> createState() => _MyFadeTest();
}

class _MyFadeTest extends State<MyFadeTest>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late CurvedAnimation curve;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );
    curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Center(
        child: FadeTransition(
          opacity: curve,
          child: const FlutterLogo(size: 100),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          controller.forward();
        },
        tooltip: 'Fade',
        child: const Icon(Icons.brush),
      ),
    );
  }
}

有关更多信息,请参阅动画与动作 Widgets动画教程动画概述

在屏幕上绘制

#

在 UIKit 中,你使用 CoreGraphics 在屏幕上绘制线条和形状。Flutter 有一套基于 Canvas 类的不同 API,并有两个辅助绘制的类:CustomPaintCustomPainter,后者负责实现绘制到画布的算法。

要了解如何在 Flutter 中实现签名绘制板,请查看 Collin 在 StackOverflow 上的回答。

dart
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: DemoApp()));

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

  @override
  Widget build(BuildContext context) => const Scaffold(body: Signature());
}

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

  @override
  State<Signature> createState() => SignatureState();
}

class SignatureState extends State<Signature> {
  List<Offset?> _points = <Offset?>[];
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (details) {
        setState(() {
          RenderBox? referenceBox = context.findRenderObject() as RenderBox;
          Offset localPosition = referenceBox.globalToLocal(
            details.globalPosition,
          );
          _points = List.from(_points)..add(localPosition);
        });
      },
      onPanEnd: (details) => _points.add(null),
      child: CustomPaint(
        painter: SignaturePainter(_points),
        size: Size.infinite,
      ),
    );
  }
}

class SignaturePainter extends CustomPainter {
  SignaturePainter(this.points);

  final List<Offset?> points;

  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()
      ..color = Colors.black
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5;
    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null) {
        canvas.drawLine(points[i]!, points[i + 1]!, paint);
      }
    }
  }

  @override
  bool shouldRepaint(SignaturePainter oldDelegate) =>
      oldDelegate.points != points;
}

Widget 透明度

#

在 UIKit 中,所有内容都有 .opacity.alpha 属性。在 Flutter 中,大多数情况下你需要将 Widget 包装在 Opacity Widget 中来实现这一点。

自定义 Widget

#

在 UIKit 中,你通常继承 UIView 或使用现有视图,重写并实现相关方法以实现所需行为。在 Flutter 中,通过组合更小的 Widget(而不是扩展它们)来构建自定义 Widget。

例如,如何构建一个在构造函数中接受标签的 CustomButton?通过组合带标签的 ElevatedButton 来创建 CustomButton,而不是通过扩展 ElevatedButton

dart
class CustomButton extends StatelessWidget {
  const CustomButton(this.label, {super.key});

  final String label;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(onPressed: () {}, child: Text(label));
  }
}

然后像使用任何其他 Flutter Widget 一样使用 CustomButton

dart
@override
Widget build(BuildContext context) {
  return const Center(child: CustomButton('Hello'));
}

管理依赖

#

在 iOS 中,你通过在 Podfile 中添加内容来使用 CocoaPods 管理依赖。Flutter 使用 Dart 的构建系统和 Pub 包管理器来处理依赖。工具会将原生 Android 和 iOS 包装应用的构建委托给各自的构建系统。

虽然 Flutter 项目的 iOS 文件夹中有一个 Podfile,但仅在添加平台特定集成所需的原生依赖时才使用它。通常情况下,使用 pubspec.yaml 声明 Flutter 中的外部依赖。寻找优质 Flutter 扩展包的好地方是 pub.dev

#

文档的本节讨论应用页面间的导航、Push 和 Pop 机制等。

#

在 UIKit 中,要在视图控制器之间跳转,可以使用管理视图控制器展示栈的 UINavigationController

Flutter 有类似的实现,使用 NavigatorRoutesRoute 是应用“屏幕”或“页面”的抽象,而 Navigator 是一个管理路由的 Widget。一个路由大致对应一个 UIViewController。导航器的工作方式与 iOS 的 UINavigationController 类似,它可以根据你想进入还是返回某个视图来执行 push()pop() 路由操作。

要在页面间导航,你有几个选择

  • 指定一个路由名称的 Map
  • 直接导航到某个路由。

以下示例构建了一个 Map

dart
void main() {
  runApp(
    CupertinoApp(
      home: const MyAppHome(), // becomes the route named '/'
      routes: <String, WidgetBuilder>{
        '/a': (context) => const MyPage(title: 'page A'),
        '/b': (context) => const MyPage(title: 'page B'),
        '/c': (context) => const MyPage(title: 'page C'),
      },
    ),
  );
}

通过将路由名称 pushNavigator 来导航到该路由。

dart
Navigator.of(context).pushNamed('/b');

Navigator 类处理 Flutter 中的路由,并用于从你推入栈中的路由获取返回结果。这通过 await 等待 push() 返回的 Future 来完成。

例如,要启动一个允许用户选择位置的 location 路由,你可以执行以下操作

dart
Object? coordinates = await Navigator.of(context).pushNamed('/location');

然后,在你的 location 路由内部,一旦用户选择了位置,就带着结果 pop() 出栈

dart
Navigator.of(context).pop({'lat': 43.821757, 'long': -79.226392});
#

在 UIKit 中,要将用户引导至另一个应用程序,你需要使用特定的 URL 方案。对于系统级应用,方案取决于应用本身。要在 Flutter 中实现此功能,请创建原生平台集成,或使用现有的插件,例如 url_launcher

手动返回(Pop)

#

从 Dart 代码中调用 SystemNavigator.pop() 会触发以下 iOS 代码

objc
UIViewController* viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
if ([viewController isKindOfClass:[UINavigationController class]]) {
  [((UINavigationController*)viewController) popViewControllerAnimated:NO];
}

如果这不能满足你的需求,你可以创建自己的 平台通道 来调用任意 iOS 代码。

处理本地化

#

与拥有 Localizable.strings 文件的 iOS 不同,Flutter 目前还没有专门处理字符串的系统。目前的最佳实践是在类中将文本声明为静态字段并从那里访问。例如

dart
class Strings {
  static const String welcomeMessage = 'Welcome To Flutter';
}

你可以按如下方式访问字符串

dart
Text(Strings.welcomeMessage);

默认情况下,Flutter 的字符串仅支持美式英语。如果你需要添加对其他语言的支持,请引入 flutter_localizations 包。你可能还需要添加 Dart 的 intl 包来使用 i10n 机制,例如日期/时间格式化。

yaml
dependencies:
  flutter_localizations:
    sdk: flutter
  intl: any # Use version of intl from flutter_localizations.

要使用 flutter_localizations 包,请在 app widget 上指定 localizationsDelegatessupportedLocales

dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      localizationsDelegates: <LocalizationsDelegate<dynamic>>[
        // Add app-specific localization delegate[s] here
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: <Locale>[
        Locale('en', 'US'), // English
        Locale('he', 'IL'), // Hebrew
        // ... other locales the app supports
      ],
    );
  }
}

Delegates(代理)包含实际的本地化值,而 supportedLocales 定义了应用支持的语言环境。上面的示例使用了 MaterialApp,因此它既有用于基础 Widget 本地化值的 GlobalWidgetsLocalizations,也有用于 Material Widget 本地化的 MaterialWidgetsLocalizations。如果你使用 WidgetsApp,则不需要后者。请注意,这两个代理包含“默认”值,但如果你希望自己应用的文本也能本地化,则需要为一个或多个代理提供你自己应用的本地化副本。

初始化时,WidgetsApp(或 MaterialApp)会根据你指定的代理为你创建一个 Localizations Widget。设备的当前语言环境始终可以从当前 context 的 Localizations Widget(以 Locale 对象的形式)或使用 Window.locale 访问。

要访问本地化资源,请使用 Localizations.of() 方法访问由给定代理提供的特定本地化类。使用 intl_translation 包将可翻译副本提取到 arb 文件进行翻译,并将其导回应用以配合 intl 使用。

有关 Flutter 国际化和本地化的更多详细信息,请参阅 国际化指南,其中包含使用和不使用 intl 包的示例代码。

ViewController

#

本节讨论 Flutter 中与 ViewController 对应的概念以及如何监听生命周期事件。

Flutter 中与 ViewController 对应的概念

#

在 UIKit 中,ViewController 代表用户界面的一部分,最常用于屏幕或部分。它们组合在一起构建复杂的用户界面,并帮助扩展应用的 UI。在 Flutter 中,这项工作由 Widget 承担。正如在导航章节中提到的,由于“万物皆 Widget”,Flutter 中的屏幕由 Widget 表示。使用 Navigator 在代表不同屏幕或页面、或同一数据的不同状态或渲染效果的不同 Route 之间移动。

监听生命周期事件

#

在 UIKit 中,你可以重写 ViewController 的方法来捕捉视图本身的生命周期方法,或在 AppDelegate 中注册生命周期回调。在 Flutter 中,你没有这两个概念,但可以通过挂载到 WidgetsBinding 观察者并监听 didChangeAppLifecycleState() 更改事件来监听生命周期事件。

可观察的生命周期事件有

inactive(非活动)

应用程序处于非活动状态且未接收

用户输入。此事件仅在 iOS 上有效,因为 Android 上没有对应的事件。

paused(已暂停)

应用程序当前对用户不可见,

不响应用户输入,但在后台运行。

resumed(已恢复)

应用程序可见并响应用户输入。

suspending(挂起中)

应用程序暂时挂起。

iOS 平台没有对应的事件。

有关这些状态含义的更多详情,请参阅 AppLifecycleState 文档

布局

#

本节讨论 Flutter 中的不同布局,以及它们与 UIKit 的比较。

显示列表视图

#

在 UIKit 中,你可能会在 UITableViewUICollectionView 中显示列表。在 Flutter 中,你可以使用 ListView 进行类似的实现。在 UIKit 中,这些视图有代理方法用于决定行数、每个索引路径的单元格以及单元格的大小。

由于 Flutter 的不可变 Widget 模式,你向 ListView 传递一个 Widget 列表,Flutter 会负责确保滚动快速且流畅。

dart
import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> _getListData() {
    final List<Widget> widgets = [];
    for (int i = 0; i < 100; i++) {
      widgets.add(
        Padding(padding: const EdgeInsets.all(10), child: Text('Row $i')),
      );
    }
    return widgets;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: ListView(children: _getListData()),
    );
  }
}

检测点击内容

#

在 UIKit 中,你实现代理方法 tableView:didSelectRowAtIndexPath:。在 Flutter 中,使用传入 Widget 提供的触摸处理。

dart
import 'dart:developer' as developer;
import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> _getListData() {
    List<Widget> widgets = [];
    for (int i = 0; i < 100; i++) {
      widgets.add(
        GestureDetector(
          onTap: () {
            developer.log('row tapped');
          },
          child: Padding(
            padding: const EdgeInsets.all(10),
            child: Text('Row $i'),
          ),
        ),
      );
    }
    return widgets;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: ListView(children: _getListData()),
    );
  }
}

动态更新 ListView

#

在 UIKit 中,你更新列表视图的数据,并使用 reloadData 方法通知表格或集合视图。

在 Flutter 中,如果你在 setState() 内部更新 Widget 列表,你会很快发现数据在视觉上没有变化。这是因为当 setState() 被调用时,Flutter 渲染引擎会查看 Widget 树以查看是否发生了变化。当它到达 ListView 时,会执行 == 检查,并判定两个 ListView 是相同的。由于没有发生变化,因此不需要更新。

为了简单地更新您的 `ListView`,在 `setState()` 内部创建一个新的 `List`,并将数据从旧列表复制到新列表。虽然这种方法很简单,但不建议用于大型数据集,如以下示例所示。

dart
import 'dart:developer' as developer;

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> widgets = <Widget>[];

  @override
  void initState() {
    super.initState();
    for (int i = 0; i < 100; i++) {
      widgets.add(getRow(i));
    }
  }

  Widget getRow(int i) {
    return GestureDetector(
      onTap: () {
        setState(() {
          widgets = List.from(widgets);
          widgets.add(getRow(widgets.length));
          developer.log('row $i');
        });
      },
      child: Padding(padding: const EdgeInsets.all(10), child: Text('Row $i')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: ListView(children: widgets),
    );
  }
}

构建列表的推荐、高效且有效的方法是使用 ListView.Builder。当你有一个动态列表或包含大量数据的列表时,此方法非常棒。

dart
import 'dart:developer' as developer;

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> widgets = [];

  @override
  void initState() {
    super.initState();
    for (int i = 0; i < 100; i++) {
      widgets.add(getRow(i));
    }
  }

  Widget getRow(int i) {
    return GestureDetector(
      onTap: () {
        setState(() {
          widgets.add(getRow(widgets.length));
          developer.log('row $i');
        });
      },
      child: Padding(padding: const EdgeInsets.all(10), child: Text('Row $i')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: ListView.builder(
        itemCount: widgets.length,
        itemBuilder: (context, position) {
          return getRow(position);
        },
      ),
    );
  }
}

不要创建普通的 ListView,而是创建一个 ListView.builder,它接收两个关键参数:列表的初始长度和一个 ItemBuilder 函数。

ItemBuilder 函数类似于 iOS 表格或集合视图中的 cellForItemAt 代理方法,因为它接收一个位置(index),并返回你希望在该位置渲染的单元格。

最后但最重要的一点是,请注意 onTap() 函数不再重新创建列表,而是向其执行 .add 操作。

创建滚动视图

#

在 UIKit 中,你将视图包装在 ScrollView 中,以便用户在需要时滚动内容。

在 Flutter 中,最简单的方法是使用 ListView Widget。它既充当 ScrollView 又充当 iOS 的 TableView,因为你可以以垂直格式布局 Widget。

dart
@override
Widget build(BuildContext context) {
  return ListView(
    children: const <Widget>[
      Text('Row One'),
      Text('Row Two'),
      Text('Row Three'),
      Text('Row Four'),
    ],
  );
}

有关如何在 Flutter 中布局 Widget 的更详细文档,请参阅 布局教程

手势检测和触摸事件处理

#

本节讨论如何在 Flutter 中检测手势和处理不同事件,以及它们与 UIKit 的比较。

添加点击监听器

#

在 UIKit 中,你为视图附加 GestureRecognizer 来处理点击事件。在 Flutter 中,有两种添加触摸监听器的方法

  1. 如果 Widget 支持事件检测,向其传递一个函数,并在该函数中处理事件。例如,ElevatedButton Widget 有一个 onPressed 参数
dart
@override
Widget build(BuildContext context) {
 return ElevatedButton(
   onPressed: () {
     developer.log('click');
   },
   child: const Text('Button'),
 );
}
  1. 如果 Widget 不支持事件检测,请将该 Widget 包装在 GestureDetector 中,并将函数传递给 onTap 参数。
dart
class SampleTapApp extends StatelessWidget {
 const SampleTapApp({super.key});

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: Center(
       child: GestureDetector(
         onTap: () {
           developer.log('tap');
         },
         child: const FlutterLogo(size: 200),
       ),
     ),
   );
 }
}

处理其他手势

#

使用 GestureDetector,你可以监听广泛的手势,例如

  • 点击(Tapping)

    onTapDown

    可能引起点击的指针已在特定位置接触

    屏幕。

    onTapUp

    触发点击的指针已停止接触

    屏幕。

    onTap

    点击已发生。

    onTapCancel

    此前触发 onTapDown 的指针

    不会引起点击。

  • 双击(Double tapping)

    onDoubleTap

    用户在极短时间内两次点击屏幕

    同一位置。

  • 长按(Long pressing)

    onLongPress

    指针在屏幕同一位置保持接触

    较长时间。

  • 垂直拖动(Vertical dragging)

    onVerticalDragStart

    指针已接触屏幕并可能开始

    垂直移动。

    onVerticalDragUpdate

    接触屏幕的指针

    在垂直方向上发生了进一步移动。

    onVerticalDragEnd

    此前接触屏幕并垂直移动的指针

    已不再接触屏幕,且在停止接触时正以特定速度移动。

  • 水平拖动(Horizontal dragging)

    onHorizontalDragStart

    指针已接触屏幕并可能开始

    水平移动。

    onHorizontalDragUpdate

    接触屏幕的指针

    在水平方向上发生了进一步移动。

    onHorizontalDragEnd

    此前接触屏幕并垂直移动的指针

    屏幕且水平移动的指针已不再接触屏幕。

以下示例展示了一个 GestureDetector,它在双击时旋转 Flutter 徽标

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

  @override
  State<SampleApp> createState() => _SampleAppState();
}

class _SampleAppState extends State<SampleApp>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late CurvedAnimation curve;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2000),
    );
    curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: GestureDetector(
          onDoubleTap: () {
            if (controller.isCompleted) {
              controller.reverse();
            } else {
              controller.forward();
            }
          },
          child: RotationTransition(
            turns: curve,
            child: const FlutterLogo(size: 200),
          ),
        ),
      ),
    );
  }
}

主题、样式和媒体

#

Flutter 应用易于设置样式;你可以在浅色和深色主题之间切换、更改文本和 UI 组件的样式等。本节涵盖了设置 Flutter 应用样式的各个方面,并比较了在 UIKit 中如何实现同样的效果。

使用主题

#

Flutter 开箱即用地提供了精美的 Material Design 实现,它涵盖了你通常需要进行的大量样式和主题设置需求。

为了在应用中充分利用 Material Components,请声明一个顶层 Widget MaterialApp 作为应用的入口。MaterialApp 是一个便利的 Widget,它包装了实现 Material Design 的应用通常需要的许多 Widget。它在 WidgetsApp 的基础上增加了 Material 特有的功能。

但 Flutter 足够灵活且具有表现力,可以实现任何设计语言。在 iOS 上,你可以使用 Cupertino 库 来生成符合 人机交互指南 (Human Interface Guidelines) 的界面。有关这些 Widget 的完整集合,请参阅 Cupertino widgets 展示页。

您还可以使用 WidgetsApp 作为您的应用 Widget,它提供了一些相同的功能,但不如 MaterialApp 丰富。

要自定义任何子组件的颜色和样式,请向 MaterialApp Widget 传递一个 ThemeData 对象。例如,在下面的代码中,种子颜色方案被设置为 deepPurple,分割线颜色为 grey。

dart
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        dividerColor: Colors.grey,
      ),
      home: const SampleAppPage(),
    );
  }
}

使用自定义字体

#

在 UIKit 中,你将所有 ttf 字体文件导入项目并在 info.plist 文件中创建引用。在 Flutter 中,将字体文件放入文件夹并在 pubspec.yaml 文件中引用,类似于导入图片的方式。

yaml
fonts:
  - family: MyCustomFont
    fonts:
      - asset: fonts/MyCustomFont.ttf
      - style: italic

然后将字体分配给您的 Text Widget

dart
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('Sample App')),
    body: const Center(
      child: Text(
        'This is a custom font text',
        style: TextStyle(fontFamily: 'MyCustomFont'),
      ),
    ),
  );
}

文本样式

#

除了字体之外,您还可以在 Text Widget 上自定义其他样式元素。Text Widget 的 style 参数接受一个 TextStyle 对象,您可以在其中自定义许多参数,例如

  • color
  • decoration
  • decorationColor
  • decorationStyle
  • fontFamily
  • fontSize
  • fontStyle
  • fontWeight
  • hashCode
  • height
  • inherit
  • letterSpacing
  • textBaseline
  • wordSpacing

在应用中打包图片

#

虽然 iOS 将图片和资源(assets)视为不同的项目,但 Flutter 应用只有资源。在 iOS 上放置在 Images.xcasset 文件夹中的资源,在 Flutter 中被放置在 assets 文件夹中。与 iOS 一样,assets 可以是任何类型的文件,不仅仅是图片。例如,你可能在 my-assets 文件夹中有一个 JSON 文件

my-assets/data.json

pubspec.yaml 文件中声明资源

yaml
assets:
 - my-assets/data.json

然后在代码中使用 AssetBundle 访问它

dart
import 'dart:async' show Future;
import 'package:flutter/services.dart' show rootBundle;

Future<String> loadAsset() async {
  return await rootBundle.loadString('my-assets/data.json');
}

对于图片,Flutter 遵循类似于 iOS 的简单基于密度的格式。图片资源可以是 1.0x2.0x3.0x 或任何其他倍数。Flutter 的 devicePixelRatio 表示单个逻辑像素中物理像素的比例。

资源可以放置在任何文件夹中——Flutter 没有预定义的文件夹结构。你在 pubspec.yaml 文件中声明资源(带路径),Flutter 就会识别它们。

例如,要将名为 my_icon.png 的图片添加到 Flutter 项目,你可能决定将其存储在名为 images 的文件夹中。将基础图片 (1.0x) 放在 images 文件夹中,并将其他变体放在以相应比例倍数命名的子文件夹中

images/my_icon.png       // Base: 1.0x image
images/2.0x/my_icon.png  // 2.0x image
images/3.0x/my_icon.png  // 3.0x image

接下来,在 pubspec.yaml 文件中声明这些图片

yaml
assets:
 - images/my_icon.png

你现在可以使用 AssetImage 访问你的图片

dart
image: AssetImage('images/a_dot_burr.png'),

或者直接在 Image Widget 中使用

dart
@override
Widget build(BuildContext context) {
  return Image.asset('images/my_image.png');
}

更多详情,请参阅 在 Flutter 中添加资源和图片

表单输入

#

本节讨论如何在 Flutter 中使用表单,以及它们与 UIKit 的比较。

获取用户输入

#

鉴于 Flutter 使用带有独立状态的不可变 Widget,你可能会好奇用户输入是如何处理的。在 UIKit 中,当你准备提交用户输入或对其采取行动时,通常会向 Widget 查询其当前值。在 Flutter 中这是如何工作的?

在实践中,表单就像 Flutter 中的所有内容一样,由专门的 Widget 处理。如果你有一个 TextFieldTextFormField,你可以提供一个 TextEditingController 来获取用户输入

dart
class _MyFormState extends State<MyForm> {
  // Create a text controller and use it to retrieve the current value.
  // of the TextField!
  final myController = TextEditingController();

  @override
  void dispose() {
    // Clean up the controller when disposing of the Widget.
    myController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Retrieve Text Input')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: TextField(controller: myController),
      ),
      floatingActionButton: FloatingActionButton(
        // When the user presses the button, show an alert dialog with the
        // text the user has typed into our text field.
        onPressed: () {
          showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                // Retrieve the text the user has typed in using our
                // TextEditingController.
                content: Text(myController.text),
              );
            },
          );
        },
        tooltip: 'Show me the value!',
        child: const Icon(Icons.text_fields),
      ),
    );
  }
}

你可以在 获取文本框的值 中找到更多信息和完整代码列表。

文本框中的占位符

#

在 Flutter 中,你可以通过为 Text Widget 的 decoration 构造参数添加 InputDecoration 对象,轻松地为字段显示“提示”或占位符文本

dart
Center(
  child: TextField(decoration: InputDecoration(hintText: 'This is a hint')),
)

显示校验错误

#

就像添加“提示”一样,将 InputDecoration 对象传递给 Text Widget 的 decoration 构造函数。

但是,您不希望一开始就显示错误。相反,当用户输入无效数据时,更新状态,并传递一个新的 InputDecoration 对象。

dart
import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  String? _errorText;

  bool isEmail(String em) {
    String emailRegexp =
        r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|'
        r'(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|'
        r'(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$';

    RegExp regExp = RegExp(emailRegexp);

    return regExp.hasMatch(em);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: Center(
        child: TextField(
          onSubmitted: (text) {
            setState(() {
              if (!isEmail(text)) {
                _errorText = 'Error: This is not an email';
              } else {
                _errorText = null;
              }
            });
          },
          decoration: InputDecoration(
            hintText: 'This is a hint',
            errorText: _errorText,
          ),
        ),
      ),
    );
  }
}

线程与异步

#

本节讨论 Flutter 中的并发,以及它与 UIKit 的比较。

编写异步代码

#

Dart 采用单线程执行模型,支持 Isolate(在另一个线程上运行 Dart 代码的一种方式)、事件循环(event loop)和异步编程。除非你派生一个 Isolate,否则你的 Dart 代码将在主 UI 线程中运行,并由事件循环驱动。Flutter 的事件循环相当于 iOS 的主循环——即附加到主线程的 Looper

Dart 的单线程模型并不意味着你必须将所有内容作为导致 UI 冻结的阻塞操作来运行。相反,应使用 Dart 语言提供的异步设施(如 async/await)来执行异步工作。

例如,您可以使用 async/await 运行网络代码,而不会导致 UI 卡顿,让 Dart 完成繁重的工作

dart
Future<void> loadData() async {
  final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final http.Response response = await http.get(dataURL);
  setState(() {
    data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
  });
}

一旦 await 的网络调用完成,通过调用 setState() 更新 UI,这将触发 Widget 子树的重建并更新数据。

以下示例异步加载数据并将其显示在 ListView

dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const SampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Map<String, Object?>> data = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  Future<void> loadData() async {
    final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
    final http.Response response = await http.get(dataURL);
    setState(() {
      data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
    });
  }

  Widget getRow(int index) {
    return Padding(
      padding: const EdgeInsets.all(10),
      child: Text('Row ${data[index]['title']}'),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: ListView.builder(
        itemCount: data.length,
        itemBuilder: (context, index) {
          return getRow(index);
        },
      ),
    );
  }
}

请参阅下一节,了解有关在后台执行工作以及 Flutter 与 iOS 区别的更多信息。

移动到后台线程

#

由于 Flutter 是单线程并运行事件循环(类似于 Node.js),你无需担心线程管理或派生后台线程。如果你在执行 I/O 密集型工作(如磁盘访问或网络调用),只需放心地使用 async/await 即可。另一方面,如果你需要执行让 CPU 保持繁忙的计算密集型工作,你需要将其移动到 Isolate 以避免阻塞事件循环。

对于 I/O 密集型工作,将函数声明为 async 函数,并在函数内部 await 长时间运行的任务

dart
Future<void> loadData() async {
  final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final http.Response response = await http.get(dataURL);
  setState(() {
    data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
  });
}

这就是你通常进行网络或数据库调用的方式,这两者都是 I/O 操作。

但是,有时您可能会处理大量数据并且 UI 挂起。在 Flutter 中,使用 Isolate 来利用多个 CPU 核心来执行长时间运行或计算密集型任务。

Isolate 是独立的执行线程,不与主执行内存堆共享任何内存。这意味着你无法从主线程访问变量,也无法通过调用 setState() 更新 UI。Isolate 名副其实(隔离),无法共享内存(例如以静态字段的形式)。

以下示例以一个简单的 isolate 形式展示了如何将数据共享回主线程以更新 UI。

dart
Future<void> loadData() async {
  final ReceivePort receivePort = ReceivePort();
  await Isolate.spawn(dataLoader, receivePort.sendPort);

  // The 'echo' isolate sends its SendPort as the first message.
  final SendPort sendPort = await receivePort.first as SendPort;

  final List<Map<String, dynamic>> msg = await sendReceive(
    sendPort,
    'https://jsonplaceholder.typicode.com/posts',
  );

  setState(() {
    data = msg;
  });
}

// The entry point for the isolate.
static Future<void> dataLoader(SendPort sendPort) async {
  // Open the ReceivePort for incoming messages.
  final ReceivePort port = ReceivePort();

  // Notify any other isolates what port this isolate listens to.
  sendPort.send(port.sendPort);

  await for (final dynamic msg in port) {
    final String url = msg[0] as String;
    final SendPort replyTo = msg[1] as SendPort;

    final Uri dataURL = Uri.parse(url);
    final http.Response response = await http.get(dataURL);
    // Lots of JSON to parse
    replyTo.send(jsonDecode(response.body) as List<Map<String, dynamic>>);
  }
}

Future<List<Map<String, dynamic>>> sendReceive(SendPort port, String msg) {
  final ReceivePort response = ReceivePort();
  port.send(<dynamic>[msg, response.sendPort]);
  return response.first as Future<List<Map<String, dynamic>>>;
}

在这里,dataLoader() 是在独立执行线程中运行的 Isolate。在 isolate 中,你可以执行更密集的 CPU 处理(例如解析大型 JSON),或执行计算密集型数学运算,如加密或信号处理。

您可以在下面运行完整示例

dart
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const SampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Map<String, Object?>> data = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  bool get showLoadingDialog => data.isEmpty;

  Future<void> loadData() async {
    final ReceivePort receivePort = ReceivePort();
    await Isolate.spawn(dataLoader, receivePort.sendPort);

    // The 'echo' isolate sends its SendPort as the first message.
    final SendPort sendPort = await receivePort.first as SendPort;

    final List<Map<String, dynamic>> msg = await sendReceive(
      sendPort,
      'https://jsonplaceholder.typicode.com/posts',
    );

    setState(() {
      data = msg;
    });
  }

  // The entry point for the isolate.
  static Future<void> dataLoader(SendPort sendPort) async {
    // Open the ReceivePort for incoming messages.
    final ReceivePort port = ReceivePort();

    // Notify any other isolates what port this isolate listens to.
    sendPort.send(port.sendPort);

    await for (final dynamic msg in port) {
      final String url = msg[0] as String;
      final SendPort replyTo = msg[1] as SendPort;

      final Uri dataURL = Uri.parse(url);
      final http.Response response = await http.get(dataURL);
      // Lots of JSON to parse
      replyTo.send(jsonDecode(response.body) as List<Map<String, dynamic>>);
    }
  }

  Future<List<Map<String, dynamic>>> sendReceive(SendPort port, String msg) {
    final ReceivePort response = ReceivePort();
    port.send(<dynamic>[msg, response.sendPort]);
    return response.first as Future<List<Map<String, dynamic>>>;
  }

  Widget getBody() {
    bool showLoadingDialog = data.isEmpty;

    if (showLoadingDialog) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  Widget getProgressDialog() {
    return const Center(child: CircularProgressIndicator());
  }

  ListView getListView() {
    return ListView.builder(
      itemCount: data.length,
      itemBuilder: (context, position) {
        return getRow(position);
      },
    );
  }

  Widget getRow(int i) {
    return Padding(
      padding: const EdgeInsets.all(10),
      child: Text("Row ${data[i]["title"]}"),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: getBody(),
    );
  }
}

发起网络请求

#

使用流行的 http 在 Flutter 中发起网络请求非常简单。它抽象出了你通常可能需要自己实现的大量网络操作,使发起网络请求变得简单。

要将 http 包添加为依赖项,请运行 flutter pub add

flutter pub add http

要发起网络请求,请对异步函数 http.get() 调用 await

dart
Future<void> loadData() async {
  final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final http.Response response = await http.get(dataURL);
  setState(() {
    data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
  });
}

展示耗时任务的进度

#

在 UIKit 中,你在后台执行耗时任务时通常使用 UIProgressView

在 Flutter 中,使用 ProgressIndicator Widget。通过布尔标志控制何时渲染它来以编程方式显示进度。在长时间运行的任务开始之前通知 Flutter 更新其状态,并在任务结束后将其隐藏。

在下面的示例中,build 函数被拆分为三个不同的函数。如果 showLoadingDialogtrue(当 widgets.length == 0 时),则渲染 ProgressIndicator。否则,渲染带有网络调用返回数据的 ListView

dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const SampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Sample App', home: SampleAppPage());
  }
}

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

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Map<String, Object?>> data = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  bool get showLoadingDialog => data.isEmpty;

  Future<void> loadData() async {
    final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
    final http.Response response = await http.get(dataURL);
    setState(() {
      data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
    });
  }

  Widget getBody() {
    if (showLoadingDialog) {
      return getProgressDialog();
    }

    return getListView();
  }

  Widget getProgressDialog() {
    return const Center(child: CircularProgressIndicator());
  }

  ListView getListView() {
    return ListView.builder(
      itemCount: data.length,
      itemBuilder: (context, index) {
        return getRow(index);
      },
    );
  }

  Widget getRow(int i) {
    return Padding(
      padding: const EdgeInsets.all(10),
      child: Text("Row ${data[i]["title"]}"),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample App')),
      body: getBody(),
    );
  }
}