SwiftUI一日速成

章侯林
2023-12-01

Embed 功能

SwiftUI 可以将一个或多个 View 外部再包裹一层 View 。在某个 SwiftUI View 上右键,Show Code Actions (快捷键 command+shift+左键),选择 Embed in HStack/VStack/ZStack/Group/Button/List(Xcode 13)。

VStack

背景色

修改背景色用 background 修饰符:

VStack {
 	...
}
.background(Color(red: 0xf2/255.0, green: 0xf2/255.0, blue: 0xf2/255.0))

Rectangle

或者用一个嵌入一个 Rectangle:

VStack {
    Rectangle()
        .fill(Color.green)
        .frame(width: 200, height: 200)
        .padding()
        .border(Color.red)
}

圆角

此外,记得圆角一定要放在背景色之后设置,否则无效

        .background(Color(red: 0xf2/255.0, green: 0xf2/255.0, blue: 0xf2/255.0))
        .cornerRadius(10)

描绘边框

通过 overlay 修饰符,可以在 View 上描绘(覆盖)一层几何图形,比如圆角矩形(RoundedRectangle)、矩形(Rectangle)、圆形(Circle)等。

Image(...)
  .overlay(RoundedRectangle(cornerSize: CGSize(width: 20,height: 20)).stroke(Color.gray,lineWidth: 4))

填充安全区

如果要让举行填充整个屏幕空间(包括安全区),可以:

Rectangle()
  .fill(Color.black.opacity(0.6))
  .edgesIgnoringSafeArea(.all)

分割线

Divider()
  .background(Color.purple) // 默认颜色为灰色
  .scaleEffect(CGSize(width: 1, height: 10)) // 高度放大10倍
  .padding(Edge.Set.init(arrayLiteral: .top, .bottom), 20) // 上下内边距设置20,

不能超过 10 个子 View 的限制

其实所有的容器(Container)都有这个限制,包括:

  • VStack
  • HStack
  • ZStack
  • Group
  • List

解决的办法就是在容器中嵌套容器,当然每一层仍然不能超过10个,比如在 VStack 中嵌套 10 个 Group,然后在每个 Group 中又嵌套 10 个 View。

布局

padding

SwiftUI 的布局基于 padding。左留白:

.padding(.leading, 10)

两边留白:

.padding(.horizontal) // 或者 .padding([.leading, .bottom]),或者 .padding(.vertical,11)

四边留白:

.padding(10.0) // 或者 .padding(),默认好像是 8?

offset 和 padding

设置图片的坐标向上偏移:

Image(...).offset(x: 0, y: -130).padding(.bottom, -130)

offset 偏移的是图片的内容,并不会移动图片的 frame。这会导致虽然图片显示内容上移后,下方留下 130 像素的空白区域。要消除这个区域,我们使用了一个 padding 修饰符,让下边距扣减 130。

所以在实际开发中,offset 和 padding 需要组合使用。

Image

常用 Modifier:

  • cornerRadius(3.0) 圆角
  • resizable() 允许修改原图大小,这个修饰符必须放在其它修改 frame(包括 frame 和纵横比)之前,否则其它修饰符无效
  • apsectRatio(contentMode: ) 修改 content mode。scaleToFit/scaleToFill 是这个方法的便利方法
  • frame(minWitdh: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) 宽高自适应 0~∞,等同于占满全屏
  • frame(width:40, height: 40) 设置固定大小
  • clipped() 修改 frame 之后还必须调用这个修饰符,否则图片的内容又可能超出 frame
  • tapAction { self.zoomed.toggle() } 触摸事件处理
  • clipShape(Circle()) 裁剪成圆形
  • overlay(Circle().stroke(Color.white,lineWidth: 4)) 覆盖(描绘)白边
  • shadow(radius: 10) 阴影
  • listRowInsets() 如果 Image 位于 List 中,Image 四边会自动带有 padding,如果需要去掉这个 padding,需要应用 listRowInsets(EdgeInsets())

字体图标

Image 类可以使用系统内置的系统图标,这些系统图标可以通过一个 SF Symbols 的 App (MacOS) 来查找。由于是字体图标,你可以修改字体的大小和颜色,比如:

Image(systemName: "person.crop.circle").imageScale(.large).color(.gray)

NavigationView 和 navigationBarTitle

相当于 UIKit 中的 Navigation View Controller。用于将某个 View 包裹在一个 Navigation View Controller并提供了 Navigation Bar。

NavigationView() {
	List(){ ... }.navigationBarTitle(Text("一个 Title"), displayMode: .inline)
}

上面的代码在 List 的外面套了一个 NavigationViewController,同时定义了一个导航栏,使用传统样式的标题(文字居中)。

**注意,navigation bar 并不是在 NavigationView 上进行设置,而是在它所包裹的 View 上设置。**这类似于在 ViewController 上设置 navigation Bar 而不是在 NavigationViewController 上设置。从这里也可以看出,SwiftUI View 就相当于 UIKit 的 UIView。

在 iPad 上的 NavigationView

这是 NavigationView 的一个坑,默认情况下 NavigationView 在 iPad 上是以 split view 的方式显示的,因此它和 iPhone 上看起来不一样!

要改变这点,需要手动设置 navigationViewStyle :

NavigationView {
  ...
}.navigationViewStyle(StackNavigationViewStyle())

NavigationLink 导航组件

跳转按钮,允许跳转到另外一个页面:

NavigationLink(destination:Text("下一页")) {
	Image(...)
}

