本章内容概览:SwiftUI常用控件
同系列文章请查看:快乐码元 - iOS篇
1.介绍
SwiftUI 是什么?
对于一个基于UIKit的项目是没有必要全部用SwiftUI重写的,在UIKit里使用SwiftUI的视图非常容易,UIHostingController是UIViewController的子类,可以直接用在UIKit里,因此直接将SwiftUI视图加到UIHostingController中,就可以在UIKit里使用SwiftUI视图了。
SwiftUI的布局核心是 GeometryReader、View Preferences和Anchor Preferences。如下图所示:
SwiftUI的数据流更适合Redux结构,如下图所示:
如上图,Redux结构是真正的单向单数据源结构,易于分割,能充分利用SwiftUI内置的数据流Property Wrapper。UI组件干净、体量小、可复用并且无业务逻辑,因此开发时可以聚焦于UI代码。业务逻辑放在一起,所有业务逻辑和数据Model都在Reducer里。 ACHNBrowserUI 和 MovieSwiftUI 开源项目都是使用的Redux架构。最近比较瞩目的TCA(The Composable Architecture)也是类Redux/Elm的架构的框架, 项目地址见 。
提到数据流就不得不说下苹果公司新出的Combine,对标的是RxSwift,由于是苹果公司官方的库,所以应该优先选择。不过和SwiftUI一样,这两个新库对APP支持最低的系统版本都要求是iOS13及以上。那么怎么能够提前用上SwiftUI和Combine呢?或者说现在使用什么库可以以相同接口方式暂时替换它们,又能在以后改为SwiftUI和Combine时成本最小化呢?
对于SwiftUI,AcFun自研了声明式UI Ysera,类似SwiftUI的接口,并且重构了AcFun里收藏模块列表视图和交互逻辑,如下图所示:
通过上图可以看到,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
基本用法
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 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 { Text (manyString) .lineLimit(3 ) .lineSpacing(10 ) .multilineTextAlignment(.leading) Text (manyString) .fixedSize(horizontal: false , vertical: true ) } PTextViewAttribute () .padding() 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 struct PTextViewMarkdown : View { let mdaStr: AttributedString = { var mda = AttributedString (localized: "这是一个 **Attribute** ~string~" ) mda = AttributedString (localized: "^[这是](p2:'one')^[一](p3:{k1:1,k2:2})个 **Attribute** ~string~" , including: \.newScope) print (mda) let mdUrl = Bundle .main.url(forResource: "1" , withExtension: "md" )! mda = try! AttributedString (contentsOf: mdUrl,options: AttributedString .MarkdownParsingOptions (interpretedSyntax: .inlineOnlyPreservingWhitespace), baseURL: nil ) 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 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 c3.swiftUI.foregroundColor = .secondary c3.font = .footnote a3.setAttributes(c3) a3.p1 = "This is a custom property." 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) for r in a1.runs { print (r) } return a1 }() var body: some View { Text (aStr) } } 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 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 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: "天晴" ) " ) } } } 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) } }
Link
使用方法如下:
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" ) } Text (aStr) Text ("[Go Ming's GitHub](https://github.com/ming1016)" ) Link ("小册子源码" , destination: URL (string: "https://github.com/ming1016/SwiftPamphletApp" )! ) .environment(\.openURL, OpenURLAction { url in return .systemAction }) 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 } }) } .padding() } func goUrl (_ url : URL , done : @escaping (_ accepted: Bool ) -> Void ) { openURL(url, completion: done) } } 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
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 { 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 ) } Label ("有边框的 Label" , systemImage: "b.square.fill" ) .labelStyle(.border) Label ("仅标题有边框" , systemImage: "text.bubble" ) .labelStyle(.borderOnlyTitle) Label ("扩展的 Label" , originalSystemImage: "cloud.sun.bolt.fill" ) } } } extension Label where Title == Text , Icon == Image { init (_ title : LocalizedStringKey , originalSystemImage systemImageString : String ) { self .init { Text (title) } icon: { Image (systemName: systemImageString) .renderingMode(.original) } } } 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) } } 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
对应的代码如下:
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 SwiftUIimport CodeEditorViewstruct PlayTextEditorView : View { @State private var txt: String = "一段可编辑文字...\n " @State private var count: Int = 0 @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 { TextEditor (text: $txt ) .font(.title) .lineSpacing(10 ) .disableAutocorrection(true ) .padding() .onChange(of: txt) { newValue in count = txt.count } Text ("字数:\(count) " ) .foregroundColor(.secondary) .font(.footnote) 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) HSplitView { PNSTextView (text: .constant("左边写...\n " ), onDidChange: { (s, i) in print ("Typing \(i) times." ) }) .padding() PNSTextView (text: .constant("右边写...\n " )) .padding() } } } struct PNSTextView : NSViewRepresentable { @Binding var text: String var onBeginEditing: () -> Void = {} var onCommit: () -> Void = {} var onDidChange: (String , Int ) -> Void = { _ ,_ in } 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 ) } } 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() } } } 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 } }
SwiftUI 中用 NSView,可以通过 NSViewRepresentable 来包装视图,这个协议主要是实现 makeNSView、updateNSView 和 makeCoordinator 三个方法。makeNSView 要求返回需要包装的 NSView。每当 SwiftUI 的状态变化时触发 updateNSView 方法的调用。为了实现 NSView 里的 delegate 和 SwiftUI 通信,就要用 makeCoordinator 返回一个用于处理 delegate 的实例。
TextField
使用方法如下:
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) .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) TextField (placeholder, text: $t ) .textFieldStyle(PClearTextStyle ()) .focused($isFocus ) } .padding() } } 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 ) ) } }
目前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
里了。
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 ("长按" ) })) .simultaneousGesture(TapGesture ().onEnded({ _ in print ("短按" ) })) Button ("要删除了" , role: .destructive) { print ("删除" ) } .tint(.purple) .controlSize(.large) .buttonStyle(.borderedProminent) .clipShape(RoundedRectangle (cornerRadius: 5 )) .accentColor(.pink) .buttonBorderShape(.automatic) .background(.ultraThinMaterial, in: Capsule ()) Button (action: { }, label: { Text ("风格化" ).font(.largeTitle) }) .buttonStyle(PStarmingButtonStyle ()) PCustomButton ("点一下触发" ) { print ("Clicked!" ) } Button { print ("Double Clicked!" ) } label: { Text ("点两下触发" ) } .buttonStyle(PCustomPrimitiveButtonStyle ()) 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) } } 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) } } extension Button where Label == Image { init (systemIconName : String , done : @escaping () -> Void ) { self .init (action: done) { Image (systemName: systemIconName) .renderingMode(.original) } } } 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 ) } } 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 ) } } struct PCustomPrimitiveButtonStyle : PrimitiveButtonStyle { func makeBody (configuration : Configuration ) -> some View { configuration.label .onTapGesture(count: 2 ) { configuration.trigger() } Button (configuration) .gesture( LongPressGesture () .onEnded({ _ in configuration.trigger() }) ) } } 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
表示位置是相对屏幕左上角,默认是相对当前视图的左上角的位置。
进度
用 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 ) } } 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) ) } } } 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) } } } }
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
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 ) Image (systemName: "scissors" ) .imageScale(.large) .foregroundColor(.pink) .frame(width: 40 , height: 40 ) 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) } }
GroupBox
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
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 { HStack { Text ("左" ) Spacer () Text ("右" ) } .padding() 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 func sizeThatFits ( proposal : ProposedViewSize , subviews : Self .Subviews , cache : inout Self .Cache ) -> CGSize func placeSubviews ( in bounds : CGRect , proposal : ProposedViewSize , subviews : Self .Subviews , cache : inout Self .Cache ) }
下面例子是一个自定义的水平 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 { 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) } 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] } } 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 } 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 { layoutValue(key: Rank .self , value: value) } }
然后,我们就可以通过 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() } } } }
同时 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 )) } }
Navigation
控制导航启动状态、管理 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 的用法。
对应代码如下:
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 { ToolbarItem (placement: .primaryAction) { Button ("primaryAction" ) {} .background(.ultraThinMaterial) .font(.headline) } 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" ) {} } } } } 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() } } } } } 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
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 { 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 () 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 () VStack { Text ("OutlineGroup 实现大纲" ) OutlineGroup (outlineModel, children: \.children) { i in Label (i.title, systemImage: i.iconName) } 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) } } } } } } } 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) } } } } 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) } } } struct POutlineModel : Hashable , Identifiable { var id = UUID () var title: String var iconName: String var children: [POutlineModel ]? } 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) } } header: { Text ("第一栏,共 \(data.count) 项" ) } footer: { Text ("The End" ) } } .listStyle(.plain) } } struct PRowView : View { var s: String var i: Int var body: some View { HStack { Text ("\(i) :" ) Text (s) } } } 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.leading
和 HorizontalEdge.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
列的设置有三种,这三种也可以组合用。
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 使用 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) } } } ZStack { PCScrollView { ForEach (0 ..< 100 ) { i in Text ("\(i) " ) } } whenMoved: { d in scrollOffset = d } infoView } } } 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) } } 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)
浮层
浮层有 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 { 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 } Button ("查看 Popover" ) { isShowPopover = true } .popover(isPresented: $isShowPopover , attachmentAnchor: .point(.trailing), arrowEdge: .trailing) { Text ("Popover 的内容" ) .padding() } } } .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 ("确定" ) { } .keyboardShortcut(.defaultAction) Button ("不不" , role: .cancel) { } } 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) } } } 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 } } } 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() } } } } 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 ) } HStack { Button ("上一页" ) { if selection > 0 { selection -= 1 } } .keyboardShortcut(.cancelAction) Button ("下一页" ) { if selection < 9 { selection += 1 } } .keyboardShortcut(.defaultAction) } .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 chart 和 Design 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 Chartsstruct 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)) } } }
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 let xStart = value.startLocation.x - nthGeoItem[proxy.plotAreaFrame].origin.x let xCurrent = value.location.x - nthGeoItem[proxy.plotAreaFrame].origin.x if let dateStart: Date = proxy.value(atX: xStart), let dateCurrent: Date = proxy.value(atX: xCurrent) { range = (dateStart, dateCurrent) } } .onEnded { _ in range = nil } ) } } } }
社区做的更多 Swift Charts 范例 Swift Charts Examples 。
Toggle
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) Toggle (isOn: $isEnable ) { Text ("\(isEnable ? "开了" : "关了" ) " ) } .toggleStyle(SwitchToggleStyle (tint: .orange)) .padding() Toggle (isOn: $isEnable ) { Text (isEnable ? "录音中" : "已静音" ) } .toggleStyle(PCToggleStyle ()) } } 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
有 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() 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 PhotosUIimport CoreTransferablestruct 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) } } }
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) 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
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) VStack (spacing: 10 ) { Text ("这是一个适配了暗黑的文字颜色" ) .foregroundColor(light: .purple, dark: .pink) .background(Color (nsColor: .quaternaryLabelColor)) Text ("自定义颜色" ) .foregroundColor(Color (red: 0 , green: 0 , blue: 100 )) } .padding() } } } 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
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 { Text (isChange ? "另一种状态" : "一种状态" ) .font(.headline) .padding() .animation(.easeInOut, value: isChange) .onTapGesture { withAnimation { isChange.toggle() } var t = Transaction (animation: .linear(duration: 2 )) t.disablesAnimations = true withTransaction(t) { isChange.toggle() } } 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) } VStack { Text ("后台" ) .font(.headline) placeStayView Text ("前台" ) .font(.headline) placeShowView } .padding(50 ) 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() 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 ) ) 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) } .padding() } TimelineView (.everySecond) { t in let e = "p\(Int.random(in: 1 ... 30 )) " Image (e) .resizable() .scaledToFit() .frame(height: 40 ) } TimelineView (.everyLoop(timeOffsets: [0.2 , 0.7 , 1 , 0.5 , 2 ])) { t in TimelineSubView (date: t.date) } } 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) } } } @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 ) } } } 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 ) } } } } extension TimelineSchedule where Self == PeriodicTimelineSchedule { static var everySecond: PeriodicTimelineSchedule { get { .init (from: .now, by: 1 ) } } } 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 } } } 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]) 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) } } 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 ) } } } 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() } } } } 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) } } } 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) } } 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) 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.