SwiftUI 通过 Modifiers 去修改各个 view 的样式,使用“点语法”呼叫方法,存取修饰器。它有以下特点:
// 同样的修饰符还可以添加多次(每个修饰符都只是添加到以前的任何内容上):
Text("Hello, world!")
.padding()
.background(.red)
.padding()
.background(.blue)
.padding()
.background(.green)
按照不同的维度,修饰符分为以下几种类别:
// 例如:以下代码的 Gryffindor 字体就会覆盖父节点的 title
VStack {
Text("Gryffindor")
.font(.largeTitle)
Text("Hufflepuff")
Text("Ravenclaw")
Text("Slytherin")
}
.font(.title)
但有些修改器就不是环境修饰符,对常规修饰符来说,应用于子视图的任何模糊都会添加到 VStack
的模糊中,而不是替换它。目前除了阅读每个修改器的单独文档并希望被提及之外,没有办法提前知道哪些修改器是环境修改器,哪些是常规修改器。
// 例如 blur() ,以下代码中,子视图的 blur 就没有覆盖父视图的,反而是叠加上去
VStack {
Text("Gryffindor")
.blur(radius: 0)
Text("Hufflepuff")
Text("Ravenclaw")
Text("Slytherin")
}
.blur(radius: 5)
像是 font,foregroundColor 这样定义在具体类型 (比如例中的 Text) 上,然后返回同样类型 (Text) 的,称为原地 modifier。
像是 padding,background 这样定义在 View extension 中,将原来的 View进行包装并返回新的 View 的,称为封装类 modifier。
<aside> 💡 原地 modifier 一般来说对顺序不敏感,对布局也不关心,它们更像是针对对象 View 本身的属性的修改。而与之相反,封装类的 modifier 的顺序十分重要。封装类修饰符的逻辑是:每个修饰符都创建一个应用了该修饰符的新 struct,而不是在视图上设置一个属性。所以修饰符的顺序非常关键。
</aside>
visualEffect()
修饰符可以在不使用 GeometryReader
的情况下读取视图的几何代理,某种程度上可以代替 GeometryReader
visualEffect()
允许改变某些东西的外观效果,它们影响视图的渲染方式,但无法影响视图的实际布局位置或框架。其名称应该清楚地表明所做的任何调整都只会改变视图外观;如果您想使用它来调整视图内容,那么就找错了地方visualEffect()
有很多修饰符可供使用,包括 rotationEffect()
、 rotation3DEffect()
、offset()
content
以及它的 GeometryProxy
content
就是添加了该修饰符的视图本身,但我们不能做任何影响视图布局位置的事情// 例如,以下代码将滚动视图中的每个视图模糊一定的模糊量,该模糊量是根据视图距其滚动视图中心的距离计算的。这意味着垂直中心附近的视图很少或没有模糊,而外部的视图则严重模糊:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(0..<100) { i in
Text("Row \\(i)")
.font(.largeTitle)
.frame(maxWidth: .infinity)
// 使用 visualEffect 可以自动获得 proxy 对象,传入方法
.visualEffect { content, proxy in
content.blur(radius: blurAmount(for: proxy))
}
}
}
}
func blurAmount(for proxy: GeometryProxy) -> Double {
let scrollViewHeight = proxy.bounds(of: .scrollView)?.height ?? 100
// 调用 proxy.frame(in: .scrollView) 可在包含该视图的最内层滚动视图中查找该视图的大小
let ourCenter = proxy.frame(in: .scrollView).midY
let distanceFromCenter = abs(scrollViewHeight / 2 - ourCenter)
return Double(distanceFromCenter) / 100
}
}
// 这些视觉效果适用于任何类型的位置,包括通过动画生成的位置。例如,这使得网格中的一系列圆圈旋转,每个圆圈根据色调旋转动态重新着色:
struct ContentView: View {
@State private var rotationAmount = 0.0
var body: some View {
Grid {
ForEach(0..<3) { _ in
GridRow {
ForEach(0..<3) { _ in
Circle()
.fill(.green)
.frame(width: 100, height: 100)
.visualEffect { content, proxy in
content.hueRotation(.degrees(proxy.frame(in: .global).midY / 2))
}
}
}
}
}
.rotationEffect(.degrees(rotationAmount))
.onAppear {
withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) {
rotationAmount = 360
}
}
}
}
// 例如使用 3D 旋转效果
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(1..<20) { num in
Text("Number \\(num)")
.font(.largeTitle)
.padding()
.background(.red)
.frame(width: 200, height: 200)
.visualEffect { content, proxy in
content
.rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
}
}
}
}
和使用 GeometryReader 对比:
// 例如:创建一个简单的 CoverFlow 风格的效果,可以水平滑动来查看在 3D 空间中移动的视图
// 使用了 GeometryReader 读取滚动视图中每个 Text 视图在全局的位置,
// 重点是:需要添加显式的 frame 修饰符,指定宽度和高度(等于Text视图尺寸),以阻止 GeometryReader 自动扩展占据所有可用空间
// 如果没有给 GeometryReader 后面添加 frame 修饰符,则 GeometryReader 会撑满屏幕,那所有 Text 的坐标都靠左上角,就都一样
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(1..<20) { num in
GeometryReader {
proxy in
Text("Number \\(num)")
.font(.largeTitle)
.padding()
.background(.red)
.rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
.frame(width: 200, height: 200)
}
.frame(width: 200, height: 200)
}
}
}
<aside> 💡 总结:用 visualEffect 也能获得 GeometryProxy。这种写法比使用 GeometryReader 更简洁,因为不再需要添加 frame() 修饰符来阻止 GeometryReader 内容占据全屏。
</aside>
若要创建自定义修饰符,首先要创建符合 ViewModifier
协议的 Struct。
// 该结构只有一个要求,即调用 `body` 的方法,它接受它被赋予的任何内容,并且必须返回 `some View`
// ViewModifier 对象是一级类型,首字母要大写
struct MyViewStyle: ViewModifier {
func body(content: Content) -> some View {
// 以下是自定义样式
return content
.foregroundColor(.white)
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 8)
.shadow(color: Color.black.opacity(0.1), radius: 1, x: 0, y: 1)
}
}
// 自定义修饰符之间可以互相调用(即修改器内部可以添加另外的修改器,但要求其里面的内容不能重复)
struct ValueStyle: ViewModifier {
func body(content: Content) -> some View {
return content
.modifier(ShadowStyle())
// 但内容不能重复
.foregroundColor(.yellow)
.font(Font.custom("Arial Rounded MT Bold", size: 24))
}
}
// 调用时:使用 modifier 修饰符
Text("Put the Bullseye").modifier(MyViewStyle())
如果希望更方便使用,还可以为自定义修饰符创建对应的 View 扩展
// 扩展的内容实际上就是多定义一个函数(可以同名)
// 例如将自定义的 ValueStyle 修饰符,包装在 View 的扩展中
extension View {
func titleStyle() -> some View {
modifier(ValueStyle())
}
}
// 调用时:就可以直接输入扩展的方法名,不需要多打一个 modifier
Text("Hello World").titleStyle()
另外一种自定义修饰符的做法是,不用创建 Struct 自定义结构,直接在 View 扩展的方法里修改样式
// 例如没有使用自定义修饰符,直接在扩展方法里修改的样式
extension View {
func stacked(at position: Int, in total: Int) -> some View {
let offset = Double(total - position)
return self.offset(y: offset * 10)
}
}
// 调用时:
Text("Hello World").stacked(at: 3, in: 8)
对于【添加自定义结构】和【直接给 View 协议添加方法】哪个更好?主要判断依据是:自定义结构可以有自己的存储属性,而 View 的扩展不能。
// 例如:可以建立带参数的修饰符结构体,在使用时输入不同的参数
struct FontModifier: ViewModifier {
var style: Font.TextStyle = .body
func body(content: Content) -> some View {
content
.font(.system(style, design: .serif))
}
}
// 调用时
Text("6 minutes left").modifier(FontModifier(style: .subheadline))
⚠️ 注意一个小问题。这个问题,其实是 Swift 的点语法省略规则导致的。
// 以下写法的闭包参数叫 view。当后面写 .toolbarVisibility 的时候,它前面其实什么都没写,Swift 会以为想调用的是类型方法(在 View 协议本身上调用),而不是在 view 这个实例上调用。这时会报错 ❌
{ view in
.toolbarVisibility(.hidden, for: .tabBar)
.toolbarBackgroundVisibility(.hidden, for: .tabBar)
}
// 正确的写法:要告诉 Swift 这是对 view 这个参数调用的修饰符,这样就清楚了:
// view → 代表传进来的那个 ScrollView {...} 或别的 View
// .toolbarVisibility(...) → 修饰 view 本身
{ view in
view.toolbarVisibility(.hidden, for: .tabBar)
.toolbarBackgroundVisibility(.hidden, for: .tabBar)
}
类比一个简单的例子,假设有个函数:
func demo(_ transform: (String) -> String) {
print(transform("Hello"))
}
// 如果这样写:❌ 报错:因为 Swift 不知道这个 .uppercased() 是谁的
// Swift 会困惑:你没写对象啊,这个方法是要调用谁的?
demo { str in
.uppercased()
}
// 正确写法应该是:✅ 这才对,明确告诉它是在 str 上调用
demo { str in
str.uppercased()
}
<aside> 💡
在 SwiftUI 里,每个修饰符其实都是在 某个 View 实例 上调用的。如果没写 view.
,编译器就会搞不清楚,误以为你在类型级别上调用修饰符,于是就报:Instance member 'toolbarVisibility' cannot be used on type 'View'
。意思就是:你是不是想在 类型 View 上调用?但是它没有这个东西。✅ 所以你只需要记住一句话:在闭包里写修饰符时,一定要在前面加上参数名(比如 view.),告诉 Swift 这是对实例调用。也可以采用 $0
(默认参数名),不用显式写 view in ...
,代码会更简洁。
</aside>
自定义修改器不仅可以应用其他修改器,还可以根据需要创建新的视图结构。记住修饰符返回新对象,而不是修改现有对象。
// 例如可以创建一个将视图嵌入堆栈并添加另一个视图的修饰符
struct Watermark: ViewModifier {
var text: String
func body(content: Content) -> some View {
ZStack(alignment: .bottomTrailing) {
// 这里把 content 嵌入了 ZStack
content
Text(text)
.font(.caption)
.foregroundStyle(.white)
.padding(5)
.background(.black)
.opacity(0.3)
}
}
}
// 然后给 View 协议扩展一个新的方法,使其更易于使用
extension View {
func watermarked(with text: String) -> some View {
modifier(Watermark(text: text))
}
}
// 调用时:
Color.blue
.frame(width: 300, height: 200)
.watermarked(with: "Hacking with Swift")
很多时候,我们希望满足某些条件才应用这个修饰符,不满足时就不应用。第一种方法是将我们的整个内容包装在 if else 语句中。如果您的内容很少,这很好,但当您的内容变大时,它可能会变得难以阅读。此外,代码重复了两次,这并不好。
struct ContentView: View {
@State private var shouldBeRed: Bool = true
var body: some View {
if shouldBeRed {
Text("Hello, world!")
.foregroundColor(.red)
} else {
Text("Hello, world!")
.foregroundColor(.blue)
}
}
}
另一种方法是在修饰符内添加三元运算符。如果应应用修饰符,即使条件的计算结果为 false,这也是一个很好的方法。它更易于阅读,并且只有一行代码,这使得代码干净。
struct ContentView: View {
@State private var shouldBeRed: Bool = true
var body: some View {
Text("Hello, world!")
.foregroundColor(shouldBeRed ? .red : .blue)
}
}