NavigationLink() 的第一个参数 destination 是一个SwiftUI View,指向要跳转到的那个页面,第二个参数 label 是一个 block,这个 block 返回的也是一个SwiftUI View,用来定义按钮的外观,可以是一个 Text ,也可以是一个 Image。

NavigationLink 的另一种用途是使用它来作为一种导航,就像 UIKit 中的 Segue,它不一定有 UI(你看不见它),但是你可以通过它,让其它 UI 控件也能实现页面的导航。

首先你需要一个 @State 属性,来控制某个页面的显示与隐藏:

 @State var pushActive = false

然后你需要一个“不可见”的 NavigationLink 来充当这个导航 Segue:

NavigationLink(
 	destination: MechanicalCheckView(viewModel: MechanicalCheckViewModel()),
 		isActive: self.$pushActive
 	) {
 		EmptyView()
	}.hidden()

这里,除了 destintaion 和 label 参数外,还多了一个 isActive 参数,用来绑定 pushActive 属性(使用 $ 关键字)。当 pushActive 被改变时,UI 会显示完全不同的结果(绑定了刷新动作)。当 pushActive 默认值为 false 时,此导航不会发生,页面显示之前的页面。一旦将 pushActive 修改为 true,NavigationLink 的导航会生效,也就是显示第二个页面。同时,这个 NavigationLink 不需要显示,因此它的 label 是一个空白视图(EmptyView 不会占用屏幕空间)。同时用 hidden() 进行了隐藏。

什么时候发生 push 导航?那是由另一个控件(比如普通 button)来触发的,触发导航的方式很简单,修改 pushActive = true 即可:

Button(action: {
 	pushActive = true
}, label: {
 	...
})

这就是 NavigatoinLink 的真实用途,它并不仅仅是用于在页面上显示一个button,而是用来代替 UIKit 的 segue 组件。

dismiss 返回

struct DestinationView: View {
    // 声明属性presentationMode
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    var body: some View {
        Text("Destination View")
            .onTapGesture {
                // 返回
                self.presentationMode.wrappedValue.dismiss()
            }
    }
}

@Environment将全局变量绑定到本地属性。

ScrollView

ScrollView 控制水平布局还是垂直布局是方式是在内部使用 HStack 或 VStack:

ScrollView(.horizontal, showsIndicators: false){
  HStack(spacing: 15) {
    ForEach(...){
      ...
    }
  }
}

这里 .horizontal 不是控制水平布局的,仅仅是指定允许手指滑动的方向,showsIndicators 指定是否显示滚动条。

自定义导航栏/工具栏

自定义导航栏其实就是自定义工具栏,因为你可以先隐藏导航栏,然后在设置一个工具栏代替它:

content
 	.navigationBarBackButtonHidden(true)
 	.navigationBarTitleDisplayMode(.inline)
 	.toolbar(content: buildToolbar)

接下来看具体怎么实现。

实现 ViewModifier

新建 swift 文件 Toolbar.swift。首先继承 ViewModifier:

struct FvtToolbar: ViewModifier {
    var title: String
    var rightTitle: String
    var rightAction: ()->()
 	  init(_ title:String, _ rightTitle:String, _ rightAction:@escaping ()->()){
        self.title = title
        self.rightTitle = rightTitle
        self.rightAction = rightAction
    }

3 个属性分别是:

  • title 工具栏中间的标题
  • rightTitle 工具栏右按钮的标题
  • rightAction 工具栏右按钮的点击事件处理回调

init 方法负责初始化它们。

作为一个 ViewModifier,最重要的方法就是 body 方法:

    func body(content: Content) -> some View {
        content
            .navigationBarBackButtonHidden(true)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar(content: buildToolbar)
    }

content 参数实际上就是工具栏所在的 View ,将由 modifier 方法传入。我们先隐藏了 back button 和导航栏原来的标题,然后用 toolbar(content:) 方法设置一个工具栏。其中 buildToolbar 是一个特殊 ToolbarContentBuilder 结构体的实例。我们无需真的去定义一个 ToolbarContentBuilder,而是只需定义一个能够返回 ToolbarContent 的方法即可:

    @ToolbarContentBuilder
    func buildToolbar() -> some ToolbarContent {
        ToolbarItem(placement: .principal) {
            Text(title)
                .font(.system(size: 17, weight: .bold, design: .default))
        }
        ToolbarItem(placement: .navigationBarTrailing) {
            Button(action: {
                rightAction()
            }) {
                Text(rightTitle)
                    .font(.system(size: 17, weight: .regular, design: .default))
            }
        }
    }

@ToolbarContentBuilder 注解将方法包装成 toolbar 方法要求传入的 ToolbarContentBuilder 结构。而这个方法对方法名和参数都没有要求,但对返回值,要求是 ToolbarContent,即一个 ToolbarItem 的集合,同时这个集合可以通过所谓的“多表达式闭包“得到。所谓的”多表达式闭包“,如同 body 方法,一个闭包中会包含多个 swift 表达式,swift 将这些表达式包装在一个集合中作为闭包的返回值。

扩展装饰器方法

为了便于使用,我们可以为 View 扩展出一个装饰器方法:

extension View {
    func fvtToolbar(_ title:String, _ rightTitle:String, _ rightAction:@escaping ()->()) -> some View {
        return modifier(FvtToolbar(title, rightTitle, rightAction))
    }
}

fvtToolbar 方法接受 3 个参数,分别是 title、rightTitle 和 rightAction。返回一个 View。方法的返回值必须用 modifier 方法包裹,以支持 View 的链式调用。此外,modifier 会将自己的 content 传递给所包裹的对象。被包裹的对象必须是一个 ViewModifier

,比如FvtToolbar,它就实现了 ViewModifier (一个协议)。这样 ,modifier 会调用它的 body 方法,从而实现对目标视图的修改(比如将导航栏用我们提供的 Toolbar 代替)。

调用扩展的装饰器

在视图的 body 属性的最外层根视图使用该装饰器:

