99d656abdfb1ec72ee69cb7dd5f2dd4f
SwiftUI 概览:十分钟构建简单应用

WWDC 2019 Session 216: SwiftUI Essentials
作者:JOJOTOV

[TOC]

概述

SwiftUI 作为 WWDC19 的重头戏,势必在未来一段时间内愈加火热。本文将从近年来同样流行的牛油果吐司出发,一步步利用 SwiftUI 构建出一个牛油果吐司预订应用,并在构建过程中为大家介绍 SwiftUI 的基础知识。

Views 和 Modifiers

从一个牛油果吐司开始

作为经常出现在各大轻食、西餐和网红店餐桌上的新一代“健康食品”,牛油果吐司可以说是 21 世纪后全球最流行的食物之一。这次的 Session 也将从这个如此流行的食物出发,使用 SwiftUI 一步步构建出一个牛油果吐司的预订平台。

首先,我们从一个简单的预订页面开始,它只是一些简单的视图拼凑在一起,可以选择吐司的口味和数量,并提交预订信息:

牛油果吐司预订页面

视图

视图定义了整体 UI 中的某一部分

在使用 SwiftUI 进行构建之前,我们先想一想在一个应用中,视图到底是什么?在广义下,视图定义了整个应用 UI 中的某一小部分。在 iOS 和 MacOS 系统中,我们使用 UIKit 中熟悉的 UIView 和 NSView 来表示视图。

UIView & NSView

在 SwiftUI 中,我们需要先抛开熟悉的 UIView 和 NSView,直接对我们所看到的 UI 进行抽象化理解。在这个牛油果吐司的预订页面中,我们可以把所有的视图看作一个不断嵌套的栈结构:

视图栈

而 SwiftUI 便是基于这种抽象结构来进行编写的。实现这个吐司预订页面的 SwiftUI 代码,基本上与栈的结构保持一致。

