跳转至正文

给 SwiftUI 开发者的 Flutter 指南

学习在构建 Flutter 应用时运用 SwiftUI 开发经验。

想用 Flutter 编写移动应用的 SwiftUI 开发者应阅读本指南,说明如何将现有 SwiftUI 知识应用于 Flutter。

Flutter 是一个用于构建跨平台应用的框架。它使用 Dart 编程语言。如果要了解 Dart 编程与 Swift 编程之间的某些差异,请参阅 Swift 开发者学习 Dart 指南给 Swift 开发者的 Flutter 并发指南

在使用 Flutter 进行开发时,你的 SwiftUI 知识和经验非常宝贵。

Flutter 还针对在 iOS 和 macOS 上运行的应用行为进行了一系列调整。如果需要了解具体方法,请参阅 平台适配

你可以在当前文档随意浏览并查找最符合你需要的内容。当前指南嵌入了示例代码。你可以通过悬停或聚焦时出现的 "Open in DartPad" 按钮,在 DartPad 上打开并运行部分示例。

概览

#

你可以观看以下视频来进行了解。该视频概述了 Flutter 在 iOS 上的工作原理,以及如何使用 Flutter 构建 iOS 应用。

Watch on YouTube in a new tab: "Flutter for iOS developers"

Flutter 和 SwiftUI 的代码描述了 UI 的外观和行为。开发者将此类代码称为 声明式框架

View 与 Widget

#

SwiftUI 将 UI 组件表示为 view,通过 modifier 配置 view。

swift
Text("Hello, World!") // <-- This is a View
  .padding(10)        // <-- This is a modifier of that View

Flutter 将 UI 组件表示为 widget

view 与 widget 仅在需要变更前存在,称为 immutability(不可变性)。 SwiftUI 用 View modifier 表示 UI 组件属性; Flutter 则用 widget 同时表示 UI 组件及其属性。

dart
Padding(                         // <-- This is a Widget
  padding: EdgeInsets.all(10.0), // <-- So is this
  child: Text("Hello, World!"),  // <-- This, too
)));

组合布局时,SwiftUI 与 Flutter 都嵌套 UI 组件:SwiftUI 嵌套 View,Flutter 嵌套 Widget。

布局过程

#

SwiftUI 按以下过程布局 view:

  1. 父 view 向子 view 提议尺寸。

  2. 所有后续子 view:

    • 子 view 提议尺寸

    • 询问子 view 期望尺寸

  3. 每个父 view 按返回的尺寸渲染子 view。

Flutter 的过程略有不同:

  1. 父 widget 向子级传递约束,包括高度与宽度的最小值和最大值。

  2. 子 widget 会尝试决定自身的尺寸,并对它自己的子级列表重复相同的过程:

    • 告知其子级所受的约束。

    • 询问其子级希望的尺寸。

  3. 父级对子级进行布局。

    • 若请求的尺寸符合约束,父级就采用该尺寸。

    • 若请求的尺寸不符合约束,父级会限制高度、宽度或两者,使其符合约束。

Flutter 与 SwiftUI 不同在于父组件可覆盖子组件期望尺寸; widget 不能任意尺寸,也无法知晓或决定屏幕位置,由父组件决定。

要强制子 widget 以特定尺寸渲染,父级须设置紧约束;最小尺寸等于最大尺寸时为紧约束。

SwiftUI 中,view 可扩展到可用空间或限制为内容尺寸。 Flutter widget 行为类似。

但 Flutter 父 widget 可提供无界约束,最大值设为无穷。

dart
UnboundedBox(
  child: Container(
      width: double.infinity, height: double.infinity, color: red),
)

若子级扩展且有无界约束,Flutter 会返回溢出警告:

dart
UnconstrainedBox(
  child: Container(color: red, width: 4000, height: 50),
)
When parents pass unbounded constraints to children, and the children are expanding, then there is an overflow warning.

要了解 Flutter 中约束的工作原理,请参阅 理解布局约束

设计系统

#

Flutter 面向多平台,应用不必遵循特定设计系统。本指南使用 Material widget,但可采用多种设计系统:

  • 自定义 Material widget

  • 社区构建的 widget

  • 你自己的自定义 widget

  • 遵循 Apple 人机界面指南的 Cupertino widgets