        .fvtToolbar("MECHANICAL_CHECK_TITLE".localize,
                    "NAVIGATION_BUTTON_CANCEL".localize){
            ...
        }

这样,视图会呈现一个由文字标题栏和取消按钮构成的 toolbar。

EditButton

SwfitUI 内置了编辑按钮:

EditButton().padding()

SegmentedControl

SegmentedControl 是一个容器,里面包裹了多个 Text:

SegmentedControl(selection: $profile.prefersSeason) {
  ForEach(User.Season.allCases.identified(by: \.self)){
    season in
    Text(season.rawValue).tag(season)
  }
}

$profile.prefersSeason 进行了双向绑定,其中 profile 是一个视图状态(@State)。ForEach 循环总是需要Sequence对象有一个唯一 id。

Identified(by:)

SwiftUI 的 List 在循环一个数组时,要求数组元素要么实现了 Identifiable 协议(其实就一个 id 属性),要么调用 identified(by:) 方法:

List(landmarkds.indetified(by: \.id))()

\.id\.self是swift 的 keypath 语法。

绑定视图的刷新 - @State

视图状态绑定了刷新操作,当视图中的 @State 属性被修改,自动触发页面的刷新(调用 body 块)。

@State var zoomed = false

@State 还带来了另外一个效果,就是改变结构体成员的可变性。结构体跟类不同,普通成员函数中,无法对自身结构体的属性进行赋值操作,比如如下代码:

    func present(isPresented: Bool) -> some View {
        isShow = isPresented
        return self
    }

编译器报错:Cannot assign to property: ‘self’ is immutable。以往的解决办法是在函数前用 mutating 修饰:

 		mutating func present(isPresented: Bool) -> some View {
      ...

但现在不用了,直接在 isShow 前面加一个 @State:

@State var isShow: Bool = false

@Published 属性包裹器

类似于 @State,@Published 允许你将被修饰的属性绑定指定视图的刷新操作。不同的是 @State 的绑定动作是自动的,默认就是绑定到 @State 属性所在的 View,但 @Published 需要你手动绑定到指定的 View。换句话说,@Published 属性可以用于任何 View ,但 @State 只能用于当前 View。

如果要让某个类中的属性能够绑定视图刷新操作,这个类必须实现 ObservableObject 协议:

class Bag: ObservableObject {
	var items = [String]()
}

如果你想让 items 属性能够绑定视图,则用 @Published 修饰它:

@Published var items = [String]()

这个语法糖会自动添加 willSet 属性监听器方法。

这样,Bag 就变成了一个 ObservableObject 对象。你可以在任意 View 中绑定它。只需在这个 View 中使用 @ObservedObject 声明一个 Bag 属性:

struct ContentView: View {
	@ObservedObject var bag = Bag()
	var body: some View {
		...
	}
}

注意 @ObservedObject 关键字的使用,它将 bag 对象包装成外部可访问和修改的。这点和 @State 不同,@State 的对象一般是 private 的。这样,当你修改 bag 中的 items 值时,会触发 ContentView 更新。

View 的生命周期

类似于 UIViewController 的 viewDidAppear/viewDidDisappear, SwiftUI View 也有对应的生命周期方法:

				.onAppear {
//            viewModel.startSNRCheck()
            viewModel.showRemovingStep = true
        }
        .onDisappear {
//            viewModel.startSNRCheck()
            viewModel.showRemovingStep = true
        }

还有一个特殊的 onReceive 方法:

        .onReceive(viewModel.$isTestSucceeded) { testResult in
            isTestSucceeded = testResult
        }

这个方法主动监听某个 Observable 对象的 published 属性,如果值发生变化,调用指定的块,块参数为新值。

SwiftUI 的动画

withAnimation

当某个 @State 属性被改变时,页面改变,同时让这种改变以动画方式进行,那么可以将该属性的修改代码包裹在 withAnimation 块中:

withAnimation(.basic(duration:1)){
	self.zoomed.toggle()
}

Preview 可能看不到动画效果,需要在模拟器里面执行。withAnimation 的动画会影响整个 View,因为 @State 的刷新会导致整个 body 刷新。

注意,调试动画时不要使用 Canvas(可能看不出效果),最好在模拟器上调试。

transition

此外还有另一种 SwiftUI 动画 Transition ,则是单独对某个 View 执行动画:

Text("...").transition(.move(edge.trailing))

.move 指定该动画是平移,并且视图将从(from)屏幕的右侧(trailing)滑入。注意 .move() 的参数实际是 from,to 就是当前位置。

swiftUI 中的动画是可取消动画,不需要等上一个动画彻底完成即可执行下一个动画。

变形恢复

可以让 View 恢复到变形(缩放、平移、旋转)之前 :

.transition(.identity)

旋转和缩放

旋转使用修饰符 rotationEffect,缩放使用修饰符 scaleEffect:

Image(...)
  .rotationEffect(.degrees(showDetaiol ? 90 : ))
  .scaleEffect(showDetail ? 1.5 : 1)

如果在切换 showDetail 状态时使用了 withAnimation 块,则旋转和缩放将以动画方式执行:

withAnimation(.spring()){
  self.showDetail.toggle()
}

animation

transition 是几何变形,但要让这个变形能够以动画方式进行,需要用到隐式动画 animation。在 SwiftUI 中,withAnimation 叫做显式动画,animation 叫做隐式动画。animation 修饰符和 transition 修饰符一样,也是作用于特定的 View(而不是 body 中的所有 view),它支持多种动画特效,比如弹簧动画:

.opacity(isShow ? 1 : 0)
.animation(.spring())

当然也可以匀速进行并指定动画时长 duration:

.animation(.linear(duration: 2))

GeometryReader

此外需要注意,隐式动画会影响到所有动画属性,包括位置,也就是说,隐式动画一创建时会默认位置从左上角开始(0,0)。因此为了让它不影响到我们的位置,我们需要用到 GeometryReader:

var body: some View {
	GeometryReader { geometry in
		VStack{}
    	.opacity(isShow ? 1 : 0)
 			.animation(.spring())
 			.frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
	}

这样它在动画开始时将 frame 设置到布局时的原始位置(宽高、中心都等于 geometry)而不是左上角(0,0)。

animation 的位置

animation 修饰符的位置非常重要,它让之前的动画特效生效。如果你把它放到一个 scaleEffect 之前执行,那么 animation 没有任何意义:

.animation(.easeIn(duration: 2))  
.scaleEffect( isPresented ? 1: 0)

因为在 animation 之前没有任何特效设置语句。你必须把 scaleEffect 放到前面执行:

.scaleEffect( isPresented ? 1: 0)
.animation(.easeIn(duration: 2))

animatino 取消

animation 还有一种特殊用法 animation(nil) ,表示取消之前的动画,例如:

Image(...)
  .rotationEffect(.degrees(showDetaiol ? 90 : ))
  .animation(nil)
  .scaleEffect(showDetail ? 1.5 : 1)
  .animation(.spring())

此时,旋转动画将被清除(注意,仅仅是不以动画方式执行,但旋转仍然有效,只不过是瞬间旋转)。但在 animation(nil) 之后的缩放动画和弹簧动画仍然执行。

不对称动画

不对称动画属于 transition 动画中的一种,但是它的进入和退出动画是非对称的。对于对称的过渡动画(转场动画,transition 动画),它的入场方式和出场方式是对称的,比如从左边进入,从左边退出。一般退出动画和进入动画是做相反运动,在这种情况下,我们只需指定入场方式即可,出场方式 SwiftUI 会自动根据入场方式计算。但对于非对称动画,Swift UI 无法通过入场方式推断出场方式,因此你必须同时指定入场动画和出场动画:

Image(...)
  .transition(
    .asymmetric(
      insertion: .move(edge:.trailing),
      removal: .scale
    )
  )

这里,Image 将从屏幕右侧滑入,但退出时则是逐渐缩小至不可见。

组合动画

combined 修饰符可以将两个动画组合成一个,形成一种1+1的效果:

Image(...)
  .transition(
    AnyTransition.move(edge:.trailing).combined(with:.opacity)
  )

从右边滑入+渐入效果。

AnyTransition

transition 修饰符中用了一个参数,比如我们用过的 move,scale,opacity 等,它们代表了不同的 transition 动画特效,但无一例外,统统都是 AnyTransition 类型。我们实际上可以自己扩展 AnyTransition 类型,从而定义自己的动画特效:

extension AnyTransition {
  static var moveAndScale: AnyTransition {
  	AnyTransition.move(edge: .trailing).combined(with: .opacity)
  }
  static var myTransition: AnyTransition {
    AnyTransition.asymmetric(
      insertion: .move(edge:trailing),
      removal: .scale()
    )
  }
}

波浪动画和 delay

波浪动画实际上就是多个动画延迟执行。比如有10个动画(可以相同或不同),一个接一个地执行,在上一个动画执行后若干秒,又启动下一个动画,以此类推。

Image(...)
  .animation(Animation.spring(initialVelocity: 5).speed(2).delay(Double(index)*0.03))

speed(2) 让动画以2倍速执行,delay 将 image 延迟若干秒,延迟的时间将根据 Image 在数组中的索引而相应递增。

SwiftUI View 转换成 UIViewController

要将一个 SwiftUI 的 View 对象转换成 一个传统的 UIKit 的 UIViewController,可以使用 UIHostingController 类:

window.rootViewController = UIHostingController(rootView: HomeView())

where 范型约束

约束返回结果必须遵循某种规范(协议)。

func max<T>(_ x: T, _ y: T) -> where T: Comparable

还有另外一种写法:

func max<T: Comparable>(_ x: T, _ y: T) -> T

Some 不透明类型

在 View 协议中,body 属性被定义为:

var body:Self.Body 

这里,body 的类型是 Self.Body,而 Body是一个 associatedType,也就是 View 的别名。而 Swift 中规定,如果一个协议中使用了 assoiatedType 或者 Self 关键字,那么此协议不能作为返回类型。

Self.Body 语法是为了引用 View 协议中的 associatedType。这里 Self 有点类似于 class 中的 self,都是引用“非静态”的成员。但是 Self 表示“实现类的实例“,即实现了该协议的子类的实例而非简单的实例(因为协议是不能实例化的,而实现类可以)。

简而言之,Self 专门用于协议引用自身成员变量。

所以我们在自己的 View 中, body 的类型如果写成 Self.body 是无法编译的,我们可以用具体类型(不能是协议),但是这个类型必须实现了 View ,这是 Body 这个类型别名所规定的,在 Body 规定了这个类型必须是 View:

associatedType Body: View

这样实际上就要求了 body 属性必须是具体的某种 View 类型。那么我们可以这样写:

var body: Text {
	Text("...")
}

指定了返回类型为 Text,因为实际上返回的就是一个 Text。当然更进一步,我们写成了这样:

var body: some View {
	Text("...")
}

这里 some 表示“某一种”,不管 Text 也好,List 也好,它们都是 View ,符合这个条件。这样,就不必要那么具体,只需要是“某一种 View“即可。

所以 some View 表示了一种含义:某种类型(不是协议),但又不是具体的类型,而是泛指一批类型中的任意一个,这就是不透明类型。

UIView 转换成 SwiftUI View

只需遵循 UIViewRepresentable 即可:

struct MapView: UIViewRepresentable {
	func makeUIView(context: Context) -> MKMapView {
    let mapView = MKMapView()
    return mapView
  }
  func updateUIView(_ uiView: MKMapView, context: Context){
    uiView.setRegion(
      MKCoordinateRegion()
        center: ...,
        span: ...),
      animated: true
    )
  }
}

然后可以在 View 中使用它:

MapView()

UIView Controller 转换成 SwiftUI View

很多时候 SwiftUI View 对应的是 UIKit 中的 UIViewController 而不是 UIView。同时,在 UIKit 中有许多 UI View Controller 并不能在 SwiftUI 中找到对应的 View,这就需要我们自己把 UIViewController 转换成 SwiftUI View。

比如SwiftUI 没有提供 PageViewController 类似的 View,你需要手动将 PageViewController 转换成 SwiftUI View。

UIViewControllerRespresentable

首先定义一个新的 SwiftUI View 让它遵循 UI ViewControllerRepresentable 并实现两个方法:

struct PageVC: UIView: UIViewControllerRepresentable {
  let pages = featuredLandmarks.map { UIHostingController(rootView: Image($0.imageName)) }
  
	func makeUIViewController(context: Context) -> UIPageViewController {
	  let pageVC = UIPageViewController(transitionStyle:.scroll, navigationOrientation: .horizontal)
	}
	func updateUIViewController(_ uiViewController: UIPageViewController, context: Context){
	  uiViewController.setViewController(
      [pages[0]],
      direction: .forward,
      animated: true
    )
	}
}

其中需要注意 UIHostingController 的使用,它将 SwiftUI View 转换成了 UIViewController。

UIPageViewController 使用委托模式,它把数据源委托给 dataSource 进行,所以我们还需要创建一个 DataSource 的类并实现委托方法:

class PageDataSource: NSObject, UIPageViewControllerDataSource {
  let pages: [UIViewController]
  init(pages: [UIViewController]){
    self.pages = pages
  }
  // 前进(左滑)
  func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore: UIViewController) -> {
    let index = pages.firstIndex(of:viewController)!
    return currentIndex == 0 ? pages.last : pages[index - 1]
  }
  // 后退(右滑)
  func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore: UIViewController) -> {
    let index = pages.firstIndex(of:viewController)!
    return currentIndex == pages.count -1 ? pages.first : pages[index + 1]
  }
}

