文章

SwiftUI 编程 Tips

SwiftUI 编程 Tips

本文记录了使用 SwiftUI 编程时需要注意的一些细节.

  1. View.onAppear
  2. 使用 GeometryReader 获取基于父视图的布局空间
  3. View 协议当作类型使用
  4. @StateObject 和 @EnvironmentObject
  5. @ViewBuilder
  6. View.mask 和 View.background / View.overlay
  7. Image 实现 AspectFill 内容填充模式( UIView.contentMode 第二个枚举值)
  8. Shape

前言

SwiftUI 是一个声明式 UI 框架, 它基于 Combine 内置数据双向绑定机制, 在 UI 构建中具有非常大的优势. 但它也存在一些功能短板, 以至于一些自定义程度较高的 UI 组件还是使用 UIKit 更为合适.

我的看法: App 主框架使用 UIKit (众所周知: 国内 App 启动时有很多操作, 各种生命周期也有很多操作) App 内部的业务 UI 使用 Swift UI 搭建

View.onAppear

View.onAppear 类似 UIKit 中 viewDidLoad / viewWillAppear 合体, 需要手动处理仅执行一次的操作.

View 的生命周期函数 onAppear 类似于 UIKit 中 viewWillAppear. 通常我们会处理一些 view 被展示到屏幕前的事件. 但不同于 UIKit 的是 SwiftUI 中没有提供类似 viewDidLoad 的函数. 对于仅需要处理一次的操作(如读取内存数据)每次都会执行, 并覆盖用户的编辑值

具体原因如下: SwiftUI 采用 Combine 的数据双向绑定, 其内部会记录很多 @State. 当某个 @State 发生改变, 会触发 UI 重绘制, 会重新执行 View.onAppear, 如果其中包含读取默认值的代码, 则会重新读取默认值, 覆盖当前修改过的 @State.

使用 GeometryReader 获取基于父视图的布局空间

GeometryReader 是一个特殊 View. 它像函数一样以其父视图的布局空间来包装指定子视图.

其主要目的是对子视图布局提供运行时的正确布局空间数据, 如获取父视图的 width/height.

通常, 我们只直接声明式布局 UI 即可, 比如 ZStack / HStack / VStack 等来布局, SwiftUI 会自动进行布局,并合理利用空间. 但是, SwiftUI 默认是根据子 View 来填充,撑开父容器. 这就有一个问题: 当子视图尺寸明显大于我们指定父视图尺寸的时候, 如果父视图被设置了固定尺寸, 此刻子视图会因为尺寸过大而超出父视图可是范围, 其超出部分并非以左上角原点为锚点对齐,从而造成布局异常.

GeometryReader 就是为了解决这问题, 当父容器固定尺寸, 子视图尺寸过大, 它会读取运行时父容器布局空间, 并以左上角为锚点对齐(真实锚点应该是平台有关, 本文以 iOS 为例.)

View 协议当作类型使用

View 是个协议, 无法直接当作类型使用, 可以使用 any View 代替, 或者返回具体的 View 类型

1
2
3
4
5
6
7
func getView() -> any View {
   return Text("")
}

func getView() -> some View {
   return Text("")
}

@StateObject 和 @EnvironmentObject

@StateObject 和 @EnvironmentObject 都是用于数据双向绑定对象的注解, 用于修饰 ObservableObject 的对象

@StateObject 用于声明 View 的成员变量, 需要初始化赋值.

@EnvironmentObject 用于声明环境变量, 通过 View.environmentObject() 赋给视图树 RootView 赋值, 之后整个视图树内的 View 可直接声明使用(由系统统一赋值).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class DataModel: ObservableObject {
    @Published var name: String = "点我试试"
}

struct BannerView: View {
    // @StateObject var dataModel: DataModel
    @EnvironmentObject var dataModel: DataModel
    
    var body: some View {
        Text(dataModel.name)
            .onTapGesture {
                dataModel.name = "试试就试试"
            }
    }
}

// BannerView(dataModel: DataModel())
BannerView().environmentObject(DataModel())

@EnvironmentObject 需要当前 View 添加到视图树中才会被系统赋值
同样只有 View 被加入到视图树中, View.onAppear 函数才会被调用
若 View 展示时需要加载网络数据, 更新 dataModel<=> View, 则必须先将 View 添加到视图树

这就意味着: 当子视图使用环境变量的时候, 若未被安装到视图树, 则环境变量因未赋值而 Crash

@ViewBuilder

@ViewBuilder 是一个注解, 如下代码会将函数体中所有 View 封装成一个整体 View 返回, 如果不用 @ViewBuilder, 则需要显示写明 return 某个具体 View

1
2
3
4
5
@ViewBuilder
func getView() -> some View {
   Text("")
   Image("")
}

View.mask 和 View.background / View.overlay

mask 就是整个图层最终绘制到屏幕上的部分, 比如一个矩形按钮, 上面有个 ‘➕’ 的 mask, 那么绘制到屏幕上最终视觉效果就是一个 ➕,

background 就是 View 的背景, 系统会给 View 分层, 将 backgound 独立一层绘制到 View 底下一层

overlay 就是 View 上盖一层, 系统会在 View 上新建一个图层来展示 overlay内容.

Image 实现 AspectFill 的 contentMode

SwiftUI 中 ContentMode 枚举仅有 fill / fit 两个状态, 且 View 关于内容函数 scaledToFill() / scaledToFit() 给人直观感受是只有两种内容模式, 实际上可以通过组合的方式来实现其他mode, 比如 aspectFill.

1
2
3
4
5
6
7
8
9
10
11
12
// 实现 aspectFill 实现
Image(uiImage: UIImage.init(named: "1")!)
 .resizable()
 .scaledToFill()
 .frame(width: UIScreen.main.bounds.width)
 
 // 更直观的查看效果 - 可以用下面代码来看直接效果
 Ellipse()
	.fill(Color.purple)
	.aspectRatio(CGSize(width: 3, height: 4), contentMode: .fit)
	.frame(width: 200, height: 200)
	.border(Color(white: 0.75))

Shape

..


THE END.

本文由作者按照 CC BY 4.0 进行授权