Watch on YouTube in a new tab: "Flutter's cupertino library for iOS developers"

参考自定义设计系统的优秀应用请参阅 Wonderous

UI 基础

#

本节涵盖 Flutter UI 基础及与 SwiftUI 的对比,包括入门、静态文本、按钮、点击响应、列表与网格等。

入门

#

SwiftUI 中,用 App 启动应用。

swift
@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      HomePage()
    }
  }
}

另一常见做法将应用 body 放在符合 View 协议的 struct 中,如下:

swift
struct HomePage: View {
  var body: some View {
    Text("Hello, World!")
  }
}

启动 Flutter 应用时,将应用实例传给 runApp

dart
void main() {
  runApp(const MyApp());
}

App 是 widget,build 方法描述所代表的用户界面。通常以 WidgetApp 类(如 CupertinoApp)开始。

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

  @override
  Widget build(BuildContext context) {
    // Returns a CupertinoApp that, by default,
    // has the look and feel of an iOS app.
    return const CupertinoApp(home: HomePage());
  }
}

HomePage 中的 widget 可能以 Scaffold 开始,实现应用基本布局结构。

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(body: Center(child: Text('Hello, World!')));
  }
}

注意 Flutter 使用 Center。 SwiftUI 默认将 view 内容居中渲染,Flutter 并非总是如此;Scaffold 不会将 body 居中。要居中文本请用 Center 包裹,详见 核心 Widget 目录

添加按钮

#

SwiftUI 中,用 Button 结构体创建按钮。

swift
Button("Do something") {
  // this closure gets called when your
  // button is tapped
}

Flutter 中,用 CupertinoButton 类达到相同效果:

dart
CupertinoButton(
  onPressed: () {
    // This closure is called when your button is tapped.
  },
  const Text('Do something'),
),

Flutter 提供多种预定义样式按钮。 CupertinoButton 来自 Cupertino 库,其 widget 使用 Apple 设计系统。

水平对齐组件

#

SwiftUI 中,stack view 在布局中很重要,有两种结构:

  1. HStack 用于水平 stack view

  2. VStack 用于垂直 stack view

以下 SwiftUI view 在水平 stack 中添加地球图标与文本:

swift
HStack {
  Image(systemName: "globe")
  Text("Hello, world!")
}

Flutter 使用 Row 而非 HStack

dart
Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [Icon(CupertinoIcons.globe), Text('Hello, world!')],
),

RowchildrenList<Widget>mainAxisAlignment 控制额外空间中的子项位置,MainAxisAlignment.center 将子项放在主轴中心; Row 的主轴为水平轴。

垂直对齐组件

#

以下示例建立在上一节基础上。

SwiftUI 中,用 VStack 将组件垂直排列。

swift
VStack {
  Image(systemName: "globe")
  Text("Hello, world!")
}

Flutter 使用与上一示例相同的 Dart 代码,但将 Row 换为 Column

dart
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [Icon(CupertinoIcons.globe), Text('Hello, world!')],
),

显示列表视图

#

SwiftUI 中,用 List 显示项序列;要显示模型对象序列,须使用户能识别模型对象,对象需符合 Identifiable 协议。

swift
struct Person: Identifiable {
  var name: String
}

var persons = [
  Person(name: "Person 1"),
  Person(name: "Person 2"),
  Person(name: "Person 3"),
]

struct ListWithPersons: View {
  let persons: [Person]
  var body: some View {
    List {
      ForEach(persons) { person in
        Text(person.name)
      }
    }
  }
}

这与 Flutter 构建列表 widget 的方式类似; Flutter 不要求列表项可识别,你设置项数并为每项构建 widget。

dart
class Person {
  String name;
  Person(this.name);
}

final List<Person> items = [
  Person('Person 1'),
  Person('Person 2'),
  Person('Person 3'),
];

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.builder(
        itemCount: items.length,
        itemBuilder: (context, index) {
          return ListTile(title: Text(items[index].name));
        },
      ),
    );
  }
}

Flutter 列表有一些注意事项:

  • ListView widget 提供了一个 builder 方法,其作用类似于 SwiftUI List 结构体中的 ForEach

  • ListViewitemCount 参数用于设置 ListView 显示的项目数量。

  • itemBuilder 带有一个 index 参数,其取值范围在 0 到 itemCount 减 1 之间。

