【iOS】SwiftUI常用控件

旨在快速打通iOS开发流程

By yesmore on 2022-11-29
阅读时间 86 分钟
文章共 16.3k
阅读量

本章内容概览:SwiftUI常用控件

同系列文章请查看:快乐码元 - iOS篇

1.介绍

SwiftUI 是什么?

对于一个基于UIKit的项目是没有必要全部用SwiftUI重写的,在UIKit里使用SwiftUI的视图非常容易,UIHostingController是UIViewController的子类,可以直接用在UIKit里,因此直接将SwiftUI视图加到UIHostingController中,就可以在UIKit里使用SwiftUI视图了。

SwiftUI的布局核心是 GeometryReader、View Preferences和Anchor Preferences。如下图所示:

img

SwiftUI的数据流更适合Redux结构,如下图所示:

img

如上图,Redux结构是真正的单向单数据源结构,易于分割,能充分利用SwiftUI内置的数据流Property Wrapper。UI组件干净、体量小、可复用并且无业务逻辑,因此开发时可以聚焦于UI代码。业务逻辑放在一起,所有业务逻辑和数据Model都在Reducer里。 ACHNBrowserUIMovieSwiftUI 开源项目都是使用的Redux架构。最近比较瞩目的TCA(The Composable Architecture)也是类Redux/Elm的架构的框架, 项目地址见

提到数据流就不得不说下苹果公司新出的Combine,对标的是RxSwift,由于是苹果公司官方的库,所以应该优先选择。不过和SwiftUI一样,这两个新库对APP支持最低的系统版本都要求是iOS13及以上。那么怎么能够提前用上SwiftUI和Combine呢?或者说现在使用什么库可以以相同接口方式暂时替换它们,又能在以后改为SwiftUI和Combine时成本最小化呢?

对于SwiftUI,AcFun自研了声明式UI Ysera,类似SwiftUI的接口,并且重构了AcFun里收藏模块列表视图和交互逻辑,如下图所示:

img

通过上图可以看到,swift代码量相比较OC减少了65%以上,原先使用Objective-C实现的相同功能代码超过了1000行,而Swift重写只需要350行,对于AcFun的业务研发工程师而言,同样的需求实现代码比之前少了至少30%,面对单周迭代这样的节奏,团队也变得更从容。代码可读性增加了,后期功能迭代和维护更容易了,Swift让AcFun驶入了iOS开发生态的“快车道”。

SwiftUI全部都是基于Swift的各大可提高开发效率特性完成的,比如前面提到的,能够访问只给语言特性级别行为的Property Wrapper,通过Property Wrapper包装代码逻辑,来降低代码复杂度,除了SwiftUI和Combine里@开头的Property Wrapper外,Swift还自带类似 @dynamicMemberLookup@dynamicCallable 这样重量级的Property Wrapper。还有 ResultBuilder 这种能够简化语法的特性,有些如GraphQL、REST和Networking实际使用ResultBuilder的 范例可以参考 。这些Swift的特性如果也能得到充分利用,即使不用SwiftUI也能使开发效率得到大幅提升。

网飞(Netflix)App已使用SwiftUI重构了登录界面,网飞增长团队移动负责人故胤道长记录了SwiftUI在网飞的落地过程,详细描述了 SwiftUI的收益 。网飞能够直接使用SwiftUI得益于他们最低支持iOS 13系统。

不过如最低支持系统低于iOS 13,还有开源项目 AltSwiftUI 也实现了SwiftUI的语法和特性,能够向前兼容到iOS 11。

Kuba Suder 做了一个 SwiftUI Index/Changelog ,从官方文档中提取版本信息,一目了然 SwiftUI 每个版本 view,modifier 还有属性做了哪些增加和改变。当然也包括这次 SwiftUI 4 的更新。还有份对今年更新整理的 cheat sheet What’s New In SwiftUI for iOS Cheat Sheet - WWDC22

SwiftUI 4 做了大量细节更新,比如添加了后台任务函数 backgroundTask(_:action:) 。List 改用 UICollectionView。AnyLayout 让 HStack 和 VStack 之间可以自由切换。scrollDismissesKeyboard() modifier 可以让键盘在滚动时自动 dismiss。scrollIndicators() modifier 可以隐藏 ScrollView 和 List 等视图的滚动指示。defersSystemGestures() modifier 允许我们的手势优先于系统的内置手势。颜色的 .gradient 可以获得很简单的渐变,Rectangle().fill(.red.gradient),还有 .shadow 用来创建投影 Rectangle().fill(.red.shadow(.drop(color: .black, radius: 10))),还有 .inner 内阴影。lineLimit() modifier 支持范围设置。还有一些 modifier 支持 toggle 参数,比如 .bold().italic() 等,这样利于运行时进行调整。

嵌入 UIKit
示例如下:

1
2
3
4
5
6
7
8
9
cell.contentConfiguration = UIHostingConfiguration {
VStack {
Image(systemName: "wand.and.stars")
.font(.title)
Text("Like magic!")
.font(.title2).bold()
}
.foregroundStyle(Color.purple)
}

锁屏的 Widget 和 WatchOS 一样,可以瞟一眼就获取信息。

官方指南 Creating Lock Screen Widgets and Watch Complications

可以将 SwiftUI 的 View 生成图片。

官方参考文档 ImageRenderer

session Efficiency awaits: Background tasks in SwiftUI 了解如何使用 SwiftUI 后台任务 API 简洁地处理任务。展示如何使用 Swift Concurrency 来处理网络响应、后台刷新等——同时保持性能和功率。

SwiftUI 参考资料

session:

社区整理的和 SwiftUI 的 digital lounges 内容:

2.视图组件使用

SwiftUI 对标的 UIKit 视图

如下:

SwiftUI UIKit
Text 和 Label UILabel
TextField UITextField
TextEditor UITextView
Button 和 Link UIButton
Image UIImageView
NavigationView UINavigationController 和 UISplitViewController
ToolbarItem UINavigationItem
ScrollView UIScrollView
List UITableView
LazyVGrid 和 LazyHGrid UICollectionView
HStack 和 LazyHStack UIStack
VStack 和 LazyVStack UIStack
TabView UITabBarController 和 UIPageViewController
Toggle UISwitch
Slider UISlider
Stepper UIStepper
ProgressView UIProgressView 和 UIActivityIndicatorView
Picker UISegmentedControl
DatePicker UIDatePicker
Alert UIAlertController
ActionSheet UIAlertController
Map MapKit

Text

基本用法

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// MARK: - Text
struct PlayTextView: View {
let manyString = "这是一段长文。总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么,总得说点什么吧。"
var body: some View {
ScrollView {
Group {
Text("大标题").font(.largeTitle)
Text("说点啥呢?")
.tracking(30) // 字间距
.kerning(30) // 尾部留白
Text("划重点")
.underline()
.foregroundColor(.yellow)
.fontWeight(.heavy)
Text("可旋转的文字")
.rotationEffect(.degrees(45))
.fixedSize()
.frame(width: 20, height: 80)
Text("自定义系统字体大小")
.font(.system(size: 30))
Text("使用指定的字体")
.font(.custom("Georgia", size: 24))
}
Group {
Text("有阴影")
.font(.largeTitle)
.foregroundColor(.orange)
.bold()
.italic()
.shadow(color: .black, radius: 1, x: 0, y: 2)
Text("Gradient Background")
.font(.largeTitle)
.padding()
.foregroundColor(.white)
.background(LinearGradient(gradient: Gradient(colors: [.white, .black, .red]), startPoint: .top, endPoint: .bottom))
.cornerRadius(10)
Text("Gradient Background")
.padding(5)
.foregroundColor(.white)
.background(LinearGradient(gradient: Gradient(colors: [.white, .black, .purple]), startPoint: .leading, endPoint: .trailing))
.cornerRadius(10)
ZStack {
Text("渐变透明材质风格")
.padding()
.background(
.regularMaterial,
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
)
.shadow(radius: 10)
.padding()
.font(.largeTitle.weight(.black))
}
.frame(width: 300, height: 200)
.background(
LinearGradient(colors: [.yellow, .pink], startPoint: .topLeading, endPoint: .bottomTrailing)
)
Text("Angular Gradient Background")
.padding()
.background(AngularGradient(colors: [.red, .yellow, .green, .blue, .purple, .red], center: .center))
.cornerRadius(20)
Text("带背景图片的")
.padding()
.font(.largeTitle)
.foregroundColor(.white)
.background {
Rectangle()
.fill(Color(.black))
.cornerRadius(10)
Image("logo")
.resizable()
.frame(width: 100, height: 100)
}
.frame(width: 200, height: 100)
}

Group {
// 设置 lineLimit 表示最多支持行数,依据情况依然有会被减少显示行数
Text(manyString)
.lineLimit(3) // 对行的限制,如果多余设定行数,尾部会显示...
.lineSpacing(10) // 行间距
.multilineTextAlignment(.leading) // 对齐

// 使用 fixedSize 就可以在任何时候完整显示
Text(manyString)
.fixedSize(horizontal: false, vertical: true)

}

// 使用 AttributeString
PTextViewAttribute()
.padding()

// 使用 Markdown
PTextViewMarkdown()
.padding()

// 时间
PTextViewDate()

// 插值
PTextViewInterpolation()
}

}
}

font 字体设置的样式对应 weight 和 size 可以在官方交互文档中查看 Typography

markdown 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// MARK: - Markdown
struct PTextViewMarkdown: View {
let mdaStr: AttributedString = {

var mda = AttributedString(localized: "这是一个 **Attribute** ~string~")

/// 自定义的属性语法是^[string](key:value)
mda = AttributedString(localized: "^[这是](p2:'one')^[一](p3:{k1:1,k2:2})个 **Attribute** ~string~", including: \.newScope)
print(mda)
/// 这是 {
/// NSLanguage = en
/// p2 = one
/// }
/// 一 {
/// NSLanguage = en
/// p3 = P3(k1: 1, k2: 2)
/// }
/// 个 {
/// NSLanguage = en
/// }
/// Attribute {
/// NSLanguage = en
/// NSInlinePresentationIntent = NSInlinePresentationIntent(rawValue: 2)
/// }
/// {
/// NSLanguage = en
/// }
/// string {
/// NSInlinePresentationIntent = NSInlinePresentationIntent(rawValue: 32)
/// NSLanguage = en
/// }

// 从文件中读取 Markdown 内容
let mdUrl = Bundle.main.url(forResource: "1", withExtension: "md")!
mda = try! AttributedString(contentsOf: mdUrl,options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace), baseURL: nil) // .inlineOnlyPreservingWhitespace 支持 markdown 文件的换行

// Markdown 已转换成 AtrributedString 结构。
for r in mda.runs {
if let ipi = r.inlinePresentationIntent {
switch ipi {
case .lineBreak:
print("paragrahp")
case .code:
print("this is code")
default:
break
}
}
if let pi = r.presentationIntent {
for c in pi.components {
switch c.kind {
case .paragraph:
print("this is paragraph")
case .codeBlock(let lang):
print("this is \(lang ?? "") code")
case .header(let level):
print("this is \(level) level")
default:
break
}
}
}
}

return mda
}()
var body: some View {
Text(mdaStr)
}
}

