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)
}
}
// 调用时:
Text("Put the Bullseye").modifier(MyViewStyle())
## 自定义修饰符之间可以互相调用(即修改器内部可以添加另外的修改器,但要求其里面的内容不能重复)
struct ValueStyle: ViewModifier {
func body(content: Content) -> some View {
return content
.modifier(ShadowStyle())
// 但内容不能重复
.foregroundColor(.yellow)
.font(Font.custom("Arial Rounded MT Bold", size: 24))
}
}
如果希望更方便使用,还可以为自定义修饰符创建对应的 View 扩展
// 扩展的内容实际上就是多定义一个函数(可以同名)
// 例如将自定义的 ValueStyle 修饰符,包装在 View 的扩展中
extension View {
func titleStyle() -> some View {
modifier(ValueStyle())
}
}
// 调用时就可以直接在后面添加该方法
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))
自定义修改器不仅可以应用其他修改器,还可以根据需要创建新的视图结构。记住修饰符返回新对象,而不是修改现有对象。
// 例如可以创建一个将视图嵌入堆栈并添加另一个视图的修饰符
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)
}
}
但是有时候,光通过修改参数值并不能达到想要的效果,我们就希望条件不满足时,不要出现该修饰符,该如何实现呢?例如,如果 shouldAddShadow 的值为 true,则只想向 Text 添加阴影。如果为 false,则您不想添加任何阴影。这里可以设置一个 View
扩展来实现:
// 1.首先创建一个扩展文件
extension View {
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
// 2.调用时:用 if 函数调用,其中的 shouldAddShadow 是一个之前定义好的布尔值,通过它决定是否采用闭包中的修饰符;view 代表视图本身
struct ContentView: View {
@State private var shouldAddShadow: Bool = true
var body: some View {
Text("Hello, world!")
.if(shouldAddShadow) { view in
view.shadow(color: .black, radius: 10, x: 0.0, y: 0.0)
}
}
}
// 这对应用程序的性能也有好处,因为如果用户使用旧设备,您可以让用户禁用阴影和所有其他 CPU 开销较大的修饰符,例如模糊视图。
有时候,当我们使用三元表达式去控制修饰符的参数时,会遇到想要的两种效果的类型不一致的报错情况,例如背景填充 color
或 material
这时我们可以通过给它们转成 AnyView
类型来修复
.background(
hasSimpleWave || hasComplexWave ?
AnyView(Color(.secondarySystemBackground)) :
AnyView(Color.clear.background(.regularMaterial))
)
// material 转成 AnyView 时要用 color.clear.background 的写法