跳转至正文

面向 SwiftUI 开发者的 Flutter

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

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

Flutter is a framework for building cross-platform applications that uses the Dart programming language. To understand some differences between programming with Dart and programming with Swift, see Learning Dart as a Swift Developer and Flutter concurrency for Swift developers.

Your SwiftUI knowledge and experience are highly valuable when building with Flutter.

Flutter also makes a number of adaptations to app behavior when running on iOS and macOS. To learn how, see Platform adaptations.

This document can be used as a cookbook by jumping around and finding questions that are most relevant to your needs. This guide embeds sample code. By using the "Open in DartPad" button that appears on hover or focus, you can open and run some of the examples on DartPad.

概览

#

As an introduction, watch the following video. It outlines how Flutter works on iOS and how to use Flutter to build iOS apps.

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

Flutter and SwiftUI code describes how the UI looks and works. Developers call this type of code a declarative framework.

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. The parent view proposes a size to its child view.

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

  3. All subsequent child views:

    • propose a size to their child's view
    • ask that child what size it wants
  4. 所有后续子 view:

    • 子 view 提议尺寸
    • 询问子 view 期望尺寸
  5. Each parent view renders its child view at the returned size.

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

Flutter 的过程略有不同:

  1. The parent widget passes constraints down to its children. Constraints include minimum and maximum values for height and width.

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

  3. The child tries to decide its size. It repeats the same process with its own list of children:

    • It informs its child of the child's constraints.
    • It asks its child what size it wishes to be.
  4. 子 widget 尝试决定尺寸,对其子级重复相同过程:告知子级约束并询问期望尺寸。

  5. The parent lays out the child.

    • If the requested size fits in the constraints, the parent uses that size.
    • If the requested size doesn't fit in the constraints, the parent limits the height, width, or both to fit in its constraints.
  6. 父级布局子级:若请求尺寸符合约束则采用;否则限制高度、宽度或两者以符合约束。

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 约束,请参阅 Understanding constraints

设计系统

#

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

  • Custom Material widgets

  • 自定义 Material widget

  • Community built widgets

  • 社区构建的 widget

  • Your own custom widgets

  • 你自己的自定义 widget

  • Cupertino widgets that follow Apple's Human Interface Guidelines

  • 遵循 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 catalog

添加按钮

#

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 for horizontal stack views

  2. HStack 用于水平 stack

  3. VStack for vertical stack views

  4. VStack 用于垂直 stack

以下 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 列表有一些注意事项:

  • The ListView widget has a builder method. This works like the ForEach within SwiftUI's List struct.

  • The itemCount parameter of the ListView sets how many items the ListView displays.

  • The itemBuilder has an index parameter that will be between zero and one less than itemCount.

The previous example returned a ListTile widget for each item. The ListTile widget includes properties like height and font-size. These properties help build a list. However, Flutter allows you to return almost any widget that represents your data.

显示网格

#

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],
      ),
    );
  }
}

The SliverGridDelegateWithFixedCrossAxisCount delegate determines various parameters that the grid uses to lay out its components. This includes crossAxisCount that dictates the number of items displayed on each row.

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 尺寸。

例如,你可以:

  • Multiply geometry.size.width by some factor to set the width.

  • geometry.size.width 乘以某因子设置 width(宽度)。

  • Use GeometryReader as a breakpoint to change the design of your app.

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

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

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

  • Get the BoxConstraints object in the LayoutBuilder class.
  • Use the MediaQuery.of() in your build functions to get the size and orientation of your current app.

To learn more, check out Creating responsive and adaptive apps.

管理状态

#

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 manages local state using a StatefulWidget. Implement a stateful widget with the following two classes:

  • a subclass of StatefulWidget
  • a subclass of State

The State object stores the widget's state. To change a widget's state, call setState() from the State subclass to tell the framework to redraw the widget.

The following example shows a part of a counter app:

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('+'),
            ),
          ],
        ),
      ),
    );
  }
}

To learn more ways to manage state, check out State management.

动画

#

UI 动画主要有两类。

  • Implicit that animated from a current value to a new target.

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

  • Explicit that animates when asked.

  • 显式:按需动画。

隐式动画

#

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 创建自定义显式动画。

更多动画信息请参阅 Animations overview

在屏幕上绘制

#

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

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

  1. CustomPaint that requires a painter:

    dart
    CustomPaint(
      painter: SignaturePainter(_points),
      size: Size.infinite,
    ),
    
  2. CustomPainter that implements your algorithm to draw to the canvas.

    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. Name each route in the class passed to the runApp() function. The following example uses App:

  2. 在传给 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()},
        );
      }
    }
    

    The following sample generates a list of persons using mockPersons(). Tapping a person pushes the person's detail page to the Navigator using pushNamed().

    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);
          },
        );
      },
    ),
    
  3. Define the DetailsPage widget that displays the details of each person. In Flutter, you can pass arguments into the widget when navigating to the new route. Extract the arguments using ModalRoute.of():

  4. 定义显示每人详情的 DetailsPage 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 等路由包。

更多内容请参阅 Navigation and routing

手动返回

#

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 中,设置子项样式或修改按钮属性。

在以下示例中:

  • The color property of CupertinoButton sets its color.

  • CupertinoButtoncolor 设置其颜色。

  • The color property of the child Text widget sets the button text color.

  • 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. Create a folder called fonts in the project's root directory. This optional step helps to organize your fonts.

  2. 在项目根目录创建 fonts 文件夹(可选,便于组织字体)。

  3. Add your .ttf, .otf, or .ttc font file into the fonts folder.

  4. .ttf.otf.ttc 字体文件放入 fonts 文件夹。

  5. Open the pubspec.yaml file within the project.

  6. 打开项目中的 pubspec.yaml

  7. Find the flutter section.

  8. 找到 flutter 节。

  9. Add your custom font(s) under the fonts section.

  10. 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. Add an images folder to the root directory.

  2. 在根目录添加 images 文件夹。

  3. Add this asset to the pubspec.yaml file.

  4. pubspec.yaml 中添加该资源。

    yaml
    flutter:
      assets:
        - images/Blueberries.jpg
    

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

  1. Instantiates the given image using the provided path.

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

  3. Reads the image from the assets bundled with your app.

  4. 从与应用捆绑的资源读取图像。

  5. Displays the image on the screen.

  6. 在屏幕上显示图像。

完整示例请参阅 Image 文档。

在应用中打包视频

#

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

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

  1. Add the plugin to your app and add the video file to your project.

  2. 将插件加入应用并添加视频文件。

  3. Add the asset to your pubspec.yaml file.

  4. pubspec.yaml 中添加资源。

  5. Use the VideoPlayerController class to load and play your video file.

  6. VideoPlayerController 加载并播放视频。

完整 walkthrough 请参阅 video_player example