Coordinator

然后设置上 page controller 的 dataSource 属性:

func makeUIViewController(context: Context) -> UIPageViewController {
	  ...
	  pageVC.dataSource = PageDataSource(pages: pages)
	  return pageVC
	}

事实上 Xcode 会在这里给出一个警告,Instance will be immediately deallocated because property ‘dataSource’ is ‘weak’ 意思是 .dataSource 是一个弱引用,你不能这样直接把一个 DataSource 对象赋值给它,而要采取特殊的方法。

SwiftUI 采用另外一种方法将传递数据给 UIViewController weak 属性(比如 delegate 和 dataSource),叫做 Coordinator。在PageVC 需要实现另外一个协议方法:

func makeCoordinator() -> PageDataSource {
  return PageDataSource(pages: pages)
}

然后你可以这样赋值给 dataSource 属性:

pageVC.dataSource = context.coordintator

这样就将简单变量(pages )变成了函数调用(指针),这样无论何时调用 .dataSource,你都有一个 condinator 可用(因为 context.condinator 会调用 makeCoordinator 方法)。

隐藏状态栏

状态栏属于安全区的内容,因此可以通过取消顶部安全区来隐藏状态栏:

MapView().edgesIgnoringSafeArea(.top).frame(height:360)

注意,**edgesIgnoringSafeArea 修饰符必须位于 frame 等布局修饰符之前。**否则会导致 frame 计算不正确。

自定义画布大小

对于 cell,通常不需要在预览区展示完整画布,只需展示 cell 差不多大小的画布即可。这可以通过 previewLayout 修饰符来实现:

static var previews: some View {
	LandmarkCell().previewLayout(.fixed(width:300, height: 70))
}

预览设备型号

可以用 previewDevice 设置预览设备的型号:

LandmarkList().previewDevice(PreviewDevice(rawValue:"iPhone 8")).previewDisplayName("iPhone 8")

rawValue 的写法跟 target 下拉列表中列出的保持一致。previewDisplayName 指定设备屏幕预览下方的标题。