上面的示例为每个项目返回了一个 ListTile widget。 ListTile widget 包含 heightfont-size 等属性,这些属性有助于构建列表。不过,Flutter 允许你返回几乎任何能表示你数据的 widget。

显示网格

#

SwiftUI 中构建非条件网格时,使用 GridGridRow

swift
Grid {
  GridRow {
    Text("Row 1")
    Image(systemName: "square.and.arrow.down")
    Image(systemName: "square.and.arrow.up")
  }
  GridRow {
    Text("Row 2")
    Image(systemName: "square.and.arrow.down")
    Image(systemName: "square.and.arrow.up")
  }
}

Flutter 中用 GridView widget 显示网格,有多种构造函数,以下使用 .builder() 初始化:

dart
const widgets = <Widget>[
  Text('Row 1'),
  Icon(CupertinoIcons.arrow_down_square),
  Icon(CupertinoIcons.arrow_up_square),
  Text('Row 2'),
  Icon(CupertinoIcons.arrow_down_square),
  Icon(CupertinoIcons.arrow_up_square),
];

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          mainAxisExtent: 40,
        ),
        itemCount: widgets.length,
        itemBuilder: (context, index) => widgets[index],
      ),
    );
  }
}

SliverGridDelegateWithFixedCrossAxisCount delegate 决定了网格用来布局其组件的各种参数,其中包括决定每行显示项目数量的 crossAxisCount

SwiftUI 的 Grid 与 Flutter 的 GridView 的区别在于 Grid 需要 GridRowGridView 用 delegate 决定布局。

创建滚动视图

#

SwiftUI 中,用 ScrollView 创建自定义滚动组件,以下示例以可滚动方式显示一系列 PersonView

swift
ScrollView {
  VStack(alignment: .leading) {
    ForEach(persons) { person in
      PersonView(person: person)
    }
  }
}

FlutterSingleChildScrollView 创建滚动视图,以下 mockPerson 模拟 Person 实例创建 PersonView

dart
SingleChildScrollView(
  child: Column(
    children: mockPersons
        .map((person) => PersonView(person: person))
        .toList(),
  ),
),

响应式与自适应设计

#

SwiftUI 中,用 GeometryReader 创建相对 view 尺寸。

例如,你可以:

  • geometry.size.width 乘以某个因子来设置 width

  • GeometryReader 用作断点以更改应用设计。

还可用 horizontalSizeClass 查看 size class 为 .regular.compact

Flutter 中创建相对视图有两种方式:

要了解更多内容,请参阅 创建响应式与自适应应用

管理状态

#

SwiftUI 中,用 @State 属性包装器表示 SwiftUI view 的内部状态。

swift
struct ContentView: View {
  @State private var counter = 0;
  var body: some View {
    VStack{
      Button("+") { counter+=1 }
      Text(String(counter))
    }
  }}

SwiftUI 还有 ObservableObject 等更复杂状态管理选项。

Flutter 使用 StatefulWidget 管理局部状态。通过以下两个类来实现一个 stateful widget:

  • StatefulWidget 的一个子类

  • State 的一个子类

State 对象存储着 widget 的状态。要改变 widget 的状态,可在 State 子类中调用 setState(),以通知框架重绘该 widget。

以下示例展示了一个计数器应用的部分代码:

dart
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('$_counter'),
            TextButton(
              onPressed: () => setState(() {
                _counter++;
              }),
              child: const Text('+'),
            ),
          ],
        ),
      ),
    );
  }
}

要了解更多管理状态的方式,请参阅 状态管理

动画

#

UI 动画主要有两类。

  • 隐式:从当前值动画到新目标。

  • 显式:按需动画。

隐式动画

#

SwiftUI 与 Flutter 动画方式相似,都指定 durationcurve 等参数。

SwiftUI 中,用 animate() modifier 处理隐式动画。

swift
Button("Tap me!"){
   angle += 45
}
.rotationEffect(.degrees(angle))
.animation(.easeIn(duration: 1))

Flutter 有隐式动画 widget,简化常见 widget 动画,命名格式为 AnimatedFoo

例如旋转按钮用 AnimatedRotation,为 Transform.rotate widget 添加动画。