VStack {
    Text("Avocado Toast”).font(.title)
    Toggle(isOn: $order.includeSalt) {
        Text("Include Salt")
    }
    Toggle(isOn: $order.includeRedPepperFlakes) { 
        Text("Include Red Pepper Flakes")
    }
    Stepper(value: $order.quantity, in: 1...10) {
        Text("Quantity: \(order.quantity)")
    }
    Button(action: submitOrder) {
        Text("Order")
    }
 }

声明式编程

为了更好地理解 SwiftUI,我们得从理解声明式编程开始,这与我们普遍使用的命令式编程有所不同。维基百科它们 的描述如下:

声明式编程:是一种不使用控制流来表示计算逻辑的编程范式。

命令式编程:是一种通过语句来改变程序状态的编程范式。

利用命令式做牛油果吐司

在我们牛油果吐司的例子中,如果要用命令式的思想来做一个牛油果吐司,那么它的步骤会类似这样:

  1. 准备食材:牛油果、面包、牛油、盐、酱料
  2. 准备工具:面包机、刀、盘子
  3. 从面包上切下一片吐司
  4. 把吐司放到盘子里
  5. ……

这就是命令式的特点,通过一系列语句一步步让程序达到你所期望的状态,中间也可能会出现许多的判断语句,例如:如果客人喜欢不加盐的吐司,那需要省略加盐的步骤;客人如果不喜欢烤过的吐司,那需要省略烤吐司的步骤以及面包机工具;如果……

利用声明式做牛油果吐司

那么利用声明式做一个同样的牛油果吐司是怎样的呢?它更像现实中我们与餐厅工作人员的真实对话:

“我想要一个牛油果吐司,面包不要烤的,然后加点盐和酱料。”
“还有,面包要去皮。”
“要快一点,我赶时间,谢谢!“

声明式的 SwiftUI

VStack {
    Text("Avocado Toast”).font(.title)
    Toggle(isOn: $order.includeSalt) {
        Text("Include Salt")
    }
    Toggle(isOn: $order.includeRedPepperFlakes) { 
        Text("Include Red Pepper Flakes")
    }
    Stepper(value: $order.quantity, in: 1...10) {
        Text("Quantity: \(order.quantity)")
    }
    Button(action: submitOrder) {
        Text("Order")
    }
 }

回归到 SwiftUI 中,再看我们实现预订页面所编写的代码,是不是也看到了声明式编程的特点——我们告诉这个文本控件,它所显示的文本是什么,它的字体大小是多少;我们告诉这个切换控件,它的标题是什么,它所绑定的状态是哪个变量;我们告诉这个按钮,它绑定的事件是哪个闭包……

视图容器

在上面的代码中,我们对于 TextToggleButton 都很熟悉,但 VStack 却显得较为陌生。如果你使用过 python 的 Numpy 框架的话,你应该会对这个类型有所印象,在 Numpy 中它的作用是 把几个数组垂直排列成一个矩阵。在 SwiftUI 中,它代表的是一个内容垂直排列的视图容器:

container { 
    content
    content
    ...
}

如果查看 VStack 源代码的话,我们会留意到它是一个结构体,并有着如下的初始化方法:

public struct VStack<Content : View> : View { 
    public init(
        alignment: HorizontalAlignment = .center, 
          spacing: Length? = nil,
          @ViewBuilder content: () -> Content
      ) 
}

这个初始化方法接收传入的 alignmentspacing 参数来控制容器内视图的排列和间距,同时接收一个尾随闭包 @ViewBuilder content 来控制容器内的具体视图(关于 ViewBuilder 属性可以参考 苹果官方文档)。正是此类的初始化方法让我们可以极为优雅地利用 SwiftUI 构建出一个视图容器。

数据绑定

SwiftUI 中另一个非常便捷的功能是视图与数据的绑定。在我们的吐司预订页面中,ToggleStepper 控件都绑定了订单模型中的某个属性,而且它们绑定数据的方式都非常优雅——在初始化方法中传入 $ 声明的某个属性便可以让视图自动绑定此变量。

更多关于数据绑定的内容请参考 Session 226: Data Flow Through SwiftUI

Modifier

VStack {
    Text("Avocado Toast")
                .font(.title)
                .foregroundColor(.green)
    ...
}

除了 View 之外,Modifier 是 SwiftUI 另一个重要概念。在上面的代码中, .font(...).foregroundColor(...) 都修饰了 Text() 视图的某些属性——字体和颜色。每一个单独的 Modifier 并不会对 View 类型实例进行操作,而是一个返回 some View 类型的闭包。因此 Modifier 的运行机制与我们熟悉的 UIKit 中对视图属性进行修改的方式是相反的,我们构建出一个视图时并不会先初始化出一个 View 实例再对其进行修饰,而是通过声明的各种 Modifier 构建出 View 实例。

Modifier

Modifier 甚至可以对整个容器内的视图进行修饰,此类 Sharing Modifier 意在把容器内视图的相同属性抽取到容器外进行定义,从而减少大量的重复代码:

// Sharing Modifiers
VStack(alignment: .leading) {
        Toggle(isOn: $order.includeSalt) { ... }
        Stepper(value: $order.quantity, in: 1...10) { ... } 
        Button(action: submitOrder) { ... }
}
.opacity(0.5)

构建自定义视图

加入吐司订单历史页面

订单历史页面

当我们的吐司预订页面做好后,我们现在需要一个订单历史页面来查看所有的历史订单。显然,这是一个列表视图,而 SwiftUI 中构建列表的方式与基于 UIKit 的构建方式也有所不同:在 View 协议化后,我们不需要像以前一样通过继承 UIView 实现子类的方式构建自定义视图,而是使用更 Swift 的方式——定义一个遵循 View 协议的结构体——来构建出所需要的自定义视图。

 struct OrderHistory : View {
   let previousOrders: [CompletedOrder]

     var body: some View {
            List(previousOrders) { order in
         VStack(alignment: .leading) {
            Text(order.summary)
            Text(order.purchaseDate)
               .font(.subheadline)
               .foregroundColor(.secondary)
         }
      }
   }
}

这个简单的结构体仅包含两个变量:表示订单历史数据源的数组 previousOrders 和表示视图的 body。在 SwiftUI 中,要实现遵循 View 的自定义视图,必须实现协议中的 body 变量。关于更多关于 View 协议的内容,可以参考 苹果官方文档

// A piece of user interface
public protocol View {
   // The type of view representing the body of this view
   associatedtype Body : View
   // Declares the content and behavior of this view 
   var body: Body { get }
} 

通常来说,我们的列表视图不会像上面的列表一样那么简单,我们可能会对列表每个元素的 UI 进行更多的自定义,比如加入一些图标或者按钮。现在,我们需要根据每个吐司订单的特殊口味要求加入一些图标,让每个元素看起来更饱满一点。

订单历史页面

要实现这样的效果,我们首先需要把列表元素抽象到一个新的结构体 OrderCell 中,再根据数据源的不同属性来判断是否需要添加不同的图标:

struct OrderCell : View {
    var order: CompletedOrder

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(order.summary)
                Text(order.purchaseDate)
                    .font(.subheadline)
                      .foregroundColor(.secondary)
            }
        Spacer()
        if order.includeSalt {
            SaltIcon()
        }
        if order.includeRedPepperFlakes {
            RedPepperFlakesIcon()
        }
    }
}