视图状态的双向绑定 - $ 关键字

对于某些 View,可以将视图的属性和一个 Observable 变量双向绑定。比如 Toggle 控件 的 isOn 属性:

Toggle(isOn: $showFavoritesOnly) { ... }

这里,showFavoritesOnly 是一个 @State 属性:

@State private var showFavoritesOnly = false

什么叫做双向绑定呢?当我们将 Toggle 的 isOn 属性 和 一个 Binding 对象进行绑定后(也就是调用 Toggle(isOn:) ),每当 isOn 属性被改变(比如用户的 UI 操作),则 Binding 对象所包裹的 T 对象会被改变。相反,当 Binding 对象所包裹的 T 对象改变,isOn 属性也会被改变。

实际上这里的 s h o w F a v o r i t e s O n l y 就 是 一 个 B i n d i n g < T > 类 型 的 对 象 。 showFavoritesOnly 就是一个 Binding<T> 类型的对象。 showFavoritesOnlyBinding<T>是一个语法糖,它帮我们将 showFavoritesOnly(Bool 类型对象)转换成一个 Binding 类型。

实际上这里还有第三个方向的绑定,这就是 showFavoritesOnly 的 @State 绑定,@State 是另一个语法糖,它绑定了刷新操作——可以想象成自动生成了 setter 方法代码并触发刷新操作。这样,每当 showFavoritesOnly 属性改变,就会触发 View 的刷新动作。

$ 类似于 o-c 的取地址运算符 &。但 Swift 中极力避免使用指针,因此进行了某些限制。

环境对象

所谓环境对象,借用 Java IoC 中的话说,就是容器对象,或者依赖注入对象、容器托管对象。如果我们把整个 App 看成是 IoC 容器,那么我们可以向容器中注入环境对象,然后在需要的时候使用它。用于它是受 App 容器管理的,所以它的生命周期可以和 App 一样长,并且对 App 中的所有其它对象来说是可见的,因此可以把它当成全局对象使用。

注入环境对象

首先新建一个 SwiftUI 应用。在 ContentView 中,添加一个 NavigationLink,让它跳转到第二个 View : SecondView。然后在 SecondView 中添加一个 NavigationLink,让它跳到第三个 View: ThirdView。

然后我们在 App.swift( 或者 SceneDelegate.swift)中,注入一个环境对象:

ContentView().environmentObject(AppState())

ContentView 是我们的第一个View(根视图),我们在初始化 ContentView 之后随即调用它的 environmentObject 方法注入了一个 环境对象,这个环境对象要求必须是一个可观察对象(ObservableObject):

class AppState: ObservableObject {
    @Published var rootViewShowing = false
}

AppState 遵循 ObservableObject 协议,这样它的值被改变时可以向外界发射通知并被其它对象所感知。AppState 目前只有一个 @Published 的 Bool 属性。@Published 会将这个属性的变化通知到其它对象——这点和 @State 类似,但不同的是 @State 只能用在 View 自身,同时自动将属性绑定到刷新操作。但 @Published 可以用在任意 class(不能用于 struct),同时绑定 View 的刷新操作。

引用环境对象

如同 Java Spring 的 @Autowired 注解,环境对象需要你自己去引用它。假设我们在第三个视图 ThirdView 中使用到环境对象,那么我们需要在 ThirdView 中引用它:

@EnvironmentObject var appState: AppState

这里 @EnvironmentObject 属性包装器类似于 @Autowired 注解,将容器中环境对象取出放入到成员变量中。

这样,你就可以在 ThridView 中使用这个环境变量了。我们可以在 body 中添加两个控件分别用来显示和修改它的值:

Text(appState.rootViewShowing ? "true" : "false")
Button("change rootViewShowing") {
 	appState.rootViewShowing.toggle()
}

运行 App,跳转到第 3 页,Text 上显示了“false“,但当你点击 Button,Text 上文字会随之改变,这说明我们不仅可以修改环境变量,也可以及时感知它的变化并绑定视图刷新。

同时当环境变量被改变时,所有引用它的视图都能同时获得刷新通知,并非仅仅是某个视图。为了验证这一点,你可以将上面的代码同样拷贝到 ContentView 和 SecondView 中,看在某个 View 中改变 rootViewShowing 是否导致所有的视图刷新。

返回根视图

我们设计 rootViewShowing 的目的,其实是为了让第三个视图直接返回到根视图,而不用两次 back。那么我们可以在第一个视图 ContentView 中,找到 NavigationLink 的代码,原来的代码是这样:

NavigationLink("Second view") {
  SecondView()
}.navigationBarTitle("first view")

修改为:

NavigationLink(destination: SecondView(), isActive: $appState.rootViewShowing) {
  Text("second view")
}.navigationBarTitle("first view")

我们使用了 NavigationLink() 的 isActive 参数。这是一个奇特的参数,如果你将这个参数和一个可观察变量进行双向绑定,那么表示当用户点击按钮时,这个变量会被同步地改变为 true,同时跳转到下一页;如果用代码将这个变量改变为 false,则 NavigationLink 会返回到此页(也就是 ContentView)。

运行 app,跳转到 ThirdView,你会发现此时 Text 显示为 “true”,说明 isActive 参数已经生效,rootViewShowing 已经由 false 变成了 true。点击 Button,直接跳转到 ContentView 页,同时 Text 显示为 false,说明rootViewShowing 已经由 true 变成了 false。。

窗体间传值