dart
AnimatedRotation(
  duration: const Duration(seconds: 1),
  turns: turns,
  curve: Curves.easeIn,
  TextButton(
    onPressed: () {
      setState(() {
        turns += .125;
      });
    },
    const Text('Tap me!'),
  ),
),

Flutter 可创建自定义隐式动画,用 TweenAnimationBuilder 组合新动画 widget。

显式动画

#

显式动画方面,SwiftUIwithAnimation()

Flutter 有显式动画 widget,命名如 FooTransition,例如 RotationTransition

Flutter 还可用 AnimatedWidgetAnimatedBuilder 创建自定义显式动画。

更多动画信息请参阅 动画概览

在屏幕上绘制

#

SwiftUI 中,用 CoreGraphics 在屏幕上绘制线条与形状。

Flutter 基于 Canvas 类提供 API,有两个辅助类:

  1. 需要一个 painter 的 CustomPaint

    dart
    CustomPaint(
      painter: SignaturePainter(_points),
      size: Size.infinite,
    ),
    
  2. 实现你的算法、负责将内容绘制到画布上的 CustomPainter

    dart
    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;
    }
    
#

本节说明应用页面间导航、push/pop 机制等。

#

开发者使用称为 navigation routes(导航路由)的不同页面来构建 iOS 与 macOS 应用。

SwiftUI 中,NavigationStack 表示该页面栈。

以下示例创建显示人员列表的应用,点击人员在新的导航链接中显示详情。

swift
NavigationStack(path: $path) {
  List {
    ForEach(persons) { person in
      NavigationLink(
        person.name,
        value: person
      )
    }
  }
  .navigationDestination(for: Person.self) { person in
    PersonView(person: person)
  }
}

若无复杂链接的小型 Flutter 应用,可用 Navigator 命名路由;定义路由后按名称调用。

  1. 在传给 runApp() 函数的类中为每条路由命名,以下示例使用 App

    dart
    // Defines the route name as a constant
    // so that it's reusable.
    const detailsPageRouteName = '/details';
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext context) {
        return CupertinoApp(
          home: const HomePage(),
          // The [routes] property defines the available named routes
          // and the widgets to build when navigating to those routes.
          routes: {detailsPageRouteName: (context) => const DetailsPage()},
        );
      }
    }
    

    下面的示例使用 mockPersons() 生成一个人员列表。点击某个人员时,会使用 pushNamed() 将该人员的详情页推入 Navigator

    dart
    ListView.builder(
      itemCount: mockPersons.length,
      itemBuilder: (context, index) {
        final person = mockPersons.elementAt(index);
        final age = '${person.age} years old';
        return ListTile(
          title: Text(person.name),
          subtitle: Text(age),
          trailing: const Icon(Icons.arrow_forward_ios),
          onTap: () {
            // When a [ListTile] that represents a person is
            // tapped, push the detailsPageRouteName route
            // to the Navigator and pass the person's instance
            // to the route.
            Navigator.of(
              context,
            ).pushNamed(detailsPageRouteName, arguments: person);
          },
        );
      },
    ),
    
  2. 定义用于显示每个人员详情的 DetailsPage widget。在 Flutter 中,导航到新路由时可以向 widget 传入参数,并使用 ModalRoute.of() 提取这些参数。

    dart
    class DetailsPage extends StatelessWidget {
      const DetailsPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        // Read the person instance from the arguments.
        final Person person = ModalRoute.of(context)?.settings.arguments as Person;
        // Extract the age.
        final age = '${person.age} years old';
        return Scaffold(
          // Display name and age.
          body: Column(children: [Text(person.name), Text(age)]),
        );
      }
    }
    

更高级导航需求可使用 go_router 等路由 package。

更多内容请参阅 导航与路由

手动返回

#

SwiftUI 中,用 dismiss 环境值返回上一屏。

swift
Button("Pop back") {
  dismiss()
}

Flutter 中,用 Navigator 类的 pop()

dart
TextButton(
  onPressed: () {
    // This code allows the
    // view to pop back to its presenter.
    Navigator.of(context).pop();
  },
  child: const Text('Pop back'),
),
#

SwiftUI 中,用 openURL 环境变量打开其他应用的 URL。

swift
@Environment(\.openURL) private var openUrl