此时,我们的列表视图代码显得更加简洁了:

struct OrderHistory : View {
    let previousOrders: [CompletedOrder]

    var body: some View {
        List(previousOrders) { order in
            OrderCell(order: order)
        }
    }
}

关于更多使用 SwiftUI 构建自定义视图的内容,请参考 Session 237: Building Custom Views with SwiftUI

视图的组合

现在,我们再回头看看订吐司的页面,现实当中根本不会有这么简陋的页面。为了提高这个页面的用户体验和视觉效果,我们可以用列表的样式对它进行优化。

吐司预订表单

试想一下,为了完成这个转变,如果基于 UIKit 开发,我们需要改动这个视图基本上所有的代码。而现在在 SwiftUI 中,只需要把视图的容器由 VStack 改为 List 便可以实现了(原 Session 中为 Form,但在 SwiftUI 文档中并没有找到 Form 相关的内容,因此暂时使用 List 代替)。

List {
    Section(header: Text("Avocado Toast").font(.title)){
        Toggle(isOn: $order.includeSalt) {
            Text("Include Salt")
        }
        Toggle(isOn: $order.includeRedPepperFlakes) {
            Text("Include Red Pepper Flakes")
        }

        Stepper(value: $order.quantity, in: 1...10) {
            Text("Quantity: \(order.quantity)")
        }
    }


    Section {
        Button(action: submitOrder) {
            Text("Order")
        }
    }
}

由此一来,我们的改动仅限于视图容器和容器内新增的两个 Section。但其带来的视觉上的改变却不仅仅只是容器的样式改变,容器内的所有元素都相应地发生了样式改变,比如文本、按钮等。

吐司预订表单

这种自适应的控件,是 SwiftUI 的一大特点,比如按钮、切换控件、选择控件等。SwiftUI 中的自适应控件更聚集于它们本身的目的,而非视觉,并且有着更高的可复用性。

按钮

首先我们来关注开发中最常见的控件之一——按钮。在众多的 iOS 应用中,按钮的样式成千上万,开发者在自定义按钮样式上也花了相当多的时间。SwiftUI 将按钮抽象为一个简单的数据结构,只需按钮绑定的事件 action 参数和按钮上的标签 label 参数便可以初始化出一个自适应的按钮:

public struct Button<Label : View> : View { 
     public init(
      action: @escaping () -> Void,
            @ViewBuilder label: () -> Label 
     )
}

更为重要的是,由于 label 参数其实是一个 View 协议,因此我们可以非常轻易地自定义按钮的样式,比如要实现一个图片文字垂直排列的按钮,基于 SwiftUI 的实现比基于 UIKit 的实现要优雅的多:

Button(action: submitOrder) {
    VStack {
        Image("Toast")
        Text("Order")
    }
}

图文并排的按钮

切换控件

与按钮或 UIKit 中的 UISwitch 不同,SwiftUI 中的换控件 Toggle 并没有绑定的事件。取而代之的是一个绑定值 isOn。在初始化 Toggle 时,我们可以直接传入一个绑定的布尔值变量,在此之后,开关的表现与此变量会一直绑定:

Toggle(isOn: $order.includeSalt) {
    Text("Include Salt")
}

选择控件

与切换控件类似,SwiftUI 中的选择控件 Picker 也提供了绑定值功能。在我们的牛油果吐司中,我们提供了不同面包种类供客人选择, 为此我们定义了一个枚举值来描述各种类型的面包:

enum Spread : CaseIterable, Hashable, Identifiable {
    case none
    case almondButter
    case peanutButter
    case honey
}

然后,我们的选择控件可以直接绑定此枚举类型的变量:

Picker(selection: $order.spread, label: Text("Spread")) {
    Text("None").tag(Spread.none)
    Text("Almond Butter").tag(Spread.almondButter)
    Text("Peanut Butter").tag(Spread.peanutButter)
    Text("Hone").tag(Spread.honey)
}

而当面包的种类越来越多时,枚举值也会越来越多,这时如果仍旧像上面一样针对每个枚举值初始化出一个 Text 对象的话,代码会变得难以维护。此时,结合 CaseIterable ,SwiftUI 的强大让我们可以用非常优雅的方式解决:

Picker(selection: $order.spread, label: Text("Spread")) {
    ForEach(Spread.AllCases) { spread in
        Text(spread.name).tag(spread)
    }
}

导航

接下来,我们想给这个牛油果吐司预订应用加一个更好玩的功能——提供客人选择是否加煎蛋,以及煎蛋位置的功能。我们希望用户确认添加煎蛋后,可以让用户进入一个选择煎蛋位置的页面,这需要一个类似导航控制器的功能。

预订页面与煎蛋选择页面

首先,我们需要使用一个 NavigationView 作为预订页面的容器,然后根据是否添加煎蛋来确定是否显示跳转选择煎蛋位置页面的按钮:

struct ContentView : View {
    var body: some View {
        NavigationView {
            OrderForm()
        }
    }
}

struct OrderForm : View {
    var body: some View {
        List {
            Toggle(isOn: $order.includeEgg.animation()) {
                Text("Include Egg")
            }
            if order.includeEgg {
                NavigationButton(destination: EggLocationPicker(eggLocation: $order.eggLocation)) {
                    Text("Egg Location")
                }
            }
        }
        .navigationBarTitle(Text("Avocado Toast"))
    }
}

接下来,我们还要把订单历史也加入这个导航控制器中,但这次我们想把订单历史页面放在与预订页面同等级的位置,类似于 UIKit 中的 UITabBarController

加入订单历史

在 SwiftUI 中实现这个功能也非常简便,我们只需要在 NavigationView 中加入一个 TabbedView,并把 OrderFormOrderHistory 放在同一个 TabbedView 之下便可以:

struct ContentView : View {
    var body: some View {
        NavigationView {
            TabbedView {
                OrderForm()
                    .tabItemLabel {
                        Image(systemName: "square.and.pencil")
                        Text("New Order")
                }
                OrderHistory()
                    .tabItemLabel {
                        Image(systemName: "clock.fill")
                        Text("History")
                }
            }
        }
    }
}

总结

至此,我们的牛油果吐司预订应用已经基本完成了。在构建整个应用期间,我们接触了 SwiftUI 的众多特性,例如 View 和 Modifier 等。相信大家也感受到了 SwiftUI 强大的视图描述能力和可复用能力。如此一个在语言层级上获得加持的响应式 UI 框架,再加上其跨平台的特性,SwiftUI 的未来显得一片光明。如果有兴趣关注更多 SwiftUI 的细节,可以关注 WWDC19 的以下 Session:

© 著作权归作者所有
这个作品真棒,我要支持一下!
一年一度的 WWDC 又来啦!今年国内三大 iOS 组织(排名不分先后): 老司机 iOS 周报 知识小集 Sw...
3条评论
花菜
#1

不错

这个吐司Button还是UIButton吗,应该就是传说中的直接用Metal渲染的吧,UIButton做不到这么高的灵活性。

demo 在哪呢

top Created with Sketch.