AttributedString 的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// MARK: - AttributedString
struct PTextViewAttribute: View {
let aStr: AttributedString = {
var a1 = AttributedString("这是一个 ")
var c1 = AttributeContainer()
c1.font = .footnote
c1.foregroundColor = .secondary
a1.setAttributes(c1)

var a2 = AttributedString("Attribute ")
var c2 = AttributeContainer()
c2.font = .title
a2.setAttributes(c2)

var a3 = AttributedString("String ")
var c3 = AttributeContainer()
c3.baselineOffset = 10
c3.appKit.foregroundColor = .yellow // 仅在 macOS 里显示的颜色
c3.swiftUI.foregroundColor = .secondary
c3.font = .footnote
a3.setAttributes(c3)
// a3 使用自定义属性
a3.p1 = "This is a custom property."

// formatter 的支持
var a4 = Date.now.formatted(.dateTime
.hour()
.minute()
.weekday()
.attributed
)

let c4AMPM = AttributeContainer().dateField(.amPM)
let c4AMPMColor = AttributeContainer().foregroundColor(.green)

a4.replaceAttributes(c4AMPM, with: c4AMPMColor)
let c4Week = AttributeContainer().dateField(.weekday)
let c4WeekColor = AttributeContainer().foregroundColor(.purple)
a4.replaceAttributes(c4Week, with: c4WeekColor)

a1.append(a2)
a1.append(a3)
a1.append(a4)



// Runs 视图
for r in a1.runs {
print(r)
}
/// 这是一个 {
/// SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7ff91d4a5e90).FontBox<SwiftUI.Font.(unknown context at $7ff91d4ad5d8).TextStyleProvider>)
/// SwiftUI.ForegroundColor = secondary
/// }
/// Attribute {
/// SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7ff91d4a5e90).FontBox<SwiftUI.Font.(unknown context at $7ff91d4ad5d8).TextStyleProvider>)
/// }
/// String {
/// SwiftUI.ForegroundColor = secondary
/// SwiftUI.BaselineOffset = 10.0
/// NSColor = sRGB IEC61966-2.1 colorspace 1 1 0 1
/// SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7ff91d4a5e90).FontBox<SwiftUI.Font.(unknown context at $7ff91d4ad5d8).TextStyleProvider>)
/// p1 = This is a custom property.
/// }
/// Tue {
/// SwiftUI.ForegroundColor = purple
/// }
/// {
/// }
/// 5 {
/// Foundation.DateFormatField = hour
/// }
/// : {
/// }
/// 16 {
/// Foundation.DateFormatField = minute
/// }
/// {
/// }
/// PM {
/// SwiftUI.ForegroundColor = green
/// }

return a1
}()
var body: some View {
Text(aStr)
}
}

// MARK: - 自定 AttributedString 属性
struct PAKP1: AttributedStringKey {
typealias Value = String
static var name: String = "p1"


}
struct PAKP2: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
public enum P2: String, Codable {
case one, two, three
}

static var name: String = "p2"
typealias Value = P2
}
struct PAKP3: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
public struct P3: Codable, Hashable {
let k1: Int
let k2: Int
}
typealias Value = P3
static var name: String = "p3"
}
extension AttributeScopes {
public struct NewScope: AttributeScope {
let p1: PAKP1
let p2: PAKP2
let p3: PAKP3
}
var newScope: NewScope.Type {
NewScope.self
}
}

extension AttributeDynamicLookup{
subscript<T>(dynamicMember keyPath:KeyPath<AttributeScopes.NewScope,T>) -> T where T:AttributedStringKey {
self[T.self]
}
}

时间的显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// MARK: - 时间
struct PDateTextView: View {
let date: Date = Date()
let df: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .long
df.timeStyle = .short
return df
}()
var dv: String {
return df.string(from: date)
}
var body: some View {
HStack {
Text(dv)
}
.environment(\.locale, Locale(identifier: "zh_cn"))
}
}

插值使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// MARK: - 插值
struct PTextViewInterpolation: View {
let nf: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .currencyPlural
return f
}()
var body: some View {
VStack {
Text("图文 \(Image(systemName: "sun.min"))")
Text("💰 \(999 as NSNumber, formatter: nf)")
.environment(\.locale, Locale(identifier: "zh_cn"))
Text("数组: \(["one", "two"])")
Text("红字:\(red: "变红了"),带图标的字:\(sun: "天晴")")
}
}
}

// 扩展 LocalizedStringKey.StringInterpolation 自定义插值
extension LocalizedStringKey.StringInterpolation {
// 特定类型处理
mutating func appendInterpolation(_ value: [String]) {
for s in value {
appendLiteral(s + "")
appendInterpolation(Text(s + " ").bold().foregroundColor(.secondary))
}
}

// 实现不同情况处理,可以简化设置修改器设置
mutating func appendInterpolation(red value: LocalizedStringKey) {
appendInterpolation(Text(value).bold().foregroundColor(.red))
}
mutating func appendInterpolation(sun value: String) {
appendInterpolation(Image(systemName: "sun.max.fill"))
appendLiteral(value)
}
}

使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
struct PlayLinkView: View {
@Environment(\.openURL) var openURL
var aStr: AttributedString {
var a = AttributedString("戴铭的博客")
a.link = URL(string: "https://ming1016.github.io/")
return a
}
var body: some View {
VStack {
// 普通
Link("前往 www.starming.com", destination: URL(string: "http://www.starming.com")!)
.buttonStyle(.borderedProminent)
Link(destination: URL(string: "https://twitter.com/daiming_cn")!) {
Label("My Twitter", systemImage: "message.circle.fill")
}

// AttributedString 链接
Text(aStr)

// markdown 链接
Text("[Go Ming's GitHub](https://github.com/ming1016)")

// 控件使用 OpenURL
Link("小册子源码", destination: URL(string: "https://github.com/ming1016/SwiftPamphletApp")!)
.environment(\.openURL, OpenURLAction { url in
return .systemAction
/// return .handled 不会返回系统打开浏览器动作,只会处理 return 前的事件。
/// .discard 和 .handled 类似。
/// .systemAction(URL(string: "https://www.anotherurl.com")) 可以返回另外一个 url 来替代指定的url
})

// 扩展 View 后更简洁的使用 OpenURL
Link("戴铭的微博", destination: URL(string: "https://weibo.com/allstarming")!)
.goOpenURL { url in
print(url.absoluteString)
return .systemAction
}

// 根据内容返回不同链接
Text("戴铭博客有好几个,存在[GitHub Page](github)、[自建服务器](starming)和[知乎](zhihu)上")
.environment(\.openURL, OpenURLAction { url in
switch url.absoluteString {
case "github":
return .systemAction(URL(string: "https://ming1016.github.io/")!)
case "starming":
return .systemAction(URL(string: "http://www.starming.com")!)
case "zhihu":
return .systemAction(URL(string: "https://www.zhihu.com/people/starming/posts")!)
default:
return .handled
}
})
} // end VStack
.padding()

}

// View 支持 openURL 的能力
func goUrl(_ url: URL, done: @escaping (_ accepted: Bool) -> Void) {
openURL(url, completion: done)
}
}

// 为 View 扩展一个 OpenURL 方法
extension View {
func goOpenURL(done: @escaping (URL) -> OpenURLAction.Result) -> some View {
environment(\.openURL, OpenURLAction(handler: done))
}
}

View 的 onOpenURL 方法可以处理 Universal Links。

1
2
3
4
5
6
7
8
9
10
struct V: View {
var body: some View {
VStack {
Text("hi")
}
.onOpenURL { url in
print(url.absoluteString)
}
}
}

Label

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
struct PlayLabelView: View {
var body: some View {
VStack(spacing: 10) {
Label("一个 Label", systemImage: "bolt.circle")

Label("只显示 icon", systemImage: "heart.fill")
.labelStyle(.iconOnly)
.foregroundColor(.red)

// 自建 Label
Label {
Text("自建 Label")
.foregroundColor(.orange)
.bold()
.font(.largeTitle)
.shadow(color: .black, radius: 1, x: 0, y: 2)
} icon: {
Image("p3")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30)
.shadow(color: .black, radius: 1, x: 0, y: 2)
}


// 自定义 LabelStyle
Label("有边框的 Label", systemImage: "b.square.fill")
.labelStyle(.border)

Label("仅标题有边框", systemImage: "text.bubble")
.labelStyle(.borderOnlyTitle)

// 扩展的 Label
Label("扩展的 Label", originalSystemImage: "cloud.sun.bolt.fill")

} // end VStack
} // end body
}

// 对 Label 做扩展
extension Label where Title == Text, Icon == Image {
init(_ title: LocalizedStringKey, originalSystemImage systemImageString: String) {
self.init {
Text(title)
} icon: {
Image(systemName: systemImageString)
.renderingMode(.original) // 让 SFSymbol 显示本身的颜色
}

}
}

// 添加自定义 LabelStyle,用来加上边框
struct BorderLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
Label(configuration)
.padding()
.overlay(RoundedRectangle(cornerRadius: 20)
.stroke(.purple, lineWidth: 4))
.shadow(color: .black, radius: 4, x: 0, y: 5)
.labelStyle(.automatic) // 样式擦除器,防止样式被 .iconOnly、.titleOnly 这样的 LabelStyle 擦除了样式。

}
}
extension LabelStyle where Self == BorderLabelStyle {
internal static var border: BorderLabelStyle {
BorderLabelStyle()
}
}

// 只给标题加边框
struct BorderOnlyTitleLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon
configuration.title
.padding()
.overlay(RoundedRectangle(cornerRadius: 20)
.stroke(.pink, lineWidth: 4))
.shadow(color: .black, radius: 1, x: 0, y: 1)
.labelStyle(.automatic)
}
}
}
extension LabelStyle where Self == BorderOnlyTitleLabelStyle {
internal static var borderOnlyTitle: BorderOnlyTitleLabelStyle {
BorderOnlyTitleLabelStyle()
}
}

TextEditor

img

对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import SwiftUI
import CodeEditorView

struct PlayTextEditorView: View {
// for TextEditor
@State private var txt: String = "一段可编辑文字...\n"
@State private var count: Int = 0

// for CodeEditorView
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@State private var codeMessages: Set<Located<Message>> = Set ()
@SceneStorage("editLocation") private var editLocation: CodeEditor.Location = CodeEditor.Location()
var body: some View {

// 使用 SwiftUI 自带 TextEditor
TextEditor(text: $txt)
.font(.title)
.lineSpacing(10)
.disableAutocorrection(true)
.padding()
.onChange(of: txt) { newValue in
count = txt.count
}
Text("字数:\(count)")
.foregroundColor(.secondary)
.font(.footnote)

// 使用的 CodeEditorView 显示和编辑代码高亮的代码,还有 minimap
CodeEditor(text: .constant("""
static func number() {
// Int
let i1 = 100
let i2 = 22
print(i1 / i2) // 向下取整得 4

// Float
let f1: Float = 100.0
let f2: Float = 22.0
print(f1 / f2) // 4.5454545

let f4: Float32 = 5.0
let f5: Float64 = 5.0
print(f4, f5) // 5.0 5.0 5.0

// Double
let d1: Double = 100.0
let d2: Double = 22.0
print(d1 / d2) // 4.545454545454546

// 字面量
print(Int(0b10101)) // 0b 开头是二进制
print(Int(0x00afff)) // 0x 开头是十六进制
print(2.5e4) // 2.5x10^4 十进制用 e
print(0xAp2) // 10*2^2 十六进制用 p
print(2_000_000) // 2000000

// isMultiple(of:) 方法检查一个数字是否是另一个数字的倍数
let i3 = 36
print(i3.isMultiple(of: 9)) // true
}
"""),
messages: $codeMessages,
language: .swift,
layout: CodeEditor.LayoutConfiguration(showMinimap: true)
)
.environment(\.codeEditorTheme, colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight)

// 包装的 NSTextView
HSplitView {
PNSTextView(text: .constant("左边写...\n"), onDidChange: { (s, i) in
print("Typing \(i) times.")
})
.padding()
PNSTextView(text: .constant("右边写...\n"))
.padding()
} // end HSplitView
} // end body
}

// MARK: - 自己包装 NSTextView
struct PNSTextView: NSViewRepresentable {
@Binding var text: String
var onBeginEditing: () -> Void = {}
var onCommit: () -> Void = {}
var onDidChange: (String, Int) -> Void = { _,_ in }

// 返回要包装的 NSView
func makeNSView(context: Context) -> PNSTextConfiguredView {
let t = PNSTextConfiguredView(text: text)
t.delegate = context.coordinator
return t
}

func updateNSView(_ view: PNSTextConfiguredView, context: Context) {
view.text = text
view.selectedRanges = context.coordinator.sRanges
}

// 回调
func makeCoordinator() -> TextViewDelegate {
TextViewDelegate(self)
}
}

// 处理 delegate 回调
extension PNSTextView {
class TextViewDelegate: NSObject, NSTextViewDelegate {
var tView: PNSTextView
var sRanges: [NSValue] = []
var typeCount: Int = 0

init(_ v: PNSTextView) {
self.tView = v
}
// 开始编辑
func textDidBeginEditing(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
self.tView.text = textView.string
self.tView.onBeginEditing()
}
// 每次敲字
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
typeCount += 1
self.tView.text = textView.string
self.sRanges = textView.selectedRanges
self.tView.onDidChange(textView.string, typeCount)
}
// 提交
func textDidEndEditing(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
self.tView.text = textView.string
self.tView.onCommit()
}
}
}