当一个窗体切换到另一个窗体(比如一个编辑窗口),那么编辑窗口中所做的修改必须传回第一个窗口,这就是窗体之间的反向传值。一般可以用 环境对象来实现,它是全局对象,整个 app 生命周期中都可用,因此它常用于窗口之间传值,无论是正向还是反向。

首先需要定义一个 BindableObject 的类:

class UserData: BindableObject {
  var didChange = PassthroughSubject<UserData,Never>()
  var userLandmarks = landmarks {
    didSet {
      didChange.send(self)
    }
  }
}

这里,PassthroughSubject 是 Combine 为我们提供的一个 Subject 封装,包含两个范型参数,第一个是要存放的数据类型,这里当然就是 UserData 类自己了,第二个是一个 Failure 类型,发生错误时可能返回的错误类型,这里使用 Never,就是假定永远不会 send 任何错误——因为我们不需要它。简单地说,didChange 是一个发布者,它会发送两种类型的数据,因为第二种数据类型是 Never,将被忽略,所以实际上它只会发送一种类型,也就是 UserData。如果你熟悉 Promise 或者 Stream,那么这些概念自然熟悉。

userLandmarks 的 setter 方法执行了一个绑定。一旦 userLandMarks 数组发生了任何改变,则通过 didChange 发送 self 给(所有)订阅者。

发布者有了,接下来就是订阅它,在要用到环境对象的类中,引用环境对象:

@EnvironmentObject var userData: UserDatas

环境对象类似于全局对象,它在所有视图中都可以用,方便我们在窗体之间传递数据。同时,它是一个增强版的 @State 变量,一旦它被改变,视图会被刷新。它不需要显式地订阅,直接在视图中使用就可以了:

ForEach(userData.userLandmarks) { ... }

在编辑页面,同样需要声明这个东西,因为我们需要操作它的数据:

@EnvironmentObject var userData: UserData
...
Button(action: {
  let index = self.userData.userLandmarks.irstIndex(where: {$0.id == landmark.id})!
  self.userData.userLandmarks[index].isFavorite.toggle()
}){ ... }

然后来实例化(注入)这个 UserData。在LandmarkList 视图初始化的同时用 environmentObject 方法注入UserData:

LandmarkList().environmentObject(UserData())

然后在 LandMarkDetail 中直接引用 UserData 环境变量(不用初始化):

@EnvironmentObject var userData: UserData

可以看到环境变量具有一次初始化(注入),随处使用的特点。

注意,@EnvironmentObject 和 @Environment 无关,二者是不同的概念。@Environment 表示从运行时环境中获取配置,比如获取 iPhone 是否运行在暗夜模式,屏幕大小等:

@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.managedObjectContext) var managedObjectContext

而 @EnvironmentObject 则表示这个属性是一个环境变量,它的初始化/注入是通过 environmentObject() 方法,一旦你用 environmentObject 注入后,你就可以在任何地方引用和使用它。

视图编辑状态

视图的编辑状态保存在 App 的环境变量,你可以通过 @Environment 语法糖获取:

@Environment(\.editMode) var mode

这样就将环境变量中的 editMode 变量和 mode 绑定。你可以通过 mode 来判断当前视图是否处于编辑状态,并进行不同的界面展示:

if mode?.value == .inactive {
	...
}else {
  ...
}

这里的问题在于,展示数据必须和编辑的数据单独区分,也就是说,在一开始我们需要从展示数据拷贝一份做为编辑数据,将编辑数据绑定到编辑界面的视图上,这样用户编辑时不会错误地修改了显示的数据,而是在单独的数据中编辑,如果用户最终点击了 Done,那么将编辑数据复制给显示数据,这样切换回浏览视图后 UI 回做相应改变,如果用户点击了 Cancel,那么拷贝不会进行,切换回浏览视图后 UI 保持原样。

@State private var profile = User.default
@State private var profileCopy = User.default

这里,编辑数据 profileCopy 也需要是 @State。当 EditButton 被点击,切换 mode :

Button(action:{
  self.mode?.value = .inactive
  self.profile = self.profileCopy
}){
	Text("Done")
}
EditButton().padding()

注意,mode 是一个封装对象,它内部封装了 .editMode 环境变量。所以进行修改操作时,我们不直接修改 mode 对象,而是修改 mode?.value。@Environment 修饰符和 @State 类似,同样绑定了视图刷新操作,一旦我们修改了 mode,页面得到刷新,从编辑状态恢复到浏览状态。

此外,Done 按钮是另外一个普通按钮,并不是 EditButton。这里,当 Done 按钮被按下,我们拷贝了编辑后的数据,这样编辑过的数据才会生效。EditButton 不需要做什么,因为我们抛弃了编辑数据。

视图的生命周期

View 类似于 UI ViewConroller,当然也有生命周期。比如我们需要在编辑视图消失的时候做一些事情,可以用 onDisappear 修饰符(相当于 viewWillDisappear 方法)中,将编辑数据恢复原状:

.onDisappear {
  self.profileCopy = self.profile
}

动画属性

对于动画属性,可以在属性被改变时增加动画特效,比如 mode 属性:

self.mode?.animation().value = .inactive

@Binding

@Binding 表示某个属性/变量从外部传入一个引用,无论 struct 还是 class。

struct AddView: View {
	@Binding var isPresented: Bool
	var body: some View {
	  Button("Dismiss") {
	    self.isPresented = false
	  }
	}
}

