在这一节中,将介绍如何使用SwiftUI来实现UIKit中的UITabBarController,UINavigationController,以及UITableView。
UIKit的中导航是基于UIViewController容器,也就是UITabBarController,UINavigationController,由容器来管理多个UIViewController实例。这些UIViewController实例间的关系分为两种,其一是平级关系,也就是UITabBarController中的多个Tab;其二是父子关系,比如基于NavigationController的master,detail视图。
而SwiftUI中,所有呈现在界面上的皆是View实例,包括复杂导航的View容器也是一种View实例。
UITabBarController -> TabView
先来看一下取代UITabBarController的TabView。回忆一下UIKit时的Tab,通常UIWindow的rootViewController是一个UITabBarController,作为根导航容器,通过设置UITabBarController的viewControllers这个属性,来设置多个平级UIViewController。在SwiftUI中流程大致是相同的:
let contentView = ContentView()
window.rootViewController = UIHostingController(rootView: contentView)
根视图是这个ContentView,这个ContentView的实现中:
struct ContentView: View {
var body: some View {
TabView {
FlightBoard()
.tabItem ({
Image(systemName: "icloud.and.arrow.down").resizable()
Text("Arrivals")
})
FlightBoard()
.tabItem ({
Image(systemName: "icloud.and.arrow.up").resizable()
Text("Departures")
})
}
}
}
TabView便起到了导航的作用,TabView中可以保护多个View元素,每个View便是一个Tab。在上述代码中FlightBoard()
只是一个普通的View:
struct FlightBoard: View {
var body: some View {
Text("Hello World!")
}
}
通过设置View的tabItem来配置Tab的图片以及文字。相比UIKit中Tab实现方式,TabView显得更加直接,简单明了。
UINavigationController -> NavigationView
了解了TabView的基本用法后,NavigationView就比较容易上手了,代码如下:
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: FlightBoard(boardName: "Arrivals")) {
HStack {
Image(systemName: "icloud.and.arrow.down").resizable().frame(width: 30, height: 30)
Text("Arrivals")
}
}
NavigationLink(destination: FlightBoard(boardName: "Departures")) {
HStack {
Image(systemName: "icloud.and.arrow.up").resizable().frame(width: 30, height: 30)
Text("Departures")
}
}
}.navigationBarTitle(Text("Mountain Airport"))
}
}
}
稍微修改一下FlightBoard:
struct FlightBoard: View {
let boardName: String
var body: some View {
VStack {
Text(boardName)
}.navigationBarTitle(Text(boardName))
}
}
不同于TabView,NavigationView只允许保护一个View元素,通常是一个VStack类的容器,而Navigation的Title也是通过给该容器添加一个修饰符navigationBarTitle来实现。
UITableView -> List
在演示List之前,回想一下在UIKit中另一个可以滚动的View:UIScrollView,它在SwiftUI中为ScrollView,为了展示可以滚动的效果,来创造一下数据,修改FlightBoard:
struct FlightBoard: View {
let boardName: String
let flightData: [FlightInformation]
var body: some View {
VStack {
Text(boardName).font(.title)
ScrollView(showsIndicators: false) {
ForEach(flightData) { fl in
VStack {
Text("\(fl.airline) \(fl.number)")
Text("\(fl.flightStatus) at \(fl.currentTimeString)")
Text("At gate \(fl.gate)")
}
}
}
}.navigationBarTitle(Text(boardName))
}
}
上述代码中let flightData: [FlightInformation]
为上层视图传入的一个model数组,ScrollView的用法也简单明了,和一般的Stack相同,放入一组View数组即可,此处用了ForEach,看一下它的定义:
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable {
/// The collection of underlying identified data.
public var data: Data
/// A function that can be used to generate content on demand given
/// underlying data.
public var content: (Data.Element) -> Content
}
从定义可以看出,它的构造函数需要一个Data
,以及一个Block,Block很好理解,就是在遍历Data中的每个元素。而这个Data
是一个泛型,它需要实现RandomAccessCollection协议,这个协议要求可以通过角标的方式访问集合中的元素,Swift的Array类型也实现了该协议,可以把Data理解为一个数组。
extension Array : RandomAccessCollection, MutableCollection {...}
同时ForEach也要求每个元素都有一个ID属性,而这个ID需要是在当前列表中是唯一的。实现方式也很简单,只需要让FlightInformation,实现一个Identifiable协议:
extension FlightInformation : Identifiable { }
/// A class of types whose instances hold the value of an entity with stable identity.
@available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *)
public protocol Identifiable {
/// A type representing the stable identity of the entity associated with `self`.
associatedtype ID : Hashable
/// The stable identity of the entity associated with `self`.
var id: Self.ID { get }
}
看完了ScrollView,我们可以进入主题List,直接将上方代码的ScrollView换为List即可,对比UITableView,我们需要一个Cell,而Cell在SwiftUI就是一个普通的View:
struct FlightBoard: View {
let boardName: String
let flightData: [FlightInformation]
var body: some View {
VStack {
List(flightData) { fl in
FlightRow(flight: fl)
}
}.navigationBarTitle(Text(boardName), displayMode: NavigationBarItem.TitleDisplayMode.large)
}
}
struct FlightRow: View {
let flight: FlightInformation
var body: some View {
HStack {
Text("\(self.flight.airline) \(self.flight.number)")
.frame(width: 120, alignment: .leading)
Text(self.flight.otherAirport).frame(alignment: .leading)
Spacer()
Text(self.flight.flightStatus).frame(alignment: .trailing)
}
}
}
那么如何给这个列表中的每一项添加点击效果呢?点击一个cell跳转到一个detail页面,先添加一个Detail页面:
struct FlightBoardInformation: View {
let flight: FlightInformation
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("\(flight.airline) Flight \(flight.number)")
.font(.largeTitle)
Spacer()
}
Text("\(flight.direction == .arrival ? "From: " : "To: ") \(flight.otherAirport)")
Text(flight.flightStatus)
.foregroundColor(Color(flight.timelineColor))
Spacer()
}.font(.headline).padding(10)
}
}
跳转逻辑也很简单,只需要将cell的视图包裹在NavigationLink即可:
List(flightData) { fl in
NavigationLink(destination: FlightBoardInformation(flight: fl)) {
FlightRow(flight: fl)
}
}
Present
在UIKit中,可以调用UIViewController的present方法来开启一个modal形式的页面,SwiftUI中操作的均为View,实现方式会有所区别,通过一个布尔类型@State来控制显示和消失。改一下上面的List:
List(flightData) { fl in
FlightRow(flight: fl)
}
同时也更改一下cell的实现,presented页面是通过这个cell也就是这个button点击触发的,而这个sheet定义在View的一个extension中:
struct FlightRow: View {
let flight: FlightInformation
@State private var isPresented = false
var body: some View {
Button(action: {
self.isPresented.toggle()
}) {
HStack {
Text("\(self.flight.airline) \(self.flight.number)")
.frame(width: 120, alignment: .leading)
Text(self.flight.otherAirport).frame(alignment: .leading)
Spacer()
Text(self.flight.flightStatus).frame(alignment: .trailing)
}.sheet(isPresented: $isPresented, onDismiss: {
print("Modal dismissed. State now: \(self.isPresented)")
}) {
FlightBoardInformation(showModel: self.$isPresented, flight: self.flight)
}
}
}
}
在上述实现中,也把这个isPresented的引用传递了进去,传递进入的目的,是想要在弹出的页面中控制presented view的收起:
struct FlightBoardInformation: View {
@Binding var showModel: Bool
let flight: FlightInformation
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("\(flight.airline) Flight \(flight.number)")
.font(.largeTitle)
Spacer()
Button("Done") {
self.showModel = false
}
}
Text("\(flight.direction == .arrival ? "From: " : "To: ") \(flight.otherAirport)")
Text(flight.flightStatus)
.foregroundColor(Color(flight.timelineColor))
Spacer()
}.font(.headline).padding(10)
}
}
这种实现方式和React Native非常相似,页面的展示结果,和一个state变量进行了绑定,想要更新页面只需要修改一个变量即可。
Alert
看完了present,alert和它十分相似,alert的展示同样也是通过一个state来控制的,我们改一下FlightBoardInformation的实现:
struct FlightBoardInformation: View {
@Binding var showModel: Bool
let flight: FlightInformation
@State private var rebootAlert = false
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("\(flight.airline) Flight \(flight.number)")
.font(.largeTitle)
Spacer()
Button("Done") {
self.showModel = false
}
}
Text("\(flight.direction == .arrival ? "From: " : "To: ") \(flight.otherAirport)")
Text(flight.flightStatus)
.foregroundColor(Color(flight.timelineColor))
if flight.status == .cancelled {
Button("Reboot Flight") {
self.rebootAlert = true
}.alert(isPresented: $rebootAlert) { () -> Alert in
Alert(
title: Text("Contact Your Airline"),
message: Text("We cannot rebook this flight. Please contact the airline to reschedule this flight.")
)
}
}
Spacer()
}.font(.headline).padding(10)
}
}
上述代码中的button点击事件就是将rebootAlert设置为true。而弹出的alert默认会有一个button,点击它会将rebootAlert设置为false,从而关闭alert。