// View code goes here

Button("Open website") {
  openUrl(
    URL(
      string: "https://google.com"
    )!
  )
}

Flutter 中,使用 url_launcher 插件。

dart
CupertinoButton(
  onPressed: () async {
    await launchUrl(Uri.parse('https://google.com'));
  },
  const Text('Open website'),
),

主题、样式与媒体

#

可轻松设置 Flutter 应用样式,包括主题切换、文本与 UI 组件设计等。

使用深色模式

#

SwiftUI 中,在 View 上调用 preferredColorScheme() 使用深色模式。

Flutter 中,可在应用级用 Apptheme 控制亮度模式。

dart
const CupertinoApp(
  theme: CupertinoThemeData(brightness: Brightness.dark),
  home: HomePage(),
);

设置文本样式

#

SwiftUI 中,用 modifier 设置文本样式,例如用 font() 修改 Text 字体。

swift
Text("Hello, world!")
  .font(.system(size: 30, weight: .heavy))
  .foregroundColor(.yellow)

Flutter 中,将 TextStyle 作为 Textstyle 参数。

dart
Text(
  'Hello, world!',
  style: TextStyle(
    fontSize: 30,
    fontWeight: FontWeight.bold,
    color: CupertinoColors.systemYellow,
  ),
),

设置按钮样式

#

SwiftUI 中,用 modifier 设置按钮样式。

swift
Button("Do something") {
  // Do something when the button is tapped.
}
.font(.system(size: 30, weight: .bold))
.background(Color.yellow)
.foregroundColor(Color.blue)

Flutter 中,设置子项样式或修改按钮属性。

在以下示例中:

  • CupertinoButtoncolor 设置其颜色。

  • Text widget 的 color 设置按钮文字颜色。

dart
child: CupertinoButton(
  color: CupertinoColors.systemYellow,
  onPressed: () {},
  child: const Text(
    'Do something',
    style: TextStyle(
      color: CupertinoColors.systemBlue,
      fontSize: 30,
      fontWeight: FontWeight.bold,
    ),
  ),
),

使用自定义字体

#

SwiftUI 中,两步使用自定义字体:将字体文件加入项目,再用 .font() modifier 应用到 UI 组件。

swift
Text("Hello")
  .font(
    Font.custom(
      "BungeeSpice-Regular",
      size: 40
    )
  )

Flutter 中,用 pubspec.yaml 管理平台无关的资源。添加自定义字体步骤:

  1. 在项目的根目录创建一个名为 fonts 的文件夹。此步骤可选,有助于组织你的字体。

  2. 将你的 .ttf.otf.ttc 字体文件放入 fonts 文件夹。

  3. 打开项目中的 pubspec.yaml 文件。

  4. 找到 flutter 部分。

  5. fonts 部分下添加你的自定义字体。

    yaml
    flutter:
      fonts:
        - family: BungeeSpice
          fonts:
            - asset: fonts/BungeeSpice-Regular.ttf
    

添加字体后,可如下使用:

dart
Text(
  'Cupertino',
  style: TextStyle(fontSize: 40, fontFamily: 'BungeeSpice'),
),

在应用中打包图片

#

SwiftUI 中,先将图像加入 Assets.xcassets,再用 Image view 显示。

Flutter 中添加图像的方式类似自定义字体。

  1. 在根目录添加一个 images 文件夹。

  2. pubspec.yaml 文件中添加该资源。

    yaml
    flutter:
      assets:
        - images/Blueberries.jpg
    

添加图像后,用 Image widget 的 .asset() 构造函数显示。该构造函数:

  1. 使用提供的路径实例化给定的图像。

  2. 从随应用捆绑的资源中读取该图像。

  3. 在屏幕上显示该图像。

完整示例请参阅 Image 文档。

在应用中打包视频

#

SwiftUI 中,两步捆绑本地视频:导入 AVKit,再实例化 VideoPlayer view。

Flutter 中,添加 video_player 插件,可从同一代码库在 Android、iOS 与 Web 上播放视频。

  1. 将该插件添加到你的应用,并将视频文件添加到项目中。

  2. pubspec.yaml 文件中添加该资源。

  3. 使用 VideoPlayerController 类加载并播放你的视频文件。

完整教程请参阅 video_player 示例