// 配置 NSTextView
final class PNSTextConfiguredView: NSView {
weak var delegate: NSTextViewDelegate?

private lazy var tv: NSTextView = {
let contentSize = sv.contentSize
let textStorage = NSTextStorage()

let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)

let textContainer = NSTextContainer(containerSize: sv.frame.size)
textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(
width: contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)

layoutManager.addTextContainer(textContainer)

let t = NSTextView(frame: .zero, textContainer: textContainer)
t.delegate = self.delegate
t.isEditable = true
t.allowsUndo = true

t.font = .systemFont(ofSize: 24)
t.textColor = NSColor.labelColor
t.drawsBackground = true
t.backgroundColor = NSColor.textBackgroundColor

t.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
t.minSize = NSSize(width: 0, height: contentSize.height)
t.autoresizingMask = .width

t.isHorizontallyResizable = false
t.isVerticallyResizable = true

return t
}()

private lazy var sv: NSScrollView = {
let s = NSScrollView()
s.drawsBackground = true
s.borderType = .noBorder
s.hasVerticalScroller = true
s.hasHorizontalRuler = false
s.translatesAutoresizingMaskIntoConstraints = false
s.autoresizingMask = [.width, .height]
return s
}()

var text: String {
didSet {
tv.string = text
}
}

var selectedRanges: [NSValue] = [] {
didSet {
guard selectedRanges.count > 0 else {
return
}
tv.selectedRanges = selectedRanges
}
}

required init?(coder: NSCoder) {
fatalError("Error coder")
}

init(text: String) {
self.text = text
super.init(frame: .zero)
}

override func viewWillDraw() {
super.viewWillDraw()
sv.translatesAutoresizingMaskIntoConstraints = false
addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: topAnchor),
sv.trailingAnchor.constraint(equalTo: trailingAnchor),
sv.bottomAnchor.constraint(equalTo: bottomAnchor),
sv.leadingAnchor.constraint(equalTo: leadingAnchor)
])
sv.documentView = tv
} // end viewWillDraw

}

SwiftUI 中用 NSView,可以通过 NSViewRepresentable 来包装视图,这个协议主要是实现 makeNSView、updateNSView 和 makeCoordinator 三个方法。makeNSView 要求返回需要包装的 NSView。每当 SwiftUI 的状态变化时触发 updateNSView 方法的调用。为了实现 NSView 里的 delegate 和 SwiftUI 通信,就要用 makeCoordinator 返回一个用于处理 delegate 的实例。

TextField

img

使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
struct PlayTextFieldView: View {
@State private var t = "Starming"
@State private var showT = ""
@State private var isEditing = false
var placeholder = "输入些文字..."

@FocusState private var isFocus: Bool

var body: some View {
VStack {
TextField(placeholder, text: $t)

// 样式设置
TextField(placeholder, text: $t)
.padding(10)
.textFieldStyle(.roundedBorder) // textFieldStyle 有三个预置值 automatic、plain 和 roundedBorder。
.multilineTextAlignment(.leading) // 对齐方式
.font(.system(size: 14, weight: .heavy, design: .rounded))
.border(.teal, width: 4)
.background(.white)
.foregroundColor(.brown)
.textCase(.uppercase)

// 多视图组合
HStack {
Image(systemName: "lock.circle")
.foregroundColor(.gray).font(.headline)
TextField(placeholder, text: $t)
.textFieldStyle(.plain)
.submitLabel(.done)
.onSubmit {
showT = t
isFocus = true
}
.onChange(of: t) { newValue in
t = String(newValue.prefix(20)) // 限制字数
}
Image(systemName: "eye.slash")
.foregroundColor(.gray)
.font(.headline)
}
.padding()
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.gray, lineWidth: 1)
)
.padding(.horizontal)

Text(showT)


// 自定义 textFieldStyle 样式
TextField(placeholder, text: $t)
.textFieldStyle(PClearTextStyle())
.focused($isFocus)
}
.padding()
} // end body
}

struct PClearTextStyle: TextFieldStyle {
@ViewBuilder
func _body(configuration: TextField<_Label>) -> some View {
let mirror = Mirror(reflecting: configuration)
let bindingText: Binding<String> = mirror.descendant("_text") as! Binding<String>
configuration
.overlay(alignment: .trailing) {
Button(action: {
bindingText.wrappedValue = ""
}, label: {
Image(systemName: "clear")
})
}

let text: String = mirror.descendant("_text", "_value") as! String
configuration
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(text.count > 10 ? .pink : .gray, lineWidth: 4)
)
} // end func
}

目前iOS 和 iPadOS上支持的键盘有:

  • asciiCapable:能显示标准 ASCII 字符的键盘
  • asciiCapableNumberPad:只输出 ASCII 数字的数字键盘
  • numberPad:用于输入 PIN 码的数字键盘
  • numbersAndPunctuation:数字和标点符号的键盘
  • decimalPad:带有数字和小数点的键盘
  • phonePad:电话中使用的键盘
  • namePhonePad:用于输入人名或电话号码的小键盘
  • URL:用于输入URL的键盘
  • emailAddress:用于输入电子邮件地址的键盘
  • twitter:用于Twitter文本输入的键盘,支持@和#字符简便输入
  • webSearch:用于网络搜索词和URL输入的键盘

可以通过 keyboardType 修改器来指定。

支持多行,使用 Axis.vertical 以允许多行。TextField 超过行限制可以变成滚动视图。

今年 TextField 可以嵌到 .alert 里了。

Button

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
struct PlayButtonView: View {
var asyncAction: () async -> Void = {
do {
try await Task.sleep(nanoseconds: 300_000_000)
} catch {}
}
@State private var isFollowed: Bool = false
var body: some View {
VStack {
// 常用方式
Button {
print("Clicked")
} label: {
Image(systemName: "ladybug.fill")
Text("Report Bug")
}

// 图标
Button(systemIconName: "ladybug.fill") {
print("bug")
}
.buttonStyle(.plain) // 无背景
.simultaneousGesture(LongPressGesture().onEnded({ _ in
print("长按") // macOS 暂不支持
}))
.simultaneousGesture(TapGesture().onEnded({ _ in
print("短按") // macOS 暂不支持
}))


// iOS 15 修改器的使用。role 在 macOS 上暂不支持
Button("要删除了", role: .destructive) {
print("删除")
}
.tint(.purple)
.controlSize(.large) // .regular 是默认大小
.buttonStyle(.borderedProminent) // borderedProminent 可显示 tint 的设置。还有 bordered、plain 和 borderless 可选。
.clipShape(RoundedRectangle(cornerRadius: 5))
.accentColor(.pink)
.buttonBorderShape(.automatic) // 会依据 controlSize 调整边框样式
.background(.ultraThinMaterial, in: Capsule()) // 添加材质就像在视图和背景间加了个透明层达到模糊的效果。效果由高到底分别是.ultraThinMaterial、.thinMaterial、.regularMaterial、.thickMaterial、.ultraThickMaterial。

// 风格化
Button(action: {
//
}, label: {
Text("风格化").font(.largeTitle)
})
.buttonStyle(PStarmingButtonStyle())


// 自定义 Button
PCustomButton("点一下触发") {
print("Clicked!")
}

// 自定义 ButtonStyle
Button {
print("Double Clicked!")
} label: {
Text("点两下触发")
}
.buttonStyle(PCustomPrimitiveButtonStyle())

// 将 Text 视图加上另一个 Text 视图中,类型仍还是 Text。
PCustomButton(Text("点我 ").underline() + Text("别犹豫").font(.title) + Text("🤫悄悄说声,有惊喜").font(.footnote).foregroundColor(.secondary)) {
print("多 Text 组合标题按钮点击!")
}

// 异步按钮
ButtonAsync {
await asyncAction()
isFollowed = true
} label: {
if isFollowed == true {
Text("已关注")
} else {
Text("关注")
}
}
.font(.largeTitle)
.disabled(isFollowed)
.buttonStyle(PCustomButtonStyle(backgroundColor: isFollowed == true ? .gray : .pink))
}
.padding()
.background(Color.skeumorphismBG)

}
}

// MARK: - 异步操作的按钮
struct ButtonAsync<Label: View>: View {
var doAsync: () async -> Void
@ViewBuilder var label: () -> Label
@State private var isRunning = false // 避免连续点击造成重复执行事件

var body: some View {
Button {
isRunning = true
Task {
await doAsync()
isRunning = false
}
} label: {
label().opacity(isRunning == true ? 0 : 1)
if isRunning == true {
ProgressView()
}
}
.disabled(isRunning)

}
}

// MARK: - 扩展 Button
// 使用 SFSymbol 做图标
extension Button where Label == Image {
init(systemIconName: String, done: @escaping () -> Void) {
self.init(action: done) {
Image(systemName: systemIconName)
.renderingMode(.original)
}
}
}

// MARK: - 自定义 Button
struct PCustomButton: View {
let desTextView: Text
let act: () -> Void

init(_ des: LocalizedStringKey, act: @escaping () -> Void) {
self.desTextView = Text(des)
self.act = act
}

var body: some View {
Button {
act()
} label: {
desTextView.bold()
}
.buttonStyle(.starming)
}
}

extension PCustomButton {
init(_ desTextView: Text, act: @escaping () -> Void) {
self.desTextView = desTextView
self.act = act
}
}

// 点语法使用自定义样式
extension ButtonStyle where Self == PCustomButtonStyle {
static var starming: PCustomButtonStyle {
PCustomButtonStyle(cornerRadius: 15)
}
}


// MARK: - ButtonStyle
struct PCustomButtonStyle: ButtonStyle {
var cornerRadius:Double = 10
var backgroundColor: Color = .pink
func makeBody(configuration: Configuration) -> some View {
HStack {
Spacer()
configuration.label
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(backgroundColor)
.shadow(color: configuration.isPressed ? .white : .black, radius: 1, x: 0, y: 1)
)
.opacity(configuration.isPressed ? 0.5 : 1)
.scaleEffect(configuration.isPressed ? 0.99 : 1)

}
}

// MARK: - PrimitiveButtonStyle
struct PCustomPrimitiveButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: Configuration) -> some View {
// 双击触发
configuration.label
.onTapGesture(count: 2) {
configuration.trigger()
}
// 手势识别
Button(configuration)
.gesture(
LongPressGesture()
.onEnded({ _ in
configuration.trigger()
})
)
}
}

// MARK: - 风格化
struct PStarmingButtonStyle: ButtonStyle {
var backgroundColor = Color.skeumorphismBG
func makeBody(configuration: Configuration) -> some View {
HStack {
Spacer()
configuration.label
Spacer()
}
.padding(20)
.background(
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.shadow(color: .white, radius: configuration.isPressed ? 7 : 10, x: configuration.isPressed ? -5 : -10, y: configuration.isPressed ? -5 : -10)
.shadow(color: .black, radius: configuration.isPressed ? 7 : 10, x: configuration.isPressed ? 5 : 10, y: configuration.isPressed ? 5 : 10)
.blendMode(.overlay)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(backgroundColor)
}
)
.scaleEffect(configuration.isPressed ? 0.98 : 1)
}
}

extension Color {
static let skeumorphismBG = Color(hex: "f0f0f3")
}

extension Color {
init(hex: String) {
var rgbValue: UInt64 = 0
Scanner(string: hex).scanHexInt64(&rgbValue)

let r = (rgbValue & 0xff0000) >> 16
let g = (rgbValue & 0xff00) >> 8
let b = rgbValue & 0xff

self.init(red: Double(r) / 0xff, green: Double(g) / 0xff, blue: Double(b) / 0xff)
}
}

.buttonStyle 可组合,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct PButtonStyleComposition: View {
@State private var isT = false
var body: some View {
Section("标签") {
VStack(alignment: .leading) {
HStack {
Toggle("Swift", isOn: $isT)
Toggle("SwiftUI", isOn: $isT)
}
HStack {
Toggle("Swift Chart", isOn: $isT)
Toggle("Navigation API", isOn: $isT)
}
}
.toggleStyle(.button)
.buttonStyle(.bordered)
}
}
}

Tap Location 可以获取点击的位置,示例代码如下:

1
2
3
4
5
6
Rectangle()
.fill(.green)
.frame(width: 50, height: 50)
.onTapGesture(coordinateSpace: .global) { location in
print("Tap in \(location)")
}

其中 coordinateSpace 指定为 .global 表示位置是相对屏幕左上角,默认是相对当前视图的左上角的位置。

进度

img

用 ProgressViewStyle 协议,可以创建自定义的进度条视图。在 WatchOS 上会多一个 Guage 视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
struct PlayProgressView: View {
@State private var v: CGFloat = 0.0
var body: some View {
VStack {
// 默认旋转
ProgressView()

// 有进度条
ProgressView(value: v / 100)
.tint(.yellow)

ProgressView(value: v / 100) {
Image(systemName: "music.note.tv")
}
.progressViewStyle(CircularProgressViewStyle(tint: .pink))

// 自定义样式
ProgressView(value: v / 100)
.padding(.vertical)
.progressViewStyle(PCProgressStyle1(borderWidth: 3))

ProgressView(value: v / 100)
.progressViewStyle(PCProgressStyle2())
.frame(height:200)

Slider(value: $v, in: 0...100, step: 1)
}
.padding(20)
}
}

// 自定义 Progress 样式
struct PCProgressStyle1: ProgressViewStyle {
var lg = LinearGradient(colors: [.purple, .black, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)
var borderWidth: Double = 2

func makeBody(configuration: Configuration) -> some View {
let fc = configuration.fractionCompleted ?? 0

return VStack {
ZStack(alignment: .topLeading) {
GeometryReader { g in
Rectangle()
.fill(lg)
.frame(maxWidth: g.size.width * CGFloat(fc))
}
}
.frame(height: 20)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(lg, lineWidth: borderWidth)
)
// end ZStack
} // end VStack
}
}

struct PCProgressStyle2: ProgressViewStyle {
var lg = LinearGradient(colors: [.orange, .yellow, .green, .blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)

var borderWidth: Double = 20

func makeBody(configuration: Configuration) -> some View {
let fc = configuration.fractionCompleted ?? 0

func strokeStyle(_ g: GeometryProxy) -> StrokeStyle {
StrokeStyle(lineWidth: 0.1 * min(g.size.width, g.size.height), lineCap: .round)
}

return VStack {
GeometryReader { g in
ZStack {
Group {
Circle()
.trim(from: 0, to: 1)
.stroke(lg, style: strokeStyle(g))
.padding(borderWidth)
.opacity(0.2)
Circle()
.trim(from: 0, to: fc)
.stroke(lg, style: strokeStyle(g))
.padding(borderWidth)
}
.rotationEffect(.degrees(90 + 360 * 0.5), anchor: .center)
.offset(x: 0, y: 0.1 * min(g.size.width, g.size.height))
}

Text("读取 \(Int(fc * 100)) %")
.bold()
.font(.headline)
}
// end ZStack
} // end VStack
}
}

SwiftUI 引入一个新显示进度的视图 Gauge。

简单示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct PGauge: View {
@State private var progress = 0.45
var body: some View {
Gauge(value: progress) {
Text("进度")
} currentValueLabel: {
Text(progress.formatted(.percent))
} minimumValueLabel: {
Text(0.formatted(.percent))
} maximumValueLabel: {
Text(100.formatted(.percent))
}

Gauge(value: progress) {

} currentValueLabel: {
Text(progress.formatted(.percent))
.font(.footnote)
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(.cyan)
}
}

Image

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct PlayImageView: View {
var body: some View {
Image("logo")
.resizable()
.frame(width: 100, height: 100)

Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
.clipShape(Circle())
.overlay(
Circle().stroke(.cyan, lineWidth: 4)
)
.shadow(radius: 10)

// SF Symbols
Image(systemName: "scissors")
.imageScale(.large)
.foregroundColor(.pink)
.frame(width: 40, height: 40)

// SF Symbols 多色时使用原色
Image(systemName: "thermometer.sun.fill")
.renderingMode(.original)
.imageScale(.large)
}
}

ControlGroup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct PlayControlGroupView: View {
var body: some View {
ControlGroup {
Button {
print("plus")
} label: {
Image(systemName: "plus")
}

Button {
print("minus")
} label: {
Image(systemName: "minus")
}
}
.padding()
.controlGroupStyle(.automatic) // .automatic 是默认样式,还有 .navigation
}
}

GroupBox

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct PlayGroupBoxView: View {
var body: some View {
GroupBox {
Text("这是 GroupBox 的内容")
} label: {
Label("标题一", systemImage: "t.square.fill")
}
.padding()

GroupBox {
Text("还是 GroupBox 的内容")
} label: {
Label("标题二", systemImage: "t.square.fill")
}
.padding()
.groupBoxStyle(PCGroupBoxStyle())

}
}

struct PCGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading) {
configuration.label
.font(.title)
configuration.content
}
.padding()
.background(.pink)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}

Stack

Stack View 有 VStack、HStack 和 ZStack

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct PlayStackView: View {
var body: some View {
// 默认是 VStack 竖排

// 横排
HStack {
Text("左")
Spacer()
Text("右")
}
.padding()

// Z 轴排
ZStack(alignment: .top) {
Image("logo")
Text("戴铭的开发小册子")
.font(.title)
.bold()
.foregroundColor(.white)
.shadow(color: .black, radius: 1, x: 0, y: 2)
.padding()
}

Color.cyan
.cornerRadius(10)
.frame(width: 100, height: 100)
.overlay(
Text("一段文字")
)
}
}

Advanced layout control

session Compose custom layouts with SwiftUI

提供了新的 Grid 视图来同时满足 VStack 和 HStack。还有一个更低级别 Layout 接口,可以完全控制构建应用所需的布局。另外还有 ViewThatFits 可以自动选择填充可用空间的方式。

Grid 示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Grid {
GridRow {
Text("One")
Text("One")
Text("One")
}
GridRow {
Text("Two")
Text("Two")
}
Divider()
GridRow {
Text("Three")
Text("Three")
.gridCellColumns(2)
}
}

gridCellColumns() modifier 可以让一个单元格跨多列。

ViewThatFits 的新视图,允许根据适合的大小放视图。ViewThatFits 会自动选择对于当前屏幕大小合适的子视图进行显示。Ryan Lintott 的示例效果 ,对应示例代码 LayoutThatFits.swift

新的 Layout 协议可以观看 Swift Talk 第 308 期 The Layout Protocol

通过符合 Layout 协议,我们可以自定义一个自定义的布局容器,直接参与 SwiftUI 的布局过程。新的 ProposedViewSize 结构,它是容器视图提供的大小。 Layout.Subviews 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public protocol Layout: Animatable {
static var layoutProperties: LayoutProperties { get }
associatedtype Cache = Void
typealias Subviews = LayoutSubviews

func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews)

func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing

/// We return our view size here, use the passed parameters for computing the
/// layout.
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache // 👈🏻 use this for calculated data shared among Layout methods
) -> CGSize

/// Use this to tell your subviews where to appear.
func placeSubviews(
in bounds: CGRect, // 👈🏻 region where we need to place our subviews into, origin might not be .zero
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
)

// ... there are more a couple more optional methods
}

下面例子是一个自定义的水平 stack 视图,为其所有子视图提供其最大子视图的宽度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
struct MyEqualWidthHStack: Layout {
/// Returns a size that the layout container needs to arrange its subviews.
/// - Tag: sizeThatFitsHorizontal
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
guard !subviews.isEmpty else { return .zero }

let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }

return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
height: maxSize.height)
}

/// Places the stack's subviews.
/// - Tag: placeSubviewsHorizontal
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
guard !subviews.isEmpty else { return }

let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)

let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
var nextX = bounds.minX + maxSize.width / 2

for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: nextX, y: bounds.midY),
anchor: .center,
proposal: placementProposal)
nextX += maxSize.width + spacing[index]
}
}

/// Finds the largest ideal size of the subviews.
private func maxSize(subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}

return maxSize
}

/// Gets an array of preferred spacing sizes between subviews in the
/// horizontal dimension.
private func spacing(subviews: Subviews) -> [CGFloat] {
subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 }
return subviews[index].spacing.distance(
to: subviews[index + 1].spacing,
along: .horizontal)
}
}
}

自定义 layout 只能访问子视图代理 Layout.Subviews ,而不是视图或数据模型。我们可以通过 LayoutValueKey 在每个子视图上存储自定义值,通过 layoutValue(key:value:) modifier 设置。

1
2
3
4
5
6
7
8
9
private struct Rank: LayoutValueKey {
static let defaultValue: Int = 1
}

extension View {
func rank(_ value: Int) -> some View { // 👈🏻 convenience method
layoutValue(key: Rank.self, value: value) // 👈🏻 the new modifier
}
}

然后,我们就可以通过 Layout 方法中的 Layout.Subviews 代理读取自定义 LayoutValueKey 值:

1
2
3
4
5
6
7
8
9
10
11
12
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let ranks = subviews.map { subview in
subview[Rank.self] // 👈🏻
}

// ...
}

要在布局之间变化使用动画,需要用 AnyLayout,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct PAnyLayout: View {
@State private var isVertical = false
var body: some View {
let layout = isVertical ? AnyLayout(VStack()) : AnyLayout(HStack())
layout {
Image(systemName: "star").foregroundColor(.yellow)
Text("Starming.com")
Text("戴铭")
}
Button("Click") {
withAnimation {
isVertical.toggle()
}
} // end button
} // end body
}

同时 Text 和图片也支持了样式布局变化,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct PTextTransitionsView: View {
@State private var expandMessage = true
private let mintWithShadow: AnyShapeStyle = AnyShapeStyle(Color.mint.shadow(.drop(radius: 2)))
private let primaryWithoutShadow: AnyShapeStyle = AnyShapeStyle(Color.primary.shadow(.drop(radius: 0)))

var body: some View {
Text("Dai Ming Swift Pamphlet")
.font(expandMessage ? .largeTitle.weight(.heavy) : .body)
.foregroundStyle(expandMessage ? mintWithShadow : primaryWithoutShadow)
.onTapGesture { withAnimation { expandMessage.toggle() }}
.frame(maxWidth: expandMessage ? 150 : 250)
.drawingGroup()
.padding(20)
.background(.cyan.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
}

控制导航启动状态、管理 size class 之间的 transition 和响应 deep link。

Navigation bar 有新的默认行为,如果没有提供标题,导航栏默认为 inline title 显示模式。使用 navigationBarTitleDisplayMode(_:) 改变显示模式。如果 navigation bar 没有标题、工具栏项或搜索内容,它就会自动隐藏。使用 .toolbar(.visible) modifier 显示一个空 navigation bar。

参考:

NavigationStack 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct PNavigationStack: View {
@State private var a = [1, 3, 9] // 深层链接
var body: some View {
NavigationStack(path: $a) {
List(1..<10) { i in
NavigationLink(value: i) {
Label("第 \(i) 行", systemImage: "\(i).circle")
}
}
.navigationDestination(for: Int.self) { i in
Text("第 \(i) 行内容")
}
.navigationTitle("NavigationStack Demo")
}
}
}

这里的 path 设置了 stack 的深度路径。

NavigationSplitView 两栏的例子:

1
2
3
4
5
6
7
8
9
10
11
12
struct PNavigationSplitViewTwoColumn: View {
@State private var a = ["one", "two", "three"]
@State private var choice: String?

var body: some View {
NavigationSplitView {
List(a, id: \.self, selection: $choice, rowContent: Text.init)
} detail: {
Text(choice ?? "选一个")
}
}
}

NavigationSplitView 三栏的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct PNavigationSplitViewThreeColumn: View {
struct Group: Identifiable, Hashable {
let id = UUID()
var title: String
var subs: [String]
}

@State private var gps = [
Group(title: "One", subs: ["o1", "o2", "o3"]),
Group(title: "Two", subs: ["t1", "t2", "t3"])
]

@State private var choiceGroup: Group?
@State private var choiceSub: String?

@State private var cv = NavigationSplitViewVisibility.automatic

var body: some View {
NavigationSplitView(columnVisibility: $cv) {
List(gps, selection: $choiceGroup) { g in
Text(g.title).tag(g)
}
.navigationSplitViewColumnWidth(250)
} content: {
List(choiceGroup?.subs ?? [], id: \.self, selection: $choiceSub) { s in
Text(s)
}
} detail: {
Text(choiceSub ?? "选一个")
Button("点击") {
cv = .all
}
}
.navigationSplitViewStyle(.prominentDetail)
}
}

navigationSplitViewColumnWidth() 是用来自定义宽的,navigationSplitViewStyle 设置为 .prominentDetail 是让 detail 的视图尽量保持其大小。

SwiftUI 新加了个功能可以配置是否隐藏 Tabbar,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

1
ContentView().toolbar(.hidden, in: .tabBar)

相比较以前 NavigationView 增强的是 destination 可以根据值的不同类型展示不同的目的页面,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct PNavigationStackDestination: View {
var body: some View {
NavigationStack {
List {
NavigationLink(value: "字符串") {
Text("字符串")
}
NavigationLink(value: Color.red) {
Text("红色")
}
}
.navigationTitle("不同类型 Destination")
.navigationDestination(for: Color.self) { c in
c.clipShape(Circle())
}
.navigationDestination(for: String.self) { s in
Text("\(s) 的 detail")
}
}
}
}

对 toolbar 的自定义,示例如下:

1
2
3
4
5
6
7
8
.toolbar(id: "toolbar") {
ToolbarItem(id: "new", placement: .secondaryAction) {
Button(action: {}) {
Label("New Invitation", systemImage: "envelope")
}
}
}
.toolbarRole(.editor)

以下是废弃的 NavigationView 的用法。

img

对应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
struct PlayNavigationView: View {
let lData = 1...10
var body: some View {
NavigationView {
ZStack {
LinearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()

List(lData, id: \.self) { i in
NavigationLink {
PNavDetailView(contentStr: "\(i)")
} label: {
Text("\(i)")
}
}
}

ZStack {
LinearGradient(colors: [.mint, .yellow], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()

VStack {
Text("一个 NavigationView 的示例")
.bold()
.font(.largeTitle)
.shadow(color: .white, radius: 9, x: 0, y: 0)
.scaleEffect(2)
}
}
.safeAreaInset(edge: .bottom) {
HStack {
Button("bottom1") {}
.font(.headline)
Button("bottom2") {}
Button("bottom3") {}
Spacer()
}
.padding(5)
.background(LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing))
}
}
.foregroundColor(.white)
.navigationTitle("数字列表")
.toolbar {
// placement 共有 keyboard、destructiveAction、cancellationAction、confirmationAction、status、primaryAction、navigation、principal、automatic 这些
ToolbarItem(placement: .primaryAction) {
Button("primaryAction") {}
.background(.ultraThinMaterial)
.font(.headline)
}
// 通过 ToolbarItemGroup 可以简化相同位置 ToolbarItem 的编写。
ToolbarItemGroup(placement: .navigation) {
Button("返回") {}
Button("前进") {}
}
PCToolbar(doDestruct: {
print("删除了")
}, doCancel: {
print("取消了")
}, doConfirm: {
print("确认了")
})
ToolbarItem(placement: .status) {
Button("status") {}
}
ToolbarItem(placement: .principal) {
Button("principal") {

}
}
ToolbarItem(placement: .keyboard) {
Button("Touch Bar Button") {}
}
} // end toolbar
}
}

// MARK: - NavigationView 的目的页面
struct PNavDetailView: View {
@Environment(\.presentationMode) var pMode: Binding<PresentationMode>
var contentStr: String
var body: some View {
ZStack {
LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
VStack {
Text(contentStr)
Button("返回") {
pMode.wrappedValue.dismiss()
}
}
} // end ZStack
} // end body
}

// MARK: - 自定义 toolbar
// 通过 ToolbarContent 创建可重复使用的 toolbar 组
struct PCToolbar: ToolbarContent {
let doDestruct: () -> Void
let doCancel: () -> Void
let doConfirm: () -> Void

var body: some ToolbarContent {
ToolbarItem(placement: .destructiveAction) {
Button("删除", action: doDestruct)
}
ToolbarItem(placement: .cancellationAction) {
Button("取消", action: doCancel)
}
ToolbarItem(placement: .confirmationAction) {
Button("确定", action: doConfirm)
}
}
}

toolbar 的位置设置可选项如下:

  • primaryAction:放置到最主要位置,macOS 就是放在 toolbar 的最左边
  • automatic:根据平台不同放到默认位置
  • confirmationAction:一些确定的动作
  • cancellationAction:取消动作
  • destructiveAction:删除的动作
  • status:状态变化,比如检查更新等动作
  • navigation:导航动作,比如浏览器的前进后退
  • principal:突出的位置,iOS 和 macOS 会出现在中间的位置
  • keyboard:macOS 会出现在 Touch Bar 里。iOS 会出现在弹出的虚拟键盘上。

List

img

List 除了能够展示数据外,还有下拉刷新、过滤搜索和侧滑 Swipe 动作提供更多 Cell 操作的能力。

通过 List 的可选子项参数提供数据模型的关键路径来制定子项路劲,还可以实现大纲视图,使用 DisclosureGroup 和 OutlineGroup 可以进一步定制大纲视图。

下面是 List 使用,包括了 DisclosureGroup 和 OutlineGroup 的演示代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
struct PlayListView: View {
@StateObject var l: PLVM = PLVM()
@State private var s: String = ""

var outlineModel = [
POutlineModel(title: "文件夹一", iconName: "folder.fill", children: [
POutlineModel(title: "个人", iconName: "person.crop.circle.fill"),
POutlineModel(title: "群组", iconName: "person.2.circle.fill"),
POutlineModel(title: "加好友", iconName: "person.badge.plus")
]),
POutlineModel(title: "文件夹二", iconName: "folder.fill", children: [
POutlineModel(title: "晴天", iconName: "sun.max.fill"),
POutlineModel(title: "夜间", iconName: "moon.fill"),
POutlineModel(title: "雨天", iconName: "cloud.rain.fill", children: [
POutlineModel(title: "雷加雨", iconName: "cloud.bolt.rain.fill"),
POutlineModel(title: "太阳雨", iconName: "cloud.sun.rain.fill")
])
]),
POutlineModel(title: "文件夹三", iconName: "folder.fill", children: [
POutlineModel(title: "电话", iconName: "phone"),
POutlineModel(title: "拍照", iconName: "camera.circle.fill"),
POutlineModel(title: "提醒", iconName: "bell")
])
]

var body: some View {
HStack {
// List 通过$语法可以将集合的元素转换成可绑定的值
List {
ForEach($l.ls) { $d in
PRowView(s: d.s, i: d.i)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowBackground(Color.black.opacity(0.2))
}
}
.refreshable {
// 下拉刷新
}
.searchable(text: $s) // 搜索
.onChange(of: s) { newValue in
print("搜索关键字:\(s)")
}

Divider()

// 自定义 List
VStack {
PCustomListView($l.ls) { $d in
PRowView(s: d.s, i: d.i)
}
// 添加数据
Button {
l.ls.append(PLModel(s: "More", i: 0))
} label: {
Text("添加")
}
}
.padding()

Divider()

// 使用大纲
List(outlineModel, children: \.children) { i in
Label(i.title, systemImage: i.iconName)
}

Divider()

// 自定义大纲视图
VStack {
Text("可点击标题展开")
.font(.headline)
PCOutlineListView(d: outlineModel, c: \.children) { i in
Label(i.title, systemImage: i.iconName)
}
}
.padding()

Divider()

// 使用 OutlineGroup 实现大纲视图
VStack {
Text("OutlineGroup 实现大纲")

OutlineGroup(outlineModel, children: \.children) { i in
Label(i.title, systemImage: i.iconName)
}

// OutlineGroup 和 List 结合
Text("OutlineGroup 和 List 结合")
List {
ForEach(outlineModel) { s in
Section {
OutlineGroup(s.children ?? [], children: \.children) { i in
Label(i.title, systemImage: i.iconName)
}
} header: {
Label(s.title, systemImage: s.iconName)
}

} // end ForEach
} // end List
} // end VStack
} // end HStack
} // end body
}

// MARK: - 自定义大纲视图
struct PCOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {
private let v: PCOutlineView<D, Content>

init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {
self.v = PCOutlineView(d: d, c: c, content: content)
}

var body: some View {
List {
v
}
}
}

struct PCOutlineView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {
let d: D
let c: KeyPath<D.Element, D?>
let content: (D.Element) -> Content
@State var isExpanded = true // 控制初始是否展开的状态

var body: some View {
ForEach(d) { i in
if let sub = i[keyPath: c] {
PCDisclosureGroup(content: PCOutlineView(d: sub, c: c, content: content), label: content(i))
} else {
content(i)
} // end if
} // end ForEach
} // end body
}

struct PCDisclosureGroup<C, L>: View where C: View, L: View {
@State var isExpanded = false
var content: C
var label: L
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
content
} label: {
Button {
isExpanded.toggle()
} label: {
label
}
.buttonStyle(.plain)
}
}
}

// MARK: - 大纲模式数据模型
struct POutlineModel: Hashable, Identifiable {
var id = UUID()
var title: String
var iconName: String
var children: [POutlineModel]?
}

// MARK: - List 的抽象,数据兼容任何集合类型
struct PCustomListView<D: RandomAccessCollection & MutableCollection & RangeReplaceableCollection, Content: View>: View where D.Element: Identifiable {
@Binding var data: D
var content: (Binding<D.Element>) -> Content

init(_ data: Binding<D>, content: @escaping (Binding<D.Element>) -> Content) {
self._data = data
self.content = content
}

var body: some View {
List {
Section {
ForEach($data, content: content)
.onMove { indexSet, offset in
data.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
data.remove(atOffsets: indexSet) // macOS 暂不支持
}
} header: {
Text("第一栏,共 \(data.count) 项")
} footer: {
Text("The End")
}
}
.listStyle(.plain) // 有.automatic、.inset、.plain、sidebar,macOS 暂不支持的有.grouped 和 .insetGrouped
}
}

// MARK: - Cell 视图
struct PRowView: View {
var s: String
var i: Int
var body: some View {
HStack {
Text("\(i):")
Text(s)
}
}
}

// MARK: - 数据模型设计
struct PLModel: Hashable, Identifiable {
let id = UUID()
var s: String
var i: Int
}

final class PLVM: ObservableObject {
@Published var ls: [PLModel]
init() {
ls = [PLModel]()
for i in 0...20 {
ls.append(PLModel(s: "\(i)", i: i))
}
}
}

list 支持 Section footer。

list 分隔符可以自定义,使用 HorizontalEdge.leadingHorizontalEdge.trailing

list 不使用 UITableView 了。

今年 list 还新增了一个 EditOperation 可以自动生成移动和删除,新增了 edits 参数,传入 [.delete, .move] 数组即可。这也是一个演示如何更好扩展和配置功能的方式。

.searchable 支持 token 和 scope,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct PSearchTokensAndScopes: View {
enum AttendanceScope {
case inPerson, online
}
@State private var queryText: String
@State private var queryTokens: [InvitationToken]
@State private var scope: AttendanceScope

var body: some View {
invitationCountView()
.searchable(text: $queryText, tokens: $queryTokens, scope: $scope) { token in
Label(token.diplayName, systemImage: token.systemImage)
} scopes: {
Text("In Person").tag(AttendanceScope.inPerson)
Text("Online").tag(AttendanceScope.online)
}
}
}

LazyVStack 和 LazyHStack

LazyVStack 和 LazyHStack 里的视图只有在滚到时才会被创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct PlayLazyVStackAndLazyHStackView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(1...300, id: \.self) { i in
PLHSRowView(i: i)
}
}
}
}
}

struct PLHSRowView: View {
let i: Int
var body: some View {
Text("第 \(i) 个")
}
init(i: Int) {
print("第 \(i) 个初始化了") // 用来查看什么时候创建的。
self.i = i
}
}

LazyVGrid 和 LazyHGrid

img

列的设置有三种,这三种也可以组合用。

  • GridItem(.fixed(10)) 会固定设置有多少列。
  • GridItem(.flexible()) 会充满没有使用的空间。
  • GridItem(.adaptive(minimum: 10)) 表示会根据设置大小自动设置有多少列展示。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
struct PlayLazyVGridAndLazyHGridView: View {
@State private var colors: [String:Color] = [
"red" : .red,
"orange" : .orange,
"yellow" : .yellow,
"green" : .green,
"mint" : .mint,
"teal" : .teal,
"cyan" : .cyan,
"blue" : .blue,
"indigo" : .indigo,
"purple" : .purple,
"pink" : .pink,
"brown" : .brown,
"gray" : .gray,
"black" : .black
]

var body: some View {
ScrollView {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 50), spacing: 10)
], pinnedViews: [.sectionHeaders]) {
Section(header:
Text("🎨调色板")
.font(.title)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RoundedRectangle(cornerRadius: 0)
.fill(.black.opacity(0.1)))
) {
ForEach(Array(colors.keys), id: \.self) { k in
colors[k].frame(height:Double(Int.random(in: 50...150)))
.overlay(
Text(k)
)
.shadow(color: .black, radius: 2, x: 0, y: 2)
}
}
}
.padding()

LazyVGrid(columns: [
GridItem(.adaptive(minimum: 20), spacing: 10)
]) {
Section(header: Text("图标集").font(.title)) {
ForEach(1...30, id: \.self) { i in
Image("p\(i)")
.resizable()
.aspectRatio(contentMode: .fit)
.shadow(color: .black, radius: 2, x: 0, y: 2)
}
}
}
.padding()
}
}
}

table

今年 iOS 和 iPadOS 也可以使用去年只能在 macOS 上使用的 Table了,据 digital lounges 里说,iOS table 的性能和 list 差不多,table 默认为 plian list。我想 iOS 上加上 table 只是为了兼容 macOS 代码吧。

table 使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Table(attendeeStore.attendees) {
TableColumn("Name") { attendee in
AttendeeRow(attendee)
}
TableColumn("City", value: \.city)
TableColumn("Status") { attendee in
StatusRow(attendee)
}
}
.contextMenu(forSelectionType: Attendee.ID.self) { selection in
if selection.isEmpty {
Button("New Invitation") { addInvitation() }
} else if selection.count == 1 {
Button("Mark as VIP") { markVIPs(selection) }
} else {
Button("Mark as VIPs") { markVIPs(selection) }
}
}

ScrollView

ScrollView 使用 scrollTo 可以直接滚动到指定的位置。ScrollView 还可以透出偏移量,利用偏移量可以定义自己的动态视图,比如向下向上滚动视图时有不同效果,到顶部显示标题视图等。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
struct PlayScrollView: View {
@State private var scrollOffset: CGFloat = .zero

var infoView: some View {
GeometryReader { g in
Text("移动了 \(Double(scrollOffset).formatted(.number.precision(.fractionLength(1)).rounded()))")
.padding()
}
}

var body: some View {
// 标准用法
ScrollViewReader { s in
ScrollView {
ForEach(0..<300) { i in
Text("\(i)")
.id(i)
}
}
Button("跳到150") {
withAnimation {
s.scrollTo(150, anchor: .top)
}
} // end Button
} // end ScrollViewReader

// 自定义的 ScrollView 透出 offset 供使用
ZStack {
PCScrollView {
ForEach(0..<100) { i in
Text("\(i)")
}
} whenMoved: { d in
scrollOffset = d
}
infoView

} // end ZStack
} // end body
}

// MARK: - 自定义 ScrollView
struct PCScrollView<C: View>: View {
let c: () -> C
let whenMoved: (CGFloat) -> Void

init(@ViewBuilder c: @escaping () -> C, whenMoved: @escaping (CGFloat) -> Void) {
self.c = c
self.whenMoved = whenMoved
}

var offsetReader: some View {
GeometryReader { g in
Color.clear
.preference(key: OffsetPreferenceKey.self, value: g.frame(in: .named("frameLayer")).minY)
}
.frame(height:0)
}

var body: some View {
ScrollView {
offsetReader
c()
.padding(.top, -8)
}
.coordinateSpace(name: "frameLayer")
.onPreferenceChange(OffsetPreferenceKey.self, perform: whenMoved)
} // end body
}

private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

新增 modifier

1
2
3
4
5
6
7
8
9
ScrollView {
ForEach(0..<300) { i in
Text("\(i)")
.id(i)
}
}
.scrollDisabled(false)
.scrollDismissesKeyboard(.interactively)
.scrollIndicators(.visible)

浮层

img

浮层有 HUD、ContextMenu、Sheet、Alert、ConfirmationDialog、Popover、ActionSheet 等几种方式。这些方式实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
struct PlaySuperposedLayerView: View {
@StateObject var hudVM = PHUDVM()
@State private var isShow = false
@State private var isShowAlert = false
@State private var isShowConfirmationDialog = false
@State private var isShowPopover = false

var body: some View {
VStack {


List {
ForEach(0..<100) { i in
Text("\(i)")
.contextMenu {
// 在 macOS 上右键会出现的菜单
Button {
print("\(i) is clicked")
} label: {
Text("Click \(i)")
}
}
}
}
.navigationTitle("列表")
.toolbar {
ToolbarItemGroup(placement: .automatic) {
Button("查看 Sheet") {
isShow = true
}

Button("查看 Alert") {
isShowAlert = true
}

Button("查看 confirmationDialog", role: .destructive) {
isShowConfirmationDialog = true
}

// Popover 样式默认是弹出窗口置于按钮上方,指向底部。
Button("查看 Popover") {
isShowPopover = true
}
.popover(isPresented: $isShowPopover, attachmentAnchor: .point(.trailing), arrowEdge: .trailing) {
Text("Popover 的内容")
.padding()
}

} // end ToolbarItemGroup
} // end toolbar
.alert(isPresented: $isShowAlert) {
Alert(title: Text("弹框标题"), message: Text("弹框内容"))
}
.sheet(isPresented: $isShow) {
print("dismiss")
} content: {
VStack {
Label("Sheet", systemImage: "brain.head.profile")
Button("关闭") {
isShow = false
}
}
.padding(20)
}
.confirmationDialog("确定删除?", isPresented: $isShowConfirmationDialog, titleVisibility: .hidden) {
Button("确定") {
// do good thing
}
.keyboardShortcut(.defaultAction) // 使用 keyboardShortcut 可以设置成为默认选项样式

Button("不不", role: .cancel) {
// good choice
}

} message: {
Text("这个东西还有点重要哦")
}

Button {
hudVM.show(title: "您有一条新的短消息", systemImage: "ellipsis.bubble")
} label: {
Label("查看 HUD", systemImage: "switch.2")
}
.padding()
}
.environmentObject(hudVM)
.hud(isShow: $hudVM.isShow) {
Label(hudVM.title, systemImage: hudVM.systemImage)
}
}
}

// MARK: - 供全局使用的 HUD
final class PHUDVM: ObservableObject {
@Published var isShow: Bool = false
var title: String = ""
var systemImage: String = ""

func show(title: String, systemImage: String) {
self.title = title
self.systemImage = systemImage
withAnimation {
isShow = true
}
}
}

// MARK: - 扩展 View 使其能够有 HUD 的能力
extension View {
func hud<V: View>(
isShow: Binding<Bool>,
@ViewBuilder v: () -> V
) -> some View {
ZStack(alignment: .top) {
self

if isShow.wrappedValue == true {
PHUD(v: v)
.transition(AnyTransition.move(edge: .top).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isShow.wrappedValue = false
}
}
}
.zIndex(1)
.padding()
}
}
}
}

// MARK: - 自定义 HUD
struct PHUD<V: View>: View {
@ViewBuilder let v: V

var body: some View {
v
.padding()
.foregroundColor(.black)
.background(
Capsule()
.foregroundColor(.white)
.shadow(color: .black.opacity(0.2), radius: 12, x: 0, y: 5)
)
}
}

SwiftUI 新推出的 presentationDetents() modifier 可以创建一个可以定制的 bottom sheet。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct PSheet: View {
@State private var isShow = false
var body: some View {
Button("显示 Sheet") {
isShow.toggle()
}
.sheet(isPresented: $isShow) {
Text("这里是 Sheet 的内容")
.presentationDetents([.medium, .large])
}
}
}

detent 默认值是 .large。也可以提供一个百分比,比如 .presentationDetents([.fraction(0.7)]),或者直接指定高度 .presentationDetents([.height(100)])

presentationDragIndicator modifier 可以用来显示隐藏拖动标识。

TabView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
struct PlayTabView: View {
@State private var selection = 0

var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selection) {
Text("one")
.tabItem {
Text("首页")
.hidden()
}
.tag(0)
Text("two")
.tabItem {
Text("二栏")
}
.tag(1)
Text("three")
.tabItem {
Text("三栏")
}
.tag(2)
Text("four")
.tag(3)
Text("five")
.tag(4)
Text("six")
.tag(5)
Text("seven")
.tag(6)
Text("eight")
.tag(7)
Text("nine")
.tag(8)
Text("ten")
.tag(9)
} // end TabView


HStack {
Button("上一页") {
if selection > 0 {
selection -= 1
}
}
.keyboardShortcut(.cancelAction)
Button("下一页") {
if selection < 9 {
selection += 1
}
}
.keyboardShortcut(.defaultAction)
} // end HStack
.padding()
}
}
}

.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) 可以实现 UIPageViewController 的效果,如果要给小白点加上背景,可以多添加一个 .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) 修改器。

Swift Charts

可视化数据,使用 SwiftUI 语法来创建。还可以使用 ChartRenderer 接口将图标渲染成图。

官方文档 Swift Charts

入门参看 Hello Swift Charts

Apple 文章 Creating a chart using Swift Charts

高级定制和创建更精细图表,可以看这个 session Swift Charts: Raise the bar 这个 session 也会提到如何在图表中进行交互。这里是 session 对应的代码示例 Visualizing your app’s data

图表设计的 session,Design an effective chartDesign app experiences with charts

下面是一个简单的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import Charts

struct PChartModel: Hashable {
var day: String
var amount: Int = .random(in: 1..<100)
}

extension PChartModel {
static var data: [PChartModel] {
let calendar = Calendar(identifier: .gregorian)
let days = calendar.shortWeekdaySymbols
return days.map { day in
PChartModel(day: day)
}
}
}

struct PlayCharts: View {
var body: some View {
Chart(PChartModel.data, id: \.self) { v in
BarMark(x: .value("天", v.day), y: .value("数量", v.amount))

}
.padding()
}
}

struct PSwiftCharts: View {
struct CData: Identifiable {
let id = UUID()
let i: Int
let v: Double
}

@State private var a: [CData] = [
.init(i: 0, v: 2),
.init(i: 1, v: 20),
.init(i: 2, v: 3),
.init(i: 3, v: 30),
.init(i: 4, v: 8),
.init(i: 5, v: 80)
]

var body: some View {
Chart(a) { i in
LineMark(x: .value("Index", i.i), y: .value("Value", i.v))
BarMark(x: .value("Index", i.i), yStart: .value("开始", 0), yEnd: .value("结束", i.v))
.foregroundStyle(by: .value("Value", i.v))
} // end Chart
} // end body
}

BarMark 用于创建条形图,LineMark 用于创建折线图。SwiftUI Charts 框架还提供 PointMark、AxisMarks、AreaMark、RectangularMark 和 RuleMark 用于创建不同类型的图表。注释使用 .annotation modifier,修改颜色可以使用 .foregroundStyle modifier。.lineStyle modifier 可以修改线宽。

AxisMarks 的示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct MonthlySalesChart: View {
var body: some View {
Chart(data, id: \.month) {
BarMark(
x: .value("Month", $0.month, unit: .month),
y: .value("Sales", $0.sales)
)
}
.chartXAxis {
AxisMarks(values: .stride(by: .month)) { value in
if value.as(Date.self)!.isFirstMonthOfQuarter {
AxisGridLine().foregroundStyle(.black)
AxisTick().foregroundStyle(.black)
AxisValueLabel(
format: .dateTime.month(.narrow)
)
} else {
AxisGridLine()
}
}
}
}
}

可交互图表示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct InteractiveBrushingChart: View {
@State var range: (Date, Date)? = nil

var body: some View {
Chart {
ForEach(data, id: \.day) {
LineMark(
x: .value("Month", $0.day, unit: .day),
y: .value("Sales", $0.sales)
)
.interpolationMethod(.catmullRom)
.symbol(Circle().strokeBorder(lineWidth: 2))
}
if let (start, end) = range {
RectangleMark(
xStart: .value("Selection Start", start),
xEnd: .value("Selection End", end)
)
.foregroundStyle(.gray.opacity(0.2))
}
}
.chartOverlay { proxy in
GeometryReader { nthGeoItem in
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(DragGesture()
.onChanged { value in
// Find the x-coordinates in the chart’s plot area.
let xStart = value.startLocation.x - nthGeoItem[proxy.plotAreaFrame].origin.x
let xCurrent = value.location.x - nthGeoItem[proxy.plotAreaFrame].origin.x
// Find the date values at the x-coordinates.
if let dateStart: Date = proxy.value(atX: xStart),
let dateCurrent: Date = proxy.value(atX: xCurrent) {
range = (dateStart, dateCurrent)
}
}
.onEnded { _ in range = nil } // Clear the state on gesture end.
)
}
}
}
}

社区做的更多 Swift Charts 范例 Swift Charts Examples

Toggle

img

Toggle 可以设置 toggleStyle,可以自定义样式。使用示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
struct PlayToggleView: View {
@State private var isEnable = false
var body: some View {
// 普通样式
Toggle(isOn: $isEnable) {
Text("\(isEnable ? "开了" : "关了")")
}
.padding()

// 按钮样式
Toggle(isOn: $isEnable) {
Label("\(isEnable ? "打开了" : "关闭了")", systemImage: "cloud.moon")
}
.padding()
.tint(.pink)
.controlSize(.large)
.toggleStyle(.button)

// Switch 样式
Toggle(isOn: $isEnable) {
Text("\(isEnable ? "开了" : "关了")")
}
.toggleStyle(SwitchToggleStyle(tint: .orange))
.padding()

// 自定义样式
Toggle(isOn: $isEnable) {
Text(isEnable ? "录音中" : "已静音")
}
.toggleStyle(PCToggleStyle())

}
}

// MARK: - 自定义样式
struct PCToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
return HStack {
configuration.label
Image(systemName: configuration.isOn ? "mic.square.fill" : "mic.slash.circle.fill")
.renderingMode(.original)
.resizable()
.frame(width: 30, height: 30)
.onTapGesture {
configuration.isOn.toggle()
}
}
}
}

Picker

img

有 Picker 视图,还有颜色和时间选择的 ColorPicker 和 DatePicker。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
struct PlayPickerView: View {
@State private var select = 1
@State private var color = Color.red.opacity(0.3)

var dateFt: DateFormatter {
let ft = DateFormatter()
ft.dateStyle = .long
return ft
}
@State private var date = Date()

var body: some View {

// 默认是下拉的风格
Form {
Section("选区") {
Picker("选一个", selection: $select) {
Text("1")
.tag(1)
Text("2")
.tag(2)
}
}
}
.padding()

// Segment 风格,
Picker("选一个", selection: $select) {
Text("one")
.tag(1)
Text("two")
.tag(2)
}
.pickerStyle(SegmentedPickerStyle())
.padding()

// 颜色选择器
ColorPicker("选一个颜色", selection: $color, supportsOpacity: false)
.padding()

RoundedRectangle(cornerRadius: 8)
.fill(color)
.frame(width: 50, height: 50)

// 时间选择器
VStack {
DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
Text("选时间")
}

DatePicker("选时间", selection: $date)
.datePickerStyle(GraphicalDatePickerStyle())
.frame(maxHeight: 400)

Text("时间:\(date, formatter: dateFt)")
}
.padding()
}
}

选择多个日期

MultiDatePicker 视图会显示一个日历,用户可以选择多个日期,可以设置选择范围。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct PMultiDatePicker: View {
@Environment(\.calendar) var cal
@State var dates: Set<DateComponents> = []
var body: some View {
MultiDatePicker("选择个日子", selection: $dates, in: Date.now...)
Text(s)
}
var s: String {
dates.compactMap { c in
cal.date(from:c)?.formatted(date: .long, time: .omitted)
}
.formatted()
}
}

PhotosPick

支持图片选择,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import PhotosUI
import CoreTransferable

struct ContentView: View {
@ObservedObject var viewModel: FilterModel = .shared

var body: some View {
NavigationStack {
Gallery()
.navigationTitle("Birthday Filter")
.toolbar {
PhotosPicker(
selection: $viewModel.imageSelection,
matching: .images
) {
Label("Pick a photo", systemImage: "plus.app")
}
Button {
viewModel.applyFilter()
} label: {
Label("Apply Filter", systemImage: "camera.filters")
}
}
}
}
}

Slider

1
2
3
4
5
6
7
8
struct PlaySliderView: View {
@State var count: Double = 0
var body: some View {
Slider(value: $count, in: 0...100)
.padding()
Text("\(Int(count))")
}
}

Stepper

1
2
3
4
5
6
7
8
9
10
struct PlayStepperView: View {
@State private var count: Int = 0
var body: some View {
Stepper(value: $count, step: 2) {
Text("共\(count)")
} onEditingChanged: { b in
print(b)
} // end Stepper
}
}

Form

Form 今年也得到了增强,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
Form {
Section {
LabeledContent("Location") {
AddressView(location)
}
DatePicker("Date", selection: $date)
TextField("Description", text: $eventDescription, axis: .vertical)
.lineLimit(3, reservesSpace: true)
}

Section("Vibe") {
Picker("Accent color", selection: $accent) {
ForEach(Theme.allCases) { accent in
Text(accent.rawValue.capitalized).tag(accent)
}
}
Picker("Color scheme", selection: $scheme) {
Text("Light").tag(ColorScheme.light)
Text("Dark").tag(ColorScheme.dark)
}
#if os(macOS)
.pickerStyle(.inline)
#endif
Toggle(isOn: $extraGuests) {
Text("Allow extra guests")
Text("The more the merrier!")
}
if extraGuests {
Stepper("Guests limit", value: $spacesCount, format: .number)
}
}

Section("Decorations") {
Section {
List(selection: $selectedDecorations) {
DisclosureGroup {
HStack {
Toggle("Balloons 🎈", isOn: $includeBalloons)
Spacer()
decorationThemes[.balloon].map { $0.swatch }
}
.tag(Decoration.balloon)

HStack {
Toggle("Confetti 🎊", isOn: $includeConfetti)
Spacer()
decorationThemes[.confetti].map { $0.swatch }
}
.tag(Decoration.confetti)

HStack {
Toggle("Inflatables 🪅", isOn: $includeInflatables)
Spacer()
decorationThemes[.inflatables].map { $0.swatch }
}
.tag(Decoration.inflatables)

HStack {
Toggle("Party Horns 🥳", isOn: $includeBlowers)
Spacer()
decorationThemes[.noisemakers].map { $0.swatch }
}
.tag(Decoration.noisemakers)
} label: {
Toggle("All Decorations", isOn: [
$includeBalloons, $includeConfetti,
$includeInflatables, $includeBlowers
])
.tag(Decoration.all)
}
#if os(macOS)
.toggleStyle(.checkbox)
#endif
}

Picker("Decoration theme", selection: themes) {
Text("Blue").tag(Theme.blue)
Text("Black").tag(Theme.black)
Text("Gold").tag(Theme.gold)
Text("White").tag(Theme.white)
}
#if os(macOS)
.pickerStyle(.radioGroup)
#endif
}
}

}
.formStyle(.grouped)

Keyboard

键盘快捷键的使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct PlayKeyboard: View {
var body: some View {
Button(systemIconName: "camera.shutter.button") {
print("按了回车键")
}
.keyboardShortcut(.defaultAction) // 回车

Button("ESC", action: {
print("按了 ESC")
})
.keyboardShortcut(.cancelAction) // ESC 键

Button("CMD + p") {
print("按了 CMD + p")
}
.keyboardShortcut("p")

Button("SHIFT + p") {
print("按了 SHIFT + p")
}
.keyboardShortcut("p", modifiers: [.shift])
}
}

Transferable

Transferable 协议使数据可以用于剪切板、拖放和 Share Sheet。

可以在自己应用程序之间或你的应用和其他应用之间发送或接受可传输项目。

支持 SwiftUI 来使用。

官方文档 Core Transferable

session Meet Transferable

新增一个专门用来接受 Transferable 的按钮视图 PasteButton,使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
struct PPasteButton: View {
@State private var s = "戴铭"
var body: some View {
TextField("输入", text: $s)
.textFieldStyle(.roundedBorder)
PasteButton(payloadType: String.self) { str in
guard let first = str.first else { return }
s = first
}
}
}

ShareLink 视图可以让你轻松共享数据。示例代码如下:

1
2
3
4
5
6
7
8
9
10
struct PShareLink: View {
let url = URL(string: "https://ming1016.github.io/")!
var body: some View {
ShareLink(item: url, message: Text("戴铭的博客"))
ShareLink("戴铭的博客", item: url)
ShareLink(item: url) {
Label("戴铭的博客", systemImage: "swift")
}
}
}

3.视觉

Color

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
struct PlayColor: View {
var body: some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all) // Color 也是一个 View

VStack(spacing: 10) {
Text("这是一个适配了暗黑的文字颜色")
.foregroundColor(light: .purple, dark: .pink)
.background(Color(nsColor: .quaternaryLabelColor)) // 使用以前 NSColor

Text("自定义颜色")
.foregroundColor(Color(red: 0, green: 0, blue: 100))
}
.padding()

}
}
}

// MARK: - 暗黑适配颜色
struct PCColorModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
var light: Color
var dark: Color

private var adaptColor: Color {
switch colorScheme {
case .light:
return light
case .dark:
return dark
@unknown default:
return light
}
}

func body(content: Content) -> some View {
content.foregroundColor(adaptColor)
}
}

extension View {
func foregroundColor(light: Color, dark: Color) -> some View {
modifier(PCColorModifier(light: light, dark: dark))
}
}

Effect

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct PlayEffect: View {
@State private var isHover = false

var body: some View {
ZStack {
LinearGradient(colors: [.purple, .black, .pink], startPoint: .top, endPoint: .bottom).ignoresSafeArea()

VStack(spacing: 20) {

// 材质
Text("材质效果")
.font(.system(size:30))
.padding(isHover ? 40 : 30)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.onHover { b in
withAnimation {
isHover = b
}
}

// 模糊
Text("模糊效果")
.font(.system(size: 30))
.padding(30)
.background {
Color.black.blur(radius: 8, opaque: false)
}

// 选择
Text("3D 旋转")
.font(.largeTitle)
.rotation3DEffect(Angle(degrees: 45), axis: (x: 0, y: 20, z: 0))
.scaleEffect(1.5)
.blendMode(.hardLight)
.blur(radius: 3)

}

}
}
}

材质厚度从低到高有:

  • .regularMaterial
  • .thinMaterial
  • .ultraThinMaterial
  • .thickMaterial
  • .ultraThickMaterial

Gradient 和 Shadow 的 2022 的更新

下面是个简单示例:

1
2
3
4
5
6
7
8
9
10
struct PGradientAndShadow: View {
var body: some View {
Image(systemName: "bird")
.frame(width: 150, height: 150)
.background(in: Rectangle())
.backgroundStyle(.cyan.gradient)
.foregroundStyle(.white.shadow(.drop(radius: 1, y: 3.0)))
.font(.system(size: 60))
}
}

Paul Hudson 使用 Core Motion 做了一个阴影随设备倾斜而变化的效果,非常棒,How to use inner shadows to simulate depth with SwiftUI and Core Motion

Animation

SwiftUI 里实现动画的方式包括有 .animation 隐式动画、withAnimation 和 withTransaction 显示动画、matchedGeometryEffect Hero 动画和 TimelineView 等。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
struct PlayAnimation: View {
@State private var isChange = false
private var anis:[String: Animation] = [
"p1": .default,
"p2": .linear(duration: 1),
"p3": .interpolatingSpring(stiffness: 5, damping: 3),
"p4": .easeInOut(duration: 1),
"p5": .easeIn(duration: 1),
"p6": .easeOut(duration: 1),
"p7": .interactiveSpring(response: 3, dampingFraction: 2, blendDuration: 1),
"p8": .spring(),
"p9": .default.repeatCount(3)
]
@State private var selection = 1

var body: some View {
// animation 隐式动画和 withAnimation 显示动画
Text(isChange ? "另一种状态" : "一种状态")
.font(.headline)
.padding()
.animation(.easeInOut, value: isChange) // 受限的隐式动画,只绑定某个值。
.onTapGesture {
// 使用 withAnimation 就是显式动画,效果等同 withTransaction(Transaction(animation: .default))
withAnimation {
isChange.toggle()
}

// 设置 Transaction。和隐式动画共存时,优先执行 withAnimation 或 Transaction。
var t = Transaction(animation: .linear(duration: 2))
t.disablesAnimations = true // 用来禁用隐式动画
withTransaction(t) {
isChange.toggle()
}
} // end onHover

LazyVGrid(columns: [GridItem(.adaptive(minimum: isChange ? 60 : 30), spacing: 60)]) {
ForEach(Array(anis.keys), id: \.self) { s in
Image(s)
.resizable()
.scaledToFit()
.animation(anis[s], value: isChange)
.scaleEffect()
}
}
.padding()
Button {
isChange.toggle()
} label: {
Image(systemName: isChange ? "pause.fill" : "play.fill")
.renderingMode(.original)
}

// matchedGeometryEffect 的使用
VStack {
Text("后台")
.font(.headline)
placeStayView
Text("前台")
.font(.headline)
placeShowView
}
.padding(50)

// 通过使用相同 matchedGeometryEffect 的 id,绑定两个元素变化。
HStack {
if isChange {
Rectangle()
.fill(.pink)
.matchedGeometryEffect(id: "g1", in: mgeStore)
.frame(width: 100, height: 100)
}
Spacer()
Button("转换") {
withAnimation(.linear(duration: 2.0)) {
isChange.toggle()
}
}
Spacer()
if !isChange {
Circle()
.fill(.orange)
.matchedGeometryEffect(id: "g1", in: mgeStore)
.frame(width: 70, height: 70)
}
HStack {
Image("p1")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
if !isChange {
Image("p19")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.matchedGeometryEffect(id: "g1", in: mgeStore)
}
Image("p1")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
}
}
.padding()

// 使用 isSource,作为移动到相同 matchedGeometryEffect id 的方法。
HStack {
Image("p19")
.resizable()
.scaledToFit()
.frame(width: isChange ? 100 : 50, height: isChange ? 100 : 50)
.matchedGeometryEffect(id: isChange ? "g2" : "", in: mgeStore, isSource: false)

Image("p19")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: "g2", in: mgeStore)
.opacity(0)
}



// 点击跟随的效果
HStack {
ForEach(Array(1...4), id: \.self) { i in
Image("p\(i)")
.resizable()
.scaledToFit()
.frame(width: i == selection ? 200 : 50)
.matchedGeometryEffect(id: "h\(i)", in: mgeStore)
.onTapGesture {
withAnimation {
selection = i
}
}
.shadow(color: .black, radius: 3, x: 2, y: 3)
}
}
.background(
RoundedRectangle(cornerRadius: 8).fill(.pink)
.matchedGeometryEffect(id: "h\(selection)", in: mgeStore, isSource: false)
)

// matchedGeometryEffect 还可以应用到 List 中,通过 Array enumerated 获得 index 作为 matchedGeometryEffect 的 id。右侧固定按钮可以直接让对应 id 的视图滚动到固定按钮的位置


// TimelineView
TimelineView(.periodic(from: .now, by: 1)) { t in
Text("\(t.date)")
HStack(spacing: 20) {
let e = "p\(Int.random(in: 1...30))"
Image(e)
.resizable()
.scaledToFit()
.frame(height: 40)
.animation(.default.repeatCount(3), value: e)

TimelineSubView(date: t.date) // 需要传入 timeline 的时间给子视图才能够起作用。

}
.padding()
}

// matchedGeometryEffect

/// TimelineScheduler 的使用,TimelineScheduler 有以下类型
/// .animation:制定更新的频率,可以控制暂停
/// .everyMinute:每分钟更新一次
/// .explicit:所有要更新的放到一个数组里
/// .periodic:设置开始时间和更新频率
/// 也可以自定义 TimelineScheduler
TimelineView(.everySecond) { t in
let e = "p\(Int.random(in: 1...30))"
Image(e)
.resizable()
.scaledToFit()
.frame(height: 40)
}

// 自定义的 TimelineScheduler
TimelineView(.everyLoop(timeOffsets: [0.2, 0.7, 1, 0.5, 2])) { t in
TimelineSubView(date: t.date)
}
}

// MARK: - TimelineSubView
struct TimelineSubView: View {
let date : Date
@State private var s = "let's go"
// 顺序从数组中取值,取完再重头开始
@State private var idx: Int = 1
func advanceIndex(count: Int) {
idx = (idx + 1) % count
if idx == 0 { idx = 1 }
}

var body: some View {
HStack(spacing: 20) {
Image("p\(idx)")
.resizable()
.scaledToFit()
.frame(height: 40)
.animation(.easeIn(duration: 1), value: date)
.onChange(of: date) { newValue in
advanceIndex(count: 30)
s = "\(date.hour):\(date.minute):\(date.second)"
}
.onAppear {
advanceIndex(count: 30)
}

Text(s)
}
}
}

// MARK: - 用 matchedGeometryEffect 做动画
/// matchedGeometryEffect 可以无缝的将一个图像变成另外一个图像。
@State private var placeStayItems = ["p1", "p2", "p3", "p4"]
@State private var placeShowItems: [String] = []

@Namespace private var mgeStore

private var placeStayView: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 30), spacing: 10)]) {
ForEach(placeStayItems, id: \.self) { s in
Image(s)
.resizable()
.scaledToFit()
.matchedGeometryEffect(id: s, in: mgeStore)
.onTapGesture {
withAnimation {
placeStayItems.removeAll { $0 == s }
placeShowItems.append(s)
}
}
.shadow(color: .black, radius: 2, x: 2, y: 4)
} // end ForEach
} // end LazyVGrid
} // private var placeStayView

private var placeShowView: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)]) {
ForEach(placeShowItems, id: \.self) { s in
Image(s)
.resizable()
.scaledToFit()
.matchedGeometryEffect(id: s, in: mgeStore)
.onTapGesture {
withAnimation {
placeShowItems.removeAll { $0 == s }
placeStayItems.append(s)
}
}
.shadow(color: .black, radius: 2, x: 0, y: 2)
.shadow(color: .white, radius: 5, x: 0, y: 2)
} // end ForEach
} // end LazyVGrid
} // end private var placeShowView

} // end struct PlayAnimation

// MARK: - 扩展 TimelineSchedule
extension TimelineSchedule where Self == PeriodicTimelineSchedule {
static var everySecond: PeriodicTimelineSchedule {
get {
.init(from: .now, by: 1)
}
}
}

// MARK: - 自定义一个 TimelineSchedule
// timeOffsets 用完,就会再重头重新再来一遍
struct PCLoopTimelineSchedule: TimelineSchedule {
let timeOffsets: [TimeInterval]

func entries(from startDate: Date, mode: TimelineScheduleMode) -> Entries {
Entries(last: startDate, offsets: timeOffsets)
}

struct Entries: Sequence, IteratorProtocol {
var last: Date
let offsets: [TimeInterval]
var idx: Int = -1
mutating func next() -> Date? {
idx = (idx + 1) % offsets.count
last = last.addingTimeInterval(offsets[idx])
return last
}
} // end Struct Entries
}

// 为自定义的 PCLoopTimelineSchedule 做一个 TimelineSchedule 的扩展函数,方便使用
extension TimelineSchedule where Self == PCLoopTimelineSchedule {
static func everyLoop(timeOffsets: [TimeInterval]) -> PCLoopTimelineSchedule {
.init(timeOffsets: timeOffsets)
}
}

Canvas

Canvas 可以画路径、图片和文字、Symbols、可变的图形上下文、使用 CoreGraphics 代码和做动画。

图形上下文可以被 addFilter、clip、clipToLayer、concatenate、rotate、scaleBy、translateBy 这些方法来进行改变。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
struct PlayCanvas: View {
let colors: [Color] = [.purple, .blue, .yellow, .pink]

var body: some View {

// 画路径
PCCanvasPathView(t: .rounded)
PCCanvasPathView(t: .ellipse)
PCCanvasPathView(t: .circle)

// 图片和文字
PCCanvasImageAndText(text: "Starming", colors: [.purple, .pink])

// Symbol,在 Canvas 里引用 SwiftUI 视图
Canvas { c, s in
let c0 = c.resolveSymbol(id: 0)!
let c1 = c.resolveSymbol(id: 1)!
let c2 = c.resolveSymbol(id: 2)!
let c3 = c.resolveSymbol(id: 3)!

c.draw(c0, at: .init(x: 10, y: 10), anchor: .topLeading)
c.draw(c1, at: .init(x: 30, y: 20), anchor: .topLeading)
c.draw(c2, at: .init(x: 50, y: 30), anchor: .topLeading)
c.draw(c3, at: .init(x: 70, y: 40), anchor: .topLeading)

} symbols: {
ForEach(Array(colors.enumerated()), id: \.0) { i, c in
Circle()
.fill(c)
.frame(width: 100, height: 100)
.tag(i)
}
}

// Symbol 动画和 SwiftUI 视图一样,不会受影响
Canvas { c, s in
let sb = c.resolveSymbol(id: 0)!
c.draw(sb, at: CGPoint(x: s.width / 2, y: s.height / 2), anchor: .center)

} symbols: {
PCForSymbolView()
.tag(0)
}
} // end var body
}

// MARK: - 给 Symbol 用的视图
struct PCForSymbolView: View {
@State private var change = true
var body: some View {
Image(systemName: "star.fill")
.renderingMode(.original)
.font(.largeTitle)
.rotationEffect(.degrees(change ? 0 : 72))
.onAppear {
withAnimation(.linear(duration: 1.0).repeatForever(autoreverses: false)) {
change.toggle()
}
}
}
}

// MARK: - 图片和文字
struct PCCanvasImageAndText: View {
let text: String
let colors: [Color]
var fontSize: Double = 42

var body: some View {
Canvas { context, size in
let midPoint = CGPoint(x: size.width / 2, y: size.height / 2)
let font = Font.system(size: fontSize)
var resolved = context.resolve(Text(text).font(font))

let start = CGPoint(x: (size.width - resolved.measure(in: size).width) / 2.0, y: 0)
let end = CGPoint(x: size.width - start.x, y: 0)

resolved.shading = .linearGradient(Gradient(colors: colors), startPoint: start, endPoint: end)
context.draw(resolved, at: midPoint, anchor: .center)

}
}
}

// MARK: - Path
struct PCCanvasPathView: View {
enum PathType {
case rounded, ellipse, casual, circle
}
let t: PathType

var body: some View {
Canvas { context, size in

conf(context: &context, size: size, type: t)
} // end Canvas
}

func conf( context: inout GraphicsContext, size: CGSize, type: PathType) {
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 25, dy: 25)
var path = Path()
switch type {
case .rounded:
path = Path(roundedRect: rect, cornerRadius: 35.0)
case .ellipse:
let cgPath = CGPath(ellipseIn: rect, transform: nil)
path = Path(cgPath)
case .casual:
path = Path {
let points: [CGPoint] = [
.init(x: 10, y: 10),
.init(x: 0, y: 50),
.init(x: 100, y: 100),
.init(x: 100, y: 0),
]
$0.move(to: .zero)
$0.addLines(points)
}
case .circle:
path = Circle().path(in: rect)
}


let gradient = Gradient(colors: [.purple, .pink])
let from = rect.origin
let to = CGPoint(x: rect.width, y: rect.height + from.y)

// Stroke path
context.stroke(path, with: .color(.blue), lineWidth: 25)
context.fill(path, with: .linearGradient(gradient, startPoint: from, endPoint: to))
}
}

SF Symbol

SF Symbol 支持变量值,可以通过设置 variableValue 来填充不同部分,比如 wifi 图标,不同值会亮不同部分,Image(systemName: "wifi", variableValue: 0.5)

参考资料:


Tips: Please indicate the source and original author when reprinting or quoting this article.