这里 isPresented 相当于一个指针,当你在这个 View 中对它赋值为 false 时,它原来(外部的某个 Bool 值)也就变成 false 了。

当然在外部,初始化 AddView 时,你需要这样初始化 isPresented 的值:

AddView(isPresented: $showingAddUser)

注意 $ 关键字的使用,它将某个类型转换成 Binding 类型,这里就是将 showingAddUser (Bool 类型)变成了 Binding。

.constant 绑定

@Binding 不仅仅可以绑定变量,也可以绑定常量,比如上面的 AddView.init(isPresented: ),不仅仅可以传一个 $showingAddUser 变量,也可以直接传一个 Bool 值给它:

AddView(isPresented: .constant(true))

或者任意类型的常量:

ProfileEditor(profileCopy: .constant(User.default))

初始化 Binding 属性

在自定义 init 方法中初始化一个 @Binding 属性需要一点技巧,因为 @Binding 属性本质上是计算属性,不能直接对他进行赋值,而是要通过对应的实例变量进行。在每个@Binding 属性底层,都有一个隐藏的的实例变量,该实例变量的命名规则是在属性名前加一个$(swift3)或下划线 _ (swift4)前缀:

struct AmountView : View {
    @Binding var amount: Double

    private var includeDecimal = false

    init(amount: Binding<Double>) {
        // self.$amount = amount // swift 3
        self._amount = amount // swift 4
        self.includeDecimal = round(self.amount)-self.amount > 0
    }
}

改变视图状态

SwiftUI 规定,View 只能是 struct,而struct 是值而非对象,它潜在地就是一种不可变的“常量”,比如说你无法从结构体内部修改它的属性(除非 mutating )。而从外部修改一个结构体的属性,只会产生一个新的结构体实例。

那么如何从外部改变视图的状态?首先需要把这个属性定义为一个@Binding 属性,这个属性就会变成一个引用。也就是说这个数据放在了 View 之外的地方,而非和 View 位于同一块内存空间,它仅仅是记录了数据的地址。

struct AlertView: View {
    @Binding var isShow: Bool

然后需要在外部,比如另一个 View 中,找一块内存来真正存放这个 Bool 值。也就是在其它 struct 中定义一个Bool 属性,这样数据才有地方放:

struct ContentView: View {
    @State private var isAlertPresented = false

我们需要修改 isAlertPresented 属性,所以将它修饰为 @State(如果你不想 mutating 的话)。

然后在初始化 AlertView 的时候把 isAlertPresented 的地址传递进去,这样当我们修改 isAlertPresented 的时候,相当于 AlertView 中的 isShow 也就被修改了:

AlertView(isShow: $isAlertPresented, title: "error", detail: "come on")

在 AlertView 中,你可以根据 isShow 的值来显示、隐藏 View:

 var body: some View {
        if isShow {
                Rectangle()
                    .fill(Color.black.opacity(0.6))
                    .edgesIgnoringSafeArea(.all)
            }  

这也是 SwiftUI 中唯一能够动态切换视图显示/隐藏的方法。因为 hidden() 装饰器仅能隐藏,不能显示。

更进一步,我们可以控制视图中部分子视图执行某些动画:

VStack()
	 .scaleEffect(isShow==true ? 1 : 0)
   .animation(.spring())

isShow = true 时显示原大小,= false 时缩放到 0。这也是 SwiftUI 中唯一控制部分视图(非全部)状态的方法。我们不能直接动态地操纵视图的外观、颜色、位置、大小,一切都要通过状态,也就是 ViewModel 来做。

数组分组

要将一个数组分成不同的组,每个组有一个 key,则可以用 Dictionary 的自带分组功能:

let categories = Dictionary(grouping:landmarks, by: {$0.category})
ForEach(categories.keys.sorted().identified(by: \.self)){categoryName in
  let items = categories[categoryName]!
  ...
}

ForEach 循环时,要求数组必须是排序的,同时必须 Identifiable。

字符串插值中的日期格式化

通常涉及到日期的格式化,就会想到 DateFormatter 的 string(from:) 方法。但实际上,我们也可以利用字符串插值的 formatter 参数来进行格式化:

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"

Text("生日:\(date, formatter: dateFormatter)")

让枚举值可以被列举

如果要让枚举值可以通过 .allCases 被列举,需要让 enum 遵循 CaseIterable 协议。

environment修饰符

environment修饰符用于修改视图的特殊效果,比如想预览暗夜模式,可以将 colorScheme 设置为 dark,如果要查看大字体下的效果,则可以修改 sizeCategory:

Group {
  Home()
  Home().environment(\.colorScheme, .dark)
  Home().environment(\.sizeCateory, .accessibilityExtraLarge)
}

引用 SceneDelegate

在 UIKit 的世界,我们可以通过 UIApplication.shared.delegate 来引用 AppDelegate,从而可以使我们访问一些“全局”的对象,比如 window。在 SwiftUI 中, SceneDelegate 取代了 AppDelegate,因此我们同样可以通过 UIApplication.shared 单例引用 SceneDelegate:

if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate,
       let rootVC = sceneDelegate.window?.rootViewController {
        rootVC.present(UIAlertController(withError: message),
                                            animated: true, completion: nil)
    }

注意,connectedScenes 是一个集合,包含了所有 scene(SwiftUI View),我们从任意一个 scene 的 delegate 都可以拿到 SceneDelegate。进一步可以拿到 app 的 rootViewController 并修改它(切换根视图控制器)。

 类似资料: