普通视图

发现新文章,点击刷新页面。
昨天以前戴铭的博客 - 星光社

小册子之 SwiftUI 动画

作者 戴铭
2024年5月25日 19:20

以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

SwiftUI动画

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

示例代码如下:

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: - 扩展 TimelineScheduleextension 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)    }}

contentTransition

.contentTransition(.numericText()) 修饰符用于在视图内容发生变化时,以数字动画的方式进行过渡。

struct ContentView: View {    @State private var filmNumber: Int = 0        var body: some View {        VStack(spacing: 20) {            Text("\(filmNumber)")                .contentTransition(.numericText())                .animation(.easeIn, value: filmNumber)            Stepper("电影数量", value: $filmNumber, in: 0...100)        }        .font(.largeTitle)        .foregroundColor(.indigo)    }}

animation修饰符

基本用法

在 SwiftUI 中,创建一个动画需要以下三个组成部分:

  • 一个时间曲线函数
  • 一个声明将状态(或特定的依赖项)与该时间曲线函数关联起来
  • 一个依赖于该状态(或特定的依赖项)的可动画组件

动画的接口定义为 Animation(timingFunction:property:duration:delay)

  • timingFunction 是时间曲线函数,可以是线性、缓动、弹簧等
  • property 是动画属性,可以是颜色、大小、位置等
  • duration 是动画持续时间
  • delay 是动画延迟时间

三种写法

  • withAnimation(_:_:) 全局应用
  • animation(_:value:) 应用于 View
  • animation(_:) 应用于绑定的变量

第一种

withAnimation(.easeInOut(duration: 1.5).delay(1.0)) {    myProperty = newValue}

第二种

View().animation(.easeInOut(duration: 1.5).delay(1.0), value: myProperty)

第三种

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        PosterView(scale: $scale.animation(.linear(duration: 1)))    }}struct PosterView: View {    @Binding var scale: CGFloat    var body: some View {        Image("evermore")            .resizable()            .scaledToFit()            .scaleEffect(scale)            .onAppear {                scale = 1.5            }    }}

在这个示例中,我们创建了一个 MovieView,它有一个状态变量 scale。当 scale 的值改变时,PosterView 中的海报图片会以线性动画的方式进行缩放。当 PosterView 出现时,scale 的值会改变为 1.5,因此海报图片会以线性动画的方式放大到 1.5 倍。

在 SwiftUI 中,我们也可以创建一个自定义的 AnimatableModifier 来实现对图文卡片大小的动画处理。

struct ContentView: View {    @State private var isSmall = false    var body: some View {        VStack {            Image("evermore")                .resizable()                .aspectRatio(contentMode: .fit)                .clipShape(.rect(cornerSize: CGSize(width: 16, height: 16)))            Text("电影标题")                .font(.title)                .fontWeight(.bold)        }        .animatableCard(size: isSmall ? CGSize(width: 200, height: 300) : CGSize(width: 400, height: 600))        .onTapGesture {            withAnimation(.easeInOut(duration: 1)){                isSmall.toggle()            }        }    }}struct AnimatableCardModifier: AnimatableModifier {    var size: CGSize    var color: Color = .white        var animatableData: CGSize.AnimatableData {        get { CGSize.AnimatableData(size.width, size.height) }        set { size = CGSize(width: newValue.first, height: newValue.second) }    }        func body(content: Content) -> some View {        content            .frame(width: size.width, height: size.height)            .background(color)            .cornerRadius(10)    }}extension View {    func animatableCard(size: CGSize,                        color: Color = .white) -> some View {        self.modifier(AnimatableCardModifier(size: size,                                             color: color))    }}

SwiftUI 内置了许多动画过渡函数,主要分为四类:

  • 时间曲线动画函数
  • 弹簧动画函数
  • 高阶动画函数
  • 自定义动画函数

时间曲线动画函数

时间曲线函数决定了动画的速度如何随时间变化,这对于动画的自然感觉非常重要。

SwiftUI 提供了以下几种预设的时间曲线函数:

  • linear:线性动画,动画速度始终保持不变。
  • easeIn:动画开始时速度较慢,然后逐渐加速。
  • easeOut:动画开始时速度较快,然后逐渐减速。
  • easeInOut:动画开始和结束时速度较慢,中间阶段速度较快。

除此之外,SwiftUI 还提供了 timingCurve 函数,可以通过二次曲线或 Bézier 曲线来自定义插值函数,实现更复杂的动画效果。

以下是代码示例:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            withAnimation(.easeInOut(duration: 1.0)) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以 easeInOut 的方式在 1 秒内放大到 1.5 倍。

弹簧动画函数

弹簧动画函数可以模拟物理世界中的弹簧运动,使动画看起来更加自然和生动。

SwiftUI 提供了以下几种预设的弹簧动画函数:

  • smooth:平滑的弹簧动画,动画速度逐渐减慢,直到停止。
  • snappy:快速的弹簧动画,动画速度快速减慢,然后停止。
  • bouncy:弹跳的弹簧动画,动画在结束时会有一些弹跳效果。

除此之外,SwiftUI 还提供了 spring 函数,可以自定义弹簧动画的持续时间、弹跳度和混合持续时间,实现更复杂的弹簧动画效果。

以下是代码示例:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            withAnimation(.spring(response: 0.5, dampingFraction: 0.5, blendDuration: 1)) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以自定义的弹簧动画方式在 0.5 秒内放大到 1.5 倍。

高阶动画函数

高级动画函数可以在基础动画函数的基础上,添加延迟、重复、翻转和速度等功能,使动画效果更加丰富和复杂。

以下是这些函数的简单介绍:

  • func delay(TimeInterval) -> Animation:此函数可以使动画在指定的时间间隔后开始。
  • func repeatCount(Int, autoreverses: Bool) -> Animation:此函数可以使动画重复指定的次数。如果 autoreverses 参数为 true,则每次重复时动画都会翻转。
  • func repeatForever(autoreverses: Bool) -> Animation:此函数可以使动画无限次重复。如果 autoreverses 参数为 true,则每次重复时动画都会翻转。
  • func speed(Double) -> Animation:此函数可以调整动画的速度,使其比默认速度快或慢。

以下是代码示例:

struct MovieView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("movie_poster")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            withAnimation(Animation.easeInOut(duration: 1.0).delay(0.5).repeatCount(3, autoreverses: true).speed(2)) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以 easeInOut 的方式在 1 秒内放大到 1.5 倍,然后在 0.5 秒后开始,重复 3 次,每次重复都会翻转,速度是默认速度的 2 倍。

自定义动画函数

SwiftUI 可以通过实现 CustomAnimation 协议来完全自定义插值算法。

以下是一个简单的 Linear 动画函数的实现:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)                .animation(.myLinear(duration: 1), value: scale) // use myLinear animation        }        .onAppear {            scale = 1.5        }    }}struct MyLinearAnimation: CustomAnimation {  var duration: TimeInterval  func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic {    if time <= duration {      value.scaled(by: time / duration)    } else {      nil    }  }  func velocity<V: VectorArithmetic>(    value: V, time: TimeInterval, context: AnimationContext<V>  ) -> V? {    value.scaled(by: 1.0 / duration)  }  func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool where V : VectorArithmetic {    true  }}extension Animation {  public static func myLinear(duration: TimeInterval) -> Animation { // define function like linear    return Animation(MyLinearAnimation(duration: duration))  }}

Transaction

Transaction 使用指南

这段内容主要介绍了 SwiftUI 中的 TransactionwithTransactionTransaction 是 SwiftUI 中用于控制动画的一种方式,它可以用来定义动画的详细参数,如动画类型、持续时间等。withTransaction 是一个函数,它接受一个 Transaction 实例和一个闭包作为参数,闭包中的代码将在这个 Transaction 的上下文中执行。

以下是一个使用 TransactionwithTransaction 的代码示例:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            let transaction = Transaction(animation: .easeInOut(duration: 1))            withTransaction(transaction) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,我们创建了一个 Transaction,并设置了动画类型为 easeInOut,持续时间为 1 秒。然后我们在 withTransaction 的闭包中改变 scale 的值,这样海报的大小就会以 easeInOut 的方式在 1 秒内放大到 1.5 倍。

使用 TransactionwithTransaction

SwiftUI 中 TransactiondisablesAnimationsisContinuous 属性,以及 transaction(_:) 方法怎么使用?

disablesAnimations 属性可以用来禁止动画,isContinuous 属性可以用来标识一个连续的交互(例如拖动)。transaction(_:) 方法可以用来创建一个新的 Transaction 并在其闭包中设置动画参数。

以下是一个使用这些特性的代码示例:

struct ContentView: View {    @State var size: CGFloat = 100    @GestureState var dragSize: CGSize = .zero    var body: some View {        VStack {            Image("fearless")                .resizable()                .scaledToFit()                .frame(width: size, height: size) // 使用 size 控制尺寸,而非位置                .animation(.spring(), value: size) // 使用弹簧动画                .transaction {                    if $0.isContinuous {                        $0.animation = nil // 拖动时,不设置动画                    } else {                        $0.animation = .spring() // 使用弹簧动画                    }                }                .gesture(                    DragGesture()                        .updating($dragSize, body: { current, state, transaction in                            state = .init(width: current.translation.width, height: current.translation.height)                            transaction.isContinuous = true // 拖动时,设置标识                        })                )            Stepper("尺寸: \(size)", value: $size, in: 50...200) // 使用 Stepper 替代 Slider            Button("开始动画") {                var transaction = Transaction()                if size < 150 { transaction.disablesAnimations = true }                withTransaction(transaction) {                    size = 50                }            }        }    }}

在这个示例中,当 size 小于 150 时,我们禁用动画。通过 .isContinuous 属性,我们可以标识一个连续的交互(例如拖动)。在这个示例中,当拖动时,我们禁用动画。通过 transaction(_:) 方法,我们可以创建一个新的 Transaction 并在其中设置动画参数。

用于视图组件

大部分 SwiftUI 视图组件都有 transaction(_:) 方法,可以用来设置动画参数。比如 NavigationStack, Sheet, Alert 等。

Transaction 也可以用于 BindingFetchRequest

看下面的例子:

struct ContentView: View {    @State var size: CGFloat = 100    @State var isBold: Bool = false    let animation: Animation? = .spring    var sizeBinding: Binding<CGFloat> {        let transaction = Transaction(animation: animation)        return $size.transaction(transaction)    }    var isBoldBinding: Binding<Bool> {        let transaction = Transaction(animation: animation)        return $isBold.transaction(transaction)    }    var body: some View {        VStack {            Image(systemName: "film")                .resizable()                .scaledToFit()                .frame(width: size, height: size) // 使用 size 控制尺寸,而非位置                .font(.system(size: size, weight: isBold ? .bold : .regular)) // 使用 isBold 控制粗细            Stepper("尺寸: \(size)", value: sizeBinding, in: 50...200)            Toggle("粗细", isOn: isBoldBinding)        }        .padding()    }}

传播行为

Transaction 可以用于控制动画的传播行为。在 SwiftUI 中,动画可以在视图层次结构中传播,这意味着一个视图的动画效果可能会影响到其子视图。Transaction 可以用来控制动画的传播行为,例如禁用动画、设置动画类型等。

以下是一个使用 Transaction 控制动画传播行为的代码示例:

enum BookStatus {    case small, medium, large, extraLarge}extension View {    @ViewBuilder func debugAnimation() -> some View {        transaction {            debugPrint($0.animation ?? "")        }    }}struct ContentView: View {    @State var status: BookStatus = .small    var animation: Animation? {        switch status {        case .small:            return .linear        case .medium:            return .easeIn        case .large:            return .easeOut        case .extraLarge:            return .spring()        }    }    var size: CGFloat {        switch status {        case .small:            return 100        case .medium:            return 200        case .large:            return 300        case .extraLarge:            return 400        }    }    var body: some View {        VStack {            Image(systemName: "book")                .resizable()                .scaledToFit()                .frame(width: size, height: size)                .debugAnimation() // 查看动画变化信息            Button("改变状态") {                var transaction = Transaction(animation: animation)                withTransaction(transaction) {                    switch self.status {                    case .small:                        self.status = .medium                    case .medium:                        self.status = .large                    case .large:                        self.status = .extraLarge                    case .extraLarge:                        self.status = .small                    }                }            }        }    }}

这个示例中,我们创建了一个 BookView,它包含一个书籍图标。我们通过 BookStatus 枚举来控制书籍的大小,通过 animation 计算属性来根据状态返回不同的动画类型。在 withTransaction 中,我们根据状态创建一个新的 Transaction,并在其中设置动画类型。通过 debugAnimation 修饰符,我们可以查看动画的变化信息。

TransactionKey

TransactionKey 是一种在 SwiftUI 的视图更新过程中传递额外信息的机制,它可以让你在不同的视图和视图更新之间共享数据。

struct ContentView: View {    @State private var store = MovieStore()    var body: some View {        VStack {            Image("evermore")                .resizable()                .scaledToFit()                .frame(width: 300, height: 300)                .saturation(store.isPlaying ? 1 : 0) // 滤镜变化                .transaction {                    $0.animation = $0[StatusKey.self].animation                }            PlayView(store: store)            PauseView(store: store)        }    }}struct PlayView: View {    let store: MovieStore    var body: some View {        Button("播放") {            withTransaction(\.status, .playing) {                store.isPlaying.toggle()            }        }    }}struct PauseView: View {    let store: MovieStore    var body: some View {        Button("暂停") {            withTransaction(\.status, .paused) {                store.isPlaying.toggle()            }        }    }}@Observableclass MovieStore {    var isPlaying = false}enum MovieStatus {    case playing    case paused    case stopped    var animation: Animation? {        switch self {        case .playing:            Animation.linear(duration: 2)        case .paused:            nil        case .stopped:            Animation.easeInOut(duration: 1)        }    }}struct StatusKey: TransactionKey {    static var defaultValue: MovieStatus = .stopped}extension Transaction {    var status: MovieStatus {        get { self[StatusKey.self] }        set { self[StatusKey.self] = newValue }    }}

以上代码中,我们创建了一个 MovieStore 类,用于存储电影播放状态。我们通过 PlayViewPauseView 分别创建了播放和暂停按钮,点击按钮时,我们通过 withTransaction 函数改变了 MovieStoreisPlaying 属性,并根据状态设置了动画类型。在 ContentView 中,我们通过 transaction 修饰符设置了动画类型为 MovieStatus 中的动画类型。

AnyTransition

AnyTransition 是一个用于创建自定义过渡效果的类型,它可以让你定义视图之间的过渡动画。你可以使用 AnyTransitionmodifier 方法将自定义过渡效果应用到视图上。

struct ContentView: View {        @StateObject var musicViewModel = MusicViewModel()        var body: some View {        VStack {            ForEach(musicViewModel.musicNames, id: \.description) { musicName in                if musicName == musicViewModel.currentMusic {                    Image(musicName)                        .resizable()                        .frame(width: 250, height: 250)                        .ignoresSafeArea()                        .transition(.glitch.combined(with: .opacity))                }            }                        Button("Next Music") {                musicViewModel.selectNextMusic()            }            .buttonStyle(.borderedProminent)            .padding()            .background(Color.blue)            .foregroundColor(.white)            .cornerRadius(10)        }    }}struct MyTransition: ViewModifier {    let active: Bool    func body(content: Content) -> some View {        content            .rotationEffect(active ? .degrees(Double.random(in: -10...10)) : .zero)            .offset(x: active ? CGFloat.random(in: -10...10) : 0, y: active ? CGFloat.random(in: -10...10) : 0)    }}extension AnyTransition {    static var glitch: AnyTransition {        AnyTransition.modifier(            active: MyTransition(active: true),            identity: MyTransition(active: false)        )    }}class MusicViewModel: ObservableObject {    @Published var currentMusic = ""        let musicNames = ["fearless", "evermore", "red", "speaknow", "lover"]        init() {        currentMusic = musicNames.first ?? "fearless"    }        func selectNextMusic() {        guard let currentIndex = musicNames.firstIndex(of: currentMusic) else {            return        }                let nextIndex = currentIndex + 1 < musicNames.count ? currentIndex + 1 : 0                withAnimation(.easeInOut(duration: 2)) {            currentMusic = musicNames[nextIndex]        }    }}

以上代码中,我们创建了一个 MusicViewModel 类,用于存储音乐播放状态。我们通过 MyTransition 自定义了一个过渡效果,通过 AnyTransitionmodifier 方法将自定义过渡效果应用到视图上。在 ContentView 中,我们通过 transition 修饰符设置了过渡效果为 glitch,并在点击按钮时切换音乐。

Matched Geometry Effect

位置变化

Matched Geometry Effect 是一种特殊的动画效果。当你有两个视图,并且你想在一个视图消失,另一个视图出现时,创建一个平滑的过渡动画,你就可以使用这个效果。你只需要给这两个视图添加同样的标识符和命名空间,然后当你删除一个视图并添加另一个视图时,就会自动创建一个动画,让一个视图看起来像是滑动到另一个视图的位置。

示例代码如下:

struct ContentView: View {    @StateObject var viewModel = ViewModel()    @Namespace var musicSelectionNamespace    var body: some View {        VStack {            HStack {                ForEach(viewModel.topMusic) { item in                    Button(action: { viewModel.selectTopMusic(item) }) {                        ZStack {                            Image(item.name)                                .resizable()                                .frame(width: 60, height: 60)                            Text(item.name)                                .fontDesign(.rounded)                                .foregroundColor(.white)                                .shadow(radius: 10)                        }                    }                    .matchedGeometryEffect(id: item.id, in: musicSelectionNamespace)                }            }            .frame(minHeight: 150)            Spacer()                .frame(height: 250)            HStack {                ForEach(viewModel.bottomMusic) { item in                    Button(action: { viewModel.selectBottomMusic(item) }) {                        ZStack {                            Image(item.name)                                .resizable()                                .frame(width: 90, height: 90)                            Text(item.name)                                .font(.title3)                                .fontWeight(.bold)                                .foregroundColor(.white)                                .shadow(radius: 10)                        }                    }                    .matchedGeometryEffect(id: item.id, in: musicSelectionNamespace)                }            }            .frame(minHeight: 150)        }    }}

以上代码中,我们创建了一个 ContentView 视图,其中包含两个 HStack 视图,分别展示了 viewModel 中的 topMusicbottomMusic 数组。我们为每个 topMusicbottomMusic 元素创建了一个 Button 视图,当用户点击按钮时,会调用 viewModel 中的 selectTopMusicselectBottomMusic 方法。我们使用 matchedGeometryEffect 修饰符为每个 Button 视图添加了一个标识符,这样当用户点击按钮时,就会自动创建一个动画,让一个视图看起来像是滑动到另一个视图的位置。

大小变化

Matched Geometry Effect 在大小和位置上都可以进行动画过渡,这样可以让你创建更加复杂的动画效果。

以下是一个视图大小切换的示例:

struct ContentView: View {    @State var isExpanded: Bool = false        private var albumId = "Album"        @Namespace var expansionAnimation        var body: some View {        VStack {            albumView(isExpanded: isExpanded)        }        .padding()        .onTapGesture {            withAnimation {                isExpanded.toggle()            }        }    }        @ViewBuilder    func albumView(isExpanded: Bool) -> some View {        let imageSize = isExpanded ? CGSize(width: 300, height: 450) : CGSize(width: 100, height: 150)        Image(isExpanded ? "evermore" : "fearless")            .resizable()            .aspectRatio(contentMode: .fill)            .frame(width: imageSize.width, height: imageSize.height)            .clipped()            .matchedGeometryEffect(id: albumId, in: expansionAnimation)            .overlay {                Text("Taylor Swift")                    .font(isExpanded ? .largeTitle : .headline)                    .fontDesign(.monospaced)                    .fontDesign(.rounded)                    .foregroundStyle(.white)            }    }}

内容位置变化

内容位置变化的动画效果。以下是一个内容位置变化的示例:

struct ContentView: View {    @State var show = false    @Namespace var placeHolder    @State var albumCoverSize: CGSize = .zero    @State var songListSize: CGSize = .zero    var body: some View {        ZStack {            VStack {                Text("Taylor Swift,1989年12月13日出生于美国宾夕法尼亚州,美国乡村音乐、流行音乐女歌手、词曲创作人、演员、慈善家。")                    .font(.title)                    .fontDesign(.monospaced)                    .fontDesign(.rounded)                    .padding(20)                Spacer()            }            Color.clear                // AlbumCover placeholder                .overlay(alignment: .bottom) {                    Color.clear // AlbumCoverView().opacity(0.01)                        .frame(height: albumCoverSize.height)                        .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: .bottom, isSource: true)                        .matchedGeometryEffect(id: "top", in: placeHolder, anchor: .top, isSource: true)                }                .overlay(                    AlbumCoverView()                        .sizeInfo($albumCoverSize)                        .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)                )                .overlay(                    SongListView()                        .matchedGeometryEffect(id: "top", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)                )                .animation(.default, value: show)                .ignoresSafeArea()                .overlayButton(show: $show)        }    }}struct AlbumCoverView: View {    var body: some View {        Image("evermore")            .resizable()            .aspectRatio(contentMode: .fill)    }}struct SongListView: View {    var body: some View {        List {            Text("Fearless")            Text("Speak Now")            Text("Red")            // ...        }    }}extension View {    func overlayButton(show: Binding<Bool>) -> some View {        self.overlay(            Button(action: {                withAnimation {                    show.wrappedValue.toggle()                }            }) {                Image(systemName: "arrow.up.arrow.down.square")                    .font(.largeTitle)                    .padding()                    .background(Color.white.opacity(0.75))                    .clipShape(Circle())            }            .padding()            , alignment: .topTrailing        )    }        func sizeInfo(_ size: Binding<CGSize>) -> some View {        self.background(            GeometryReader { geometry in                Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)            }        )        .onPreferenceChange(SizePreferenceKey.self) { size.wrappedValue = $0 }    }}struct SizePreferenceKey: PreferenceKey {    static var defaultValue: CGSize = .zero    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {        value = nextValue()    }}

我们使用 matchedGeometryEffect 修饰符为 AlbumCoverViewSongListView 添加了一个标识符,这样当用户点击按钮时,就会自动创建一个动画,让 AlbumCoverViewSongListView 看起来像是从一个位置切换到另一个位置。

点击显示详细信息

点击显示详细信息的动画效果。

struct ContentView: View {    @Namespace var animation    @State var showDetail = false            var body: some View {        ZStack {            if (!showDetail) {                VStack {                    Text("Taylor Swift")                            .matchedGeometryEffect(id: "artist", in: animation)                            .font(.largeTitle.bold())                            .foregroundColor(Color.white)                                        Text("美国歌手")                        .matchedGeometryEffect(id: "description", in: animation)                        .font(.title3.bold())                        .foregroundColor(Color.white)                }                .padding(30)                .background(                    Rectangle().fill(.black.gradient)                        .matchedGeometryEffect(id: "background", in: animation)                )            } else {                SingerView(animation: animation)            }        }        .onTapGesture {            withAnimation {                showDetail.toggle()            }        }    }}struct SingerView: View {    var animation: Namespace.ID    var body: some View {        VStack{            Text("Taylor Swift")                    .matchedGeometryEffect(id: "artist", in: animation)                    .font(.largeTitle.bold())                    .foregroundColor(Color.white)                        Text("美国歌手")                .matchedGeometryEffect(id: "description", in: animation)                .font(.title3.bold())                .foregroundColor(Color.white)            Spacer()                .frame(height: 30)            Text("泰勒·阿利森·斯威夫特(Taylor Swift),1989年12月13日出生于美国宾夕法尼亚州,美国乡村音乐、流行音乐女歌手、词曲创作人、演员、慈善家。")                .font(.subheadline.bold())                .foregroundColor(Color.white)                        Spacer()                .frame(height: 30)            Image("evermore")                .resizable()                .scaledToFit()                .clipShape(.rect(cornerSize: CGSize(width: 16, height: 16)))                        Text("Evermore 是 Taylor Swift 的最新专辑,这是她在 2020 年的第二张专辑,也是她的第九张录音室专辑。")                .font(.subheadline.bold())                .foregroundColor(Color.white)                    }        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)        .padding(.all, 20)        .background(            Rectangle().fill(.black.gradient)                .matchedGeometryEffect(id: "background", in: animation)                .ignoresSafeArea(.all)        )    }}

导航动画

以下是一个导航动画的示例:

struct ContentView: View {    @Namespace var animation    @State var selectedManga: String? = nil            var body: some View {        ZStack {            if (selectedManga == nil) {                MangaListView(animation: animation, selectedManga: $selectedManga)            } else {                MangaDetailView(selectedManga: $selectedManga, animation: animation)            }        }    }}struct MangaDetailView: View {    @Binding var selectedManga: String?    var animation: Namespace.ID        var body: some View {        VStack {            Text( "\(selectedManga ?? "")")                    .matchedGeometryEffect(id: "mangaTitle", in: animation)                    .font(.title3.bold())                    .foregroundColor(Color.black)                        Spacer()                .frame(height: 50)            Button(action: {                withAnimation {                    selectedManga = nil                }            }, label: {                Text( "返回")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.red)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.white.gradient)            )        }        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)        .padding(.all, 20)        .background(            Color(UIColor.systemTeal)                .matchedGeometryEffect(id: "background", in: animation)                .ignoresSafeArea(.all)        )    }}struct MangaListView: View {    var animation: Namespace.ID    @Binding var selectedManga: String?    var body: some View {        VStack {            Button(action: {                withAnimation {                    selectedManga = "海贼王"                }            }, label: {                Text( "海贼王")                    .matchedGeometryEffect(id: "mangaTitle", in: animation)                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.teal)            )                        Button(action: {                withAnimation {                    selectedManga = "火影忍者"                }            }, label: {                Text( "火影忍者")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.mint)                    .matchedGeometryEffect(id: "background", in: animation)            )            Button(action: {                withAnimation {                    selectedManga = "进击的巨人"                }            }, label: {                Text( "进击的巨人")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.orange)            )            Button(action: {                withAnimation {                    selectedManga = "鬼灭之刃"                }            }, label: {                Text( "鬼灭之刃")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.purple)            )            Button(action: {                withAnimation {                    selectedManga = "我的英雄学院"                }            }, label: {                Text( "我的英雄学院")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.green)            )        }    }}

geometryGroup

.geometryGroup() 主要用于处理一组视图动画变化时不协调的问题。如果你有一组视图,它们的位置和大小会随着动画变化,你可以使用 .geometryGroup() 修饰符来确保它们的位置和大小保持一致。

PhaseAnimator

PhaseAnimator

以下代码示例演示了如何使用 PhaseAnimator 视图修饰符创建一个动画,该动画通过循环遍历所有动画步骤来连续运行。在这个例子中,我们使用 PhaseAnimator 来创建一个简单的动画,该动画通过循环遍历所有动画步骤来连续运行。当观测值发生变化时,动画会触发一次。

enum AlbumAnimationPhase: String, CaseIterable, Comparable {    case evermore, fearless, folklore, lover, midnights, red, speaknow    static func < (lhs: AlbumAnimationPhase, rhs: AlbumAnimationPhase) -> Bool {        lhs.rawValue < rhs.rawValue    }}struct ContentView: View {    @State var animate: Bool = false    var body: some View {        ScrollView {            PhaseAnimator(                AlbumAnimationPhase.allCases,                trigger: animate,                content: { phase in                    VStack {                        ForEach(AlbumAnimationPhase.allCases, id: \.self) { album in                            if phase >= album {                                VStack {                                    Image(album.rawValue)                                        .resizable()                                        .frame(width: 100, height: 100)                                    Text(album.rawValue.capitalized)                                        .font(.title)                                }                                .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)))                            }                        }                    }                    .padding()                }, animation: { phase in                    .spring(duration: 0.5)                }            )        } // end ScrollView        Button(action: {            animate.toggle()        }, label: {            Text("开始")                .font(.largeTitle)                .bold()        })    }}

在上面的代码中,我们首先定义了一个枚举类型 AlbumAnimationPhase,用于表示专辑的不同阶段。然后,我们在 ContentView 视图中创建了一个 PhaseAnimator 视图修饰符,该修饰符接受一个观测值 trigger,用于触发动画。在 content 闭包中,我们遍历所有专辑,并根据当前阶段 phase 来决定是否显示专辑。在 animation 闭包中,我们使用 .spring(duration: 0.5) 创建了一个弹簧动画效果。

KeyframeAnimator

KeyframeAnimator是一个在SwiftUI中创建关键帧动画的工具。关键帧动画是一种动画类型,其中定义了动画开始和结束的关键帧,以及可能的一些中间关键帧,然后动画系统会在这些关键帧之间进行插值以创建平滑的动画。

KeyframeAnimator接受一个初始值,一个内容闭包,以及一个关键帧闭包。初始值是一个包含了动画所需的所有属性的结构(在这个例子中是scalerotationoffset)。内容闭包接受一个这样的结构实例,并返回一个视图。这个视图将使用结构中的值进行配置,以便它可以根据这些值进行动画。关键帧闭包接受一个这样的结构实例,并定义了一系列的关键帧轨道。每个轨道都对应于结构中的一个属性,并定义了一系列的关键帧。每个关键帧都定义了一个值和一个时间点,动画系统将在这些关键帧之间进行插值。

此外,SwiftUI提供了四种不同类型的关键帧:LinearKeyframeSpringKeyframeCubicKeyframeMoveKeyframe。前三种关键帧使用不同的动画过渡函数进行插值,而MoveKeyframe则立即跳转到指定值,无需插值。

KeyframeAnimator可以用于创建各种复杂的动画效果,例如根据滚动位置调整关键帧驱动的效果,或者根据时间进行更新。

struct ContentView: View {    @State var animationTrigger: Bool = false    var body: some View {        VStack {            KeyframeAnimator(                initialValue: AnimatedMovie(),                content: { movie in                    Image("evermore")                        .resizable()                        .frame(width: 100, height: 150)                        .scaleEffect(movie.scaleRatio)                        .rotationEffect(movie.rotationAngle)                        .offset(y: movie.verticalOffset)                }, keyframes: { movie in                    KeyframeTrack(\.scaleRatio) {                        LinearKeyframe(1.0, duration: 0.36)                        SpringKeyframe(1.5, duration: 0.8, spring: .bouncy)                        SpringKeyframe(1.0, spring: .bouncy)                    }                    KeyframeTrack(\.rotationAngle) {                        CubicKeyframe(.degrees(-30), duration: 1.0)                        CubicKeyframe(.zero, duration: 1.0)                    }                    KeyframeTrack(\.verticalOffset) {                        LinearKeyframe(0.0, duration: 0.1)                        SpringKeyframe(20.0, duration: 0.15, spring: .bouncy)                        CubicKeyframe(-60.0, duration: 0.2)                        MoveKeyframe(0.0)                    }                }            )        }    }}struct AnimatedMovie {    var scaleRatio: Double = 1    var rotationAngle = Angle.zero    var verticalOffset: Double = 0}

以上代码中,我们首先定义了一个AnimatedMovie结构,它包含了动画所需的所有属性。然后,我们在ContentView视图中创建了一个KeyframeAnimator,该修饰符接受一个观测值animationTrigger,用于触发动画。在content闭包中,我们使用Image视图创建了一个电影海报,并根据AnimatedMovie结构中的值对其进行配置。在keyframes闭包中,我们为每个属性定义了一系列的关键帧轨道。例如,我们为scaleRatio属性定义了三个关键帧,分别使用LinearKeyframeSpringKeyframe进行插值。我们还为rotationAngleverticalOffset属性定义了两个关键帧轨道,分别使用CubicKeyframeMoveKeyframe进行插值。

也可以使用 .keyframeAnimator 修饰符来创建关键帧动画。以下是一个示例,演示了如何使用 .keyframeAnimator 修饰符创建一个关键帧动画,该动画在用户点击时触发。

struct ContentView: View {    @State var animationTrigger: Bool = false        var body: some View {        Image("evermore")            .resizable()            .frame(width: 100, height: 150)            .scaleEffect(animationTrigger ? 1.5 : 1.0)            .rotationEffect(animationTrigger ? .degrees(-30) : .zero)            .offset(y: animationTrigger ? -60.0 : 0.0)            .keyframeAnimator(initialValue: AnimatedMovie(),                              trigger: animationTrigger,                              content: { view, value in                view                    .scaleEffect(value.scaleRatio)                    .rotationEffect(value.rotationAngle)            },                              keyframes: { value in                KeyframeTrack(\.scaleRatio) {                    LinearKeyframe(1.5, duration: 0.36)                    SpringKeyframe(1.0, duration: 0.8, spring: .bouncy)                    SpringKeyframe(1.5, spring: .bouncy)                }                                KeyframeTrack(\.rotationAngle) {                    CubicKeyframe(.degrees(-30), duration: 1.0)                    CubicKeyframe(.zero, duration: 1.0)                }                                KeyframeTrack(\.verticalOffset) {                    LinearKeyframe(-60.0, duration: 0.1)                    SpringKeyframe(0.0, duration: 0.15, spring: .bouncy)                    CubicKeyframe(-60.0, duration: 0.2)                    MoveKeyframe(0.0)                }            })                    .onTapGesture {                withAnimation {                    animationTrigger.toggle()                }            }    }}struct AnimatedMovie {    var scaleRatio: Double = 1    var rotationAngle = Angle.zero    var verticalOffset: Double = 0}

在这个例子中,我们创建了一个 AnimatedMovie 结构,它包含了动画所需的所有属性。然后,我们在 ContentView 视图中创建了一个 KeyframeAnimator,该修饰符接受一个观测值 animationTrigger,用于触发动画。在 content 闭包中,我们使用 Image 视图创建了一个电影海报,并根据 AnimatedMovie 结构中的值对其进行配置。在 keyframes 闭包中,我们为每个属性定义了一系列的关键帧轨道。例如,我们为 scaleRatio 属性定义了三个关键帧,分别使用 LinearKeyframe 和 SpringKeyframe 进行插值。我们还为 rotationAngle 和 verticalOffset 属性定义了两个关键帧轨道,分别使用 CubicKeyframe 和 MoveKeyframe 进行插值。

布局动画

import SwiftUIstruct AnimateLayout: View { @State var changeLayout: Bool = true @Namespace var namespace var body: some View {  VStack(spacing: 30) {   if changeLayout {    HStack { items }   } else {    VStack { items }   }   Button("切换布局") {    withAnimation { changeLayout.toggle() }   }  }  .padding() } @ViewBuilder var items: some View {  Text("one")   .matchedGeometryEffect(id: "one", in: namespace)  Text("Two")   .matchedGeometryEffect(id: "Two", in: namespace)  Text("Three")   .matchedGeometryEffect(id: "Three", in: namespace) }}

动画-例子

动画的例子有很多。准备中… 请期待。

小册子之 Form、Picker、Toggle、Slider 和 Stepper 表单相关 SwiftUI 视图

作者 戴铭
2024年5月18日 10:24

以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

Form

控件视图 说明 Style
Button 触发操作的按钮 .bordered, .borderless, .borderedProminent, .plain
Picker 提供多选项供选择 .wheel, .inline, .segmented, .menu, .radioGroup
DatePicker and MultiDatePicker 选择日期的工具 .compact, .wheel, .graphical
Toggle 切换两种状态的开关 .switch, .botton, .checkbox
Stepper 调整数值的步进器 无样式选项
Menu 显示选项列表的菜单 .borderlessButton, .button

Form 有 ColumnFormStyle 还有 GroupedFormStyle。使用 buttonStyle 修饰符:

Form {   ...}.formStyle(.grouped)

Form 新版也得到了增强,示例如下:

struct SimpleFormView: View {    @State private var date = Date()    @State private var eventDescription = ""    @State private var accent = Color.red    @State private var scheme = ColorScheme.light    var body: some View {        Form {            Section {                DatePicker("Date", selection: $date)                TextField("Description", text: $eventDescription)                    .lineLimit(3)            }                        Section("Vibe") {                Picker("Accent color", selection: $accent) {                    ForEach(Color.accentColors, id: \.self) { color in                        Text(color.description.capitalized).tag(color)                    }                }                Picker("Color scheme", selection: $scheme) {                    Text("Light").tag(ColorScheme.light)                    Text("Dark").tag(ColorScheme.dark)                }            }        }        .formStyle(.grouped)    }}extension Color {    static let accentColors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]}

Form 的样式除了 .formStyle(.grouped) 还有 .formStyle(..columns)

关于 Form 字体、单元、背景颜色设置,参看下面代码:

struct ContentView: View {    @State private var movieTitle = ""    @State private var isWatched = false    @State private var rating = 1    @State private var watchDate = Date()    var body: some View {        Form {            Section {                TextField("电影标题", text: $movieTitle)                LabeledContent("导演", value: "克里斯托弗·诺兰")            } header: {                Text("关于电影")            }            .listRowBackground(Color.gray.opacity(0.1))            Section {                Toggle("已观看", isOn: $isWatched)                Picker("评分", selection: $rating) {                    ForEach(1...5, id: \.self) { number in                        Text("\(number) 星")                    }                }            } header: {                Text("电影详情")            }            .listRowBackground(Color.gray.opacity(0.1))                        Section {                DatePicker("观看日期", selection: $watchDate)            }            .listRowBackground(Color.gray.opacity(0.1))                        Section {                Button("重置所有电影数据") {                    resetAllData()                }            }            .listRowBackground(Color.white)        }        .foregroundColor(.black)        .tint(.indigo)        .background(Color.yellow)        .scrollContentBackground(.hidden)        .navigationBarTitle("电影追踪器")    }        private func resetAllData() {        movieTitle = ""        isWatched = false        rating = 1        watchDate = Date()    }}struct LabeledContent: View {    let label: String    let value: String    init(_ label: String, value: String) {        self.label = label        self.value = value    }    var body: some View {        HStack {            Text(label)            Spacer()            Text(value)        }    }}

Picker选择器

Picker

SwiftUI 中的 Picker 视图是一个用于选择列表中的一个选项的用户界面元素。你可以使用 Picker 视图来创建各种类型的选择器,包括滚动选择器、弹出菜单和分段控制。

示例代码如下:

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()    }}

上面的代码中,有三种类型的 Picker 视图:

  1. 默认的下拉风格 Picker 视图。这种类型的 Picker 视图在 Form 中使用,用户可以点击选择器来打开一个下拉菜单,然后从菜单中选择一个选项。
Form {    Section("选区") {        Picker("选一个", selection: $select) {            Text("1")                .tag(1)            Text("2")                .tag(2)        }    }}
  1. 分段控制风格 Picker 视图。这种类型的 Picker 视图使用 SegmentedPickerStyle() 修饰符,它将选择器显示为一组水平排列的按钮,用户可以点击按钮来选择一个选项。
Picker("选一个", selection: $select) {    Text("one")        .tag(1)    Text("two")        .tag(2)}.pickerStyle(SegmentedPickerStyle())
  1. ColorPickerDatePicker 视图。这两种类型的视图是 Picker 视图的特殊形式,它们分别用于选择颜色和日期。
ColorPicker("选一个颜色", selection: $color, supportsOpacity: false)DatePicker("选时间", selection: $date)    .datePickerStyle(GraphicalDatePickerStyle())

在所有这些 Picker 视图中,你都需要提供一个绑定的选择状态,这个状态会在用户选择一个新的选项时更新。你还需要为每个选项提供一个视图和一个唯一的标签。

文字Picker

基本使用

文字 Picker 示例:

struct StaticDataPickerView: View {    @State private var selectedCategory = "动作"    var body: some View {        VStack {            Text("选择的类别: \(selectedCategory)")            Picker("电影类别",                 selection: $selectedCategory) {                Text("动作")                    .tag("动作")                Text("喜剧")                    .tag("喜剧")                Text("剧情")                    .tag("剧情")                Text("恐怖")                    .tag("恐怖")            }        }    }}

使用枚举

使用枚举来创建选取器的示例:

enum MovieCategory: String, CaseIterable, Identifiable {    case action = "动作"    case comedy = "喜剧"    case drama = "剧情"    case horror = "恐怖"    var id: MovieCategory { self }}struct MoviePicker: View {   @State private var selectedCategory: MovieCategory = .action  var body: some View {     Picker("电影类别", selection: $selectedCategory) {        ForEach(MovieCategory.allCases) { category in             Text(category.rawValue).tag(category)       }     }   }}

样式

SwiftUI 提供了多种内置的 Picker 样式,以改变 Picker 的外观和行为。以下是一些主要的 Picker 样式及其使用示例:

  • DefaultPickerStyle:根据平台和环境自动调整样式。这是默认的 Picker 样式。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}
  • WheelPickerStyle:以旋转轮的形式展示选项。在 iOS 上,这种样式会显示一个滚动的选择器。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(WheelPickerStyle())
  • SegmentedPickerStyle:将选项以分段控件的形式展示。这种样式会显示一个分段控制,用户可以在其中选择一个选项。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(SegmentedPickerStyle())
  • InlinePickerStyle:在列表或表格中内联展示选项。这种样式会在 FormList 中显示一个内联的选择器。
Form {    Picker("Label", selection: $selection) {        ForEach(0..<options.count) {            Text(self.options[$0])        }    }    .pickerStyle(InlinePickerStyle())}
  • MenuPickerStyle:点击时以菜单的形式展示选项。这种样式会显示一个菜单,用户可以在其中选择一个选项。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(MenuPickerStyle())
  • .navigationLink:在 iOS 16+ 中,点击后进入下一个页面。这种样式会显示一个导航链接,用户可以点击它来打开一个新的视图。
  • .radioGrouped:仅在 macOS 中可用,以单选按钮组的形式展示选项。这种样式会显示一个单选按钮组,用户可以在其中选择一个选项。

ColorPicker

ColorPicker 是一个允许用户选择颜色的视图。以下是一个 ColorPicker 的使用示例:

import SwiftUIstruct ContentView: View {    @State private var selectedColor = Color.white    var body: some View {        VStack {            ColorPicker("选择一个颜色", selection: $selectedColor)            Text("你选择的颜色")                .foregroundColor(selectedColor)        }    }}

在这个示例中,我们创建了一个 ColorPicker 视图,用户可以通过这个视图选择一个颜色。我们使用 @State 属性包装器来创建一个可以绑定到 ColorPickerselectedColor 状态。当用户选择一个新的颜色时,selectedColor 状态会自动更新,Text 视图的前景色也会相应地更新。

DatePicker

基本使用

struct ContentView: View {    @State private var releaseDate: Date = Date()    var body: some View {        VStack(spacing: 30) {            DatePicker("选择电影发布日期", selection: $releaseDate, displayedComponents: .date)            Text("选择的发布日期: \(releaseDate, formatter: DateFormatter.dateMedium)")        }        .padding()    }}

选择多个日期

在 iOS 16 中,您现在可以允许用户选择多个日期,MultiDatePicker 视图会显示一个日历,用户可以选择多个日期,可以设置选择范围。示例如下:

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()    }}

指定日期范围

指定日期的范围,例如只能选择当前日期之后的日期,示例如下:

DatePicker(    "选择日期",    selection: $selectedDate,    in: Date()...,    displayedComponents: [.date]).datePickerStyle(WheelDatePickerStyle()).labelsHidden()

在这个示例中:

  • selection: $selectedDate 表示选定的日期和时间。
  • in: Date()... 表示可选日期的范围。在这个例子中,用户只能选择当前日期之后的日期。你也可以使用 ...Date() 来限制用户只能选择当前日期之前的日期,或者使用 Date().addingTimeInterval(86400*7) 来限制用户只能选择从当前日期开始的接下来一周内的日期。
  • displayedComponents: [.date] 表示 DatePicker 应该显示哪些组件。在这个例子中,我们只显示日期组件。你也可以使用 .hourAndMinute 来显示小时和分钟组件,或者同时显示日期和时间组件。
  • .datePickerStyle(WheelDatePickerStyle()) 表示 DatePicker 的样式。在这个例子中,我们使用滚轮样式。你也可以使用 GraphicalDatePickerStyle() 来应用图形样式。
  • .labelsHidden() 表示隐藏 DatePicker 的标签。

PhotoPicker

PhotoPicker 使用示例

import SwiftUIimport PhotosUIstruct ContentView: View {    @State private var selectedItem: PhotosPickerItem?    @State private var selectedPhotoData: Data?    var body: some View {        NavigationView {            VStack {                if let item = selectedItem, let data = selectedPhotoData, let image = UIImage(data: data) {                    Image(uiImage: image)                        .resizable()                        .scaledToFit()                } else {                    Text("选择电影海报")                }            }            .navigationTitle("电影海报")            .toolbar {                ToolbarItem(placement: .navigationBarTrailing) {                    PhotosPicker(selection: $selectedItem, matching: .images) {                        Label("选择照片", systemImage: "photo")                    }                    .tint(.indigo)                    .controlSize(.extraLarge)                    .buttonStyle(.borderedProminent)                }            }            .onChange(of: selectedItem, { oldValue, newValue in                Task {                    if let data = try? await newValue?.loadTransferable(type: Data.self) {                        selectedPhotoData = data                    }                }            })        }    }}

限制选择媒体类型

我们可以使用 matching 参数来过滤 PhotosPicker 中显示的媒体类型。这个参数接受一个 PHAssetMediaType 枚举值,可以是 .images.videos.audio.any 等。

例如,如果我们只想显示图片,可以这样设置:

PhotosPicker(selection: $selectedItem, matching: .images) {    Label("选择照片", systemImage: "photo")}

如果我们想同时显示图片和视频,可以使用 .any(of:) 方法:

PhotosPicker(selection: $selectedItem, matching: .any(of: [.images, .videos])) {    Label("选择照片", systemImage: "photo")}

此外,我们还可以使用 .not(_:) 方法来排除某种类型的媒体。例如,如果我们想显示所有的图片,但是不包括 Live Photo,可以这样设置:

PhotosPicker(selection: $selectedItem, matching: .any(of: [.images, .not(.livePhotos)])) {    Label("选择照片", systemImage: "photo")}

这些设置可以让我们更精确地控制 PhotosPicker 中显示的媒体类型。

选择多张图片

以下示例演示了如何使用 PhotosPicker 选择多张图片,并将它们显示在一个 LazyVGrid 中:

import SwiftUIimport PhotosUIstruct ContentView: View {    @State private var selectedItems: [PhotosPickerItem] = [PhotosPickerItem]()    @State private var selectedPhotosData: [Data] = [Data]()    var body: some View {        NavigationStack {            ScrollView {                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {                    ForEach(selectedPhotosData, id: \.self) { photoData in                        if let image = UIImage(data: photoData) {                            Image(uiImage: image)                                .resizable()                                .scaledToFit()                                .cornerRadius(10.0)                                .padding(.horizontal)                        }                    }                }            }            .navigationTitle("书籍")            .toolbar {                ToolbarItem(placement: .navigationBarTrailing) {                    PhotosPicker(selection: $selectedItems, maxSelectionCount: 5, matching: .images) {                        Image(systemName: "book.fill")                            .foregroundColor(.brown)                    }                    .onChange(of: selectedItems, { oldValue, newValue in                        for newItem in newValue {                            Task {                                if let data = try? await newItem.loadTransferable(type: Data.self) {                                    selectedPhotosData.append(data)                                }                            }                        }                    })                }            }        }    }}

以上示例中,我们使用了 PhotosPickermaxSelectionCount 参数来限制用户最多只能选择 5 张图片。当用户选择图片后,我们将图片数据保存在 selectedPhotosData 数组中,并在 LazyVGrid 中显示这些图片。

字体Picker

这段代码实现了一个字体选择器的功能,用户可以在其中选择和查看自己喜欢的字体。

struct ContentView: View {    @State private var fontFamily: String = ""    var body: some View {        VStack {            Text("选择字体:")            FontPicker(fontFamily: $fontFamily)                .equatable()        }    }}struct FontPicker: View, Equatable {    @Binding var fontFamily: String    var body: some View {        VStack {            Text("\(fontFamily)")                .font(.custom(fontFamily, size: 20))            Picker("", selection: $fontFamily) {                ForEach(NSFontManager.shared.availableFontFamilies, id: \.self) { family in                    Text(family)                        .tag(family)                }            }            Spacer()        }        .padding()    }    static func == (l: FontPicker, r: FontPicker) -> Bool {        l.fontFamily == r.fontFamily    }}

WheelPicker

本示例是一个可折叠的滚轮选择器 CollapsibleWheelPicker。这个选择器允许用户从一组书籍中选择一本。

struct ContentView: View {  @State private var selection = 0  let items = ["Book 1", "Book 2", "Book 3", "Book 4", "Book 5"]  var body: some View {    NavigationStack {      Form {        CollapsibleWheelPicker(selection: $selection) {          ForEach(items, id: \.self) { item in            Text("\(item)")          }        } label: {          Text("Books")          Spacer()          Text("\(items[selection])")        }      }    }  }}struct CollapsibleWheelPicker<SelectionValue, Content, Label>: View where SelectionValue: Hashable, Content: View, Label: View {    @Binding var selection: SelectionValue    @ViewBuilder let content: () -> Content    @ViewBuilder let label: () -> Label    var body: some View {        CollapsibleView(label: label) {            Picker(selection: $selection, content: content) {                EmptyView()            }            .pickerStyle(.wheel)        }    }}struct CollapsibleView<Label, Content>: View where Label: View, Content: View {  @State private var isSecondaryViewVisible = false  @ViewBuilder let label: () -> Label  @ViewBuilder let content: () -> Content  var body: some View {    Group {      Button(action: { isSecondaryViewVisible.toggle() }, label: label)        .buttonStyle(.plain)      if isSecondaryViewVisible {        content()      }    }  }}

ContentView 中,我们创建了一个 CollapsibleWheelPicker 视图。这个视图包含一个滚轮样式的选择器,用户可以从中选择一本书。选择的书籍会绑定到 selection 变量。

CollapsibleWheelPicker 视图是一个可折叠的滚轮选择器,它接受一个绑定的选择变量、一个内容视图和一个标签视图。内容视图是一个 Picker 视图,用于显示可供选择的书籍。标签视图是一个 Text 视图,显示当前选择的书籍。

Toggle

示例

使用示例如下

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()                }        }    }}

样式

Toggle 可以设置 toggleStyle,可以自定义样式。

下表是不同平台支持的样式

  • DefaultToggleStyle:iOS 表现的是 Switch,macOS 是 Checkbox
  • SwitchToggleStyle:iOS 和 macOS 都支持
  • CheckboxToggleStyle:只支持 macOS

纯图像的 Toggle

struct ContentView: View {    @State private var isMuted = false    var body: some View {        Toggle(isOn: $isMuted) {            Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.fill")                .font(.system(size: 50))        }        .tint(.red)        .toggleStyle(.button)        .clipShape(Circle())    }}

自定义 ToggleStyle

做一个自定义的切换按钮 OfflineModeToggleStyle。这个切换按钮允许用户控制是否开启离线模式。代码如下:

struct ContentView: View {    @State private var isOfflineMode = false    var body: some View {        Toggle(isOn: $isOfflineMode) {            Text("Offline Mode")        }        .toggleStyle(OfflineModeToggleStyle(systemImage: isOfflineMode ? "wifi.slash" : "wifi", activeColor: .blue))    }}struct OfflineModeToggleStyle: ToggleStyle {    var systemImage: String    var activeColor: Color    func makeBody(configuration: Configuration) -> some View {        HStack {            configuration.label            Spacer()            RoundedRectangle(cornerRadius: 16)                .fill(configuration.isOn ? activeColor : Color(.systemGray5))                .overlay {                    Circle()                        .fill(.white)                        .padding(2)                        .overlay {                            Image(systemName: systemImage)                                .foregroundColor(configuration.isOn ? activeColor : Color(.systemGray5))                        }                        .offset(x: configuration.isOn ? 8 : -8)                }                .frame(width: 50, height: 32)                .onTapGesture {                    withAnimation(.spring()) {                        configuration.isOn.toggle()                    }                }        }    }}

以上代码中,我们定义了一个 OfflineModeToggleStyle,它接受两个参数:systemImage 和 activeColor。systemImage 是一个字符串,表示图像的系统名称。activeColor 是一个颜色,表示激活状态的颜色。

动画化的 Toggle

以下是一个自定义的切换按钮 MuteToggleStyle。这个切换按钮允许用户控制是否开启静音模式。

struct ContentView: View {    @State private var isMuted = false    var body: some View {        VStack {            Toggle(isOn: $isMuted) {                Text("Mute Mode")                    .foregroundColor(isMuted ? .white : .black)            }            .toggleStyle(MuteToggleStyle())            .padding()        }        .frame(maxWidth: .infinity, maxHeight: .infinity)    }}struct MuteToggleStyle: ToggleStyle {    var onImage = "speaker.slash.fill"    var offImage = "speaker.2.fill"    func makeBody(configuration: Configuration) -> some View {        HStack {            configuration.label            Spacer()            RoundedRectangle(cornerRadius: 30)                .fill(configuration.isOn ? Color(.systemGray6) : .yellow)                .overlay {                    Image(systemName: configuration.isOn ? onImage : offImage)                        .resizable()                        .scaledToFit()                        .clipShape(Circle())                        .padding(5)                        .rotationEffect(.degrees(configuration.isOn ? 0 : 180))                        .offset(x: configuration.isOn ? 10 : -10)                }                .frame(width: 50, height: 32)                .onTapGesture {                    withAnimation(.easeInOut(duration: 0.2)) {                        configuration.isOn.toggle()                    }                }        }    }}extension ToggleStyle where Self == MuteToggleStyle {    static var mute: MuteToggleStyle { .init() }}

以上代码中,我们定义了一个 MuteToggleStyle,它接受两个参数:onImage 和 offImage。onImage 是一个字符串,表示激活状态的图像的系统名称。offImage 是一个字符串,表示非激活状态的图像的系统名称。

两个标签的 Toggle

以下是一个自定义的切换按钮,它有两个标签。这个切换按钮允许用户控制是否开启静音模式。

Toggle(isOn: $mute) {  Text("静音")  Text("这将关闭所有声音")}

Slider

简单示例

struct PlaySliderView: View {    @State var count: Double = 0    var body: some View {        Slider(value: $count, in: 0...100)            .padding()        Text("\(Int(count))")    }}

以下代码演示了如何创建一个自定义的 Slider 控件,用于调整亮度。

struct ContentView: View {    @State private var brightness: Double = 50    @State private var isEditing: Bool = false    var body: some View {        VStack {            Text("Brightness Control")                .font(.title)                .padding()            BrightnessSlider(value: $brightness, range: 0...100, step: 5, isEditing: $isEditing)            Text("Brightness: \(Int(brightness)), is changing: \(isEditing)")                .font(.footnote)                .padding()        }    }}struct BrightnessSlider: View {    @Binding var value: Double    var range: ClosedRange<Double>    var step: Double    @Binding var isEditing: Bool    var body: some View {        Slider(value: $value, in: range, step: step) {            Label("亮度", systemImage: "light.max")        } minimumValueLabel: {            Text("\(Int(range.lowerBound))")        } maximumValueLabel: {            Text("\(Int(range.upperBound))")        } onEditingChanged: {            print($0)        }    }}

以上代码中,我们创建了一个 BrightnessSlider 控件,它是一个自定义的 Slider 控件,用于调整亮度。BrightnessSlider 接受一个 value 绑定,一个 range 范围,一个 step 步长,以及一个 isEditing 绑定。在 BrightnessSlider 中,我们使用 Slider 控件来显示亮度调整器。我们还使用 Label 来显示亮度调整器的标题,并使用 minimumValueLabelmaximumValueLabel 来显示亮度调整器的最小值和最大值。最后,我们使用 onEditingChanged 修饰符来监听亮度调整器的编辑状态。

Stepper

Stepper 控件允许用户通过点击按钮来增加或减少数值。

struct ContentView: View {    @State private var count: Int = 2    var body: some View {        Stepper(value: $count, in: 2...20, step: 2) {            Text("共\(count)")        } onEditingChanged: { b in            print(b)        } // end Stepper    }}

ContentView 中,我们定义了一个状态变量 count,并将其初始化为 2。然后,我们创建了一个 Stepper 视图,并将其绑定到 count 状态变量。

Stepper 视图的值范围为 2 到 20,步进值为 2,这意味着每次点击按钮,count 的值会增加或减少 2。我们还添加了一个标签,显示当前的 count 值。

我们还添加了 onEditingChanged 回调,当 Stepper 的值改变时,会打印出一个布尔值,表示 Stepper 是否正在被编辑。

小册子之详说 Navigation、ViewThatFits、Layout 协议等布局 SwiftUI 组件

作者 戴铭
2024年5月18日 09:48

以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

Navigation导航

Navigation

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

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

参考:

NavigationStack 的示例:

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 两栏的例子:

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 三栏的例子:

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,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

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

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

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 的自定义,示例如下:

.toolbar(id: "toolbar") {    ToolbarItem(id: "new", placement: .secondaryAction) {        Button(action: {}) {            Label("New Invitation", systemImage: "envelope")        }    }}.toolbarRole(.editor)

以下是废弃的 NavigationView 的用法。

对应代码如下:

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 会出现在弹出的虚拟键盘上。

NavigationStack

使用示例

假设我们有一个 TVShow 结构体和一个 Book 结构体,它们分别包含电视剧和书籍的名字。当用户点击一个电视剧或书籍的名字时,他们会被导航到相应的详细信息页面。

以下是一个例子:

struct TVShow: Hashable {    let name: String}struct Book: Hashable {    let name: String}struct ContentView: View {    @State var tvShows = [TVShow(name: "Game of Thrones"), TVShow(name: "Breaking Bad")]    @State var books = [Book(name: "1984"), Book(name: "To Kill a Mockingbird")]    var body: some View {        NavigationStack {            List {                Section(header: Text("Best TV Shows"))  {                    ForEach(tvShows, id: \.name) { show in                        NavigationLink(value: show, label: {                            Text(show.name)                        })                    }                }                Section(header: Text("Books"))  {                    ForEach(books, id: \.name) { book in                        NavigationLink(value: book, label: {                            Text(book.name)                        })                    }                }            }            .navigationDestination(for: TVShow.self) { show in                TVShowView(show: show)            }            .navigationDestination(for: Book.self) { book in                BookView(book: book)            }            .navigationTitle(Text("Media"))        }    }}struct TVShowView: View {    let show: TVShow    var body: some View {        Text("Details for \(show.name)")    }}struct BookView: View {    let book: Book    var body: some View {        Text("Details for \(book.name)")    }}

全局路由

先写个路由的枚举

enum Route: Hashable {    case all    case add(Book)    case detail(Book)}struct Book {    let name: String    let des: String}

在 App 中设置好全局路由

@mainstruct LearnNavApp: App {    var body: some Scene {        WindowGroup {            NavigationStack {                ContentView()                    .navigationDestination(for: Route.self) { route in                        switch route {                            case .all:                                Text("显示所有图书")                            case .create(let book):                                Text("添加书 \(book.name)")                            case .detail(let book):                                Text("详细 \(book.des)")                        }                    }            }                        }    }}

所有视图都可调用,调用方式如下:

NavigationLink("查看书籍详细说明", value: Route.detail(Book(name: "1984", des: "1984 Detail")))

NavigationPath

NavigationPath 是一个用于管理 SwiftUI 中导航路径的工具。它可以帮助你在 SwiftUI 中实现更复杂的导航逻辑。

在 SwiftUI 中,我们通常使用 NavigationLink 来实现导航。然而,NavigationLink 只能实现简单的前进导航,如果你需要实现更复杂的导航逻辑,例如后退、跳转到任意页面等,你就需要使用 NavigationPath

NavigationPath 的工作原理是,它维护了一个路径数组,每个元素代表一个页面。当你需要导航到一个新的页面时,你只需要将这个页面添加到路径数组中。当你需要后退时,你只需要从路径数组中移除最后一个元素。这样,你就可以实现任意复杂的导航逻辑。

看个例子

假设我们有一个 TVShow 结构体,它包含电视剧的名字。当用户点击一个电视剧的名字时,他们会被导航到这个电视剧的详细信息页面。

struct ContentView: View {    @State private var path = NavigationPath()    @State private var tvShows = [ TVShow(name: "Game of Thrones"), TVShow(name: "Breaking Bad"), TVShow(name: "The Witcher") ]    var body: some View {        NavigationStack(path: $path) {            List {                Text("Select a TV show to get started.")                    .font(.subheadline.weight(.semibold))                ForEach(tvShows, id: \.name) { show in                    NavigationLink(value: show, label: {                        Text(show.name)                            .font(.subheadline.weight(.medium))                    })                }                Button(action: showFriends) {                    Text("This isn't navigation")                }            }            .navigationDestination(for: TVShow.self, destination: { show in                TVShowView(onSelectReset: { popToRoot() }, show: show, otherShows: tvShows)            })            .navigationTitle(Text("Select your show"))        }        .onChange(of: path.count) { oldValue, newValue in            print(newValue)        }    }    func showFriends() {        let show = TVShow(name: "Friends")        path.append(show)    }        func popToRoot() {        path.removeLast(path.count)    }}struct TVShowView: View {    var onSelectReset: () -> Void    var show: TVShow    var otherShows: [TVShow]    var body: some View {        VStack {            Text(show.name)                .font(.title)                .padding(.bottom)            Button(action: onSelectReset) {                Text("Reset Selection")            }            List(otherShows, id: \.name) { otherShow in                Text(otherShow.name)            }        }        .padding()    }}struct TVShow: Hashable {    let name: String    let premiereDate: Date = Date.now    var description: String = "detail"}

代码中,NavigationPath 被用作一个 @State 变量,这意味着它会自动响应变化,并更新视图。当你修改 NavigationPath 中的路径数组时,视图会自动更新,显示新的页面。

NavigationSplitView

以下是一个基于 NavigationSplitView 的三栏视图的示例。这个示例包含了一个主视图,一个次级视图和一个详细视图。

struct ContentView: View {    @State var books: [Book] = [        Book(title: "Book 1", author: "Author 1", description: "Description 1"),        Book(title: "Book 2", author: "Author 2", description: "Description 2"),        Book(title: "Book 3", author: "Author 3", description: "Description 3")    ]    @State var selectedBook: Book?    @State var splitVisibility: NavigationSplitViewVisibility = .all    var body: some View {        NavigationSplitView(columnVisibility: $splitVisibility, sidebar: {            List(books) { book in                Button(action: { selectedBook = book }) {                    Text(book.title)                }            }        }, content: {            if let book = selectedBook {                Text("Author: \(book.author)")            } else {                Text("Select a Book")            }        }, detail: {            if let book = selectedBook {                Text(book.description)            } else {                Text("Book details will appear here")            }        })        .onChange(of: selectedBook) { oldValue, newValue in            //...        }    }}struct Book: Identifiable, Equatable {    var id = UUID()    var title: String    var author: String    var description: String}

示例中,sidebar 是主视图,它显示了一个图书列表。当用户选择一个图书时,content 视图会显示图书的作者,detail 视图会显示图书的详细信息。NavigationSplitView 会根据 splitVisibility 的值来决定显示哪些视图。

自定义导航栏

交互样式

使用 navigationSplitViewStyle(_:) 修饰符

改变标签栏背景色

.toolbarBackground(.yellow.gradient, for: .automatic).toolbarBackground(.visible, for: .automatic)

列宽

navigationSplitViewColumnWidth(_:) 修饰符用于指定列宽。

设置列的最小、最大和理想大小,使用 navigationSplitViewColumnWidth(min:ideal:max:)。可以修饰于不同的列上。

自定返回按钮

先通过修饰符隐藏系统返回按钮 .navigationBarBackButtonHidden(true)。然后通过 ToolbarItem(placement: .navigationBarLeading) 来添加自定义的返回按钮。

struct BookDetailView: View {    var book: Book    @Binding var isDetailShown: Bool    var body: some View {        VStack {            Text(book.title).font(.largeTitle)            Text("Author: \(book.author)").font(.title)            Text(book.description).padding()        }        .navigationBarBackButtonHidden(true)        .navigationTitle(book.title)        .toolbar {            ToolbarItem(placement: .navigationBarLeading) {                Button {                    isDetailShown = false                } label: {                    HStack {                        Image(systemName: "chevron.backward")                        Text("Back to Books")                    }                }            }        }    }}

Inspectors右侧多出一栏

Inspector 的示例

struct Book: Identifiable {    var id = UUID()    var title: String    var author: String    var description: String}struct ContentView: View {    @State var books: [Book] = [        Book(title: "Book 1", author: "Author 1", description: "Description 1"),        Book(title: "Book 2", author: "Author 2", description: "Description 2"),        Book(title: "Book 3", author: "Author 3", description: "Description 3")    ]    @State var selectedBook: Book?    @State var showInspector: Bool = false    @State var splitVisibility: NavigationSplitViewVisibility = .all        var body: some View {        NavigationSplitView(columnVisibility: $splitVisibility, sidebar: {            List(books) { book in                Button(action: { selectedBook = book }) {                    Text(book.title)                }            }        }, content: {            if let book = selectedBook {                Text("Author: \(book.author)")            } else {                Text("Select a Book")            }        }, detail: {            Button("Inspector 开关") {                showInspector.toggle()            }            if let book = selectedBook {                Text(book.description)            } else {                Text("Book details will appear here")            }        })        .inspector(isPresented: $showInspector) {            if let book = selectedBook {                InspectorView(book: book)            }        }    }}struct InspectorView: View {    var book: Book    var body: some View {        VStack {            Text(book.title).font(.largeTitle)            Text("Author: \(book.author)").font(.title)            Text(book.description).padding()        }        .inspectorColumnWidth(200)        .presentationDetents([.medium, .large])    }}

它显示了一个图书列表。当用户选择一个图书时,会显示 InspectorView,这是辅助视图,它显示了图书的详细信息。inspector 方法用于显示和隐藏 InspectorView,inspectorColumnWidth 方法用于设置辅助视图的宽度,presentationDetents 方法用于设置辅助视图的大小。

导航状态保存和还原

通过 SceneStorage 保存导航路径,程序终止时会持久化存储路径,重启时恢复路径。

protocol URLProcessor<RouteType> {    associatedtype RouteType: Hashable    func process(_ url: URL, mutating: inout [RouteType])}protocol UserActivityProcessor<RouteType> {    associatedtype RouteType: Hashable    func process(_ activity: NSUserActivity, mutating: inout [RouteType])}@Observable@MainActor final class RouteManager<RouteType: Hashable> {    var navigationPath: [RouteType] = []    private let jsonDecoder = JSONDecoder()    private let jsonEncoder = JSONEncoder()    private let urlProcessor: any URLProcessor<RouteType>    private let activityProcessor: any UserActivityProcessor<RouteType>    init(        urlProcessor: some URLProcessor<RouteType>,        activityProcessor: some UserActivityProcessor<RouteType>    ) {        self.urlProcessor = urlProcessor        self.activityProcessor = activityProcessor    }    func process(_ activity: NSUserActivity) {        activityProcessor.process(activity, mutating: &navigationPath)    }    func process(_ url: URL) {        urlProcessor.process(url, mutating: &navigationPath)    }}extension RouteManager where RouteType: Codable {    func toData() -> Data? {        try? jsonEncoder.encode(navigationPath)    }        func restore(from data: Data) {        do {            navigationPath = try jsonDecoder.decode([RouteType].self, from: data)        } catch {            navigationPath = []        }    }}

这段代码定义了一个名为 RouteManager 的类,它用于处理和管理导航路径。这个类使用了 SwiftUI 的 @MainActor@Observable 属性包装器,以确保它的操作在主线程上执行,并且当 navigationPath 发生变化时,会自动更新相关的 UI。

RouteManager 类有两个协议类型的属性:urlProcessoractivityProcessor。这两个属性分别用于处理 URL 和用户活动(NSUserActivity)。这两个处理器的任务是根据给定的 URL 或用户活动,更新 navigationPath

RouteManager 类还有两个方法:process(_ activity: NSUserActivity)process(_ url: URL)。这两个方法分别用于处理用户活动和 URL。处理的方式是调用相应的处理器的 process 方法。

此外,RouteManager 类还有一个扩展,这个扩展只适用于 RouteTypeCodable 的情况。这个扩展提供了两个方法:toData()restore(from data: Data)toData() 方法将 navigationPath 转换为 Datarestore(from data: Data) 方法则将 Data 转换回 navigationPath。这两个方法可以用于将 navigationPath 保存到磁盘,并在需要时从磁盘恢复。

struct MainView: View {    @SceneStorage("navigationState") private var navigationData: Data?    @State private var dataStore = DataStore()    @State private var routeManager = RouteManager<Route>(        urlProcessor: SomeURLProcessor(),        activityProcessor: SomeUserActivityProcessor()    )        var body: some View {        NavigationStack(path: $routeManager.navigationPath) {            SomeView(categories: dataStore.categories)                .task { await dataStore.fetch() }                .navigationDestination(for: Route.self) { route in                    // ...                }                .onOpenURL { routeManager.process($0) }        }        .task {            if let navigationData = navigationData {                routeManager.restore(from: navigationData)            }                        for await _ in routeManager.$navigationPath.values {                navigationData = routeManager.toData()            }        }    }}

@SceneStorage("navigationState") 是用来保存和恢复导航状态的。当应用程序被挂起时,它会自动将 navigationData 保存到磁盘,当应用程序重新启动时,它会自动从磁盘恢复 navigationData

@State private var dataStore = DataStore()@State private var routeManager = RouteManager<Route>(...) 是用来存储数据和路由管理器的。DataStore 是用来获取和存储数据的,RouteManager 是用来处理和管理导航路径的。

body 属性定义了视图的内容。它首先创建了一个 NavigationStack,然后在这个 NavigationStack 中创建了一个 SomeViewSomeView 使用了 dataStore.categories 作为它的参数,并且在被创建后立即执行 dataStore.fetch() 来获取数据。

body 属性还定义了一个任务,这个任务在视图被创建后立即执行。这个任务首先检查 navigationData 是否存在,如果存在,就使用 routeManager.restore(from: navigationData) 来恢复导航路径。然后,它监听 routeManager.$navigationPath.values,每当 navigationPath 发生变化时,就使用 routeManager.toData() 来将 navigationPath 转换为 Data,并将结果保存到 navigationData 中。

布局基础

布局-基础

基本元素样式

通过 .font(.title) 设置字体大小。

.stroke(Color.blue) 设置描边。举个例子:

struct ContentView: View {    var body: some View {        Rectangle()            .stroke(Color.orange, style: StrokeStyle(lineWidth: 10, lineCap: .round, dash: [30]))            .padding(30)    }}

StrokeStyle(lineWidth: 10, lineCap: .round, dash: [30]) 定义了描边的样式,其中 lineWidth: 10 表示线宽为 10,lineCap: .round 表示线帽样式为圆形,dash: [30] 表示虚线模式,数组中的数字表示虚线和间隙的交替长度。

frame

.frame(width: 200, height:100, alignment: .topLeading)

  • width: 200 表示视图的宽度为 200 点。
  • height: 100 表示视图的高度为 100 点。
  • alignment: .topLeading 表示视图的内容应该在视图的左上角对齐。.topLeading 是 SwiftUI 中的一个对齐方式,表示左上角对齐。

Stack

多个视图通过 Stack 视图进行对齐排列。这些 Stack 视图主要是:

  • ZStack:Z轴排列
  • VStack:垂直排列
  • HStack:横向排列

间隔

视图之间的间隔可以用 Space(),它可以在各种布局视图中使用。

布局-留白

Space

Spacer 是一个灵活的空间,它会尽可能地占用多的空间,从而将其周围的视图推向堆栈的两边。因此,第一个 Text 视图会被推到左边,第二个 Text 视图会被推到中间,第三个 Text 视图会被推到右边。

struct ContentView: View {    var body: some View {        HStack {            Text("左边")            Spacer()            Text("中间")            Spacer()            Text("右边")        }    }}

下面这个例子是用 Space() 让三个视图都居右。

struct ContentView: View {    var body: some View {        HStack {            Spacer()            Text("视图1")            Text("视图2")            Text("视图3")        }    }}

布局-对齐

frame 对齐

.frame(width: 100, height: 50, alignment: .topLeading)

可设置对齐的视图

在 SwiftUI 中,许多视图都接受 alignment 参数,用于控制其子视图的对齐方式。以下是一些常见的接受 alignment 参数的视图:

  • HStack(alignment: .bottom):水平堆栈视图,可以控制其子视图在垂直方向上的对齐方式。
  • VStack(alignment: .trailing):垂直堆栈视图,可以控制其子视图在水平方向上的对齐方式。
  • ZStack(alignment: .center):深度堆栈视图,可以控制其子视图在水平和垂直方向上的对齐方式。
  • GridRow(alignment: .firstTextBaseline):用于定义网格的行或列的大小,可以设置行或列中的内容的对齐方式。。

基线对齐

你可以使用 alignment 参数来设置视图的对齐方式,包括基线对齐。以下是一个例子:

HStack(alignment: .firstTextBaseline) {    Text("Hello")    Text("World").font(.largeTitle)}

在这个例子中,HStack 是一个水平堆栈视图,它会将其子视图水平排列。alignment: .firstTextBaseline 是一个参数,用于设置堆栈中的内容的对齐方式。.firstTextBaseline 表示所有文本视图都应该根据它们的第一行的基线对齐。基线是文本字符的底部线。

因此,这个 HStack 中的两个 Text 视图会根据它们的第一行的基线对齐,即使它们的字体大小不同。

布局-居中

在 SwiftUI 中,有多种方法可以使视图居中:

Spacer

使用 SpacerSpacer 是一个灵活的空间,它会尽可能地占用多的空间,从而将其周围的视图推向堆栈的两边。如果在一个视图的两边都放置一个 Spacer,那么这个视图就会被推到中间。

HStack {    Spacer()    Text("居中")    Spacer()}

alignment

使用 alignment 参数:许多 SwiftUI 视图都接受 alignment 参数,用于控制其子视图的对齐方式。例如,VStackHStack 都接受 alignment 参数。

VStack(alignment: .center) {    Text("居中")}

frame

使用 frame 方法:frame 方法可以设置视图的尺寸和对齐方式。如果你想让一个视图在其父视图中居中,你可以使用 frame(maxWidth: .infinity, maxHeight: .infinity) 来使视图尽可能地占用多的空间,然后使用 alignment: .center 来使视图在这个空间中居中。

Text("居中")    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)

布局-offset偏移

struct OffsetDemo: View {    @State var offset: CGFloat = 0    var body: some View {        VStack {            Text("Hello, World!")                .font(.largeTitle)                .offset(y: offset)            Slider(value: $offset, in: -100...100)                .padding()        }    }}

我们创建了一个 Text 视图和一个 SliderText 视图使用了 .offset(y: offset) 修饰符,这意味着它的 y 偏移量会根据 offset 的值改变。Slider 则用于改变 offset 的值。当你移动滑块时,Text 视图的位置也会相应地上下移动。

Safe Area

ignoresSafeArea 忽略安全区域

使用 .ignoresSafeArea() 可以忽略安全区域。默认是所有方向都忽略。

如果只忽略部分方向,可以按照下面方法做:

// 默认会同时包含 .keyboard 和 .container。.ignoresSafeArea(edges: .top).ignoresSafeArea(edges: .vertical).ignoresSafeArea(edges: [.leading, .trailing])// 可以对安全区域分别指定.ignoresSafeArea(.keyboard, edges: .top).ignoresSafeArea(.container, edges: [.leading, .trailing])

safeAreaInset

safeAreaInset 是 SwiftUI 中的一个属性,它允许你将视图放置在安全区域内。”安全区域”是指设备屏幕上的一块区域,这块区域不会被系统界面(如状态栏、导航栏、工具栏、Tab栏等)遮挡。

例如,你可以使用 safeAreaInset 将一个视图放置在屏幕底部的安全区域内,代码如下:

VStack {    Text("Hello, World!")}.safeAreaInset(edge: .bottom, spacing: 10) {    Button("Press me") {        print("Button pressed")    }}

在这个例子中,”Press me” 按钮会被放置在屏幕底部的安全区域内,而且距离底部有 10 个点的间距。

下面是更完整点的例子:

struct ContentView: View {    @State var tasks: [TaskModel] = (0...10).map { TaskModel(name: "Task \($0)") }    @State var taskName = ""    @State var isFocused: Bool = false    var body: some View {        NavigationView {            VStack {                List {                    ForEach(tasks) { task in                        Text(task.name)                    }                }                .listStyle(PlainListStyle())                .safeAreaInset(edge: .bottom) {                    HStack {                        TextField("Add task", text: $taskName, onCommit: {                            addTask()                        })                        .textFieldStyle(RoundedBorderTextFieldStyle())                        .padding(.leading, 10)                                                Button(action: {                            addTask()                        }) {                            Image(systemName: "plus")                        }                        .padding(.trailing, 10)                    }                    .padding(.bottom, isFocused ? 0 : 10)                    .background(Color.white)                }                .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in                    withAnimation {                        isFocused = true                    }                }                .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in                    withAnimation {                        isFocused = false                    }                }            }            .navigationBarTitle("Task List Demo")        }    }    func addTask() {        if !taskName.isEmpty {            withAnimation {                tasks.append(TaskModel(name: taskName))            }            taskName = ""        }    }}struct TaskModel: Identifiable {    let id = UUID()    let name: String}

用户可以在底部的输入框中输入任务名称,然后点击 “+” 按钮将任务添加到任务清单中。添加的任务会显示在屏幕的上方。当键盘出现或消失时,底部的输入框会相应地移动,以确保不会被键盘遮挡。

布局原理

SwiftUI 的布局系统是一个两阶段的协商过程,涉及到父视图和子视图之间的交互。

建议阶段:在这个阶段,父视图会向子视图提出一个建议尺寸。这个建议尺寸是父视图希望子视图的大小。例如,如果父视图是一个 VStack,那么它可能会向子视图提出一个具有明确高度、宽度未指定的建议尺寸。

需求阶段:在这个阶段,子视图会根据父视图的建议尺寸来确定自己的需求尺寸。子视图可以选择接受父视图的建议尺寸,也可以选择返回一个不同的尺寸。例如,一个 Text 视图可能会返回一个刚好能够容纳其文本的尺寸。

在这个过程中,父视图和子视图都有可能影响最终的布局结果。父视图可以通过调整建议尺寸来影响子视图的大小,而子视图可以通过返回不同的需求尺寸来影响自己的大小。

在一些复杂的布局场景中,可能需要进行多轮的协商才能得到最终的布局结果。例如,如果一个视图使用了 GeometryReader 来获取其在父视图中的位置和尺寸,那么 GeometryReader 可能会在布局稳定之前,多次向子视图发送新的几何信息。

总的来说 SwiftUI 它允许父视图和子视图之间进行协商,以达到最佳的布局效果。

布局进阶

AnyLayout

使用 AnyLayout 包装布局组件,可以在布局之间进行切换,同时保持动画效果。

struct WeatherLayout: View {    @State private var changeLayout = false    var body: some View {        let layout = changeLayout ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())        layout {            WeatherView(icon: "sun.max.fill", temperature: 25, color: .yellow)            WeatherView(icon: "cloud.rain.fill", temperature: 18, color: .blue)            WeatherView(icon: "snow", temperature: -5, color: .white)        }        .animation(.default, value: changeLayout)        .onTapGesture {            changeLayout.toggle()        }    }}struct WeatherView: View {    let icon: String    let temperature: Int    let color: Color    var body: some View {        VStack {            Image(systemName: icon)                .font(.system(size: 80))                .foregroundColor(color)            Text("\(temperature)°")                .font(.system(size: 50))                .foregroundColor(color)        }        .frame(width: 120, height: 120)    }}

代码中,我们创建了一个 WeatherView 视图,它包含一个天气图标和一个温度标签。然后,我们在 WeatherLayout 视图中使用 AnyLayout 来动态改变布局。用户可以通过点击视图来在水平布局和垂直布局之间切换。

ViewThatFits

ViewThatFits 是一个自动选择最适合当前屏幕大小的子视图进行显示的视图。它会根据可用空间的大小来决定如何布局和显示子视图。

ViewThatFits 是一个在 SwiftUI 中用于选择最适合显示的视图的组件。它的工作原理如下:

  • 首先,ViewThatFits 会测量在特定轴(水平或垂直)或两个轴(水平和垂直)上的可用空间。这是通过 SwiftUI 的布局系统来完成的,该系统提供了当前视图的大小和位置信息。

  • 接着,ViewThatFits 会测量第一个视图的大小。这是通过调用视图的 measure(in:) 方法来完成的,该方法返回一个包含视图理想大小的 CGSize 值。

  • 如果第一个视图的大小适合可用空间,ViewThatFits 就会选择并放置这个视图。放置视图是通过调用视图的 layout(in:) 方法来完成的,该方法接受一个 CGRect 值,该值定义了视图在其父视图中的位置和大小。

  • 如果第一个视图的大小不适合可用空间,ViewThatFits 会继续测量第二个视图的大小。如果第二个视图的大小适合可用空间,ViewThatFits 就会选择并放置这个视图。

  • 如果所有视图的大小都不适合可用空间,ViewThatFits 会选择并放置 ViewBuilder 闭包中的最后一个视图。ViewBuilder 是一个特殊的闭包,它可以根据其内容动态创建视图。

ViewThatFits(in: .horizontal) {    Text("晴天,气温25°") // 宽度在200到300之间        .font(.title)        .foregroundColor(.yellow)    Text("晴天,25°") // 宽度在150到200之间        .font(.title)        .foregroundColor(.gray)    Text("晴25") // 宽度在100到150之间        .font(.title)        .foregroundColor(.white)}.border(Color.green) // ViewThatFits所需的大小.frame(width:200).border(Color.orange) // 父视图提议的大小

在不同的宽度下,ViewThatFits 会选择不同的视图进行显示。在上面的示例中,当父视图的宽度在100到150之间时,ViewThatFits 会选择显示 “晴25” 这个视图。

通过 ViewThatFits 来确定内容是否可滚动。

struct ContentView: View {    @State var step: CGFloat = 3    var count: Int {        Int(step)    }    var body: some View {        VStack(alignment:.leading) {            Text("数量: \(count)")                .font(.title)                .foregroundColor(.blue)            Stepper("数量", value: $step, in: 3...20)            ViewThatFits {                content                ScrollView(.horizontal,showsIndicators: true) {                    content                }            }        }        .padding()    }    var content: some View {        HStack {            ForEach(0 ..< count, id: \.self) { i in                Rectangle()                    .fill(Color.green)                    .frame(width: 30, height: 30)                    .overlay(                        Text("\(i)")                            .font(.headline)                            .foregroundColor(.white)                    )            }        }    }}

Layout协议

通过实现 Layout 协议,创建一个水平堆栈布局,其中所有子视图的宽度都相等。

struct OptimizedEqualWidthHStack: Layout {  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {    if subviews.isEmpty { return .zero }    let maxSubviewSize = calculateMaxSize(subviews: subviews)    let totalSpacing = calculateSpacing(subviews: subviews).reduce(0, +)    return CGSize(width: maxSubviewSize.width * CGFloat(subviews.count) + totalSpacing, height: maxSubviewSize.height)  }  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {    if subviews.isEmpty { return }    let maxSubviewSize = calculateMaxSize(subviews: subviews)    let spacings = calculateSpacing(subviews: subviews)    let placementProposal = ProposedViewSize(width: maxSubviewSize.width, height: maxSubviewSize.height)    var nextX = bounds.minX + maxSubviewSize.width / 2    for index in subviews.indices {      subviews[index].place(at: CGPoint(x: nextX, y: bounds.midY), anchor: .center, proposal: placementProposal)      nextX += maxSubviewSize.width + spacings[index]    }  }  private func calculateMaxSize(subviews: Subviews) -> CGSize {    return subviews.map { $0.sizeThatFits(.unspecified) }.reduce(.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }  }  private func calculateSpacing(subviews: Subviews) -> [CGFloat] {    return subviews.indices.map { $0 < subviews.count - 1 ? subviews[$0].spacing.distance(to: subviews[$0 + 1].spacing, along: .horizontal) : 0 }  }}

上面这段代码中 sizeThatFits 方法计算并返回布局容器需要的大小,以便排列其子视图。它首先检查子视图数组是否为空,如果为空则返回 .zero。然后,它计算子视图的最大尺寸和总间距,最后返回一个 CGSize 对象,其宽度等于最大子视图宽度乘以子视图数量加上总间距,高度等于最大子视图高度。

placeSubviews 方法将子视图放置在布局容器中。它首先检查子视图数组是否为空,如果为空则返回。然后,它计算子视图的最大尺寸和间距,然后遍历子视图数组,将每个子视图放置在布局容器中的适当位置。

calculateMaxSize 和 calculateSpacing 是两个私有方法,用于计算子视图的最大尺寸和间距。

GeometryReader

在 SwiftUI 中,有多种方法可以获取和控制视图的尺寸:

  • frame(width:60, height:60):这个方法会为子视图提供一个建议的尺寸,这里是 60 x 60。
  • fixedSize():这个方法会为子视图提供一个未指定模式的建议尺寸,这意味着视图会尽可能地大以适应其内容。
  • frame(minWidth: 120, maxWidth: 360):这个方法会将子视图的需求尺寸控制在指定的范围中,这里是宽度在 120 到 360 之间。
  • frame(idealWidth: 120, idealHeight: 120):这个方法会返回一个需求尺寸,如果当前视图收到为未指定模式的建议尺寸,那么它会返回 120 x 120 的尺寸。
  • GeometryReaderGeometryReader 会将建议尺寸作为需求尺寸直接返回,这意味着它会充满全部可用区域。你可以使用 GeometryReader 来获取其内容的尺寸和位置。

GeometryReader 可以获取其内容的尺寸和位置。在这个例子中,我们使用 GeometryReader 来获取视图的尺寸,然后打印出来。这对于理解 SwiftUI 的布局系统和调试布局问题非常有用。

extension View {    func logSizeInfo(_ label: String = "") -> some View {        background(            GeometryReader { proxy in                Color.clear                    .onAppear(perform: {                        debugPrint("\(label) Size: \(proxy.size)")                    })            }        )    }}struct ContentView: View {    var body: some View {        VStack {            Text("大标题")                .font(.largeTitle)                .logSizeInfo("大标题视图") // 打印视图尺寸            Text("正文")                .logSizeInfo("正文视图")        }    }}

这段代码首先定义了一个 View 的扩展,添加了一个 logSizeInfo(_:) 方法。这个方法接受一个标签字符串作为参数,然后返回一个新的视图。这个新的视图在背景中使用 GeometryReader 来获取并打印视图的尺寸。

然后,我们创建了一个 VStack 视图,其中包含一个 Text 视图。我们为 Text 视图调用了 logSizeInfo(_:) 方法,以打印其尺寸。

如何利用 GeometryReader 来绘制一个圆形?

struct CircleView: View {    var body: some View {        GeometryReader { proxy in            Path { path in                let radius = min(proxy.size.width, proxy.size.height) / 2                let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)                path.addArc(center: center, radius: radius, startAngle: .zero, endAngle: .init(degrees: 360), clockwise: false)            }            .fill(Color.blue)        }    }}

在这个例子中,我们首先获取 GeometryReader 的尺寸,然后计算出半径和中心点的位置。然后,我们使用 PathaddArc(center:radius:startAngle:endAngle:clockwise:) 方法来添加一个圆形路径。最后,我们使用 fill(_:) 方法来填充路径,颜色为蓝色。

关于 GeometryReader 性能问题

GeometryReader 是 SwiftUI 中的一个工具,它可以帮助我们获取视图的大小和位置。但是,它在获取这些信息时,需要等待视图被评估、布局和渲染完成。这就好比你在装修房子时,需要等待墙壁砌好、油漆干燥后,才能测量墙壁的尺寸。这个过程可能需要等待一段时间,而且可能需要多次重复,因为每次墙壁的尺寸改变,都需要重新测量。

这就是 GeometryReader 可能会影响性能的原因。它需要等待视图完成一轮的评估、布局和渲染,然后才能获取到尺寸数据,然后可能需要根据这些数据重新调整布局,这就需要再次进行评估、布局和渲染。这个过程可能需要重复多次,导致视图被多次重新评估和布局。

但是,随着 SwiftUI 的更新,这个问题已经有所改善。现在,我们可以创建自定义的布局容器,这些容器可以在布局阶段就获取到父视图的建议尺寸和所有子视图的需求尺寸,这样就可以避免反复传递尺寸数据,减少了视图的反复更新。

alignmentGuide

alignmentGuide是SwiftUI中的一个修饰符,它允许你自定义视图的对齐方式。你可以使用它来调整视图在其父视图或同级视图中的位置。

当你在一个视图上应用alignmentGuide修饰符时,你需要提供一个对齐标识符和一个闭包。对齐标识符定义了你想要调整的对齐方式(例如,.leading.trailing.center等)。闭包接收一个参数,这个参数包含了视图的尺寸,你可以使用这个参数来计算对齐指南的偏移量。

举个例子:

struct ContentView: View {    var body: some View {        HStack(alignment: .top) {            CircleView()                .alignmentGuide(.top) { vd in                    vd[.top] + 50                }            CircleView()        }        .padding()        .border(Color.gray)    }    struct CircleView: View {        var body: some View {            Circle()                .fill(Color.mint)                .frame(width: 50, height: 50)        }    }}

在HStack中,第一个CircleView使用了.alignmentGuide修饰符,这使得它在顶部对齐时向下偏移了50个单位。

布局进阶-参考资料

WWDC

23

22

20

官方接口文档

Stack

Stack View 有 VStack、HStack 和 ZStack

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("一段文字")            )    }}

GroupBox

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))    }}

叠加 GroupBox 颜色会有区分

GroupBox {    Text("电视剧名称: 人民的名义")    GroupBox {        Text("播放时间: 每周一至周五")    }}

最后,您还可以 GroupBox 使用 Label .将 Label 定位为 GroupBox 容器的标题。

GroupBox(label: Label("电视剧", systemImage: "tv")) {    HStack {        Text("播放时间: 每周一至周五")            .padding()        Spacer()    }}

GroupBox 也可以用于创建自定义的按钮组,如下所示:

struct TVShowCardView: View {    var body: some View {        VStack(alignment: .leading) {            // The header of the card            // - Photo, Show Name and Genre            HStack {                Circle()                    .frame(width: 40, height: 40)                    .foregroundColor(.gray)                VStack(alignment: .leading, spacing: 3) {                    Text("权力的游戏")                        .font(.headline)                        .fontWeight(.semibold)                    Text("奇幻剧")                        .font(.caption)                }                Spacer()            }                        Divider()                .foregroundColor(Color(uiColor: UIColor.systemGray6))                .padding([.top, .bottom], 8)                        // The description of the show in a few lines            Text("《权力的游戏》是一部改编自乔治·马丁的奇幻小说系列《冰与火之歌》的电视剧。")                .font(.body)                        // Buttons to watch, share or save the show            HStack {                actionGroupBox(imageName: "play.rectangle", actionName: "观看", action: { print("Watching...") })                actionGroupBox(imageName: "square.and.arrow.up", actionName: "分享", action: { print("Sharing...") })                actionGroupBox(imageName: "bookmark", actionName: "保存", action: { print("Saving...") })            }        }        .padding()        .background(Color.white)        .cornerRadius(10)    }        // A function to create a GroupBox for an action    func actionGroupBox(imageName: String, actionName: String, action: @escaping () -> Void) -> some View {        GroupBox {            VStack(spacing: 5) {                Image(systemName: imageName)                    .font(.headline)                Text(actionName)                    .font(.caption)            }            .foregroundColor(.red)            .frame(maxWidth: .infinity)        }.onTapGesture {            action()        }    }}struct ContentView: View {    var body: some View {        NavigationView {            ScrollView {                TVShowCardView()                                Spacer()            }            .padding()            .background(Color(UIColor.systemGray6))            .navigationTitle("电视剧")            .shadow(color: Color(.sRGB, red: 0, green: 0, blue: 0, opacity: 0.25), radius: 10, x: 0, y: 0)        }    }}

TabView

基本用法

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)) 修改器。

添加提醒

struct ContentView: View {    @State private var bookVm: BooksViewModel        init() {        bookVm = BooksViewModel()    }        var body: some View {        TabView {            BookListView(bookVm: bookVm)                .tabItem {                    Image(systemName: "list.bullet.rectangle.fill")                    Text("Book List")                }            SelectedBooksView(bookVm: bookVm)                .badge(bookVm.selectedBooks.count)                .tabItem {                    Image(systemName: "book.fill")                    Text("Selected Books")                }        }    }}

自定义样式

iOS 14 和 macOS 11 开始可以使用 tabViewStyle 修饰符更改 TabView 样式。比如有页面指示器的水平滚动图片。

显示页面指示器:

.tabViewStyle(.page(indexDisplayMode: .always))

.tabViewStyle(.page(indexDisplayMode: .never)) 修饰符隐藏页面指示器。

水平滚动图片:

struct ContentView: View {    let images = ["pencil", "scribble", "highlighter"]    var body: some View {        VStack {            TabView {                ForEach(images, id: \.self) { imageName in                    Image(systemName: imageName)                        .resizable()                        .scaledToFit()                }            }            .tabViewStyle(.page(indexDisplayMode: .always))            .frame(height: 100)        }    }}

分页视图

struct OnboardingView: View {    var body: some View {        TabView {            OnboardingPageView(imageName: "figure.mixed.cardio",                               title: "Welcome",                               description: "Welcome to MyApp! Get started by exploring our amazing features.")            OnboardingPageView(imageName: "figure.archery",                               title: "Discover",                               description: "Discover new content and stay up-to-date with the latest news and updates.")            OnboardingPageView(imageName: "figure.yoga",                               title: "Connect",                               description: "Connect with friends and share your experiences with the community.")        }        .tabViewStyle(.page(indexDisplayMode: .always))        .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))    }}

.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) 修饰符添加了背景。这将在点周围添加一个背景,使其在任何背景下都更容易看到。

背景颜色

iOS 16 和 macOS 13 开始可以更改 TabView 的背景颜色。

struct MainScreen: View {    var body: some View {        TabView {            NavigationView {                BookListView()                    .navigationTitle("图书列表")                    .toolbarBackground(.yellow, for: .navigationBar)                    .toolbarBackground(.visible, for: .navigationBar)            }            .tabItem {                Label("图书", systemImage: "book.closed")            }            UserPreferencesView()                .tabItem {                    Label("设置", systemImage: "gearshape")                }            .toolbarBackground(.indigo, for: .tabBar)            .toolbarBackground(.visible, for: .tabBar)            .toolbarColorScheme(.dark, for: .tabBar)        }    }}struct BookListView: View {    var body: some View {        Text("这里是图书列表")    }}struct UserPreferencesView: View {    var body: some View {        Text("这里是用户设置")    }}

ControlGroup

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    }}

Advanced layout control

session Compose custom layouts with SwiftUI

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

Grid 示例代码如下:

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 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

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 视图,为其所有子视图提供其最大子视图的宽度:

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 设置。

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 值:

func placeSubviews(  in bounds: CGRect,  proposal: ProposedViewSize,  subviews: Subviews,  cache: inout Void) {  let ranks = subviews.map { subview in    subview[Rank.self] // 👈🏻  }  // ...}

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

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 和图片也支持了样式布局变化,代码示例如下:

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))    }}

ContentUnavailableView

基本用法

struct ArchivedInfosView: View {    @Environment(\.modelContext) var modelContext    @Query var infos: [IOInfo]    ...        var body: some View {        List(selection: $selectInfo) {            ForEach(infos) { info in                ...            }        }        .overlay {            if infos.isEmpty {                ContentUnavailableView {                    Label("无归档", systemImage: "archivebox")                } description: {                    Text("点击下方按钮添加一个归档资料")                } actions: {                    Button("新增") {                        addInfo()                    }                }            }        }    }    ...}

搜索

struct ContentView: View {    @Bindable var vm: VModel    ...    var body: some View {        NavigationStack {            List(vm.items, id: \.self) { item in                ...            }            .navigationTitle("Products")            .overlay {                if vm.items.isEmpty {                    ContentUnavailableView.search(text: vm.query)                }            }            .searchable(text: $vm.query)        }        ...    }}

小册子之 List、Lazy 容器、ScrollView、Grid 和 Table 数据集合 SwiftUI 视图

作者 戴铭
2024年5月18日 09:06

以下内容已整理到小册子中,小册子代码在 Github 上,本文会随着系统更新和我更多的实践而新增和更新,你可以购买“戴铭的开发小册子”应用(98元),来跟踪查看本文内容新增和更新。

本文属于小册子系列中的一篇,已发布系列文章有:

ForEach

使用

在 SwiftUI 中,ForEach 是一个结构体,它可以创建一组视图,每个视图都有一个与数据集中的元素相对应的唯一标识符。这对于在列表或其他集合视图中显示数据非常有用。

以下视图集会用到 ForEach:

  • List
  • ScrollView
  • LazyVStack / LazyHStack
  • Picker
  • Grids (LazyVGrid / LazyHGrid)

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(bookmarks) { bookmark in                Text(bookmark.name)            }        }    }}

ForEach 遍历 bookmarks 数组,并为每个 BookmarkModel 对象创建一个 Text 视图。bookmark 参数是当前遍历的 BookmarkModel 对象。

BookmarkModel 必须遵循 Identifiable 协议,这样 SwiftUI 才能知道如何唯一地标识每个视图。在你的代码中,BookmarkModel 已经有一个 id 属性,所以你只需要让 BookmarkModel 遵循 Identifiable 协议即可:

final class BookmarkModel: Identifiable {    // your code here}

使用索引范围进行编号

你可以使用 ForEach 结构体的另一个版本,它接受一个范围作为其数据源。这个范围可以是一个索引范围,这样你就可以为每个项目编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(bookmarks.indices, id: \.self) { index in                Text("\(index + 1). \(bookmarks[index].name)")            }        }    }}

在这个例子中,ForEach 遍历 bookmarks 数组的索引,并为每个 BookmarkModel 对象创建一个 Text 视图。index 参数是当前遍历的索引。我们使用 \(index + 1). \(bookmarks[index].name) 来创建一个带有编号的文本视图。请注意,我们使用 index + 1 而不是 index,因为数组的索引是从 0 开始的,但我们通常希望编号是从 1 开始的。

使用 enumerated 编号

 enumerated() 

以下是一个例子:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(Array(bookmarks.enumerated()), id: \.element.id) { index, bookmark in                Text("\(index). \(bookmark.name)")            }        }    }}

我们使用 Array(bookmarks.enumerated()) 来创建一个元组数组,每个元组包含一个索引和一个 BookmarkModel 对象。然后,我们使用 ForEach 遍历这个元组数组,并为每个元组创建一个 Text 视图。index 参数是当前遍历的索引,bookmark 参数是当前遍历的 BookmarkModel 对象。

使用 zip 编号

zip(_:_:) 函数可以将两个序列合并为一个元组序列。你可以使用这个函数和 ForEach 结构体来为数组中的每个元素添加一个编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(Array(zip(1..., bookmarks)), id: \.1.id) { index, bookmark in                Text("\(index). \(bookmark.name)")            }        }    }}

写出扩展,方便调用

@dynamicMemberLookupstruct Numbered<Element> {    var number: Int    var element: Element        subscript<T>(dynamicMember keyPath: WritableKeyPath<Element, T>) -> T {        get { element[keyPath: keyPath] }        set { element[keyPath: keyPath] = newValue }    }}extension Sequence {    func numbered(startingAt start: Int = 1) -> [Numbered<Element>] {        zip(start..., self)            .map { Numbered(number: $0.0, element: $0.1) }    }}extension Numbered: Identifiable where Element: Identifiable {    var id: Element.ID { element.id }}

使用:

ForEach(bookmark.numbered()) { numberedBookmark in    Text("\(numberedBookmark.number). \(numberedBookmark.name)")}

Scroll视图

ScrollView

新增 modifier

ScrollView {    ForEach(0..<300) { i in        Text("\(i)")            .id(i)    }}.scrollDisabled(false) // 设置是否可滚动.scrollDismissesKeyboard(.interactively) // 关闭键盘.scrollIndicators(.visible) // 设置滚动指示器是否可见

ScrollViewReader

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

示例代码如下:

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: - 自定义 ScrollViewstruct 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) {}}

固定到滚动视图的顶部

LazyVStack 有个参数 pinnedViews 可以用于固定滚动视图的顶部。

ScrollView {    LazyVStack(alignment: .leading, spacing: 10, pinnedViews: .sectionHeaders) {        Section {            ForEach(books) { book in                BookRowView(book: book)            }        } header: {            HeaderView(title: "小说")        }        ....    }}

滚动到特定的位置

scrollPostion 版本

scrollPositon(id:) 比 ScrollViewReader 简单,但是只适用于 ScrollView。数据源遵循 Identifiable,不用显式使用 id 修饰符

struct ContentView: View {    @State private var id: Int?    var body: some View {        VStack {            Button("Scroll to Bookmark 3") {                withAnimation {                    id = 3                }            }            Button("Scroll to Bookmark 13") {                withAnimation {                    id = 13                }            }            ScrollView {                ScrollViewReader { scrollView in                    LazyVStack {                        ForEach(Bookmark.simpleData()) { bookmark in                            Text("\(bookmark.index)")                                .id(bookmark.index)                        }                                            }                }            }            .scrollPosition(id: $id)            .scrollTargetLayout()        }    }        struct Bookmark: Identifiable,Hashable {        let id = UUID()        let index: Int                static func simpleData() -> [Bookmark] {            var re = [Bookmark]()            for i in 0...100 {                re.append(Bookmark(index: i))            }            return re        }    }}

scrollTargetLayout 可以获得当前滚动位置。锚点不可配,默认是 center。

ScrollViewReader 版本

ScrollViewReader 这个版本可以适用于 List,也可以配置锚点

你可以使用 ScrollViewReaderscrollTo(_:anchor:) 方法来滚动到特定的元素。以下是一个例子:

struct ContentView: View {    var bookmarks: [Int] = Array(1...100)    @State private var selectedBookmarkId: Int?    var body: some View {        VStack {            Button("Scroll to Bookmark 3") {                selectedBookmarkId = 3            }            Button("Scroll to Bookmark 13") {                selectedBookmarkId = 13            }            ScrollView {                ScrollViewReader { scrollView in                    LazyVStack {                        ForEach(bookmarks.indices, id: \.self) { index in                            Text("\(bookmarks[index])")                                .id(index)                        }                        .onChange(of: selectedBookmarkId) { oldValue, newValue in                            if let newValue = newValue {                                withAnimation {                                    scrollView.scrollTo(newValue, anchor: .top)                                }                            }                        }                    }                }            }        }    }}

在这个例子中,我们首先创建了一个 Button,当点击这个按钮时,selectedBookmarkId 的值会被设置为 3。然后,我们创建了一个 ScrollView,并在 ScrollView 中添加了一个 ScrollViewReader。我们在 ScrollViewReader 中添加了一个 LazyVStack,并使用 ForEach 遍历 bookmarks 数组的索引,为每个索引创建一个 Text 视图。我们使用 id(_:) 方法为每个 Text 视图设置了一个唯一的 ID。

我们使用 onChange(of:perform:) 方法来监听 selectedBookmarkId 的变化。当 selectedBookmarkId 的值改变时,我们会调用 scrollTo(_:anchor:) 方法来滚动到特定的元素。anchor: .top 参数表示我们希望滚动到的元素位于滚动视图的顶部。

scrollTargetBehavior分页滚动

按可视尺寸分页

.scrollTargetBehavior(.paging) 可以让 ScrollView 滚动,滚动一页的范围是 ScrollView 的可视尺寸。 

struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                }            }        }        .scrollTargetBehavior(.paging)    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

按容器元素对齐分页

使用 .scrollTargetBehavior(.viewAligned) 配合 scrollTargetLayout。示例代码如下:

struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                }            }            .scrollTargetLayout(isEnabled: true)        }        .scrollTargetBehavior(.viewAligned)    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

scrollTransition视觉效果

iOS 17 新推出 .scrollTransition,用于处理滚动时的动画。

.transition 用于视图插入和移除视图树时的动画。

.scrollTransition 会和滚动联合起来进行平滑的过渡动画处理。.scrollTransition 可以修改很多属性,比如大小,可见性还有旋转等。

.scrollTransition 可以针对不同阶段进行处理,目前有三个阶段:

  • topLeading: 视图进入 ScrollView 可见区域
  • identity: 在可见区域中
  • bottomTrailing: 视图离开 ScrollView 可见区域
struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                        .scrollTransition { content, phase in                             content                                .scaleEffect(phase.isIdentity ? 1 : 0.4)                        }                }            }        }    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

使用阶段的值

.scrollTransition(.animated(.bouncy)) { content, phase in    content        .scaleEffect(phase.isIdentity ? 1 : phase.value)}

不同阶段的产生效果设置

.scrollTransition(    topLeading: .animated,    bottomTrailing: .interactive) { content, phase in    content.rotationEffect(.radians(phase.value))}

.rotation3DEffect 也是支持的。

.scrollTransition(.interactive) { content, phase in    content        .rotation3DEffect(            Angle.degrees(phase.isIdentity ? 0: 120),            axis: (x: 0.9, y: 0.0, z: 0.1))        .offset(x: phase.value * -300)}

ScrollView-参考资料

文档

WWDC

23

List列表

List

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

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

使用 .listRowSeparator(.hidden, edges: .all) 可以隐藏分割线。

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

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,示例如下:

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)            }    }}

List-设置样式

内置样式

通过 .listStyle 修饰符可以用系统内置样式更改 List 外观。

List {   ...}.listStyle(.sidebar)

不同平台有不同的选项

ListStyle iOS macOS watchOS tvOS
plain iOS 13+ macOS 10.15+ watchOS 6+ tvOS 13+
sidebar iOS 14+ macOS 10.15+ - -
inset iOS 13+ macOS 11.15+ - -
grouped iOS 13+ - - tvOS 13+
insetGrouped iOS 14+ - - -
bordered - macOS 12+ - -
carousel - - watchOS 6+ -
elliptical - - watchOS 7+ -

行高

List {  ...}.environment(\.defaultMinListRowHeight, 100).environment(\.defaultMinListHeaderHeight, 50)

分隔符

listSectionSeparator 和 listRowSeparator 隐藏行和 Section 分隔符。

listRowSeparatorTint 和 listSectionSeparatorTint 更改分隔符颜色

例如:

.listRowSeparatorTint(.cyan, edges: .bottom)

背景

.alternatingRowBackgrounds() 可以让 List 的行底色有区分。

listRowBackground 调整行的背景颜色

更改背景颜色前需要隐藏内容背景

List {  ...}.scrollContentBackground(.hidden).background(Color.cyan)

这个方法同样可用于 ScrollView 和 TextEditor。

你可以使用 .listRowBackground() 修饰符来更改列表行的背景。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            ForEach(0..<5) { index in                Text("Row \(index)")                    .listRowBackground(index % 2 == 0 ? Color.blue : Color.green)            }        }    }}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .listRowBackground() 修饰符来更改每个元素的背景颜色。如果元素的索引是偶数,我们将背景颜色设置为蓝色,否则我们将背景颜色设置为绿色。

Section

你可以使用 Section 视图的 headerfooter 参数来添加头部和尾部。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            Section {                ForEach(0..<5) { index in                    Text("Row \(index)")                }            } header: {                Text("Header").font(.title)            } footer: {                Text("Footer").font(.caption)            }        }    }}

headerProminence(.increase) 可以增加 Section Header 的大小。

safeAreaInset

你可以使用 .safeAreaInset() 修饰符来调整视图的安全区域插入。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            ForEach(0..<5) { index in                Text("Row \(index)")            }        }        .safeAreaInset(edge: .top, spacing: 20) {            Text("Header")                .frame(maxWidth: .infinity, alignment: .center)                .background(Color.blue)                .foregroundColor(.white)        }    }}

在这个例子中,我们创建了一个包含五个元素的 List。然后我们使用 .safeAreaInset() 修饰符来在 List 的顶部添加一个 Header。我们将 edge 参数设置为 .top,将 spacing 参数设置为 20,然后提供一个视图作为 Header。这个 Header 是一个文本视图,它的背景颜色是蓝色,前景颜色是白色,它被居中对齐,并且它的宽度和 List 的宽度相同。

List-移动元素

你可以使用 .onMove(perform:) 修饰符来允许用户移动 List 中的元素。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        NavigationView {            List {                ForEach(items, id: \.self) { item in                    Text(item)                }                .onMove(perform: move)            }            .toolbar {                EditButton()            }        }    }    private func move(from source: IndexSet, to destination: Int) {        items.move(fromOffsets: source, toOffset: destination)    }}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .onMove(perform:) 修饰符来允许用户移动这些元素,并提供了一个 move(from:to:) 方法来处理移动操作。我们还添加了一个 EditButton,用户可以点击它来进入编辑模式,然后就可以移动元素了。

List-搜索

搜索和搜索建议

你可以使用 .searchable() 修饰符的 suggestions 参数来提供搜索建议。以下是一个例子:

struct ContentView: View {    @State private var searchText = ""    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        NavigationView {            List {                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in                    Text(item)                }            }            .searchable(text: $searchText, suggestions: {                 Button(action: {                    searchText = "Item 1"                }) {                    Text("Item 1")                }                Button(action: {                    searchText = "Item 2"                }) {                    Text("Item 2")                }            })            .navigationBarTitle("Items")        }    }}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了一个搜索框。当用户在搜索框中输入文本时,List 会自动更新以显示匹配的元素。同时,我们提供了两个搜索建议 “Item 1” 和 “Item 2”,用户可以点击这些建议来快速填充搜索框。

在列表中显示搜索建议

struct ContentView: View {    @Environment(\.searchSuggestionsPlacement) var placement    @State private var searchText = ""    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]        var body: some View {        NavigationView {            List {                SearchSuggestionView()                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in                    Text(item)                }            }            .searchable(text: $searchText, suggestions: {                VStack {                    Button(action: {                        searchText = "Item 1"                    }) {                        Text("Item 1")                    }                    Button(action: {                        searchText = "Item 2"                    }) {                        Text("Item 2")                    }                }                .searchSuggestions(.hidden, for: .content)            })            .navigationBarTitle("Items")        }    }        @ViewBuilder    func SearchSuggestionView() -> some View {        if placement == .content {            Button(action: {                searchText = "Item 1"            }) {                Text("Item 1")            }            Button(action: {                searchText = "Item 2"            }) {                Text("Item 2")            }        }    }}

搜索状态

搜索中

@Environment(\.isSearching) var isSearching

关闭搜索

@Environment(\.dismissSearch) var dismissSearch

提交搜索

List {    ...}.searchable(text: $vm.searchTerm).onSubmit(of: .search) {    //...}

搜索栏外观

占位文字说明

.searchable(text: $wwdcVM.searchText, prompt: "搜索 WWDC Session 内容")

一直显示搜索栏

.searchable(text: $wwdcVM.searchText,             placement: .navigationBarDrawer(displayMode:.always))

更改搜索栏的位置

.searchable(text: $wwdcVM.searchText, placement: .sidebar)

搜索去抖动

你可以使用 Combine 框架来实现搜索的去抖动功能。以下是一个例子:

import SwiftUIimport Combineclass SearchViewModel: ObservableObject {    @Published var searchText = ""    @Published var searchResults: [String] = []    private var cancellables = Set<AnyCancellable>()    init() {        $searchText            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)            .sink { [weak self] in self?.search($0) }            .store(in: &cancellables)    }    private func search(_ text: String) {        // 这里是你的搜索逻辑        // 例如,你可以从一个数组中过滤出匹配的元素        let items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]        searchResults = items.filter { $0.contains(text) }    }}struct ContentView: View {    @StateObject private var viewModel = SearchViewModel()    var body: some View {        VStack {            TextField("Search", text: $viewModel.searchText)                .padding()            List(viewModel.searchResults, id: \.self) { result in                Text(result)            }        }    }}

在这个例子中,我们创建了一个 SearchViewModel 类,它有一个 searchText 属性和一个 searchResults 属性。当 searchText 属性的值发生变化时,我们使用 Combine 的 debounce(for:scheduler:) 方法来延迟执行搜索操作,从而实现去抖动功能。然后我们在 ContentView 中使用这个 SearchViewModel 来显示搜索框和搜索结果。

List-下拉刷新

你可以使用 .refreshable() 修饰符来添加下拉刷新功能。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text(item)            }        }        .refreshable {            await refresh()        }    }    func refresh() async {        // 这里是你的刷新逻辑        // 例如,你可以从网络获取新的数据,然后更新 items 数组        // 这里我们只是简单地将 items 数组反转        items.reverse()    }}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了下拉刷新功能。当用户下拉 List 时,refresh() 方法会被调用,然后我们将 items 数组反转,从而模拟刷新操作。注意,refresh() 方法需要是一个异步方法,因为刷新操作通常需要一些时间来完成。

List-轻扫操作

你可以使用 .swipeActions() 修饰符来添加轻扫操作。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text(item)                .swipeActions {                    Button(action: {                        // 这里是你的删除操作                        if let index = items.firstIndex(of: item) {                            items.remove(at: index)                        }                    }) {                        Label("Delete", systemImage: "trash")                    }                    .tint(.red)                }            }        }    }}

在这个例子中,我们创建了一个包含五个元素的 List,并为每个元素添加了一个滑动操作。当用户向左轻扫一个元素时,会显示一个 “Delete” 按钮,用户可以点击这个按钮来删除该元素。

List-大纲视图

List 树状结构

通过 children 参数指定子树路径。

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

DisclosureGroup 实现展开和折叠

DisclosureGroup 视图可以用来创建一个可以展开和折叠的内容区域。以下是一个例子:

struct ContentView: View {    @State private var isExpanded = false    var body: some View {        DisclosureGroup("Options", isExpanded: $isExpanded) {            Text("Option 1")            Text("Option 2")            Text("Option 3")        }    }}

在这个例子中,我们创建了一个 DisclosureGroup 视图,它的标题是 “Options”,并且它包含三个选项。我们使用一个 @State 属性 isExpanded 来控制 DisclosureGroup 视图是否展开。当用户点击标题时,DisclosureGroup 视图会自动展开或折叠,同时 isExpanded 属性的值也会相应地改变。

OutlineGroup 创建大纲视图

可以使用 OutlineGroup 视图来创建一个大纲视图。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            OutlineGroup(sampleData, id: \.self) { item in                Text(item.name)            }        }    }}struct Item: Identifiable {    var id = UUID()    var name: String    var children: [Item]?}let sampleData: [Item] = [    Item(name: "Parent 1", children: [        Item(name: "Child 1"),        Item(name: "Child 2")    ]),    Item(name: "Parent 2", children: [        Item(name: "Child 3"),        Item(name: "Child 4")    ])]

在这个例子中,我们创建了一个 Item 结构体,它有一个 name 属性和一个 children 属性。然后我们创建了一个 sampleData 数组,它包含两个父项,每个父项都有两个子项。最后我们在 ContentView 中使用 OutlineGroup 视图来显示这个数组,每个父项和子项都显示为一个文本视图。

结合 OutlineGroup 和 DisclosureGroup 实现自定义可折叠大纲视图

代码如下:

struct SPOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {    private let v: SPOutlineView<D, Content>        init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {        self.v = SPOutlineView(d: d, c: c, content: content)    }        var body: some View {        List {            v        }    }}struct SPOutlineView<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] {                SPDisclosureGroup(content: SPOutlineView(d: sub, c: c, content: content), label: content(i))            } else {                content(i)            } // end if        } // end ForEach    } // end body}struct SPDisclosureGroup<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 {                withAnimation {                    isExpanded.toggle()                }            } label: {                label            }            .buttonStyle(.plain)        }            }}

List-完全可点击的行

使用 .contentShape(Rectangle()) 可以使整个区域都可点击

struct ContentView: View {    var body: some View {        List {            ForEach(1..<50) { num in                HStack {                    Text("\(num)")                    Spacer()                }                .contentShape(Rectangle())                .onTapGesture {                    print("Clicked \(num)")                }            }        } // end list    }}

List-索引标题

这个代码是在创建一个带有索引标题的列表,用户可以通过拖动索引标题来快速滚动列表。

import SwiftUI...struct ContentView: View {  ...  var body: some View {    ScrollViewReader { proxy in      List {        ArticleListView      }      .listStyle(InsetGroupedListStyle())      .overlay(IndexView(proxy: proxy))    }  }  ...}struct IndexView: View {  let proxy: ScrollViewProxy  let titles: [String]  @GestureState private var dragLocation: CGPoint = .zero  var body: some View {    VStack {      ForEach(titles, id: \.self) { title in        TitleView()          .background(drag(title: title))      }    }    .gesture(      DragGesture(minimumDistance: 0, coordinateSpace: .global)        .updating($dragLocation) { value, state, _ in          state = value.location        }    )  }  func drag(title: String) -> some View {    GeometryReader { geometry in      drag(geometry: geometry, title: title)    }  }  func drag(geometry: GeometryProxy, title: String) -> some View {    if geometry.frame(in: .global).contains(dragLocation) {      DispatchQueue.main.async {        proxy.scrollTo(title, anchor: .center)      }    }    return Rectangle().fill(Color.clear)  }  ...}...

上面代码中 ContentView 是主视图,它包含一个 List 和一个 IndexViewList 中的内容由 ArticleListView 提供。IndexView 是一个自定义视图,它显示了所有的索引标题。

IndexView 接受一个 ScrollViewProxy 和一个标题数组。它使用 VStackForEach 来创建一个垂直的索引标题列表。每个标题都是一个 TitleView,并且它有一个背景,这个背景是通过 drag(title:) 方法创建的。

drag(title:) 方法接受一个标题,并返回一个视图。这个视图是一个 GeometryReader,它可以获取其包含的视图的几何信息。然后,这个 GeometryReader 使用 drag(geometry:title:) 方法来创建一个新的视图。

drag(geometry:title:) 方法接受一个 GeometryProxy 和一个标题,并返回一个视图。如果 GeometryProxy 的全局帧包含当前的拖动位置,那么这个方法将返回一个特定的视图。

IndexView 还有一个手势,这个手势是一个 DragGesture。当用户拖动索引标题时,这个手势会更新 dragLocation 属性的值,这个属性是一个 @GestureState 属性,它表示当前的拖动位置。

List-加载更多

你可以通过检测列表滚动到底部来实现加载更多的功能。以下是一个简单的例子:

struct ContentView: View {    @State private var items = Array(0..<20)    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text("Item \(item)")                    .onAppear {                        if item == items.last {                            loadMore()                        }                    }            }        }        .onAppear(perform: loadMore)    }    func loadMore() {        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {            let newItems = Array(self.items.count..<self.items.count + 20)            self.items.append(contentsOf: newItems)        }    }}

在这个例子中,我们创建了一个包含多个元素的 List。当 List 出现最后一项时,我们调用 loadMore 方法来加载更多的元素。在 loadMore 方法中,模拟在一秒后添加新的元素到 items 数组中。

请注意,这只是一个基本的使用示例,实际的使用方式可能会根据你的需求而变化。例如,你可能需要从网络获取新的元素,而不是像这个例子中那样直接创建新的元素。

Lazy容器

LazyVStack和LazyHStack

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

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)) 表示会根据设置大小自动设置有多少列展示。

示例:

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()        }    }}

Grid

Grid 会将最大的一个单元格大小应用于所有单元格

代码例子:

struct ContentView: View {    var body: some View {        Grid(alignment: .center,             horizontalSpacing: 30,             verticalSpacing: 8) {            GridRow {                Text("Tropical")                Text("Mango")                Text("Pineapple")                    .gridCellColumns(2)            }            GridRow(alignment: .bottom) {                Text("Leafy")                Text("Spinach")                Text("Kale")                Text("Lettuce")            }        }    }}

gridCellAnchor 可以让 GridRow 给自己设置对齐方式。

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

GridRow 的间距通过 Grid 的 horizontalSpacingverticalSpacing 参数来控制。

struct ContentView: View {    let numbers: [[Int]] = [        [1, 2, 3],        [4, 5, 6],        [7, 8, 9]    ]    var body: some View {        Grid(horizontalSpacing: 0, verticalSpacing: 0) {            ForEach(numbers.indices, id: \.self) { i in                GridRow {                    ForEach(numbers[i].indices, id: \.self) { j in                        Text("\(numbers[i][j])")                            .frame(maxWidth: .infinity, maxHeight: .infinity)                            .background(Color.gray.opacity(0.2))                            .border(Color.gray, width: 0.5)                    }                }            }        }    }}

按照以上代码这样写,每个数字 GridRow 之间的间隔就是0了。

空白的单元格可以这样写:

Color.clear    .gridCellUnsizedAxes([.horizontal, .vertical])

Table表格

Table

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

table 使用示例如下:

struct ContentView: View {    var body: some View {        Table(Fruit.simpleData()) {            TableColumn("名字", value: \.name)            TableColumn("颜色", value: \.color)            TableColumn("颜色") {                Text("\($0.name)")                    .font(.footnote)                    .foregroundStyle(.cyan)            }        }        .contextMenu(forSelectionType: Fruit.ID.self) { selection in            if selection.isEmpty {                Button("添加") {                    // ...                }            } else if selection.count == 1 {                Button("收藏") {                    // ...                }            } else {                Button("收藏多个") {                    // ...                }            }        }    }        struct Fruit:Identifiable {        let id = UUID()        let name: String        let color: String                static func simpleData() -> [Fruit] {            var re = [Fruit]()            re.append(Fruit(name: "Apple", color: "Red"))            re.append(Fruit(name: "Banana", color: "Yellow"))            re.append(Fruit(name: "Cherry", color: "Red"))            re.append(Fruit(name: "Date", color: "Brown"))            re.append(Fruit(name: "Elderberry", color: "Purple"))            return re        }    }}

Table-样式

在 SwiftUI 中,Table 视图的 .tableStyle 修改器可以用来设置表格的样式。目前,SwiftUI 提供了以下几种表格样式:

  • inset:默认
  • inset(alternatesRowBackgrounds: Bool):是否开启行交错背景
  • bordered:加边框
  • bordered(alternatesRowBackgrounds: Bool): 是否开启行交错背景

你可以使用 .tableStyle 修改器来设置表格的样式,例如:

Table(data) {    // ...}.tableStyle(InsetGroupedListStyle())

这段代码会将表格的样式设置为 InsetGroupedListStyle

Table-行的选择

你可以使用 Table 视图的 selection 参数来实现单选和多选。selection 参数接受一个绑定到一个可选的 Set 的变量,这个 Set 包含了被选中的元素的标识。

以下是一个使用 Table 视图实现单选和多选的例子:

struct ContentView: View {    @State private var selectionOne: UUID? // 单选    @State private var selection: Set<UUID> = [] // 多选    let data = [        Fruit(name: "Apple", color: "Red"),        Fruit(name: "Banana", color: "Yellow"),        Fruit(name: "Cherry", color: "Red"),        Fruit(name: "Date", color: "Brown"),        Fruit(name: "Elderberry", color: "Purple")    ]    var body: some View {        Table(data, selection: $selectionOne) {            TableColumn("Fruit") { item in                Text(item.name)            }            TableColumn("Color") { item in                Text(item.color)            }        }    }}struct Fruit: Identifiable {    let id = UUID()    let name: String    let color: String}

在这个例子中,我们首先定义了一个 @State 变量 selection,它是一个 Set,包含了被选中的元素的标识。然后,我们将这个变量绑定到 Table 视图的 selection 参数。

现在,当用户选择或取消选择一个元素时,selection 变量就会被更新。你可以使用这个变量来判断哪些元素被选中,或者实现其他的交互功能。

Table-多属性排序

你可以使用 Table 视图的 sortOrder 参数来实现多属性排序。sortOrder 参数接受一个绑定到一个 SortDescriptor 数组的变量,这个数组定义了排序的顺序和方式。

以下是一个使用 Table 视图实现多属性排序的例子:

struct ContentView: View {    @State private var sortOrder: [KeyPathComparator<Fruit>] = [.init(\.name, order: .reverse)]    @State var data = [        Fruit(name: "Apple", color: "Red"),        Fruit(name: "Banana", color: "Yellow"),        Fruit(name: "Cherry", color: "Red"),        Fruit(name: "Date", color: "Brown"),        Fruit(name: "Elderberry", color: "Purple")    ]    var body: some View {        sortKeyPathView() // 排序状态        Table(data, sortOrder: $sortOrder) {            TableColumn("Fruit", value: \.name)            TableColumn("Color", value: \.color)            // 不含 value 参数的不支持排序            TableColumn("ColorNoOrder") {                Text("\($0.color)")                    .font(.footnote)                    .foregroundStyle(.mint)            }        }        .task {            data.sort(using: sortOrder)        }        .onChange(of: sortOrder) { oldValue, newValue in            data.sort(using: newValue)        }        .padding()    }        @ViewBuilder    func sortKeyPathView() -> some View {        HStack {            ForEach(sortOrder, id: \.self) { order in                Text(order.keyPath == \Fruit.name ? "名字" : "颜色")                Image(systemName: order.order == .reverse ? "chevron.down" : "chevron.up")            }        }        .padding(.top)    }}struct Fruit: Identifiable {    let id = UUID()    let name: String    let color: String}

在这个例子中,我们首先定义了一个 @State 变量 sortOrder,它是一个 SortDescriptor 数组,定义了排序的顺序和方式。然后,我们将这个变量绑定到 Table 视图的 sortOrder 参数。

现在,当用户点击表头来排序一个列时,sortOrder 变量就会被更新。你可以使用这个变量来实现多属性排序,或者实现其他的交互功能。

Table-contextMenu

struct ContentView: View {    @State private var selection: Set<UUID> = []    var body: some View {        Table(Fruit.simpleData(), selection: $selection) {            ...        }        .contextMenu(forSelectionType: Fruit.ID.self) { selection in            if selection.isEmpty {                Button("添加") {                    // ...                }            } else if selection.count == 1 {                Button("收藏") {                    // ...                }            } else {                Button("收藏多个") {                    // ...                }            }        } primaryAction: { items in            // 双击某一行时            debugPrint(items)        }    }    ...}

小册子之简说 Widget 小组件

作者 戴铭
2024年5月18日 08:07

以下内容已整理到小册子中,小册子代码在 Github 上,本文会随着系统更新和我更多的实践而新增和更新,你可以购买“戴铭的开发小册子”应用(98元),来跟踪查看本文内容新增和更新。

本文属于小册子系列中的一篇,已发布系列文章有:

Widge 允许开发者在用户的主屏幕或通知中心展示应用的信息。Widget 可以提供快速的信息预览,或者提供快速访问应用的方式。

开发 Widget 的基本步骤如下:

  1. 创建 Widget Extension:在 Xcode 中,你需要创建一个新的 Widget Extension。这将会生成一个新的 target,包含了创建 Widget 所需的基本代码。

  2. 定义 Timeline Entry:Timeline Entry 是 Widget 数据的模型。你需要创建一个遵循 TimelineEntry 协议的结构体,定义你的 Widget 所需的数据。

  3. 创建 Widget View:Widget View 是 Widget 的用户界面。你需要创建一个 View,展示你的 Widget 的内容。

  4. 实现 Timeline Provider:Timeline Provider 是 Widget 数据的提供者。你需要创建一个遵循 TimelineProvider 协议的结构体,提供 Widget 的数据。

  5. 配置 Widget:在 Widget 的主结构体中,你需要配置你的 Widget,包括它的类型(静态或者动态)、数据提供者、视图等。

  6. 测试 Widget:在模拟器或者真机上测试你的 Widget,确保它的数据和视图都按预期工作。

接下来,我们将详细介绍 Widget 的开发流程。

小组件-StaticConfiguration 静态配置

在 Xcode 中,File -> New -> Target,选择 Widget Extension。这将会生成一个新的 target,包含了创建 Widget 所需的基本代码。

以下是一个简单的小组件代码示例:

import WidgetKitimport SwiftUI// Timeline Entrystruct ArticleEntry: TimelineEntry {    let date: Date    let title: String}// Widget Viewstruct ArticleWidgetView : View {    let entry: ArticleEntry    var body: some View {        Text(entry.title)    }}// Timeline Providerstruct ArticleTimelineProvider: TimelineProvider {    typealias Entry = ArticleEntry        func placeholder(in context: Context) -> Entry {        // 占位大小,内容不会显示        return ArticleEntry(date: Date(), title: "Placeholder")    }    func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {        let entry = ArticleEntry(date: Date(), title: "Snapshot")        completion(entry)    }    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {        let entry = ArticleEntry(date: Date(), title: "Timeline")        let timeline = Timeline(entries: [entry], policy: .never)        completion(timeline)    }}// Widget Configuration@mainstruct ArticleWidget: Widget {        var body: some WidgetConfiguration {        StaticConfiguration(            kind: "com.starming.articleWidget",            provider: ArticleTimelineProvider()        ) { entry in            ArticleWidgetView(entry: entry)        }        .configurationDisplayName("Article Widget")        .description("这是一个 Article Widget.")        .supportedFamilies([            .systemSmall,            .systemMedium,            .systemLarge,        ])    }}

在上面的代码中,我们定义了一个 ArticleWidget 小组件,它包含了一个 ArticleEntry 数据模型、一个 ArticleWidgetView 视图、一个 ArticleTimelineProvider 数据提供者和一个 ArticleWidget 配置。

小组件-AppIntentConfiguration

iOS 17 开始可以使用 AppIntentConfiguration 来配置小组件,这样可以让小组件和 AppIntent 交互。这样可以让小组件和 App 之间的进行交互。

下面是一个简单的小组件代码示例,展示了如何使用 AppIntentConfiguration 来配置小组件和 AppIntent 交互

import SwiftUIimport WidgetKitimport AppIntentsstruct ArticleWidget: Widget {    var body: some WidgetConfiguration {        AppIntentConfiguration(            kind: "com.starming.articleWidget",            intent: ArticleIntent.self,            provider: ArticleIntentProvider()        ) { entry in            ArticleWidgetView(entry: entry)        }        .configurationDisplayName("Article Widget")        .description("这是一个 Article Widget.")        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])    }}struct ArticleWidgetView: View {    var entry: IntentProvider.Entry    var body: some View {        Text(entry.author)    }}struct ArticleIntentProvider: AppIntentTimelineProvider {    func snapshot(for configuration: ArticleIntent, in context: Context) async -> ArticleEntry {        return .init(            date: Date(),            author: "snapshot"        )    }    func placeholder(in context: Context) -> ArticleEntry {        return .init(            date: Date(),            author: "某人"        )    }    func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {        return Timeline(            entries: [                .init(date: Date(),                      author: configuration.author,                      rate: await ArticleStore().rate())],            policy: .never)    }}struct ArticleEntry: TimelineEntry {    let date: Date    let author: String    var rate: Int = 0    //...}// 放在主应用中和小组件交互struct ArticleIntent: WidgetConfigurationIntent {        static var title: LocalizedStringResource  = "文章"    var author: String = "某某某"    func perform() async throws -> some IntentResult {        //...        return .result()    }}class ArticleStore {    //... SwiftData 相关配置    @MainActor    func rate() async -> Int {        //... 获取        return 5    }}

如上代码所示,我们定义了一个 ArticleWidget 小组件,它包含了一个 ArticleIntent 数据模型、一个 ArticleWidgetView 视图、一个 ArticleIntentProvider 数据提供者和一个 ArticleWidget 配置。

小组件-配置选项

显示区域

iOS 17 新增显示区域配置,有下面四种

  • homeScreen:主屏幕
  • lockScreen:锁屏
  • standBy:待机
  • iPhoneWidgetsOnMac:iPhone 上的 Mac 小组件

设置小组件不在哪个区域显示某尺寸。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 在 StandBy 中取消显示 systemSmall 尺寸        .disfavoredLocations([.standBy], for: [.systemSmall])    }}

取消内容边距

使用 .contentMarginsDisabled() 取消内容边距。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 使 Content margin 失效        .contentMarginsDisabled()    }}

每个平台内容边距大小不同,环境变量 \.widgetContentMargins 可以读取内容边距的大小。

取消背景删除

在 StandBy 和 LockScreen 的某些情况,小组件的背景是会被自动删除的。

使用 containerBackgroundRemovable() 修饰符可以取消背景删除。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 取消背景删除        .containerBackgroundRemovable(false)        // 让自己的背景可以全覆盖        .contentMarginsDisabled()    }}

后台网络处理

.onBackgroundURLSessionEvents { (identifier, completion) in    //...}

AppIntentTimelineProvider

AppIntentConfiguration 需要 AppIntentTimelineProvider,AppIntentTimelineProvider 需要实现 snapshotplaceholdertimeline 三个方法来确定小组件在展示和实际运行时间线时的视图和数据。

struct ArticleIntentProvider: AppIntentTimelineProvider {    func snapshot(for configuration: ArticleIntent, in context: Context) async -> ArticleEntry {        return .init(            date: Date(),            author: "snapshot"        )    }    func placeholder(in context: Context) -> ArticleEntry {        return .init(            date: Date(),            author: "某人"        )    }    func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {        return Timeline(            entries: [                .init(date: Date(),                      author: configuration.author,                      rate: await ArticleStore().rate())],            policy: .never)    }}struct ArticleEntry: TimelineEntry {    let date: Date    let author: String    var rate: Int = 0    //...}````## Widget View### 不同的大小设置不同视图```swiftstruct ArticleWidgetView: View {  var entry: Provider.Entry  @Environment(\.widgetFamily) var family  @ViewBuilder  var body: some View {    switch family {    case .systemSmall:        SomeViewSmall()    default:      SomeViewDefault()    }  }}

锁屏小组件

让小组件支持锁屏

struct ArticleWidget: Widget {    var body: some WidgetConfiguration {        StaticConfiguration(            ...        ) { entry in            ...        }        ...        .supportedFamilies([            .systemSmall,            .systemMedium,            .systemLarge,            // 添加支持到 Lock Screen widgets            .accessoryCircular,            .accessoryRectangular,            .accessoryInline,        ])    }}

不同类型 widgetFamily 实现不同视图

struct ArticleWidgetView : View {       let entry: ViewSizeEntry    // 获取 widget family 值    @Environment(\.widgetFamily) var family    var body: some View {        switch family {        case .accessoryRectangular:            RectangularWidgetView()        case .accessoryCircular:            CircularWidgetView()        case .accessoryInline:            InlineWidgetView()        default:            ArticleWidgetView(entry: entry)        }    }}

不同渲染模式实现不同视图

小组件有三种不同的渲染模式:

  • Full-color:主屏用
  • Vibrant:用于待机模式和锁屏
  • The accented:用于手表
struct ArticleWidgetView: View {    let entry: Entry        @Environment(\.widgetRenderingMode) private var renderingMode        var body: some View {        switch renderingMode {        case .accented:            AccentedWidgetView(entry: entry)        case .fullColor:            FullColorWidgetView(entry: entry)        case .vibrant:            VibrantWidgetView(entry: entry)        default:            DefaultView()        }    }}

视图交互

使用 AppIntent

struct ArticleWidgetView : View {    var entry: IntentProvider.Entry    var body: some View {        VStack(spacing: 20) {            ...            Button(intent: RunIntent(rate: entry.rate), label: {                ...            })        }    }}

刷新小组件

通过 Text 视图更新

倒计时

let futureDate = Calendar.current.date(byAdding: components, to: Date())!// 日期会在 Text 视图中动态变化
struct CountdownWidgetView: View {        var body: some View {        Text(futureDate(), style: .timer)    }        private func futureDate() -> Date {        let components = DateComponents(second: 10)        let futureDate = Calendar.current.date(byAdding: components, to: Date())!        return futureDate    }}

Timeline Provider 更新

在 timeline 方法中实现,entries 包含了不同更新的数据。

func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {    return Timeline(        entries: [            .init(date: Date(),                  author: configuration.author,                  rate: await ArticleStore().rate())],        policy: .never)}

更新策略

3 种类型的刷新策略:

  • atEnd:上个刷新完成直接进入下个刷新,但是进入下一个刷新的时间由系统决定。
  • after(Date):指定进入下个刷新的时间,但是具体时间还是由系统说了算,因此可以理解为是指定的是最早进入下个刷新的时间。
  • never:不会进入下个刷新,除非显式调用 reloadTimelines(ofKind:)

举例,指定下个刷新周期至少是上个周期结束10秒后:

let lastUpdateDate = entries.last!.datelet nextUpdateDate = Calendar.current.date(byAdding: DateComponents(second: 10), to: lastUpdate)!let timeline = Timeline(entries: entries, policy: .after(nextUpdate))

Relevance 优先级

App 自定义刷新 Timeline 的优先级,使用 Relevance。先在 TimelineEntry 里定义:

struct ArticleEntry: TimelineEntry {    let date: Date    ...    let relevance: TimelineEntryRelevance?}

在 timeline 方法中根据必要刷新程序,定义不同 relevance 的值。

App 主动刷新

// 刷新单个小组件WidgetCenter.shared.reloadTimelines(ofKind: "CountryWidget")// 刷新所有小组件WidgetCenter.shared.reloadAllTimelines()

刷新小组件的最佳实践

调试时刷新率不会有限制,生产环境每天最多40到70次,相当于每15到60分钟刷新一次。

小组件动画

Text 视图动态时间

利用 Text 的动态时间能力

timeline 动画

timeline 是由一组时间和数据组成的,每次刷新时,小组件通过和上次数据不一致加入动画效果。

默认情况小组件使用的是弹簧动画。我们也可以添加转场(Transition)、动画(Animation)和内容过渡(Content Transition)动画效果。

文本内容过渡动画效果

.contentTransition(.numericText(value: rate))

从底部翻上来的专场

.transition(.push(from: .bottom))

小组件-远程定时获取数据

在 TimelineProvider 中的 timeline 方法中加入请求逻辑

func timeline(for configuration: RunIntent, in context: Context) -> Void) async -> Timeline<ArticleEntry> {    guard let article = try? await ArticleFetch.fetchNewestArticle() else {        return    }    let entry = ArticleEntry(date: Date(), article: article)        // 下次在 30 分钟后再请求    let afterDate = Calendar.current.date(byAdding: DateComponents(minute: 30), to: Date())!    return Timeline(entries: [entry], policy: .after(afterDate))}

以上代码中,我们在 timeline 方法中请求了最新的文章数据,并且设置了下次请求的时间是当前时间的 30 分钟后。

小组件-获取位置权限更新内容

小组件获取位置权限和主应用 target 里获取方式很类似,步骤:

  • 在 info 里添加 NSWidgetUseLocation = ture
  • 使用 CLLocationManager 来获取位置信息,设置较低的精度。
  • isAuthorizedForWidgetUpdates 请求位置权限。

支持多个小组件

widget bundle 可以支持多个小组件。

@mainstruct FirstWidgetBundle: WidgetBundle {        @WidgetBundleBuilder    var body: some Widget {        FirstWidget()        SecondWidget()        ...        SecondWidgetBundle().body    }}struct SecondWidgetBundle: WidgetBundle {    @WidgetBundleBuilder    var body: some Widget {        SomeWidgetOne()        SomeWidgetTwo()        ...    }}

获取小组件形状

不同设备小组件大小和形状都不同,比如要加个边框,就很困难。这就需要使用 ContainerRelativeShape 来获取 Shape 视图容器。

var body: some View {  ZStack {    ContainerRelativeShape()        .inset(by: 2)        .fill(.pink)    Text("Hello world")    ...  }}

小组件-Deep link

medium 和 large 的小组件可以使用 Link,small 小组件使用 .widgetURL 修饰符。

小组件访问SwiftData

Wdiget target 访问主应用 target 的 SwiftData 数据步骤如下:

  • 对主应用和 Widget 的 target 中的 Signing & Capabilities 都添加 App Groups,并创建一个新组,名字相同。
  • SwiftData 的模型同时在主应用和 Widget 的 target 中。
  • StaticConfiguration 或 AppIntentConfiguration 中添加 modelContainer() 修饰符,让 SwiftData 的容器可用。

小组件-参考资料

WWDC

23

22

21

20

小册子之如何使用 SwiftData 开发 SwiftUI 应用

作者 戴铭
2024年5月18日 00:17

以下内容已整理到小册子中,小册子代码在 Github 上,本文会随着系统更新和我更多的实践而新增和更新,你可以购买“戴铭的开发小册子”应用(98元),来跟踪查看本文内容新增和更新。

本文属于小册子系列中的一篇,已发布系列文章有:

小册子代码里有大量 SwiftData 实际使用实践的代码。

在 Swift 中,有许多库可以用于处理数据,包括但不限于 SwiftData、CoreData、Realm、SQLite.swift 等。这些库各有优势。

但,如果使用 SwiftData,你可以在 Swift 中更加方便地处理数据。SwiftData 是 Apple 在 WWDC23 上推出的一个新的数据持久化框架,它是 CoreData 的替代品,提供了更简单、更易用的 API。

创建@Model模型

先说说如何创建 SwiftData 模型。

创建

@Model 宏装饰类

@Modelfinal class Article {    let title: String    let author: String    let content: String    let publishedDate: Date        init(title: String, author: String, content: String, publishedDate: Date) {        self.title = title        self.author = author        self.content = content        self.publishedDate = publishedDate    }}

以上代码创建了一个 Article 模型,包含了标题、作者、内容和发布日期。

以下数据类型默认支持:

  • 基础类型:Int, Int8, Int16, Int32, Int64, UInt, UInt8, UInt16, UInt32, UInt64, Float, Double, Bool, String, Date, Data 等
  • 复杂的类型:Array, Dictionary, Set, Optional, Enum, Struct, Codable 等
  • 模型关系:一对一、一对多、多对多

默认数据库路径: Data/Library/Application Support/default.store

@Attribute

接下来说说如何使用 @Attribute 宏。

一些常用的:

  • spotlight:使其能出现在 Spotlight 搜索结果里
  • unique:值是唯一的
  • externalStorage:值存储为二进制数据
  • transient:值不存储
  • encrypt:加密存储

使用方法

@Attribute(.externalStorage) var imgData: Data? = nil

二进制会将其存储为单独的文件,然后在数据库中引用文件名。文件会存到 Data/Library/Application Support/.default_SUPPORT/_EXTERNAL_DATA 目录下。

@Transient 不存

如果有的属性不希望进行存储,可以使用 @Transient

@Modelfinal class Article {    let title: String    let author: String    @Transient var content: String    ...}

transformable

SwiftData 除了能够存储字符串和整数这样基本类型,还可以存储更复杂的自定义类型。要存储自定义类型,可用 transformable。

@Modelfinal class Article {    let title: String    let author: String    let content: String    let publishedDate: Date    @Attribute(.transformable(by: UIColorValueTransformer.self)) var bgColor: UIColor    ...}

UIColorValueTransformer 类的实现

class UIColorValueTransformer: ValueTransformer {        // return data    override func transformedValue(_ value: Any?) -> Any? {        guard let color = value as? UIColor else { return nil }        do {            let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: true)            return data        } catch {            return nil        }    }        // return UIColor    override func reverseTransformedValue(_ value: Any?) -> Any? {        guard let data = value as? Data else { return nil }                do {            let color = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data)            return color        } catch {            return nil         }    }}

注册

struct SwiftPamphletAppApp: App {    init() {        ValueTransformer.setValueTransformer(UIColorValueTransformer(), forName: NSValueTransformerName("UIColorValueTransformer"))    }        var body: some Scene {        WindowGroup {            ContentView()                .modelContainer(for: [Article.self])        }    }}

SwiftData-模型关系

使用 ``@Relationship` 添加关系,但是不加这个宏也可以,SwiftData 会自动添加模型之间的关系。

@Modelfinal class Author {    var name: String    @Relationship(deleteRule: .cascade, inverse: \Brew.brewer)    var articles: [Article] = []}@Modelfinal class Article {    ...    var author: Author}

默认情况 deleteRule 是 .nullify,这个删除后只会删除引用关系。.cascade 会在删除用户后删除其所有文章。

SwiftData 可以添加一对一,一对多,多对多的关系。

限制关系表数量

@Relationship(maximumModelCount: 5)    var articles: [Article] = []

容器配置modelContainer

多模型

配置方法

@mainstruct SomeApp: App {    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(for: [Article.self, Author.self])    }}

有关系的两个模型,只需要加父模型,SwiftData 会推断出子模型。

数据存内存

let configuration = ModelConfiguration(inMemory: true)let container = try ModelContainer(for: schema, configurations: [configuration])

数据只读

let config = ModelConfiguration(allowsSave: false)

自定义存储文件和位置

如果要指定数据库存储的位置,可以按下面写法:

@mainstruct SomeApp: App {    var container: ModelContainer    init() {        do {            let storeURL = URL.documentsDirectory.appending(path: "database.sqlite")            let config = ModelConfiguration(url: storeURL)            container = try ModelContainer(for: Article.self, configurations: config)        } catch {            fatalError("Failed")        }    }    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(container)    }}

iCloud 支持

如果要添加 iCloud 支持,需要先确定模型满足以下条件:

  • 没有唯一约束
  • 关系是可选的
  • 有所值有默认值

iCloud 支持操作步骤:

  • 进入 Signing & Capabilities 中,在 Capability 里选择 iCloud
  • 选中 CloudKit 旁边的框
  • 设置 bundle identifier
  • 再按 Capability,选择 Background Modes
  • 选择 Remote Notifications

指定部分表同步到 iCloud

使用多个 ModelConfiguration 对象来配置,这样可以指定哪个配置成同步到 iCloud,哪些不同步。

添加多个配置

@mainstruct SomeApp: App {    var container: ModelContainer    init() {        do {            let c1 = ModelConfiguration(for: Article.self)            let c2 = ModelConfiguration(for: Author.self, isStoredInMemoryOnly: true)            container = try ModelContainer(for: Article.self, Author.self, configurations: c1, c2)        } catch {            fatalError("Failed")        }    }    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(container)    }}

撤销和重做

创建容器时进行指定

.modelContainer(for: Article.self, isUndoEnabled: true)

这样 modelContext 就可以调用撤销和重做函数。

struct SomeView: View {    @Environment(\.modelContext) private var context    var body: some View {        Button(action: {            context.undoManager?.undo()        }, label: {            Text("撤销")        })    }}

context

View 之外的地方,可以通过 ModelContainer 的 context 属性来获取 modelContext。

let context = container.mainContextlet context = ModelContext(container)

预先导入数据

方法如下:

.modelContainer(for: Article.self) { result in    do {        let container = try result.get()        // 先检查有没数据        let descriptor = FetchDescriptor<Article>()        let existingArticles = try container.mainContext.fetchCount(descriptor)        guard existingArticles == 0 else { return }        // 读取 bundle 里的文件        guard let url = Bundle.main.url(forResource: "articles", withExtension: "json") else {            fatalError("Failed")        }        let data = try Data(contentsOf: url)        let articles = try JSONDecoder().decode([Article].self, from: data)        for article in articles {            container.mainContext.insert(article)        }    } catch {        print("Failed")    }}

增删modelContext

添加保存数据

struct SomeView: View {   @Environment(\.modelContext) var context   ...   var body: some View {         ...         Button(action: {             self.add()         }, label: {             Text("添加")         })   }   func add() {      ...      context.insert(article)   }}

默认不用使用 context.save(),SwiftData 会自动进行保存,如果不想自动保存,可以在容器中设置

var body: some Scene {   WindowGroup {      ContentView()   }   .modelContainer(for: Article.self, isAutosaveEnabled: false)       }

编辑和删除数据

编辑数据使用 @Bindable

struct SomeView: View {    @Bindable var article: Article    @Environment(\.modelContext) private var modelContext    ...        var body: some View {        Form {            TextField("文章标题", text: $article.title)            ...        }        .toolbar {            ToolbarItem(placement: .destructiveAction) {                Button("删除") {                    modelContext.delete(article)                }            }        }        ...    }}

SwiftData-检索

@Query

使用 @Query 会从数据库中获取数据。

@Query private var articles: [Article]

@Query 还支持 filter、sort、order 和 animation 等参数。

@Query(sort: \Article.title, order: .forward) private var articles: [Article]

sort 可支持多个 SortDescriptor,SwiftData 会按顺序处理。

@Query(sort: [SortDescriptor(\Article.isArchived, order: .forward),SortDescriptor(\Article.updateDate, order: .reverse)]) var articles: [Article]

Predicate

filter 使用的是 #Predicate

static var now: Date { Date.now }@Query(filter: #Predicate<Article> { article in    article.releaseDate > now}) var draftArticles: [Article]

Predicate 支持的内置方法主要有 containsallSatisfyflatMapfiltersubscriptstartsminmaxlocalizedStandardContainslocalizedComparecaseInsensitiveCompare 等。

@Query(filter: #Predicate<Article> { article in    article.title.starts(with: "苹果发布会")}) var articles: [Article]

需要注意的是 .isEmpty 不能使用 article.title.isEmpty == false ,否则会崩溃。

FetchDescriptor

FetchDescriptor 可以在模型中查找数据,而不必在视图层做。

@Modelfinal class Article {    var title: String    ...    static var all: FetchDescriptor<Article> {        FetchDescriptor(sortBy: [SortDescriptor(\Article.updateDate, order: .reverse)])    }}struct SomeView: View {       @Query(Article.all) private var articles: [Article]    ...}

获取数量而不加载

使用 fetchCount() 方法,可完成整个计数,且很快,内存占用少。

let descriptor = FetchDescriptor<Article>(predicate: #Predicate { $0.words > 50 })let count = (try? modelContext.fetchCount(descriptor)) ?? 0

fetchLimit 限制获取数量

var descriptor = FetchDescriptor<Article>(  predicate: #Predicate { $0.read },  sortBy: [SortDescriptor(\Article.updateDate,           order: .reverse)])descriptor.fetchLimit = 30let articles = try context.fetch(descriptor)// 翻页let pSize = 30let pNumber = 1var fetchDescriptor = FetchDescriptor<Article>(sortBy: [SortDescriptor(\Article.updateDate, order: .reverse)])fetchDescriptor.fetchOffset = pNumber * pSizefetchDescriptor.fetchLimit = pSize

限制获取的属性

只请求要用的属性

var fetchDescriptor = FetchDescriptor<Article>(sortBy: [SortDescriptor(\.updateDate, order: .reverse)])fetchDescriptor.propertiesToFetch = [\.title, \.updateDate]

SwiftData-处理大量数据

SwiftData 模型上下文有个方法叫 enumerate(),可以高效遍历大量数据。

let descriptor = FetchDescriptor<Article>()...do {    try modelContext.enumerate(descriptor, batchSize: 1000) { article in        ...    }} catch {    print("Failed.")}

其中 batchSize 参数是调整批量处理的数量,也就是一次加载多少对象。因此可以通过这个值来权衡内存和IO数量。这个值默认是 5000。

SwiftData多线程

创建一个 Actor,然后 SwiftData 上下文在其中执行操作。

@ModelActoractor DataHandler {}extension DataHandler {    func addInfo() throws -> IOInfo {        let info = IOInfo()        modelContext.insert(info)        try modelContext.save()        return info    }    ...}

使用

Task.detached {    let handler = DataHandler()    let item = try await handler.addInfo()       ...}

SwiftData-版本迁移

以下的小改动 SwiftData 会自动执行轻量迁移:

  • 增加模型
  • 增加有默认值的新属性
  • 重命名属性
  • 删除属性
  • 增加或删除 .externalStorage.allowsCloudEncryption 属性。
  • 增加所有值都是唯一属性为 .unique
  • 调整关系的删除规则

其他情况需要用到版本迁移,版本迁移步骤如下:

  • 用 VersionedSchema 创建 SwiftData 模型的版本
  • 用 SchemaMigrationPlan 对创建的版本进行排序
  • 为每个迁移定义一个迁移阶段

设置版本

enum ArticleV1Schema: VersionedSchema {    static var versionIdentifier: String? = "v1"    static var models: [any PersistentModel.Type] { [Article.self] }    @Model    final class Article {        ...    }}

SchemaMigrationPlan 轻量迁移

enum ArticleMigrationPlan: SchemaMigrationPlan {    static var schemas: [any VersionedSchema.Type] {        [ArticleV1Schema.self, ArticleV2Schema.self]    }    static var stages: [MigrationStage] {        [migrateV1toV2]    }    static let migrateV1toV2 = MigrationStage.lightweight(        fromVersion: ArticleV1Schema.self,        toVersion: ArticleV2Schema.self    )}

自定义迁移

static let migrateV1toV2 = MigrationStage.custom(    fromVersion: ArticleV1Schema.self,    toVersion: ArticleV2Schema.self,    willMigrate: { context in        // 合并前的处理    },    didMigrate: { context in        // 合并后的处理    })

SwiftData-调试

CoreData 的调试方式依然适用于 SwiftData。

你可以设置启动参数来让 CoreData 打印出执行的 SQL 语句。在你的项目中,选择 “Product” -> “Scheme” -> “Edit Scheme”,然后在 “Arguments” 标签下的 “Arguments Passed On Launch” 中添加 -com.apple.CoreData.SQLDebug 1。这样,每当 CoreData 执行 SQL 语句时,都会在控制台中打印出来。

使用 -com.apple.CoreData.SQLDebug 3 获取后台更多信息。

SwiftData-资料

WWDC

23

使用 SwiftUI 开发 RSS 阅读器

作者 戴铭
2023年4月24日 15:51

在 Apple 加速器活动和字节内分享了使用 SwiftUI 做 RSS 阅读器的一点心得。可能你还不知道什么是 RSS 阅读器,简单来说 RSS 是一些博客和新闻网站,甚至是播客和视频平台发布他们的内容更新的一种 XML 格式,阅读器就是通过请求这个 XML 以获取他们内容更新的客户端。

这就有了接下来几个问题:

目前已有 Reeder 和 NetNewsWire 等 RSS 阅读器,那么为什么还要再开发一个呢,早在14年我曾做过一个,陆续也更新过,后来还是以 Reeder 作为主力,feedly 作为服务,后来 feedly 有些不稳定,我又改成本地获取 feed 的方式,但是改成本地模式后设备同步又成了问题。正好最近几年苹果在界面、数据流和存储上都做了很大的功能加强。于是我打算将以前 objc、rac和 FMDB 替换成 SwiftUI 和 CoreData 技术,同时补上以前缺少的一些功能,比如添加管理feed,不同设备同步订阅 feed、文章已读状态和收藏信息等功能。

先说下怎么订阅 RSS。

如上图所示先通过链接获取待解析的数据,以及 mimeType,通过 mimeType 看里面是否包含如下描述:

application/atom+xmlapplication/rss+xmlapplication/jsonapplication/feed+jsontext/xmlapplication/xml

包含的话就可以判断是 RSS。

如果不是的话就需要手动从网页里获取 RSS 的链接,方法如下:

mime.contains("text/html")SwiftSoup.parse(homepageHTML)htmlDom.select("link[rel=alternate]")

其中 SwiftSoup 是一个专门用来将 HTML 解析成 DOM 对象的库。一般 RSS 的链接会在属性键值是 rel 和 alternate 的 Link 这个标签里。但是很多网站并没有遵循这个规范,那么就需要在链接后直接通过添加以下文件名来查找哪个是它的 RSS 链接:

["feed.xml","rss.xml","atom.xml","feed","feed.rss","rss","index.xml"]

找到了 RSS 的链接就可以获取到它的数据,接下来就是对数据的处理,根据 RSS 的规范,RSS 的数据主要是以下三种。

对应的结构体如下:

RSS 的图标的获取方式有两种

对处理好的数据需要进行本地的存储,目前不管是 Apple 还是三方库主要都是基于 SQLite 的封装。估计是因为 SQLite 开销小,支持大多数 SQL 92 标准语法,采用标准的 ANSI-C 代码,很容易在多个平台运行,同时 SQLite 还支持所有 SQL 用来保障数据安全和完整性的事务属性,比如原子性、一致性、隔离性和持久性。以下是 iOS 上一些主要基于 SQLite 封装库:

我选择的是 Core Data,首先是 Core Data 的 API 很强,将复杂数据建模和操作的 SQL 语句都做成了可视化和对象模式操作。多个数据对象之间的关联关系也做了很多自动处理。Core Data 还使用了惰性加载的方式,只有在需要时才从存储区域获取数据,以节省内存,提高执行效率。

Core Data 的使用需要对数据库进行设置。

在读取实体存储时可以设置 Core Spotlight 以及进行一些调试测试工作。

Core Data 对数据的增删改和检索操作都是在 NSManagerObjectContext 中完成的。

如果要支持 CloudKit,NSManagerObjectContext 初始化时需要在合并策略做一些设置。context 的数据操作都是基于对象操作的方式,比如增加一个 feed 就是在 context 中创建一个 feed 的对象,然后对其字段对应的属性进行设置即可。

删除就是用 context 的 delete 方法将对要删除数据对应的对象进行删除即可。

修改就是对读取的对象进行设置。

检索有两种方式,一种是创建一个 Controller,使用 lazy 来修饰检索检索结果,惰性加载以节省内存。数据变化会在 NSFetchedResultsController 代理里进行回调,在回调里可以更新 @Published 属性包装的属性以及时同步展示更新的数据。

另一种检索方式是使用 @FetchRequest 属性包装,写法更加简洁。

下面是 RSS 数据操作对应的代码。

添加 Feed 的代码

let newFeed = WebFeedMO(context: stack.context)newFeed.id = UUID()newFeed.createAt = Date.nownewFeed.homePageURL = inputURLstack.save()await handleAFeed(webFeed: newFeed) // 文章

删除 Feed

for a in webFeed.allElements {    stack.context.delete(a)}stack.deleteWebFeed(webFeed)

检索 Feed 列表

let fetch = WebFeedMO.fetchRequest()let sortDescriptorUnreadCount = NSSortDescriptor(key: "unreadCount", ascending: false)let sortDescriptorCreateAt = NSSortDescriptor(key: "createAt", ascending: false)fetch.sortDescriptors = [sortDescriptorUnreadCount, sortDescriptorCreateAt]let controller = NSFetchedResultsController(fetchRequest: fetch, managedObjectContext: stack.context, sectionNameKeyPath: nil, cacheName: "webFeeds")controller.delegate = selftry? controller.performFetch()

Feed 里文章的列表检索

let fetch = ArticleMO.fetchRequest()let sortDescriptor = NSSortDescriptor(key: "datePublished", ascending: false)fetch.sortDescriptors = [sortDescriptor]let controller = NSFetchedResultsController(fetchRequest: fetch, managedObjectContext: stack.context, sectionNameKeyPath: nil, cacheName: "newArticles")controller.delegate = selftry? controller.performFetch()

标记已读

@Published var selectedArticle: ArticleMO? {    willSet(newValue) {        newValue?.read = true        selectedWebFeed?.countUnreadArticles()    }}

全部标记已读

let countElement = selectedWebFeed?.allElements.count ?? 0var index = 0for a in selectedWebFeed?.allElements ?? [] {    index += 1    if a.read == false {        a.read = true    }    if countElement > 1000 && index > 1000 && a.favourite == false {        stack.context.delete(a)    }}// 最后重置未读总数selectedWebFeed?.countUnreadArticles()

收藏状态的切换直接对布尔属性 favourite 执行 toggle 方法。

selectedArticle?.favourite.toggle()selectedArticle?.dateModified = Date.now

工具栏中的分享功能可以直接使用 SwiftUI 内置的 ShareLink 视图。Item 的 placement 对于不同平台的位置会有不同。

.toolbar {    ToolbarItemGroup(placement: .primaryAction) {        Menu {            Button { ... } label: {                Label("拷贝链接", systemImage: "doc.on.doc")            }            Divider()            ShareLink("分享", item: link)        } label: {            Image(systemName: "square.and.arrow.up")        }    }    ToolbarItemGroup(placement: .automatic) {        Button { ... } label: {            Label("收藏", systemImage: "star")        }        Button { ... } label: {            Label("浏览器", systemImage: "safari")        }    }} // end toolbar

由于网站提供的 RSS 是静态的,因此每次获取数据时需要进行和本地存储的数据进行比对去重。

Core Data 提供了一种通过简单配置约束就可以去重的方法。具体方法如上图所示。

但是如果要支持 iCloud 就没法使用唯一约束这个功能。因此只能回到老办法,手动比对。

为了提升大量数据添加的效率,可以使用 NSBatchInsertRequest。正常情况下,在使用 Core Data 进行大量数据插入时,应用程序需要为每个插入操作都创建上下文和执行请求。这样会导致上下文过度膨胀和查询操作的重复,并且会对内存和 CPU 带来负担。而 NSBatchInsertRequest 则能够通过批量插入的方式一次性将多条数据插入到 Core Data 中,并且执行速度要比逐条插入要快得多。NSBatchInsertRequest 实际上是在底层利用 SQLite 数据库的 INSERT INTO 语法来执行批量插入操作。这种方式通过一次性将数据提交给 SQLite,可以减少插入操作所需的检查、协调和锁定操作,从而提高插入操作的效率和性能。当使用 NSBatchInsertRequest 执行批量插入时,Core Data 会首先创建一个临时表,然后将待插入的数据全部插入到该临时表中。接着,Core Data 会使用关联操作将临时表中的数据一次性插入到实际的数据库表中,从而进一步提高了数据插入的效率。NSBatchInsertRequest 还提供了一些可用的参数设置选项,开发者可以根据具体的需求进行灵活配置。例如,通过设置 batchSize 参数,可以控制批量插入时每个批次所包含的最大行数,以避免内存的过度消耗;通过设置 propertiesToUpdate 参数,可以在批量插入后更新指定的属性值,从而避免对整个对象进行额外的查询和更新操作。

Core Data 里的数据可以通过 iCloud 实现多设备的同步,比如我在 macOS 上订阅、阅读和收藏的信息能够无缝切换到手机和 iPad 上。未来支持 iCloud 可以进行如下的设置:

支持 iCloud 也会有一些限制,对于我目前来说最大限制就是不支持唯一约束,另外数据表结构更改后老版本的兼容也是需要注意的,这是由于 iCloud 是云端数据统一传输,并不会兼容多版本。

通过以下方法可以让兼容合并更安全。

应用支持 iCloud 后会有 cloudd 这个后台进程对 iCloud 服务的同步和管理,定期检查 iCloud 上数据是否需要同步到本地设备,或者本地数据是否需要传到 iCloud。 apsd 进程会将数据的更新以通知的方式推送到其他设备,dasd 进程会对 iCloud 的数据进行处理然后交给应用进程。对这个流程的调试就是基于上面提到的这四个进程进行日志记录。

另外 Core Data 还支持一些调试参数,除了 iCloud 还可以支持多线程、SQL、合并等信息的日志打印。

为了节省 iCloud 空间大小,对于文章内容这样数据量大的数据就不用支持 iCloud 了,方法是如下:

另外,Core Data 里的数据还能够很容易的支持 spotlight 索引,方便在应用外能够被检索。

界面使用的是 NavigationSplitView。代码如下:

struct HomeThreeColumnView: View {    @EnvironmentObject var webFeedController: WebFeedController    var body: some View {        NavigationSplitView {            SidebarView() // 左侧频道列表        } content: {            AWebFeedArticlesView() // 文章列表        } detail: {            ArticleWebView() // 文章内容        }    } // end body}

NavigationSplitView 可以同时显示主视图和辅助视图。实现了 iOS 系统中常见的 iPad 多窗口布局模式,允许用户同时操作两个视图,提高了应用程序的多任务处理能力和用户体验。NavigationSplitView 提供了一组简洁易用的 API,开发者可以通过少量的代码实现大部分常见的多窗口布局需求。例如,只需要设置主视图和辅助视图的内容即可快速创建一个 NavigationSplitView,而无需手动管理视图控制器的层次结构。NavigationSplitView 还支持自定义视图拆分行为、边缘滑动手势等功能。

数据处理,包括 Core Data 的初始化配置和增删改和检索等我都放在了 Controller 里,Controller 的关键代码如下:

final class WebFeedController: NSObject ,ObservableObject {    @Published var selectedWebFeed: WebFeedMO?    @Published var selectedArticle: ArticleMO?        @Published private(set) var webFeeds: [WebFeedMO] = []    @Published private(set) var newArticles: [ArticleMO] = [] // 最新文章    @Published private(set) var favoriteArticles: [ArticleMO] = [] // 收藏的文章    var stack: NRCDStack        init(stack: NRCDStack) {        ...        webFeeds = fetchedResults.fetchedObjects ?? []        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }        // 获取所有 feed 源    lazy var fetchedResults: NSFetchedResultsController<WebFeedMO> = { ... }()    // 获取最新 article    lazy var fetchedNewArticlesResults: NSFetchedResultsController<ArticleMO> = { ... }()    // 获取收集 article    lazy var fetchFavoriteArticlesResults: NSFetchedResultsController<ArticleMO> = { ... }()}// MARK: - NSFetchedResultsControllerDelegate// 跟踪变化,在回调中处理。extension WebFeedController: NSFetchedResultsControllerDelegate {    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {        webFeeds = fetchedResults.fetchedObjects ?? []        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }}// MARK: - 数据 CRUD 操作extension WebFeedController {    // 更新全部 Feed    func updateAllFeeds() async { ... }        // MARK: - Feed 的操作    // 添加 Feed    @discardableResult    func createFeed(inputURL: String, nameForDisplay: String = "") -> WebFeedMO { ... }    // 删除操作,删掉一个 Feed    func deleteWebFeed(_ webFeed: WebFeedMO) {        stack.deleteWebFeed(webFeed)    }        // 更新    func updateFeedByModel(for webFeed: WebFeedMO, model: FeedModel) { ... }        // MARK: - 文章的操作    // 收藏的文章    func fetchFavoriteArticles() {        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }        // 最新文章    func fetchNewArticles() {        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []    }        // 收藏    func favoriteArticle() {        selectedArticle?.favourite.toggle()        selectedArticle?.dateModified = Date.now    }        // 清空所选 feed 下所有文章    func deleteAll() { ... }        // 标记全部已读    func markAllAsRead() { ... }        // 新增文章    func createArticleByModel(for webFeed: WebFeedMO, model: ArticleModel) async { ... } }

应用最终效果如下图:

小册子之 Form、Picker、Toggle、Slider 和 Stepper 表单相关视图

作者 戴铭
2024年5月18日 10:24

以下内容已整理到小册子中,小册子代码在 Github 上,可以在 macOS 应用商店安装“戴铭的开发小册子”应用查看。

Form

控件视图 说明 Style
Button 触发操作的按钮 .bordered, .borderless, .borderedProminent, .plain
Picker 提供多选项供选择 .wheel, .inline, .segmented, .menu, .radioGroup
DatePicker and MultiDatePicker 选择日期的工具 .compact, .wheel, .graphical
Toggle 切换两种状态的开关 .switch, .botton, .checkbox
Stepper 调整数值的步进器 无样式选项
Menu 显示选项列表的菜单 .borderlessButton, .button

Form 有 ColumnFormStyle 还有 GroupedFormStyle。使用 buttonStyle 修饰符:

Form {   ...}.formStyle(.grouped)

Form 新版也得到了增强,示例如下:

struct SimpleFormView: View {    @State private var date = Date()    @State private var eventDescription = ""    @State private var accent = Color.red    @State private var scheme = ColorScheme.light    var body: some View {        Form {            Section {                DatePicker("Date", selection: $date)                TextField("Description", text: $eventDescription)                    .lineLimit(3)            }                        Section("Vibe") {                Picker("Accent color", selection: $accent) {                    ForEach(Color.accentColors, id: \.self) { color in                        Text(color.description.capitalized).tag(color)                    }                }                Picker("Color scheme", selection: $scheme) {                    Text("Light").tag(ColorScheme.light)                    Text("Dark").tag(ColorScheme.dark)                }            }        }        .formStyle(.grouped)    }}extension Color {    static let accentColors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]}

Form 的样式除了 .formStyle(.grouped) 还有 .formStyle(..columns)

关于 Form 字体、单元、背景颜色设置,参看下面代码:

struct ContentView: View {    @State private var movieTitle = ""    @State private var isWatched = false    @State private var rating = 1    @State private var watchDate = Date()    var body: some View {        Form {            Section {                TextField("电影标题", text: $movieTitle)                LabeledContent("导演", value: "克里斯托弗·诺兰")            } header: {                Text("关于电影")            }            .listRowBackground(Color.gray.opacity(0.1))            Section {                Toggle("已观看", isOn: $isWatched)                Picker("评分", selection: $rating) {                    ForEach(1...5, id: \.self) { number in                        Text("\(number) 星")                    }                }            } header: {                Text("电影详情")            }            .listRowBackground(Color.gray.opacity(0.1))                        Section {                DatePicker("观看日期", selection: $watchDate)            }            .listRowBackground(Color.gray.opacity(0.1))                        Section {                Button("重置所有电影数据") {                    resetAllData()                }            }            .listRowBackground(Color.white)        }        .foregroundColor(.black)        .tint(.indigo)        .background(Color.yellow)        .scrollContentBackground(.hidden)        .navigationBarTitle("电影追踪器")    }        private func resetAllData() {        movieTitle = ""        isWatched = false        rating = 1        watchDate = Date()    }}struct LabeledContent: View {    let label: String    let value: String    init(_ label: String, value: String) {        self.label = label        self.value = value    }    var body: some View {        HStack {            Text(label)            Spacer()            Text(value)        }    }}

Picker选择器

Picker

SwiftUI 中的 Picker 视图是一个用于选择列表中的一个选项的用户界面元素。你可以使用 Picker 视图来创建各种类型的选择器,包括滚动选择器、弹出菜单和分段控制。

示例代码如下:

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()    }}

上面的代码中,有三种类型的 Picker 视图:

  1. 默认的下拉风格 Picker 视图。这种类型的 Picker 视图在 Form 中使用,用户可以点击选择器来打开一个下拉菜单,然后从菜单中选择一个选项。
Form {    Section("选区") {        Picker("选一个", selection: $select) {            Text("1")                .tag(1)            Text("2")                .tag(2)        }    }}
  1. 分段控制风格 Picker 视图。这种类型的 Picker 视图使用 SegmentedPickerStyle() 修饰符,它将选择器显示为一组水平排列的按钮,用户可以点击按钮来选择一个选项。
Picker("选一个", selection: $select) {    Text("one")        .tag(1)    Text("two")        .tag(2)}.pickerStyle(SegmentedPickerStyle())
  1. ColorPickerDatePicker 视图。这两种类型的视图是 Picker 视图的特殊形式,它们分别用于选择颜色和日期。
ColorPicker("选一个颜色", selection: $color, supportsOpacity: false)DatePicker("选时间", selection: $date)    .datePickerStyle(GraphicalDatePickerStyle())

在所有这些 Picker 视图中,你都需要提供一个绑定的选择状态,这个状态会在用户选择一个新的选项时更新。你还需要为每个选项提供一个视图和一个唯一的标签。

文字Picker

基本使用

文字 Picker 示例:

struct StaticDataPickerView: View {    @State private var selectedCategory = "动作"    var body: some View {        VStack {            Text("选择的类别: \(selectedCategory)")            Picker("电影类别",                 selection: $selectedCategory) {                Text("动作")                    .tag("动作")                Text("喜剧")                    .tag("喜剧")                Text("剧情")                    .tag("剧情")                Text("恐怖")                    .tag("恐怖")            }        }    }}

使用枚举

使用枚举来创建选取器的示例:

enum MovieCategory: String, CaseIterable, Identifiable {    case action = "动作"    case comedy = "喜剧"    case drama = "剧情"    case horror = "恐怖"    var id: MovieCategory { self }}struct MoviePicker: View {   @State private var selectedCategory: MovieCategory = .action  var body: some View {     Picker("电影类别", selection: $selectedCategory) {        ForEach(MovieCategory.allCases) { category in             Text(category.rawValue).tag(category)       }     }   }}

样式

SwiftUI 提供了多种内置的 Picker 样式,以改变 Picker 的外观和行为。以下是一些主要的 Picker 样式及其使用示例:

  • DefaultPickerStyle:根据平台和环境自动调整样式。这是默认的 Picker 样式。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}
  • WheelPickerStyle:以旋转轮的形式展示选项。在 iOS 上,这种样式会显示一个滚动的选择器。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(WheelPickerStyle())
  • SegmentedPickerStyle:将选项以分段控件的形式展示。这种样式会显示一个分段控制,用户可以在其中选择一个选项。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(SegmentedPickerStyle())
  • InlinePickerStyle:在列表或表格中内联展示选项。这种样式会在 FormList 中显示一个内联的选择器。
Form {    Picker("Label", selection: $selection) {        ForEach(0..<options.count) {            Text(self.options[$0])        }    }    .pickerStyle(InlinePickerStyle())}
  • MenuPickerStyle:点击时以菜单的形式展示选项。这种样式会显示一个菜单,用户可以在其中选择一个选项。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(MenuPickerStyle())
  • .navigationLink:在 iOS 16+ 中,点击后进入下一个页面。这种样式会显示一个导航链接,用户可以点击它来打开一个新的视图。
  • .radioGrouped:仅在 macOS 中可用,以单选按钮组的形式展示选项。这种样式会显示一个单选按钮组,用户可以在其中选择一个选项。

ColorPicker

ColorPicker 是一个允许用户选择颜色的视图。以下是一个 ColorPicker 的使用示例:

import SwiftUIstruct ContentView: View {    @State private var selectedColor = Color.white    var body: some View {        VStack {            ColorPicker("选择一个颜色", selection: $selectedColor)            Text("你选择的颜色")                .foregroundColor(selectedColor)        }    }}

在这个示例中,我们创建了一个 ColorPicker 视图,用户可以通过这个视图选择一个颜色。我们使用 @State 属性包装器来创建一个可以绑定到 ColorPickerselectedColor 状态。当用户选择一个新的颜色时,selectedColor 状态会自动更新,Text 视图的前景色也会相应地更新。

DatePicker

基本使用

struct ContentView: View {    @State private var releaseDate: Date = Date()    var body: some View {        VStack(spacing: 30) {            DatePicker("选择电影发布日期", selection: $releaseDate, displayedComponents: .date)            Text("选择的发布日期: \(releaseDate, formatter: DateFormatter.dateMedium)")        }        .padding()    }}

选择多个日期

在 iOS 16 中,您现在可以允许用户选择多个日期,MultiDatePicker 视图会显示一个日历,用户可以选择多个日期,可以设置选择范围。示例如下:

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()    }}

指定日期范围

指定日期的范围,例如只能选择当前日期之后的日期,示例如下:

DatePicker(    "选择日期",    selection: $selectedDate,    in: Date()...,    displayedComponents: [.date]).datePickerStyle(WheelDatePickerStyle()).labelsHidden()

在这个示例中:

  • selection: $selectedDate 表示选定的日期和时间。
  • in: Date()... 表示可选日期的范围。在这个例子中,用户只能选择当前日期之后的日期。你也可以使用 ...Date() 来限制用户只能选择当前日期之前的日期,或者使用 Date().addingTimeInterval(86400*7) 来限制用户只能选择从当前日期开始的接下来一周内的日期。
  • displayedComponents: [.date] 表示 DatePicker 应该显示哪些组件。在这个例子中,我们只显示日期组件。你也可以使用 .hourAndMinute 来显示小时和分钟组件,或者同时显示日期和时间组件。
  • .datePickerStyle(WheelDatePickerStyle()) 表示 DatePicker 的样式。在这个例子中,我们使用滚轮样式。你也可以使用 GraphicalDatePickerStyle() 来应用图形样式。
  • .labelsHidden() 表示隐藏 DatePicker 的标签。

PhotoPicker

PhotoPicker 使用示例

import SwiftUIimport PhotosUIstruct ContentView: View {    @State private var selectedItem: PhotosPickerItem?    @State private var selectedPhotoData: Data?    var body: some View {        NavigationView {            VStack {                if let item = selectedItem, let data = selectedPhotoData, let image = UIImage(data: data) {                    Image(uiImage: image)                        .resizable()                        .scaledToFit()                } else {                    Text("选择电影海报")                }            }            .navigationTitle("电影海报")            .toolbar {                ToolbarItem(placement: .navigationBarTrailing) {                    PhotosPicker(selection: $selectedItem, matching: .images) {                        Label("选择照片", systemImage: "photo")                    }                    .tint(.indigo)                    .controlSize(.extraLarge)                    .buttonStyle(.borderedProminent)                }            }            .onChange(of: selectedItem, { oldValue, newValue in                Task {                    if let data = try? await newValue?.loadTransferable(type: Data.self) {                        selectedPhotoData = data                    }                }            })        }    }}

限制选择媒体类型

我们可以使用 matching 参数来过滤 PhotosPicker 中显示的媒体类型。这个参数接受一个 PHAssetMediaType 枚举值,可以是 .images.videos.audio.any 等。

例如,如果我们只想显示图片,可以这样设置:

PhotosPicker(selection: $selectedItem, matching: .images) {    Label("选择照片", systemImage: "photo")}

如果我们想同时显示图片和视频,可以使用 .any(of:) 方法:

PhotosPicker(selection: $selectedItem, matching: .any(of: [.images, .videos])) {    Label("选择照片", systemImage: "photo")}

此外,我们还可以使用 .not(_:) 方法来排除某种类型的媒体。例如,如果我们想显示所有的图片,但是不包括 Live Photo,可以这样设置:

PhotosPicker(selection: $selectedItem, matching: .any(of: [.images, .not(.livePhotos)])) {    Label("选择照片", systemImage: "photo")}

这些设置可以让我们更精确地控制 PhotosPicker 中显示的媒体类型。

选择多张图片

以下示例演示了如何使用 PhotosPicker 选择多张图片,并将它们显示在一个 LazyVGrid 中:

import SwiftUIimport PhotosUIstruct ContentView: View {    @State private var selectedItems: [PhotosPickerItem] = [PhotosPickerItem]()    @State private var selectedPhotosData: [Data] = [Data]()    var body: some View {        NavigationStack {            ScrollView {                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {                    ForEach(selectedPhotosData, id: \.self) { photoData in                        if let image = UIImage(data: photoData) {                            Image(uiImage: image)                                .resizable()                                .scaledToFit()                                .cornerRadius(10.0)                                .padding(.horizontal)                        }                    }                }            }            .navigationTitle("书籍")            .toolbar {                ToolbarItem(placement: .navigationBarTrailing) {                    PhotosPicker(selection: $selectedItems, maxSelectionCount: 5, matching: .images) {                        Image(systemName: "book.fill")                            .foregroundColor(.brown)                    }                    .onChange(of: selectedItems, { oldValue, newValue in                        for newItem in newValue {                            Task {                                if let data = try? await newItem.loadTransferable(type: Data.self) {                                    selectedPhotosData.append(data)                                }                            }                        }                    })                }            }        }    }}

以上示例中,我们使用了 PhotosPickermaxSelectionCount 参数来限制用户最多只能选择 5 张图片。当用户选择图片后,我们将图片数据保存在 selectedPhotosData 数组中,并在 LazyVGrid 中显示这些图片。

字体Picker

这段代码实现了一个字体选择器的功能,用户可以在其中选择和查看自己喜欢的字体。

struct ContentView: View {    @State private var fontFamily: String = ""    var body: some View {        VStack {            Text("选择字体:")            FontPicker(fontFamily: $fontFamily)                .equatable()        }    }}struct FontPicker: View, Equatable {    @Binding var fontFamily: String    var body: some View {        VStack {            Text("\(fontFamily)")                .font(.custom(fontFamily, size: 20))            Picker("", selection: $fontFamily) {                ForEach(NSFontManager.shared.availableFontFamilies, id: \.self) { family in                    Text(family)                        .tag(family)                }            }            Spacer()        }        .padding()    }    static func == (l: FontPicker, r: FontPicker) -> Bool {        l.fontFamily == r.fontFamily    }}

WheelPicker

本示例是一个可折叠的滚轮选择器 CollapsibleWheelPicker。这个选择器允许用户从一组书籍中选择一本。

struct ContentView: View {  @State private var selection = 0  let items = ["Book 1", "Book 2", "Book 3", "Book 4", "Book 5"]  var body: some View {    NavigationStack {      Form {        CollapsibleWheelPicker(selection: $selection) {          ForEach(items, id: \.self) { item in            Text("\(item)")          }        } label: {          Text("Books")          Spacer()          Text("\(items[selection])")        }      }    }  }}struct CollapsibleWheelPicker<SelectionValue, Content, Label>: View where SelectionValue: Hashable, Content: View, Label: View {    @Binding var selection: SelectionValue    @ViewBuilder let content: () -> Content    @ViewBuilder let label: () -> Label    var body: some View {        CollapsibleView(label: label) {            Picker(selection: $selection, content: content) {                EmptyView()            }            .pickerStyle(.wheel)        }    }}struct CollapsibleView<Label, Content>: View where Label: View, Content: View {  @State private var isSecondaryViewVisible = false  @ViewBuilder let label: () -> Label  @ViewBuilder let content: () -> Content  var body: some View {    Group {      Button(action: { isSecondaryViewVisible.toggle() }, label: label)        .buttonStyle(.plain)      if isSecondaryViewVisible {        content()      }    }  }}

ContentView 中,我们创建了一个 CollapsibleWheelPicker 视图。这个视图包含一个滚轮样式的选择器,用户可以从中选择一本书。选择的书籍会绑定到 selection 变量。

CollapsibleWheelPicker 视图是一个可折叠的滚轮选择器,它接受一个绑定的选择变量、一个内容视图和一个标签视图。内容视图是一个 Picker 视图,用于显示可供选择的书籍。标签视图是一个 Text 视图,显示当前选择的书籍。

Toggle

示例

使用示例如下

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()                }        }    }}

样式

Toggle 可以设置 toggleStyle,可以自定义样式。

下表是不同平台支持的样式

  • DefaultToggleStyle:iOS 表现的是 Switch,macOS 是 Checkbox
  • SwitchToggleStyle:iOS 和 macOS 都支持
  • CheckboxToggleStyle:只支持 macOS

纯图像的 Toggle

struct ContentView: View {    @State private var isMuted = false    var body: some View {        Toggle(isOn: $isMuted) {            Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.fill")                .font(.system(size: 50))        }        .tint(.red)        .toggleStyle(.button)        .clipShape(Circle())    }}

自定义 ToggleStyle

做一个自定义的切换按钮 OfflineModeToggleStyle。这个切换按钮允许用户控制是否开启离线模式。代码如下:

struct ContentView: View {    @State private var isOfflineMode = false    var body: some View {        Toggle(isOn: $isOfflineMode) {            Text("Offline Mode")        }        .toggleStyle(OfflineModeToggleStyle(systemImage: isOfflineMode ? "wifi.slash" : "wifi", activeColor: .blue))    }}struct OfflineModeToggleStyle: ToggleStyle {    var systemImage: String    var activeColor: Color    func makeBody(configuration: Configuration) -> some View {        HStack {            configuration.label            Spacer()            RoundedRectangle(cornerRadius: 16)                .fill(configuration.isOn ? activeColor : Color(.systemGray5))                .overlay {                    Circle()                        .fill(.white)                        .padding(2)                        .overlay {                            Image(systemName: systemImage)                                .foregroundColor(configuration.isOn ? activeColor : Color(.systemGray5))                        }                        .offset(x: configuration.isOn ? 8 : -8)                }                .frame(width: 50, height: 32)                .onTapGesture {                    withAnimation(.spring()) {                        configuration.isOn.toggle()                    }                }        }    }}

以上代码中,我们定义了一个 OfflineModeToggleStyle,它接受两个参数:systemImage 和 activeColor。systemImage 是一个字符串,表示图像的系统名称。activeColor 是一个颜色,表示激活状态的颜色。

动画化的 Toggle

以下是一个自定义的切换按钮 MuteToggleStyle。这个切换按钮允许用户控制是否开启静音模式。

struct ContentView: View {    @State private var isMuted = false    var body: some View {        VStack {            Toggle(isOn: $isMuted) {                Text("Mute Mode")                    .foregroundColor(isMuted ? .white : .black)            }            .toggleStyle(MuteToggleStyle())            .padding()        }        .frame(maxWidth: .infinity, maxHeight: .infinity)    }}struct MuteToggleStyle: ToggleStyle {    var onImage = "speaker.slash.fill"    var offImage = "speaker.2.fill"    func makeBody(configuration: Configuration) -> some View {        HStack {            configuration.label            Spacer()            RoundedRectangle(cornerRadius: 30)                .fill(configuration.isOn ? Color(.systemGray6) : .yellow)                .overlay {                    Image(systemName: configuration.isOn ? onImage : offImage)                        .resizable()                        .scaledToFit()                        .clipShape(Circle())                        .padding(5)                        .rotationEffect(.degrees(configuration.isOn ? 0 : 180))                        .offset(x: configuration.isOn ? 10 : -10)                }                .frame(width: 50, height: 32)                .onTapGesture {                    withAnimation(.easeInOut(duration: 0.2)) {                        configuration.isOn.toggle()                    }                }        }    }}extension ToggleStyle where Self == MuteToggleStyle {    static var mute: MuteToggleStyle { .init() }}

以上代码中,我们定义了一个 MuteToggleStyle,它接受两个参数:onImage 和 offImage。onImage 是一个字符串,表示激活状态的图像的系统名称。offImage 是一个字符串,表示非激活状态的图像的系统名称。

两个标签的 Toggle

以下是一个自定义的切换按钮,它有两个标签。这个切换按钮允许用户控制是否开启静音模式。

Toggle(isOn: $mute) {  Text("静音")  Text("这将关闭所有声音")}

Slider

简单示例

struct PlaySliderView: View {    @State var count: Double = 0    var body: some View {        Slider(value: $count, in: 0...100)            .padding()        Text("\(Int(count))")    }}

以下代码演示了如何创建一个自定义的 Slider 控件,用于调整亮度。

struct ContentView: View {    @State private var brightness: Double = 50    @State private var isEditing: Bool = false    var body: some View {        VStack {            Text("Brightness Control")                .font(.title)                .padding()            BrightnessSlider(value: $brightness, range: 0...100, step: 5, isEditing: $isEditing)            Text("Brightness: \(Int(brightness)), is changing: \(isEditing)")                .font(.footnote)                .padding()        }    }}struct BrightnessSlider: View {    @Binding var value: Double    var range: ClosedRange<Double>    var step: Double    @Binding var isEditing: Bool    var body: some View {        Slider(value: $value, in: range, step: step) {            Label("亮度", systemImage: "light.max")        } minimumValueLabel: {            Text("\(Int(range.lowerBound))")        } maximumValueLabel: {            Text("\(Int(range.upperBound))")        } onEditingChanged: {            print($0)        }    }}

以上代码中,我们创建了一个 BrightnessSlider 控件,它是一个自定义的 Slider 控件,用于调整亮度。BrightnessSlider 接受一个 value 绑定,一个 range 范围,一个 step 步长,以及一个 isEditing 绑定。在 BrightnessSlider 中,我们使用 Slider 控件来显示亮度调整器。我们还使用 Label 来显示亮度调整器的标题,并使用 minimumValueLabelmaximumValueLabel 来显示亮度调整器的最小值和最大值。最后,我们使用 onEditingChanged 修饰符来监听亮度调整器的编辑状态。

Stepper

Stepper 控件允许用户通过点击按钮来增加或减少数值。

struct ContentView: View {    @State private var count: Int = 2    var body: some View {        Stepper(value: $count, in: 2...20, step: 2) {            Text("共\(count)")        } onEditingChanged: { b in            print(b)        } // end Stepper    }}

ContentView 中,我们定义了一个状态变量 count,并将其初始化为 2。然后,我们创建了一个 Stepper 视图,并将其绑定到 count 状态变量。

Stepper 视图的值范围为 2 到 20,步进值为 2,这意味着每次点击按钮,count 的值会增加或减少 2。我们还添加了一个标签,显示当前的 count 值。

我们还添加了 onEditingChanged 回调,当 Stepper 的值改变时,会打印出一个布尔值,表示 Stepper 是否正在被编辑。

小册子之详说 Navigation、ViewThatFits、Layout 协议等布局组件

作者 戴铭
2024年5月18日 09:48

以下内容已整理到小册子中,小册子代码在 Github 上,可以在 macOS 应用商店安装“戴铭的开发小册子”应用查看。

Navigation导航

Navigation

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

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

参考:

NavigationStack 的示例:

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 两栏的例子:

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 三栏的例子:

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,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

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

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

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 的自定义,示例如下:

.toolbar(id: "toolbar") {    ToolbarItem(id: "new", placement: .secondaryAction) {        Button(action: {}) {            Label("New Invitation", systemImage: "envelope")        }    }}.toolbarRole(.editor)

以下是废弃的 NavigationView 的用法。

对应代码如下:

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 会出现在弹出的虚拟键盘上。

NavigationStack

使用示例

假设我们有一个 TVShow 结构体和一个 Book 结构体,它们分别包含电视剧和书籍的名字。当用户点击一个电视剧或书籍的名字时,他们会被导航到相应的详细信息页面。

以下是一个例子:

struct TVShow: Hashable {    let name: String}struct Book: Hashable {    let name: String}struct ContentView: View {    @State var tvShows = [TVShow(name: "Game of Thrones"), TVShow(name: "Breaking Bad")]    @State var books = [Book(name: "1984"), Book(name: "To Kill a Mockingbird")]    var body: some View {        NavigationStack {            List {                Section(header: Text("Best TV Shows"))  {                    ForEach(tvShows, id: \.name) { show in                        NavigationLink(value: show, label: {                            Text(show.name)                        })                    }                }                Section(header: Text("Books"))  {                    ForEach(books, id: \.name) { book in                        NavigationLink(value: book, label: {                            Text(book.name)                        })                    }                }            }            .navigationDestination(for: TVShow.self) { show in                TVShowView(show: show)            }            .navigationDestination(for: Book.self) { book in                BookView(book: book)            }            .navigationTitle(Text("Media"))        }    }}struct TVShowView: View {    let show: TVShow    var body: some View {        Text("Details for \(show.name)")    }}struct BookView: View {    let book: Book    var body: some View {        Text("Details for \(book.name)")    }}

全局路由

先写个路由的枚举

enum Route: Hashable {    case all    case add(Book)    case detail(Book)}struct Book {    let name: String    let des: String}

在 App 中设置好全局路由

@mainstruct LearnNavApp: App {    var body: some Scene {        WindowGroup {            NavigationStack {                ContentView()                    .navigationDestination(for: Route.self) { route in                        switch route {                            case .all:                                Text("显示所有图书")                            case .create(let book):                                Text("添加书 \(book.name)")                            case .detail(let book):                                Text("详细 \(book.des)")                        }                    }            }                        }    }}

所有视图都可调用,调用方式如下:

NavigationLink("查看书籍详细说明", value: Route.detail(Book(name: "1984", des: "1984 Detail")))

NavigationPath

NavigationPath 是一个用于管理 SwiftUI 中导航路径的工具。它可以帮助你在 SwiftUI 中实现更复杂的导航逻辑。

在 SwiftUI 中,我们通常使用 NavigationLink 来实现导航。然而,NavigationLink 只能实现简单的前进导航,如果你需要实现更复杂的导航逻辑,例如后退、跳转到任意页面等,你就需要使用 NavigationPath

NavigationPath 的工作原理是,它维护了一个路径数组,每个元素代表一个页面。当你需要导航到一个新的页面时,你只需要将这个页面添加到路径数组中。当你需要后退时,你只需要从路径数组中移除最后一个元素。这样,你就可以实现任意复杂的导航逻辑。

看个例子

假设我们有一个 TVShow 结构体,它包含电视剧的名字。当用户点击一个电视剧的名字时,他们会被导航到这个电视剧的详细信息页面。

struct ContentView: View {    @State private var path = NavigationPath()    @State private var tvShows = [ TVShow(name: "Game of Thrones"), TVShow(name: "Breaking Bad"), TVShow(name: "The Witcher") ]    var body: some View {        NavigationStack(path: $path) {            List {                Text("Select a TV show to get started.")                    .font(.subheadline.weight(.semibold))                ForEach(tvShows, id: \.name) { show in                    NavigationLink(value: show, label: {                        Text(show.name)                            .font(.subheadline.weight(.medium))                    })                }                Button(action: showFriends) {                    Text("This isn't navigation")                }            }            .navigationDestination(for: TVShow.self, destination: { show in                TVShowView(onSelectReset: { popToRoot() }, show: show, otherShows: tvShows)            })            .navigationTitle(Text("Select your show"))        }        .onChange(of: path.count) { oldValue, newValue in            print(newValue)        }    }    func showFriends() {        let show = TVShow(name: "Friends")        path.append(show)    }        func popToRoot() {        path.removeLast(path.count)    }}struct TVShowView: View {    var onSelectReset: () -> Void    var show: TVShow    var otherShows: [TVShow]    var body: some View {        VStack {            Text(show.name)                .font(.title)                .padding(.bottom)            Button(action: onSelectReset) {                Text("Reset Selection")            }            List(otherShows, id: \.name) { otherShow in                Text(otherShow.name)            }        }        .padding()    }}struct TVShow: Hashable {    let name: String    let premiereDate: Date = Date.now    var description: String = "detail"}

代码中,NavigationPath 被用作一个 @State 变量,这意味着它会自动响应变化,并更新视图。当你修改 NavigationPath 中的路径数组时,视图会自动更新,显示新的页面。

NavigationSplitView

以下是一个基于 NavigationSplitView 的三栏视图的示例。这个示例包含了一个主视图,一个次级视图和一个详细视图。

struct ContentView: View {    @State var books: [Book] = [        Book(title: "Book 1", author: "Author 1", description: "Description 1"),        Book(title: "Book 2", author: "Author 2", description: "Description 2"),        Book(title: "Book 3", author: "Author 3", description: "Description 3")    ]    @State var selectedBook: Book?    @State var splitVisibility: NavigationSplitViewVisibility = .all    var body: some View {        NavigationSplitView(columnVisibility: $splitVisibility, sidebar: {            List(books) { book in                Button(action: { selectedBook = book }) {                    Text(book.title)                }            }        }, content: {            if let book = selectedBook {                Text("Author: \(book.author)")            } else {                Text("Select a Book")            }        }, detail: {            if let book = selectedBook {                Text(book.description)            } else {                Text("Book details will appear here")            }        })        .onChange(of: selectedBook) { oldValue, newValue in            //...        }    }}struct Book: Identifiable, Equatable {    var id = UUID()    var title: String    var author: String    var description: String}

示例中,sidebar 是主视图,它显示了一个图书列表。当用户选择一个图书时,content 视图会显示图书的作者,detail 视图会显示图书的详细信息。NavigationSplitView 会根据 splitVisibility 的值来决定显示哪些视图。

自定义导航栏

交互样式

使用 navigationSplitViewStyle(_:) 修饰符

改变标签栏背景色

.toolbarBackground(.yellow.gradient, for: .automatic).toolbarBackground(.visible, for: .automatic)

列宽

navigationSplitViewColumnWidth(_:) 修饰符用于指定列宽。

设置列的最小、最大和理想大小,使用 navigationSplitViewColumnWidth(min:ideal:max:)。可以修饰于不同的列上。

自定返回按钮

先通过修饰符隐藏系统返回按钮 .navigationBarBackButtonHidden(true)。然后通过 ToolbarItem(placement: .navigationBarLeading) 来添加自定义的返回按钮。

struct BookDetailView: View {    var book: Book    @Binding var isDetailShown: Bool    var body: some View {        VStack {            Text(book.title).font(.largeTitle)            Text("Author: \(book.author)").font(.title)            Text(book.description).padding()        }        .navigationBarBackButtonHidden(true)        .navigationTitle(book.title)        .toolbar {            ToolbarItem(placement: .navigationBarLeading) {                Button {                    isDetailShown = false                } label: {                    HStack {                        Image(systemName: "chevron.backward")                        Text("Back to Books")                    }                }            }        }    }}

Inspectors右侧多出一栏

Inspector 的示例

struct Book: Identifiable {    var id = UUID()    var title: String    var author: String    var description: String}struct ContentView: View {    @State var books: [Book] = [        Book(title: "Book 1", author: "Author 1", description: "Description 1"),        Book(title: "Book 2", author: "Author 2", description: "Description 2"),        Book(title: "Book 3", author: "Author 3", description: "Description 3")    ]    @State var selectedBook: Book?    @State var showInspector: Bool = false    @State var splitVisibility: NavigationSplitViewVisibility = .all        var body: some View {        NavigationSplitView(columnVisibility: $splitVisibility, sidebar: {            List(books) { book in                Button(action: { selectedBook = book }) {                    Text(book.title)                }            }        }, content: {            if let book = selectedBook {                Text("Author: \(book.author)")            } else {                Text("Select a Book")            }        }, detail: {            Button("Inspector 开关") {                showInspector.toggle()            }            if let book = selectedBook {                Text(book.description)            } else {                Text("Book details will appear here")            }        })        .inspector(isPresented: $showInspector) {            if let book = selectedBook {                InspectorView(book: book)            }        }    }}struct InspectorView: View {    var book: Book    var body: some View {        VStack {            Text(book.title).font(.largeTitle)            Text("Author: \(book.author)").font(.title)            Text(book.description).padding()        }        .inspectorColumnWidth(200)        .presentationDetents([.medium, .large])    }}

它显示了一个图书列表。当用户选择一个图书时,会显示 InspectorView,这是辅助视图,它显示了图书的详细信息。inspector 方法用于显示和隐藏 InspectorView,inspectorColumnWidth 方法用于设置辅助视图的宽度,presentationDetents 方法用于设置辅助视图的大小。

导航状态保存和还原

通过 SceneStorage 保存导航路径,程序终止时会持久化存储路径,重启时恢复路径。

protocol URLProcessor<RouteType> {    associatedtype RouteType: Hashable    func process(_ url: URL, mutating: inout [RouteType])}protocol UserActivityProcessor<RouteType> {    associatedtype RouteType: Hashable    func process(_ activity: NSUserActivity, mutating: inout [RouteType])}@Observable@MainActor final class RouteManager<RouteType: Hashable> {    var navigationPath: [RouteType] = []    private let jsonDecoder = JSONDecoder()    private let jsonEncoder = JSONEncoder()    private let urlProcessor: any URLProcessor<RouteType>    private let activityProcessor: any UserActivityProcessor<RouteType>    init(        urlProcessor: some URLProcessor<RouteType>,        activityProcessor: some UserActivityProcessor<RouteType>    ) {        self.urlProcessor = urlProcessor        self.activityProcessor = activityProcessor    }    func process(_ activity: NSUserActivity) {        activityProcessor.process(activity, mutating: &navigationPath)    }    func process(_ url: URL) {        urlProcessor.process(url, mutating: &navigationPath)    }}extension RouteManager where RouteType: Codable {    func toData() -> Data? {        try? jsonEncoder.encode(navigationPath)    }        func restore(from data: Data) {        do {            navigationPath = try jsonDecoder.decode([RouteType].self, from: data)        } catch {            navigationPath = []        }    }}

这段代码定义了一个名为 RouteManager 的类,它用于处理和管理导航路径。这个类使用了 SwiftUI 的 @MainActor@Observable 属性包装器,以确保它的操作在主线程上执行,并且当 navigationPath 发生变化时,会自动更新相关的 UI。

RouteManager 类有两个协议类型的属性:urlProcessoractivityProcessor。这两个属性分别用于处理 URL 和用户活动(NSUserActivity)。这两个处理器的任务是根据给定的 URL 或用户活动,更新 navigationPath

RouteManager 类还有两个方法:process(_ activity: NSUserActivity)process(_ url: URL)。这两个方法分别用于处理用户活动和 URL。处理的方式是调用相应的处理器的 process 方法。

此外,RouteManager 类还有一个扩展,这个扩展只适用于 RouteTypeCodable 的情况。这个扩展提供了两个方法:toData()restore(from data: Data)toData() 方法将 navigationPath 转换为 Datarestore(from data: Data) 方法则将 Data 转换回 navigationPath。这两个方法可以用于将 navigationPath 保存到磁盘,并在需要时从磁盘恢复。

struct MainView: View {    @SceneStorage("navigationState") private var navigationData: Data?    @State private var dataStore = DataStore()    @State private var routeManager = RouteManager<Route>(        urlProcessor: SomeURLProcessor(),        activityProcessor: SomeUserActivityProcessor()    )        var body: some View {        NavigationStack(path: $routeManager.navigationPath) {            SomeView(categories: dataStore.categories)                .task { await dataStore.fetch() }                .navigationDestination(for: Route.self) { route in                    // ...                }                .onOpenURL { routeManager.process($0) }        }        .task {            if let navigationData = navigationData {                routeManager.restore(from: navigationData)            }                        for await _ in routeManager.$navigationPath.values {                navigationData = routeManager.toData()            }        }    }}

@SceneStorage("navigationState") 是用来保存和恢复导航状态的。当应用程序被挂起时,它会自动将 navigationData 保存到磁盘,当应用程序重新启动时,它会自动从磁盘恢复 navigationData

@State private var dataStore = DataStore()@State private var routeManager = RouteManager<Route>(...) 是用来存储数据和路由管理器的。DataStore 是用来获取和存储数据的,RouteManager 是用来处理和管理导航路径的。

body 属性定义了视图的内容。它首先创建了一个 NavigationStack,然后在这个 NavigationStack 中创建了一个 SomeViewSomeView 使用了 dataStore.categories 作为它的参数,并且在被创建后立即执行 dataStore.fetch() 来获取数据。

body 属性还定义了一个任务,这个任务在视图被创建后立即执行。这个任务首先检查 navigationData 是否存在,如果存在,就使用 routeManager.restore(from: navigationData) 来恢复导航路径。然后,它监听 routeManager.$navigationPath.values,每当 navigationPath 发生变化时,就使用 routeManager.toData() 来将 navigationPath 转换为 Data,并将结果保存到 navigationData 中。

布局基础

布局-基础

基本元素样式

通过 .font(.title) 设置字体大小。

.stroke(Color.blue) 设置描边。举个例子:

struct ContentView: View {    var body: some View {        Rectangle()            .stroke(Color.orange, style: StrokeStyle(lineWidth: 10, lineCap: .round, dash: [30]))            .padding(30)    }}

StrokeStyle(lineWidth: 10, lineCap: .round, dash: [30]) 定义了描边的样式,其中 lineWidth: 10 表示线宽为 10,lineCap: .round 表示线帽样式为圆形,dash: [30] 表示虚线模式,数组中的数字表示虚线和间隙的交替长度。

frame

.frame(width: 200, height:100, alignment: .topLeading)

  • width: 200 表示视图的宽度为 200 点。
  • height: 100 表示视图的高度为 100 点。
  • alignment: .topLeading 表示视图的内容应该在视图的左上角对齐。.topLeading 是 SwiftUI 中的一个对齐方式,表示左上角对齐。

Stack

多个视图通过 Stack 视图进行对齐排列。这些 Stack 视图主要是:

  • ZStack:Z轴排列
  • VStack:垂直排列
  • HStack:横向排列

间隔

视图之间的间隔可以用 Space(),它可以在各种布局视图中使用。

布局-留白

Space

Spacer 是一个灵活的空间,它会尽可能地占用多的空间,从而将其周围的视图推向堆栈的两边。因此,第一个 Text 视图会被推到左边,第二个 Text 视图会被推到中间,第三个 Text 视图会被推到右边。

struct ContentView: View {    var body: some View {        HStack {            Text("左边")            Spacer()            Text("中间")            Spacer()            Text("右边")        }    }}

下面这个例子是用 Space() 让三个视图都居右。

struct ContentView: View {    var body: some View {        HStack {            Spacer()            Text("视图1")            Text("视图2")            Text("视图3")        }    }}

布局-对齐

frame 对齐

.frame(width: 100, height: 50, alignment: .topLeading)

可设置对齐的视图

在 SwiftUI 中,许多视图都接受 alignment 参数,用于控制其子视图的对齐方式。以下是一些常见的接受 alignment 参数的视图:

  • HStack(alignment: .bottom):水平堆栈视图,可以控制其子视图在垂直方向上的对齐方式。
  • VStack(alignment: .trailing):垂直堆栈视图,可以控制其子视图在水平方向上的对齐方式。
  • ZStack(alignment: .center):深度堆栈视图,可以控制其子视图在水平和垂直方向上的对齐方式。
  • GridRow(alignment: .firstTextBaseline):用于定义网格的行或列的大小,可以设置行或列中的内容的对齐方式。。

基线对齐

你可以使用 alignment 参数来设置视图的对齐方式,包括基线对齐。以下是一个例子:

HStack(alignment: .firstTextBaseline) {    Text("Hello")    Text("World").font(.largeTitle)}

在这个例子中,HStack 是一个水平堆栈视图,它会将其子视图水平排列。alignment: .firstTextBaseline 是一个参数,用于设置堆栈中的内容的对齐方式。.firstTextBaseline 表示所有文本视图都应该根据它们的第一行的基线对齐。基线是文本字符的底部线。

因此,这个 HStack 中的两个 Text 视图会根据它们的第一行的基线对齐,即使它们的字体大小不同。

布局-居中

在 SwiftUI 中,有多种方法可以使视图居中:

Spacer

使用 SpacerSpacer 是一个灵活的空间,它会尽可能地占用多的空间,从而将其周围的视图推向堆栈的两边。如果在一个视图的两边都放置一个 Spacer,那么这个视图就会被推到中间。

HStack {    Spacer()    Text("居中")    Spacer()}

alignment

使用 alignment 参数:许多 SwiftUI 视图都接受 alignment 参数,用于控制其子视图的对齐方式。例如,VStackHStack 都接受 alignment 参数。

VStack(alignment: .center) {    Text("居中")}

frame

使用 frame 方法:frame 方法可以设置视图的尺寸和对齐方式。如果你想让一个视图在其父视图中居中,你可以使用 frame(maxWidth: .infinity, maxHeight: .infinity) 来使视图尽可能地占用多的空间,然后使用 alignment: .center 来使视图在这个空间中居中。

Text("居中")    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)

布局-offset偏移

struct OffsetDemo: View {    @State var offset: CGFloat = 0    var body: some View {        VStack {            Text("Hello, World!")                .font(.largeTitle)                .offset(y: offset)            Slider(value: $offset, in: -100...100)                .padding()        }    }}

我们创建了一个 Text 视图和一个 SliderText 视图使用了 .offset(y: offset) 修饰符,这意味着它的 y 偏移量会根据 offset 的值改变。Slider 则用于改变 offset 的值。当你移动滑块时,Text 视图的位置也会相应地上下移动。

Safe Area

ignoresSafeArea 忽略安全区域

使用 .ignoresSafeArea() 可以忽略安全区域。默认是所有方向都忽略。

如果只忽略部分方向,可以按照下面方法做:

// 默认会同时包含 .keyboard 和 .container。.ignoresSafeArea(edges: .top).ignoresSafeArea(edges: .vertical).ignoresSafeArea(edges: [.leading, .trailing])// 可以对安全区域分别指定.ignoresSafeArea(.keyboard, edges: .top).ignoresSafeArea(.container, edges: [.leading, .trailing])

safeAreaInset

safeAreaInset 是 SwiftUI 中的一个属性,它允许你将视图放置在安全区域内。”安全区域”是指设备屏幕上的一块区域,这块区域不会被系统界面(如状态栏、导航栏、工具栏、Tab栏等)遮挡。

例如,你可以使用 safeAreaInset 将一个视图放置在屏幕底部的安全区域内,代码如下:

VStack {    Text("Hello, World!")}.safeAreaInset(edge: .bottom, spacing: 10) {    Button("Press me") {        print("Button pressed")    }}

在这个例子中,”Press me” 按钮会被放置在屏幕底部的安全区域内,而且距离底部有 10 个点的间距。

下面是更完整点的例子:

struct ContentView: View {    @State var tasks: [TaskModel] = (0...10).map { TaskModel(name: "Task \($0)") }    @State var taskName = ""    @State var isFocused: Bool = false    var body: some View {        NavigationView {            VStack {                List {                    ForEach(tasks) { task in                        Text(task.name)                    }                }                .listStyle(PlainListStyle())                .safeAreaInset(edge: .bottom) {                    HStack {                        TextField("Add task", text: $taskName, onCommit: {                            addTask()                        })                        .textFieldStyle(RoundedBorderTextFieldStyle())                        .padding(.leading, 10)                                                Button(action: {                            addTask()                        }) {                            Image(systemName: "plus")                        }                        .padding(.trailing, 10)                    }                    .padding(.bottom, isFocused ? 0 : 10)                    .background(Color.white)                }                .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in                    withAnimation {                        isFocused = true                    }                }                .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in                    withAnimation {                        isFocused = false                    }                }            }            .navigationBarTitle("Task List Demo")        }    }    func addTask() {        if !taskName.isEmpty {            withAnimation {                tasks.append(TaskModel(name: taskName))            }            taskName = ""        }    }}struct TaskModel: Identifiable {    let id = UUID()    let name: String}

用户可以在底部的输入框中输入任务名称,然后点击 “+” 按钮将任务添加到任务清单中。添加的任务会显示在屏幕的上方。当键盘出现或消失时,底部的输入框会相应地移动,以确保不会被键盘遮挡。

布局原理

SwiftUI 的布局系统是一个两阶段的协商过程,涉及到父视图和子视图之间的交互。

建议阶段:在这个阶段,父视图会向子视图提出一个建议尺寸。这个建议尺寸是父视图希望子视图的大小。例如,如果父视图是一个 VStack,那么它可能会向子视图提出一个具有明确高度、宽度未指定的建议尺寸。

需求阶段:在这个阶段,子视图会根据父视图的建议尺寸来确定自己的需求尺寸。子视图可以选择接受父视图的建议尺寸,也可以选择返回一个不同的尺寸。例如,一个 Text 视图可能会返回一个刚好能够容纳其文本的尺寸。

在这个过程中,父视图和子视图都有可能影响最终的布局结果。父视图可以通过调整建议尺寸来影响子视图的大小,而子视图可以通过返回不同的需求尺寸来影响自己的大小。

在一些复杂的布局场景中,可能需要进行多轮的协商才能得到最终的布局结果。例如,如果一个视图使用了 GeometryReader 来获取其在父视图中的位置和尺寸,那么 GeometryReader 可能会在布局稳定之前,多次向子视图发送新的几何信息。

总的来说 SwiftUI 它允许父视图和子视图之间进行协商,以达到最佳的布局效果。

布局进阶

AnyLayout

使用 AnyLayout 包装布局组件,可以在布局之间进行切换,同时保持动画效果。

struct WeatherLayout: View {    @State private var changeLayout = false    var body: some View {        let layout = changeLayout ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())        layout {            WeatherView(icon: "sun.max.fill", temperature: 25, color: .yellow)            WeatherView(icon: "cloud.rain.fill", temperature: 18, color: .blue)            WeatherView(icon: "snow", temperature: -5, color: .white)        }        .animation(.default, value: changeLayout)        .onTapGesture {            changeLayout.toggle()        }    }}struct WeatherView: View {    let icon: String    let temperature: Int    let color: Color    var body: some View {        VStack {            Image(systemName: icon)                .font(.system(size: 80))                .foregroundColor(color)            Text("\(temperature)°")                .font(.system(size: 50))                .foregroundColor(color)        }        .frame(width: 120, height: 120)    }}

代码中,我们创建了一个 WeatherView 视图,它包含一个天气图标和一个温度标签。然后,我们在 WeatherLayout 视图中使用 AnyLayout 来动态改变布局。用户可以通过点击视图来在水平布局和垂直布局之间切换。

ViewThatFits

ViewThatFits 是一个自动选择最适合当前屏幕大小的子视图进行显示的视图。它会根据可用空间的大小来决定如何布局和显示子视图。

ViewThatFits 是一个在 SwiftUI 中用于选择最适合显示的视图的组件。它的工作原理如下:

  • 首先,ViewThatFits 会测量在特定轴(水平或垂直)或两个轴(水平和垂直)上的可用空间。这是通过 SwiftUI 的布局系统来完成的,该系统提供了当前视图的大小和位置信息。

  • 接着,ViewThatFits 会测量第一个视图的大小。这是通过调用视图的 measure(in:) 方法来完成的,该方法返回一个包含视图理想大小的 CGSize 值。

  • 如果第一个视图的大小适合可用空间,ViewThatFits 就会选择并放置这个视图。放置视图是通过调用视图的 layout(in:) 方法来完成的,该方法接受一个 CGRect 值,该值定义了视图在其父视图中的位置和大小。

  • 如果第一个视图的大小不适合可用空间,ViewThatFits 会继续测量第二个视图的大小。如果第二个视图的大小适合可用空间,ViewThatFits 就会选择并放置这个视图。

  • 如果所有视图的大小都不适合可用空间,ViewThatFits 会选择并放置 ViewBuilder 闭包中的最后一个视图。ViewBuilder 是一个特殊的闭包,它可以根据其内容动态创建视图。

ViewThatFits(in: .horizontal) {    Text("晴天,气温25°") // 宽度在200到300之间        .font(.title)        .foregroundColor(.yellow)    Text("晴天,25°") // 宽度在150到200之间        .font(.title)        .foregroundColor(.gray)    Text("晴25") // 宽度在100到150之间        .font(.title)        .foregroundColor(.white)}.border(Color.green) // ViewThatFits所需的大小.frame(width:200).border(Color.orange) // 父视图提议的大小

在不同的宽度下,ViewThatFits 会选择不同的视图进行显示。在上面的示例中,当父视图的宽度在100到150之间时,ViewThatFits 会选择显示 “晴25” 这个视图。

通过 ViewThatFits 来确定内容是否可滚动。

struct ContentView: View {    @State var step: CGFloat = 3    var count: Int {        Int(step)    }    var body: some View {        VStack(alignment:.leading) {            Text("数量: \(count)")                .font(.title)                .foregroundColor(.blue)            Stepper("数量", value: $step, in: 3...20)            ViewThatFits {                content                ScrollView(.horizontal,showsIndicators: true) {                    content                }            }        }        .padding()    }    var content: some View {        HStack {            ForEach(0 ..< count, id: \.self) { i in                Rectangle()                    .fill(Color.green)                    .frame(width: 30, height: 30)                    .overlay(                        Text("\(i)")                            .font(.headline)                            .foregroundColor(.white)                    )            }        }    }}

Layout协议

通过实现 Layout 协议,创建一个水平堆栈布局,其中所有子视图的宽度都相等。

struct OptimizedEqualWidthHStack: Layout {  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {    if subviews.isEmpty { return .zero }    let maxSubviewSize = calculateMaxSize(subviews: subviews)    let totalSpacing = calculateSpacing(subviews: subviews).reduce(0, +)    return CGSize(width: maxSubviewSize.width * CGFloat(subviews.count) + totalSpacing, height: maxSubviewSize.height)  }  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {    if subviews.isEmpty { return }    let maxSubviewSize = calculateMaxSize(subviews: subviews)    let spacings = calculateSpacing(subviews: subviews)    let placementProposal = ProposedViewSize(width: maxSubviewSize.width, height: maxSubviewSize.height)    var nextX = bounds.minX + maxSubviewSize.width / 2    for index in subviews.indices {      subviews[index].place(at: CGPoint(x: nextX, y: bounds.midY), anchor: .center, proposal: placementProposal)      nextX += maxSubviewSize.width + spacings[index]    }  }  private func calculateMaxSize(subviews: Subviews) -> CGSize {    return subviews.map { $0.sizeThatFits(.unspecified) }.reduce(.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }  }  private func calculateSpacing(subviews: Subviews) -> [CGFloat] {    return subviews.indices.map { $0 < subviews.count - 1 ? subviews[$0].spacing.distance(to: subviews[$0 + 1].spacing, along: .horizontal) : 0 }  }}

上面这段代码中 sizeThatFits 方法计算并返回布局容器需要的大小,以便排列其子视图。它首先检查子视图数组是否为空,如果为空则返回 .zero。然后,它计算子视图的最大尺寸和总间距,最后返回一个 CGSize 对象,其宽度等于最大子视图宽度乘以子视图数量加上总间距,高度等于最大子视图高度。

placeSubviews 方法将子视图放置在布局容器中。它首先检查子视图数组是否为空,如果为空则返回。然后,它计算子视图的最大尺寸和间距,然后遍历子视图数组,将每个子视图放置在布局容器中的适当位置。

calculateMaxSize 和 calculateSpacing 是两个私有方法,用于计算子视图的最大尺寸和间距。

GeometryReader

在 SwiftUI 中,有多种方法可以获取和控制视图的尺寸:

  • frame(width:60, height:60):这个方法会为子视图提供一个建议的尺寸,这里是 60 x 60。
  • fixedSize():这个方法会为子视图提供一个未指定模式的建议尺寸,这意味着视图会尽可能地大以适应其内容。
  • frame(minWidth: 120, maxWidth: 360):这个方法会将子视图的需求尺寸控制在指定的范围中,这里是宽度在 120 到 360 之间。
  • frame(idealWidth: 120, idealHeight: 120):这个方法会返回一个需求尺寸,如果当前视图收到为未指定模式的建议尺寸,那么它会返回 120 x 120 的尺寸。
  • GeometryReaderGeometryReader 会将建议尺寸作为需求尺寸直接返回,这意味着它会充满全部可用区域。你可以使用 GeometryReader 来获取其内容的尺寸和位置。

GeometryReader 可以获取其内容的尺寸和位置。在这个例子中,我们使用 GeometryReader 来获取视图的尺寸,然后打印出来。这对于理解 SwiftUI 的布局系统和调试布局问题非常有用。

extension View {    func logSizeInfo(_ label: String = "") -> some View {        background(            GeometryReader { proxy in                Color.clear                    .onAppear(perform: {                        debugPrint("\(label) Size: \(proxy.size)")                    })            }        )    }}struct ContentView: View {    var body: some View {        VStack {            Text("大标题")                .font(.largeTitle)                .logSizeInfo("大标题视图") // 打印视图尺寸            Text("正文")                .logSizeInfo("正文视图")        }    }}

这段代码首先定义了一个 View 的扩展,添加了一个 logSizeInfo(_:) 方法。这个方法接受一个标签字符串作为参数,然后返回一个新的视图。这个新的视图在背景中使用 GeometryReader 来获取并打印视图的尺寸。

然后,我们创建了一个 VStack 视图,其中包含一个 Text 视图。我们为 Text 视图调用了 logSizeInfo(_:) 方法,以打印其尺寸。

如何利用 GeometryReader 来绘制一个圆形?

struct CircleView: View {    var body: some View {        GeometryReader { proxy in            Path { path in                let radius = min(proxy.size.width, proxy.size.height) / 2                let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)                path.addArc(center: center, radius: radius, startAngle: .zero, endAngle: .init(degrees: 360), clockwise: false)            }            .fill(Color.blue)        }    }}

在这个例子中,我们首先获取 GeometryReader 的尺寸,然后计算出半径和中心点的位置。然后,我们使用 PathaddArc(center:radius:startAngle:endAngle:clockwise:) 方法来添加一个圆形路径。最后,我们使用 fill(_:) 方法来填充路径,颜色为蓝色。

关于 GeometryReader 性能问题

GeometryReader 是 SwiftUI 中的一个工具,它可以帮助我们获取视图的大小和位置。但是,它在获取这些信息时,需要等待视图被评估、布局和渲染完成。这就好比你在装修房子时,需要等待墙壁砌好、油漆干燥后,才能测量墙壁的尺寸。这个过程可能需要等待一段时间,而且可能需要多次重复,因为每次墙壁的尺寸改变,都需要重新测量。

这就是 GeometryReader 可能会影响性能的原因。它需要等待视图完成一轮的评估、布局和渲染,然后才能获取到尺寸数据,然后可能需要根据这些数据重新调整布局,这就需要再次进行评估、布局和渲染。这个过程可能需要重复多次,导致视图被多次重新评估和布局。

但是,随着 SwiftUI 的更新,这个问题已经有所改善。现在,我们可以创建自定义的布局容器,这些容器可以在布局阶段就获取到父视图的建议尺寸和所有子视图的需求尺寸,这样就可以避免反复传递尺寸数据,减少了视图的反复更新。

alignmentGuide

alignmentGuide是SwiftUI中的一个修饰符,它允许你自定义视图的对齐方式。你可以使用它来调整视图在其父视图或同级视图中的位置。

当你在一个视图上应用alignmentGuide修饰符时,你需要提供一个对齐标识符和一个闭包。对齐标识符定义了你想要调整的对齐方式(例如,.leading.trailing.center等)。闭包接收一个参数,这个参数包含了视图的尺寸,你可以使用这个参数来计算对齐指南的偏移量。

举个例子:

struct ContentView: View {    var body: some View {        HStack(alignment: .top) {            CircleView()                .alignmentGuide(.top) { vd in                    vd[.top] + 50                }            CircleView()        }        .padding()        .border(Color.gray)    }    struct CircleView: View {        var body: some View {            Circle()                .fill(Color.mint)                .frame(width: 50, height: 50)        }    }}

在HStack中,第一个CircleView使用了.alignmentGuide修饰符,这使得它在顶部对齐时向下偏移了50个单位。

布局进阶-参考资料

WWDC

23

22

20

官方接口文档

Stack

Stack View 有 VStack、HStack 和 ZStack

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("一段文字")            )    }}

GroupBox

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))    }}

叠加 GroupBox 颜色会有区分

GroupBox {    Text("电视剧名称: 人民的名义")    GroupBox {        Text("播放时间: 每周一至周五")    }}

最后,您还可以 GroupBox 使用 Label .将 Label 定位为 GroupBox 容器的标题。

GroupBox(label: Label("电视剧", systemImage: "tv")) {    HStack {        Text("播放时间: 每周一至周五")            .padding()        Spacer()    }}

GroupBox 也可以用于创建自定义的按钮组,如下所示:

struct TVShowCardView: View {    var body: some View {        VStack(alignment: .leading) {            // The header of the card            // - Photo, Show Name and Genre            HStack {                Circle()                    .frame(width: 40, height: 40)                    .foregroundColor(.gray)                VStack(alignment: .leading, spacing: 3) {                    Text("权力的游戏")                        .font(.headline)                        .fontWeight(.semibold)                    Text("奇幻剧")                        .font(.caption)                }                Spacer()            }                        Divider()                .foregroundColor(Color(uiColor: UIColor.systemGray6))                .padding([.top, .bottom], 8)                        // The description of the show in a few lines            Text("《权力的游戏》是一部改编自乔治·马丁的奇幻小说系列《冰与火之歌》的电视剧。")                .font(.body)                        // Buttons to watch, share or save the show            HStack {                actionGroupBox(imageName: "play.rectangle", actionName: "观看", action: { print("Watching...") })                actionGroupBox(imageName: "square.and.arrow.up", actionName: "分享", action: { print("Sharing...") })                actionGroupBox(imageName: "bookmark", actionName: "保存", action: { print("Saving...") })            }        }        .padding()        .background(Color.white)        .cornerRadius(10)    }        // A function to create a GroupBox for an action    func actionGroupBox(imageName: String, actionName: String, action: @escaping () -> Void) -> some View {        GroupBox {            VStack(spacing: 5) {                Image(systemName: imageName)                    .font(.headline)                Text(actionName)                    .font(.caption)            }            .foregroundColor(.red)            .frame(maxWidth: .infinity)        }.onTapGesture {            action()        }    }}struct ContentView: View {    var body: some View {        NavigationView {            ScrollView {                TVShowCardView()                                Spacer()            }            .padding()            .background(Color(UIColor.systemGray6))            .navigationTitle("电视剧")            .shadow(color: Color(.sRGB, red: 0, green: 0, blue: 0, opacity: 0.25), radius: 10, x: 0, y: 0)        }    }}

TabView

基本用法

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)) 修改器。

添加提醒

struct ContentView: View {    @State private var bookVm: BooksViewModel        init() {        bookVm = BooksViewModel()    }        var body: some View {        TabView {            BookListView(bookVm: bookVm)                .tabItem {                    Image(systemName: "list.bullet.rectangle.fill")                    Text("Book List")                }            SelectedBooksView(bookVm: bookVm)                .badge(bookVm.selectedBooks.count)                .tabItem {                    Image(systemName: "book.fill")                    Text("Selected Books")                }        }    }}

自定义样式

iOS 14 和 macOS 11 开始可以使用 tabViewStyle 修饰符更改 TabView 样式。比如有页面指示器的水平滚动图片。

显示页面指示器:

.tabViewStyle(.page(indexDisplayMode: .always))

.tabViewStyle(.page(indexDisplayMode: .never)) 修饰符隐藏页面指示器。

水平滚动图片:

struct ContentView: View {    let images = ["pencil", "scribble", "highlighter"]    var body: some View {        VStack {            TabView {                ForEach(images, id: \.self) { imageName in                    Image(systemName: imageName)                        .resizable()                        .scaledToFit()                }            }            .tabViewStyle(.page(indexDisplayMode: .always))            .frame(height: 100)        }    }}

分页视图

struct OnboardingView: View {    var body: some View {        TabView {            OnboardingPageView(imageName: "figure.mixed.cardio",                               title: "Welcome",                               description: "Welcome to MyApp! Get started by exploring our amazing features.")            OnboardingPageView(imageName: "figure.archery",                               title: "Discover",                               description: "Discover new content and stay up-to-date with the latest news and updates.")            OnboardingPageView(imageName: "figure.yoga",                               title: "Connect",                               description: "Connect with friends and share your experiences with the community.")        }        .tabViewStyle(.page(indexDisplayMode: .always))        .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))    }}

.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) 修饰符添加了背景。这将在点周围添加一个背景,使其在任何背景下都更容易看到。

背景颜色

iOS 16 和 macOS 13 开始可以更改 TabView 的背景颜色。

struct MainScreen: View {    var body: some View {        TabView {            NavigationView {                BookListView()                    .navigationTitle("图书列表")                    .toolbarBackground(.yellow, for: .navigationBar)                    .toolbarBackground(.visible, for: .navigationBar)            }            .tabItem {                Label("图书", systemImage: "book.closed")            }            UserPreferencesView()                .tabItem {                    Label("设置", systemImage: "gearshape")                }            .toolbarBackground(.indigo, for: .tabBar)            .toolbarBackground(.visible, for: .tabBar)            .toolbarColorScheme(.dark, for: .tabBar)        }    }}struct BookListView: View {    var body: some View {        Text("这里是图书列表")    }}struct UserPreferencesView: View {    var body: some View {        Text("这里是用户设置")    }}

ControlGroup

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    }}

Advanced layout control

session Compose custom layouts with SwiftUI

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

Grid 示例代码如下:

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 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

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 视图,为其所有子视图提供其最大子视图的宽度:

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 设置。

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 值:

func placeSubviews(  in bounds: CGRect,  proposal: ProposedViewSize,  subviews: Subviews,  cache: inout Void) {  let ranks = subviews.map { subview in    subview[Rank.self] // 👈🏻  }  // ...}

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

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 和图片也支持了样式布局变化,代码示例如下:

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))    }}

ContentUnavailableView

基本用法

struct ArchivedInfosView: View {    @Environment(\.modelContext) var modelContext    @Query var infos: [IOInfo]    ...        var body: some View {        List(selection: $selectInfo) {            ForEach(infos) { info in                ...            }        }        .overlay {            if infos.isEmpty {                ContentUnavailableView {                    Label("无归档", systemImage: "archivebox")                } description: {                    Text("点击下方按钮添加一个归档资料")                } actions: {                    Button("新增") {                        addInfo()                    }                }            }        }    }    ...}

搜索

struct ContentView: View {    @Bindable var vm: VModel    ...    var body: some View {        NavigationStack {            List(vm.items, id: \.self) { item in                ...            }            .navigationTitle("Products")            .overlay {                if vm.items.isEmpty {                    ContentUnavailableView.search(text: vm.query)                }            }            .searchable(text: $vm.query)        }        ...    }}

小册子之 List、Lazy 容器、ScrollView、Grid 和 Table 数据集合视图

作者 戴铭
2024年5月18日 09:06

以下内容已整理到小册子中,小册子代码在 Github 上,可以在 macOS 应用商店安装“戴铭的开发小册子”应用查看。

ForEach

使用

在 SwiftUI 中,ForEach 是一个结构体,它可以创建一组视图,每个视图都有一个与数据集中的元素相对应的唯一标识符。这对于在列表或其他集合视图中显示数据非常有用。

以下视图集会用到 ForEach:

  • List
  • ScrollView
  • LazyVStack / LazyHStack
  • Picker
  • Grids (LazyVGrid / LazyHGrid)

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(bookmarks) { bookmark in                Text(bookmark.name)            }        }    }}

ForEach 遍历 bookmarks 数组,并为每个 BookmarkModel 对象创建一个 Text 视图。bookmark 参数是当前遍历的 BookmarkModel 对象。

BookmarkModel 必须遵循 Identifiable 协议,这样 SwiftUI 才能知道如何唯一地标识每个视图。在你的代码中,BookmarkModel 已经有一个 id 属性,所以你只需要让 BookmarkModel 遵循 Identifiable 协议即可:

final class BookmarkModel: Identifiable {    // your code here}

使用索引范围进行编号

你可以使用 ForEach 结构体的另一个版本,它接受一个范围作为其数据源。这个范围可以是一个索引范围,这样你就可以为每个项目编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(bookmarks.indices, id: \.self) { index in                Text("\(index + 1). \(bookmarks[index].name)")            }        }    }}

在这个例子中,ForEach 遍历 bookmarks 数组的索引,并为每个 BookmarkModel 对象创建一个 Text 视图。index 参数是当前遍历的索引。我们使用 \(index + 1). \(bookmarks[index].name) 来创建一个带有编号的文本视图。请注意,我们使用 index + 1 而不是 index,因为数组的索引是从 0 开始的,但我们通常希望编号是从 1 开始的。

使用 enumerated 编号

 enumerated() 

以下是一个例子:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(Array(bookmarks.enumerated()), id: \.element.id) { index, bookmark in                Text("\(index). \(bookmark.name)")            }        }    }}

我们使用 Array(bookmarks.enumerated()) 来创建一个元组数组,每个元组包含一个索引和一个 BookmarkModel 对象。然后,我们使用 ForEach 遍历这个元组数组,并为每个元组创建一个 Text 视图。index 参数是当前遍历的索引,bookmark 参数是当前遍历的 BookmarkModel 对象。

使用 zip 编号

zip(_:_:) 函数可以将两个序列合并为一个元组序列。你可以使用这个函数和 ForEach 结构体来为数组中的每个元素添加一个编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(Array(zip(1..., bookmarks)), id: \.1.id) { index, bookmark in                Text("\(index). \(bookmark.name)")            }        }    }}

写出扩展,方便调用

@dynamicMemberLookupstruct Numbered<Element> {    var number: Int    var element: Element        subscript<T>(dynamicMember keyPath: WritableKeyPath<Element, T>) -> T {        get { element[keyPath: keyPath] }        set { element[keyPath: keyPath] = newValue }    }}extension Sequence {    func numbered(startingAt start: Int = 1) -> [Numbered<Element>] {        zip(start..., self)            .map { Numbered(number: $0.0, element: $0.1) }    }}extension Numbered: Identifiable where Element: Identifiable {    var id: Element.ID { element.id }}

使用:

ForEach(bookmark.numbered()) { numberedBookmark in    Text("\(numberedBookmark.number). \(numberedBookmark.name)")}

Scroll视图

ScrollView

新增 modifier

ScrollView {    ForEach(0..<300) { i in        Text("\(i)")            .id(i)    }}.scrollDisabled(false) // 设置是否可滚动.scrollDismissesKeyboard(.interactively) // 关闭键盘.scrollIndicators(.visible) // 设置滚动指示器是否可见

ScrollViewReader

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

示例代码如下:

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: - 自定义 ScrollViewstruct 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) {}}

固定到滚动视图的顶部

LazyVStack 有个参数 pinnedViews 可以用于固定滚动视图的顶部。

ScrollView {    LazyVStack(alignment: .leading, spacing: 10, pinnedViews: .sectionHeaders) {        Section {            ForEach(books) { book in                BookRowView(book: book)            }        } header: {            HeaderView(title: "小说")        }        ....    }}

滚动到特定的位置

scrollPostion 版本

scrollPositon(id:) 比 ScrollViewReader 简单,但是只适用于 ScrollView。数据源遵循 Identifiable,不用显式使用 id 修饰符

struct ContentView: View {    @State private var id: Int?    var body: some View {        VStack {            Button("Scroll to Bookmark 3") {                withAnimation {                    id = 3                }            }            Button("Scroll to Bookmark 13") {                withAnimation {                    id = 13                }            }            ScrollView {                ScrollViewReader { scrollView in                    LazyVStack {                        ForEach(Bookmark.simpleData()) { bookmark in                            Text("\(bookmark.index)")                                .id(bookmark.index)                        }                                            }                }            }            .scrollPosition(id: $id)            .scrollTargetLayout()        }    }        struct Bookmark: Identifiable,Hashable {        let id = UUID()        let index: Int                static func simpleData() -> [Bookmark] {            var re = [Bookmark]()            for i in 0...100 {                re.append(Bookmark(index: i))            }            return re        }    }}

scrollTargetLayout 可以获得当前滚动位置。锚点不可配,默认是 center。

ScrollViewReader 版本

ScrollViewReader 这个版本可以适用于 List,也可以配置锚点

你可以使用 ScrollViewReaderscrollTo(_:anchor:) 方法来滚动到特定的元素。以下是一个例子:

struct ContentView: View {    var bookmarks: [Int] = Array(1...100)    @State private var selectedBookmarkId: Int?    var body: some View {        VStack {            Button("Scroll to Bookmark 3") {                selectedBookmarkId = 3            }            Button("Scroll to Bookmark 13") {                selectedBookmarkId = 13            }            ScrollView {                ScrollViewReader { scrollView in                    LazyVStack {                        ForEach(bookmarks.indices, id: \.self) { index in                            Text("\(bookmarks[index])")                                .id(index)                        }                        .onChange(of: selectedBookmarkId) { oldValue, newValue in                            if let newValue = newValue {                                withAnimation {                                    scrollView.scrollTo(newValue, anchor: .top)                                }                            }                        }                    }                }            }        }    }}

在这个例子中,我们首先创建了一个 Button,当点击这个按钮时,selectedBookmarkId 的值会被设置为 3。然后,我们创建了一个 ScrollView,并在 ScrollView 中添加了一个 ScrollViewReader。我们在 ScrollViewReader 中添加了一个 LazyVStack,并使用 ForEach 遍历 bookmarks 数组的索引,为每个索引创建一个 Text 视图。我们使用 id(_:) 方法为每个 Text 视图设置了一个唯一的 ID。

我们使用 onChange(of:perform:) 方法来监听 selectedBookmarkId 的变化。当 selectedBookmarkId 的值改变时,我们会调用 scrollTo(_:anchor:) 方法来滚动到特定的元素。anchor: .top 参数表示我们希望滚动到的元素位于滚动视图的顶部。

scrollTargetBehavior分页滚动

按可视尺寸分页

.scrollTargetBehavior(.paging) 可以让 ScrollView 滚动,滚动一页的范围是 ScrollView 的可视尺寸。 

struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                }            }        }        .scrollTargetBehavior(.paging)    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

按容器元素对齐分页

使用 .scrollTargetBehavior(.viewAligned) 配合 scrollTargetLayout。示例代码如下:

struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                }            }            .scrollTargetLayout(isEnabled: true)        }        .scrollTargetBehavior(.viewAligned)    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

scrollTransition视觉效果

iOS 17 新推出 .scrollTransition,用于处理滚动时的动画。

.transition 用于视图插入和移除视图树时的动画。

.scrollTransition 会和滚动联合起来进行平滑的过渡动画处理。.scrollTransition 可以修改很多属性,比如大小,可见性还有旋转等。

.scrollTransition 可以针对不同阶段进行处理,目前有三个阶段:

  • topLeading: 视图进入 ScrollView 可见区域
  • identity: 在可见区域中
  • bottomTrailing: 视图离开 ScrollView 可见区域
struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                        .scrollTransition { content, phase in                             content                                .scaleEffect(phase.isIdentity ? 1 : 0.4)                        }                }            }        }    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

使用阶段的值

.scrollTransition(.animated(.bouncy)) { content, phase in    content        .scaleEffect(phase.isIdentity ? 1 : phase.value)}

不同阶段的产生效果设置

.scrollTransition(    topLeading: .animated,    bottomTrailing: .interactive) { content, phase in    content.rotationEffect(.radians(phase.value))}

.rotation3DEffect 也是支持的。

.scrollTransition(.interactive) { content, phase in    content        .rotation3DEffect(            Angle.degrees(phase.isIdentity ? 0: 120),            axis: (x: 0.9, y: 0.0, z: 0.1))        .offset(x: phase.value * -300)}

ScrollView-参考资料

文档

WWDC

23

List列表

List

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

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

使用 .listRowSeparator(.hidden, edges: .all) 可以隐藏分割线。

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

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,示例如下:

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)            }    }}

List-设置样式

内置样式

通过 .listStyle 修饰符可以用系统内置样式更改 List 外观。

List {   ...}.listStyle(.sidebar)

不同平台有不同的选项

ListStyle iOS macOS watchOS tvOS
plain iOS 13+ macOS 10.15+ watchOS 6+ tvOS 13+
sidebar iOS 14+ macOS 10.15+ - -
inset iOS 13+ macOS 11.15+ - -
grouped iOS 13+ - - tvOS 13+
insetGrouped iOS 14+ - - -
bordered - macOS 12+ - -
carousel - - watchOS 6+ -
elliptical - - watchOS 7+ -

行高

List {  ...}.environment(\.defaultMinListRowHeight, 100).environment(\.defaultMinListHeaderHeight, 50)

分隔符

listSectionSeparator 和 listRowSeparator 隐藏行和 Section 分隔符。

listRowSeparatorTint 和 listSectionSeparatorTint 更改分隔符颜色

例如:

.listRowSeparatorTint(.cyan, edges: .bottom)

背景

.alternatingRowBackgrounds() 可以让 List 的行底色有区分。

listRowBackground 调整行的背景颜色

更改背景颜色前需要隐藏内容背景

List {  ...}.scrollContentBackground(.hidden).background(Color.cyan)

这个方法同样可用于 ScrollView 和 TextEditor。

你可以使用 .listRowBackground() 修饰符来更改列表行的背景。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            ForEach(0..<5) { index in                Text("Row \(index)")                    .listRowBackground(index % 2 == 0 ? Color.blue : Color.green)            }        }    }}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .listRowBackground() 修饰符来更改每个元素的背景颜色。如果元素的索引是偶数,我们将背景颜色设置为蓝色,否则我们将背景颜色设置为绿色。

Section

你可以使用 Section 视图的 headerfooter 参数来添加头部和尾部。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            Section {                ForEach(0..<5) { index in                    Text("Row \(index)")                }            } header: {                Text("Header").font(.title)            } footer: {                Text("Footer").font(.caption)            }        }    }}

headerProminence(.increase) 可以增加 Section Header 的大小。

safeAreaInset

你可以使用 .safeAreaInset() 修饰符来调整视图的安全区域插入。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            ForEach(0..<5) { index in                Text("Row \(index)")            }        }        .safeAreaInset(edge: .top, spacing: 20) {            Text("Header")                .frame(maxWidth: .infinity, alignment: .center)                .background(Color.blue)                .foregroundColor(.white)        }    }}

在这个例子中,我们创建了一个包含五个元素的 List。然后我们使用 .safeAreaInset() 修饰符来在 List 的顶部添加一个 Header。我们将 edge 参数设置为 .top,将 spacing 参数设置为 20,然后提供一个视图作为 Header。这个 Header 是一个文本视图,它的背景颜色是蓝色,前景颜色是白色,它被居中对齐,并且它的宽度和 List 的宽度相同。

List-移动元素

你可以使用 .onMove(perform:) 修饰符来允许用户移动 List 中的元素。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        NavigationView {            List {                ForEach(items, id: \.self) { item in                    Text(item)                }                .onMove(perform: move)            }            .toolbar {                EditButton()            }        }    }    private func move(from source: IndexSet, to destination: Int) {        items.move(fromOffsets: source, toOffset: destination)    }}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .onMove(perform:) 修饰符来允许用户移动这些元素,并提供了一个 move(from:to:) 方法来处理移动操作。我们还添加了一个 EditButton,用户可以点击它来进入编辑模式,然后就可以移动元素了。

List-搜索

搜索和搜索建议

你可以使用 .searchable() 修饰符的 suggestions 参数来提供搜索建议。以下是一个例子:

struct ContentView: View {    @State private var searchText = ""    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        NavigationView {            List {                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in                    Text(item)                }            }            .searchable(text: $searchText, suggestions: {                 Button(action: {                    searchText = "Item 1"                }) {                    Text("Item 1")                }                Button(action: {                    searchText = "Item 2"                }) {                    Text("Item 2")                }            })            .navigationBarTitle("Items")        }    }}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了一个搜索框。当用户在搜索框中输入文本时,List 会自动更新以显示匹配的元素。同时,我们提供了两个搜索建议 “Item 1” 和 “Item 2”,用户可以点击这些建议来快速填充搜索框。

在列表中显示搜索建议

struct ContentView: View {    @Environment(\.searchSuggestionsPlacement) var placement    @State private var searchText = ""    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]        var body: some View {        NavigationView {            List {                SearchSuggestionView()                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in                    Text(item)                }            }            .searchable(text: $searchText, suggestions: {                VStack {                    Button(action: {                        searchText = "Item 1"                    }) {                        Text("Item 1")                    }                    Button(action: {                        searchText = "Item 2"                    }) {                        Text("Item 2")                    }                }                .searchSuggestions(.hidden, for: .content)            })            .navigationBarTitle("Items")        }    }        @ViewBuilder    func SearchSuggestionView() -> some View {        if placement == .content {            Button(action: {                searchText = "Item 1"            }) {                Text("Item 1")            }            Button(action: {                searchText = "Item 2"            }) {                Text("Item 2")            }        }    }}

搜索状态

搜索中

@Environment(\.isSearching) var isSearching

关闭搜索

@Environment(\.dismissSearch) var dismissSearch

提交搜索

List {    ...}.searchable(text: $vm.searchTerm).onSubmit(of: .search) {    //...}

搜索栏外观

占位文字说明

.searchable(text: $wwdcVM.searchText, prompt: "搜索 WWDC Session 内容")

一直显示搜索栏

.searchable(text: $wwdcVM.searchText,             placement: .navigationBarDrawer(displayMode:.always))

更改搜索栏的位置

.searchable(text: $wwdcVM.searchText, placement: .sidebar)

搜索去抖动

你可以使用 Combine 框架来实现搜索的去抖动功能。以下是一个例子:

import SwiftUIimport Combineclass SearchViewModel: ObservableObject {    @Published var searchText = ""    @Published var searchResults: [String] = []    private var cancellables = Set<AnyCancellable>()    init() {        $searchText            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)            .sink { [weak self] in self?.search($0) }            .store(in: &cancellables)    }    private func search(_ text: String) {        // 这里是你的搜索逻辑        // 例如,你可以从一个数组中过滤出匹配的元素        let items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]        searchResults = items.filter { $0.contains(text) }    }}struct ContentView: View {    @StateObject private var viewModel = SearchViewModel()    var body: some View {        VStack {            TextField("Search", text: $viewModel.searchText)                .padding()            List(viewModel.searchResults, id: \.self) { result in                Text(result)            }        }    }}

在这个例子中,我们创建了一个 SearchViewModel 类,它有一个 searchText 属性和一个 searchResults 属性。当 searchText 属性的值发生变化时,我们使用 Combine 的 debounce(for:scheduler:) 方法来延迟执行搜索操作,从而实现去抖动功能。然后我们在 ContentView 中使用这个 SearchViewModel 来显示搜索框和搜索结果。

List-下拉刷新

你可以使用 .refreshable() 修饰符来添加下拉刷新功能。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text(item)            }        }        .refreshable {            await refresh()        }    }    func refresh() async {        // 这里是你的刷新逻辑        // 例如,你可以从网络获取新的数据,然后更新 items 数组        // 这里我们只是简单地将 items 数组反转        items.reverse()    }}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了下拉刷新功能。当用户下拉 List 时,refresh() 方法会被调用,然后我们将 items 数组反转,从而模拟刷新操作。注意,refresh() 方法需要是一个异步方法,因为刷新操作通常需要一些时间来完成。

List-轻扫操作

你可以使用 .swipeActions() 修饰符来添加轻扫操作。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text(item)                .swipeActions {                    Button(action: {                        // 这里是你的删除操作                        if let index = items.firstIndex(of: item) {                            items.remove(at: index)                        }                    }) {                        Label("Delete", systemImage: "trash")                    }                    .tint(.red)                }            }        }    }}

在这个例子中,我们创建了一个包含五个元素的 List,并为每个元素添加了一个滑动操作。当用户向左轻扫一个元素时,会显示一个 “Delete” 按钮,用户可以点击这个按钮来删除该元素。

List-大纲视图

List 树状结构

通过 children 参数指定子树路径。

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

DisclosureGroup 实现展开和折叠

DisclosureGroup 视图可以用来创建一个可以展开和折叠的内容区域。以下是一个例子:

struct ContentView: View {    @State private var isExpanded = false    var body: some View {        DisclosureGroup("Options", isExpanded: $isExpanded) {            Text("Option 1")            Text("Option 2")            Text("Option 3")        }    }}

在这个例子中,我们创建了一个 DisclosureGroup 视图,它的标题是 “Options”,并且它包含三个选项。我们使用一个 @State 属性 isExpanded 来控制 DisclosureGroup 视图是否展开。当用户点击标题时,DisclosureGroup 视图会自动展开或折叠,同时 isExpanded 属性的值也会相应地改变。

OutlineGroup 创建大纲视图

可以使用 OutlineGroup 视图来创建一个大纲视图。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            OutlineGroup(sampleData, id: \.self) { item in                Text(item.name)            }        }    }}struct Item: Identifiable {    var id = UUID()    var name: String    var children: [Item]?}let sampleData: [Item] = [    Item(name: "Parent 1", children: [        Item(name: "Child 1"),        Item(name: "Child 2")    ]),    Item(name: "Parent 2", children: [        Item(name: "Child 3"),        Item(name: "Child 4")    ])]

在这个例子中,我们创建了一个 Item 结构体,它有一个 name 属性和一个 children 属性。然后我们创建了一个 sampleData 数组,它包含两个父项,每个父项都有两个子项。最后我们在 ContentView 中使用 OutlineGroup 视图来显示这个数组,每个父项和子项都显示为一个文本视图。

结合 OutlineGroup 和 DisclosureGroup 实现自定义可折叠大纲视图

代码如下:

struct SPOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {    private let v: SPOutlineView<D, Content>        init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {        self.v = SPOutlineView(d: d, c: c, content: content)    }        var body: some View {        List {            v        }    }}struct SPOutlineView<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] {                SPDisclosureGroup(content: SPOutlineView(d: sub, c: c, content: content), label: content(i))            } else {                content(i)            } // end if        } // end ForEach    } // end body}struct SPDisclosureGroup<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 {                withAnimation {                    isExpanded.toggle()                }            } label: {                label            }            .buttonStyle(.plain)        }            }}

List-完全可点击的行

使用 .contentShape(Rectangle()) 可以使整个区域都可点击

struct ContentView: View {    var body: some View {        List {            ForEach(1..<50) { num in                HStack {                    Text("\(num)")                    Spacer()                }                .contentShape(Rectangle())                .onTapGesture {                    print("Clicked \(num)")                }            }        } // end list    }}

List-索引标题

这个代码是在创建一个带有索引标题的列表,用户可以通过拖动索引标题来快速滚动列表。

import SwiftUI...struct ContentView: View {  ...  var body: some View {    ScrollViewReader { proxy in      List {        ArticleListView      }      .listStyle(InsetGroupedListStyle())      .overlay(IndexView(proxy: proxy))    }  }  ...}struct IndexView: View {  let proxy: ScrollViewProxy  let titles: [String]  @GestureState private var dragLocation: CGPoint = .zero  var body: some View {    VStack {      ForEach(titles, id: \.self) { title in        TitleView()          .background(drag(title: title))      }    }    .gesture(      DragGesture(minimumDistance: 0, coordinateSpace: .global)        .updating($dragLocation) { value, state, _ in          state = value.location        }    )  }  func drag(title: String) -> some View {    GeometryReader { geometry in      drag(geometry: geometry, title: title)    }  }  func drag(geometry: GeometryProxy, title: String) -> some View {    if geometry.frame(in: .global).contains(dragLocation) {      DispatchQueue.main.async {        proxy.scrollTo(title, anchor: .center)      }    }    return Rectangle().fill(Color.clear)  }  ...}...

上面代码中 ContentView 是主视图,它包含一个 List 和一个 IndexViewList 中的内容由 ArticleListView 提供。IndexView 是一个自定义视图,它显示了所有的索引标题。

IndexView 接受一个 ScrollViewProxy 和一个标题数组。它使用 VStackForEach 来创建一个垂直的索引标题列表。每个标题都是一个 TitleView,并且它有一个背景,这个背景是通过 drag(title:) 方法创建的。

drag(title:) 方法接受一个标题,并返回一个视图。这个视图是一个 GeometryReader,它可以获取其包含的视图的几何信息。然后,这个 GeometryReader 使用 drag(geometry:title:) 方法来创建一个新的视图。

drag(geometry:title:) 方法接受一个 GeometryProxy 和一个标题,并返回一个视图。如果 GeometryProxy 的全局帧包含当前的拖动位置,那么这个方法将返回一个特定的视图。

IndexView 还有一个手势,这个手势是一个 DragGesture。当用户拖动索引标题时,这个手势会更新 dragLocation 属性的值,这个属性是一个 @GestureState 属性,它表示当前的拖动位置。

List-加载更多

你可以通过检测列表滚动到底部来实现加载更多的功能。以下是一个简单的例子:

struct ContentView: View {    @State private var items = Array(0..<20)    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text("Item \(item)")                    .onAppear {                        if item == items.last {                            loadMore()                        }                    }            }        }        .onAppear(perform: loadMore)    }    func loadMore() {        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {            let newItems = Array(self.items.count..<self.items.count + 20)            self.items.append(contentsOf: newItems)        }    }}

在这个例子中,我们创建了一个包含多个元素的 List。当 List 出现最后一项时,我们调用 loadMore 方法来加载更多的元素。在 loadMore 方法中,模拟在一秒后添加新的元素到 items 数组中。

请注意,这只是一个基本的使用示例,实际的使用方式可能会根据你的需求而变化。例如,你可能需要从网络获取新的元素,而不是像这个例子中那样直接创建新的元素。

Lazy容器

LazyVStack和LazyHStack

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

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)) 表示会根据设置大小自动设置有多少列展示。

示例:

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()        }    }}

Grid

Grid 会将最大的一个单元格大小应用于所有单元格

代码例子:

struct ContentView: View {    var body: some View {        Grid(alignment: .center,             horizontalSpacing: 30,             verticalSpacing: 8) {            GridRow {                Text("Tropical")                Text("Mango")                Text("Pineapple")                    .gridCellColumns(2)            }            GridRow(alignment: .bottom) {                Text("Leafy")                Text("Spinach")                Text("Kale")                Text("Lettuce")            }        }    }}

gridCellAnchor 可以让 GridRow 给自己设置对齐方式。

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

GridRow 的间距通过 Grid 的 horizontalSpacingverticalSpacing 参数来控制。

struct ContentView: View {    let numbers: [[Int]] = [        [1, 2, 3],        [4, 5, 6],        [7, 8, 9]    ]    var body: some View {        Grid(horizontalSpacing: 0, verticalSpacing: 0) {            ForEach(numbers.indices, id: \.self) { i in                GridRow {                    ForEach(numbers[i].indices, id: \.self) { j in                        Text("\(numbers[i][j])")                            .frame(maxWidth: .infinity, maxHeight: .infinity)                            .background(Color.gray.opacity(0.2))                            .border(Color.gray, width: 0.5)                    }                }            }        }    }}

按照以上代码这样写,每个数字 GridRow 之间的间隔就是0了。

空白的单元格可以这样写:

Color.clear    .gridCellUnsizedAxes([.horizontal, .vertical])

Table表格

Table

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

table 使用示例如下:

struct ContentView: View {    var body: some View {        Table(Fruit.simpleData()) {            TableColumn("名字", value: \.name)            TableColumn("颜色", value: \.color)            TableColumn("颜色") {                Text("\($0.name)")                    .font(.footnote)                    .foregroundStyle(.cyan)            }        }        .contextMenu(forSelectionType: Fruit.ID.self) { selection in            if selection.isEmpty {                Button("添加") {                    // ...                }            } else if selection.count == 1 {                Button("收藏") {                    // ...                }            } else {                Button("收藏多个") {                    // ...                }            }        }    }        struct Fruit:Identifiable {        let id = UUID()        let name: String        let color: String                static func simpleData() -> [Fruit] {            var re = [Fruit]()            re.append(Fruit(name: "Apple", color: "Red"))            re.append(Fruit(name: "Banana", color: "Yellow"))            re.append(Fruit(name: "Cherry", color: "Red"))            re.append(Fruit(name: "Date", color: "Brown"))            re.append(Fruit(name: "Elderberry", color: "Purple"))            return re        }    }}

Table-样式

在 SwiftUI 中,Table 视图的 .tableStyle 修改器可以用来设置表格的样式。目前,SwiftUI 提供了以下几种表格样式:

  • inset:默认
  • inset(alternatesRowBackgrounds: Bool):是否开启行交错背景
  • bordered:加边框
  • bordered(alternatesRowBackgrounds: Bool): 是否开启行交错背景

你可以使用 .tableStyle 修改器来设置表格的样式,例如:

Table(data) {    // ...}.tableStyle(InsetGroupedListStyle())

这段代码会将表格的样式设置为 InsetGroupedListStyle

Table-行的选择

你可以使用 Table 视图的 selection 参数来实现单选和多选。selection 参数接受一个绑定到一个可选的 Set 的变量,这个 Set 包含了被选中的元素的标识。

以下是一个使用 Table 视图实现单选和多选的例子:

struct ContentView: View {    @State private var selectionOne: UUID? // 单选    @State private var selection: Set<UUID> = [] // 多选    let data = [        Fruit(name: "Apple", color: "Red"),        Fruit(name: "Banana", color: "Yellow"),        Fruit(name: "Cherry", color: "Red"),        Fruit(name: "Date", color: "Brown"),        Fruit(name: "Elderberry", color: "Purple")    ]    var body: some View {        Table(data, selection: $selectionOne) {            TableColumn("Fruit") { item in                Text(item.name)            }            TableColumn("Color") { item in                Text(item.color)            }        }    }}struct Fruit: Identifiable {    let id = UUID()    let name: String    let color: String}

在这个例子中,我们首先定义了一个 @State 变量 selection,它是一个 Set,包含了被选中的元素的标识。然后,我们将这个变量绑定到 Table 视图的 selection 参数。

现在,当用户选择或取消选择一个元素时,selection 变量就会被更新。你可以使用这个变量来判断哪些元素被选中,或者实现其他的交互功能。

Table-多属性排序

你可以使用 Table 视图的 sortOrder 参数来实现多属性排序。sortOrder 参数接受一个绑定到一个 SortDescriptor 数组的变量,这个数组定义了排序的顺序和方式。

以下是一个使用 Table 视图实现多属性排序的例子:

struct ContentView: View {    @State private var sortOrder: [KeyPathComparator<Fruit>] = [.init(\.name, order: .reverse)]    @State var data = [        Fruit(name: "Apple", color: "Red"),        Fruit(name: "Banana", color: "Yellow"),        Fruit(name: "Cherry", color: "Red"),        Fruit(name: "Date", color: "Brown"),        Fruit(name: "Elderberry", color: "Purple")    ]    var body: some View {        sortKeyPathView() // 排序状态        Table(data, sortOrder: $sortOrder) {            TableColumn("Fruit", value: \.name)            TableColumn("Color", value: \.color)            // 不含 value 参数的不支持排序            TableColumn("ColorNoOrder") {                Text("\($0.color)")                    .font(.footnote)                    .foregroundStyle(.mint)            }        }        .task {            data.sort(using: sortOrder)        }        .onChange(of: sortOrder) { oldValue, newValue in            data.sort(using: newValue)        }        .padding()    }        @ViewBuilder    func sortKeyPathView() -> some View {        HStack {            ForEach(sortOrder, id: \.self) { order in                Text(order.keyPath == \Fruit.name ? "名字" : "颜色")                Image(systemName: order.order == .reverse ? "chevron.down" : "chevron.up")            }        }        .padding(.top)    }}struct Fruit: Identifiable {    let id = UUID()    let name: String    let color: String}

在这个例子中,我们首先定义了一个 @State 变量 sortOrder,它是一个 SortDescriptor 数组,定义了排序的顺序和方式。然后,我们将这个变量绑定到 Table 视图的 sortOrder 参数。

现在,当用户点击表头来排序一个列时,sortOrder 变量就会被更新。你可以使用这个变量来实现多属性排序,或者实现其他的交互功能。

Table-contextMenu

struct ContentView: View {    @State private var selection: Set<UUID> = []    var body: some View {        Table(Fruit.simpleData(), selection: $selection) {            ...        }        .contextMenu(forSelectionType: Fruit.ID.self) { selection in            if selection.isEmpty {                Button("添加") {                    // ...                }            } else if selection.count == 1 {                Button("收藏") {                    // ...                }            } else {                Button("收藏多个") {                    // ...                }            }        } primaryAction: { items in            // 双击某一行时            debugPrint(items)        }    }    ...}

小册子之简说 Widget 小组件

作者 戴铭
2024年5月18日 08:07

以下内容已整理到小册子中,小册子代码在 Github 上,可以在 macOS 应用商店安装“戴铭的开发小册子”应用查看。

Widge 允许开发者在用户的主屏幕或通知中心展示应用的信息。Widget 可以提供快速的信息预览,或者提供快速访问应用的方式。

开发 Widget 的基本步骤如下:

  1. 创建 Widget Extension:在 Xcode 中,你需要创建一个新的 Widget Extension。这将会生成一个新的 target,包含了创建 Widget 所需的基本代码。

  2. 定义 Timeline Entry:Timeline Entry 是 Widget 数据的模型。你需要创建一个遵循 TimelineEntry 协议的结构体,定义你的 Widget 所需的数据。

  3. 创建 Widget View:Widget View 是 Widget 的用户界面。你需要创建一个 View,展示你的 Widget 的内容。

  4. 实现 Timeline Provider:Timeline Provider 是 Widget 数据的提供者。你需要创建一个遵循 TimelineProvider 协议的结构体,提供 Widget 的数据。

  5. 配置 Widget:在 Widget 的主结构体中,你需要配置你的 Widget,包括它的类型(静态或者动态)、数据提供者、视图等。

  6. 测试 Widget:在模拟器或者真机上测试你的 Widget,确保它的数据和视图都按预期工作。

接下来,我们将详细介绍 Widget 的开发流程。

小组件-StaticConfiguration 静态配置

在 Xcode 中,File -> New -> Target,选择 Widget Extension。这将会生成一个新的 target,包含了创建 Widget 所需的基本代码。

以下是一个简单的小组件代码示例:

import WidgetKitimport SwiftUI// Timeline Entrystruct ArticleEntry: TimelineEntry {    let date: Date    let title: String}// Widget Viewstruct ArticleWidgetView : View {    let entry: ArticleEntry    var body: some View {        Text(entry.title)    }}// Timeline Providerstruct ArticleTimelineProvider: TimelineProvider {    typealias Entry = ArticleEntry        func placeholder(in context: Context) -> Entry {        // 占位大小,内容不会显示        return ArticleEntry(date: Date(), title: "Placeholder")    }    func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {        let entry = ArticleEntry(date: Date(), title: "Snapshot")        completion(entry)    }    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {        let entry = ArticleEntry(date: Date(), title: "Timeline")        let timeline = Timeline(entries: [entry], policy: .never)        completion(timeline)    }}// Widget Configuration@mainstruct ArticleWidget: Widget {        var body: some WidgetConfiguration {        StaticConfiguration(            kind: "com.starming.articleWidget",            provider: ArticleTimelineProvider()        ) { entry in            ArticleWidgetView(entry: entry)        }        .configurationDisplayName("Article Widget")        .description("这是一个 Article Widget.")        .supportedFamilies([            .systemSmall,            .systemMedium,            .systemLarge,        ])    }}

在上面的代码中,我们定义了一个 ArticleWidget 小组件,它包含了一个 ArticleEntry 数据模型、一个 ArticleWidgetView 视图、一个 ArticleTimelineProvider 数据提供者和一个 ArticleWidget 配置。

小组件-AppIntentConfiguration

iOS 17 开始可以使用 AppIntentConfiguration 来配置小组件,这样可以让小组件和 AppIntent 交互。这样可以让小组件和 App 之间的进行交互。

下面是一个简单的小组件代码示例,展示了如何使用 AppIntentConfiguration 来配置小组件和 AppIntent 交互

import SwiftUIimport WidgetKitimport AppIntentsstruct ArticleWidget: Widget {    var body: some WidgetConfiguration {        AppIntentConfiguration(            kind: "com.starming.articleWidget",            intent: ArticleIntent.self,            provider: ArticleIntentProvider()        ) { entry in            ArticleWidgetView(entry: entry)        }        .configurationDisplayName("Article Widget")        .description("这是一个 Article Widget.")        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])    }}struct ArticleWidgetView: View {    var entry: IntentProvider.Entry    var body: some View {        Text(entry.author)    }}struct ArticleIntentProvider: AppIntentTimelineProvider {    func snapshot(for configuration: ArticleIntent, in context: Context) async -> ArticleEntry {        return .init(            date: Date(),            author: "snapshot"        )    }    func placeholder(in context: Context) -> ArticleEntry {        return .init(            date: Date(),            author: "某人"        )    }    func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {        return Timeline(            entries: [                .init(date: Date(),                      author: configuration.author,                      rate: await ArticleStore().rate())],            policy: .never)    }}struct ArticleEntry: TimelineEntry {    let date: Date    let author: String    var rate: Int = 0    //...}// 放在主应用中和小组件交互struct ArticleIntent: WidgetConfigurationIntent {        static var title: LocalizedStringResource  = "文章"    var author: String = "某某某"    func perform() async throws -> some IntentResult {        //...        return .result()    }}class ArticleStore {    //... SwiftData 相关配置    @MainActor    func rate() async -> Int {        //... 获取        return 5    }}

如上代码所示,我们定义了一个 ArticleWidget 小组件,它包含了一个 ArticleIntent 数据模型、一个 ArticleWidgetView 视图、一个 ArticleIntentProvider 数据提供者和一个 ArticleWidget 配置。

小组件-配置选项

显示区域

iOS 17 新增显示区域配置,有下面四种

  • homeScreen:主屏幕
  • lockScreen:锁屏
  • standBy:待机
  • iPhoneWidgetsOnMac:iPhone 上的 Mac 小组件

设置小组件不在哪个区域显示某尺寸。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 在 StandBy 中取消显示 systemSmall 尺寸        .disfavoredLocations([.standBy], for: [.systemSmall])    }}

取消内容边距

使用 .contentMarginsDisabled() 取消内容边距。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 使 Content margin 失效        .contentMarginsDisabled()    }}

每个平台内容边距大小不同,环境变量 \.widgetContentMargins 可以读取内容边距的大小。

取消背景删除

在 StandBy 和 LockScreen 的某些情况,小组件的背景是会被自动删除的。

使用 containerBackgroundRemovable() 修饰符可以取消背景删除。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 取消背景删除        .containerBackgroundRemovable(false)        // 让自己的背景可以全覆盖        .contentMarginsDisabled()    }}

后台网络处理

.onBackgroundURLSessionEvents { (identifier, completion) in    //...}

AppIntentTimelineProvider

AppIntentConfiguration 需要 AppIntentTimelineProvider,AppIntentTimelineProvider 需要实现 snapshotplaceholdertimeline 三个方法来确定小组件在展示和实际运行时间线时的视图和数据。

struct ArticleIntentProvider: AppIntentTimelineProvider {    func snapshot(for configuration: ArticleIntent, in context: Context) async -> ArticleEntry {        return .init(            date: Date(),            author: "snapshot"        )    }    func placeholder(in context: Context) -> ArticleEntry {        return .init(            date: Date(),            author: "某人"        )    }    func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {        return Timeline(            entries: [                .init(date: Date(),                      author: configuration.author,                      rate: await ArticleStore().rate())],            policy: .never)    }}struct ArticleEntry: TimelineEntry {    let date: Date    let author: String    var rate: Int = 0    //...}````## Widget View### 不同的大小设置不同视图```swiftstruct ArticleWidgetView: View {  var entry: Provider.Entry  @Environment(\.widgetFamily) var family  @ViewBuilder  var body: some View {    switch family {    case .systemSmall:        SomeViewSmall()    default:      SomeViewDefault()    }  }}

锁屏小组件

让小组件支持锁屏

struct ArticleWidget: Widget {    var body: some WidgetConfiguration {        StaticConfiguration(            ...        ) { entry in            ...        }        ...        .supportedFamilies([            .systemSmall,            .systemMedium,            .systemLarge,            // 添加支持到 Lock Screen widgets            .accessoryCircular,            .accessoryRectangular,            .accessoryInline,        ])    }}

不同类型 widgetFamily 实现不同视图

struct ArticleWidgetView : View {       let entry: ViewSizeEntry    // 获取 widget family 值    @Environment(\.widgetFamily) var family    var body: some View {        switch family {        case .accessoryRectangular:            RectangularWidgetView()        case .accessoryCircular:            CircularWidgetView()        case .accessoryInline:            InlineWidgetView()        default:            ArticleWidgetView(entry: entry)        }    }}

不同渲染模式实现不同视图

小组件有三种不同的渲染模式:

  • Full-color:主屏用
  • Vibrant:用于待机模式和锁屏
  • The accented:用于手表
struct ArticleWidgetView: View {    let entry: Entry        @Environment(\.widgetRenderingMode) private var renderingMode        var body: some View {        switch renderingMode {        case .accented:            AccentedWidgetView(entry: entry)        case .fullColor:            FullColorWidgetView(entry: entry)        case .vibrant:            VibrantWidgetView(entry: entry)        default:            DefaultView()        }    }}

视图交互

使用 AppIntent

struct ArticleWidgetView : View {    var entry: IntentProvider.Entry    var body: some View {        VStack(spacing: 20) {            ...            Button(intent: RunIntent(rate: entry.rate), label: {                ...            })        }    }}

刷新小组件

通过 Text 视图更新

倒计时

let futureDate = Calendar.current.date(byAdding: components, to: Date())!// 日期会在 Text 视图中动态变化
struct CountdownWidgetView: View {        var body: some View {        Text(futureDate(), style: .timer)    }        private func futureDate() -> Date {        let components = DateComponents(second: 10)        let futureDate = Calendar.current.date(byAdding: components, to: Date())!        return futureDate    }}

Timeline Provider 更新

在 timeline 方法中实现,entries 包含了不同更新的数据。

func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {    return Timeline(        entries: [            .init(date: Date(),                  author: configuration.author,                  rate: await ArticleStore().rate())],        policy: .never)}

更新策略

3 种类型的刷新策略:

  • atEnd:上个刷新完成直接进入下个刷新,但是进入下一个刷新的时间由系统决定。
  • after(Date):指定进入下个刷新的时间,但是具体时间还是由系统说了算,因此可以理解为是指定的是最早进入下个刷新的时间。
  • never:不会进入下个刷新,除非显式调用 reloadTimelines(ofKind:)

举例,指定下个刷新周期至少是上个周期结束10秒后:

let lastUpdateDate = entries.last!.datelet nextUpdateDate = Calendar.current.date(byAdding: DateComponents(second: 10), to: lastUpdate)!let timeline = Timeline(entries: entries, policy: .after(nextUpdate))

Relevance 优先级

App 自定义刷新 Timeline 的优先级,使用 Relevance。先在 TimelineEntry 里定义:

struct ArticleEntry: TimelineEntry {    let date: Date    ...    let relevance: TimelineEntryRelevance?}

在 timeline 方法中根据必要刷新程序,定义不同 relevance 的值。

App 主动刷新

// 刷新单个小组件WidgetCenter.shared.reloadTimelines(ofKind: "CountryWidget")// 刷新所有小组件WidgetCenter.shared.reloadAllTimelines()

刷新小组件的最佳实践

调试时刷新率不会有限制,生产环境每天最多40到70次,相当于每15到60分钟刷新一次。

小组件动画

Text 视图动态时间

利用 Text 的动态时间能力

timeline 动画

timeline 是由一组时间和数据组成的,每次刷新时,小组件通过和上次数据不一致加入动画效果。

默认情况小组件使用的是弹簧动画。我们也可以添加转场(Transition)、动画(Animation)和内容过渡(Content Transition)动画效果。

文本内容过渡动画效果

.contentTransition(.numericText(value: rate))

从底部翻上来的专场

.transition(.push(from: .bottom))

小组件-远程定时获取数据

在 TimelineProvider 中的 timeline 方法中加入请求逻辑

func timeline(for configuration: RunIntent, in context: Context) -> Void) async -> Timeline<ArticleEntry> {    guard let article = try? await ArticleFetch.fetchNewestArticle() else {        return    }    let entry = ArticleEntry(date: Date(), article: article)        // 下次在 30 分钟后再请求    let afterDate = Calendar.current.date(byAdding: DateComponents(minute: 30), to: Date())!    return Timeline(entries: [entry], policy: .after(afterDate))}

以上代码中,我们在 timeline 方法中请求了最新的文章数据,并且设置了下次请求的时间是当前时间的 30 分钟后。

小组件-获取位置权限更新内容

小组件获取位置权限和主应用 target 里获取方式很类似,步骤:

  • 在 info 里添加 NSWidgetUseLocation = ture
  • 使用 CLLocationManager 来获取位置信息,设置较低的精度。
  • isAuthorizedForWidgetUpdates 请求位置权限。

支持多个小组件

widget bundle 可以支持多个小组件。

@mainstruct FirstWidgetBundle: WidgetBundle {        @WidgetBundleBuilder    var body: some Widget {        FirstWidget()        SecondWidget()        ...        SecondWidgetBundle().body    }}struct SecondWidgetBundle: WidgetBundle {    @WidgetBundleBuilder    var body: some Widget {        SomeWidgetOne()        SomeWidgetTwo()        ...    }}

获取小组件形状

不同设备小组件大小和形状都不同,比如要加个边框,就很困难。这就需要使用 ContainerRelativeShape 来获取 Shape 视图容器。

var body: some View {  ZStack {    ContainerRelativeShape()        .inset(by: 2)        .fill(.pink)    Text("Hello world")    ...  }}

小组件-Deep link

medium 和 large 的小组件可以使用 Link,small 小组件使用 .widgetURL 修饰符。

小组件访问SwiftData

Wdiget target 访问主应用 target 的 SwiftData 数据步骤如下:

  • 对主应用和 Widget 的 target 中的 Signing & Capabilities 都添加 App Groups,并创建一个新组,名字相同。
  • SwiftData 的模型同时在主应用和 Widget 的 target 中。
  • StaticConfiguration 或 AppIntentConfiguration 中添加 modelContainer() 修饰符,让 SwiftData 的容器可用。

小组件-参考资料

WWDC

23

22

21

20

小册子之如何使用 SwiftData 开发 SwiftUI 应用

作者 戴铭
2024年5月18日 00:17

以下内容已整理到小册子中,小册子代码在 Github 上,可以在 macOS 应用商店安装“戴铭的开发小册子”应用查看。

小册子代码里有大量 SwiftData 实际使用实践的代码。

在 Swift 中,有许多库可以用于处理数据,包括但不限于 SwiftData、CoreData、Realm、SQLite.swift 等。这些库各有优势。

但,如果使用 SwiftData,你可以在 Swift 中更加方便地处理数据。SwiftData 是 Apple 在 WWDC23 上推出的一个新的数据持久化框架,它是 CoreData 的替代品,提供了更简单、更易用的 API。

创建@Model模型

先说说如何创建 SwiftData 模型。

创建

@Model 宏装饰类

@Modelfinal class Article {    let title: String    let author: String    let content: String    let publishedDate: Date        init(title: String, author: String, content: String, publishedDate: Date) {        self.title = title        self.author = author        self.content = content        self.publishedDate = publishedDate    }}

以上代码创建了一个 Article 模型,包含了标题、作者、内容和发布日期。

以下数据类型默认支持:

  • 基础类型:Int, Int8, Int16, Int32, Int64, UInt, UInt8, UInt16, UInt32, UInt64, Float, Double, Bool, String, Date, Data 等
  • 复杂的类型:Array, Dictionary, Set, Optional, Enum, Struct, Codable 等
  • 模型关系:一对一、一对多、多对多

默认数据库路径: Data/Library/Application Support/default.store

@Attribute

接下来说说如何使用 @Attribute 宏。

一些常用的:

  • spotlight:使其能出现在 Spotlight 搜索结果里
  • unique:值是唯一的
  • externalStorage:值存储为二进制数据
  • transient:值不存储
  • encrypt:加密存储

使用方法

@Attribute(.externalStorage) var imgData: Data? = nil

二进制会将其存储为单独的文件,然后在数据库中引用文件名。文件会存到 Data/Library/Application Support/.default_SUPPORT/_EXTERNAL_DATA 目录下。

@Transient 不存

如果有的属性不希望进行存储,可以使用 @Transient

@Modelfinal class Article {    let title: String    let author: String    @Transient var content: String    ...}

transformable

SwiftData 除了能够存储字符串和整数这样基本类型,还可以存储更复杂的自定义类型。要存储自定义类型,可用 transformable。

@Modelfinal class Article {    let title: String    let author: String    let content: String    let publishedDate: Date    @Attribute(.transformable(by: UIColorValueTransformer.self)) var bgColor: UIColor    ...}

UIColorValueTransformer 类的实现

class UIColorValueTransformer: ValueTransformer {        // return data    override func transformedValue(_ value: Any?) -> Any? {        guard let color = value as? UIColor else { return nil }        do {            let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: true)            return data        } catch {            return nil        }    }        // return UIColor    override func reverseTransformedValue(_ value: Any?) -> Any? {        guard let data = value as? Data else { return nil }                do {            let color = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data)            return color        } catch {            return nil         }    }}

注册

struct SwiftPamphletAppApp: App {    init() {        ValueTransformer.setValueTransformer(UIColorValueTransformer(), forName: NSValueTransformerName("UIColorValueTransformer"))    }        var body: some Scene {        WindowGroup {            ContentView()                .modelContainer(for: [Article.self])        }    }}

SwiftData-模型关系

使用 ``@Relationship` 添加关系,但是不加这个宏也可以,SwiftData 会自动添加模型之间的关系。

@Modelfinal class Author {    var name: String    @Relationship(deleteRule: .cascade, inverse: \Brew.brewer)    var articles: [Article] = []}@Modelfinal class Article {    ...    var author: Author}

默认情况 deleteRule 是 .nullify,这个删除后只会删除引用关系。.cascade 会在删除用户后删除其所有文章。

SwiftData 可以添加一对一,一对多,多对多的关系。

限制关系表数量

@Relationship(maximumModelCount: 5)    var articles: [Article] = []

容器配置modelContainer

多模型

配置方法

@mainstruct SomeApp: App {    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(for: [Article.self, Author.self])    }}

有关系的两个模型,只需要加父模型,SwiftData 会推断出子模型。

数据存内存

let configuration = ModelConfiguration(inMemory: true)let container = try ModelContainer(for: schema, configurations: [configuration])

数据只读

let config = ModelConfiguration(allowsSave: false)

自定义存储文件和位置

如果要指定数据库存储的位置,可以按下面写法:

@mainstruct SomeApp: App {    var container: ModelContainer    init() {        do {            let storeURL = URL.documentsDirectory.appending(path: "database.sqlite")            let config = ModelConfiguration(url: storeURL)            container = try ModelContainer(for: Article.self, configurations: config)        } catch {            fatalError("Failed")        }    }    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(container)    }}

iCloud 支持

如果要添加 iCloud 支持,需要先确定模型满足以下条件:

  • 没有唯一约束
  • 关系是可选的
  • 有所值有默认值

iCloud 支持操作步骤:

  • 进入 Signing & Capabilities 中,在 Capability 里选择 iCloud
  • 选中 CloudKit 旁边的框
  • 设置 bundle identifier
  • 再按 Capability,选择 Background Modes
  • 选择 Remote Notifications

指定部分表同步到 iCloud

使用多个 ModelConfiguration 对象来配置,这样可以指定哪个配置成同步到 iCloud,哪些不同步。

添加多个配置

@mainstruct SomeApp: App {    var container: ModelContainer    init() {        do {            let c1 = ModelConfiguration(for: Article.self)            let c2 = ModelConfiguration(for: Author.self, isStoredInMemoryOnly: true)            container = try ModelContainer(for: Article.self, Author.self, configurations: c1, c2)        } catch {            fatalError("Failed")        }    }    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(container)    }}

撤销和重做

创建容器时进行指定

.modelContainer(for: Article.self, isUndoEnabled: true)

这样 modelContext 就可以调用撤销和重做函数。

struct SomeView: View {    @Environment(\.modelContext) private var context    var body: some View {        Button(action: {            context.undoManager?.undo()        }, label: {            Text("撤销")        })    }}

context

View 之外的地方,可以通过 ModelContainer 的 context 属性来获取 modelContext。

let context = container.mainContextlet context = ModelContext(container)

预先导入数据

方法如下:

.modelContainer(for: Article.self) { result in    do {        let container = try result.get()        // 先检查有没数据        let descriptor = FetchDescriptor<Article>()        let existingArticles = try container.mainContext.fetchCount(descriptor)        guard existingArticles == 0 else { return }        // 读取 bundle 里的文件        guard let url = Bundle.main.url(forResource: "articles", withExtension: "json") else {            fatalError("Failed")        }        let data = try Data(contentsOf: url)        let articles = try JSONDecoder().decode([Article].self, from: data)        for article in articles {            container.mainContext.insert(article)        }    } catch {        print("Failed")    }}

增删modelContext

添加保存数据

struct SomeView: View {   @Environment(\.modelContext) var context   ...   var body: some View {         ...         Button(action: {             self.add()         }, label: {             Text("添加")         })   }   func add() {      ...      context.insert(article)   }}

默认不用使用 context.save(),SwiftData 会自动进行保存,如果不想自动保存,可以在容器中设置

var body: some Scene {   WindowGroup {      ContentView()   }   .modelContainer(for: Article.self, isAutosaveEnabled: false)       }

编辑和删除数据

编辑数据使用 @Bindable

struct SomeView: View {    @Bindable var article: Article    @Environment(\.modelContext) private var modelContext    ...        var body: some View {        Form {            TextField("文章标题", text: $article.title)            ...        }        .toolbar {            ToolbarItem(placement: .destructiveAction) {                Button("删除") {                    modelContext.delete(article)                }            }        }        ...    }}

SwiftData-检索

@Query

使用 @Query 会从数据库中获取数据。

@Query private var articles: [Article]

@Query 还支持 filter、sort、order 和 animation 等参数。

@Query(sort: \Article.title, order: .forward) private var articles: [Article]

sort 可支持多个 SortDescriptor,SwiftData 会按顺序处理。

@Query(sort: [SortDescriptor(\Article.isArchived, order: .forward),SortDescriptor(\Article.updateDate, order: .reverse)]) var articles: [Article]

Predicate

filter 使用的是 #Predicate

static var now: Date { Date.now }@Query(filter: #Predicate<Article> { article in    article.releaseDate > now}) var draftArticles: [Article]

Predicate 支持的内置方法主要有 containsallSatisfyflatMapfiltersubscriptstartsminmaxlocalizedStandardContainslocalizedComparecaseInsensitiveCompare 等。

@Query(filter: #Predicate<Article> { article in    article.title.starts(with: "苹果发布会")}) var articles: [Article]

需要注意的是 .isEmpty 不能使用 article.title.isEmpty == false ,否则会崩溃。

FetchDescriptor

FetchDescriptor 可以在模型中查找数据,而不必在视图层做。

@Modelfinal class Article {    var title: String    ...    static var all: FetchDescriptor<Article> {        FetchDescriptor(sortBy: [SortDescriptor(\Article.updateDate, order: .reverse)])    }}struct SomeView: View {       @Query(Article.all) private var articles: [Article]    ...}

获取数量而不加载

使用 fetchCount() 方法,可完成整个计数,且很快,内存占用少。

let descriptor = FetchDescriptor<Article>(predicate: #Predicate { $0.words > 50 })let count = (try? modelContext.fetchCount(descriptor)) ?? 0

fetchLimit 限制获取数量

var descriptor = FetchDescriptor<Article>(  predicate: #Predicate { $0.read },  sortBy: [SortDescriptor(\Article.updateDate,           order: .reverse)])descriptor.fetchLimit = 30let articles = try context.fetch(descriptor)// 翻页let pSize = 30let pNumber = 1var fetchDescriptor = FetchDescriptor<Article>(sortBy: [SortDescriptor(\Article.updateDate, order: .reverse)])fetchDescriptor.fetchOffset = pNumber * pSizefetchDescriptor.fetchLimit = pSize

限制获取的属性

只请求要用的属性

var fetchDescriptor = FetchDescriptor<Article>(sortBy: [SortDescriptor(\.updateDate, order: .reverse)])fetchDescriptor.propertiesToFetch = [\.title, \.updateDate]

SwiftData-处理大量数据

SwiftData 模型上下文有个方法叫 enumerate(),可以高效遍历大量数据。

let descriptor = FetchDescriptor<Article>()...do {    try modelContext.enumerate(descriptor, batchSize: 1000) { article in        ...    }} catch {    print("Failed.")}

其中 batchSize 参数是调整批量处理的数量,也就是一次加载多少对象。因此可以通过这个值来权衡内存和IO数量。这个值默认是 5000。

SwiftData多线程

创建一个 Actor,然后 SwiftData 上下文在其中执行操作。

@ModelActoractor DataHandler {}extension DataHandler {    func addInfo() throws -> IOInfo {        let info = IOInfo()        modelContext.insert(info)        try modelContext.save()        return info    }    ...}

使用

Task.detached {    let handler = DataHandler()    let item = try await handler.addInfo()       ...}

SwiftData-版本迁移

以下的小改动 SwiftData 会自动执行轻量迁移:

  • 增加模型
  • 增加有默认值的新属性
  • 重命名属性
  • 删除属性
  • 增加或删除 .externalStorage.allowsCloudEncryption 属性。
  • 增加所有值都是唯一属性为 .unique
  • 调整关系的删除规则

其他情况需要用到版本迁移,版本迁移步骤如下:

  • 用 VersionedSchema 创建 SwiftData 模型的版本
  • 用 SchemaMigrationPlan 对创建的版本进行排序
  • 为每个迁移定义一个迁移阶段

设置版本

enum ArticleV1Schema: VersionedSchema {    static var versionIdentifier: String? = "v1"    static var models: [any PersistentModel.Type] { [Article.self] }    @Model    final class Article {        ...    }}

SchemaMigrationPlan 轻量迁移

enum ArticleMigrationPlan: SchemaMigrationPlan {    static var schemas: [any VersionedSchema.Type] {        [ArticleV1Schema.self, ArticleV2Schema.self]    }    static var stages: [MigrationStage] {        [migrateV1toV2]    }    static let migrateV1toV2 = MigrationStage.lightweight(        fromVersion: ArticleV1Schema.self,        toVersion: ArticleV2Schema.self    )}

自定义迁移

static let migrateV1toV2 = MigrationStage.custom(    fromVersion: ArticleV1Schema.self,    toVersion: ArticleV2Schema.self,    willMigrate: { context in        // 合并前的处理    },    didMigrate: { context in        // 合并后的处理    })

SwiftData-调试

CoreData 的调试方式依然适用于 SwiftData。

你可以设置启动参数来让 CoreData 打印出执行的 SQL 语句。在你的项目中,选择 “Product” -> “Scheme” -> “Edit Scheme”,然后在 “Arguments” 标签下的 “Arguments Passed On Launch” 中添加 -com.apple.CoreData.SQLDebug 1。这样,每当 CoreData 执行 SQL 语句时,都会在控制台中打印出来。

使用 -com.apple.CoreData.SQLDebug 3 获取后台更多信息。

SwiftData-资料

WWDC

23

使用 SwiftUI 开发 RSS 阅读器

作者 戴铭
2023年4月24日 15:51

在 Apple 加速器活动和字节内分享了使用 SwiftUI 做 RSS 阅读器的一点心得。可能你还不知道什么是 RSS 阅读器,简单来说 RSS 是一些博客和新闻网站,甚至是播客和视频平台发布他们的内容更新的一种 XML 格式,阅读器就是通过请求这个 XML 以获取他们内容更新的客户端。

这就有了接下来几个问题:

目前已有 Reeder 和 NetNewsWire 等 RSS 阅读器,那么为什么还要再开发一个呢,早在14年我曾做过一个,陆续也更新过,后来还是以 Reeder 作为主力,feedly 作为服务,后来 feedly 有些不稳定,我又改成本地获取 feed 的方式,但是改成本地模式后设备同步又成了问题。正好最近几年苹果在界面、数据流和存储上都做了很大的功能加强。于是我打算将以前 objc、rac和 FMDB 替换成 SwiftUI 和 CoreData 技术,同时补上以前缺少的一些功能,比如添加管理feed,不同设备同步订阅 feed、文章已读状态和收藏信息等功能。

先说下怎么订阅 RSS。

如上图所示先通过链接获取待解析的数据,以及 mimeType,通过 mimeType 看里面是否包含如下描述:

application/atom+xmlapplication/rss+xmlapplication/jsonapplication/feed+jsontext/xmlapplication/xml

包含的话就可以判断是 RSS。

如果不是的话就需要手动从网页里获取 RSS 的链接,方法如下:

mime.contains("text/html")SwiftSoup.parse(homepageHTML)htmlDom.select("link[rel=alternate]")

其中 SwiftSoup 是一个专门用来将 HTML 解析成 DOM 对象的库。一般 RSS 的链接会在属性键值是 rel 和 alternate 的 Link 这个标签里。但是很多网站并没有遵循这个规范,那么就需要在链接后直接通过添加以下文件名来查找哪个是它的 RSS 链接:

["feed.xml","rss.xml","atom.xml","feed","feed.rss","rss","index.xml"]

找到了 RSS 的链接就可以获取到它的数据,接下来就是对数据的处理,根据 RSS 的规范,RSS 的数据主要是以下三种。

对应的结构体如下:

RSS 的图标的获取方式有两种

对处理好的数据需要进行本地的存储,目前不管是 Apple 还是三方库主要都是基于 SQLite 的封装。估计是因为 SQLite 开销小,支持大多数 SQL 92 标准语法,采用标准的 ANSI-C 代码,很容易在多个平台运行,同时 SQLite 还支持所有 SQL 用来保障数据安全和完整性的事务属性,比如原子性、一致性、隔离性和持久性。以下是 iOS 上一些主要基于 SQLite 封装库:

我选择的是 Core Data,首先是 Core Data 的 API 很强,将复杂数据建模和操作的 SQL 语句都做成了可视化和对象模式操作。多个数据对象之间的关联关系也做了很多自动处理。Core Data 还使用了惰性加载的方式,只有在需要时才从存储区域获取数据,以节省内存,提高执行效率。

Core Data 的使用需要对数据库进行设置。

在读取实体存储时可以设置 Core Spotlight 以及进行一些调试测试工作。

Core Data 对数据的增删改和检索操作都是在 NSManagerObjectContext 中完成的。

如果要支持 CloudKit,NSManagerObjectContext 初始化时需要在合并策略做一些设置。context 的数据操作都是基于对象操作的方式,比如增加一个 feed 就是在 context 中创建一个 feed 的对象,然后对其字段对应的属性进行设置即可。

删除就是用 context 的 delete 方法将对要删除数据对应的对象进行删除即可。

修改就是对读取的对象进行设置。

检索有两种方式,一种是创建一个 Controller,使用 lazy 来修饰检索检索结果,惰性加载以节省内存。数据变化会在 NSFetchedResultsController 代理里进行回调,在回调里可以更新 @Published 属性包装的属性以及时同步展示更新的数据。

另一种检索方式是使用 @FetchRequest 属性包装,写法更加简洁。

下面是 RSS 数据操作对应的代码。

添加 Feed 的代码

let newFeed = WebFeedMO(context: stack.context)newFeed.id = UUID()newFeed.createAt = Date.nownewFeed.homePageURL = inputURLstack.save()await handleAFeed(webFeed: newFeed) // 文章

删除 Feed

for a in webFeed.allElements {    stack.context.delete(a)}stack.deleteWebFeed(webFeed)

检索 Feed 列表

let fetch = WebFeedMO.fetchRequest()let sortDescriptorUnreadCount = NSSortDescriptor(key: "unreadCount", ascending: false)let sortDescriptorCreateAt = NSSortDescriptor(key: "createAt", ascending: false)fetch.sortDescriptors = [sortDescriptorUnreadCount, sortDescriptorCreateAt]let controller = NSFetchedResultsController(fetchRequest: fetch, managedObjectContext: stack.context, sectionNameKeyPath: nil, cacheName: "webFeeds")controller.delegate = selftry? controller.performFetch()

Feed 里文章的列表检索

let fetch = ArticleMO.fetchRequest()let sortDescriptor = NSSortDescriptor(key: "datePublished", ascending: false)fetch.sortDescriptors = [sortDescriptor]let controller = NSFetchedResultsController(fetchRequest: fetch, managedObjectContext: stack.context, sectionNameKeyPath: nil, cacheName: "newArticles")controller.delegate = selftry? controller.performFetch()

标记已读

@Published var selectedArticle: ArticleMO? {    willSet(newValue) {        newValue?.read = true        selectedWebFeed?.countUnreadArticles()    }}

全部标记已读

let countElement = selectedWebFeed?.allElements.count ?? 0var index = 0for a in selectedWebFeed?.allElements ?? [] {    index += 1    if a.read == false {        a.read = true    }    if countElement > 1000 && index > 1000 && a.favourite == false {        stack.context.delete(a)    }}// 最后重置未读总数selectedWebFeed?.countUnreadArticles()

收藏状态的切换直接对布尔属性 favourite 执行 toggle 方法。

selectedArticle?.favourite.toggle()selectedArticle?.dateModified = Date.now

工具栏中的分享功能可以直接使用 SwiftUI 内置的 ShareLink 视图。Item 的 placement 对于不同平台的位置会有不同。

.toolbar {    ToolbarItemGroup(placement: .primaryAction) {        Menu {            Button { ... } label: {                Label("拷贝链接", systemImage: "doc.on.doc")            }            Divider()            ShareLink("分享", item: link)        } label: {            Image(systemName: "square.and.arrow.up")        }    }    ToolbarItemGroup(placement: .automatic) {        Button { ... } label: {            Label("收藏", systemImage: "star")        }        Button { ... } label: {            Label("浏览器", systemImage: "safari")        }    }} // end toolbar

由于网站提供的 RSS 是静态的,因此每次获取数据时需要进行和本地存储的数据进行比对去重。

Core Data 提供了一种通过简单配置约束就可以去重的方法。具体方法如上图所示。

但是如果要支持 iCloud 就没法使用唯一约束这个功能。因此只能回到老办法,手动比对。

为了提升大量数据添加的效率,可以使用 NSBatchInsertRequest。正常情况下,在使用 Core Data 进行大量数据插入时,应用程序需要为每个插入操作都创建上下文和执行请求。这样会导致上下文过度膨胀和查询操作的重复,并且会对内存和 CPU 带来负担。而 NSBatchInsertRequest 则能够通过批量插入的方式一次性将多条数据插入到 Core Data 中,并且执行速度要比逐条插入要快得多。NSBatchInsertRequest 实际上是在底层利用 SQLite 数据库的 INSERT INTO 语法来执行批量插入操作。这种方式通过一次性将数据提交给 SQLite,可以减少插入操作所需的检查、协调和锁定操作,从而提高插入操作的效率和性能。当使用 NSBatchInsertRequest 执行批量插入时,Core Data 会首先创建一个临时表,然后将待插入的数据全部插入到该临时表中。接着,Core Data 会使用关联操作将临时表中的数据一次性插入到实际的数据库表中,从而进一步提高了数据插入的效率。NSBatchInsertRequest 还提供了一些可用的参数设置选项,开发者可以根据具体的需求进行灵活配置。例如,通过设置 batchSize 参数,可以控制批量插入时每个批次所包含的最大行数,以避免内存的过度消耗;通过设置 propertiesToUpdate 参数,可以在批量插入后更新指定的属性值,从而避免对整个对象进行额外的查询和更新操作。

Core Data 里的数据可以通过 iCloud 实现多设备的同步,比如我在 macOS 上订阅、阅读和收藏的信息能够无缝切换到手机和 iPad 上。未来支持 iCloud 可以进行如下的设置:

支持 iCloud 也会有一些限制,对于我目前来说最大限制就是不支持唯一约束,另外数据表结构更改后老版本的兼容也是需要注意的,这是由于 iCloud 是云端数据统一传输,并不会兼容多版本。

通过以下方法可以让兼容合并更安全。

应用支持 iCloud 后会有 cloudd 这个后台进程对 iCloud 服务的同步和管理,定期检查 iCloud 上数据是否需要同步到本地设备,或者本地数据是否需要传到 iCloud。 apsd 进程会将数据的更新以通知的方式推送到其他设备,dasd 进程会对 iCloud 的数据进行处理然后交给应用进程。对这个流程的调试就是基于上面提到的这四个进程进行日志记录。

另外 Core Data 还支持一些调试参数,除了 iCloud 还可以支持多线程、SQL、合并等信息的日志打印。

为了节省 iCloud 空间大小,对于文章内容这样数据量大的数据就不用支持 iCloud 了,方法是如下:

另外,Core Data 里的数据还能够很容易的支持 spotlight 索引,方便在应用外能够被检索。

界面使用的是 NavigationSplitView。代码如下:

struct HomeThreeColumnView: View {    @EnvironmentObject var webFeedController: WebFeedController    var body: some View {        NavigationSplitView {            SidebarView() // 左侧频道列表        } content: {            AWebFeedArticlesView() // 文章列表        } detail: {            ArticleWebView() // 文章内容        }    } // end body}

NavigationSplitView 可以同时显示主视图和辅助视图。实现了 iOS 系统中常见的 iPad 多窗口布局模式,允许用户同时操作两个视图,提高了应用程序的多任务处理能力和用户体验。NavigationSplitView 提供了一组简洁易用的 API,开发者可以通过少量的代码实现大部分常见的多窗口布局需求。例如,只需要设置主视图和辅助视图的内容即可快速创建一个 NavigationSplitView,而无需手动管理视图控制器的层次结构。NavigationSplitView 还支持自定义视图拆分行为、边缘滑动手势等功能。

数据处理,包括 Core Data 的初始化配置和增删改和检索等我都放在了 Controller 里,Controller 的关键代码如下:

final class WebFeedController: NSObject ,ObservableObject {    @Published var selectedWebFeed: WebFeedMO?    @Published var selectedArticle: ArticleMO?        @Published private(set) var webFeeds: [WebFeedMO] = []    @Published private(set) var newArticles: [ArticleMO] = [] // 最新文章    @Published private(set) var favoriteArticles: [ArticleMO] = [] // 收藏的文章    var stack: NRCDStack        init(stack: NRCDStack) {        ...        webFeeds = fetchedResults.fetchedObjects ?? []        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }        // 获取所有 feed 源    lazy var fetchedResults: NSFetchedResultsController<WebFeedMO> = { ... }()    // 获取最新 article    lazy var fetchedNewArticlesResults: NSFetchedResultsController<ArticleMO> = { ... }()    // 获取收集 article    lazy var fetchFavoriteArticlesResults: NSFetchedResultsController<ArticleMO> = { ... }()}// MARK: - NSFetchedResultsControllerDelegate// 跟踪变化,在回调中处理。extension WebFeedController: NSFetchedResultsControllerDelegate {    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {        webFeeds = fetchedResults.fetchedObjects ?? []        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }}// MARK: - 数据 CRUD 操作extension WebFeedController {    // 更新全部 Feed    func updateAllFeeds() async { ... }        // MARK: - Feed 的操作    // 添加 Feed    @discardableResult    func createFeed(inputURL: String, nameForDisplay: String = "") -> WebFeedMO { ... }    // 删除操作,删掉一个 Feed    func deleteWebFeed(_ webFeed: WebFeedMO) {        stack.deleteWebFeed(webFeed)    }        // 更新    func updateFeedByModel(for webFeed: WebFeedMO, model: FeedModel) { ... }        // MARK: - 文章的操作    // 收藏的文章    func fetchFavoriteArticles() {        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }        // 最新文章    func fetchNewArticles() {        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []    }        // 收藏    func favoriteArticle() {        selectedArticle?.favourite.toggle()        selectedArticle?.dateModified = Date.now    }        // 清空所选 feed 下所有文章    func deleteAll() { ... }        // 标记全部已读    func markAllAsRead() { ... }        // 新增文章    func createArticleByModel(for webFeed: WebFeedMO, model: ArticleModel) async { ... } }

应用最终效果如下图:

WWDC22 笔记

作者 戴铭
2022年6月10日 12:13

第一天

今年是 WWDC 的第39个年头了。今年的 WWDC.playground 活动()是 SwiftGG、T 沙龙和老司机技术一起会和社区开发者们一起聊聊这次 WWDC。WWDC.playground 活动在节日期间每天都会有直播,我会和 61、13 他们参加 6月11日晚上8点那场直播。现在那场直播的录播已经放了出来,地址是 WWDC22.playground - Day 5:回顾 WWDC22

下面我整理了一份今年 WWDC 的指南,也算提供个方便的入口吧。

  1. WWDC22 直播地址微博直播WWDC22 YouTube 地址
  2. Apple WWDC22 页面
  3. Apple WWDC22 指南
  4. Apple Developer app 观看 Session 的 Apple 出的 App。
  5. Session 网页版
  6. Digital Lounge 注册感兴趣的主题,到时候就可以和 Apple 工程师在 Slack 上一起看 Session,交流。
  7. Labs 可以获得和 Apple 专家一对一指导。6号 keynote 完后就可以开始预约。
  8. Beyond WWDC22 和去年一样,这里是 Apple 制作的世界各地的社区活动。
  9. weak self Discord WWDC22 Keynote Watch Party 全球最多听众的 iOS 中文 Podcast 之一 weak self 的活动。
  10. Swiftly Rush WWDC22
  11. iOS Feeds 的 WWDC 2022 新闻聚合
  12. WWWDC.io App 社区的看 Session 的 App。
  13. Keynote 后的 Platforms State of the Union 这个主题是对后面一周 Session 的总结,开发者可以重点关注下。
  14. WWDC Notes 汇聚了大家的 Session 笔记,可以快速看到各个 Session 的重点。
  15. Technologies 这里是 Apple 框架 API 分类地址,看完 Session 可以直接在这里找对应 API 的更新。还有个网站 Apple Platform SDK API Differences 会列出新 SDK 里有哪些框架更新了。
  16. Apple Design Awards 提名作品

Apple Design Awards 提名作品,我先列几个我喜欢的:

  1. procreate
  2. Wylde Flowers
  3. 笼中窥梦
  4. Gibbon: Beyond the Trees
  5. Vectornator: Design Software
  6. Wylde Flowers
  7. Behind the Frame
  8. MD Clock - Live in the present
  9. 专注面条
  10. Townscaper

第二天

今天最让我印象深刻是 M2、Lock screen widgets、Stage manager、Swift Charts、WeatherKit、SwiftUI Navigation API、只要一个 1024x1024 App Icon、Sticky headers on Xcode scrolling、Xcode View Debugger 可以用于 SwiftUI 了,还有 iOS 16 原生的支持 Nintendo Switch Pro 手柄了。

后面我将更多内容使用点对点的分发,可以用 Planet 关注,我的 IPNS 是:k51qzi5uqu5dlorvgrleqaphsd1suegn8w40xwhxl0bgsyxw3zerivt59xbk74

Keynote 要点:

  • iOS 16
    • new lock screen
    • live activities
    • extend focus to lock screen
    • forcus filter for apps
    • dictation improvements
    • live text in video
    • visual lookup
    • maps
      • multistop routing
      • transit(add card to wallet)
      • new details
      • lookaround api
    • iCloud shared photo library
    • persanalized spatial audio
    • quick notes on iPhone
    • fitness app without watch
    • messages
      • edit messages
      • delete messages
      • mark as unread
      • share play
    • pay
      • tap to pay on iPhone
      • order tracking
    • carplay
      • widgets
      • more personalization
      • multi-screen
    • safety check
      • quickly remove access for others
    • home
      • introduce matter as new standard
      • redesign of app
  • M2
    • 15.8 trillion operations per seconds
    • 10-core GPU
    • macbook air and macbook pro 13”
    • better and faster
    • silent design
    • fast charge
    • new colors
    • magsafe
    • audio jack
  • macOS Ventura
    • improved spotlight
    • undo send and more
    • shared tab groups
    • passkeys
    • desk view
    • stage manager
    • continuity for facetime
    • use iPhone as camera on macbook
  • iPadOS 16
    • weather app
    • WeatherKit
    • collaborations api
    • freeform board
    • stage manager
  • WatchOS 9
    • four new watch faces
    • new ShareKit api
    • improved metrics for running
    • heart rate zones
    • create custom workouts

重要的几个信息:

大赞的库:

好用的功能和组件:

一些方便上手的例子:

一些感兴趣的 Session:

第三天


WWDC.playground 很精彩,怎么感觉昨天的 WWDC.playground 像是听了一期枫言枫语呢。预感 11 号可能会变成为一期 weak self 呢。

昨天老司机还整理了份 WWDC22 Session 观看介绍的列表

Apple 出的内容看不够的话,可使用 Follow WWDC 2022 News! 来看最新的 WWDC 相关的社区文章。

下面是我今天的一些记录。

Xcode

代码补全的更新。以前多个可选参数的体验很差,这次输入参数比如 frame 里的 maxWidth,会只显示当前要补全的参数。而且速度快了很多。

以前是编完源码再生成 module,然后 link编好的文件,最后再 link。现在整个过程改成并行执行,同时 link 还快了两倍。结果是比以前快了25%,核越多效果越明显。还有可可视化整个过程。

多平台以前是多个 tagets,现在是在一个 target 里管理。

Hangs 是官方线上主线程被卡了的检查工具,在 Organizer 里查看对应问题堆栈也很方便。

当然最爱的还是 sticky headers,秒杀其它编辑器 (虽然我还是觉得 Emacs 最好,由于会暴露年龄,一般我都不说)。

还有内存也好了很多,总体来说,这次 Xcode 更新很棒。

完整 Xcode release notes

WidgetKit

WidgetKit 将 WatchOS 上的 Circular、Rectangle 还有 Inline 带到了 iOS 和其他平台。

WeatherKit

安全方便获得用户位置信息,只用于天气。

VisionKit

Live Text API,感觉这类库都是为了以后出眼镜做铺垫的。

macOS

macOS 支持window,menuBar也支持了。

Swift

distrubuted actor 更安全,还可以在设备间(本地设备<->本地设备本地设备<->服务器)进行通信保护。

泛型新语法 some 和 any 关键字写起来真的简化了很多。

Swift 的更新了什么,除了 Session 外,还可以参看 Paul Hudson 这篇文章 What’s new in Swift 5.7 ,还有 Donny Wals 的这篇 What’s the difference between any and some in Swift 5.7?

SwiftUI

SwiftUI里没有用属性包装的属性也能够和视图变化绑定了。

关于 SwiftUI 的更新,Paul Hudson 写了很多例子 What’s new in SwiftUI for iOS 16

Reda Lemeden 整理了 WWDC22 SwiftUI 的所有相关内容 SwiftUI @ WWDC 2022 。可见社区对 SwiftUI 热情依然是最高的。

SPM

Swift Package Plugin,本来用其他语言,比如 ruby 、python 或 shell 做的事情,现在可以通过 Swift 语言来完成了,写的 plugin 还可以方便的在 Xcode 中使用。

虚机

使用 Virtualization 框架,享受 Rosetta 2 的优势,运行 x86-64 Linux 系统。

Apple 出虚机可运行 Linux 系统这点可以看得出 Apple 对开源的拥抱,原因还有一点是 Swift 也可以用在 Linux 服务器上了,Apple 用心良苦,也是想让开发者用本打算买其它硬件的钱来买 Apple 的硬件吧,更好的榨干 Apple 硬件过于优秀的性能,如同新出 Stage Manager 通过投到大屏来榨干 M1 的 iPad 性能。 不光是这样,还有文件,也就是存储设备也只需要一份了,更方便,还有苹果特有的 Trackpad 和 Magic mouse 也能够用于 Linux 系统中。

虚机运行 Linux 和 macOS 的区别是,启动 Linux 使用的是 EFI Boot Loader 来加载 Linux 文件,VirtioGraphicDevice 进行 Linux 系统图形界面的设置和渲染。使用Rosetta 运行 Linux 系统,运行 Linux 就是比其它虚机要快。

介绍的 session Create macOS or Linux virtual machines ,代码说明 Running GUI Linux in a virtual machine on a Mac,相关主题 Virtualization

第四天

今晚五神会现身 WWDC.playground 。内容涉及 SwiftUI 和 AR,不要错过。

今日零散记录

从 Apple 推出 WeatherKit 可以看出,Apple 喜欢把关键和有想象空间盈利价值的技术掌握在自己手上,WeatherKit 提供大量数据,包括分钟、小时、每日预报,还有提前警报,这些信息的商业价值本就很大。

今天看了 WeatherKit、Swift Chart 还有 SwiftUI 的 Layout,感觉 Apple 的接口设计能力很值得学习,可能具备了这些能力才能更好地沟通。

swift-algorithms 可以使用 .indexed() 来替代 zip。

Federico Zanetello 对 Platforms State of the Union 这个 Session 做的笔记

应用层面,今天还有好多 Swift Chart 的介绍。

Layout

Grid、Layout、ViewThatFits、AnyLayout,特别是 Grid 还统一了 HStack 和 VStack。这些布局方式,让先前复杂的要借助 GeometryReader,且容易出错的布局有了更易的写法。Layout 协议可以为 layout 创建自定义属性,另外布局计算也会被缓存。

Link

Link fast: Improve build and launch time 详细讲了 Apple 今年怎么改进了 link,思路很棒,很值得学习。

Static linking 和 Dynamic linking ,也就是静态链接和动态链接。

静态链接就是链接各个编译好的源文件以及链接源文件和编译好的库文件,通过将函数名放到符号表,链接新文件时确定先前是否有包含的 undefined 符号,给函数的数据指令分配地址,最后生成一个有 TEXT、DATA、LINKEDIT 段的可执行文件。

今年 Apple 通过利用多核优势让静态链接快了两倍。

具体做法是,并行的拷贝文件内容。并行构建 LINKEDIT 段的各个不同部分。并行改变 UUID 计算和 codesigning 哈希。然后是提高 exports-trie 构建器的算法。使用最新的 Crypto 库利用硬件加速的优势加速 UUID 计算。提高其它静态库处理算法库,debug-notes 生成也更快了。

Apple 推荐静态库最佳实践是:

使用 -all_load-force_load 可以让 .a 文件像 .o 文件那样并行处理,不过开启这个选项需要先处理重复的符号。另外一个副作用是会将一些被判断无用的代码也被链接进来,使包体变大,因此开启之前可以先使用静态分析工具分析处理,这个过程定期做就行,不用放到每次编译过程中。演讲者推荐使用 -dead_strip 选项,但是这样做并没有真实去掉费代码,以后这些代码还是会被编译分析,如果只是暂时不用,可以先注释掉。

使用 -no_exported_symbols 选项。链接器生成的 LINKEDIT 段的一部分是 exports trie,这是一个前缀树,对所有导出的符号名称、地址和标志进行编码。动态库 是会导出符号的,但运行的二进制文件其实是不用这些符号的,因此可以用 -no_exported_symbols 选项来跳过 LINKEDIT 中 trie 数据结构的创建,这样链接起来就快多了。如果程序导出符号是一百万个,这个选项就可以减少 2 到 3 秒的时间。但需要注意的是,如果要加载插件链接回主程序就需要所有的导出的 trie 数据,无法用这个选项。

另外一个是 -no_deduplicate 选项。先前 Apple 给链接器加了个 pass 用来合并函数的指令相同,函数名不相同,这个 pass 会对每个函数的指令进行递归散列,用这种方式来找重复指令,这样做比较费 CPU,由于调试时其实是不需要关注包大小,因此可以加上 -no_deduplicate 选项来跳过这个 pass。

这些选项在 Xcode 的 Other Linker Flags 里进行设置即可。

动态库也就是 dylib,其它平台就是 DSO 或 DLL。 动态链接器不是将代码从库里考到主二进制里,而是记录某种承诺,记录从动态库中使用符号名称,还有库路径。这样做好处就是好复用动态库,不用拷贝多份。虚拟内存看到多进程使用相同动态库,就会重新给这个动态库用相同的物理内存页。

动态库好处是构建快了,启动加载慢了,多个动态库不光要加载,还要在启动时链接。也就是把链接成本从本地构建换到了用户启动时。动态库还有个缺点是基于动态库的程序会有更多的 dirty 页,因为静态链接时会把全局数据放到主程序同一个 DATA 页中,动态库的话,每个都在自己的 DATA 页中。

动态库工作的原理是,可执行的二进制会有不同权限的段,至少会有 TEXT、DATA 和 LINKEDIT。分段总是操作系统页大小的倍数。TEXT 段有执行的权限,CPU 可以将页上的字节当做机器代码指令。运行时,dyld 会根据每个段权限将可执行文件 mmap() 到内存,这些段是页大小和页对齐的,虚拟内存系统可以直接将程序或动态库文件设置为 VM 范围的备份存储。在这些页的内存访问前是不会被加载到 RAM 里,就会触发一个页 fault,导致 VM 去读取文件的子范围,将内存填充到需要 RAM 页中。光映射不够,还要用某种方式“wired up”或绑到动态库上。比如要调用动态库上的某个函数,会转换成调用 site,调用 site 成为一个在相同 TEXT 段合成的 sub 的调用,相对地址在构建时就知道了,就意味着可以正确的形成 BL 指令。这样做的好处是,stub 从 DATA 加载一个指针并跳到对应的位置,不用在运行时修改 TEXT 段,dyld 只在运行时改 DATA 段。dyld 所进行的修改很简单,就是在 DATA 段里设置了一个指针而已。

当 dyld 或应用程序的指针指向自己时要 rebase,ASLR 使 dyld 以随机地址加载动态库,内部指针不能在构建时设置,dyld 在启动时 rebase 这些指针,磁盘上,如果动态库在地址零出被加载,这些指针包含它们的目标地址。LINKEDIT 需要记录的就是每个重定位的位置。然后,dyld 只需将动态库的实际加载地址添加到每个 rebase 位置。还有种修改方式是绑定,绑定就是符号引用,符号存储在 LINKEDIT 中,dyld 在动态库的 exports tire 中找实际地址,然后 dyld 将该值存储在绑定指定的位置。

今年 Apple 发布了一个新的修改方式 chained fixups。较前面两种的优势就是可以使 LINKEDIT 更小。新格式只存储每个 DATA 页中第一个 fixup 位置和一个导入的符号列表。其它信息编码到 DATA 段。iOS 13.4 就开始支持了。

下面先说下 dyld 原理介绍。

dyld 从主可执行文件开始,解析 mach-o 找依赖动态库,对动态库进行 mmap()。然后对每个动态库进行遍历并解析 mach-o 结构,根据需要加载其它动态库。加载完毕,dyld 会查找所有需要绑定符号,并在修改时使用这些地址。最后修改完,dyld 自下而上运行初始化程序。先前做的优化是只要程序和动态库,dyld 很多步骤都可以在首次启动时被缓存。

今年 Apple 做了更多的优化,这个优化叫 page-in linking,就是 dyld 在启动时做的 DATA 页面修改放到 page-in 时,也可以理解为懒修改。以前,在 mmap() 区域的某些页面中第一次使用某些地址会触发内核读入该页面。现在如果它是一个数据页,内核会应用改页需要的修改。这种机制减少了 dirty 内存和启动时间。意味着 DATA_CONST 也是干净的,可以像 TEXT 页一样被 evicted 和重新创建,以减少内存压力。需要注意的是 page-in linking 只用于启动,dlopen() 不支持。你看,Apple 优化启动的思路也是按需加载。

Apple 还提供了追踪 dyld 运行情况的 dyld_usage 工具。检查磁盘和 dyld 缓存中的二进制文件的 dyld_info 工具。

今日推荐 Session

除了 link 外,还有 Meet distributed actors in Swift 也是比看的,Mike Ash 和 Doug Gregor 一年的心血就在这了。

第五天

性能

性能的 Improve app size and runtime performance Session 值得一看。

今年苹果通过更有效的检查 Swift 协议,使 OC 消息发送调用更小,使 autorelease elision 更快更小这几个个方面来让 App 体积更小,性能更高。

Swift 协议检查。

一个协议通过 as 操作符检查传递值是否符合协议,这种检查会在编译器的构建时间被优化掉,所以往往需要在运行时借助之前计算协议检查元数据来看对象是否真的符合了协议。一些元数据是在编译时建的,但还有很多元数据只能在启动时建立,特别是使用泛型时。协议多了,会增加耗时,差不多会多一半启动时间。

今年 Apple 推出新的 Swift 运行时,可以提前计算 Swift 协议元数据,作为 App 可执行文件和它在启动时使用的任何动态库的 dyld 闭包的一部分。这个是在系统上的,因此,只要是使用了今年最新系统的 App 都会享受这个优化,可以理解为,新系统上启动老 App 也会快些。

消息发送。

Xcode 14 中新的编译器和链接器已经将 ARM64 的消息发送调用从 12 字节减少到 8 字节。因此如果你的 App 都是 OC 代码的话,使用 Xcode 14 编出来的二进制文件可以少 2%。老系统也有效。

使用 objc_stubs_small 选项可以只优化大小,获得最大的大小优化。objc_msgSend 调动有 8 个字节指令,也就是2个指令是专门用来准备 selector 的,对于任何特定的 selector,总是相同的代码,由于始终是相同的代码,那么就可以对其共享,每个 selector 只 emit 一次,而不是每次发送消息时都 emit。共享这段代码地方是一个叫 selector stub 的函数。

ARC 会在编译器插入大量的 c 的 retain/release 函数调用。这些调用遵守平台应用二进制接口(ABI)所定义的 c 语言 call convention。也就意味着我们要更多代码来完成这些调用,用来传递正确寄存器的指针。Apple 今年推出了自定义的 call convention 根据指针位置,适时使用正确变量而不用移动它,从而摆脱了调用里的多余代码。Apple 果然是坚持用户体验优先,为了更好体验不惜修改 c 的 ABI。

autorelease elision 。

App 今年对 objc 运行时进行了修改,使 autorelease elision 更小更快。deployment target 为 iOS 16 今年新系统时才可享用哦。

Apple 怎么做的呢?

ARC 在调用方插入一个 retain,在被调用的函数中插入一个 release。当我们返回我们的临时对象时,我们需要在函数中先释放它,因为它要离开 scope。在它还没有任何其它引用时还不能这么做,不然返回前他就会被销毁。Apple 现在使用一个新的 convention ,让其可以返回临时对象。做法是当返回一个自动释放值,编译器会发出一个特殊标记,这个标记会告诉运行时这是符合自动释放条件的。它的后面是 retain,我们会在后面执行。获取返回地址,也就是一个指针,将它先保存起来,然后离开运行时的自动释放调用。在运行时,可以将保留时得到的指正和先前做自动释放时保存的指针进行比较,这样标记指令不再是数据之间的比较,比较指针内存访问少。比较成功就可以省去 autorelease/retain。

autorelease elision 的优化同样也可以减少 2% 大小。感谢 Apple 为了用户和开发者 OKR 的付出。

SwiftUI

new navigation api,看完感觉我做的小册子还有幻灯应用要花些时间好好改改了。

接下来,有活干了。

WWDC.playground

明天的 WWDC.playground 嘉宾有谜底科技和 weak self,欢迎来捧场。

下面是按分类做的记录:

Swift

String Index 大升级 String Index Overhaul

参考

Regex

标准库多了个 Regex<Output> 类型,Regex 语法与 Perl、Python、Ruby、Java、NSRegularExpression 和许多其他语言兼容。可以用 let regex = try! Regex("a[bc]+")let regex = /a[bc]+/ 写法来使用。SE-0350 Regex Type and Overview 引入 Regex 类型。SE-0351 Regex builder DSL 使用 result builder 来构建正则表达式的 DSL。SE-0354 Regex Literals 简化的正则表达式。SE-0357 Regex-powered string processing algorithms 提案里有基于正则表达式的新字符串处理算法。

RegexBuilder 文档

session Meet Swift RegexSwift Regex: Beyond the basics

Regex 示例代码如下:

let s1 = "I am not a good painter"print(s1.ranges(of: /good/))do {    let regGood = try Regex("[a-z]ood")    print(s1.replacing(regGood, with: "bad"))} catch {    print(error)}print(s1.trimmingPrefix(/i am /.ignoresCase()))let reg1 = /(.+?) read (\d+) books./let reg2 = /(?<name>.+?) read (?<books>\d+) books./let s2 = "Jack read 3 books."do {    if let r1 = try reg1.wholeMatch(in: s2) {        print(r1.1)        print(r1.2)    }    if let r2 = try reg2.wholeMatch(in: s2) {        print("name:" + r2.name)        print("books:" + r2.books)    }} catch {    print(error)}

使用 regex builders 的官方示例:

// Text to parse:// CREDIT  03/02/2022  Payroll from employer     $200.23// CREDIT  03/03/2022  Suspect A           $2,000,000.00// DEBIT   03/03/2022  Ted's Pet Rock Sanctuary    $2,000,000.00// DEBIT   03/05/2022  Doug's Dugout Dogs      $33.27import RegexBuilderlet fieldSeparator = /\s{2,}|\t/let transactionMatcher = Regex {  /CREDIT|DEBIT/  fieldSeparator  One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) // 👈🏻 we define which data locale/timezone we want to use  fieldSeparator  OneOrMore {    NegativeLookahead { fieldSeparator } // 👈🏻 we stop as soon as we see one field separator    CharacterClass.any  }  fieldSeparator  One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US")))}

在正则表达式中捕获数据,使用 Capture:

let fieldSeparator = /\s{2,}|\t/let transactionMatcher = Regex {  Capture { /CREDIT|DEBIT/ } // 👈🏻  fieldSeparator  Capture { One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) } // 👈🏻  fieldSeparator  Capture { // 👈🏻    OneOrMore {      NegativeLookahead { fieldSeparator }      CharacterClass.any    }  }  fieldSeparator  Capture { One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US"))) } // 👈🏻}// transactionMatcher: Regex<(Substring, Substring, Date, Substring, Decimal)>

泛型与协议

session Embrace Swift genericsDesign protocol interfaces in Swift

swift 5.6 和之前编写泛型接口如下:

func feed<A>(_ animal: A) where A: Animal// 👆🏻👇🏻 Equivalentsfunc feed<A: Animal>(_ animal: A)

swift 5.7 可以这样写:

func feed(_ animal: some Animal)

some 关键字可以用于参数和结构类型。some 会保证类型关系,而 any 会持有任意具体类型,删除类型关系。

SE-0347 Type inference from default expressions 扩展 Swift 泛型参数类型的默认值能力。如下代码示例:

func suffledArray<T: Sequence>(from options: T = 1...100) -> [T.Element] {    Array(options.shuffled())}print(suffledArray())print(suffledArray(from: ["one", "two", "three"]))

SE-0341 Opaque Parameter Declarations 使用 some 参数简化泛型参数声明。SE-0328 Structural opaque result types 扩大不透明结果返回类型可以使用的范围。SE-0360 Opaque result types with limited availability 可用性有限的不透明结果类型,比如 if #available(macOS 13.0, *) {} 就可以根据系统不同版本返回不同类型,新版本出现新类型的 View 就可以和以前的 View 类型区别开。

SE-0309 Unlock existentials for all protocols 改进了 existentials 和 泛型的交互。这样就可以更方便的检查 Any 类型的两个值是否相等

any 关键字充当的是类型擦除的助手,是通过告知编译器你使用 existential 作为类型,此语法可兼容以前系统。

SE-0346 Lightweight same-type requirements for primary associated types 引入一种新语法,用于符合泛型参数并通过相同类型要求约束关联类型。SE-0358 Primary Associated Types in the Standard Library 引入主要关联类型概念,并将其带入了标准库。这些关联类型很像泛型,允许开发者将给定关联类型的类型指定为通用约束。

SE-0353 Constrained Existential Types 基于 SE-0309 和 SE-0346 提案,在 existential 类型的上下文中重用轻量关联类型的约束。

SE-0352 Implicitly Opened Existentials 允许 Swift 在很多情况下使用协议调用泛型函数。

Swift 论坛上一个对 any 和 some 关键字语法使用场景的讨论,Do any and some help with “Protocol Oriented Testing” at all?

Swift Concurrency

session Eliminate data races using Swift ConcurrencyVisualize and optimize Swift concurrencyMeet Swift Async Algorithms

表示持续时间有了新的放来来表达,对应提案是 SE-0329 Clock, Instant, and Duration ,continuous clock 是在系统睡眠状态还会增加时间,suspending clock 在系统睡眠状态不会增加时间。Instants 表示一个确定的时间。Duration 表示两个时间经历了多久。

新增 SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions 通过收紧可发送性检查的规则来避免潜在的数据竞争。

SE-0343 Concurrency in Top-level Code 这个提案主要是更好地支持命令行工具的开发,可以直接将 concurrency 代码写到 main.swift 文件里。

SE-0340 Unavailable From Async Attribute 提供 noasync 语法以允许我们将类型和函数标记为在异步上下文不可用。

Task 是按顺序执行的,是异步的,在 await 时可以暂停任意次数。task 是自包含的,有自己的资源,可以独立于任何其他 task 独立运行。task 通过在 body 末尾返回一个值来传递对象,值类型没问题,如果是引用类型有可能出现数据竞争。

通过 Sendable 协议 Swift 可以帮助告诉我们什么时候 task 之间共享数据是安全的。Sendable 描述的类型可以跨隔离 domain,不会有数据竞争,Swift 编译器会在构建时检查数据竞争。task 的返回类型要符合 Sendable。

引用类型只能在很少的情况下符合 Sendable。比如 final class 只有不可变的存储。对于自己内部同步的引用类型,比如锁,可以用 @unchecked Sendable

class ConcurrentCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {  var lock: NSLock  var storage: [Key: Value]  // ...}

Actor 提供了一种隔离状态的方法可以消除数据竞争。使用 task 来执行 actor 定义的代码。一次只能在一个 actor 上执行一个 task。actor 也是依赖 Sendable。actor 是引用类型,但隔离了他们所有属性和代码来防止并发访问。@MainActor 表示的是主线程,你要在应用中更新 UI 时来用它。

@MainActor func updateView() {}Task { @MainActor in  // update UI here}

@MainActor 也可以用于类,类的属性和方法只能在主 main actor 上访问,除非标记为 nonisolated

@MainActorclass ChickenValley: Sendable {  var flock: [Chicken]  var food: [Pineapple]  func advanceTime() {    for chicken in flock {      chicken.eat(from: &food)    }  }}

Distributed Actors

actor 具有分布式形式工作能力,也就是可以 RPC 通过网络读取和写入属性或者调用方法。设计为保护在跨多个进程中的低级别数据竞争。Distributed actors 可以在两个进程间建立通道,隔离它们状态,并在它们之间异步通信。每个 distributed actors 在 actor 初始化时分配一个不可以手动创建的 id,在它所属整个 distributed actor 系统中唯一标识所指 actor,这样无论 distributed actors 在哪,都可以以相同的方式与之交互。

session Meet distributed actors in Swift 。这里有个 distributed actors 的代码示例 TicTacFish: Implementing a game using distributed actors

SE-0336 Distributed Actor IsolationSE-0344 Distributed Actor Runtime 是两个 Distributed Actors 的相关提案。

Apple 提供了一个参考的服务端 cluster actor 系统实现示例,cluster actor system implementation

Optional

SE-0345 if let shorthand for shadowing an existing optional variable 引入的新语法,用于 unwrapping optinal。

let s1: String? = "hey"let s2: String? = "u"if let s1 {    print(s1)}guard let s1, let s2 else { return }print(s1 + " " + s2)

类型推断

SE-0326 提高了 Swift 对闭包使用参数和类型推断的能力。如下代码:

let a = [1,2,3]let r = a.map { i in    if i >= 2 {        return "\(i) 大于等于2"    } else {        return "\(i) 小于2"    }}print(r)

Result Builders

SE-0348 buildPartialBlock for result builders 简化了实现复杂 result buiders 所需的重载。

Swift-DocC

现在支持 Swift、OC 和 C,文档标记一样。.doccarchive 包含可部署的网站内容,兼容大多数托管服务,比如 Github pages。部署到在线服务上可参考 Generating Documentation for Hosting OnlinePublishing to GitHub Pages 文档。

和 SPM 集成参看 SwiftDocCPlugin

session 有 What’s new in Swift -DocCImprove the discoverability of your Swift-DocC content

SE-0356 Swift Snippets 代码片段用于示例文档的提案。

调试

session Debug Swift debugging with LLDB

编译器编译 swift 文件生成 .o 文件会有 __debug_info 段,其中有可以映射到源文件和行号的地址。debug 信息可以链接到 .dSYM 包。debug 信息链接器叫 dsymutil,dsymutil 可以为每个动态库、framework 或 dylib 和可执行文件打包一个 debug 信息存档(.dSYM 包)。

image 和路径怎么重映射。使用 image list nameOfFramework 来检查 LLDB 是否找到了我们应用程序里嵌入的第三方框架的 debug dSYM。使用 image lookup 0xMemoryAddressHere 获取当前地址更多信息。要重新映射源文件 .dSYM 路径,使用 settings set target.source-map old/path new/path。每个 .dSYM 都有一个 UUID.plist,我们可以在其中设置 DBGSourcePathRemapping 这个字典。

Xcode 14 新增 swift-healthcheck 命令,这个命令可以了解 module 为何导入失败。

LLDB 怎么找到 Swift module?每个 .dSYM 包都可以包含二级制 swift module,其中可能包含桥头文件、swift 接口文件 .swiftinterface,还有 debug 信息。静态存档不是由链接器生成的,需要向链接器注册 swift module,使用 ld ... -add-ast-path /path/to/My.swiftmodule ,动态库和可执行文件的话,Xcode 会自动完成此操作。可以使用 dsymutil 来 dump 你可执行文件的符号表,并用 grep 找 swiftmodule,命令是 dsymutil -s MyApp | grep .swiftmodule

内存管理

相关提案包括 SE-0349 Unaligned Loads and Stores from Raw MemorySE-0334 Pointer API Usability ImprovementsSE-0333 Expand usability of withMemoryRebound

Set 使用新的 Temporary Buffers 功能,让 intersect 速度提升了 4 到 6 倍。

SwiftUI

介绍

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() 等,这样利于运行时进行调整。

参考

session:

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

Navigation 接口

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

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

参考:

NavigationStack 的示例:

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 两栏的例子:

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 三栏的例子:

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,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

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

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

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")            }        }    }}

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

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

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))        } // end Chart    } // end body}

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

AxisMarks 的示例如下:

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()                }            }        }    }}

可交互图表示例如下:

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

Advanced layout control

session Compose custom layouts with SwiftUI

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

Grid 示例代码如下:

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 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

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 视图,为其所有子视图提供其最大子视图的宽度:

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 设置。

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 值:

func placeSubviews(  in bounds: CGRect,  proposal: ProposedViewSize,  subviews: Subviews,  cache: inout Void) {  let ranks = subviews.map { subview in    subview[Rank.self] // 👈🏻  }  // ...}

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

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 和图片也支持了样式布局变化,代码示例如下:

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))    }}

分享接口

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

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

支持 SwiftUI 来使用。

官方文档 Core Transferable

session Meet Transferable

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

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 视图可以让你轻松共享数据。示例代码如下:

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")        }    }}

锁屏的 Widget

和 WatchOS 一样,可以瞟一眼就获取信息。

官方指南 Creating Lock Screen Widgets and Watch Complications

Bottom Sheet

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

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 可以用来显示隐藏拖动标识。

List

list 支持 Section footer。

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

list 不使用 UITableView 了。

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

ScrollView

新增 modifier

ScrollView {    ForEach(0..<300) { i in        Text("\(i)")            .id(i)    }}.scrollDisabled(false).scrollDismissesKeyboard(.interactively).scrollIndicators(.visible)

TextField

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

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

Search

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

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)            }    }}

Gauge

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

简单示例如下:

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)    }}

Group Form

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

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)

Button

.buttonStyle 可组合,示例如下:

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

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

Rectangle()    .fill(.green)    .frame(width: 50, height: 50)    .onTapGesture(coordinateSpace: .global) { location in        print("Tap in \(location)")    }

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

选择多个日期

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

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

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

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")                    }                }        }    }}

Table

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

table 使用示例如下:

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) }    }}

Toolbar

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

.toolbar(id: "toolbar") {    ToolbarItem(id: "new", placement: .secondaryAction) {        Button(action: {}) {            Label("New Invitation", systemImage: "envelope")        }    }}.toolbarRole(.editor)

SF Symbol

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

Gradient 和 Shadow

下面是个简单示例:

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

嵌入 UIKit

示例如下:

cell.contentConfiguration = UIHostingConfiguration {    VStack {        Image(systemName: "wand.and.stars")            .font(.title)        Text("Like magic!")            .font(.title2).bold()    }    .foregroundStyle(Color.purple)}

macOS

支持了 window,可以控制位置和大小。官方代码示例 Bringing multiple windows to your SwiftUI app

openWindow 代码示例如下:

struct PartyPlanner: App {    var body: some Scene {        WindowGroup("Party Planner") {            PartyPlannerHome()        }        Window("Party Budget", id: "budget") {            Text("Budget View")        }        .keyboardShortcut("0")        .defaultPosition(.topLeading)        .defaultSize(width: 220, height: 250)    }}struct DetailView: View {    @Environment(\.openWindow) var openWindow    var body: some View {        Text("Detail View")            .toolbar {                Button {                    openWindow(id: "budget")                } label: {                    Image(systemName: "dollarsign")                }            }    }}

session Bring multiple windows to your SwiftUI app 两个新 Scene 类型。WindowGroup 允许多 window。MenuBarExtra。可编程方式打开新 window 和 document。

MenuBarExtra 代码示例如下:

struct PartyPlanner: App {    var body: some Scene {        Window("Party Budget", id: "budget") {            Text("Budget View")        }        MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") {            BulletinBoard()        }        .menuBarExtraStyle(.window)    }}

讲和 AppKit 混编的 session Use SwiftUI with AppKit

The craft of SwiftUI API design: Progressive disclosure 使用 windows 还有 MenuBarExtra,使用 modifier 来自定义应用程序 window 的 presentation 和行为。

使用 .dropDestination 来支持拖动。示例如下:

.dropDestination(payloadType: Image.self) { receivedImages, location in        guard let image = receivedImages.first else {            return false        }        viewModel.imageState = .success(image)        return true    }

今年有新的 FormStyle ,示例如下:

Form {    Picker("Notify Me About:", selection: $notifyMeAbout) {        Text("Direct Messages").tag(NotifyMeAboutType.directMessages)        Text("Mentions").tag(NotifyMeAboutType.mentions)        Text("Anything").tag(NotifyMeAboutType.anything)    }    Toggle("Play notification sounds", isOn: $playNotificationSounds)    Toggle("Send read receipts", isOn: $sendReadReceipts)    Picker("Profile Image Size:", selection: $profileImageSize) {        Text("Large").tag(ProfileImageSize.large)        Text("Medium").tag(ProfileImageSize.medium)        Text("Small").tag(ProfileImageSize.small)    }    .pickerStyle(.inline)}.formStyle(.columns)

Apple 自身在 macOS 系统中使用了多少 SwiftUI 呢?邮件、iWork 和 Keychain Access 的部分视图使用了,笔记、照片 和 Xcode 部分功能及新增功能的完整界面都是用的 SwiftUI,另外控制中心、字体册和系统设置的大部分都是用 SwiftUI 开发了。

ImageRenderer

可以将 SwiftUI 的 View 生成图片。

官方参考文档 ImageRenderer

后台任务

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

Xcode 14

Xcode 14 里有新的 Swift 5.7,其中对泛型和协议有很大的改进。

参考

通用

编出来的二进制小 30%。

改进了并行性,构建提速 25%。

改进了在 iOS 设备上调试 Swift 程序的性能。

提供单一图标大小,Xcode 完成剩下的。

更智能的代码完成,滚动时置顶类、结构体和函数名。错误消息在重新处理时会变暗。

Xcode 搜索和替换栏中可以使用正则表达式。相信以后社区会出现很多好用的正则表达式分享。

Xcode Organizer 中新增 Hang 报告,用来提供主线程上发生挂起的调用堆栈信息,以及提供设备和 iOS 版本信息等统计信息。

Xcode 14 现在支持为 iPadOS 开发 DriverKit 驱动程序。

创建新 C++ 项目,Clang 默认使用 C++20。已经实现了几篇 C++20 和 C++2b 论文。

iOS、tvOS 和 watchOS 的构建默认不再包含 bitcode。

legacy 构建系统被删除,LLVM 14 也不再支持 legacy。

Xcode 中的 Swift-DocC 现支持 OC 和 C 的 API 构建文档。生成的 Swift-DocC 文档网站包括一个新的导航侧边栏,用于浏览和过滤文档。可将 Swift-DocC 部署到 GitHub Pages。

性能问题修复

代码完成不再自动导入模块。

提高了复杂表达式 SwiftUI 中代码完成的速度和准确性。

修复了包含大量错误或警告的文件时导致性能下降的问题。

修复了 minimap 在长文件时性能问题。

源码编辑器

滚动编辑器时,Xcode 会将代码结构的元素固定到编辑器顶部。

支持了 Regex 表达式语法高亮。Editor > Refactoring > Convert to Regex Builder 可以将正则文本转成等效 Regex builder。

可以输入匹配参数来选择代码完成中默认参数的任意组合。

Swift 中代码完成提供基于变量名的 map、filter 和 contains 的 snippet。

提高 Swift 代码完成的准确性。

SwiftUI 的代码完成,现在有了 List 和 ForEach 的 snippet。

Xcode 14 还要很多贴心代码完成改进,比如写 struct 的 init 可以自动完成。Codable 的 encode 也可以自动完成。

Xcode Preview

Preview 增强,默认是交互式的。

创建新项目会自动 resume。大量编辑时也不会暂停。会动态调整更新频率。

Swift Packages

引入新参数 moduleAliases 来为冲突的模块定义唯一名称,并以新名称构建而不用改代码。注意的是起别名的模块要是纯 Swift 模块。

允许使用 Swift Package command plugins。Xcode 为 Swift Package plugins 提供了 XcodeProjectPlugin 接口,这个接口扩展了 Swift Package Manager 的 PackagePlugin 接口。用这个接口可以获得 Xcode 项目结构的简化描述。

session 有 Meet Swift Package pluginsCreate Swift Package plugins

Instrument

Hang Tracing 工具,可以显示应用程序的主线程什么时候无法长时间处理传入事件,从而导致 UI 卡住。

Runloop 工具,显示 runloop 的使用和单独的迭代,视觉上区分了进程中所有 runloop 的 runloop sleep 和 busy interval。

Instrument 新模板更方便调试 distributed actors 和其它 Swift concurrency 特性。

memory graph 调试器可以显示 memory graph 的所有传入和传出引用。

Instrument 现有一个新的 Swift Concurrency 模板,用于跟踪 swift concurrency 的使用。这个模板包括 Swift Tasks 工具,可显示随时间变化的 task 的状态,总结 task 状态,提供详细的 task 描述,task 关系和 task 创建 callstacks 的调用树结构。还有 Swift Actors 工具,可以跟踪 actor 之间的 task 行为,显示每个 actor 的 task 队列,并帮助诊断 actor-isolated 代码等问题。

Instrument 里的代码查看更好显示包含了性能数据。Interleave 模式,可以同时查看源码和关联的反汇编。源码查看现在会在源码和反汇编判断显示 CPU 计数器,PMC 事件和动态公式。

修复了很多 Swift 相关显示不友好的问题。

多端

官方例子 Configuring a multiplatform app 。一个示例了 NavigationSplitView、Layout、Chart 和 WeatherKit 的运用的官方例子 Food Truck: Building a SwiftUI multiplatform app

Session 笔记

https://www.wwdcnotes.com/notes/wwdc22/110371/

下面是 App Intents、WidgetKit 相关内容,这些都属于 App Services,WWDC22 专门整理了 App Service 专题 。新系统服务比如 Messages collaboration、网络、CloudKit 的 System Service 主题

Widget

iOS 16 和 WatchOS 9 可以使用同一套代码编写 widget。iOS 新增场景是锁屏和 Live Activities(晚些时候推出)。

利用 Smart Stack,让 widget 出现到栈顶,可以使用 TimelineEntryRelevance

官方参考:

介绍怎么将 widgets 添加到 lock screen 的 session Complications and widgets: Reloaded 。对应的实例代码 Adding widgets to the Lock Screen and watch faces

App Intents

打通 App Shortcuts,从 Shortcuts 应用、Spotlight 和 Siri 运行你的 App 特定任务。

对应 Session

文档 App Intents

官方几篇 App Intents 文章:

对于 Shortcut 的使用少数派有篇很棒的文章 《iOS 快捷指令搭配 Notion API,更快速地编辑内容》 。

WeatherKit

Apple 收购 Dark Sky 后带来了 WeatherKit 和 WeatherKit REST API。有着易用的 Swift 接口,还有配套的 REST API。WeatherKit 内置了 async/await 支持。

WeatherKit 指南
WeatherKit 文档

session Meet WeatherKit 。一个 Apple 提供的天气代码示例 Fetching weather forecasts with WeatherKit

HealthKit

提供了更详细的睡眠和锻炼数据。session 介绍 What’s new in HealthKit

Vision

更新介绍 session What’s new in Vision

VisionKit 现在有一个结合 AVCapture 和 Vision 的数据扫描仪进行实时捕捉。 session Capture machine-readable codes and text with VisionKit

Live Text 接口

视觉库的应用接口。可以从照片和暂停视频中获取文本。

官方参考:

ScreenCaptureKit

creenCaptureKit 框架可以给你的 macOS 程序添加对高性能屏幕录制的支持。文档地址:ScreenCaptureKit

App Store

内购

可以将 App Store Connect 内购产品同步到 Xcode。

新测试功能,比如在沙盒和 Xcode 里请求测试通知和测试其它应用内购买场景。

官方参考:

这里有个 Kevin 开源的微信支付 SDK wechatpay-swift

全球化

session Build global apps: Localization by example

request review

你可以用 requestReview 这个 environment 键提示用户对你的 App 进行评论。示例代码如下:

struct PRequestReview: View {    @Environment(\.requestReview) var rr    var body: some View {        Button("来评论吧") {            rr()        }    }}

Apple 的最佳实践例子 Requesting App Store Reviews

参考

审核

这次审核,规则 4.2.3 中取消二进制要有启动时足够的内容,这可能是因为 Background Assets 的推出可以让用户更快更聪明的下载。另外 5.3.3 放宽了彩排等限制。

性能

Apple 除了做编译优化体积外,还提供了一个 Background Assets 在应用安装后、应用更新时以及应用保留在设备上时定期在后台下载资源,看起来类似 ODR。Background Assets 的 session Meet Background Assets

官方参考:

硬件和虚机

官方参考:

session 有:

虚机的应用可见 insidegui/VirtualBuddy 这个开源项目。

网络

session Reduce networking delays for a more responsive appBuild device-to-device interactions with Network Framework

Metal 3

利用多核优势,高分辨率图形渲染更快,资源加载更快。使用 GPU 训练机器学习网络。WWDC22 期间社区有个给背景添加雨水效果有些流行,作者放出了代码,介绍了如何将 Metal 引入 SwiftUI 工作流,Atmos

官方参考:

RoomPlan

ARKit 支持的新 Swift 接口。使用摄像头和 LiDAR 创建 3D 平面图。另外还有一个视觉库的代码例子很有趣,就是从视频中检测人物行为,Detecting Human Actions in a Live Video Feed

官方参考:

session Create parametric 3D room scans with RoomPlan 。官方示例代码 Create a 3D model of an interior room by guiding the user through an AR experience

Passkeys

身份验证,使用行业标准。

官方参考:

交互设计

Apple 的人机界面交互指南 Human Interface Guidelines 。内容超级详细,涉及程序界面方方面面。

官方参考:

资料

在苹果加速器活动做的 SwiftUI 开发分享

作者 戴铭
2022年3月25日 13:04

受 Apple 加速器 SwiftUI 活动邀请,做了个分享,还和喵神、61、浙剑、思琪等社区大神参与了圆桌讨论。这次完善了 SwiftUI 做的幻灯片,比去年要好一点,增加了解释执行代码交互功能。

为这次幻灯画了几张图,在新西兰认识的画家,指导我了些画法,用其完成了幻灯里的图。





macOS 多栏

内容有 macOS 多栏、Toolbar、文件夹嵌套、文本和代码编辑器、网格视图和斜45度视觉。

macOS 的多栏只需要使用 NavigationView 就可以了,闭包里的第一个视图就是 Sidebar,后面的视图可以作为占位视图,显示一些初始信息,通过 Sidebar 的 NavigationLink 来设置第二栏的视图就好了,第三栏通过第二栏来指定。

如果想要隐藏收起 Sidebar 需要先获取 SplitViewController,然后调用 toggleSidebar 方法就可以了。如果想要收起最右侧视图或任意一栏视图,可以在 SplitViewController 的 splitViewItems 里找到对应的 Item,比如最右一栏就是 splitViewItems.last。调用找到视图的 animator().isCollapsed.toggle() 就可以了。

如果只想让其中一栏全屏显示,先在 splitViewItems 找到那一栏,然后调用对应 ViewController 里 View 的 enterFullScreenMode 方法,要注意的是,设置的 Options 需要包含 .autoHideDock 和 .autoHideMenuBar,否则就没法退出全屏了。由于全屏后会将视图放到另一个 Window 中,因此退出全屏可以直接调用当前 key window 的 contentView 的 exitFullScreenMode() 方法。

Toolbar

一般的 macOS 程序多栏顶部会有一些功能按钮,以方便用户了解到程序的主要高频功能,比如 Xcode 的调试和 Target 选择按钮,Keynote 的播放、添加幻灯片、缩放文本、形状、表格、图表等按钮。这些按钮都可以通过 Toolbar 来实现。Toolbar 根据摆放位置和语义设置了一组 Options,通过 Options 统一了多平台的表现形式,比如默认位置的 option 就是 .automatic,中间位置就是 .principal,macOS Touch Bar iOS 的虚拟键盘上的按钮用的就是 .keyboard。另外还有很多语义表示,用于放置到不同平台特定的位置,比如用于导航的按钮在 macOS 上会出现在最左侧,用的就是 .navigation 这种语义的 Options。

文件夹嵌套结构

得益于 keypaths 在 SwiftUI 中的应用,文件夹嵌套结构实现起来简单了太多。给 List 的 children 参数指定嵌套模型的嵌套键值路径即可,比如模型结构如下:

// MARK: - 目录结构数据模型struct POM: Hashable, Identifiable {  var id = UUID()  var s: String // 文字  var i: String // 图标  var sub: [POM] ?}

其中嵌套键值是 sub,那么 children 参数只需要添上 \.sub ,List 内部会处理嵌套逻辑并展示出来。如果 List 表现出来的效果并不能够满足你,你也可以自己定制视图和交互。比如点击文件夹名字也能够展开子内容,List 默认只有点击左侧箭头才能够展开。

自定义嵌套视图底层可以使用 DisclosureGroup,DisclosureGroup 能够展示自定义视图内容,还有一个 isExpanded 值绑定参数用来显示和隐藏内容。在遍历已展示视图时,通过 keypaths 发现嵌套值不为空时就读取子内容,同时默认 isExpanded 值为 false 就会显示不展开的箭头符号,将文件夹名字做成按钮,点击按钮触发 isExpanded.toggle(),如 isExpanded 为 false 就置为 true,即展开文件夹,反之就会收起文件夹。

文本和代码编辑器

文本和代码编辑器也是 macOS 上很常见的效率工具的核心功能,包含的技术点较多,比如调试和代码分析会用到编译器这里就不展开说了。感兴趣可以参看深入剖析 iOS 编译 Clang / LLVM深入剖析 iOS 编译 Clang / LLVM 直播的 SlidesatSwift大会上分享《学习iOS编译原理能做哪些有意思的事情》的 Slides这次swift大会分享准备的幻灯片和 demo,这几篇。

文本或代码分析完后可以通过 Attribute 来进行富文本展示的设置,富文本属性都在 AttributeContainer 中设置,设置好的富文本直接通过 append 进行组合。编辑器其它的比如编辑、重做、存储或自定义的能力可以通过 NSViewRepresentable 来包装 Appkit 直接使用 Appkit 里的 NSTextView 的能力。

Grid

Vision

接下来详细的说下 SwiftUI 的视图组件的使用,这次的幻灯片程序用到的技术,除了解释执行代码的功能,其它基本都来自下面的内容。

已更新 WWDC22 内容。

SwiftUI 组件

视图组件使用

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

基本用法

// MARK: - Textstruct 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 使用

// MARK: - Markdownstruct 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 的使用

// MARK: - AttributedStringstruct 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]    }}

时间的显示

// 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"))    }}

插值使用

// 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)    }}

Link

使用方法如下:

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/KwaiAppTeam/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。

struct V: View {    var body: some View {        VStack {            Text("hi")        }        .onOpenURL { url in            print(url.absoluteString)        }    }}

Label

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

对应的代码如下:

import SwiftUIimport CodeEditorViewstruct 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: - 自己包装 NSTextViewstruct 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()        }    }}// 配置 NSTextViewfinal 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

使用方法如下:

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

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: - 自定义 Buttonstruct 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: - ButtonStylestruct 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: - PrimitiveButtonStylestruct 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 可组合,示例如下:

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 可以获取点击的位置,示例代码如下:

Rectangle()    .fill(.green)    .frame(width: 50, height: 50)    .onTapGesture(coordinateSpace: .global) { location in        print("Tap in \(location)")    }

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

进度

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

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。

简单示例如下:

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

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

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

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

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 示例代码如下:

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 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

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 视图,为其所有子视图提供其最大子视图的宽度:

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 设置。

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 值:

func placeSubviews(  in bounds: CGRect,  proposal: ProposedViewSize,  subviews: Subviews,  cache: inout Void) {  let ranks = subviews.map { subview in    subview[Rank.self] // 👈🏻  }  // ...}

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

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 和图片也支持了样式布局变化,代码示例如下:

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 的示例:

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 两栏的例子:

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 三栏的例子:

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,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

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

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

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 的自定义,示例如下:

.toolbar(id: "toolbar") {    ToolbarItem(id: "new", placement: .secondaryAction) {        Button(action: {}) {            Label("New Invitation", systemImage: "envelope")        }    }}.toolbarRole(.editor)

以下是废弃的 NavigationView 的用法。

对应代码如下:

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

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

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

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

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,示例如下:

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 里的视图只有在滚到时才会被创建。

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)) 表示会根据设置大小自动设置有多少列展示。

示例:

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 使用示例如下:

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 还可以透出偏移量,利用偏移量可以定义自己的动态视图,比如向下向上滚动视图时有不同效果,到顶部显示标题视图等。

示例代码如下:

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: - 自定义 ScrollViewstruct 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

ScrollView {    ForEach(0..<300) { i in        Text("\(i)")            .id(i)    }}.scrollDisabled(false).scrollDismissesKeyboard(.interactively).scrollIndicators(.visible)

浮层

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

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: - 供全局使用的 HUDfinal 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: - 自定义 HUDstruct 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。示例代码如下:

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

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

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

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))        } // end Chart    } // end body}

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

AxisMarks 的示例如下:

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()                }            }        }    }}

可交互图表示例如下:

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

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

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

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

示例代码如下:

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 视图会显示一个日历,用户可以选择多个日期,可以设置选择范围。示例如下:

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

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

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

struct PlaySliderView: View {    @State var count: Double = 0    var body: some View {        Slider(value: $count, in: 0...100)            .padding()        Text("\(Int(count))")    }}

Stepper

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 今年也得到了增强,示例如下:

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

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

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,使用示例如下:

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 视图可以让你轻松共享数据。示例代码如下:

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")        }    }}

视觉

Color

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

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 的更新

下面是个简单示例:

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 等。

示例代码如下:

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: - 扩展 TimelineScheduleextension 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 这些方法来进行改变。

示例代码如下:

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: - Pathstruct 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)

Swift 演进之路

作者 戴铭
2022年2月10日 18:28

已更新到 Swift 5.7。

本篇主要是对《A站 的 Swift 实践》文章中的一幅配图做了详细的扩展,能够更加全面和详细了解 Swift 语言的发展,文章中提到的 Swift 各版本的语法示例代码,及本文内容都可以在戴铭的开发小册子里对应栏目里找到,这个假期我也对 Swift 小册子里栏目内容进行了些更新和补充。《A站 的 Swift 实践》文章的那个演进配图如下:

文章内容如下:

Swift 1.1
  • countElements() 改成了 count()。
  • @NSApplicationMain 可以在 macOS 上使用。
Swift 1.2
  • 引入 Set 类型。
  • if let 可以放到一起,使用逗号分隔。
  • 新增 zip() 和 flatMap()。
  • 类增加静态方法和静态属性,使用 static 关键字描述。
  • as! 用于类型强转,失败会崩溃。
  • @noescape 用于描述作为参数闭包,用来告诉 Swift 闭包将在函数返回前使用。
  • 常量可以延后初始化。
Swift 2.0
  • 增加 guard 关键字,用于解可选项值。
  • defer 关键字用来延迟执行,即使抛出错误了都会在最后执行。
  • ErrorType 协议,以及 throws、do、try 和 catch 的引入用来处理错误。
  • characters 加上 count,用来替代 count()。
  • #available 用来检查系统版本。
Swift 2.1
  • 字符串插值可以包含字符串字面符号。

Swift 2.2

官方博客介绍:Swift 2.2 Released!New Features in Swift 2.2Swift 2.2 Release Process

  • FILE, LINEFUNCTION 换成 #file,#line 和 #function。
  • 废弃 ++ 和 – 操作符。
  • C 语言风格 for 循环废弃。
  • 废弃变量参数,因为变量参数容易和 inout 搞混。
  • 废弃字符串化的选择器,选择器不再能写成字符串了。
  • 元组可直接比较是否相等。
Swift 3.0

官方博客介绍:Swift 3.0 Released!Swift 3.0 Preview 1 Released!Swift 3.0 Release Process

  • 规范动词和名词来命名。
  • 去掉 NS 前缀。
  • 方法名描述参数部分变为参数名。
  • 省略没必要的单词,命名做了简化呢。比如 stringByTrimmingCharactersInSet 就换成了 trimmingCharacters。
  • 枚举的属性使用小写开头。
  • 引入 C 函数的属性。
Swift 3.1

官方博客介绍:Swift 3.1 Released!Swift 3.1 Release Process

  • 序列新增 prefix(while:) 和 drop(while:) 方法,顺序遍历执行闭包里的逻辑判断,满足条件就返回,遇到不匹配就会停止遍历。prefix 返回满足条件的元素集合,drop 返回停止遍历之后那些元素集合。
  • 泛型适用于嵌套类型。
  • 类型的扩展可以使用约束条件,比如扩展数组时,加上元素为整数的约束,这样的扩展就只会对元素为整数的数组有效。
Swift 4.0

官方博客介绍:Swift 4.0 Released!Swift 4 Release Process

Swift 4.1

官方博客介绍:Swift 4.1 Released!Swift 4.1 Release Process

Swift 4.2

官方博客介绍:Swift 4.2 Released!Swift 4.2 Release Process

Swift 5.0

官方博客介绍:Swift 5 Released!Swift 5.0 Release Process

Swift 5.1

官方博客介绍:Swift 5.1 Released!Swift 5.1 Release Process

Swift 5.2

官方博客介绍:Swift 5.2 Released!Swift 5.2 Release Process

Swift 5.3

官方博客介绍:Swift 5.3 released!Swift 5.3 Release Process

Swift 5.4

官方博客介绍:Swift 5.4 Released!

Swift 5.5

官方博客介绍:Swift 5.5 Released!

Swift 5.6

5.6 官方博客介绍

Package Manage 的一些提案

Swift 5.7

上面各个版本的语法更新的内容和更多代码的示例都可以在 Swift 小册子里查看,内容力求更全,描述力求更简洁,more big, more small。不想下载 app 也可以查看《戴铭的 Swift 小册子5.0》这篇,内容也同步做了更新和补充(内容达十五万字,值得你收藏和分享)。我对小册子内容查看样式视觉做了更新,排版用了赫蹏(hètí),字体是霞鹜文楷,更新了代码高亮风格,内容看起来更舒服。还有 icon 也进行替换,不用再对着枯燥的 SFSymbol 和我先前临时从以前图里随便挑的那条小狗 App icon 看了。


小册子现在可以直接下载 dmg 使用了,4.3下载地址:戴铭的小册子4.3.dmg.zip

如何用 SwiftUI + Combine + Swift Concurrency Aysnc/Await Actor 欢畅开发

作者 戴铭
2022年1月3日 11:53

先说两句废话(Don’t blame me about my calculation)

为啥写这篇文章,简单说,这些日子以来,总觉着做事还是专注些好,于是也逐步减少了很多信息消费,缩减了些欲望吧。目前更加关注怎么能够让开发更快乐些,相信有了这个方向,其他事情就更容易见招拆招了,面对的挑战也不再是挑战,而是激发自己斗志的辅助工具,其实不用在乎那些看似权威的做法和打法,只要是没让你开心的,肯定是有改进空间的。思路和方向才是最重要的,比如《大侦探波洛》,每次破案之前波洛就已经通过利害关系找好了方向,他的推理都是基于认定的方向去寻找素材。

开心不是因为没有挑战,没有困难,没有煎熬,而是因为找到了方向,这个方向就是,快乐的 Coding,开心的工作,为了达成这个目标那些艰难挑战也就不算什么了。对于 Coding,经过实操,我觉得声明式 UI 响应式编程范式就是很好的提升工作愉悦程度的方式。代码在 GitHub 上,链接。后面我会详细跟你说说这个应用如何开发的及相关知识点,希望你也能够感受下这种 Happy 的开发模式。

这之前,我想先说下为什么我觉得快乐是很件重要的事情。这段时间,我接受了好几次采访,有关于工程师文化方面的,还有《时尚COSMOPOLITAN》杂志的采访,记者会问到一些以前的事情,在聊过往事情时我发现原来快乐才是每天自己存在着的最根本的原动力。为了能够让自己能够一直活着,就不要偏离快乐。摄影师是任欣羽,参与过《一代宗师》的拍摄,还是《时尚芭莎》的模特。以下是时尚 COSMOPOLITAN 的采访内容:

完整内容见:https://mp.weixin.qq.com/s/b5fj2b65xRv4mhFpftwNcg

视频可见这条微博地址:https://weibo.com/1351051897/KEdu5Fi1x?pagetype=profilefeed

视频有六十多万播放量,两百多评论和一千多转发。

话题还上了微博热搜,有六百多万阅读和三千多讨论。

你肯定会觉得很奇怪,我怎么会接受时尚杂志采访,其实我早在2006年就跟时尚娱乐圈有染了,那年张纪中版《神雕侠侣》刚热播完,刘亦菲演的小龙女,我特别的喜欢。有幸在一次活动中我成为她的御用摄影师,由于过于激动手抖,拍糊了好多张,蛮可惜的。私存这批里还是有些清晰的,这些照片最近在找资料时不小心被我翻了出来。挑几张看看十六年前的刘亦菲和我是什么样的吧。

我还很用心的置办了新家。也是希望能够让自己能够开心些。

那么,怎样高效开发,带来愉悦的呢?

看看做出来的样子

这是个 macOS 应用《戴铭的小册子》,能够方便的查看 Swift 语法,还有一些主要库的使用指南,内容还在完善中,选择的库主要就是开发小册子应用使用到的 SwitUI、Combine、Swift Concurrency。

除了这些速查和库的使用内容外,这个应用还有一些开发者的动态,当他们有新的动作,比如提交了代码、star 了什么项目,提交和留言了议题都会直接在程序坞中提醒你。

我对一些库做了分类,方便按需查找,库有新的提交也会在程序坞中提醒。

还能方便的查看库的议题。比如在阮一峰的《科技爱好者周刊》的议题中可以看到有很多人推荐和自荐了一些信息。保留议题有一千六百多个。

这个元旦假期,我又添加了博客动态的功能,可以跟进一些博客内容的更新。

由于 Swift 语言的简洁,这些库的先进,最近有同学做实验,5.5版本还有瘦体积的效果。这样的一个小册子应用程序累积开发的时间不多,就是很高效的嘛。特别是最后博客动态这个功能,七年前我用 Objective-C 做的一个RSS阅读器耗费了我两三周的时间。同样的功能用 Swift 这套来做元旦假期两天就完成了。声明式 UI 响应式范式配合上 Swift 简洁的语法真是蛮 Cool 的。

基础网络能力

小册子应用会大量使用网络,先看看怎么用 Swift Concurrency 来做吧。

func RSSReq(_ urlStr: String) async throws -> String? {  guard let url = URL(string: urlStr) else {    fatalError("wrong url")  }  let req = URLRequest(url: url)  let (data, res) = try await URLSession.shared.data(for: req)  guard (res as? HTTPURLResponse)?.statusCode == 200 else {    fatalError("wrong data")  }  let dataStr = String(data: data, encoding: .utf8)  return dataStr}

如上,通过 url 可以获取到 data 和 response,和其他网络请求的方式不同的是,使用 await 后就不用繁琐的代理或闭包来进行后续的处理,代码变得更好理解,即字面意思上的 await 后执行后面的行。举个例子,获取博客 RSS 时,如果希望处理完一个 RSS 后再处理后面一个 RSS,使用 await 语法看起来就非常简洁清爽易于理解了。

Task {    do {        let rssFeed = SPC.rssFeed() // 获取所有 rss 源的模型        for r in rssFeed {            let str = try await RSSReq(r.feedLink)            guard let str = str else {                break            }            RSSVM.handleFetchFeed(str: str, rssModel: r)            // 在 Main Actor 更新通知数            await rssUpdateNotis()        }    } catch {}}

如上,当出现数据获取错误就跳过后面逻辑直接去请求下个 RSS,获取成功会更新 Main Actor 处理通知逻辑,不同队列之间切换就是这么自然,短短几行代码就都讲清楚了。

Combine 来处理网络的优势就是能够将网络请求到数据处理,最后到数据绑定都负责了。也就是发布者、操作符和订阅者的组合。下面我通过开发指南功能的过程说明下 Combine 的用法。

怎么开发指南功能

指南的列表结构使用的是 JSON,我把列表的数据保存在仓库的议题中,通过 GitHub 的 REST API 获取议题进行展示,这样对于指南列表的内容修改丰富可以通过直接在议题中进行编辑即可,无需升级应用。

Combine 网络请求我写在 APIRequest.swift 文件里,主要代码如下:

final class APISev: APISevType {    private let rootUrl: URL        init(rootUrl: URL = URL(string: "https://api.github.com")!) {        self.rootUrl = rootUrl    }        func response<Request>(from req: Request) -> AnyPublisher<Request.Res, APISevError> where Request : APIReqType {        let path = URL(string: req.path, relativeTo: rootUrl)!        var comp = URLComponents(url: path, resolvingAgainstBaseURL: true)!        comp.queryItems = req.qItems        var req = URLRequest(url: comp.url!)        req.addValue("token \(SPC.gitHubAccessToken)", forHTTPHeaderField: "Authorization")        req.addValue("SwiftPamphletApp", forHTTPHeaderField: "User-Agent")        let de = JSONDecoder()        de.keyDecodingStrategy = .convertFromSnakeCase        let sch = DispatchQueue(label: "GitHub API Queue", qos: .default, attributes: .concurrent)        return URLSession.shared.dataTaskPublisher(for: req)            .retry(3)            .subscribe(on: sch)            .receive(on: sch)            .map { data, res in                return data            }            .mapError { _ in                APISevError.resError            }            .decode(type: Request.Res.self, decoder: de)            .mapError { _ in                APISevError.parseError            }            .receive(on: RunLoop.main)            .eraseToAnyPublisher()    }}

如上,Combine 有 decode 的操作符,能够直接指定 JSON 模型数据类型和 JSONDecoder 对象。还有重试、队列指定以及抛错误的操作符。

一个应用的生命周期内,相同的请求会发布很多次,需要定义一个发起请求的 Subject,还有请求完成响应的 Subject。定义如下:

private let apCustomIssuesSj = PassthroughSubject<Void, Never>()private let resCustomIssuesSj = PassthroughSubject<IssueModel, Never>()

apCustomIssuesSj 会发起网络请求,代码如下:

let resCustomIssuesSm = apCustomIssuesSj    .flatMap { [apiSev] in        apiSev.response(from: reqCustomIssues)            .catch { [weak self] error -> Empty<IssueModel, Never> in                self?.errSj.send(error)                return .init()            }    }    .share()    .subscribe(resCustomIssuesSj)

上面 .catch 里errSj 发布者就是嵌套发布者,.flatMap 会让每次返回都是新发布者。apiSev.response 返回的是被类型擦除到 AnyPublisher 上,这样不同类型的发布者能够被 .flatMap 处理。闭包内的 .catch 处理能区分发布者,仅对当前发布者有效,不会影响后面发布者,导致整个管道被取消。发布者失败类型是 Never,失败本身会被连贯的处理。

.flatMap 除了从它 map 函数里生产发布者,还有个可选参数 maxPublishers,通过这个参数可以限制一次生产的最大发布者数量,也就是你可以通过 .flatMap 对管道上游的发布者进行反压(Backpressure),maxPublishers 能有效的节流管道,按照管道内部实际上的发布速度进行反压,这个也是 Combine 相较于 RxSwift 来说的一个优势。比如当网络请求多时,你可以通过设置 .max(1) 来减轻请求对服务的压力,同时还能够保证结果到达的顺序和请求顺序的一致。

resCustomIssuesSj 会去处理网络请求成功的数据,最后通过 .assign 将处理的数据分配给遵循 ObservableObject 协议类的 @Published 属性包装的属性 customIssues,用于响应式的更新 SwiftUI 布局数据。实现代码如下:

let repCustomIssuesSm = resCustomIssuesSj    .map({ issueModel in        let str = issueModel.body?.base64Decoded() ?? ""        let data: Data        data = str.data(using: String.Encoding.utf8)!        do {            let decoder = JSONDecoder()            return try decoder.decode([CustomIssuesModel].self, from: data)        } catch {            return [CustomIssuesModel]()        }    })    .assign(to: \.customIssues, on: self)

如上,你会发现在 .map 中还会对数据进行 base64 decode,这是因为我在仓库议题中保存的是 base64 encode 的数据,decode 成 JSON 数据再用 JSONDecoder 转为 [CustomIssuesModel] 模型 数据分配给 customIssues。

使用 SwiftUI 写的指南列表视图,代码如下:

struct IssuesListFromCustomView: View {    @StateObject var vm: IssueVM    var body: some View {        List {            ForEach(vm.customIssues) { ci in                Section {                    ForEach(ci.issues) { i in                        NavigationLink {                            IssueView(vm: IssueVM(repoName: SPC.pamphletIssueRepoName, issueNumber: i.number))                        } label: {                            Text(i.title)                                .bold()                        }                    }                } header: {                    Text(ci.name).font(.title)                }            }        }        .alert(vm.errMsg, isPresented: $vm.errHint, actions: {})        .onAppear {            vm.doing(.customIssues)        }    }}

代码中的属性包装 @StateObject 会在当前视图生命周期中保持 vm 这个属性的数据,vm 需要遵循 ObservableObject 协议,其 @Published 发布属性的值会被 SwiftUI 自动进行管理,属性 vm 的发布属性数据变化时会自动触发布局依据新数据的更新。

上面代码中的 SwiftUI 写的布局界面效果如下:

界面主体是 List 视图,根据 List 的定义,要求的输入是一个数组,数组内元素需要遵循 Identifiable,每行的返回是被 @ViewBuilder 标记的 View。ForEach 根据数组中的元素会创建能够重复使用的视图,性能接近大家熟悉的 UITableView,但是写法上简洁的不要太多,真实完美解痛点案例,😄❤️。

指南的内容也会以 markdown 格式存在议题中,通过调用 GitHub API 的接口进行指南内容的读取。一个接口是议题接口,请求结构体定义如下:

struct IssueRequest: APIReqType {    typealias Res = IssueModel    var repoName: String    var issueNumber: Int    var path: String {        return "/repos/\(repoName)/issues/\(issueNumber)"    }    var qItems: [URLQueryItem]? {        return nil    }}

另一个是议题留言的接口,定义如下:

struct IssueRequest: APIReqType {    typealias Res = IssueModel    var repoName: String    var issueNumber: Int    var path: String {        return "/repos/\(repoName)/issues/\(issueNumber)"    }    var qItems: [URLQueryItem]? {        return nil    }}

实现效果如下图:

指南内容放在议题中,也是希望能够通过议题留言功能,让反馈和大家经验的补充被更多人看到。

除了语法速查的内容,关于 Swift 的一些特性,专题,还有 Combine、Concurrency、SwiftUI 这些库的使用指南内容都是采用的 GitHub API 接口读取议题方式获取的。

读取议题接口获取指南列表的模式,也用在了开发者和仓库动态列表中。接下来我跟你说下开发者和仓库动态怎么开发的吧。

开发者和仓库动态

显示开发者信息的页面代码在 UserView.swift 里,开发者介绍信息页面如下:

界面中的数据都来自 /users/(userName) 接口,获取数据逻辑在 UserVM.swift 里。数据多,但情况不复杂,布局上只要注意进行数据是否有的区分即可,布局代码如下:

HStack {    VStack(alignment: .leading, spacing: 10) {        HStack() {            AsyncImageWithPlaceholder(size: .normalSize, url: vm.user.avatarUrl)            VStack(alignment: .leading, spacing: 5) {                HStack {                    Text(vm.user.name ?? vm.user.login).font(.system(.title))                    Text("(\(vm.user.login))")                    Text("订阅者 \(vm.user.followers) 人,仓库 \(vm.user.publicRepos) 个")                }                HStack {                    ButtonGoGitHubWeb(url: vm.user.htmlUrl, text: "在 GitHub 上访问")                    if vm.user.location != nil {                        Text("居住:\(vm.user.location ?? "")").font(.system(.subheadline))                    }                }            } // end VStack        } // end HStack                if vm.user.bio != nil {            Text("简介:\(vm.user.bio ?? "")")        }        HStack {            if vm.user.blog != nil {                if !vm.user.blog!.isEmpty {                    Text("博客:\(vm.user.blog ?? "")")                    ButtonGoGitHubWeb(url: vm.user.blog ?? "", text: "访问")                }            }            if vm.user.twitterUsername != nil {                Text("Twitter:")                ButtonGoGitHubWeb(url: "https://twitter.com/\(vm.user.twitterUsername ?? "")", text: "@\(vm.user.twitterUsername ?? "")")            }        } // end HStack    } // end VStack    Spacer()}

上面代码可以看到,对于数据是否存在,SwiftUI 是可以使用 if 来进行判断是否展示视图的,这个条件判断也会存在于整个视图结构类型中被编译生成,因此更好的方式是将数据判断放到 ViewModifier 中,因为 ViewModifier 处理时机是在运行时,可以减少布局初始创建逻辑运算。

开发者的事件和接受事件部分的数据就比介绍部分复杂些,使得界面变化也多些,事件接口是 /users/(userName)/events,接受事件接口是 /users/(userName)/received_events 。数据的复杂体现在类型上,类型种类较多,我采用的是直接处理 payload 里的字段,如果其 issue.number 字段不为空,那么就表示这个开发者事件是和议题相关,会显示 issue.title 标题,有内容的话,也就是 issue.body 不为空,继续显示议题的内容。如果字段是 comment,就表示事件是议题的留言。如果字段是 commits,表示需要列出这个事件中所有的 commit 提交及标题和描述。pullRequest 字段不为空就显示这个 PR 的标题和内容描述。字段处理逻辑代码实现如下:

if event.payload.issue?.number != nil {    if event.payload.issue?.title != nil {        Text(event.payload.issue?.title ?? "").bold()    }    if event.payload.issue?.body != nil && event.type != "IssueCommentEvent" {        Markdown(Document(event.payload.issue?.body ?? ""))    }    if event.type == "IssueCommentEvent" && event.payload.comment?.body != nil {        Markdown(Document(event.payload.comment?.body ?? ""))    }}if event.payload.commits != nil {    ListCommits(event: event)}if event.payload.pullRequest != nil {    if event.payload.pullRequest?.title != nil {        Text(event.payload.pullRequest?.title ?? "").bold()    }    if event.payload.pullRequest?.body != nil {        Markdown(Document(event.payload.pullRequest?.body ?? ""))    }}if event.payload.description != nil {    Markdown(Document(event.payload.description ?? ""))}

上面代码中,对于不定数量的 commit 视图写在了一个单独的 ListCommits 视图中。只要是遵循了 View 协议,就可以作为自定义视图在其他视图中直接使用。ListCommits 代码如下:

struct ListCommits: View {    var event: EventModel    var body: some View {        ForEach(event.payload.commits ?? [PayloadCommitModel](), id: \.self) { c in            ButtonGoGitHubWeb(url: "https://github.com/\(event.repo.name)/commit/\(c.sha ?? "")", text: "提交")            Text(c.message ?? "")        }    }}

上面代码你会发现一个 ButtonGoGitHubWeb的视图,进入看会发现用到了一个自定义的 ButtonStyle:

.buttonStyle(FixAwfulPerformanceStyle())

FixAwfulPerformanceStyle() 的实现如下:

/// 列表加按钮性能问题,需观察官方后面是否解决/// https://twitter.com/fcbunn/status/1259078251340800000struct FixAwfulPerformanceStyle: ButtonStyle {    func makeBody(configuration: Self.Configuration) -> some View {        configuration.label            .font(.body)            .padding(EdgeInsets.init(top: 2, leading: 6, bottom: 2, trailing: 6))            .foregroundColor(configuration.isPressed ? Color(nsColor: NSColor.selectedControlTextColor) : Color(nsColor: NSColor.controlTextColor))            .background(configuration.isPressed ? Color(nsColor: NSColor.selectedControlColor) : Color(nsColor: NSColor.controlBackgroundColor))            .overlay(RoundedRectangle(cornerRadius: 6.0).stroke(Color(nsColor: NSColor.lightGray), lineWidth: 0.5))            .clipShape(RoundedRectangle(cornerRadius: 6.0))            .shadow(color: Color.gray, radius: 0.5, x: 0, y: 0.5)    }}

这是社区 @Kam-To 提的一个 PR,是解的 macOS 上的一个性能问题,也就是在 List 中直接使用 Button,在列表快速滚动时,流畅度会有损伤,加上上面的 ButtonStyle 代码就好了。

原推见 https://twitter.com/fcbunn/status/1259078251340800000

开发者接受事件和事件类似,只是会多显示事件的 actor 字段内容,表明开发者接受的是谁发出的事件。事件界面如下所示:

仓库整体处理和开发者类似,只是多了议题和 README 内容,数据复杂度比开发者要低。接下来我要跟你说的是如果开发者或仓库有新的提交,怎么能够获取到,并提示有更新。

动态有更新,怎么提醒的

我的思路是通过本地定时器,定期获取数据,本地记录上次浏览的位置,通过对比,看有多少新的动态没有查看,并通过 .badge 这个 ViewModifier 和 NSApp.dockTile.badgeLabel 来进行端内端外的提醒。

定时器

在 SwiftUI 中,可以使用 Combine 的 Timer.publish 发布器来设置一个定时属性,Timer.publish 指定好时间周期和队列模式等参数。比如设置一个开发者动态定时器属性,代码如下:

let timerForRepos = Timer.publish(every: SPC.timerForReposSec, on: .main, in: .common).autoconnect()

然后再在 .onReceive 中执行网络数据获取操作,就可以定时获取数据了。

.onReceive(timerForRepos, perform: { time in    if let repoName = appVM.timeForReposEvent() {        let vm = RepoVM(repoName: repoName)        vm.doing(.notiRepo)    }})

获取到的数据会跟本地已经存储的数据进行对比。

本地存储

本地数据存储,我用的是 SQLite.swift,这个库是使用 Swift 对 SQLite 做了一层封装,使用很简便,在 DBHandler.swift 里有数据库初始化和表的创建相关代码,DBDevNoti.swift 中的 DevsNotiDataHelper 有对数据操作的代码,DBDevNoti 定义了数据表的结构。如何使用可以参考 SQLite.swift 官方的指南,里面讲得非常详细清楚。

用 DB Browser for SQLite 应用可以查看本地的数据库。下面是用它查看记录的 RSS 的数据,如图:

更新未读数的判断逻辑,我封到了一个函数里,代码如下:

func updateDBDevsInfo(ems: [EventModel]) {    do {        if let f = try DevsNotiDataHelper.find(sLogin: userName) {            var i = 0            var lrid = f.lastReadId            for em in ems {                if i == 0 {                    lrid = em.id                }                if em.id == f.lastReadId {                    break                }                i += 1            }            i = f.unRead + i            do {                let _ = try DevsNotiDataHelper.update(i: DBDevNoti(login: userName, lastReadId: lrid, unRead: i))            } catch {}        } // end if let f    } catch {}} // end func updateDBDevsInfo

如上面代码所示,入参 ems 是获取到的最新数据,先从本地数据库中取到上次最新的阅读编号 lastReadId,迭代 ems,如果第一个 ems 的编号就和本地数据库 lastReadId 一样,那表示无新动态,如果没有就开始计数,直到找到相等的 lastReadId 位置,记了多少数就表示有多少新动态。

提醒

列表、Sidebar 还有 macOS 系统的 Dock 上都可以显示新状态数的提醒。列表和 Sidebar 直接使用 .badge ViewModifier 就可以展示未读数了,效果如下:

Dock 栏提示设置需要用到系统的 NSApp,代码如下:

NSApp.dockTile.showsApplicationBadge = trueNSApp.dockTile.badgeLabel = "\(count)"

小册子里还可以查看 Swift 社区里博主们博客更新动态。我接着跟你说说我怎么做的。

博客 RSS 更新动态

博客 RSS 的数据获取我在前面基础网络能力中已经说了。所有解析逻辑我都写在了工程 RSSReader/Parser/ 目录下的 ParseStandXMLTagTokens.swift、ParseStandXMLTags.swift、ParseStandXML.swift 三个文件中,实现思路我在先前《如何对 iOS 启动阶段耗时进行分析》文章的“优化后如何保持?”章节有详细说明。

根据 RSS 的 XML 结构,定义 Model 结构如下:

struct RSSModel: Identifiable {    var id = UUID()    var title = ""    var description = ""    var feedLink = ""    var siteLink = ""    var language = ""    var lastBuildDate = ""    var pubDate = ""    var items = [RSSItemModel]()    var unReadCount = 0}struct RSSItemModel: Identifiable {    var id = UUID()    var guid = ""    var title = ""    var description = ""    var link = ""    var pubDate = ""    var content = ""    var isRead = false}

根据这个结构,也会在本地数据库设计对应的两个表,两个表的增删改代码分别在 DBRSSFeed.swift 和 DBRSSItems.swift 里。表的结构和 Model 的结构基本一致,方便内存和磁盘进行切换。更新提醒逻辑和前面说的开发者动态更新逻辑区别在于,RSS 使用 isRead 标记有没有阅读过,直接在本地数据里 count 出 isRead 字段值为 false 的数量就是需要提醒的数。

新 RSS 的添加会先在本地数据库中查找是否有存在,依据的是文章的 url,如果不存在就会添加到数据库中设置为未读作为提醒。

RSS 里文章的内容是 HTML,显示内容使用的是 WebKit 库,要在 SwiftUI 中使用,需要封装下,代码如下:

import SwiftUIimport WebKitstruct WebUIView : NSViewRepresentable {    let html: String    func makeNSView(context: Context) -> some WKWebView {        return WKWebView()    }    func updateNSView(_ nsView: NSViewType, context: Context) {        nsView.loadHTMLString(html, baseURL: nil)    }}

效果如下图:

云打包

工程如果是本地编译,在 SwiftPamphletAppConfig.swift 的 gitHubAccessToken 中添上 token 就可以了,如果想快速打包使用小册子,使用 Github Action Workflow 编译,无需在本地操作、也无需开启 Xcode 设置个人开发帐号,只需设置 personal access token(PAT) 在 repository 设定中 action secrets,并命名为 PAT。Frok 此 repository,设置 PAT,手动启用 action,等候约3分钟即可下载档案,往后专案更新时,只需 fetch and merge,action 会自动进行。非常感谢社区 @powenn 开发的这个 Github Action。

推荐可以学习的开源仓库

为了避免闭门造车,可以多关注些开源项目,以下这些仓库是我放在小册子里可以关注到更新动态的项目,这里作为附录列下,也可以直接在小册子里查看。除了 Swift 也有些非常有趣的项目,希望可以丰富到你的开发生活。

好库

官方

新鲜事

封装易用功能

网络

图片

文字处理

动画

持久化存储

编程范式

路由

静态检查

系统能力

接口

macOS程序

性能和工程构建

音视频

服务器

探索库

SwiftUI扩展

接口应用

macOS

应用

  • Clendar SwiftUI 写的日历应用

游戏

新技术展示

新鲜事

聚合

知识管理

  • logseq 更好的知识管理工具

性能和工程构建

网络

图形

系统

Apple

待分类

戴铭的 Swift 小册子 5.0

作者 戴铭
2021年11月23日 16:28

update:内容已更新到 5.0 版本。新增 WWDC22 内容。

新dyld源码透出近期苹果出新系统必然,依苹果 taste,势必要用好技术抛落后技术。漫漫长假我完善了Swift手册内容,字数达到十五万字,内容已压缩压缩再压缩,求全存简,more big, so small 。满满诚意,望有用、值得收藏、求转发。

come on and learn (੭*ˊᵕˋ)੭

– 戴·代码之使徒·画终结者·被光选中的人·铭

背景说明

越来越多同学打算开始用 Swift 来开发了,可很多人以前都没接触过 Swift。这篇和我以前文章不同的是,本篇只是面向 Swift 零基础的同学,内容主要是一些直接可用的小例子,例子可以直接在工程中用或自己调试着看。

记得以前 PHP 有个 chm 的手册,写的很简单,但很全,每个知识点都有例子,社区版每个知识点下面还有留言互动。因此,我弄了个 Swift 的手册,是个 macOS 程序。建议使用我开发的这个 macOS 程序来浏览。源码地址:ming1016/SwiftPamphletApp,直接下载 dmg 地址:戴铭的小册子5.0.dmg.zip

这个程序是Swift写的,按照声明式UI,响应式编程范式开发的,源码也可以看看。与其讲一堆,不如调着试。

下面是文本内容。注:代码中简化变量名是为了能更快速关注到语言用法。

语法速查

基础

变量 let, var

变量是可变的,使用 var 修饰,常量是不可变的,使用 let 修饰。类、结构体和枚举里的变量是属性。

var v1:String = "hi" // 标注类型var v2 = "类型推导"let l1 = "标题" // 常量class a {    let p1 = 3    var p2: Int {        p1 * 3    }}

属性没有 set 可以省略 get,如果有 set 需加 get。变量设置前通过 willSet 访问到,变量设置后通过 didSet 访问。

打印 print(“”)

控制台打印值

print("hi")let i = 14print(i)print("9月\(i)是小柠檬的生日")for i in 1...3{    print(i)}// output:// 1// 2// 3// 使用terminator使循环打印更整洁for i in 1...3 {    print("\(i) ", terminator: "")}// output:// 1 2 3

注释 //

// 单行注释/*多行注释第一行。多行注释第二行。*/ // MARK: 会在 minimap 上展示// TODO: 待做// FIXME: 待修复

可选 ?, !

可能会是 nil 的变量就是可选变量。当变量为 nil 通过??操作符可以提供一个默认值。

var o: Int? = nillet i = o ?? 0

闭包

闭包也可以叫做 lambda,是匿名函数,对应 OC 的 block。

let a1 = [1,3,2].sorted(by: { (l: Int, r: Int) -> Bool in    return l < r})// 如果闭包是唯一的参数并在表达式最后可以使用结尾闭包语法,写法简化为let a2 = [1,3,2].sorted { (l: Int, r: Int) -> Bool in    return l < r}// 已知类型可以省略let a3 = [1,3,2].sorted { l, r in    return l < r}// 通过位置来使用闭包的参数,最后简化如下:let a4 = [1,3,2].sorted { $0 < $1 }

函数也是闭包的一种,函数的参数也可以是闭包。@escaping 表示逃逸闭包,逃逸闭包是可以在函数返回之后继续调用的。@autoclosure 表示自动闭包,可以用来省略花括号。

函数 func

函数可以作为另一个函数的参数,也可以作为另一个函数的返回。函数是特殊的闭包,在类、结构体和枚举中是方法。

// 为参数设置默认值func f1(p: String = "p") -> String {    "p is \(p)"}// 函数作为参数func f2(fn: (String) -> String, p: String) -> String {    return fn(p)}print(f2(fn:f1, p: "d")) // p is d// 函数作为返回值func f3(p: String) -> (String) -> String {    return f1}print(f3(p: "yes")("no")) // p is no

函数可以返回多个值,函数是可以嵌套的,也就是函数里内可以定义函数,函数内定义的函数可以访问自己作用域外函数内的变量。inout 表示的是输入输出参数,函数可以在函数内改变输入输出参数。defer 标识的代码块会在函数返回之前执行。

函数在 Swift 5.4 时开始有了使用多个变量参数的能力,使用方法如下:

func f4(s: String..., i: Int...) {    print(s)    print(i)}f4(s: "one", "two", "three", i: 1, 2, 3)/// ["one", "two", "three"]/// [1, 2, 3]

嵌套函数可以重载,嵌套函数可以在声明函数之前调用他。

func f5() {    nf5()    func nf5() {        print("this is nested function")    }}f5() // this is nested function

访问控制

在 Xcode 里的 target 就是模块,使用 import 可导入模块。模块内包含源文件,每个源文件里可以有多个类、结构体、枚举和函数等多种类型。访问级别可以通过一些关键字描述,分为如下几种:

  • open:在模块外可以调用和继承。
  • public:在模块外可调用不可继承,open 只适用类和类成员。
  • internal:默认级别,模块内可跨源文件调用,模块外不可调用。
  • fileprivate:只能在源文件内访问。
  • private:只能在所在的作用域内访问。

重写继承类的成员,可以设置成员比父类的这个成员更高的访问级别。Setter 的级别可以低于对应的 Getter 的级别,比如设置 Setter 访问级别为 private,可以在属性使用 private(set) 来修饰。

基础类型

数字 Int, Float

数字的类型有 Int、Float 和 Double

// Intlet i1 = 100let i2 = 22print(i1 / i2) // 向下取整得 4// Floatlet f1: Float = 100.0let f2: Float = 22.0print(f1 / f2) // 4.5454545let f3: Float16 = 5.0 // macOS 还不能用let f4: Float32 = 5.0let f5: Float64 = 5.0let f6: Float80 = 5.0print(f4, f5, f6) // 5.0 5.0 5.0// Doublelet d1: Double = 100.0let d2: Double = 22.0print(d1 / d2) // 4.545454545454546// 字面量print(Int(0b10101)) // 0b 开头是二进制 print(Int(0x00afff)) // 0x 开头是十六进制print(2.5e4) // 2.5x10^4 十进制用 eprint(0xAp2) // 10*2^2  十六进制用 pprint(2_000_000) // 2000000// isMultiple(of:) 方法检查一个数字是否是另一个数字的倍数let i3 = 36print(i3.isMultiple(of: 9)) // true

处理数字有 floor、ceil、round。floor 是向下取整,只取整数部分;cell 是向上取整,只要有不为零的小数,整数就加1;round 是四舍五入。

布尔数 Bool

布尔数有 true 和 false 两种值,还有一个能够切换这两个值的 toggle 方法。

var b = falseb.toggle() // trueb.toggle() // false

元组 (a, b, c)

元组里的值类型可以是不同的。元组可以看成是匿名的结构体。

let t1 = (p1: 1, p2: "two", p3: [1,2,3])print(t1.p1)print(t1.p3)// 类型推导let t2 = (1, "two", [1,2,3])// 通过下标访问print(t2.1) // two// 分解元组let (dp1, dp2, _) = t2print(dp1)print(dp2)

字符串

let s1 = "Hi! This is a string. Cool?"/// 转义符 \n 表示换行。/// 其它转义字符有 \0 空字符)、\t 水平制表符 、\n 换行符、\r 回车符let s2 = "Hi!\nThis is a string. Cool?"// 多行let s3 = """Hi!This is a string.Cool?"""// 长度print(s3.count)print(s3.isEmpty)// 拼接print(s3 + "\nSure!")// 字符串中插入变量let i = 1print("Today is good day, double \(i)\(i)!")/// 遍历字符串/// 输出:/// o/// n/// efor c in "one" {    print(c)}// 查找print(s3.lowercased().contains("cool")) // true// 替换let s4 = "one is two"let newS4 = s4.replacingOccurrences(of: "two", with: "one")print(newS4)// 删除空格和换行let s5 = " Simple line. \n\n  "print(s5.trimmingCharacters(in: .whitespacesAndNewlines))// 切割成数组let s6 = "one/two/three"let a1 = s6.components(separatedBy: "/") // 继承自 NSString 的接口print(a1) // ["one", "two", "three"]let a2 = s6.split(separator: "/")print(a2) // ["one", "two", "three"] 属于切片,性能较 components 更好// 判断是否是某种类型let c1: Character = "🤔"print(c1.isASCII) // falseprint(c1.isSymbol) // trueprint(c1.isLetter) // falseprint(c1.isNumber) // falseprint(c1.isUppercase) // false// 字符串和 Data 互转let data = Data("hi".utf8)let s7 = String(decoding: data, as: UTF8.self)print(s7) // hi// 字符串可以当作集合来用。let revered = s7.reversed()print(String(revered))

Unicode、Character 和 SubString 等内容参见官方字符串文档:Strings and Characters — The Swift Programming Language (Swift 5.1)

字符串字面符号可以参看《String literals in Swift》。

原始字符串

// 原始字符串在字符串前加上一个或多个#符号。里面的双引号和转义符号将不再起作用了,如果想让转义符起作用,需要在转义符后面加上#符号。let s8 = #"\(s7)\#(s7) "one" and "two"\n. \#nThe second line."#print(s8)/// \(s7)hi "one" and "two"\n./// The second line.// 原始字符串在正则使用效果更佳,反斜杠更少了。let s9 = "\\\\[A-Z]+[A-Za-z]+\\.[a-z]+"let s10 = #"\\[A-Z]+[A-Za-z]+\.[a-z]+"#print(s9) // \\[A-Z]+[A-Za-z]+\.[a-z]+print(s10) // \\[A-Z]+[A-Za-z]+\.[a-z]+

枚举

Swift的枚举有类的一些特性,比如计算属性、实例方法、扩展、遵循协议等等。

enum E1:String, CaseIterable {    case e1, e2 = "12"}// 关联值enum E2 {    case e1([String])    case e2(Int)}let e1 = E2.e1(["one","two"])let e2 = E2.e2(3)switch e1 {case .e1(let array):    print(array)case .e2(let int):    print(int)}print(e2)// 原始值print(E1.e1.rawValue)// 遵循 CaseIterable 协议可迭代for ie in E1.allCases {    print("show \(ie)")}// 递归枚举enum RE {    case v(String)    indirect case node(l:RE, r:RE)}let lNode = RE.v("left")let rNode = RE.v("right")let pNode = RE.node(l: lNode, r: rNode)switch pNode {case .v(let string):    print(string)case .node(let l, let r):    print(l,r)    switch l {    case .v(let string):        print(string)    case .node(let l, let r):        print(l, r)    }    switch r {    case .v(let string):        print(string)    case .node(let l, let r):        print(l, r)    }}

@unknown 用来区分固定的枚举和可能改变的枚举的能力。@unknown 用于防止未来新增枚举属性会进行提醒提示完善每个 case 的处理。

// @unknownenum E3 {    case e1, e2, e3}func fe1(e: E3) {    switch e {    case .e1:        print("e1 ok")    case .e2:        print("e2 ok")    case .e3:        print("e3 ok")    @unknown default:        print("not ok")    }}

符合 Comparable 协议的枚举可以进行比较。

// Comparable 枚举比较enum E4: Comparable {    case e1, e2    case e3(i: Int)    case e4}let e3 = E4.e4let e4 = E4.e3(i: 3)let e5 = E4.e3(i: 2)let e6 = E4.e1print(e3 > e4) // truelet a1 = [e3, e4, e5, e6]let a2 = a1.sorted()for i in a2 {    print(i.self)}/// e1/// e3(i: 2)/// e3(i: 3)/// e4

泛型

泛型可以减少重复代码,是一种抽象的表达方式。where 关键字可以对泛型做约束。

func fn<T>(p: T) -> [T] {    var r = [T]()    r.append(p)    return r}print(fn(p: "one"))// 结构体struct S1<T> {    var arr = [T]()    mutating func add(_ p: T) {        arr.append(p)    }}var s1 = S1(arr: ["zero"])s1.add("one")s1.add("two")print(s1.arr) // ["zero", "one", "two"]

关联类型

protocol pc {    associatedtype T    mutating func add(_ p: T)}struct S2: pc {    typealias T = String // 类型推导,可省略    var strs = [String]()    mutating func add(_ p: String) {        strs.append(p)    }}

泛型适用于嵌套类型

struct S3<T> {    struct S4 {        var p: T    }        var p1: T    var p2: S4}let s2 = S3(p1: 1, p2: S3.S4(p: 3))let s3 = S3(p1: "one", p2: S3.S4(p: "three"))print(s2,s3)

不透明类型

不透明类型会隐藏类型,让使用者更关注功能。不透明类型和协议很类似,不同的是不透明比协议限定的要多,协议能够对应更多类型。

protocol P {    func f() -> String}struct S1: P {    func f() -> String {        return "one\n"    }}struct S2<T: P>: P {    var p: T    func f() -> String {        return p.f() + "two\n"    }}struct S3<T1: P, T2: P>: P {    var p1: T1    var p2: T2    func f() -> String {        return p1.f() + p2.f() + "three\n"    }}func someP() -> some P {    return S3(p1: S1(), p2: S2(p: S1()))}let r = someP()print(r.f())

函数调用者决定返回什么类型是泛型,函数自身决定返回什么类型使用不透明返回类型。

Result

Result 类型用来处理错误,特别适用异步接口的错误处理。

extension URLSession {    func dataTaskWithResult(        with url: URL,        handler: @escaping (Result<Data, Error>) -> Void    ) -> URLSessionDataTask {        dataTask(with: url) { data, _, err in            if let err = err {                handler(.failure(err))            } else {                handler(.success(data ?? Data()))            }        }    }}let url = URL(string: "https://ming1016.github.io/")!// 以前网络请求let t1 = URLSession.shared.dataTask(with: url) {    data, _, error in    if let err = error {        print(err)    } else if let data = data {        print(String(decoding: data, as: UTF8.self))    }}t1.resume()// 使用 Result 网络请求let t2 = URLSession.shared.dataTaskWithResult(with: url) { result in    switch result {    case .success(let data):        print(String(decoding: data, as: UTF8.self))    case .failure(let err):        print(err)    }}t2.resume()

类型转换

使用 is 关键字进行类型判断, 使用as 关键字来转换成子类。

class S0 {}class S1: S0 {}class S2: S0 {}var a = [S0]()a.append(S1())a.append(S2())for e in a {    // 类型判断    if e is S1 {        print("Type is S1")    } else if e is S2 {        print("Type is S2")    }    // 使用 as 关键字转换成子类    if let s1 = e as? S1 {        print("As S1 \(s1)")    } else if let s2 = e as? S2 {        print("As S2 \(s2)")    }}

类和结构体

类可以定义属性、方法、构造器、下标操作。类使用扩展来扩展功能,遵循协议。类还以继承,运行时检查实例类型。

class C {    var p: String    init(_ p: String) {        self.p = p    }        // 下标操作    subscript(s: String) -> String {        get {            return p + s        }        set {            p = s + newValue        }    }}let c = C("hi")print(c.p)print(c[" ming"])c["k"] = "v"print(c.p)

结构体

结构体是值类型,可以定义属性、方法、构造器、下标操作。结构体使用扩展来扩展功能,遵循协议。

struct S {    var p1: String = ""    var p2: Int}extension S {    func f() -> String {        return p1 + String(p2)    }}var s = S(p2: 1)s.p1 = "1"print(s.f()) // 11

属性

类、结构体或枚举里的变量常量就是他们的属性。

struct S {    static let sp = "类型属性" // 类型属性通过类型本身访问,非实例访问    var p1: String = ""    var p2: Int = 1    // cp 是计算属性    var cp: Int {        get {            return p2 * 2        }        set {            p2 = newValue + 2        }    }    // 只有 getter 的是只读计算属性    var rcp: Int {        p2 * 4    }}print(S.sp)print(S().cp) // 2var s = S()s.cp = 3print(s.p2) // 5print(S().rcp) // 4

willSet 和 didSet 是属性观察器,可以在属性值设置前后插入自己的逻辑处理。

键路径表达式作为函数

struct S2 {    let p1: String    let p2: Int}let s2 = S2(p1: "one", p2: 1)let s3 = S2(p1: "two", p2: 2)let a1 = [s2, s3]let a2 = a1.map(\.p1)print(a2) // ["one", "two"]

方法

enum E: String {    case one, two, three    func showRawValue() {        print(rawValue)    }}let e = E.threee.showRawValue() // three// 可变的实例方法,使用 mutating 标记struct S {    var p: String    mutating func addFullStopForP() {        p += "."    }}var s = S(p: "hi")s.addFullStopForP()print(s.p)// 类方法class C {    class func cf() {        print("类方法")    }}

static和class关键字修饰的方法类似 OC 的类方法。static 可以修饰存储属性,而 class 不能;class 修饰的方法可以继承,而 static 不能。在协议中需用 static 来修饰。

静态下标方法

// 静态下标struct S2 {    static var sp = [String: Int]()        static subscript(_ s: String, d: Int = 10) -> Int {        get {            return sp[s] ?? d        }        set {            sp[s] = newValue        }    }}S2["key1"] = 1S2["key2"] = 2print(S2["key2"]) // 2print(S2["key3"]) // 10

自定义类型中实现了 callAsFunction() 的话,该类型的值就可以直接调用。

// callAsFunction()struct S3 {    var p1: String        func callAsFunction() -> String {        return "show \(p1)"    }}let s2 = S3(p1: "hi")print(s2()) // show hi

继承

类能继承另一个类,继承它的方法、属性等。

// 类继承class C1 {    var p1: String    var cp1: String {        get {            return p1 + " like ATM"        }        set {            p1 = p1 + newValue        }    }    init(p1: String) {        self.p1 = p1    }    func sayHi() {        print("Hi! \(p1)")    }}class C2: C1 {    var p2: String    init(p2: String) {        self.p2 = p2        super.init(p1: p2 + "'s father")    }}C2(p2: "Lemon").sayHi() // Hi! Lemon's father// 重写父类方法class C3: C2 {    override func sayHi() {        print("Hi! \(p2)")    }}C3(p2: "Lemon").sayHi() // Hi! Lemon// 重写计算属性class C4: C1 {    override var cp1: String {        get {            return p1 + " like Out of the blade"        }        set {            p1 = p1 + newValue        }    }}print(C1(p1: "Lemon").cp1) // Lemon like ATMprint(C4(p1: "Lemon").cp1) // Lemon like Out of the blade

通过 final 关键字可以防止类被继承,final 还可以用于属性和方法。使用 super 关键字指代父类。

函数式

map

map 可以依次处理数组中元素,并返回一个处理后的新数组。

let a1 = ["a", "b", "c"]let a2 = a1.map {    "\($0)2"}print(a2) // ["a2", "b2", "c2"]

使用 compactMap 可以过滤 nil 的元素。flatMap 会将多个数组合成一个数组返回。

filter

根据指定条件返回

let a1 = ["a", "b", "c", "call my name"]let a2 = a1.filter {    $0.prefix(1) == "c"}print(a2) // ["c", "call my name"]

reduce

reduce 可以将迭代中返回的结果用于下个迭代中,并,还能让你设个初始值。

let a1 = ["a", "b", "c", "call my name.", "get it?"]let a2 = a1.reduce("Hey u,", { partialResult, s in    // partialResult 是前面返回的值,s 是遍历到当前的值    partialResult + " \(s)"})print(a2) // Hey u, a b c call my name. get it?

sorted

排序

// 类型遵循 Comparablelet a1 = ["a", "b", "c", "call my name.", "get it?"]let a2 = a1.sorted()let a3 = a1.sorted(by: >)let a4 = a1.sorted(by: <)print(a2) // Hey u, a b c call my name. get it?print(a3) // ["get it?", "call my name.", "c", "b", "a"]print(a4) // ["a", "b", "c", "call my name.", "get it?"]// 类型不遵循 Comparablestruct S {    var s: String    var i: Int}let a5 = [S(s: "a", i: 0), S(s: "b", i: 1), S(s: "c", i: 2)]let a6 = a5    .sorted { l, r in        l.i > r.i    }    .map {        $0.i    }print(a6) // [2, 1, 0]

控制流

If • If let • If case let

// iflet s = "hi"if s.isEmpty {    print("String is Empty")} else {    print("String is \(s)")}// 三元条件s.isEmpty ? print("String is Empty again") : print("String is \(s) again")// if let-elsefunc f(s: String?) {    if let s1 = s {        print("s1 is \(s1)")    } else {        print("s1 is nothing")    }    // nil-coalescing    let s2 = s ?? "nothing"    print("s2 is \(s2)")}f(s: "something")f(s: nil)// if case letenum E {    case c1(String)    case c2([String])        func des() {        switch self {        case .c1(let string):            print(string)        case .c2(let array):            print(array)        }    }}E.c1("enum c1").des()E.c2(["one", "two", "three"]).des()

Guard guard, guard let

更好地处理异常情况

// guardfunc f1(p: String) -> String {    guard p.isEmpty != true else {        return "Empty string."    }    return "String \(p) is not empty."}print(f1(p: "")) // Empty string.print(f1(p: "lemon")) // String lemon is not empty.// guard letfunc f2(p1: String?) -> String {    guard let p2 = p1 else {        return "Nil."    }    return "String \(p2) is not nil."}print(f2(p1: nil)) // Nil.print(f2(p1: "lemon")) // String lemon is not nil.

遍历 For-in

let a = ["one", "two", "three"]for str in a {    print(str)}// 使用下标范围for i in 0..<10 {    print(i)}// 使用 enumeratedfor (i, str) in a.enumerated() {    print("第\(i + 1)个是:\(str)")}// for in wherefor str in a where str.prefix(1) == "t" {    print(str)}// 字典 for in,遍历是无序的let dic = [    "one": 1,    "two": 2,    "three": 3]for (k, v) in dic {    print("key is \(k), value is \(v)")}// stridefor i in stride(from: 10, through: 0, by: -2) {    print(i)}/* 10 8 6 4 2 0 */

While while, repeat-while

// whilevar i1 = 10while i1 > 0 {    print("positive even number \(i1)")    i1 -= 2}// repeat whilevar i2 = 10repeat {    print("positive even number \(i2)")    i2 -= 2} while i2 > 0

使用 break 结束遍历,使用 continue 跳过当前作用域,继续下个循环

Switch

func f1(pa: String, t:(String, Int)) {    var p1 = 0    var p2 = 10    switch pa {    case "one":        p1 = 1    case "two":        p1 = 2        fallthrough // 继续到下个 case 中    default:        p2 = 0    }    print("p1 is \(p1)")    print("p2 is \(p2)")        // 元组    switch t {    case ("0", 0):        print("zero")    case ("1", 1):        print("one")    default:        print("no")    }}f1(pa: "two", t:("1", 1))/* p1 is 2 p2 is 0 one */// 枚举enum E {    case one, two, three, unknown(String)}func f2(pa: E) {    var p: String    switch pa {    case .one:        p = "1"    case .two:        p = "2"    case .three:        p = "3"    case let .unknown(u) where Int(u) ?? 0 > 0 : // 枚举关联值,使用 where 增加条件        p = u    case .unknown(_):        p = "negative number"    }    print(p)}f2(pa: E.one) // 1f2(pa: E.unknown("10")) // 10f2(pa: E.unknown("-10")) // negative number

集合

数组 [1, 2, 3]

数组是有序集合

var a0: [Int] = [1, 10]a0.append(2)a0.remove(at: 0)print(a0) // [10, 2]let a1 = ["one", "two", "three"]let a2 = ["three", "four"]// 找两个集合的不同let dif = a1.difference(from: a2) // swift的 diffing 算法在这 http://www.xmailserver.org/diff2.pdf swift实现在  swift/stdlib/public/core/Diffing.swiftfor c in dif {    switch c {    case .remove(let o, let e, let a):        print("offset:\(o), element:\(e), associatedWith:\(String(describing: a))")    case .insert(let o, let e, let a):        print("offset:\(o), element:\(e), associatedWith:\(String(describing: a))")    }}/* remove offset:1, element:four, associatedWith:nil insert offset:0, element:one, associatedWith:nil insert offset:1, element:two, associatedWith:nil */let a3 = a2.applying(dif) ?? [] // 可以用于添加删除动画print(a3) // ["one", "two", "three"]

dif 有第三个 case 值 .insert(let offset, let element, let associatedWith) 可以跟踪成对的变化,用于高级动画。

从数组中随机取一个元素

print(a0.randomElement() ?? 0)

数组排序

// 排序struct S1 {    let n: Int    var b = true}let a4 = [    S1(n: 1),    S1(n: 10),    S1(n: 3),    S1(n: 2)]let a5 = a4.sorted { i1, i2 in    i1.n < i2.n}for n in a5 {    print(n)}/// S1(n: 1)/// S1(n: 2)/// S1(n: 3)/// S1(n: 10)let a6 = [1,10,4,7,2]print(a6.sorted(by: >)) // [10, 7, 4, 2, 1]

可以加到数组扩展中,通过扩展约束能够指定特定元素类型的排序,代码如下:

extension Array where Element == Int {    // 升序    func intSortedASC() -> [Int] {        return self.sorted(by: <)    }    // 降序    func intSortedDESC() -> [Int] {        return self.sorted(by: <)    }}print(a6.intSortedASC()) // 使用扩展增加自定义排序能力

在数组中检索满足条件的元素,代码如下:

// 第一个满足条件了就返回let a7 = a4.first {    $0.n == 10}print(a7?.n ?? 0)// 是否都满足了条件print(a4.allSatisfy { $0.n == 1 }) // falseprint(a4.allSatisfy(\.b)) // true// 找出最大的那个print(a4.max(by: { e1, e2 in    e1.n < e2.n}) ?? S1(n: 0))// S1(n: 10, b: true)// 看看是否包含某个元素print(a4.contains(where: {    $0.n == 7}))// false

一些切割数组的方法。

// 切片// 取前3个,并不是直接复制,对于大的数组有性能优势。print(a6[..<3]) // [1, 10, 4] 需要做越界检查print(a6.prefix(30)) // [1, 10, 4, 7, 2] 不需要做越界检查,也是切片,性能一样// 去掉前3个print(a6.dropFirst(3)) // [7, 2]

prefix(while:) 和 drop(while:) 方法,顺序遍历执行闭包里的逻辑判断,满足条件就返回,遇到不匹配就会停止遍历。prefix 返回满足条件的元素集合,drop 返回停止遍历之后那些元素集合。

let a8 = [8, 9, 20, 1, 35, 3]let a9 = a8.prefix {    $0 < 30}print(a9) // [8, 9, 20, 1]let a10 = a8.drop {    $0 < 30}print(a10) // [35, 3]

比 filter 更高效的删除元素的方法 removeAll

// 删除所有不满足条件的元素var a11 = [1, 3, 5, 12, 25]a11.removeAll { $0 < 10 }print(a11) // [4, 3, 1, 3, 3] 随机// 创建未初始化的数组let a12 = (0...4).map { _ in    Int.random(in: 0...5)}print(a12) // [0, 3, 3, 2, 5] 随机

#if 用于后缀表达式

// #if 用于后缀表达式let a13 = a11#if os(iOS)    .count#else    .reduce(0, +)#endifprint(a13) //37

Sets Set<Int>

Set 是无序集合,元素唯一

let s0: Set<Int> = [2, 4]let s1: Set = [2, 10, 6, 4, 8]let s2: Set = [7, 3, 5, 1, 9, 10]let s3 = s1.union(s2) // 合集let s4 = s1.intersection(s2) // 交集let s5 = s1.subtracting(s2) // 非交集部分let s6 = s1.symmetricDifference(s2) // 非交集的合集print(s3) // [4, 2, 1, 7, 3, 10, 8, 9, 6, 5]print(s4) // [10]print(s5) // [8, 4, 2, 6]print(s6) // [9, 1, 3, 4, 5, 2, 6, 8, 7]// s0 是否被 s1 包含print(s0.isSubset(of: s1)) // true// s1 是否包含了 s0print(s1.isSuperset(of: s0)) // truelet s7: Set = [3, 5]// s0 和 s7 是否有交集print(s0.isDisjoint(with: s7)) // true// 可变 Setvar s8: Set = ["one", "two"]s8.insert("three")s8.remove("one")print(s8) // ["two", "three"]

字典 [:]

字典是无序集合,键值对应。

var d1 = [    "k1": "v1",    "k2": "v2"]d1["k3"] = "v3"d1["k4"] = nilprint(d1) // ["k2": "v2", "k3": "v3", "k1": "v1"]for (k, v) in d1 {    print("key is \(k), value is \(v)")}/* key is k1, value is v1 key is k2, value is v2 key is k3, value is v3 */ if d1.isEmpty == false {    print(d1.count) // 3}// mapValueslet d2 = d1.mapValues {    $0 + "_new"}print(d2) // ["k2": "v2_new", "k3": "v3_new", "k1": "v1_new"]// 对字典的值或键进行分组let d3 = Dictionary(grouping: d1.values) {    $0.count}print(d3) // [2: ["v1", "v2", "v3"]]// 从字典中取值,如果键对应无值,则使用通过 default 指定的默认值d1["k5", default: "whatever"] += "."print(d1["k5"] ?? "") // whatever.let v1 = d1["k3", default: "whatever"]print(v1) // v3// compactMapValues() 对字典值进行转换和解包。可以解可选类型,并去掉 nil 值let d4 = [    "k1": 1,    "k2": 2,    "k3": nil]let d5 = d4.mapValues { $0 }let d6 = d4.compactMapValues{ $0 }print(d5)// ["k3": nil, "k1": Optional(1), "k2": Optional(2)]print(d6)// ["k1": 1, "k2": 2]

操作符

赋值 =, +=. -=, *=, /=

let i1 = 1var i2 = i1i2 = 2print(i2) // 2i2 += 1print(i2) // 3i2 -= 2print(i2) // 1i2 *= 10print(i2) // 10i2 /= 2print(i2) // 5

计算符 +, -, *, /, %

let i1 = 1let i2 = i1print((i1 + i2 - 1) * 10 / 2 % 3) // 2print("i" + "1") // i1// 一元运算符print(-i1) // -1

比较运算符 ==, >

遵循 Equatable 协议可以使用 == 和 != 来判断是否相等

print(1 > 2) // falsestruct S: Equatable {    var p1: String    var p2: Int}let s1 = S(p1: "one", p2: 1)let s2 = S(p1: "two", p2: 2)let s3 = S(p1: "one", p2: 2)let s4 = S(p1: "one", p2: 1)print(s1 == s2) // falseprint(s1 == s3) // falseprint(s1 == s4) // true

类需要实现 == 函数

class C: Equatable {    var p1: String    var p2: Int    init(p1: String, p2: Int) {        self.p1 = p1        self.p2 = p2    }        static func == (l: C, r: C) -> Bool {        return l.p1 == r.p1 && l.p2 == r.p2    }}let c1 = C(p1: "one", p2: 1)let c2 = C(p1: "one", p2: 1)print(c1 == c2)
// 元组比较// 会先比较第一个数,第一个无法比较才会比较第二个数// 字符串比较和字母大小还有长度有关。先比较字母大小,在比较长度("apple", 1) < ("apple", 2) // true("applf", 1) < ("apple", 2) // false("appl", 2) < ("apple", 1) // true("appm", 2) < ("apple", 1) // false

三元 _ ? _ : _

简化 if else 写法

// if elsefunc f1(p: Int) {    if p > 0 {        print("positive number")    } else {        print("negative number")    }}// 三元func f2(p: Int) {    p > 0 ? print("positive number") : print("negative number")}f1(p: 1)f2(p: 1)

Nil-coalescing ??

简化 if let else 写法

// if elsefunc f1(p: Int?) {    if let i = p {        print("p have value is \(i)")    } else {        print("p is nil, use defalut value")    }}// 使用 ??func f2(p: Int?) {    let i = p ?? 0    print("p is \(i)")}

范围 a…b

简化的值范围表达方式。

// 封闭范围for i in 0...10 {    print(i)}// 半开范围for i in 0..<10 {    print(i)}
// 单侧区间let nums = [5,6,7,8]print(nums[2...]) // 7 8

逻辑 !, &&, ||

let i1 = -1let i2 = 2if i1 != i2 && (i1 < 0 || i2 < 0) {    print("i1 and i2 not equal, and one of them is negative number.")}

恒等 ===, !==

恒等返回是否引用了相同实例。

class C {    var p: String    init(p: String) {        self.p = p    }}let c1 = C(p: "one")let c2 = C(p: "one")let c3 = c1print(c1 === c2) // falseprint(c1 === c3) // trueprint(c1 !== c2) // true

运算符

位运算符

let i1: UInt8 = 0b00001111let i2 = ~i1 // Bitwise NOT Operator(按位取反运算符),取反let i3: UInt8 = 0b00111111let i4 = i1 & i3 // Bitwise AND Operator(按位与运算符),都为1才是1let i5 = i1 | i3 // Bitwise OR Operator(按位或运算符),有一个1就是1let i6 = i1 ^ i3 // Bitwise XOR Operator(按位异或运算符),不同为1,相同为0print(i1,i2,i3,i4,i5,i6)// << 按位左移,>> 按位右移let i7 = i1 << 1let i8 = i1 >> 2print(i7,i8)

溢出运算符,有 &+、&- 和 &*

var i1 = Int.maxprint(i1) // 9223372036854775807i1 = i1 &+ 1print(i1) // -9223372036854775808i1 = i1 &+ 10print(i1) // -9223372036854775798var i2 = UInt.maxi2 = i2 &+ 1print(i2) // 0

运算符函数包括前缀运算符、后缀运算符、复合赋值运算符以及等价运算符。另,还可以自定义运算符,新的运算符要用 operator 关键字进行定义,同时要指定 prefix、infix 或者 postfix 修饰符。

基础库

时间

Date 的基本用法如下:

let now = Date()// Date 转 时间戳let interval = now.timeIntervalSince1970 // 时间戳let df = DateFormatter()df.dateFormat = "yyyy 年 MM 月 dd 日 HH:mm:ss"print("时间戳:\(Int(interval))") // 时间戳:1642399901print("格式化的时间:" + df.string(from: now)) // 格式化的时间:2022 年 01 月 17 日 14:11:41df.dateStyle = .shortprint("short 样式时间:" + df.string(from: now)) // short 样式时间:2022/1/17df.locale = Locale(identifier: "zh_Hans_CN")df.dateStyle = .fullprint("full 样式时间:" + df.string(from: now)) // full 样式时间:2022年1月17日 星期一// 时间戳转 Datelet date = Date(timeIntervalSince1970: interval)print(date) // 2022-01-17 06:11:41 +0000

复杂的时间操作,比如说 GitHub 接口使用的是 ISO 标准,RSS 输出的是 RSS 标准字符串,不同标准对应不同时区的时间计算处理,可以使用开源库 SwiftDate 来完成。示例代码如下:

import SwiftDate// 使用 SwiftDate 库let cn = Region(zone: Zones.asiaShanghai, locale: Locales.chineseChina)SwiftDate.defaultRegion = cnprint("2008-02-14 23:12:14".toDate()?.year ?? "") // 2008let d1 = "2022-01-17T23:20:35".toISODate(region: cn)guard let d1 = d1 else {    return}print(d1.minute) // 20let d2 = d1 + 1.minutesprint(d2.minute)// 两个 DateInRegion 相差时间 intervallet i1 = DateInRegion(Date(), region: cn) - d1let s1 = i1.toString {    $0.maximumUnitCount = 4    $0.allowedUnits = [.day, .hour, .minute]    $0.collapsesLargestUnit = true    $0.unitsStyle = .abbreviated    $0.locale = Locales.chineseChina}print(s1) // 9小时45分钟

格式化

使用标准库的格式来描述不同场景的情况可以不用去考虑由于不同地区的区别,这些在标准库里就可以自动完成了。

描述两个时间之间相差多长时间

// 计算两个时间之间相差多少时间,支持多种语言字符串let d1 = Date().timeIntervalSince1970 - 60 * 60 * 24let f1 = RelativeDateTimeFormatter()f1.dateTimeStyle = .namedf1.formattingContext = .beginningOfSentencef1.locale = Locale(identifier: "zh_Hans_CN")let str = f1.localizedString(for: Date(timeIntervalSince1970: d1), relativeTo: Date())print(str) // 昨天// 简写let str2 = Date.now.addingTimeInterval(-(60 * 60 * 24))    .formatted(.relative(presentation: .named))print(str2) // yesterday

描述多个事物

// 描述多个事物let s1 = ListFormatter.localizedString(byJoining: ["冬天","春天","夏天","秋天"])print(s1)

描述名字

// 名字let f2 = PersonNameComponentsFormatter()var nc1 = PersonNameComponents()nc1.familyName = "戴"nc1.givenName = "铭"nc1.nickname = "铭哥"print(f2.string(from: nc1)) // 戴铭f2.style = .shortprint(f2.string(from: nc1)) // 铭哥f2.style = .abbreviatedprint(f2.string(from: nc1)) // 戴var nc2 = PersonNameComponents()nc2.familyName = "Dai"nc2.givenName = "Ming"nc2.nickname = "Starming"f2.style = .defaultprint(f2.string(from: nc2)) // Ming Daif2.style = .shortprint(f2.string(from: nc2)) // Starmingf2.style = .abbreviatedprint(f2.string(from: nc2)) // MD// 取出名let componets = f2.personNameComponents(from: "戴铭")print(componets?.givenName ?? "") // 铭

描述数字

// 数字let f3 = NumberFormatter()f3.locale = Locale(identifier: "zh_Hans_CN")f3.numberStyle = .currencyprint(f3.string(from: 123456) ?? "") // ¥123,456.00f3.numberStyle = .percentprint(f3.string(from: 123456) ?? "") // 12,345,600%let n1 = 1.23456let n1Str = n1.formatted(.number.precision(.fractionLength(3)).rounded())print(n1Str) // 1.235

描述地址

// 地址import Contactslet f4 = CNPostalAddressFormatter()let address = CNMutablePostalAddress()address.street = "海淀区王庄路XX号院X号楼X门XXX"address.postalCode = "100083"address.city = "北京"address.country = "中国"print(f4.string(from: address))/// 海淀区王庄路XX号院X号楼X门XXX/// 北京 100083/// 中国

度量值

标准库里的物理量,在这个文档里有详细列出,包括角度、平方米等。

// 参考:https://developer.apple.com/documentation/foundation/nsdimensionlet m1 = Measurement(value: 1, unit: UnitLength.kilometers)let m2 = m1.converted(to: .meters) // 千米转米print(m2) // 1000.0 m// 度量值转为本地化的值let mf = MeasurementFormatter()mf.locale = Locale(identifier: "zh_Hans_CN")print(mf.string(from: m1)) // 1公里

一些物理公式供参考:

面积 = 长度 × 长度体积 = 长度 × 长度 × 长度 = 面积 × 长度速度=长度/时间加速度=速度/时间力 = 质量 × 加速度扭矩 = 力 × 长度压力 = 力 / 面积密度=质量 / 体积能量 = 功率 × 时间电阻 = 电压 / 电流

Data

数据压缩和解压

// 对数据的压缩let d1 = "看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?".data(using: .utf8)! as NSDataprint("ori \(d1.count) bytes")do {    /// 压缩算法    /// * lz4    /// * lzma    /// * zlib    /// * lzfse    let compressed = try d1.compressed(using: .zlib)    print("comp \(compressed.count) bytes")        // 对数据解压    let decomressed = try compressed.decompressed(using: .zlib)    let deStr = String(data: decomressed as Data, encoding: .utf8)    print(deStr ?? "")} catch {}/// ori 297 bytes/// comp 37 bytes

文件

文件的一些基本操作的代码如下:

let path1 = "/Users/mingdai/Downloads/1.html"let path2 = "/Users/mingdai/Documents/GitHub/"let u1 = URL(string: path1)do {    // 写入    let url1 = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: u1, create: true) // 保证原子性安全保存    print(url1)    // 读取    let s1 = try String(contentsOfFile: path1, encoding: .utf8)    print(s1)} catch {}// 检查路径是否可用let u2 = URL(fileURLWithPath:path2)do {    let values = try u2.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])    if let capacity = values.volumeAvailableCapacityForImportantUsage {        print("可用: \(capacity)")    } else {        print("不可用")    }} catch {    print("错误: \(error.localizedDescription)")}

怎么遍历多级目录结构中的文件呢?看下面的代码的实现:

// 遍历路径下所有目录let u3 = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)let fm = FileManager.defaultfm.enumerator(atPath: u3.path)?.forEach({ path in    guard let path = path as? String else {        return    }    let url = URL(fileURLWithPath: path, relativeTo: u3)    print(url.lastPathComponent)})

可以使用 FileWrapper 来创建文件夹和文件。举个例子:

// FileWrapper 的使用// 创建文件let f1 = FileWrapper(regularFileWithContents: Data("# 第 n 个文件\n ## 标题".utf8))f1.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()f1.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()// 创建文件夹let folder1 = FileWrapper(directoryWithFileWrappers: [    "file1.md": f1])folder1.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()folder1.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()do {    try folder1.write(        to: URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("NewFolder"),        options: .atomic,        originalContentsURL: nil    )} catch {}print(FileManager.default.currentDirectoryPath)

上面代码写起来比较繁琐,对 FileWrapper 更好的封装可以参考这篇文章《 A Type-Safe FileWrapper | Heberti Almeida 》。

文件读写处理完整能力可以参看这个库 GitHub - JohnSundell/Files: A nicer way to handle files & folders in Swift

本地或者网络上,比如网盘和FTP的文件发生变化时,怎样知道能够观察到呢?

通过 HTTPHeader 里的 If-Modified-Since、Last-Modified、If-None-Match 和 Etag 等字段来判断文件的变化,本地则是使用 DispatchSource.makeFileSystemObjectSource 来进行的文件变化监听。可以参考 KZFileWatchers 库的做法。

Scanner

let s1 = """one1,two2,three3."""let sn1 = Scanner(string: s1)while !sn1.isAtEnd {    if let r1 = sn1.scanUpToCharacters(from: .newlines) {        print(r1 as String)    }}/// one1,/// two2,/// three3.// 找出数字let sn2 = Scanner(string: s1)sn2.charactersToBeSkipped = CharacterSet.decimalDigits.inverted // 不是数字的就跳过var p: Int = 0while !sn2.isAtEnd {    if sn2.scanInt(&p) {        print(p)    }}/// 1/// 2/// 3

上面的代码还不是那么 Swifty,可以通过用AnySequence和AnyIterator来包装下,将序列中的元素推迟到实际需要时再来处理,这样性能也会更好些。具体实现可以参看《 String parsing in Swift 》这篇文章。

AttributeString

效果如下:

代码如下:

var aStrs = [AttributedString]()var aStr1 = AttributedString("""标题正文内容,具体查看链接。这里摘出第一个重点,还要强调的内容。""")// 标题let title = aStr1.range(of: "标题")guard let title = title else {    return aStrs}var c1 = AttributeContainer() // 可复用容器c1.inlinePresentationIntent = .stronglyEmphasizedc1.font = .largeTitleaStr1[title].setAttributes(c1)// 链接let link = aStr1.range(of: "链接")guard let link = link else {    return aStrs}var c2 = AttributeContainer() // 链接c2.strokeColor = .bluec2.link = URL(string: "https://ming1016.github.io/")aStr1[link].setAttributes(c2.merging(c1)) // 合并 AttributeContainer// Runslet i1 = aStr1.range(of: "重点")let i2 = aStr1.range(of: "强调")guard let i1 = i1, let i2 = i2 else {    return aStrs}var c3 = AttributeContainer()c3.foregroundColor = .yellowc3.inlinePresentationIntent = .stronglyEmphasizedaStr1[i1].setAttributes(c3)aStr1[i2].setAttributes(c3)for r in aStr1.runs {    print("-------------")    print(r.attributes)}aStrs.append(aStr1)// Markdowndo {    let aStr2 = try AttributedString(markdown: """    内容[链接](https://ming1016.github.io/)。需要**强调**的内容。    """)        aStrs.append(aStr2)    } catch {}

SwiftUI 的 Text 可以直接读取 AttributedString 来进行显示。

随机

用法:

let ri = Int.random(in: 0..<10)print(ri) // 0到10随机数let a = [0, 1, 2, 3, 4, 5]print(a.randomElement() ?? 0) // 数组中随机取个数print(a.shuffled()) // 随机打乱数组顺序

UserDefaults

使用方法如下:

enum UDKey {    static let k1 = "token"}let ud = UserDefaults.standardud.set("xxxxxx", forKey: UDKey.k1)let tk = ud.string(forKey: UDKey.k1)print(tk ?? "")

模式

单例

struct S {    static let shared = S()    private init() {        // 防止实例初始化    }}

系统及设备

系统判断

#if os(tvOS)     // do something in tvOS#elseif os(iOS)     // do somthing in iOS#elseif os(macOS)    // do somthing in macOS#endif

版本兼容

// 版本@available(iOS 15, *)func f() {}// 版本检查if #available(iOS 15, macOS 12, *) {    f()} else {    // nothing happen}

canImport 判断库是否可使用

#if canImport(SpriteKit)   // iOS 等苹果系统执行#else   // 非苹果系统#endif

targetEnvironment 环境的判断

#if targetEnvironment(simulator)   // 模拟器#else   // 真机#endif

自带属性包装

@resultBuilder

结果生成器(Result builders),通过传递序列创建新值,SwiftUI就是使用的结果生成器将多个视图生成一个视图

@resultBuilderstruct RBS {    // 基本闭包支持    static func buildBlock(_ components: Int...) -> Int {        components.reduce(0) { partialResult, i in            partialResult + i        }    }    // 支持条件判断    static func buildEither(first component: Int) -> Int {        component    }    static func buildEither(second component: Int) -> Int {        component    }    // 支持循环    static func buildArray(_ components: [Int]) -> Int {        components.reduce(0) { partialResult, i in            partialResult + i        }    }}let a = RBS.buildBlock(    1,    2,    3)print(a) // 6// 应用到函数中@RBS func f1() -> Int {    1    2    3}print(f1()) // 6// 设置了 buildEither 就可以在闭包中进行条件判断。@RBS func f2(stopAtThree: Bool) -> Int {    1    2    3    if stopAtThree == true {        0    } else {        4        5        6    }}print(f2(stopAtThree: false)) // 21// 设置了 buildArray 就可以在闭包内使用循环了@RBS func f3() -> Int {    for i in 1...3 {        i * 2    }}print(f3()) // 12

@dynamicMemberLookup 动态成员查询

@dynamicMemberLookup 指示访问属性时调用一个已实现的处理动态查找的下标方法 subscript(dynamicMemeber:),通过指定属性字符串名返回值。使用方法如下:

@dynamicMemberLookupstruct D {    // 找字符串    subscript(dynamicMember m: String) -> String {        let p = ["one": "first", "two": "second"]        return p[m, default: ""]    }    // 找整型    subscript(dynamicMember m: String) -> Int {        let p = ["one": 1, "two": 2]        return p[m, default: 0]    }    // 找闭包    subscript(dynamicMember m: String) -> (_ s: String) -> Void {        return {            print("show \($0)")        }    }    // 静态数组成员    var p = ["This is a member"]    // 动态数组成员    subscript(dynamicMember m: String) -> [String] {        return ["This is a dynamic member"]    }}let d = D()let s1: String = d.oneprint(s1) // firstlet i1: Int = d.oneprint(i1) // 1d.show("something") // show somethingprint(d.p) // ["This is a member"]let dynamicP:[String] = d.dpprint(dynamicP) // ["This is a dynamic member"]

类使用 @dynamicMemberLookup,继承的类也会自动加上 @dynamicMemberLookup。协议上定义 @dynamicMemberLookup,通过扩展可以默认实现 subscript(dynamicMember:) 方法。

@dynamicCallable 动态可调用类型

@dynamicCallable 动态可调用类型。通过实现 dynamicallyCall 方法来定义变参的处理。

@dynamicCallablestruct D {    // 带参数说明    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, Int>) -> Int {        let firstArg = args.first?.value ?? 0        return firstArg * 2    }        // 无参数说明    func dynamicallyCall(withArguments args: [String]) -> String {        var firstArg = ""        if args.count > 0 {            firstArg = args[0]        }        return "show \(firstArg)"    }}let d = D()let i = d(numberIs: 2)print(i) // 4let s = d("hi")print(s) // show hi

自带协议

Hashable

struct H: Hashable {    var p1: String    var p2: Int        // 提供随机 seed    func hash(into hasher: inout Hasher) {        hasher.combine(p1)    }}let h1 = H(p1: "one", p2: 1)let h2 = H(p1: "two", p2: 2)var hs1 = Hasher()hs1.combine(h1)hs1.combine(h2)print(h1.hashValue) // 7417088153212460033 随机值print(h2.hashValue) // -6972912482785541972 随机值print(hs1.finalize()) // 7955861102637572758 随机值print(h1.hashValue) // 7417088153212460033 和前面 h1 一样let h3 = H(p1: "one", p2: 1)print(h3.hashValue) // 7417088153212460033 和前面 h1 一样var hs2 = Hasher()hs2.combine(h3)hs2.combine(h2)print(hs2.finalize()) // 7955861102637572758 和前面 hs1 一样

应用生命周期内,调用 combine() 添加相同属性哈希值相同,由于 Hasher 每次都会使用随机的 seed,因此不同应用生命周期,也就是下次启动的哈希值,就会和上次的哈希值不同。

Codable

JSON 没有 id 字段

如果SwiftUI要求数据Model都是遵循Identifiable协议的,而有的json没有id这个字段,可以使用扩展struct的方式解决:

struct CommitModel: Decodable, Hashable {  var sha: String  var author: AuthorModel  var commit: CommitModel}extension CommitModel: Identifiable {  var id: String {    return sha  }}

网络

网络状态检查

通过 Network 库的 NWPathMonitor 来检查

import Combineimport Network// 网络状态检查 network state checkfinal class Nsck: ObservableObject {    static let shared = Nsck()    private(set) lazy var pb = mkpb()    @Published private(set) var pt: NWPath        private let monitor: NWPathMonitor    private lazy var sj = CurrentValueSubject<NWPath, Never>(monitor.currentPath)    private var sb: AnyCancellable?        init() {        monitor = NWPathMonitor()        pt = monitor.currentPath        monitor.pathUpdateHandler = { [weak self] path in            self?.pt = path            self?.sj.send(path)        }        monitor.start(queue: DispatchQueue.global())    }        deinit {        monitor.cancel()        sj.send(completion: .finished)    }        private func mkpb() -> AnyPublisher<NWPath, Never> {        return sj.eraseToAnyPublisher()    }}

使用方法

var sb = Set<AnyCancellable>()var alertMsg = ""Nsck.shared.pb    .sink { _ in        //    } receiveValue: { path in        alertMsg = path.debugDescription        switch path.status {        case .satisfied:            alertMsg = ""        case .unsatisfied:            alertMsg = "😱"        case .requiresConnection:            alertMsg = "🥱"        @unknown default:            alertMsg = "🤔"        }        if path.status == .unsatisfied {            switch path.unsatisfiedReason {            case .notAvailable:                alertMsg += "网络不可用"            case .cellularDenied:                alertMsg += "蜂窝网不可用"            case .wifiDenied:                alertMsg += "Wifi不可用"            case .localNetworkDenied:                alertMsg += "网线不可用"            @unknown default:                alertMsg += "网络不可用"            }        }    }    .store(in: &sb)

动画

布局动画

import SwiftUIstruct AnimateLayout: View { @State var changeLayout: Bool = true @Namespace var namespace var body: some View {  VStack(spacing: 30) {   if changeLayout {    HStack { items }   } else {    VStack { items }   }   Button("切换布局") {    withAnimation { changeLayout.toggle() }   }  }  .padding() } @ViewBuilder var items: some View {  Text("one")   .matchedGeometryEffect(id: "one", in: namespace)  Text("Two")   .matchedGeometryEffect(id: "Two", in: namespace)  Text("Three")   .matchedGeometryEffect(id: "Three", in: namespace) }}

安全

Keychain

使用方法:

let d1 = Data("keyChain github token".utf8)let service = "access-token"let account = "github"let q1 = [    kSecValueData: d1,    kSecClass: kSecClassGenericPassword,    kSecAttrService: service,    kSecAttrAccount: account] as CFDictionary// 添加一个 keychainlet status = SecItemAdd(q1, nil)// 如果已经添加过会抛出 -25299 错误代码,需要调用 SecItemUpdate 来进行更新if status == errSecDuplicateItem {    let q2 = [        kSecClass: kSecClassGenericPassword,        kSecAttrService: service,        kSecAttrAccount: account    ] as CFDictionary    let q3 = [        kSecValueData: d1    ] as CFDictionary    SecItemUpdate(q2, q3)}// 读取let q4 = [    kSecAttrService: service,    kSecAttrAccount: account,    kSecClass: kSecClassGenericPassword,    kSecReturnData: true] as CFDictionaryvar re: AnyObject?SecItemCopyMatching(q4, &re)guard let reData = re as? Data else { return }print(String(decoding: reData, as: UTF8.self)) // keyChain github token// 删除let q5 = [    kSecAttrService: service,    kSecAttrAccount: account,    kSecClass: kSecClassGenericPassword,] as CFDictionarySecItemDelete(q5)

工程

程序入口点

Swift 允许全局编写 Swift 代码,实际上 clang 会自动将代码包进一个模拟 C 的函数中。Swift 也能够指定入口点,比如 @UIApplicationMain 或 @NSApplicationMain,UIKit 启动后生命周期管理是 AppDelegate 和 SceneDelegate,《 Understanding the iOS 13 Scene Delegate 》这篇有详细介绍。

@UIApplicationMain 和 @NSApplicationMain 会自动生成入口点。这些入口点都是平台相关的,Swift 发展来看是多平台的,这样在 Swift 5.3 时引入了 @main,可以方便的指定入口点。代码如下:

@main // 要定义个静态的 main 函数struct M {  static func main() {    print("let's begin")  }}

ArgumentParser 库,Swift 官方开源的一个开发命令行工具的库,也支持 @main。使用方法如下:

import ArgumentParser@mainstruct C: ParsableCommand {  @Argument(help: "Start")  var phrase: String     func run() throws {    for _ in 1...5 {      print(phrase)    }  }}

专题

Swift 那些事

语法速查

基础

变量 let, var

变量是可变的,使用 var 修饰,常量是不可变的,使用 let 修饰。类、结构体和枚举里的变量是属性。

var v1:String = "hi" // 标注类型var v2 = "类型推导"let l1 = "标题" // 常量class a {    let p1 = 3    var p2: Int {        p1 * 3    }}

属性没有 set 可以省略 get,如果有 set 需加 get。变量设置前通过 willSet 访问到,变量设置后通过 didSet 访问。

打印 print(“”)

控制台打印值

print("hi")let i = 14print(i)print("9月\(i)是小柠檬的生日")for i in 1...3{    print(i)}// output:// 1// 2// 3// 使用terminator使循环打印更整洁for i in 1...3 {    print("\(i) ", terminator: "")}// output:// 1 2 3

注释 //

// 单行注释/*多行注释第一行。多行注释第二行。*/ // MARK: 会在 minimap 上展示// TODO: 待做// FIXME: 待修复

可选 ?, !

可能会是 nil 的变量就是可选变量。当变量为 nil 通过??操作符可以提供一个默认值。

var o: Int? = nillet i = o ?? 0

SE-0345 if let shorthand for shadowing an existing optional variable 引入的新语法,用于 unwrapping optinal。

let s1: String? = "hey"let s2: String? = "u"if let s1 {    print(s1)}guard let s1, let s2 else { return }print(s1 + " " + s2)

闭包

闭包也可以叫做 lambda,是匿名函数,对应 OC 的 block。

let a1 = [1,3,2].sorted(by: { (l: Int, r: Int) -> Bool in    return l < r})// 如果闭包是唯一的参数并在表达式最后可以使用结尾闭包语法,写法简化为let a2 = [1,3,2].sorted { (l: Int, r: Int) -> Bool in    return l < r}// 已知类型可以省略let a3 = [1,3,2].sorted { l, r in    return l < r}// 通过位置来使用闭包的参数,最后简化如下:let a4 = [1,3,2].sorted { $0 < $1 }

函数也是闭包的一种,函数的参数也可以是闭包。@escaping 表示逃逸闭包,逃逸闭包是可以在函数返回之后继续调用的。@autoclosure 表示自动闭包,可以用来省略花括号。

SE-0326 提高了 Swift 对闭包使用参数和类型推断的能力。如下代码:

let a = [1,2,3]let r = a.map { i in    if i >= 2 {        return "\(i) 大于等于2"    } else {        return "\(i) 小于2"    }}print(r)

函数 func

函数可以作为另一个函数的参数,也可以作为另一个函数的返回。函数是特殊的闭包,在类、结构体和枚举中是方法。

// 为参数设置默认值func f1(p: String = "p") -> String {    "p is \(p)"}// 函数作为参数func f2(fn: (String) -> String, p: String) -> String {    return fn(p)}print(f2(fn:f1, p: "d")) // p is d// 函数作为返回值func f3(p: String) -> (String) -> String {    return f1}print(f3(p: "yes")("no")) // p is no

函数可以返回多个值,函数是可以嵌套的,也就是函数里内可以定义函数,函数内定义的函数可以访问自己作用域外函数内的变量。inout 表示的是输入输出参数,函数可以在函数内改变输入输出参数。defer 标识的代码块会在函数返回之前执行。

函数在 Swift 5.4 时开始有了使用多个变量参数的能力,使用方法如下:

func f4(s: String..., i: Int...) {    print(s)    print(i)}f4(s: "one", "two", "three", i: 1, 2, 3)/// ["one", "two", "three"]/// [1, 2, 3]

嵌套函数可以重载,嵌套函数可以在声明函数之前调用他。

func f5() {    nf5()    func nf5() {        print("this is nested function")    }}f5() // this is nested function

访问控制

在 Xcode 里的 target 就是模块,使用 import 可导入模块。模块内包含源文件,每个源文件里可以有多个类、结构体、枚举和函数等多种类型。访问级别可以通过一些关键字描述,分为如下几种:

  • open:在模块外可以调用和继承。
  • public:在模块外可调用不可继承,open 只适用类和类成员。
  • internal:默认级别,模块内可跨源文件调用,模块外不可调用。
  • fileprivate:只能在源文件内访问。
  • private:只能在所在的作用域内访问。

重写继承类的成员,可以设置成员比父类的这个成员更高的访问级别。Setter 的级别可以低于对应的 Getter 的级别,比如设置 Setter 访问级别为 private,可以在属性使用 private(set) 来修饰。

Regex

标准库多了个 Regex<Output> 类型,Regex 语法与 Perl、Python、Ruby、Java、NSRegularExpression 和许多其他语言兼容。可以用 let regex = try! Regex("a[bc]+")let regex = /a[bc]+/ 写法来使用。SE-0350 Regex Type and Overview 引入 Regex 类型。SE-0351 Regex builder DSL 使用 result builder 来构建正则表达式的 DSL。SE-0354 Regex Literals 简化的正则表达式。SE-0357 Regex-powered string processing algorithms 提案里有基于正则表达式的新字符串处理算法。

RegexBuilder 文档

session Meet Swift RegexSwift Regex: Beyond the basics

Regex 示例代码如下:

let s1 = "I am not a good painter"print(s1.ranges(of: /good/))do {    let regGood = try Regex("[a-z]ood")    print(s1.replacing(regGood, with: "bad"))} catch {    print(error)}print(s1.trimmingPrefix(/i am /.ignoresCase()))let reg1 = /(.+?) read (\d+) books./let reg2 = /(?<name>.+?) read (?<books>\d+) books./let s2 = "Jack read 3 books."do {    if let r1 = try reg1.wholeMatch(in: s2) {        print(r1.1)        print(r1.2)    }    if let r2 = try reg2.wholeMatch(in: s2) {        print("name:" + r2.name)        print("books:" + r2.books)    }} catch {    print(error)}

使用 regex builders 的官方示例:

// Text to parse:// CREDIT  03/02/2022  Payroll from employer     $200.23// CREDIT  03/03/2022  Suspect A           $2,000,000.00// DEBIT   03/03/2022  Ted's Pet Rock Sanctuary    $2,000,000.00// DEBIT   03/05/2022  Doug's Dugout Dogs      $33.27import RegexBuilderlet fieldSeparator = /\s{2,}|\t/let transactionMatcher = Regex {  /CREDIT|DEBIT/  fieldSeparator  One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) // 👈🏻 we define which data locale/timezone we want to use  fieldSeparator  OneOrMore {    NegativeLookahead { fieldSeparator } // 👈🏻 we stop as soon as we see one field separator    CharacterClass.any  }  fieldSeparator  One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US")))}

在正则表达式中捕获数据,使用 Capture:

let fieldSeparator = /\s{2,}|\t/let transactionMatcher = Regex {  Capture { /CREDIT|DEBIT/ } // 👈🏻  fieldSeparator  Capture { One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) } // 👈🏻  fieldSeparator  Capture { // 👈🏻    OneOrMore {      NegativeLookahead { fieldSeparator }      CharacterClass.any    }  }  fieldSeparator  Capture { One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US"))) } // 👈🏻}// transactionMatcher: Regex<(Substring, Substring, Date, Substring, Decimal)>

基础类型

数字 Int, Float

数字的类型有 Int、Float 和 Double

// Intlet i1 = 100let i2 = 22print(i1 / i2) // 向下取整得 4// Floatlet f1: Float = 100.0let f2: Float = 22.0print(f1 / f2) // 4.5454545let f3: Float16 = 5.0 // macOS 还不能用let f4: Float32 = 5.0let f5: Float64 = 5.0let f6: Float80 = 5.0print(f4, f5, f6) // 5.0 5.0 5.0// Doublelet d1: Double = 100.0let d2: Double = 22.0print(d1 / d2) // 4.545454545454546// 字面量print(Int(0b10101)) // 0b 开头是二进制 print(Int(0x00afff)) // 0x 开头是十六进制print(2.5e4) // 2.5x10^4 十进制用 eprint(0xAp2) // 10*2^2  十六进制用 pprint(2_000_000) // 2000000// isMultiple(of:) 方法检查一个数字是否是另一个数字的倍数let i3 = 36print(i3.isMultiple(of: 9)) // true

处理数字有 floor、ceil、round。floor 是向下取整,只取整数部分;cell 是向上取整,只要有不为零的小数,整数就加1;round 是四舍五入。

布尔数 Bool

布尔数有 true 和 false 两种值,还有一个能够切换这两个值的 toggle 方法。

var b = falseb.toggle() // trueb.toggle() // false

元组 (a, b, c)

元组里的值类型可以是不同的。元组可以看成是匿名的结构体。

let t1 = (p1: 1, p2: "two", p3: [1,2,3])print(t1.p1)print(t1.p3)// 类型推导let t2 = (1, "two", [1,2,3])// 通过下标访问print(t2.1) // two// 分解元组let (dp1, dp2, _) = t2print(dp1)print(dp2)

字符串

let s1 = "Hi! This is a string. Cool?"/// 转义符 \n 表示换行。/// 其它转义字符有 \0 空字符)、\t 水平制表符 、\n 换行符、\r 回车符let s2 = "Hi!\nThis is a string. Cool?"// 多行let s3 = """Hi!This is a string.Cool?"""// 长度print(s3.count)print(s3.isEmpty)// 拼接print(s3 + "\nSure!")// 字符串中插入变量let i = 1print("Today is good day, double \(i)\(i)!")/// 遍历字符串/// 输出:/// o/// n/// efor c in "one" {    print(c)}// 查找print(s3.lowercased().contains("cool")) // true// 替换let s4 = "one is two"let newS4 = s4.replacingOccurrences(of: "two", with: "one")print(newS4)// 删除空格和换行let s5 = " Simple line. \n\n  "print(s5.trimmingCharacters(in: .whitespacesAndNewlines))// 切割成数组let s6 = "one/two/three"let a1 = s6.components(separatedBy: "/") // 继承自 NSString 的接口print(a1) // ["one", "two", "three"]let a2 = s6.split(separator: "/")print(a2) // ["one", "two", "three"] 属于切片,性能较 components 更好// 判断是否是某种类型let c1: Character = "🤔"print(c1.isASCII) // falseprint(c1.isSymbol) // trueprint(c1.isLetter) // falseprint(c1.isNumber) // falseprint(c1.isUppercase) // false// 字符串和 Data 互转let data = Data("hi".utf8)let s7 = String(decoding: data, as: UTF8.self)print(s7) // hi// 字符串可以当作集合来用。let revered = s7.reversed()print(String(revered))

Unicode、Character 和 SubString 等内容参见官方字符串文档:Strings and Characters — The Swift Programming Language (Swift 5.1)

字符串字面符号可以参看《String literals in Swift》。

原始字符串

// 原始字符串在字符串前加上一个或多个#符号。里面的双引号和转义符号将不再起作用了,如果想让转义符起作用,需要在转义符后面加上#符号。let s8 = #"\(s7)\#(s7) "one" and "two"\n. \#nThe second line."#print(s8)/// \(s7)hi "one" and "two"\n./// The second line.// 原始字符串在正则使用效果更佳,反斜杠更少了。let s9 = "\\\\[A-Z]+[A-Za-z]+\\.[a-z]+"let s10 = #"\\[A-Z]+[A-Za-z]+\.[a-z]+"#print(s9) // \\[A-Z]+[A-Za-z]+\.[a-z]+print(s10) // \\[A-Z]+[A-Za-z]+\.[a-z]+

Swift5.7 String Index 大升级 String Index Overhaul

枚举

Swift的枚举有类的一些特性,比如计算属性、实例方法、扩展、遵循协议等等。

enum E1:String, CaseIterable {    case e1, e2 = "12"}// 关联值enum E2 {    case e1([String])    case e2(Int)}let e1 = E2.e1(["one","two"])let e2 = E2.e2(3)switch e1 {case .e1(let array):    print(array)case .e2(let int):    print(int)}print(e2)// 原始值print(E1.e1.rawValue)// 遵循 CaseIterable 协议可迭代for ie in E1.allCases {    print("show \(ie)")}// 递归枚举enum RE {    case v(String)    indirect case node(l:RE, r:RE)}let lNode = RE.v("left")let rNode = RE.v("right")let pNode = RE.node(l: lNode, r: rNode)switch pNode {case .v(let string):    print(string)case .node(let l, let r):    print(l,r)    switch l {    case .v(let string):        print(string)    case .node(let l, let r):        print(l, r)    }    switch r {    case .v(let string):        print(string)    case .node(let l, let r):        print(l, r)    }}

@unknown 用来区分固定的枚举和可能改变的枚举的能力。@unknown 用于防止未来新增枚举属性会进行提醒提示完善每个 case 的处理。

// @unknownenum E3 {    case e1, e2, e3}func fe1(e: E3) {    switch e {    case .e1:        print("e1 ok")    case .e2:        print("e2 ok")    case .e3:        print("e3 ok")    @unknown default:        print("not ok")    }}

符合 Comparable 协议的枚举可以进行比较。

// Comparable 枚举比较enum E4: Comparable {    case e1, e2    case e3(i: Int)    case e4}let e3 = E4.e4let e4 = E4.e3(i: 3)let e5 = E4.e3(i: 2)let e6 = E4.e1print(e3 > e4) // truelet a1 = [e3, e4, e5, e6]let a2 = a1.sorted()for i in a2 {    print(i.self)}/// e1/// e3(i: 2)/// e3(i: 3)/// e4

泛型和协议

泛型可以减少重复代码,是一种抽象的表达方式。where 关键字可以对泛型做约束。

func fn<T>(p: T) -> [T] {    var r = [T]()    r.append(p)    return r}print(fn(p: "one"))// 结构体struct S1<T> {    var arr = [T]()    mutating func add(_ p: T) {        arr.append(p)    }}var s1 = S1(arr: ["zero"])s1.add("one")s1.add("two")print(s1.arr) // ["zero", "one", "two"]

关联类型

protocol pc {    associatedtype T    mutating func add(_ p: T)}struct S2: pc {    typealias T = String // 类型推导,可省略    var strs = [String]()    mutating func add(_ p: String) {        strs.append(p)    }}

泛型适用于嵌套类型

struct S3<T> {    struct S4 {        var p: T    }        var p1: T    var p2: S4}let s2 = S3(p1: 1, p2: S3.S4(p: 3))let s3 = S3(p1: "one", p2: S3.S4(p: "three"))print(s2,s3)

session Embrace Swift genericsDesign protocol interfaces in Swift

swift 5.6 和之前编写泛型接口如下:

func feed<A>(_ animal: A) where A: Animal// 👆🏻👇🏻 Equivalentsfunc feed<A: Animal>(_ animal: A)

swift 5.7 可以这样写:

func feed(_ animal: some Animal)

some 关键字可以用于参数和结构类型。some 会保证类型关系,而 any 会持有任意具体类型,删除类型关系。

SE-0347 Type inference from default expressions 扩展 Swift 泛型参数类型的默认值能力。如下代码示例:

func suffledArray<T: Sequence>(from options: T = 1...100) -> [T.Element] {    Array(options.shuffled())}print(suffledArray())print(suffledArray(from: ["one", "two", "three"]))

SE-0341 Opaque Parameter Declarations 使用 some 参数简化泛型参数声明。SE-0328 Structural opaque result types 扩大不透明结果返回类型可以使用的范围。SE-0360 Opaque result types with limited availability 可用性有限的不透明结果类型,比如 if #available(macOS 13.0, *) {} 就可以根据系统不同版本返回不同类型,新版本出现新类型的 View 就可以和以前的 View 类型区别开。

SE-0309 Unlock existentials for all protocols 改进了 existentials 和 泛型的交互。这样就可以更方便的检查 Any 类型的两个值是否相等

any 关键字充当的是类型擦除的助手,是通过告知编译器你使用 existential 作为类型,此语法可兼容以前系统。

SE-0346 Lightweight same-type requirements for primary associated types 引入一种新语法,用于符合泛型参数并通过相同类型要求约束关联类型。SE-0358 Primary Associated Types in the Standard Library 引入主要关联类型概念,并将其带入了标准库。这些关联类型很像泛型,允许开发者将给定关联类型的类型指定为通用约束。

SE-0353 Constrained Existential Types 基于 SE-0309 和 SE-0346 提案,在 existential 类型的上下文中重用轻量关联类型的约束。

SE-0352 Implicitly Opened Existentials 允许 Swift 在很多情况下使用协议调用泛型函数。

Swift 论坛上一个对 any 和 some 关键字语法使用场景的讨论,Do any and some help with “Protocol Oriented Testing” at all?

不透明类型

不透明类型会隐藏类型,让使用者更关注功能。不透明类型和协议很类似,不同的是不透明比协议限定的要多,协议能够对应更多类型。

protocol P {    func f() -> String}struct S1: P {    func f() -> String {        return "one\n"    }}struct S2<T: P>: P {    var p: T    func f() -> String {        return p.f() + "two\n"    }}struct S3<T1: P, T2: P>: P {    var p1: T1    var p2: T2    func f() -> String {        return p1.f() + p2.f() + "three\n"    }}func someP() -> some P {    return S3(p1: S1(), p2: S2(p: S1()))}let r = someP()print(r.f())

函数调用者决定返回什么类型是泛型,函数自身决定返回什么类型使用不透明返回类型。

Result

Result 类型用来处理错误,特别适用异步接口的错误处理。

extension URLSession {    func dataTaskWithResult(        with url: URL,        handler: @escaping (Result<Data, Error>) -> Void    ) -> URLSessionDataTask {        dataTask(with: url) { data, _, err in            if let err = err {                handler(.failure(err))            } else {                handler(.success(data ?? Data()))            }        }    }}let url = URL(string: "https://ming1016.github.io/")!// 以前网络请求let t1 = URLSession.shared.dataTask(with: url) {    data, _, error in    if let err = error {        print(err)    } else if let data = data {        print(String(decoding: data, as: UTF8.self))    }}t1.resume()// 使用 Result 网络请求let t2 = URLSession.shared.dataTaskWithResult(with: url) { result in    switch result {    case .success(let data):        print(String(decoding: data, as: UTF8.self))    case .failure(let err):        print(err)    }}t2.resume()

类型转换

使用 is 关键字进行类型判断, 使用as 关键字来转换成子类。

class S0 {}class S1: S0 {}class S2: S0 {}var a = [S0]()a.append(S1())a.append(S2())for e in a {    // 类型判断    if e is S1 {        print("Type is S1")    } else if e is S2 {        print("Type is S2")    }    // 使用 as 关键字转换成子类    if let s1 = e as? S1 {        print("As S1 \(s1)")    } else if let s2 = e as? S2 {        print("As S2 \(s2)")    }}

类和结构体

类可以定义属性、方法、构造器、下标操作。类使用扩展来扩展功能,遵循协议。类还以继承,运行时检查实例类型。

class C {    var p: String    init(_ p: String) {        self.p = p    }        // 下标操作    subscript(s: String) -> String {        get {            return p + s        }        set {            p = s + newValue        }    }}let c = C("hi")print(c.p)print(c[" ming"])c["k"] = "v"print(c.p)

结构体

结构体是值类型,可以定义属性、方法、构造器、下标操作。结构体使用扩展来扩展功能,遵循协议。

struct S {    var p1: String = ""    var p2: Int}extension S {    func f() -> String {        return p1 + String(p2)    }}var s = S(p2: 1)s.p1 = "1"print(s.f()) // 11

属性

类、结构体或枚举里的变量常量就是他们的属性。

struct S {    static let sp = "类型属性" // 类型属性通过类型本身访问,非实例访问    var p1: String = ""    var p2: Int = 1    // cp 是计算属性    var cp: Int {        get {            return p2 * 2        }        set {            p2 = newValue + 2        }    }    // 只有 getter 的是只读计算属性    var rcp: Int {        p2 * 4    }}print(S.sp)print(S().cp) // 2var s = S()s.cp = 3print(s.p2) // 5print(S().rcp) // 4

willSet 和 didSet 是属性观察器,可以在属性值设置前后插入自己的逻辑处理。

键路径表达式作为函数

struct S2 {    let p1: String    let p2: Int}let s2 = S2(p1: "one", p2: 1)let s3 = S2(p1: "two", p2: 2)let a1 = [s2, s3]let a2 = a1.map(\.p1)print(a2) // ["one", "two"]

方法

enum E: String {    case one, two, three    func showRawValue() {        print(rawValue)    }}let e = E.threee.showRawValue() // three// 可变的实例方法,使用 mutating 标记struct S {    var p: String    mutating func addFullStopForP() {        p += "."    }}var s = S(p: "hi")s.addFullStopForP()print(s.p)// 类方法class C {    class func cf() {        print("类方法")    }}

static和class关键字修饰的方法类似 OC 的类方法。static 可以修饰存储属性,而 class 不能;class 修饰的方法可以继承,而 static 不能。在协议中需用 static 来修饰。

静态下标方法

// 静态下标struct S2 {    static var sp = [String: Int]()        static subscript(_ s: String, d: Int = 10) -> Int {        get {            return sp[s] ?? d        }        set {            sp[s] = newValue        }    }}S2["key1"] = 1S2["key2"] = 2print(S2["key2"]) // 2print(S2["key3"]) // 10

自定义类型中实现了 callAsFunction() 的话,该类型的值就可以直接调用。

// callAsFunction()struct S3 {    var p1: String        func callAsFunction() -> String {        return "show \(p1)"    }}let s2 = S3(p1: "hi")print(s2()) // show hi

继承

类能继承另一个类,继承它的方法、属性等。

// 类继承class C1 {    var p1: String    var cp1: String {        get {            return p1 + " like ATM"        }        set {            p1 = p1 + newValue        }    }    init(p1: String) {        self.p1 = p1    }    func sayHi() {        print("Hi! \(p1)")    }}class C2: C1 {    var p2: String    init(p2: String) {        self.p2 = p2        super.init(p1: p2 + "'s father")    }}C2(p2: "Lemon").sayHi() // Hi! Lemon's father// 重写父类方法class C3: C2 {    override func sayHi() {        print("Hi! \(p2)")    }}C3(p2: "Lemon").sayHi() // Hi! Lemon// 重写计算属性class C4: C1 {    override var cp1: String {        get {            return p1 + " like Out of the blade"        }        set {            p1 = p1 + newValue        }    }}print(C1(p1: "Lemon").cp1) // Lemon like ATMprint(C4(p1: "Lemon").cp1) // Lemon like Out of the blade

通过 final 关键字可以防止类被继承,final 还可以用于属性和方法。使用 super 关键字指代父类。

函数式

map

map 可以依次处理数组中元素,并返回一个处理后的新数组。

let a1 = ["a", "b", "c"]let a2 = a1.map {    "\($0)2"}print(a2) // ["a2", "b2", "c2"]

使用 compactMap 可以过滤 nil 的元素。flatMap 会将多个数组合成一个数组返回。

filter

根据指定条件返回

let a1 = ["a", "b", "c", "call my name"]let a2 = a1.filter {    $0.prefix(1) == "c"}print(a2) // ["c", "call my name"]

reduce

reduce 可以将迭代中返回的结果用于下个迭代中,并,还能让你设个初始值。

let a1 = ["a", "b", "c", "call my name.", "get it?"]let a2 = a1.reduce("Hey u,", { partialResult, s in    // partialResult 是前面返回的值,s 是遍历到当前的值    partialResult + " \(s)"})print(a2) // Hey u, a b c call my name. get it?

sorted

排序

// 类型遵循 Comparablelet a1 = ["a", "b", "c", "call my name.", "get it?"]let a2 = a1.sorted()let a3 = a1.sorted(by: >)let a4 = a1.sorted(by: <)print(a2) // Hey u, a b c call my name. get it?print(a3) // ["get it?", "call my name.", "c", "b", "a"]print(a4) // ["a", "b", "c", "call my name.", "get it?"]// 类型不遵循 Comparablestruct S {    var s: String    var i: Int}let a5 = [S(s: "a", i: 0), S(s: "b", i: 1), S(s: "c", i: 2)]let a6 = a5    .sorted { l, r in        l.i > r.i    }    .map {        $0.i    }print(a6) // [2, 1, 0]

控制流

If • If let • If case let

// iflet s = "hi"if s.isEmpty {    print("String is Empty")} else {    print("String is \(s)")}// 三元条件s.isEmpty ? print("String is Empty again") : print("String is \(s) again")// if let-elsefunc f(s: String?) {    if let s1 = s {        print("s1 is \(s1)")    } else {        print("s1 is nothing")    }    // nil-coalescing    let s2 = s ?? "nothing"    print("s2 is \(s2)")}f(s: "something")f(s: nil)// if case letenum E {    case c1(String)    case c2([String])        func des() {        switch self {        case .c1(let string):            print(string)        case .c2(let array):            print(array)        }    }}E.c1("enum c1").des()E.c2(["one", "two", "three"]).des()

Guard guard, guard let

更好地处理异常情况

// guardfunc f1(p: String) -> String {    guard p.isEmpty != true else {        return "Empty string."    }    return "String \(p) is not empty."}print(f1(p: "")) // Empty string.print(f1(p: "lemon")) // String lemon is not empty.// guard letfunc f2(p1: String?) -> String {    guard let p2 = p1 else {        return "Nil."    }    return "String \(p2) is not nil."}print(f2(p1: nil)) // Nil.print(f2(p1: "lemon")) // String lemon is not nil.

遍历 For-in

let a = ["one", "two", "three"]for str in a {    print(str)}// 使用下标范围for i in 0..<10 {    print(i)}// 使用 enumeratedfor (i, str) in a.enumerated() {    print("第\(i + 1)个是:\(str)")}// for in wherefor str in a where str.prefix(1) == "t" {    print(str)}// 字典 for in,遍历是无序的let dic = [    "one": 1,    "two": 2,    "three": 3]for (k, v) in dic {    print("key is \(k), value is \(v)")}// stridefor i in stride(from: 10, through: 0, by: -2) {    print(i)}/* 10 8 6 4 2 0 */

While while, repeat-while

// whilevar i1 = 10while i1 > 0 {    print("positive even number \(i1)")    i1 -= 2}// repeat whilevar i2 = 10repeat {    print("positive even number \(i2)")    i2 -= 2} while i2 > 0

使用 break 结束遍历,使用 continue 跳过当前作用域,继续下个循环

Switch

func f1(pa: String, t:(String, Int)) {    var p1 = 0    var p2 = 10    switch pa {    case "one":        p1 = 1    case "two":        p1 = 2        fallthrough // 继续到下个 case 中    default:        p2 = 0    }    print("p1 is \(p1)")    print("p2 is \(p2)")        // 元组    switch t {    case ("0", 0):        print("zero")    case ("1", 1):        print("one")    default:        print("no")    }}f1(pa: "two", t:("1", 1))/* p1 is 2 p2 is 0 one */// 枚举enum E {    case one, two, three, unknown(String)}func f2(pa: E) {    var p: String    switch pa {    case .one:        p = "1"    case .two:        p = "2"    case .three:        p = "3"    case let .unknown(u) where Int(u) ?? 0 > 0 : // 枚举关联值,使用 where 增加条件        p = u    case .unknown(_):        p = "negative number"    }    print(p)}f2(pa: E.one) // 1f2(pa: E.unknown("10")) // 10f2(pa: E.unknown("-10")) // negative number

集合

数组 [1, 2, 3]

数组是有序集合

var a0: [Int] = [1, 10]a0.append(2)a0.remove(at: 0)print(a0) // [10, 2]let a1 = ["one", "two", "three"]let a2 = ["three", "four"]// 找两个集合的不同let dif = a1.difference(from: a2) // swift的 diffing 算法在这 http://www.xmailserver.org/diff2.pdf swift实现在  swift/stdlib/public/core/Diffing.swiftfor c in dif {    switch c {    case .remove(let o, let e, let a):        print("offset:\(o), element:\(e), associatedWith:\(String(describing: a))")    case .insert(let o, let e, let a):        print("offset:\(o), element:\(e), associatedWith:\(String(describing: a))")    }}/* remove offset:1, element:four, associatedWith:nil insert offset:0, element:one, associatedWith:nil insert offset:1, element:two, associatedWith:nil */let a3 = a2.applying(dif) ?? [] // 可以用于添加删除动画print(a3) // ["one", "two", "three"]

dif 有第三个 case 值 .insert(let offset, let element, let associatedWith) 可以跟踪成对的变化,用于高级动画。

从数组中随机取一个元素

print(a0.randomElement() ?? 0)

数组排序

// 排序struct S1 {    let n: Int    var b = true}let a4 = [    S1(n: 1),    S1(n: 10),    S1(n: 3),    S1(n: 2)]let a5 = a4.sorted { i1, i2 in    i1.n < i2.n}for n in a5 {    print(n)}/// S1(n: 1)/// S1(n: 2)/// S1(n: 3)/// S1(n: 10)let a6 = [1,10,4,7,2]print(a6.sorted(by: >)) // [10, 7, 4, 2, 1]

可以加到数组扩展中,通过扩展约束能够指定特定元素类型的排序,代码如下:

extension Array where Element == Int {    // 升序    func intSortedASC() -> [Int] {        return self.sorted(by: <)    }    // 降序    func intSortedDESC() -> [Int] {        return self.sorted(by: <)    }}print(a6.intSortedASC()) // 使用扩展增加自定义排序能力

在数组中检索满足条件的元素,代码如下:

// 第一个满足条件了就返回let a7 = a4.first {    $0.n == 10}print(a7?.n ?? 0)// 是否都满足了条件print(a4.allSatisfy { $0.n == 1 }) // falseprint(a4.allSatisfy(\.b)) // true// 找出最大的那个print(a4.max(by: { e1, e2 in    e1.n < e2.n}) ?? S1(n: 0))// S1(n: 10, b: true)// 看看是否包含某个元素print(a4.contains(where: {    $0.n == 7}))// false

一些切割数组的方法。

// 切片// 取前3个,并不是直接复制,对于大的数组有性能优势。print(a6[..<3]) // [1, 10, 4] 需要做越界检查print(a6.prefix(30)) // [1, 10, 4, 7, 2] 不需要做越界检查,也是切片,性能一样// 去掉前3个print(a6.dropFirst(3)) // [7, 2]

prefix(while:) 和 drop(while:) 方法,顺序遍历执行闭包里的逻辑判断,满足条件就返回,遇到不匹配就会停止遍历。prefix 返回满足条件的元素集合,drop 返回停止遍历之后那些元素集合。

let a8 = [8, 9, 20, 1, 35, 3]let a9 = a8.prefix {    $0 < 30}print(a9) // [8, 9, 20, 1]let a10 = a8.drop {    $0 < 30}print(a10) // [35, 3]

比 filter 更高效的删除元素的方法 removeAll

// 删除所有不满足条件的元素var a11 = [1, 3, 5, 12, 25]a11.removeAll { $0 < 10 }print(a11) // [4, 3, 1, 3, 3] 随机// 创建未初始化的数组let a12 = (0...4).map { _ in    Int.random(in: 0...5)}print(a12) // [0, 3, 3, 2, 5] 随机

#if 用于后缀表达式

// #if 用于后缀表达式let a13 = a11#if os(iOS)    .count#else    .reduce(0, +)#endifprint(a13) //37

Sets Set

Set 是无序集合,元素唯一

let s0: Set<Int> = [2, 4]let s1: Set = [2, 10, 6, 4, 8]let s2: Set = [7, 3, 5, 1, 9, 10]let s3 = s1.union(s2) // 合集let s4 = s1.intersection(s2) // 交集let s5 = s1.subtracting(s2) // 非交集部分let s6 = s1.symmetricDifference(s2) // 非交集的合集print(s3) // [4, 2, 1, 7, 3, 10, 8, 9, 6, 5]print(s4) // [10]print(s5) // [8, 4, 2, 6]print(s6) // [9, 1, 3, 4, 5, 2, 6, 8, 7]// s0 是否被 s1 包含print(s0.isSubset(of: s1)) // true// s1 是否包含了 s0print(s1.isSuperset(of: s0)) // truelet s7: Set = [3, 5]// s0 和 s7 是否有交集print(s0.isDisjoint(with: s7)) // true// 可变 Setvar s8: Set = ["one", "two"]s8.insert("three")s8.remove("one")print(s8) // ["two", "three"]

字典 [:]

字典是无序集合,键值对应。

var d1 = [    "k1": "v1",    "k2": "v2"]d1["k3"] = "v3"d1["k4"] = nilprint(d1) // ["k2": "v2", "k3": "v3", "k1": "v1"]for (k, v) in d1 {    print("key is \(k), value is \(v)")}/* key is k1, value is v1 key is k2, value is v2 key is k3, value is v3 */ if d1.isEmpty == false {    print(d1.count) // 3}// mapValueslet d2 = d1.mapValues {    $0 + "_new"}print(d2) // ["k2": "v2_new", "k3": "v3_new", "k1": "v1_new"]// 对字典的值或键进行分组let d3 = Dictionary(grouping: d1.values) {    $0.count}print(d3) // [2: ["v1", "v2", "v3"]]// 从字典中取值,如果键对应无值,则使用通过 default 指定的默认值d1["k5", default: "whatever"] += "."print(d1["k5"] ?? "") // whatever.let v1 = d1["k3", default: "whatever"]print(v1) // v3// compactMapValues() 对字典值进行转换和解包。可以解可选类型,并去掉 nil 值let d4 = [    "k1": 1,    "k2": 2,    "k3": nil]let d5 = d4.mapValues { $0 }let d6 = d4.compactMapValues{ $0 }print(d5)// ["k3": nil, "k1": Optional(1), "k2": Optional(2)]print(d6)// ["k1": 1, "k2": 2]

操作符

赋值 =, +=. -=, *=, /=

let i1 = 1var i2 = i1i2 = 2print(i2) // 2i2 += 1print(i2) // 3i2 -= 2print(i2) // 1i2 *= 10print(i2) // 10i2 /= 2print(i2) // 5

计算符 +, -, *, /, %

let i1 = 1let i2 = i1print((i1 + i2 - 1) * 10 / 2 % 3) // 2print("i" + "1") // i1// 一元运算符print(-i1) // -1

比较运算符 ==, >

遵循 Equatable 协议可以使用 == 和 != 来判断是否相等

print(1 > 2) // falsestruct S: Equatable {    var p1: String    var p2: Int}let s1 = S(p1: "one", p2: 1)let s2 = S(p1: "two", p2: 2)let s3 = S(p1: "one", p2: 2)let s4 = S(p1: "one", p2: 1)print(s1 == s2) // falseprint(s1 == s3) // falseprint(s1 == s4) // true

类需要实现 == 函数

class C: Equatable {    var p1: String    var p2: Int    init(p1: String, p2: Int) {        self.p1 = p1        self.p2 = p2    }        static func == (l: C, r: C) -> Bool {        return l.p1 == r.p1 && l.p2 == r.p2    }}let c1 = C(p1: "one", p2: 1)let c2 = C(p1: "one", p2: 1)print(c1 == c2)
// 元组比较// 会先比较第一个数,第一个无法比较才会比较第二个数// 字符串比较和字母大小还有长度有关。先比较字母大小,在比较长度("apple", 1) < ("apple", 2) // true("applf", 1) < ("apple", 2) // false("appl", 2) < ("apple", 1) // true("appm", 2) < ("apple", 1) // false

三元 _ ? _ : _

简化 if else 写法

// if elsefunc f1(p: Int) {    if p > 0 {        print("positive number")    } else {        print("negative number")    }}// 三元func f2(p: Int) {    p > 0 ? print("positive number") : print("negative number")}f1(p: 1)f2(p: 1)

Nil-coalescing ??

简化 if let else 写法

// if elsefunc f1(p: Int?) {    if let i = p {        print("p have value is \(i)")    } else {        print("p is nil, use defalut value")    }}// 使用 ??func f2(p: Int?) {    let i = p ?? 0    print("p is \(i)")}

范围 a…b

简化的值范围表达方式。

// 封闭范围for i in 0...10 {    print(i)}// 半开范围for i in 0..<10 {    print(i)}
// 单侧区间let nums = [5,6,7,8]print(nums[2...]) // 7 8

逻辑 !, &&, ||

let i1 = -1let i2 = 2if i1 != i2 && (i1 < 0 || i2 < 0) {    print("i1 and i2 not equal, and one of them is negative number.")}

恒等 ===, !==

恒等返回是否引用了相同实例。

class C {    var p: String    init(p: String) {        self.p = p    }}let c1 = C(p: "one")let c2 = C(p: "one")let c3 = c1print(c1 === c2) // falseprint(c1 === c3) // trueprint(c1 !== c2) // true

运算符

位运算符

let i1: UInt8 = 0b00001111let i2 = ~i1 // Bitwise NOT Operator(按位取反运算符),取反let i3: UInt8 = 0b00111111let i4 = i1 & i3 // Bitwise AND Operator(按位与运算符),都为1才是1let i5 = i1 | i3 // Bitwise OR Operator(按位或运算符),有一个1就是1let i6 = i1 ^ i3 // Bitwise XOR Operator(按位异或运算符),不同为1,相同为0print(i1,i2,i3,i4,i5,i6)// << 按位左移,>> 按位右移let i7 = i1 << 1let i8 = i1 >> 2print(i7,i8)

溢出运算符,有 &+、&- 和 &*

var i1 = Int.maxprint(i1) // 9223372036854775807i1 = i1 &+ 1print(i1) // -9223372036854775808i1 = i1 &+ 10print(i1) // -9223372036854775798var i2 = UInt.maxi2 = i2 &+ 1print(i2) // 0

运算符函数包括前缀运算符、后缀运算符、复合赋值运算符以及等价运算符。另,还可以自定义运算符,新的运算符要用 operator 关键字进行定义,同时要指定 prefix、infix 或者 postfix 修饰符。

基础库

时间

Date 的基本用法如下:

let now = Date()// Date 转 时间戳let interval = now.timeIntervalSince1970 // 时间戳let df = DateFormatter()df.dateFormat = "yyyy 年 MM 月 dd 日 HH:mm:ss"print("时间戳:\(Int(interval))") // 时间戳:1642399901print("格式化的时间:" + df.string(from: now)) // 格式化的时间:2022 年 01 月 17 日 14:11:41df.dateStyle = .shortprint("short 样式时间:" + df.string(from: now)) // short 样式时间:2022/1/17df.locale = Locale(identifier: "zh_Hans_CN")df.dateStyle = .fullprint("full 样式时间:" + df.string(from: now)) // full 样式时间:2022年1月17日 星期一// 时间戳转 Datelet date = Date(timeIntervalSince1970: interval)print(date) // 2022-01-17 06:11:41 +0000

复杂的时间操作,比如说 GitHub 接口使用的是 ISO 标准,RSS 输出的是 RSS 标准字符串,不同标准对应不同时区的时间计算处理,可以使用开源库 SwiftDate 来完成。示例代码如下:

import SwiftDate// 使用 SwiftDate 库let cn = Region(zone: Zones.asiaShanghai, locale: Locales.chineseChina)SwiftDate.defaultRegion = cnprint("2008-02-14 23:12:14".toDate()?.year ?? "") // 2008let d1 = "2022-01-17T23:20:35".toISODate(region: cn)guard let d1 = d1 else {    return}print(d1.minute) // 20let d2 = d1 + 1.minutesprint(d2.minute)// 两个 DateInRegion 相差时间 intervallet i1 = DateInRegion(Date(), region: cn) - d1let s1 = i1.toString {    $0.maximumUnitCount = 4    $0.allowedUnits = [.day, .hour, .minute]    $0.collapsesLargestUnit = true    $0.unitsStyle = .abbreviated    $0.locale = Locales.chineseChina}print(s1) // 9小时45分钟

格式化

使用标准库的格式来描述不同场景的情况可以不用去考虑由于不同地区的区别,这些在标准库里就可以自动完成了。

描述两个时间之间相差多长时间

// 计算两个时间之间相差多少时间,支持多种语言字符串let d1 = Date().timeIntervalSince1970 - 60 * 60 * 24let f1 = RelativeDateTimeFormatter()f1.dateTimeStyle = .namedf1.formattingContext = .beginningOfSentencef1.locale = Locale(identifier: "zh_Hans_CN")let str = f1.localizedString(for: Date(timeIntervalSince1970: d1), relativeTo: Date())print(str) // 昨天// 简写let str2 = Date.now.addingTimeInterval(-(60 * 60 * 24))    .formatted(.relative(presentation: .named))print(str2) // yesterday

描述多个事物

// 描述多个事物let s1 = ListFormatter.localizedString(byJoining: ["冬天","春天","夏天","秋天"])print(s1)

描述名字

// 名字let f2 = PersonNameComponentsFormatter()var nc1 = PersonNameComponents()nc1.familyName = "戴"nc1.givenName = "铭"nc1.nickname = "铭哥"print(f2.string(from: nc1)) // 戴铭f2.style = .shortprint(f2.string(from: nc1)) // 铭哥f2.style = .abbreviatedprint(f2.string(from: nc1)) // 戴var nc2 = PersonNameComponents()nc2.familyName = "Dai"nc2.givenName = "Ming"nc2.nickname = "Starming"f2.style = .defaultprint(f2.string(from: nc2)) // Ming Daif2.style = .shortprint(f2.string(from: nc2)) // Starmingf2.style = .abbreviatedprint(f2.string(from: nc2)) // MD// 取出名let componets = f2.personNameComponents(from: "戴铭")print(componets?.givenName ?? "") // 铭

描述数字

// 数字let f3 = NumberFormatter()f3.locale = Locale(identifier: "zh_Hans_CN")f3.numberStyle = .currencyprint(f3.string(from: 123456) ?? "") // ¥123,456.00f3.numberStyle = .percentprint(f3.string(from: 123456) ?? "") // 12,345,600%let n1 = 1.23456let n1Str = n1.formatted(.number.precision(.fractionLength(3)).rounded())print(n1Str) // 1.235

描述地址

// 地址import Contactslet f4 = CNPostalAddressFormatter()let address = CNMutablePostalAddress()address.street = "海淀区王庄路XX号院X号楼X门XXX"address.postalCode = "100083"address.city = "北京"address.country = "中国"print(f4.string(from: address))/// 海淀区王庄路XX号院X号楼X门XXX/// 北京 100083/// 中国

度量值

标准库里的物理量,在这个文档里有详细列出,包括角度、平方米等。

// 参考:https://developer.apple.com/documentation/foundation/nsdimensionlet m1 = Measurement(value: 1, unit: UnitLength.kilometers)let m2 = m1.converted(to: .meters) // 千米转米print(m2) // 1000.0 m// 度量值转为本地化的值let mf = MeasurementFormatter()mf.locale = Locale(identifier: "zh_Hans_CN")print(mf.string(from: m1)) // 1公里

一些物理公式供参考:

面积 = 长度 × 长度体积 = 长度 × 长度 × 长度 = 面积 × 长度速度=长度/时间加速度=速度/时间力 = 质量 × 加速度扭矩 = 力 × 长度压力 = 力 / 面积密度=质量 / 体积能量 = 功率 × 时间电阻 = 电压 / 电流

Data

数据压缩和解压

// 对数据的压缩let d1 = "看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?看看能够压缩多少?".data(using: .utf8)! as NSDataprint("ori \(d1.count) bytes")do {    /// 压缩算法    /// * lz4    /// * lzma    /// * zlib    /// * lzfse    let compressed = try d1.compressed(using: .zlib)    print("comp \(compressed.count) bytes")        // 对数据解压    let decomressed = try compressed.decompressed(using: .zlib)    let deStr = String(data: decomressed as Data, encoding: .utf8)    print(deStr ?? "")} catch {}/// ori 297 bytes/// comp 37 bytes

文件

文件的一些基本操作的代码如下:

let path1 = "/Users/mingdai/Downloads/1.html"let path2 = "/Users/mingdai/Documents/GitHub/"let u1 = URL(string: path1)do {    // 写入    let url1 = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: u1, create: true) // 保证原子性安全保存    print(url1)    // 读取    let s1 = try String(contentsOfFile: path1, encoding: .utf8)    print(s1)} catch {}// 检查路径是否可用let u2 = URL(fileURLWithPath:path2)do {    let values = try u2.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])    if let capacity = values.volumeAvailableCapacityForImportantUsage {        print("可用: \(capacity)")    } else {        print("不可用")    }} catch {    print("错误: \(error.localizedDescription)")}

怎么遍历多级目录结构中的文件呢?看下面的代码的实现:

// 遍历路径下所有目录let u3 = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)let fm = FileManager.defaultfm.enumerator(atPath: u3.path)?.forEach({ path in    guard let path = path as? String else {        return    }    let url = URL(fileURLWithPath: path, relativeTo: u3)    print(url.lastPathComponent)})

可以使用 FileWrapper 来创建文件夹和文件。举个例子:

// FileWrapper 的使用// 创建文件let f1 = FileWrapper(regularFileWithContents: Data("# 第 n 个文件\n ## 标题".utf8))f1.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()f1.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()// 创建文件夹let folder1 = FileWrapper(directoryWithFileWrappers: [    "file1.md": f1])folder1.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()folder1.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()do {    try folder1.write(        to: URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("NewFolder"),        options: .atomic,        originalContentsURL: nil    )} catch {}print(FileManager.default.currentDirectoryPath)

上面代码写起来比较繁琐,对 FileWrapper 更好的封装可以参考这篇文章《 A Type-Safe FileWrapper | Heberti Almeida 》。

文件读写处理完整能力可以参看这个库 GitHub - JohnSundell/Files: A nicer way to handle files & folders in Swift

本地或者网络上,比如网盘和FTP的文件发生变化时,怎样知道能够观察到呢?

通过 HTTPHeader 里的 If-Modified-Since、Last-Modified、If-None-Match 和 Etag 等字段来判断文件的变化,本地则是使用 DispatchSource.makeFileSystemObjectSource 来进行的文件变化监听。可以参考 KZFileWatchers 库的做法。

Scanner

let s1 = """one1,two2,three3."""let sn1 = Scanner(string: s1)while !sn1.isAtEnd {    if let r1 = sn1.scanUpToCharacters(from: .newlines) {        print(r1 as String)    }}/// one1,/// two2,/// three3.// 找出数字let sn2 = Scanner(string: s1)sn2.charactersToBeSkipped = CharacterSet.decimalDigits.inverted // 不是数字的就跳过var p: Int = 0while !sn2.isAtEnd {    if sn2.scanInt(&p) {        print(p)    }}/// 1/// 2/// 3

上面的代码还不是那么 Swifty,可以通过用AnySequence和AnyIterator来包装下,将序列中的元素推迟到实际需要时再来处理,这样性能也会更好些。具体实现可以参看《 String parsing in Swift 》这篇文章。

AttributeString

效果如下:

代码如下:

var aStrs = [AttributedString]()var aStr1 = AttributedString("""标题正文内容,具体查看链接。这里摘出第一个重点,还要强调的内容。""")// 标题let title = aStr1.range(of: "标题")guard let title = title else {    return aStrs}var c1 = AttributeContainer() // 可复用容器c1.inlinePresentationIntent = .stronglyEmphasizedc1.font = .largeTitleaStr1[title].setAttributes(c1)// 链接let link = aStr1.range(of: "链接")guard let link = link else {    return aStrs}var c2 = AttributeContainer() // 链接c2.strokeColor = .bluec2.link = URL(string: "https://ming1016.github.io/")aStr1[link].setAttributes(c2.merging(c1)) // 合并 AttributeContainer// Runslet i1 = aStr1.range(of: "重点")let i2 = aStr1.range(of: "强调")guard let i1 = i1, let i2 = i2 else {    return aStrs}var c3 = AttributeContainer()c3.foregroundColor = .yellowc3.inlinePresentationIntent = .stronglyEmphasizedaStr1[i1].setAttributes(c3)aStr1[i2].setAttributes(c3)for r in aStr1.runs {    print("-------------")    print(r.attributes)}aStrs.append(aStr1)// Markdowndo {    let aStr2 = try AttributedString(markdown: """    内容[链接](https://ming1016.github.io/)。需要**强调**的内容。    """)        aStrs.append(aStr2)    } catch {}

SwiftUI 的 Text 可以直接读取 AttributedString 来进行显示。

随机

用法:

let ri = Int.random(in: 0..<10)print(ri) // 0到10随机数let a = [0, 1, 2, 3, 4, 5]print(a.randomElement() ?? 0) // 数组中随机取个数print(a.shuffled()) // 随机打乱数组顺序

UserDefaults

使用方法如下:

enum UDKey {    static let k1 = "token"}let ud = UserDefaults.standardud.set("xxxxxx", forKey: UDKey.k1)let tk = ud.string(forKey: UDKey.k1)print(tk ?? "")

模式

单例

struct S {    static let shared = S()    private init() {        // 防止实例初始化    }}

系统及设备

系统判断

#if os(tvOS)     // do something in tvOS#elseif os(iOS)     // do somthing in iOS#elseif os(macOS)    // do somthing in macOS#endif

版本兼容

// 版本@available(iOS 15, *)func f() {}// 版本检查if #available(iOS 15, macOS 12, *) {    f()} else {    // nothing happen}

canImport 判断库是否可使用

#if canImport(SpriteKit)   // iOS 等苹果系统执行#else   // 非苹果系统#endif

targetEnvironment 环境的判断

#if targetEnvironment(simulator)   // 模拟器#else   // 真机#endif

自带属性包装

@resultBuilder

结果生成器(Result builders),通过传递序列创建新值,SwiftUI就是使用的结果生成器将多个视图生成一个视图

@resultBuilderstruct RBS {    // 基本闭包支持    static func buildBlock(_ components: Int...) -> Int {        components.reduce(0) { partialResult, i in            partialResult + i        }    }    // 支持条件判断    static func buildEither(first component: Int) -> Int {        component    }    static func buildEither(second component: Int) -> Int {        component    }    // 支持循环    static func buildArray(_ components: [Int]) -> Int {        components.reduce(0) { partialResult, i in            partialResult + i        }    }}let a = RBS.buildBlock(    1,    2,    3)print(a) // 6// 应用到函数中@RBS func f1() -> Int {    1    2    3}print(f1()) // 6// 设置了 buildEither 就可以在闭包中进行条件判断。@RBS func f2(stopAtThree: Bool) -> Int {    1    2    3    if stopAtThree == true {        0    } else {        4        5        6    }}print(f2(stopAtThree: false)) // 21// 设置了 buildArray 就可以在闭包内使用循环了@RBS func f3() -> Int {    for i in 1...3 {        i * 2    }}print(f3()) // 12

SE-0348 buildPartialBlock for result builders 简化了实现复杂 result buiders 所需的重载。

@dynamicMemberLookup 动态成员查询

@dynamicMemberLookup 指示访问属性时调用一个已实现的处理动态查找的下标方法 subscript(dynamicMemeber:),通过指定属性字符串名返回值。使用方法如下:

@dynamicMemberLookupstruct D {    // 找字符串    subscript(dynamicMember m: String) -> String {        let p = ["one": "first", "two": "second"]        return p[m, default: ""]    }    // 找整型    subscript(dynamicMember m: String) -> Int {        let p = ["one": 1, "two": 2]        return p[m, default: 0]    }    // 找闭包    subscript(dynamicMember m: String) -> (_ s: String) -> Void {        return {            print("show \($0)")        }    }    // 静态数组成员    var p = ["This is a member"]    // 动态数组成员    subscript(dynamicMember m: String) -> [String] {        return ["This is a dynamic member"]    }}let d = D()let s1: String = d.oneprint(s1) // firstlet i1: Int = d.oneprint(i1) // 1d.show("something") // show somethingprint(d.p) // ["This is a member"]let dynamicP:[String] = d.dpprint(dynamicP) // ["This is a dynamic member"]

类使用 @dynamicMemberLookup,继承的类也会自动加上 @dynamicMemberLookup。协议上定义 @dynamicMemberLookup,通过扩展可以默认实现 subscript(dynamicMember:) 方法。

@dynamicCallable 动态可调用类型

@dynamicCallable 动态可调用类型。通过实现 dynamicallyCall 方法来定义变参的处理。

@dynamicCallablestruct D {    // 带参数说明    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, Int>) -> Int {        let firstArg = args.first?.value ?? 0        return firstArg * 2    }        // 无参数说明    func dynamicallyCall(withArguments args: [String]) -> String {        var firstArg = ""        if args.count > 0 {            firstArg = args[0]        }        return "show \(firstArg)"    }}let d = D()let i = d(numberIs: 2)print(i) // 4let s = d("hi")print(s) // show hi

自带协议

Hashable

struct H: Hashable {    var p1: String    var p2: Int        // 提供随机 seed    func hash(into hasher: inout Hasher) {        hasher.combine(p1)    }}let h1 = H(p1: "one", p2: 1)let h2 = H(p1: "two", p2: 2)var hs1 = Hasher()hs1.combine(h1)hs1.combine(h2)print(h1.hashValue) // 7417088153212460033 随机值print(h2.hashValue) // -6972912482785541972 随机值print(hs1.finalize()) // 7955861102637572758 随机值print(h1.hashValue) // 7417088153212460033 和前面 h1 一样let h3 = H(p1: "one", p2: 1)print(h3.hashValue) // 7417088153212460033 和前面 h1 一样var hs2 = Hasher()hs2.combine(h3)hs2.combine(h2)print(hs2.finalize()) // 7955861102637572758 和前面 hs1 一样

应用生命周期内,调用 combine() 添加相同属性哈希值相同,由于 Hasher 每次都会使用随机的 seed,因此不同应用生命周期,也就是下次启动的哈希值,就会和上次的哈希值不同。

Codable

JSON 没有 id 字段

如果SwiftUI要求数据Model都是遵循Identifiable协议的,而有的json没有id这个字段,可以使用扩展struct的方式解决:

struct CommitModel: Decodable, Hashable {  var sha: String  var author: AuthorModel  var commit: CommitModel}extension CommitModel: Identifiable {  var id: String {    return sha  }}

网络

网络状态检查

通过 Network 库的 NWPathMonitor 来检查

import Combineimport Network// 网络状态检查 network state checkfinal class Nsck: ObservableObject {    static let shared = Nsck()    private(set) lazy var pb = mkpb()    @Published private(set) var pt: NWPath        private let monitor: NWPathMonitor    private lazy var sj = CurrentValueSubject<NWPath, Never>(monitor.currentPath)    private var sb: AnyCancellable?        init() {        monitor = NWPathMonitor()        pt = monitor.currentPath        monitor.pathUpdateHandler = { [weak self] path in            self?.pt = path            self?.sj.send(path)        }        monitor.start(queue: DispatchQueue.global())    }        deinit {        monitor.cancel()        sj.send(completion: .finished)    }        private func mkpb() -> AnyPublisher<NWPath, Never> {        return sj.eraseToAnyPublisher()    }}

使用方法

var sb = Set<AnyCancellable>()var alertMsg = ""Nsck.shared.pb    .sink { _ in        //    } receiveValue: { path in        alertMsg = path.debugDescription        switch path.status {        case .satisfied:            alertMsg = ""        case .unsatisfied:            alertMsg = "😱"        case .requiresConnection:            alertMsg = "🥱"        @unknown default:            alertMsg = "🤔"        }        if path.status == .unsatisfied {            switch path.unsatisfiedReason {            case .notAvailable:                alertMsg += "网络不可用"            case .cellularDenied:                alertMsg += "蜂窝网不可用"            case .wifiDenied:                alertMsg += "Wifi不可用"            case .localNetworkDenied:                alertMsg += "网线不可用"            @unknown default:                alertMsg += "网络不可用"            }        }    }    .store(in: &sb)

动画

布局动画

import SwiftUIstruct AnimateLayout: View { @State var changeLayout: Bool = true @Namespace var namespace var body: some View {  VStack(spacing: 30) {   if changeLayout {    HStack { items }   } else {    VStack { items }   }   Button("切换布局") {    withAnimation { changeLayout.toggle() }   }  }  .padding() } @ViewBuilder var items: some View {  Text("one")   .matchedGeometryEffect(id: "one", in: namespace)  Text("Two")   .matchedGeometryEffect(id: "Two", in: namespace)  Text("Three")   .matchedGeometryEffect(id: "Three", in: namespace) }}

安全

Keychain

使用方法:

let d1 = Data("keyChain github token".utf8)let service = "access-token"let account = "github"let q1 = [    kSecValueData: d1,    kSecClass: kSecClassGenericPassword,    kSecAttrService: service,    kSecAttrAccount: account] as CFDictionary// 添加一个 keychainlet status = SecItemAdd(q1, nil)// 如果已经添加过会抛出 -25299 错误代码,需要调用 SecItemUpdate 来进行更新if status == errSecDuplicateItem {    let q2 = [        kSecClass: kSecClassGenericPassword,        kSecAttrService: service,        kSecAttrAccount: account    ] as CFDictionary    let q3 = [        kSecValueData: d1    ] as CFDictionary    SecItemUpdate(q2, q3)}// 读取let q4 = [    kSecAttrService: service,    kSecAttrAccount: account,    kSecClass: kSecClassGenericPassword,    kSecReturnData: true] as CFDictionaryvar re: AnyObject?SecItemCopyMatching(q4, &re)guard let reData = re as? Data else { return }print(String(decoding: reData, as: UTF8.self)) // keyChain github token// 删除let q5 = [    kSecAttrService: service,    kSecAttrAccount: account,    kSecClass: kSecClassGenericPassword,] as CFDictionarySecItemDelete(q5)

工程

程序入口点

Swift 允许全局编写 Swift 代码,实际上 clang 会自动将代码包进一个模拟 C 的函数中。Swift 也能够指定入口点,比如 @UIApplicationMain 或 @NSApplicationMain,UIKit 启动后生命周期管理是 AppDelegate 和 SceneDelegate,《 Understanding the iOS 13 Scene Delegate 》这篇有详细介绍。

@UIApplicationMain 和 @NSApplicationMain 会自动生成入口点。这些入口点都是平台相关的,Swift 发展来看是多平台的,这样在 Swift 5.3 时引入了 @main,可以方便的指定入口点。代码如下:

@main // 要定义个静态的 main 函数struct M {  static func main() {    print("let's begin")  }}

ArgumentParser 库,Swift 官方开源的一个开发命令行工具的库,也支持 @main。使用方法如下:

import ArgumentParser@mainstruct C: ParsableCommand {  @Argument(help: "Start")  var phrase: String     func run() throws {    for _ in 1...5 {      print(phrase)    }  }}

工具

Swift-DocC

现在支持 Swift、OC 和 C,文档标记一样。.doccarchive 包含可部署的网站内容,兼容大多数托管服务,比如 Github pages。部署到在线服务上可参考 Generating Documentation for Hosting OnlinePublishing to GitHub Pages 文档。

和 SPM 集成参看 SwiftDocCPlugin

session 有 What’s new in Swift -DocCImprove the discoverability of your Swift-DocC content

SE-0356 Swift Snippets 代码片段用于示例文档的提案。

专题

Swift 那些事

Swift 各版本演进

Swift 1.1
  • countElements() 改成了 count()。
  • @NSApplicationMain 可以在 macOS 上使用。
Swift 1.2
  • 引入 Set 类型。
  • if let 可以放到一起,使用逗号分隔。
  • 新增 zip() 和 flatMap()。
  • 类增加静态方法和静态属性,使用 static 关键字描述。
  • as! 用于类型强转,失败会崩溃。
  • @noescape 用于描述作为参数闭包,用来告诉 Swift 闭包将在函数返回前使用。
  • 常量可以延后初始化。
Swift 2.0
  • 增加 guard 关键字,用于解可选项值。
  • defer 关键字用来延迟执行,即使抛出错误了都会在最后执行。
  • ErrorType 协议,以及 throws、do、try 和 catch 的引入用来处理错误。
  • characters 加上 count,用来替代 count()。
  • #available 用来检查系统版本。
Swift 2.1
  • 字符串插值可以包含字符串字面符号。

Swift 2.2

官方博客介绍:Swift 2.2 Released!New Features in Swift 2.2Swift 2.2 Release Process

  • FILE, LINEFUNCTION 换成 #file,#line 和 #function。
  • 废弃 ++ 和 – 操作符。
  • C 语言风格 for 循环废弃。
  • 废弃变量参数,因为变量参数容易和 inout 搞混。
  • 废弃字符串化的选择器,选择器不再能写成字符串了。
  • 元组可直接比较是否相等。
Swift 3.0

官方博客介绍:Swift 3.0 Released!Swift 3.0 Preview 1 Released!Swift 3.0 Release Process

  • 规范动词和名词来命名。
  • 去掉 NS 前缀。
  • 方法名描述参数部分变为参数名。
  • 省略没必要的单词,命名做了简化呢。比如 stringByTrimmingCharactersInSet 就换成了 trimmingCharacters。
  • 枚举的属性使用小写开头。
  • 引入 C 函数的属性。
Swift 3.1

官方博客介绍:Swift 3.1 Released!Swift 3.1 Release Process

  • 序列新增 prefix(while:) 和 drop(while:) 方法,顺序遍历执行闭包里的逻辑判断,满足条件就返回,遇到不匹配就会停止遍历。prefix 返回满足条件的元素集合,drop 返回停止遍历之后那些元素集合。
  • 泛型适用于嵌套类型。
  • 类型的扩展可以使用约束条件,比如扩展数组时,加上元素为整数的约束,这样的扩展就只会对元素为整数的数组有效。
Swift 4.0

官方博客介绍:Swift 4.0 Released!Swift 4 Release Process

Swift 4.1

官方博客介绍:Swift 4.1 Released!Swift 4.1 Release Process

Swift 4.2

官方博客介绍:Swift 4.2 Released!Swift 4.2 Release Process

Swift 5.0

官方博客介绍:Swift 5 Released!Swift 5.0 Release Process

Swift 5.1

官方博客介绍:Swift 5.1 Released!Swift 5.1 Release Process

Swift 5.2

官方博客介绍:Swift 5.2 Released!Swift 5.2 Release Process

Swift 5.3

官方博客介绍:Swift 5.3 released!Swift 5.3 Release Process

Swift 5.4

官方博客介绍:Swift 5.4 Released!

Swift 5.5

官方博客介绍:Swift 5.5 Released!

Swift 5.6

5.6 官方博客介绍

Package Manage 的一些提案

Swift 5.7

规范

注意事项

参考:

多用静态特性。swift 在编译期间所做的优化比 OC 要多,这是由于他的静态派发、泛型特化、写时复制这些静态特性决定的。另外通过 final 和 private 这样的表示可将动态特性转化为静态方式,编译开启 WMO 可以自动推导出哪些动态派发可转化为静态派发。

如何避免崩溃?

  • 字典:用结构体替代
  • Any:可用泛型或关联关联类型替代
  • as? :少用 AnyObject,多用泛型或不透明类型
  • !:要少用

好的实践?

  • 少用继承,多用 protocol
  • 多用 extension 对自己代码进行管理

资料推荐

书单

  • 《Thinking in SwiftUI》
  • 《Swift 进阶》
  • 《函数式Swift》
  • 《深入解析Mac OS X & iOS操作系统》
  • 《LLVM Techniques, Tips, and Best Practices Clang and Middle-End Libraries》
  • 《Learn LLVM 12》
  • 《Crafting Interpreters》
  • 《TCP/IP Illustrated》
  • 《松本行弘的程序世界》
  • 《现代操作系统》
  • 《深入理解计算机系统》
  • 《程序员的自我修养》
  • 《Head First 设计模式》

三方库使用

SQLite.swift 的使用

下面是 SQLite.swift 库的使用介绍,包括了数据库创建,表创建,表的添加、更新、删除、查找等处理方法

import SQLitestruct DB {    static let shared = DB()    static let path = NSSearchPathForDirectoriesInDomains(        .applicationSupportDirectory, .userDomainMask, true    ).first!    let BBDB: Connection?    private init() {        do {            print(DB.path)            BBDB = try Connection("\(DB.path)/github.sqlite3")                    } catch {            BBDB = nil        }        /// Swift 类型和 SQLite 类型对标如下:        /// Int64 = INTEGER        /// Double = REAL        /// String = TEXT        /// nil = NULL        /// SQLite.Blob = BLOB            }        // 创建表    func cTbs() throws {        do {            try ReposNotiDataHelper.createTable()            try DevsNotiDataHelper.createTable()        } catch {            throw DBError.connectionErr        }    }    }enum DBError: Error {    case connectionErr, insertErr, deleteErr, searchErr, updateErr, nilInData}protocol DataHelperProtocol {    associatedtype T    static func createTable() throws -> Void    static func insert(i: T) throws -> Int64    static func delete(i: T) throws -> Void    static func findAll() throws -> [T]?}// MARK: 开发者更新提醒typealias DBDevNoti = (    login: String,    lastReadId: String,    unRead: Int)struct DevsNotiDataHelper: DataHelperProtocol {    static let table = Table("devsNoti")    static let login = Expression<String>("login")    static let lastReadId = Expression<String>("lastReadId")    static let unRead = Expression<Int>("unRead")    typealias T = DBDevNoti        static func createTable() throws {        guard let db = DB.shared.BBDB else {            throw DBError.connectionErr        }        do {            let _ = try db.run(table.create(ifNotExists: true) { t in                t.column(login, unique: true)                t.column(lastReadId, defaultValue: "")                t.column(unRead, defaultValue: 0)            })        } catch _ {            throw DBError.connectionErr        }    } // end createTable        static func insert(i: DBDevNoti) throws -> Int64 {        guard let db = DB.shared.BBDB else {            throw DBError.connectionErr        }        let insert = table.insert(login <- i.login, lastReadId <- i.lastReadId, unRead <- i.unRead)        do {            let rowId = try db.run(insert)            guard rowId > 0 else {                throw DBError.insertErr            }            return rowId        } catch {            throw DBError.insertErr        }    } // end insert        static func delete(i: DBDevNoti) throws {        guard let db = DB.shared.BBDB else {            throw DBError.connectionErr        }        let query = table.filter(login == i.login)        do {            let tmp = try db.run(query.delete())            guard tmp == 1 else {                throw DBError.deleteErr            }        } catch {            throw DBError.deleteErr        }    } // end delete        static func find(sLogin: String) throws -> DBDevNoti? {        guard let db = DB.shared.BBDB else {            throw DBError.connectionErr        }        let query = table.filter(login == sLogin)        let items = try db.prepare(query)        for i in items {            return DBDevNoti(login: i[login], lastReadId: i[lastReadId], unRead: i[unRead])        }        return nil    } // end find        static func update(i: DBDevNoti) throws {        guard let db = DB.shared.BBDB else {            throw DBError.connectionErr        }        let query = table.filter(login == i.login)        do {            if try db.run(query.update(lastReadId <- i.lastReadId, unRead <- i.unRead)) > 0 {                            } else {                throw DBError.updateErr            }        } catch {            throw DBError.updateErr        }    } // end update        static func findAll() throws -> [DBDevNoti]? {        guard let db = DB.shared.BBDB else {            throw DBError.connectionErr        }        var arr = [DBDevNoti]()        let items = try db.prepare(table)        for i in items {            arr.append(DBDevNoti(login: i[login], lastReadId: i[lastReadId], unRead: i[unRead]))        }        return arr    } // end find all    }

使用时,可以在初始化时这么做:

// MARK: 初始化数据库et db = DB.shareddo {    try db.cTbs() // 创建表} catch {    }

使用的操作示例如下:

do {    if let fd = try ReposNotiDataHelper.find(sFullName: r.id) {        reposDic[fd.fullName] = fd.unRead    } else {        do {            let _ = try ReposNotiDataHelper.insert(i: DBRepoNoti(fullName: r.id, lastReadCommitSha: "", unRead: 0))            reposDic[r.id] = 0        } catch {            return reposDic        }    }} catch {    return reposDic}

macOS

范例

三栏结构

三栏结构架子搭建,代码如下:

import SwiftUIstruct SwiftPamphletApp: View {    var body: some View {        NavigationView {            SPSidebar()            Text("第二栏")            Text("第三栏")        }        .navigationTitle("Swift 小册子")        .toolbar {            ToolbarItem(placement: ToolbarItemPlacement.navigation) {                Button {                    NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)                } label: {                    Label("Sidebar", systemImage: "sidebar.left")                }            }        }    }}struct SPSidebar: View {    var body: some View {        List {            Section("第一组") {                NavigationLink("第一项", destination: SPList(title: "列表1"))                    .badge(3)                NavigationLink("第二项", destination: SPList(title: "列表2"))            }            Section("第二组") {                NavigationLink("第三项", destination: SPList(title: "列表3"))                NavigationLink("第四项", destination: SPList(title: "列表4"))            }        }        .listStyle(SidebarListStyle())        .frame(minWidth: 160)        .toolbar {            ToolbarItem {                Menu {                    Text("1")                    Text("2")                } label: {                    Label("Label", systemImage: "slider.horizontal.3")                }            }        }    }}struct SPList: View {    var title: String    @State var searchText: String = ""    var body: some View {        List(0..<3) { i in            Text("内容\(i)")        }        .toolbar(content: {            Button {                //            } label: {                Label("Add", systemImage: "plus")            }        })        .navigationTitle(title)        .navigationSubtitle("副标题")        .searchable(text: $searchText)    }}

显示效果如下:

全屏模式

将 NSSplitView 里的其中一个 NSView 设置为全屏和退出全屏的函数如下:

// MARK: - 获取 NSSplitViewControllerfunc splitVC() -> NSSplitViewController {    return ((NSApp.keyWindow?.contentView?.subviews.first?.subviews.first?.subviews.first as? NSSplitView)?.delegate as? NSSplitViewController)!}// MARK: - 全屏func fullScreen(isEnter: Bool) {    if isEnter == true {        // 进入全屏        let presOptions:        NSApplication.PresentationOptions = ([.autoHideDock,.autoHideMenuBar])        let optionsDictionary = [NSView.FullScreenModeOptionKey.fullScreenModeApplicationPresentationOptions : NSNumber(value: presOptions.rawValue)]                let v = splitVC().splitViewItems[2].viewController.view        v.enterFullScreenMode(NSScreen.main!, withOptions: optionsDictionary)        v.wantsLayer = true    } else {        // 退出全屏        NSApp.keyWindow?.contentView?.exitFullScreenMode()    } // end if}

使用方法

struct V: View {    @StateObject var appVM = AppVM()    @State var isEnterFullScreen: Bool = false // 全屏控制    var body: some View {        Button {            isEnterFullScreen.toggle()            appVM.fullScreen(isEnter: isEnterFullScreen)        } label: {            Image(systemName: isEnterFullScreen == true ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")        }    }}

共享菜单

struct ShareView: View {    var s: String    var body: some View {        Menu {            Button {                let p = NSPasteboard.general                p.declareTypes([.string], owner: nil)                p.setString(s, forType: .string)            } label: {                Text("拷贝链接")                Image(systemName: "doc.on.doc")            }            Divider()            ForEach(NSSharingService.sharingServices(forItems: [""]), id: \.title) { item in                Button {                    item.perform(withItems: [s])                } label: {                    Text(item.title)                    Image(nsImage: item.image)                }            }        } label: {            Text("分享")            Image(systemName: "square.and.arrow.up")        }    }}

剪贴板

添加和读取剪贴板的方法如下:

// 读取剪贴板内容let s = NSPasteboard.general.string(forType: .string)guard let s = s else {    return}print(s)// 设置剪贴板内容let p = NSPasteboard.generalp.declareTypes([.string], owner: nil)p.setString(s, forType: .string)

性能和构建

调试

session Debug Swift debugging with LLDB

编译器编译 swift 文件生成 .o 文件会有 __debug_info 段,其中有可以映射到源文件和行号的地址。debug 信息可以链接到 .dSYM 包。debug 信息链接器叫 dsymutil,dsymutil 可以为每个动态库、framework 或 dylib 和可执行文件打包一个 debug 信息存档(.dSYM 包)。

image 和路径怎么重映射。使用 image list nameOfFramework 来检查 LLDB 是否找到了我们应用程序里嵌入的第三方框架的 debug dSYM。使用 image lookup 0xMemoryAddressHere 获取当前地址更多信息。要重新映射源文件 .dSYM 路径,使用 settings set target.source-map old/path new/path。每个 .dSYM 都有一个 UUID.plist,我们可以在其中设置 DBGSourcePathRemapping 这个字典。

Xcode 14 新增 swift-healthcheck 命令,这个命令可以了解 module 为何导入失败。

LLDB 怎么找到 Swift module?每个 .dSYM 包都可以包含二级制 swift module,其中可能包含桥头文件、swift 接口文件 .swiftinterface,还有 debug 信息。静态存档不是由链接器生成的,需要向链接器注册 swift module,使用 ld ... -add-ast-path /path/to/My.swiftmodule ,动态库和可执行文件的话,Xcode 会自动完成此操作。可以使用 dsymutil 来 dump 你可执行文件的符号表,并用 grep 找 swiftmodule,命令是 dsymutil -s MyApp | grep .swiftmodule

内存管理

相关提案包括 SE-0349 Unaligned Loads and Stores from Raw MemorySE-0334 Pointer API Usability ImprovementsSE-0333 Expand usability of withMemoryRebound

Set 使用新的 Temporary Buffers 功能,让 intersect 速度提升了 4 到 6 倍。

链接器

Link fast: Improve build and launch time 详细讲了 Apple 今年怎么改进了 link,思路很棒,很值得学习。

Static linking 和 Dynamic linking ,也就是静态链接和动态链接。

静态链接就是链接各个编译好的源文件以及链接源文件和编译好的库文件,通过将函数名放到符号表,链接新文件时确定先前是否有包含的 undefined 符号,给函数的数据指令分配地址,最后生成一个有 TEXT、DATA、LINKEDIT 段的可执行文件。

今年 Apple 通过利用多核优势让静态链接快了两倍。

具体做法是,并行的拷贝文件内容。并行构建 LINKEDIT 段的各个不同部分。并行改变 UUID 计算和 codesigning 哈希。然后是提高 exports-trie 构建器的算法。使用最新的 Crypto 库利用硬件加速的优势加速 UUID 计算。提高其它静态库处理算法库,debug-notes 生成也更快了。

Apple 推荐静态库最佳实践是:

使用 -all_load-force_load 可以让 .a 文件像 .o 文件那样并行处理,不过开启这个选项需要先处理重复的符号。另外一个副作用是会将一些被判断无用的代码也被链接进来,使包体变大,因此开启之前可以先使用静态分析工具分析处理,这个过程定期做就行,不用放到每次编译过程中。演讲者推荐使用 -dead_strip 选项,但是这样做并没有真实去掉费代码,以后这些代码还是会被编译分析,如果只是暂时不用,可以先注释掉。

使用 -no_exported_symbols 选项。链接器生成的 LINKEDIT 段的一部分是 exports trie,这是一个前缀树,对所有导出的符号名称、地址和标志进行编码。动态库 是会导出符号的,但运行的二进制文件其实是不用这些符号的,因此可以用 -no_exported_symbols 选项来跳过 LINKEDIT 中 trie 数据结构的创建,这样链接起来就快多了。如果程序导出符号是一百万个,这个选项就可以减少 2 到 3 秒的时间。但需要注意的是,如果要加载插件链接回主程序就需要所有的导出的 trie 数据,无法用这个选项。

另外一个是 -no_deduplicate 选项。先前 Apple 给链接器加了个 pass 用来合并函数的指令相同,函数名不相同,这个 pass 会对每个函数的指令进行递归散列,用这种方式来找重复指令,这样做比较费 CPU,由于调试时其实是不需要关注包大小,因此可以加上 -no_deduplicate 选项来跳过这个 pass。

这些选项在 Xcode 的 Other Linker Flags 里进行设置即可。

动态库也就是 dylib,其它平台就是 DSO 或 DLL。 动态链接器不是将代码从库里考到主二进制里,而是记录某种承诺,记录从动态库中使用符号名称,还有库路径。这样做好处就是好复用动态库,不用拷贝多份。虚拟内存看到多进程使用相同动态库,就会重新给这个动态库用相同的物理内存页。

动态库好处是构建快了,启动加载慢了,多个动态库不光要加载,还要在启动时链接。也就是把链接成本从本地构建换到了用户启动时。动态库还有个缺点是基于动态库的程序会有更多的 dirty 页,因为静态链接时会把全局数据放到主程序同一个 DATA 页中,动态库的话,每个都在自己的 DATA 页中。

动态库工作的原理是,可执行的二进制会有不同权限的段,至少会有 TEXT、DATA 和 LINKEDIT。分段总是操作系统页大小的倍数。TEXT 段有执行的权限,CPU 可以将页上的字节当做机器代码指令。运行时,dyld 会根据每个段权限将可执行文件 mmap() 到内存,这些段是页大小和页对齐的,虚拟内存系统可以直接将程序或动态库文件设置为 VM 范围的备份存储。在这些页的内存访问前是不会被加载到 RAM 里,就会触发一个页 fault,导致 VM 去读取文件的子范围,将内存填充到需要 RAM 页中。光映射不够,还要用某种方式“wired up”或绑到动态库上。比如要调用动态库上的某个函数,会转换成调用 site,调用 site 成为一个在相同 TEXT 段合成的 sub 的调用,相对地址在构建时就知道了,就意味着可以正确的形成 BL 指令。这样做的好处是,stub 从 DATA 加载一个指针并跳到对应的位置,不用在运行时修改 TEXT 段,dyld 只在运行时改 DATA 段。dyld 所进行的修改很简单,就是在 DATA 段里设置了一个指针而已。

当 dyld 或应用程序的指针指向自己时要 rebase,ASLR 使 dyld 以随机地址加载动态库,内部指针不能在构建时设置,dyld 在启动时 rebase 这些指针,磁盘上,如果动态库在地址零出被加载,这些指针包含它们的目标地址。LINKEDIT 需要记录的就是每个重定位的位置。然后,dyld 只需将动态库的实际加载地址添加到每个 rebase 位置。还有种修改方式是绑定,绑定就是符号引用,符号存储在 LINKEDIT 中,dyld 在动态库的 exports tire 中找实际地址,然后 dyld 将该值存储在绑定指定的位置。

今年 Apple 发布了一个新的修改方式 chained fixups。较前面两种的优势就是可以使 LINKEDIT 更小。新格式只存储每个 DATA 页中第一个 fixup 位置和一个导入的符号列表。其它信息编码到 DATA 段。iOS 13.4 就开始支持了。

下面先说下 dyld 原理介绍。

dyld 从主可执行文件开始,解析 mach-o 找依赖动态库,对动态库进行 mmap()。然后对每个动态库进行遍历并解析 mach-o 结构,根据需要加载其它动态库。加载完毕,dyld 会查找所有需要绑定符号,并在修改时使用这些地址。最后修改完,dyld 自下而上运行初始化程序。先前做的优化是只要程序和动态库,dyld 很多步骤都可以在首次启动时被缓存。

今年 Apple 做了更多的优化,这个优化叫 page-in linking,就是 dyld 在启动时做的 DATA 页面修改放到 page-in 时,也可以理解为懒修改。以前,在 mmap() 区域的某些页面中第一次使用某些地址会触发内核读入该页面。现在如果它是一个数据页,内核会应用改页需要的修改。这种机制减少了 dirty 内存和启动时间。意味着 DATA_CONST 也是干净的,可以像 TEXT 页一样被 evicted 和重新创建,以减少内存压力。需要注意的是 page-in linking 只用于启动,dlopen() 不支持。你看,Apple 优化启动的思路也是按需加载。

Apple 还提供了追踪 dyld 运行情况的 dyld_usage 工具。检查磁盘和 dyld 缓存中的二进制文件的 dyld_info 工具。

2022年 Apple 性能更新

Improve app size and runtime performance

今年苹果通过更有效的检查 Swift 协议,使 OC 消息发送调用更小,使 autorelease elision 更快更小这几个个方面来让 App 体积更小,性能更高。

Swift 协议检查。

一个协议通过 as 操作符检查传递值是否符合协议,这种检查会在编译器的构建时间被优化掉,所以往往需要在运行时借助之前计算协议检查元数据来看对象是否真的符合了协议。一些元数据是在编译时建的,但还有很多元数据只能在启动时建立,特别是使用泛型时。协议多了,会增加耗时,差不多会多一半启动时间。

今年 Apple 推出新的 Swift 运行时,可以提前计算 Swift 协议元数据,作为 App 可执行文件和它在启动时使用的任何动态库的 dyld 闭包的一部分。这个是在系统上的,因此,只要是使用了今年最新系统的 App 都会享受这个优化,可以理解为,新系统上启动老 App 也会快些。

消息发送。

Xcode 14 中新的编译器和链接器已经将 ARM64 的消息发送调用从 12 字节减少到 8 字节。因此如果你的 App 都是 OC 代码的话,使用 Xcode 14 编出来的二进制文件可以少 2%。老系统也有效。

使用 objc_stubs_small 选项可以只优化大小,获得最大的大小优化。objc_msgSend 调动有 8 个字节指令,也就是2个指令是专门用来准备 selector 的,对于任何特定的 selector,总是相同的代码,由于始终是相同的代码,那么就可以对其共享,每个 selector 只 emit 一次,而不是每次发送消息时都 emit。共享这段代码地方是一个叫 selector stub 的函数。

ARC 会在编译器插入大量的 c 的 retain/release 函数调用。这些调用遵守平台应用二进制接口(ABI)所定义的 c 语言 call convention。也就意味着我们要更多代码来完成这些调用,用来传递正确寄存器的指针。Apple 今年推出了自定义的 call convention 根据指针位置,适时使用正确变量而不用移动它,从而摆脱了调用里的多余代码。Apple 果然是坚持用户体验优先,为了更好体验不惜修改 c 的 ABI。

autorelease elision 。

App 今年对 objc 运行时进行了修改,使 autorelease elision 更小更快。deployment target 为 iOS 16 今年新系统时才可享用哦。

Apple 怎么做的呢?

ARC 在调用方插入一个 retain,在被调用的函数中插入一个 release。当我们返回我们的临时对象时,我们需要在函数中先释放它,因为它要离开 scope。在它还没有任何其它引用时还不能这么做,不然返回前他就会被销毁。Apple 现在使用一个新的 convention ,让其可以返回临时对象。做法是当返回一个自动释放值,编译器会发出一个特殊标记,这个标记会告诉运行时这是符合自动释放条件的。它的后面是 retain,我们会在后面执行。获取返回地址,也就是一个指针,将它先保存起来,然后离开运行时的自动释放调用。在运行时,可以将保留时得到的指正和先前做自动释放时保存的指针进行比较,这样标记指令不再是数据之间的比较,比较指针内存访问少。比较成功就可以省去 autorelease/retain。

autorelease elision 的优化同样也可以减少 2% 大小。感谢 Apple 为了用户和开发者 OKR 的付出。

Combine

介绍

Combine 是什么?

WWDC 2019苹果推出Combine,Combine是一种响应式编程范式,采用声明式的Swift API。

Combine 写代码的思路是你写代码不同于以往命令式的描述如何处理数据,Combine 是要去描述好数据会经过哪些逻辑运算处理。这样代码更好维护,可以有效的减少嵌套闭包以及分散的回调等使得代码维护麻烦的苦恼。

声明式和过程时区别可见如下代码:

// 所有数相加// 命令式思维func sum1(arr: [Int]) -> Int {  var sum: Int = 0  for v in arr {    sum += v  }  return sum}// 声明式思维func sum2(arr: [Int]) -> Int {  return arr.reduce(0, +)}

Combine 主要用来处理异步的事件和值。苹果 UI 框架都是在主线程上进行 UI 更新,Combine 通过 Publisher 的 receive 设置回主线程更新UI会非常的简单。

已有的 RxSwift 和 ReactiveSwift 框架和 Combine 的思路和用法类似。

Combine 的三个核心概念

  • 发布者
  • 订阅者
  • 操作符

简单举个发布数据和类属性绑定的例子:

let pA = Just(0)let _ = pA.sink { v in    print("pA is: \(v)")}let pB = [7,90,16,11].publisherlet _ = pB    .sink { v in        print("pB: \(v)")    }class AClass {    var p: Int = 0 {        didSet {            print("property update to \(p)")        }    }}let o = AClass()let _ = pB.assign(to: \.p, on: o)

Combine 资料

官方文档链接 Combine | Apple Developer Documentation 。还有 Using Combine 这里有大量使用示例,内容较全。官方讨论Combine的论坛 Topics tagged combine 。StackOverflow上相关问题 Newest ‘combine’ Questions

WWDC上关于Combine的Session如下:

和Combine相关的Session:

使用说明

publisher

publisher 是发布者,sink 是订阅者

import Combinevar cc = Set<AnyCancellable>()struct S {    let p1: String    let p2: String}[S(p1: "1", p2: "one"), S(p1: "2", p2: "two")]    .publisher    .print("array")    .sink {        print($0)    }    .store(in: &cc)

输出

 array: receive subscription: ([戴铭的开发小册子.AppDelegate.(unknown context at $10ac82d20).(unknown context at $10ac82da4).S(p1: "1", p2: "one"), 戴铭的开发小册子.AppDelegate.(unknown context at $10ac82d20).(unknown context at $10ac82da4).S(p1: "2", p2: "two")])array: request unlimitedarray: receive value: (S(p1: "1", p2: "one"))S(p1: "1", p2: "one")array: receive value: (S(p1: "2", p2: "two"))S(p1: "2", p2: "two")array: receive finished

Just

Just 是发布者,发布的数据在初始化时完成

import Combinevar cc = Set<AnyCancellable>()struct S {    let p1: String    let p2: String}let pb = Just(S(p1: "1", p2: "one"))pb    .print("pb")    .sink {        print($0)    }    .store(in: &cc)

输出

pb: receive subscription: (Just)pb: request unlimitedpb: receive value: (S(p1: "1", p2: "one"))S(p1: "1", p2: "one")pb: receive finished

PassthroughSubject

PassthroughSubject 可以传递多值,订阅者可以是一个也可以是多个,send 指明 completion 后,订阅者就没法接收到新发送的值了。

import Combinevar cc = Set<AnyCancellable>()struct S {    let p1: String    let p2: String}enum CError: Error {    case aE, bE}let ps1 = PassthroughSubject<S, CError>()ps1    .print("ps1")    .sink { c in        print("completion:", c) // send 了 .finished 后会执行    } receiveValue: { s in        print("receive:", s)            }    .store(in: &cc)ps1.send(S(p1: "1", p2: "one"))ps1.send(completion: .failure(CError.aE)) // 和 .finished 一样后面就不会发送了ps1.send(S(p1: "2", p2: "two"))ps1.send(completion: .finished)ps1.send(S(p1: "3", p2: "three"))// 多个订阅者let ps2 = PassthroughSubject<String, Never>()ps2.send("one") // 订阅之前 send 的数据没有订阅者可以接收ps2.send("two")let sb1 = ps2    .print("ps2 sb1")    .sink { s in    print(s)    }ps2.send("three") // 这个 send 的值会被 sb1let sb2 = ps2    .print("ps2 sb2")    .sink { s in        print(s)    }ps2.send("four") // 这个 send 的值会被 sb1 和 sb2 接受sb1.store(in: &cc)sb2.store(in: &cc)ps2.send(completion: .finished)

输出

ps1: receive subscription: (PassthroughSubject)ps1: request unlimitedps1: receive value: (S(p1: "1", p2: "one"))receive: S(p1: "1", p2: "one")ps1: receive error: (aE)completion: failure(戴铭的开发小册子.AppDelegate.(unknown context at $10b15ce10).(unknown context at $10b15cf3c).CError.aE)ps2 sb1: receive subscription: (PassthroughSubject)ps2 sb1: request unlimitedps2 sb1: receive value: (three)threeps2 sb2: receive subscription: (PassthroughSubject)ps2 sb2: request unlimitedps2 sb1: receive value: (four)fourps2 sb2: receive value: (four)fourps2 sb1: receive finishedps2 sb2: receive finished

Empty

import Combinevar cc = Set<AnyCancellable>()struct S {    let p1: String    let p2: String}let ept = Empty<S, Never>() // 加上 completeImmediately: false 后面即使用 replaceEmpty 也不会接受值ept    .print("ept")    .sink { c in        print("completion:", c)    } receiveValue: { s in        print("receive:", s)    }    .store(in: &cc)ept.replaceEmpty(with: S(p1: "1", p2: "one"))    .sink { c in        print("completion:", c)    } receiveValue: { s in        print("receive:", s)    }    .store(in: &cc)

输出

ept: receive subscription: (Empty)ept: request unlimitedept: receive finishedcompletion: finishedreceive: S(p1: "1", p2: "one")completion: finished

CurrentValueSubject

CurrentValueSubject 的订阅者可以收到订阅时已发出的那条数据

import Combinevar cc = Set<AnyCancellable>()let cs = CurrentValueSubject<String, Never>("one")cs.send("two")cs.send("three")let sb1 = cs    .print("cs sb1")    .sink {        print($0)    }    cs.send("four")cs.send("five")let sb2 = cs    .print("cs sb2")    .sink {        print($0)    }cs.send("six")sb1.store(in: &cc)sb2.store(in: &cc)

输出

cs sb1: receive subscription: (CurrentValueSubject)cs sb1: request unlimitedcs sb1: receive value: (three)threecs sb1: receive value: (four)fourcs sb1: receive value: (five)fivecs sb2: receive subscription: (CurrentValueSubject)cs sb2: request unlimitedcs sb2: receive value: (five)fivecs sb1: receive value: (six)sixcs sb2: receive value: (six)sixcs sb1: receive cancelcs sb2: receive cancel

removeDuplicates

使用 removeDuplicates,重复的值就不会发送了。

import Combinevar cc = Set<AnyCancellable>()let pb = ["one","two","three","three","four"]    .publisherlet sb = pb    .print("sb")    .removeDuplicates()    .sink {        print($0)    }    sb.store(in: &cc)

输出

sb: receive subscription: (["one", "two", "three", "three", "four"])sb: request unlimitedsb: receive value: (one)onesb: receive value: (two)twosb: receive value: (three)threesb: receive value: (three)sb: request max: (1) (synchronous)sb: receive value: (four)foursb: receive finished

flatMap

flatMap 能将多个发布者的值打平发送给订阅者

import Combinevar cc = Set<AnyCancellable>()struct S {    let p: AnyPublisher<String, Never>}let s1 = S(p: Just("one").eraseToAnyPublisher())let s2 = S(p: Just("two").eraseToAnyPublisher())let s3 = S(p: Just("three").eraseToAnyPublisher())let pb = [s1, s2, s3].publisher    let sb = pb    .print("sb")    .flatMap {        $0.p    }    .sink {        print($0)    }sb.store(in: &cc)

输出

sb: receive subscription: ([戴铭的开发小册子.AppDelegate.(unknown context at $101167070).(unknown context at $1011670f4).S(p: AnyPublisher), 戴铭的开发小册子.AppDelegate.(unknown context at $101167070).(unknown context at $1011670f4).S(p: AnyPublisher), 戴铭的开发小册子.AppDelegate.(unknown context at $101167070).(unknown context at $1011670f4).S(p: AnyPublisher)])sb: request unlimitedsb: receive value: (S(p: AnyPublisher))onesb: receive value: (S(p: AnyPublisher))twosb: receive value: (S(p: AnyPublisher))threesb: receive finished

append

append 会在发布者发布结束后追加发送数据,发布者不结束,append 的数据不会发送。

import Combinevar cc = Set<AnyCancellable>()let pb = PassthroughSubject<String, Never>()let sb = pb    .print("sb")    .append("five", "six")    .sink {        print($0)    }sb.store(in: &cc)pb.send("one")pb.send("two")pb.send("three")pb.send(completion: .finished)

输出

sb: receive subscription: ([戴铭的开发小册子.AppDelegate.(unknown context at $101167070).(unknown context at $1011670f4).S(p: AnyPublisher), 戴铭的开发小册子.AppDelegate.(unknown context at $101167070).(unknown context at $1011670f4).S(p: AnyPublisher), 戴铭的开发小册子.AppDelegate.(unknown context at $101167070).(unknown context at $1011670f4).S(p: AnyPublisher)])sb: request unlimitedsb: receive value: (S(p: AnyPublisher))onesb: receive value: (S(p: AnyPublisher))twosb: receive value: (S(p: AnyPublisher))threesb: receive finished

prepend

prepend 会在发布者发布前先发送数据,发布者不结束也不会受影响。发布者和集合也可以被打平发布。

import Combinevar cc = Set<AnyCancellable>()let pb1 = PassthroughSubject<String, Never>()let pb2 = ["nine", "ten"].publisherlet sb = pb1    .print("sb")    .prepend(pb2)    .prepend(["seven","eight"])    .prepend("five", "six")    .sink {        print($0)    }sb.store(in: &cc)pb1.send("one")pb1.send("two")pb1.send("three")

输出

fivesixseveneightninetensb: receive subscription: (PassthroughSubject)sb: request unlimitedsb: receive value: (one)onesb: receive value: (two)twosb: receive value: (three)threesb: receive cancel

merge

订阅者可以通过 merge 合并多个发布者发布的数据

import Combinevar cc = Set<AnyCancellable>()let ps1 = PassthroughSubject<String, Never>()let ps2 = PassthroughSubject<String, Never>()let sb1 = ps1.merge(with: ps2)    .sink {        print($0)    }    ps1.send("one")ps1.send("two")ps2.send("1")ps2.send("2")ps1.send("three")sb1.store(in: &cc)

输出

sb1: receive subscription: (Merge)sb1: request unlimitedsb1: receive value: (one)onesb1: receive value: (two)twosb1: receive value: (1)1sb1: receive value: (2)2sb1: receive value: (three)threesb1: receive cancel

zip

zip 会合并多个发布者发布的数据,只有当多个发布者都发布了数据后才会组合成一个数据给订阅者。

import Combinevar cc = Set<AnyCancellable>()let ps1 = PassthroughSubject<String, Never>()let ps2 = PassthroughSubject<String, Never>()let ps3 = PassthroughSubject<String, Never>()let sb1 = ps1.zip(ps2, ps3)    .print("sb1")    .sink {        print($0)    }    ps1.send("one")ps1.send("two")ps1.send("three")ps2.send("1")ps2.send("2")ps1.send("four")ps2.send("3")ps3.send("一")sb1.store(in: &cc)

输出

sb1: receive subscription: (Zip)sb1: request unlimitedsb1: receive value: (("one", "1", "一"))("one", "1", "一")sb1: receive cancel

combineLatest

combineLatest 会合并多个发布者发布的数据,只有当多个发布者都发布了数据后才会触发合并,合并每个发布者发布的最后一个数据。

import Combinevar cc = Set<AnyCancellable>()        let ps1 = PassthroughSubject<String, Never>()let ps2 = PassthroughSubject<String, Never>()let ps3 = PassthroughSubject<String, Never>()let sb1 = ps1.combineLatest(ps2, ps3)    .print("sb1")    .sink {        print($0)    }    ps1.send("one")ps1.send("two")ps1.send("three")ps2.send("1")ps2.send("2")ps1.send("four")ps2.send("3")ps3.send("一")ps3.send("二")sb1.store(in: &cc)

输出

sb1: receive subscription: (CombineLatest)sb1: request unlimitedsb1: receive value: (("four", "3", "一"))("four", "3", "一")sb1: receive value: (("four", "3", "二"))("four", "3", "二")sb1: receive cancel

Scheduler

Scheduler 处理队列。

import Combinevar cc = Set<AnyCancellable>()        let sb1 = ["one","two","three"].publisher    .print("sb1")    .subscribe(on: DispatchQueue.global())    .handleEvents(receiveOutput: {        print("receiveOutput",$0)    })    .receive(on: DispatchQueue.main)    .sink {        print($0)    }sb1.store(in: &cc)

输出

sb1: receive subscription: ([1, 2, 3])sb1: request unlimitedsb1: receive value: (1)receiveOutput 1sb1: receive value: (2)receiveOutput 2sb1: receive value: (3)receiveOutput 3sb1: receive finished123

使用场景

网络请求

网络URLSession.dataTaskPublisher使用例子如下:

let req = URLRequest(url: URL(string: "http://www.starming.com")!)let dpPublisher = URLSession.shared.dataTaskPublisher(for: req)

一个请求Github接口并展示结果的例子

//// CombineSearchAPI.swift// SwiftOnly (iOS)//// Created by Ming Dai on 2021/11/4.//import SwiftUIimport Combinestruct CombineSearchAPI: View {  var body: some View {    GithubSearchView()  }}// MARK: Github Viewstruct GithubSearchView: View {  @State var str: String = "Swift"  @StateObject var ss: SearchStore = SearchStore()  @State var repos: [GithubRepo] = []  var body: some View {    NavigationView {      List {        TextField("输入:", text: $str, onCommit: fetch)        ForEach(self.ss.repos) { repo -> GithubRepoCell in          GithubRepoCell(repo: repo)        }      }      .navigationTitle("搜索")    }    .onAppear(perform: fetch)  }     private func fetch() {    self.ss.search(str: self.str)  }}struct GithubRepoCell: View {  let repo: GithubRepo  var body: some View {    VStack(alignment: .leading, spacing: 20) {      Text(self.repo.name)      Text(self.repo.description)    }  }}// MARK: Github Servicestruct GithubRepo: Decodable, Identifiable {  let id: Int  let name: String  let description: String}struct GithubResp: Decodable {  let items: [GithubRepo]}final class GithubSearchManager {  func search(str: String) -> AnyPublisher<GithubResp, Never> {    guard var urlComponents = URLComponents(string: "https://api.github.com/search/repositories") else {      preconditionFailure("链接无效")    }    urlComponents.queryItems = [URLQueryItem(name: "q", value: str)]         guard let url = urlComponents.url else {      preconditionFailure("链接无效")    }    let sch = DispatchQueue(label: "API", qos: .default, attributes: .concurrent)         return URLSession.shared      .dataTaskPublisher(for: url)      .receive(on: sch)      .tryMap({ element -> Data in        print(String(decoding: element.data, as: UTF8.self))        return element.data      })      .decode(type: GithubResp.self, decoder: JSONDecoder())      .catch { _ in        Empty().eraseToAnyPublisher()      }      .eraseToAnyPublisher()  }}final class SearchStore: ObservableObject {  @Published var query: String = ""  @Published var repos: [GithubRepo] = []  private let searchManager: GithubSearchManager  private var cancellable = Set<AnyCancellable>()     init(searchManager: GithubSearchManager = GithubSearchManager()) {    self.searchManager = searchManager    $query      .debounce(for: .milliseconds(500), scheduler: RunLoop.main)      .flatMap { query -> AnyPublisher<[GithubRepo], Never> in        return searchManager.search(str: query)          .map {            $0.items          }          .eraseToAnyPublisher()      }      .receive(on: DispatchQueue.main)      .assign(to: \.repos, on: self)      .store(in: &cancellable)  }  func search(str: String) {    self.query = str  }}

抽象基础网络能力,方便扩展,代码如下:

//// CombineAPI.swift// SwiftOnly (iOS)//// Created by Ming Dai on 2021/11/4.//import SwiftUIimport Combinestruct CombineAPI: View {  var body: some View {    RepListView(vm: .init())  }}struct RepListView: View {  @ObservedObject var vm: RepListVM     var body: some View {    NavigationView {      List(vm.repos) { rep in        RepListCell(rep: rep)      }      .alert(isPresented: $vm.isErrorShow) { () -> Alert in        Alert(title: Text("出错了"), message: Text(vm.errorMessage))      }      .navigationBarTitle(Text("仓库"))    }    .onAppear {      vm.apply(.onAppear)    }  }}struct RepListCell: View {  @State var rep: RepoModel  var body: some View {    HStack() {      VStack() {        AsyncImage(url: URL(string: rep.owner.avatarUrl ?? ""), content: { image in          image            .resizable()            .aspectRatio(contentMode: .fit)            .frame(width: 100, height: 100)        },        placeholder: {          ProgressView()            .frame(width: 100, height: 100)        })        Text("\(rep.owner.login)")          .font(.system(size: 10))      }      VStack(alignment: .leading, spacing: 10) {        Text("\(rep.name)")          .font(.title)        Text("\(rep.stargazersCount)")          .font(.title3)        Text("\(String(describing: rep.description ?? ""))")        Text("\(String(describing: rep.language ?? ""))")          .font(.title3)      }      .font(.system(size: 14))    }       }}// MARK: Repo View Modelfinal class RepListVM: ObservableObject, UnidirectionalDataFlowType {  typealias InputType = Input  private var cancellables: [AnyCancellable] = []     // Input  enum Input {    case onAppear  }  func apply(_ input: Input) {    switch input {    case .onAppear:      onAppearSubject.send(())    }  }  private let onAppearSubject = PassthroughSubject<Void, Never>()     // Output  @Published private(set) var repos: [RepoModel] = []  @Published var isErrorShow = false  @Published var errorMessage = ""  @Published private(set) var shouldShowIcon = false     private let resSubject = PassthroughSubject<SearchRepoModel, Never>()  private let errSubject = PassthroughSubject<APISevError, Never>()     private let apiSev: APISev     init(apiSev: APISev = APISev()) {    self.apiSev = apiSev    bindInputs()    bindOutputs()  }     private func bindInputs() {    let req = SearchRepoRequest()    let resPublisher = onAppearSubject      .flatMap { [apiSev] in        apiSev.response(from: req)          .catch { [weak self] error -> Empty<SearchRepoModel, Never> in            self?.errSubject.send(error)            return .init()          }      }    let resStream = resPublisher      .share()      .subscribe(resSubject)         // 其它异步事件,比如日志等操作都可以做成Stream加到下面数组内。    cancellables += [resStream]  }     private func bindOutputs() {    let repStream = resSubject      .map {        $0.items      }      .assign(to: \.repos, on: self)    let errMsgStream = errSubject      .map { error -> String in        switch error {        case .resError: return "network error"        case .parseError: return "parse error"        }      }      .assign(to: \.errorMessage, on: self)    let errStream = errSubject      .map { _ in        true      }      .assign(to: \.isErrorShow, on: self)    cancellables += [repStream,errStream,errMsgStream]  }   }protocol UnidirectionalDataFlowType {  associatedtype InputType  func apply(_ input: InputType)}// MARK: Repo Request and Modelsstruct SearchRepoRequest: APIReqType {  typealias Res = SearchRepoModel     var path: String {    return "/search/repositories"  }  var qItems: [URLQueryItem]? {    return [      .init(name: "q", value: "Combine"),      .init(name: "order", value: "desc")    ]  }}struct SearchRepoModel: Decodable {  var items: [RepoModel]}struct RepoModel: Decodable, Hashable, Identifiable {  var id: Int64  var name: String  var fullName: String  var description: String?  var stargazersCount: Int = 0  var language: String?  var owner: OwnerModel}struct OwnerModel: Decodable, Hashable, Identifiable {  var id: Int64  var login: String  var avatarUrl: String?}// MARK: API Request Fundationprotocol APIReqType {  associatedtype Res: Decodable  var path: String { get }  var qItems: [URLQueryItem]? { get }}protocol APISevType {  func response<Request>(from req: Request) -> AnyPublisher<Request.Res, APISevError> where Request: APIReqType}final class APISev: APISevType {  private let rootUrl: URL  init(rootUrl: URL = URL(string: "https://api.github.com")!) {    self.rootUrl = rootUrl  }     func response<Request>(from req: Request) -> AnyPublisher<Request.Res, APISevError> where Request : APIReqType {    let path = URL(string: req.path, relativeTo: rootUrl)!    var comp = URLComponents(url: path, resolvingAgainstBaseURL: true)!    comp.queryItems = req.qItems    print(comp.url?.description ?? "url wrong")    var req = URLRequest(url: comp.url!)    req.addValue("application/json", forHTTPHeaderField: "Content-Type")         let de = JSONDecoder()    de.keyDecodingStrategy = .convertFromSnakeCase    return URLSession.shared.dataTaskPublisher(for: req)      .map { data, res in        print(String(decoding: data, as: UTF8.self))        return data      }      .mapError { _ in        APISevError.resError      }      .decode(type: Request.Res.self, decoder: de)      .mapError(APISevError.parseError)      .receive(on: RunLoop.main)      .eraseToAnyPublisher()  }}enum APISevError: Error {  case resError  case parseError(Error)}

KVO

例子如下:

private final class KVOObject: NSObject {  @objc dynamic var intV: Int = 0  @objc dynamic var boolV: Bool = false}let o = KVOObject()let _ = o.publisher(for: \.intV)  .sink { v in    print("value : \(v)")  }

通知

使用例子如下:

extension Notification.Name {    static let noti = Notification.Name("nameofnoti")}let notiPb = NotificationCenter.default.publisher(for: .noti, object: nil)        .sink {            print($0)        }

退到后台接受通知的例子如下:

class A {  var storage = Set<AnyCancellable>()     init() {    NotificationCenter.default.publisher(for: UIWindowScene.didEnterBackgroundNotification)      .sink { _ in        print("enter background")      }      .store(in: &self.storage)  }}

Timer

使用方式如下:

let timePb = Timer.publish(every: 1.0, on: RunLoop.main, in: .default)let timeSk = timePb.sink { r in    print("r is \(r)")}let cPb = timePb.connect()

Concurrency

介绍

Swift Concurrency 是什么?

ABI 稳定后,Swift 的核心团队可以开始关注 Swift 语言一直缺失的原生并发能力了。最初是由 Chris Lattner 在17年发的 Swift并发宣言 ,从此开阔了大家的眼界。后来 Swift Evolution 社区讨论了十几个提案,几十个方案,以及几百页的设计文件,做了大量的改进,社区中用户积极的参与反馈,Chris 也一直在 Evolution 中积极的参与设计。

Swift Concurrency 的实现用了 LLVM的协程 把 async/await 函数转换为基于回调的代码,这个过程发生在编译后期,这个阶段你的代码都没法辨识了。异步的函数被实现为 coroutines,在每次异步调用时,函数被分割成可调用的函数部分和后面恢复的部分。coroutine 拆分的过程发生在生成LLVM IR阶段。Swift使用了哪些带有自定义调用约定的函数保证尾部调用,并专门为Swift进行了调整。

Swift Concurrency 不是建立在 GCD 上,而是使用的一个全新的线程池。GCD 中启动队列工作会很快在提起线程,一个队列阻塞了线程,就会生成一个新线程。基于这种机制 GCD 线程数很容易比 CPU 核心数量多,线程多了,线程就会有大量的调度开销,大量的上下文切换,会使 CPU 运行效率降低。而 Swift Concurrency 的线程数量不会超过 CPU 内核,将上下文切换放到同一个线程中去做。为了实现线程不被阻塞,需要通过语言特性来做。做法是,每个线程都有一个堆栈记录函数调用情况,一个函数占一个帧。函数返回后,这个函数所占的帧就会从堆栈弹出。await 的 async 函数被作为异步帧保存在堆上等待恢复,而不阻碍其它函数入栈执行。在 await 后运行的代码叫 continuation,continuation 会在要恢复时放回到线程的堆栈里。异步帧会根据需要放回栈上。在一个异步函数中调用同步代码将添加帧到线程的堆栈中。这样线程就能够一直向前跑,而不用创建更多线程减少调度。

Douglas 在 Swift 论坛里发的 Swift Concurrency 下个版本的规划贴 Concurrency in Swift 5 and 6 ,论坛里还有一个帖子是专门用来 征集Swift Concurrency意见 的,帖子本身列出了 Swift Concurrency 相关的所有提案,也提出欢迎有新提案发出来,除了这些提案可以看外,帖子回复目前已经过百,非常热闹,可以看出大家对 Swift Concurrency 的关注度相当的高。

非常多的人参与了 Swift Concurrency 才使其看起来和用起来那么简单。Doug Gregor 在参与 John Sundell 的播客后,发了很多条推聊 Swift Concurrency,可以看到参与的人非常多,可见背后付出的努力有多大。下面我汇总了 Doug Gregor 在推上发的一些信息,你通过这些信息也可以了解 Swift Concurrency 幕后信息,所做的事和负责的人。

@pathofshrines 是 Swift Concurrency 整体架构师,包括低级别运行时和编译器相关细节。 @illian 是 async sequences、stream 和 Fundation 的负责人。 @optshiftk 对 UI 和并发交互的极好的洞察力带来了很棒的 async 接口, @phausler 带来了 async sequences。Arnold Schwaighofer、 @neightchan@typesanitizer 还有 Tim Northover 实现了 async calling convention。

@ktosopl 有很深厚的 actor、分布式计算和 Swift-on-Server 经验,带来了 actor 系统。Erik Eckstein 为 async 函数和actors建立了关键的优化和功能。

SwiftUI是 @ricketson_@luka_bernardi 完成的async接口。async I/O的接口是 @Catfish_Man 完成的。 @slava_pestov 处理了 Swift 泛型问题,还指导其他人编译器实现的细节。async 重构工具是Ben Barham 做的。大量代码移植到 async 是由 @AirspeedSwift 领导,由 Angela Laar,Clack Cole,Nicole Jacques 和 @mishaldshah 共同完成的。

@lorentey 负责 Swift 接口的改进。 @jckarter 有着敏锐的语言设计洞察力,带来了语言设计经验和编译器及运行时实现技能。 @mikeash 也参与了运行时开发中。操作系统的集成是 @rokhinip 完成的, @chimz 提供了关于 Dispatch 和 OS 很好的建议,Pavel Yaskevich 和
@hollyborla 进行了并发所需要关键类型检查器的改进。 @kastiglione 、Adrian Prantl和 @fred_riss 实现了调试。 @etcwilde@call1cc 实现了语义模型中的重要部分。

@evonox 负责了服务器Linux 的支持。 @compnerd 将 Swift Concurrency 移植到了 Windows。

Swift Concurrency 模型简单,细节都被隐藏了,比 Kotlin 和 C++的 Coroutine 接口要简洁很多。比如 Task 接口形式就很简洁。Swift Concurrency 大体可分为 async/await、Async Sequences、结构化并发和 Actors。

async/await

通过类似 throws 语法的 async 来指定函数为异步函数,异步函数才能够使用 await,使用异步函数要用 await。await 修饰在 suspension point 时当前线程可以让给其它任务执行,而不用阻塞当前线程,等 await 后面的函数执行完成再回来继续执行,这里需要注意的是回来执行不一定是在离开时的线程上。async/await 提案是 SE-0296 。如果想把现有的异步开发带到 async/await 世界,请使用 withCheckedThrowingContinuation。

async/await 还有一个非常明显的好处,就是不会再有[weak self] dance 了。

Async Sequences

AsyncSequence 的使用方式是 for-await-in 和 for-try-await-in,系统提供了一些接口,如下:

  • FileHandle.standardInput.bytes.lines
  • URL.lines
  • URLSession.shared.data(from: URL)
  • let (localURL, _ ) = try await session.download(from: url) 下载和get请求数据区别是需要边请求边存储数据以减少内存占用
  • let (responseData, response) = try await session.upload(for: request, from: data)
  • URLSession.shared.bytes(from: URL)
  • NotificationCenter.default.notifications

结构化并发

使用这些接口可以一边接收数据一边进行显示,AsyncSequence 的提案是 SE-0298 (Swift 5.5可用)。AsyncStream 是创建自己异步序列的最简单的方法,处理迭代、取消和缓冲。AsyncStream 正在路上,提案是 SE-0314

Task 为一组并发任务创建一个运行环境,async let 可以让任务并发执行,结构化并发(Structured concurrency,提案在路上 SE-0304 )withTaskGroup 中 group.async 可以将并发任务进行分组。

Actors

我们写的程序会在进程中被拆成一个一个小指令,这些指令会在某刻会一个接一个同步的或者并发的执行。系统会用多个线程执行并行的任务,执行顺序是调度器来管理的,现代多核可以同时处理多个线程,当一个资源在多个线程上同时被更改时就会出问题。并发任务对数据资源操作容易造成数据竞争,以前需要手动放到串行队列、使用锁、调度屏障或 Atomics 的方式来避免。以前处理容易导致昂贵的上下文切换,过多线程容易导致线程爆炸,容易意外阻断线程导致后面代码没法执行,多任务相互的等待造成了死锁,block 和内存引用容易出错等等问题。

现在 Swift Concurrency 可以通过 actor 来创建一个区域,在这个区域会自动进行数据安全保护,保证一定时间只有一个线程访问里面数据,防止数据竞争。actor 内部对成员访问是同步的,成员默认是隔离的,actor 外部对 actor 内成员的访问只能是异步的,隐式同步以防止数据竞争。MainActor 继承自能确保全局唯一实例的 GlobalActor,保证任务在主线程执行,这样你就可以抛弃掉在你的 ViewModel 里写 DispatchQueue.main.async 了。

Actors 的概念通常被用于分布式计算,Actor 模型参看 Wikipedia 里的详细解释,Swift 中的实现效果也非常的理想。Actors 的提案 SE-0306 已在 Swift 5.5落实。

很多语言都支持 actors 还有 async/await,实现的方式也类似,actor 使用的不是锁,而是用的 async/await 这样能够在一个线程中切换上下文来避免线程空闲的线程模型。actor 还利用编译器,提前做会引起并发问题的检查。

actor 是遵循 Sendable 协议的,只有结构体和 final 类才能够遵循 Sendable,继承于 Sendable 协议的 Excutor 协议表示方法本身,SerialExecutor 表示以串行方式执行。actor 使用 C++写的,源码在 这里 ,可以看到 actor 主要是通过控制各个 job 执行的状态的管理器。job 执行优先级来自 Task 对象,排队时需要确保高优 job 先被执行。全局 Executor 用来为 job 排队,通知 actor 拥有或者放弃线程,实现在 这里 。由于等待而放弃当前线程让其他 actor 执行的 actor,在收到全局 Executor 创建一个新的 job 的通知,使其可以进入一个可能不同线程,这个过程就是并发模型中描述的 Actor Reentrancy。

Distributed Actors

actor 具有分布式形式工作能力,也就是可以 RPC 通过网络读取和写入属性或者调用方法。设计为保护在跨多个进程中的低级别数据竞争。Distributed actors 可以在两个进程间建立通道,隔离它们状态,并在它们之间异步通信。每个 distributed actors 在 actor 初始化时分配一个不可以手动创建的 id,在它所属整个 distributed actor 系统中唯一标识所指 actor,这样无论 distributed actors 在哪,都可以以相同的方式与之交互。

session Meet distributed actors in Swift 。这里有个 distributed actors 的代码示例 TicTacFish: Implementing a game using distributed actors

SE-0336 Distributed Actor IsolationSE-0344 Distributed Actor Runtime 是两个 Distributed Actors 的相关提案。

Apple 提供了一个参考的服务端 cluster actor 系统实现示例,cluster actor system implementation

相关提案

所有相关提案清单如下:

学习路径

如果打算尝试 Swift Concurrency 的话,按照先后顺序,可以先看官方手册介绍文章 Concurrency 。再看 Meet async/await in Swift 这个Session,了解背后原理看 Explore structured concurrency in Swift 。动手照着试示例代码,看Paul的 Swift Concurrency by Example 这个系列。接着看 Protect mutable state with Swift actors 来了解 actors 怎么防止数据竞争。通过 Discover concurrency in SwiftUI 看 concurrency 如何在 SwiftUI 中使用, Use async/await with URLSession 来看怎么在 URLSession 中使用 async/await。最后听听负责 Swift Concurrency 的 Doug Gregor 参加的一个 播客的访谈 ,了解下 Swift Concurrency 背后的故事。

Swift Concurrency 和 Combine

由于 Swift Concurrency 的推出和大量的 Session 发布,特别是 AsyncSequence 的出现,以及正在路上的 AsyncStream、AsyncThrowingStreamcontinuation 提案(在Xcode 13.0 beta 3 AsyncStream 正式 release ),这些越来越多和 Combine 功能重叠的特性出现在 Swift Concurrency 蓝图里时,大家开始猜测是否 Combine 会被 Swift Concurrency 替代。关于未来是 Swift Concurrency 还是 Combine,我的感觉是,Combine 更侧重在响应式编程上,而响应式编程并不是所有开发人员都会接受的,而 Swift Concurrency 是所有人都愿意接受的开发方式,从 Swift Concurrency 推出后开发者使用的数量和社区反应火热程度来看都比 Combine 要大。在苹果对 Combine 有下一步动作之前,我还是更偏向 Swift Concurrency。

Concurrency 5.7 版本更新

session Eliminate data races using Swift ConcurrencyVisualize and optimize Swift concurrencyMeet Swift Async Algorithms

表示持续时间有了新的放来来表达,对应提案是 SE-0329 Clock, Instant, and Duration ,continuous clock 是在系统睡眠状态还会增加时间,suspending clock 在系统睡眠状态不会增加时间。Instants 表示一个确定的时间。Duration 表示两个时间经历了多久。

新增 SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions 通过收紧可发送性检查的规则来避免潜在的数据竞争。

SE-0343 Concurrency in Top-level Code 这个提案主要是更好地支持命令行工具的开发,可以直接将 concurrency 代码写到 main.swift 文件里。

SE-0340 Unavailable From Async Attribute 提供 noasync 语法以允许我们将类型和函数标记为在异步上下文不可用。

Task 是按顺序执行的,是异步的,在 await 时可以暂停任意次数。task 是自包含的,有自己的资源,可以独立于任何其他 task 独立运行。task 通过在 body 末尾返回一个值来传递对象,值类型没问题,如果是引用类型有可能出现数据竞争。

通过 Sendable 协议 Swift 可以帮助告诉我们什么时候 task 之间共享数据是安全的。Sendable 描述的类型可以跨隔离 domain,不会有数据竞争,Swift 编译器会在构建时检查数据竞争。task 的返回类型要符合 Sendable。

引用类型只能在很少的情况下符合 Sendable。比如 final class 只有不可变的存储。对于自己内部同步的引用类型,比如锁,可以用 @unchecked Sendable

class ConcurrentCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {  var lock: NSLock  var storage: [Key: Value]  // ...}

Actor 提供了一种隔离状态的方法可以消除数据竞争。使用 task 来执行 actor 定义的代码。一次只能在一个 actor 上执行一个 task。actor 也是依赖 Sendable。actor 是引用类型,但隔离了他们所有属性和代码来防止并发访问。@MainActor 表示的是主线程,你要在应用中更新 UI 时来用它。

@MainActor func updateView() {}Task { @MainActor in  // update UI here}

@MainActor 也可以用于类,类的属性和方法只能在主 main actor 上访问,除非标记为 nonisolated

@MainActorclass ChickenValley: Sendable {  var flock: [Chicken]  var food: [Pineapple]  func advanceTime() {    for chicken in flock {      chicken.eat(from: &food)    }  }}

SwiftUI

介绍

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里。 ACHNBrowserUIMovieSwiftUI 开源项目都是使用的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
示例如下:

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 内容:

视图组件使用

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

基本用法

// MARK: - Textstruct 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 使用

// MARK: - Markdownstruct 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 的使用

// MARK: - AttributedStringstruct 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]    }}

时间的显示

// 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"))    }}

插值使用

// 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)    }}

Link

使用方法如下:

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。

struct V: View {    var body: some View {        VStack {            Text("hi")        }        .onOpenURL { url in            print(url.absoluteString)        }    }}

Label

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

对应的代码如下:

import SwiftUIimport CodeEditorViewstruct 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: - 自己包装 NSTextViewstruct 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()        }    }}// 配置 NSTextViewfinal 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

使用方法如下:

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

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: - 自定义 Buttonstruct 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: - ButtonStylestruct 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: - PrimitiveButtonStylestruct 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 可组合,示例如下:

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 可以获取点击的位置,示例代码如下:

Rectangle()    .fill(.green)    .frame(width: 50, height: 50)    .onTapGesture(coordinateSpace: .global) { location in        print("Tap in \(location)")    }

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

进度

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

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。

简单示例如下:

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

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

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

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

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 示例代码如下:

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 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

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 视图,为其所有子视图提供其最大子视图的宽度:

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 设置。

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 值:

func placeSubviews(  in bounds: CGRect,  proposal: ProposedViewSize,  subviews: Subviews,  cache: inout Void) {  let ranks = subviews.map { subview in    subview[Rank.self] // 👈🏻  }  // ...}

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

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 和图片也支持了样式布局变化,代码示例如下:

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 的示例:

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 两栏的例子:

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 三栏的例子:

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,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

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

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

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 的自定义,示例如下:

.toolbar(id: "toolbar") {    ToolbarItem(id: "new", placement: .secondaryAction) {        Button(action: {}) {            Label("New Invitation", systemImage: "envelope")        }    }}.toolbarRole(.editor)

以下是废弃的 NavigationView 的用法。

对应代码如下:

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

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

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

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

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,示例如下:

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 里的视图只有在滚到时才会被创建。

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)) 表示会根据设置大小自动设置有多少列展示。

示例:

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 使用示例如下:

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 还可以透出偏移量,利用偏移量可以定义自己的动态视图,比如向下向上滚动视图时有不同效果,到顶部显示标题视图等。

示例代码如下:

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: - 自定义 ScrollViewstruct 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

ScrollView {    ForEach(0..<300) { i in        Text("\(i)")            .id(i)    }}.scrollDisabled(false).scrollDismissesKeyboard(.interactively).scrollIndicators(.visible)

浮层

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

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: - 供全局使用的 HUDfinal 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: - 自定义 HUDstruct 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。示例代码如下:

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

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

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

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))        } // end Chart    } // end body}

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

AxisMarks 的示例如下:

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()                }            }        }    }}

可交互图表示例如下:

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

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

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

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

示例代码如下:

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 视图会显示一个日历,用户可以选择多个日期,可以设置选择范围。示例如下:

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

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

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

struct PlaySliderView: View {    @State var count: Double = 0    var body: some View {        Slider(value: $count, in: 0...100)            .padding()        Text("\(Int(count))")    }}

Stepper

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 今年也得到了增强,示例如下:

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

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

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,使用示例如下:

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 视图可以让你轻松共享数据。示例代码如下:

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")        }    }}

视觉

Color

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

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 的更新

下面是个简单示例:

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 等。

示例代码如下:

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: - 扩展 TimelineScheduleextension 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 这些方法来进行改变。

示例代码如下:

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: - Pathstruct 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)

开发者

Swift官方

社区

探索库

新鲜事

SwiftOldDriver/iOS-Weekly
老司机 iOS 周报
Star:4222 Issue:19 开发语言:
🇨🇳 老司机技术 iOS 周报

matteocrippa/awesome-swift
Star:22100 Issue:1 开发语言:Ruby
A collaborative list of awesome Swift libraries and resources. Feel free to contribute!

ruanyf/weekly
科技爱好者周刊
Star:24124 Issue:1902 开发语言:
科技爱好者周刊,每周五发布

ming1016/SwiftPamphletApp
戴铭的开发小册子
Star:0 Issue:0 开发语言:

封装易用功能

SwifterSwift/SwifterSwift
Handy Swift extensions
Star:11409 Issue:17 开发语言:Swift
A handy collection of more than 500 native Swift extensions to boost your productivity.

JoanKing/JKSwiftExtension
Swift常用扩展、组件、协议
Star:359 Issue:1 开发语言:Swift
Swift常用扩展、组件、协议,方便项目快速搭建,提供完整清晰的Demo示例,不断的完善中……

infinum/iOS-Nuts-And-Bolts
Star:178 Issue:0 开发语言:Swift
iOS bits and pieces that you can include in your project to make your life a bit easier.

gtokman/ExtensionKit
Star:101 Issue:0 开发语言:Swift
Helpful extensions for iOS app development 🚀

SwiftUI 扩展

SwiftUIX/SwiftUIX
扩展 SwiftUI
Star:4777 Issue:8 开发语言:Swift
Extensions and additions to the standard SwiftUI library.

SDWebImage/SDWebImageSwiftUI
Star:1403 Issue:45 开发语言:Swift
SwiftUI Image loading and Animation framework powered by SDWebImage

apptekstudios/ASCollectionView
SwiftUI collection
Star:1208 Issue:32 开发语言:Swift
A SwiftUI collection view with support for custom layouts, preloading, and more.

siteline/SwiftUI-Introspect
SwiftUI 引入 UIKit
Star:3262 Issue:53 开发语言:Swift
Introspect underlying UIKit components from SwiftUI

AvdLee/SwiftUIKitView
在 SwiftUI 中 使用 UIKit
Star:506 Issue:3 开发语言:Swift
Easily use UIKit views in your SwiftUI applications. Create Xcode Previews for UIView elements

danielsaidi/SwiftUIKit
给 SwiftUI 添加更多功能
Star:666 Issue:3 开发语言:Swift
SwiftUIKit contains additional functionality for SwiftUI.

Toni77777/awesome-swiftui-libraries
SwiftUI 可使用的库
Star:199 Issue:0 开发语言:Swift
:rocket: Awesome SwiftUI Libraries

rakutentech/AltSwiftUI
类 SwiftUI
Star:269 Issue:6 开发语言:Swift
Open Source UI framework based on SwiftUI syntax and features, adding backwards compatibility.

gymshark/ios-stack-kit
类 SwiftUI
Star:118 Issue:2 开发语言:Swift
The power of SwiftUI with UIKit

jordibruin/Swift-Charts-Examples
Swift Charts 制作的不同类型图表示例
Star:0 Issue:0 开发语言:

图片

onevcat/Kingfisher
Star:20267 Issue:76 开发语言:Swift
A lightweight, pure-Swift library for downloading and caching images from the web.

kean/Nuke
Star:6721 Issue:6 开发语言:Swift
Image loading system

suzuki-0000/SKPhotoBrowser
图片浏览
Star:2358 Issue:94 开发语言:Swift
Simple PhotoBrowser/Viewer inspired by facebook, twitter photo browsers written by swift

文字处理

gonzalezreal/MarkdownUI
Star:730 Issue:14 开发语言:Swift
Render Markdown text in SwiftUI

tophat/RichTextView
Star:1073 Issue:31 开发语言:Swift
iOS Text View (UIView) that Properly Displays LaTeX, HTML, Markdown, and YouTube/Vimeo Links

keitaoouchi/MarkdownView
Star:1778 Issue:32 开发语言:Swift
Markdown View for iOS.

johnxnguyen/Down
fast Markdown
Star:1963 Issue:25 开发语言:C
Blazing fast Markdown / CommonMark rendering in Swift, built upon cmark.

qeude/SwiftDown
Swift 写的可换主题的 Markdown 编辑器组件
Star:115 Issue:2 开发语言:Swift
📦 A themable markdown editor component for your SwiftUI apps.

JohnSundell/Ink
Markdown 解析器
Star:2117 Issue:22 开发语言:Swift
A fast and flexible Markdown parser written in Swift.

tnantoka/edhita
Star:1194 Issue:15 开发语言:Swift
Fully open source text editor for iOS written in Swift.

glushchenko/fsnotes
Star:5025 Issue:222 开发语言:Swift
Notes manager for macOS/iOS

coteditor/CotEditor
Star:4602 Issue:96 开发语言:Swift
Lightweight Plain-Text Editor for macOS

mchakravarty/CodeEditorView
SwiftUI 写的代码编辑器
Star:400 Issue:30 开发语言:Swift
SwiftUI code editor view for iOS and macOS

CodeEditApp/CodeEdit
原生,性能好的代码编辑器
Star:10816 Issue:93 开发语言:Swift
CodeEdit App for macOS – Elevate your code editing experience. Open source, free forever.

ZeeZide/CodeEditor
使用 Highlight.js 的来做语法高亮的 SwiftUI 编辑器
Star:202 Issue:3 开发语言:Swift
A SwiftUI TextEditor with syntax highlighting using Highlight.js

tw93/MiaoYan
轻灵的 Markdown 笔记本伴你写出妙言
Star:0 Issue:0 开发语言:

动画

recherst/kavsoft-swiftui-animations
Star:132 Issue:0 开发语言:Swift
SwiftUI animation tutorials, all of demos are consisted of youtube videos at website of kavsoft. 🔗 https://kavsoft.dev

timdonnelly/Advance
Physics-based animations
Star:4463 Issue:4 开发语言:Swift
Physics-based animations for iOS, tvOS, and macOS.

MengTo/Spring
动画
Star:13987 Issue:167 开发语言:Swift
A library to simplify iOS animations in Swift.

持久化存储

stephencelis/SQLite.swift
Star:8349 Issue:88 开发语言:Swift
A type-safe, Swift-language layer over SQLite3.

groue/GRDB.swift
Star:5185 Issue:2 开发语言:Swift
A toolkit for SQLite databases, with a focus on application development

caiyue1993/IceCream
CloudKit 同步 Realm 数据库
Star:1732 Issue:46 开发语言:Swift
Sync Realm Database with CloudKit

realm/realm-cocoa
Star:15375 Issue:367 开发语言:Objective-C
Realm is a mobile database: a replacement for Core Data & SQLite

PostgresApp/PostgresApp
PostgreSQL macOS 应用
Star:6332 Issue:125 开发语言:Makefile
The easiest way to get started with PostgreSQL on the Mac

编程范式

ReactiveX/RxSwift
函数响应式编程
Star:22278 Issue:10 开发语言:Swift
Reactive Programming in Swift

pointfreeco/swift-composable-architecture
Star:6377 Issue:20 开发语言:Swift
A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.

onmyway133/awesome-ios-architecture
Star:4628 Issue:0 开发语言:
:japanese_castle: Better ways to structure iOS apps

ReSwift/ReSwift
单页面状态和数据管理
Star:7206 Issue:39 开发语言:Swift
Unidirectional Data Flow in Swift - Inspired by Redux

gre4ixin/ReduxUI
SwiftUI Redux 架构
Star:29 Issue:0 开发语言:Swift
💎 Redux like architecture for SwiftUI

BohdanOrlov/iOS-Developer-Roadmap
Star:5834 Issue:7 开发语言:Swift
Roadmap to becoming an iOS developer in 2018.

ReactiveCocoa/ReactiveCocoa
Star:19983 Issue:4 开发语言:Swift
Cocoa framework and Obj-C dynamism bindings for ReactiveSwift.

mehdihadeli/awesome-software-architecture
软件架构
Star:291 Issue:2 开发语言:
A curated list of awesome articles, videos, and other resources to learn and practice software architecture, patterns, and principles.

nalexn/clean-architecture-swiftui
干净完整的SwiftUI+Combine例子,包含网络和单元测试等
Star:3517 Issue:17 开发语言:Swift
SwiftUI sample app using Clean Architecture. Examples of working with CoreData persistence, networking, dependency injection, unit testing, and more.

krzysztofzablocki/Sourcery
Swift 元编程
Star:6644 Issue:55 开发语言:Swift
Meta-programming for Swift, stop writing boilerplate code.

路由

pointfreeco/swiftui-navigation
Star:866 Issue:2 开发语言:Swift
Tools for making SwiftUI navigation simpler, more ergonomic and more precise.

静态检查

realm/SwiftLint
Star:16224 Issue:312 开发语言:Swift
A tool to enforce Swift style and conventions.

系统能力

devicekit/DeviceKit
UIDevice 易用封装
Star:3700 Issue:46 开发语言:Swift
DeviceKit is a value-type replacement of UIDevice.

kishikawakatsumi/KeychainAccess
Star:6935 Issue:39 开发语言:Swift
Simple Swift wrapper for Keychain that works on iOS, watchOS, tvOS and macOS.

nvzqz/FileKit
文件操作
Star:2217 Issue:11 开发语言:Swift
Simple and expressive file management in Swift

JohnSundell/Files
文件操作
Star:2298 Issue:23 开发语言:Swift
A nicer way to handle files & folders in Swift

kylef/PathKit
文件操作
Star:1348 Issue:12 开发语言:Swift
Effortless path operations in Swift

rushisangani/BiometricAuthentication
FaceID or TouchID authentication
Star:798 Issue:14 开发语言:Swift
Use Apple FaceID or TouchID authentication in your app using BiometricAuthentication.

sunshinejr/SwiftyUserDefaults
Star:4653 Issue:48 开发语言:Swift
Modern Swift API for NSUserDefaults

MonitorControl/MonitorControl
亮度和声音控制
Star:16608 Issue:65 开发语言:Swift
🖥 Control your display’s brightness & volume on your Mac as if it was a native Apple Display. Use Apple Keyboard keys or custom shortcuts. Shows the native macOS OSDs.

carekit-apple/CareKit
使用 SwiftUI 开发健康相关的库
Star:2224 Issue:71 开发语言:Swift
CareKit is an open source software framework for creating apps that help people better understand and manage their health.

Cay-Zhang/SwiftSpeech
苹果语言识别封装库,已适配 SwiftUI
Star:269 Issue:0 开发语言:Swift
A speech recognition framework designed for SwiftUI.

malcommac/SwiftDate
Swift编写的时间时区,时间比较等复杂处理的包装
Star:6943 Issue:68 开发语言:Swift
🐔 Toolkit to parse, validate, manipulate, compare and display dates, time & timezones in Swift.

接口

OAuthSwift/OAuthSwift
Star:3017 Issue:42 开发语言:Swift
Swift based OAuth library for iOS

p2/OAuth2
Star:1067 Issue:74 开发语言:Swift
OAuth2 framework for macOS and iOS, written in Swift.

public-apis/public-apis
Star:197421 Issue:19 开发语言:Python
A collective list of free APIs

接口应用

bpisano/Weather
天气应用
Star:282 Issue:2 开发语言:Swift
A Weather app in SwiftUI.

Dimillian/MovieSwiftUI
电影 MovieDB 应用
Star:6063 Issue:8 开发语言:Swift
SwiftUI & Combine app using MovieDB API. With a custom Flux (Redux) implementation.

chojnac/NotionSwift
Star:29 Issue:4 开发语言:Swift
Unofficial Notion API SDK for iOS & macOS

Dimillian/RedditOS
SwiftUI 写的 Reddit客户端
Star:3605 Issue:16 开发语言:Swift
The product name is Curiosity, a SwiftUI Reddit client for macOS Big Sur

carson-katri/reddit-swiftui
SwiftUI 写的 Reddit客户端
Star:1127 Issue:7 开发语言:Swift
A cross-platform Reddit client built in SwiftUI

Dimillian/SwiftHN
Hacker News 阅读
Star:1685 Issue:20 开发语言:Swift
A Hacker News reader in Swift

tatsuz0u/EhPanda
Star:1507 Issue:24 开发语言:Swift
An unofficial E-Hentai App for iOS built with SwiftUI & TCA.

Dimillian/MortyUI
GraphQL + SwiftUI 开发的瑞克和莫蒂应用
Star:431 Issue:4 开发语言:Swift
A very simple Rick & Morty app to demo GraphQL + SwiftUI

Finb/V2ex-Swift
V2EX 客户端
Star:1513 Issue:11 开发语言:Swift
An iOS client written in Swift for V2EX

v2er-app/iOS
V2EX 客户端
Star:194 Issue:4 开发语言:Swift
The source of V2er.iOS

sinaweibosdk/weibo_ios_sdk
Star:1423 Issue:66 开发语言:Objective-C
新浪微博 IOS SDK

miniLV/MNWeibo
Swift5 + MVVM 微博客户端
Star:251 Issue:4 开发语言:Swift
Swift5 + MVVM + 文艺复兴微博(纯代码 + 纯Swift),可作为第一个上手的Swift项目.

nerdishbynature/octokit.swift
Swift API Client for GitHub
Star:400 Issue:10 开发语言:Swift
A Swift API Client for GitHub and GitHub Enterprise

GitHawkApp/GitHawk
iOS app for GitHub
Star:2838 Issue:460 开发语言:Swift
The (second) best iOS app for GitHub.

fangzesheng/free-api
Star:12335 Issue:28 开发语言:
收集免费的接口服务,做一个api的搬运工

nerdsupremacist/Graphaello
SwiftUI 中使用 GraphQL 的工具
Star:454 Issue:22 开发语言:Swift
A Tool for Writing Declarative, Type-Safe and Data-Driven Applications in SwiftUI using GraphQL

nerdsupremacist/tmdb
GraphQL 包装电影数据接口
Star:16 Issue:1 开发语言:Swift
A GraphQL Wrapper for The Movie Database

macOS

serhii-londar/open-source-mac-os-apps
开源 macOS 程序合集
Star:30760 Issue:65 开发语言:Swift
🚀 Awesome list of open source applications for macOS. https://t.me/s/opensourcemacosapps

Ranchero-Software/NetNewsWire
Star:5773 Issue:551 开发语言:Swift
RSS reader for macOS and iOS.

overtake/TelegramSwift
Star:3876 Issue:485 开发语言:Swift
Source code of Telegram for macos on Swift 5.0

eonist/FileWatcher
macOS 上监听文件变化
Star:159 Issue:5 开发语言:Swift
Monitoring file system changes in macOS

waylybaye/XcodeCleaner-SwiftUI
清理 Xcode
Star:1204 Issue:3 开发语言:Swift
Make Xcode Clean Again

gao-sun/eul
SwiftUI 写的 macOS 状态监控工具
Star:7658 Issue:55 开发语言:Swift
🖥️ macOS status monitoring app written in SwiftUI.

Dimillian/ACHNBrowserUI
SwiftUI 写的动物之森小助手程序
Star:1528 Issue:32 开发语言:Swift
Animal Crossing New Horizon companion app in SwiftUI

lexrus/RegExPlus
正则表达式
Star:186 Issue:0 开发语言:Swift
A nifty RegEx test tool built with SwiftUI

v2ex/launcher
用来启动那些本地开发时需要的各种进程,及查看其输出
Star:212 Issue:5 开发语言:Swift

lukakerr/Pine
Markdown 编辑器
Star:2988 Issue:45 开发语言:Swift
A modern, native macOS markdown editor

root3nl/SupportApp
企业支持 macOS 软件
Star:283 Issue:20 开发语言:Swift
The Support App is developed by Root3, specialized in managing Apple devices. Root3 offers consultancy and support for organizations to get the most out of their Apple devices and is based in The Netherlands (Haarlem).

jaywcjlove/awesome-mac
macOS 软件大全
Star:49993 Issue:126 开发语言:JavaScript
 Now we have become very big, Different from the original idea. Collect premium software in various categories.

insidegui/WWDC
Star:8216 Issue:33 开发语言:Swift
The unofficial WWDC app for macOS

sindresorhus/Actions
Star:904 Issue:11 开发语言:Swift
⚙️ Supercharge your shortcuts

ObuchiYuki/DevToysMac
开发者工具合集
Star:5552 Issue:40 开发语言:Swift
DevToys For mac

jacklandrin/OnlySwitch
macOS 状态栏一键设置工具,隐藏桌面图标、清理 Xcode 缓存、一键隐藏刘海儿、进入夜览模式等数十种功能
Star:1389 Issue:10 开发语言:Swift
⚙️ All-in-One menu bar app, hide 💻MacBook Pro’s notch, dark mode, AirPods, Shortcuts

exelban/stats
macOS 系统资源监控
Star:11296 Issue:14 开发语言:Swift
macOS system monitor in your menu bar

brunophilipe/Cakebrew
可视化管理 Homebrew 软件包
Star:4234 Issue:57 开发语言:Objective-C
Manage your Homebrew formulas with style using Cakebrew.

应用

vinhnx/Clendar
SwiftUI 写的日历应用
Star:361 Issue:58 开发语言:Swift
Clendar - universal Apple-platform calendar app. Written in SwiftUI. Available on App Store. MIT License.

SvenTiigi/WhatsNewKit
欢迎屏
Star:2560 Issue:1 开发语言:Swift
Showcase your awesome new app features 📱

kickstarter/ios-oss
Kickstarter 的 iOS 版本
Star:7968 Issue:1 开发语言:Swift
Kickstarter for iOS. Bring new ideas to life, anywhere.

CoreOffice/CryptoOffice
Swift 解析 Office Open XML(OOXML)包括 xlsx, docx, pptx
Star:27 Issue:0 开发语言:Swift
Office Open XML (OOXML) formats (.xlsx, .docx, .pptx) decryption for Swift

CoreOffice/CoreXLSX
Swift编写的Excel电子表格(XLSX)格式解析器
Star:643 Issue:13 开发语言:Swift
Excel spreadsheet (XLSX) format parser written in pure Swift

analogcode/Swift-Radio-Pro
电台应用
Star:2679 Issue:13 开发语言:Swift
Professional Radio Station App for iOS!

bizz84/SwiftyStoreKit
应用内购框架
Star:6000 Issue:163 开发语言:Swift
Lightweight In App Purchases Swift framework for iOS 8.0+, tvOS 9.0+ and macOS 10.10+ ⛺

wikimedia/wikipedia-ios
Star:2462 Issue:6 开发语言:Swift
📱The official Wikipedia iOS app.

游戏

pointfreeco/isowords
单词搜索游戏
Star:1792 Issue:2 开发语言:Swift
Open source game built in SwiftUI and the Composable Architecture.

michelpereira/awesome-games-of-coding
教你学编程的游戏收集
Star:1459 Issue:1 开发语言:
A curated list of games that can teach you how to learn a programming language.

OpenEmu/OpenEmu
视频游戏模拟器
Star:13964 Issue:201 开发语言:Swift
🕹 Retro video game emulation for macOS

jVirus/swiftui-2048
Star:162 Issue:0 开发语言:Swift
🎲 100% SwiftUI 3.0, classic 2048 game [iOS 15.0+, iPadOS 15.0+, macOS 12.0+, Swift 5.5].

schellingb/dosbox-pure
DOS 游戏模拟器
Star:452 Issue:112 开发语言:C++
DOSBox Pure is a new fork of DOSBox built for RetroArch/Libretro aiming for simplicity and ease of use.

chrismaltby/gb-studio
拖放式复古游戏创建器
Star:6089 Issue:513 开发语言:C
A quick and easy to use drag and drop retro game creator for your favourite handheld video game system

darrellroot/Netrek-SwiftUI
SwiftUI 开发的1989年的 Netrek 游戏
Star:10 Issue:0 开发语言:Swift

freeCodeCamp/LearnToCodeRPG
学习编码的游戏
Star:828 Issue:10 开发语言:Ren’Py
A visual novel video game where you learn to code and get a dev job 🎯

pmgl/microstudio
游戏开发平台集搜索、开发、学习、体验、交流等功能于一身
Star:634 Issue:37 开发语言:JavaScript
Free, open source game engine online

InvadingOctopus/octopuskit
2D游戏引擎,用的 GameplayKit + SpriteKit + SwiftUI
Star:310 Issue:0 开发语言:Swift
2D ECS game engine in 100% Swift + SwiftUI for iOS, macOS, tvOS

a-little-org-called-mario/a-little-game-called-mario
用 Godot 引擎做的马里奥游戏
Star:1046 Issue:5 开发语言:GDScript
open source collective hell game

新技术展示

JakeLin/Moments-SwiftUI
SwiftUI、Async、Actor
Star:42 Issue:0 开发语言:Swift
WeChat-like Moments App implemented using Swift 5.5 and SwiftUI

twostraws/HackingWithSwift
示例代码
Star:4529 Issue:10 开发语言:Swift
The project source code for hackingwithswift.com

carson-katri/awesome-result-builders
Result Builders awesome
Star:798 Issue:2 开发语言:
A list of cool DSLs made with Swift 5.4’s @resultBuilder

pointfreeco/episode-code-samples
Star:684 Issue:3 开发语言:Swift
💾 Point-Free episode code.

SwiftGGTeam/the-swift-programming-language-in-chinese
中文版 Apple 官方 Swift 教程
Star:20535 Issue:5 开发语言:CSS
中文版 Apple 官方 Swift 教程《The Swift Programming Language》

jessesquires/TIL
学习笔记
Star:258 Issue:1 开发语言:
Things I’ve learned and/or things I want to remember. Notes, links, advice, example code, etc.

Combine 扩展

OpenCombine/OpenCombine
Combine 的开源实现
Star:2228 Issue:13 开发语言:Swift
Open source implementation of Apple’s Combine framework for processing values over time.

CombineCommunity/CombineExt
对 Combine 的补充
Star:1198 Issue:23 开发语言:Swift
CombineExt provides a collection of operators, publishers and utilities for Combine, that are not provided by Apple themselves, but are common in other Reactive Frameworks and standards.

聚合

dkhamsing/open-source-ios-apps
开源的完整 App 例子
Star:30612 Issue:0 开发语言:
:iphone: Collaborative List of Open-Source iOS Apps

vlondon/awesome-swiftui
Star:1246 Issue:5 开发语言:
A collaborative list of awesome articles, talks, books, videos and code examples about SwiftUI.

ivanvorobei/SwiftUI
Star:3896 Issue:3 开发语言:Swift
Examples projects using SwiftUI released by WWDC2019. Include Layout, UI, Animations, Gestures, Draw and Data.

kon9chunkit/GitHub-Chinese-Top-Charts
GitHub中文排行榜
Star:46675 Issue:88 开发语言:Java
:cn: GitHub中文排行榜,各语言分设「软件 | 资料」榜单,精准定位中文好项目。各取所需,高效学习。

onmyway133/awesome-swiftui
Star:402 Issue:4 开发语言:
🌮 Awesome resources, articles, libraries about SwiftUI

Juanpe/About-SwiftUI
汇总 SwiftUI 的资料
Star:6225 Issue:0 开发语言:Swift
Gathering all info published, both by Apple and by others, about new framework SwiftUI.

sindresorhus/awesome
内容广
Star:206308 Issue:37 开发语言:
😎 Awesome lists about all kinds of interesting topics

SwiftPackageIndex/PackageList
Swift 开源库索引
Star:627 Issue:0 开发语言:Swift
The master list of repositories for the Swift Package Index.

matteocrippa/awesome-swift
Star:22100 Issue:1 开发语言:Ruby
A collaborative list of awesome Swift libraries and resources. Feel free to contribute!

性能、工程构建及自动化

tuist/tuist
创建和维护 Xcode projects 文件
Star:2782 Issue:131 开发语言:Swift
🚀 Create, maintain, and interact with Xcode projects at scale

swift-server/vscode-swift
VSCode 的 Swift 扩展
Star:316 Issue:33 开发语言:TypeScript
Visual Studio Code Extension for Swift

peripheryapp/periphery
检测 Swift 无用代码
Star:3438 Issue:31 开发语言:Swift
A tool to identify unused code in Swift projects.

nalexn/ViewInspector
SwiftUI Runtime introspection 和 单元测试
Star:1251 Issue:24 开发语言:Swift
Runtime introspection and unit testing of SwiftUI views

shibapm/Komondor
Git Hooks for Swift projects
Star:513 Issue:20 开发语言:Swift
Git Hooks for Swift projects 🐩

SwiftGen/SwiftGen
代码生成
Star:7987 Issue:91 开发语言:Swift
The Swift code generator for your assets, storyboards, Localizable.strings, … — Get rid of all String-based APIs!

hyperoslo/Cache
Star:2602 Issue:23 开发语言:Swift
:package: Nothing but Cache.

kylef/Commander
命令行
Star:1492 Issue:3 开发语言:Swift
Compose beautiful command line interfaces in Swift

Carthage/Carthage
Star:14605 Issue:169 开发语言:Swift
A simple, decentralized dependency manager for Cocoa

NARKOZ/hacker-scripts
程序员的活都让机器干的脚本(真实故事)
Star:44591 Issue:67 开发语言:JavaScript
Based on a true story

RobotsAndPencils/XcodesApp
Xcode 多版本安装
Star:3276 Issue:51 开发语言:Swift
The easiest way to install and switch between multiple versions of Xcode - with a mouse click.

ZeeZide/5GUIs
可以分析程序用了哪些库,用了LLVM objdump
Star:189 Issue:11 开发语言:Swift
A tiny macOS app that can detect the GUI technologies used in other apps.

faisalmemon/ios-crash-dump-analysis-book
iOS Crash Dump Analysis Book
Star:474 Issue:1 开发语言:Objective-C
iOS Crash Dump Analysis Book

majd/ipatool
下载 ipa
Star:2303 Issue:12 开发语言:Swift
Command-line tool that allows searching and downloading app packages (known as ipa files) from the iOS App Store

测试

Quick/Quick
测试框架
Star:9456 Issue:31 开发语言:Swift
The Swift (and Objective-C) testing framework.

网络

Alamofire/Alamofire
Star:37835 Issue:29 开发语言:Swift
Elegant HTTP Networking in Swift

socketio/socket.io-client-swift
Star:4814 Issue:189 开发语言:Swift

Lojii/Knot
使用 SwiftNIO 实现 HTTPS 抓包
Star:1239 Issue:3 开发语言:C
一款iOS端基于MITM(中间人攻击技术)实现的HTTPS抓包工具,完整的App,核心代码使用SwiftNIO实现

swift-server/async-http-client
使用 SwiftNIO 开发的 HTTP 客户端
Star:609 Issue:83 开发语言:Swift
HTTP client library built on SwiftNIO

kean/Get
Star:406 Issue:0 开发语言:Swift
Web API client built using async/await

awesome-selfhosted/awesome-selfhosted
网络服务及上面的应用
Star:92737 Issue:151 开发语言:JavaScript
A list of Free Software network services and web applications which can be hosted on your own servers

daltoniam/Starscream
WebSocket
Star:7348 Issue:149 开发语言:Swift
Websockets in swift for iOS and OSX

shadowsocks/ShadowsocksX-NG
Star:30440 Issue:257 开发语言:Swift
Next Generation of ShadowsocksX

carson-katri/swift-request
声明式的网络请求
Star:648 Issue:7 开发语言:Swift
Declarative HTTP networking, designed for SwiftUI

alibaba/xquic
阿里巴巴发布的 XQUIC 库
Star:1132 Issue:21 开发语言:C
XQUIC Library released by Alibaba is a cross-platform implementation of QUIC and HTTP/3 protocol.

kasketis/netfox
获取所有网络请求
Star:3269 Issue:18 开发语言:Swift
A lightweight, one line setup, iOS / OSX network debugging library! 🦊

Moya/Moya
Swift 编写的网络抽象层
Star:14012 Issue:125 开发语言:Swift
Network abstraction layer written in Swift.

Kitura/BlueSocket
Star:1301 Issue:31 开发语言:Swift
Socket framework for Swift using the Swift Package Manager. Works on iOS, macOS, and Linux.

rhummelmose/BluetoothKit
蓝牙
Star:2086 Issue:35 开发语言:Swift
Easily communicate between iOS/OSX devices using BLE

WeTransfer/Mocker
Mock Alamofire and URLSession
Star:817 Issue:3 开发语言:Swift
Mock Alamofire and URLSession requests without touching your code implementation

bagder/everything-curl
记录了 curl 的一切
Star:1488 Issue:0 开发语言:Perl
The book documenting the curl project, the curl tool, libcurl and more. Simply put: everything curl.

LANDrop/LANDrop
全平台局域网文件传输
Star:2787 Issue:90 开发语言:C++
Drop any files to any devices on your LAN.

图形

willdale/SwiftUICharts
用于SwiftUI的图表绘图库
Star:560 Issue:30 开发语言:Swift
A charts / plotting library for SwiftUI. Works on macOS, iOS, watchOS, and tvOS and has accessibility features built in.

lludo/SwiftSunburstDiagram
SwiftUI 图表
Star:479 Issue:12 开发语言:Swift
SwiftUI library to easily render diagrams given a tree of objects. Similar to ring chart, sunburst chart, multilevel pie chart.

ivanschuetz/SwiftCharts
Star:2399 Issue:49 开发语言:Swift
Easy to use and highly customizable charts library for iOS

danielgindi/Charts
Star:25565 Issue:832 开发语言:Swift
Beautiful charts for iOS/tvOS/OSX! The Apple side of the crossplatform MPAndroidChart.

imxieyi/waifu2x-ios
waifu2x Core ML 动漫风格图片的高清渲染
Star:430 Issue:3 开发语言:Swift
iOS Core ML implementation of waifu2x

mecid/SwiftUICharts
支持 SwiftUI 的简单的线图和柱状图库
Star:1306 Issue:2 开发语言:Swift
A simple line and bar charting library that supports accessibility written using SwiftUI.

Tencent/libpag
PAG(Portable Animated Graphics)实时渲染库,多个平台渲染AE动画。
Star:1809 Issue:2 开发语言:C++
The official rendering library for PAG (Portable Animated Graphics) files that renders After Effects animations natively across multiple platforms.

jathu/UIImageColors
获取图片主次颜色
Star:3100 Issue:10 开发语言:Swift
Fetches the most dominant and prominent colors from an image.

BradLarson/GPUImage3
Metal 实现
Star:2362 Issue:74 开发语言:Swift
GPUImage 3 is a BSD-licensed Swift framework for GPU-accelerated video and image processing using Metal.

exyte/Macaw
SVG
Star:5845 Issue:129 开发语言:Swift
Powerful and easy-to-use vector graphics Swift library with SVG support

exyte/SVGView
支持 SwiftUI 的 SVG 解析渲染视图
Star:173 Issue:3 开发语言:Swift
SVG parser and renderer written in SwiftUI

efremidze/Magnetic
SpriteKit气泡支持SwiftUI
Star:1398 Issue:23 开发语言:Swift
SpriteKit Floating Bubble Picker (inspired by Apple Music) 🧲

NextLevel/NextLevel
相机
Star:1994 Issue:69 开发语言:Swift
⬆️ Rad Media Capture in Swift

Harley-xk/MaLiang
基于 Metal 的涂鸦绘图库
Star:1271 Issue:43 开发语言:Swift
iOS painting and drawing library based on Metal. 神笔马良有一支神笔(基于 Metal 的涂鸦绘图库)

frzi/Model3DView
毫不费力的使用 SwiftUI 渲染 3d models
Star:49 Issue:0 开发语言:Swift
Render 3d models with SwiftUI effortlessly

音视频

iina/iina
Star:30443 Issue:1348 开发语言:Swift
The modern video player for macOS.

shogo4405/HaishinKit.swift
RTMP, HLS
Star:2319 Issue:12 开发语言:Swift
Camera and Microphone streaming library via RTMP, HLS for iOS, macOS and tvOS.

AudioKit/AudioKit
Star:9194 Issue:2 开发语言:Swift
Swift audio synthesis, processing, & analysis platform for iOS, macOS and tvOS

josejuanqm/VersaPlayer
Star:698 Issue:3 开发语言:Swift
Versatile Video Player implementation for iOS, macOS, and tvOS

bilibili/ijkplayer
bilibili 播放器
Star:30489 Issue:2748 开发语言:C
Android/iOS video player based on FFmpeg n3.4, with MediaCodec, VideoToolbox support.

mpv-player/mpv
命令行视频播放器
Star:19139 Issue:867 开发语言:C
🎥 Command line video player

analogcode/Swift-Radio-Pro
广播电台
Star:2679 Issue:13 开发语言:Swift
Professional Radio Station App for iOS!

安全

krzyzanowskim/CryptoSwift
Star:9121 Issue:40 开发语言:Swift
CryptoSwift is a growing collection of standard and secure cryptographic algorithms implemented in Swift

rockbruno/SwiftInfo
提取和分析一个iOS应用
Star:1094 Issue:20 开发语言:Swift
📊 Extract and analyze the evolution of an iOS app’s code.

Web

Kitura/swift-html-entities
HTML5 规范字符编码/解码器
Star:145 Issue:5 开发语言:Swift
HTML5 spec-compliant character encoder/decoder for Swift

TokamakUI/Tokamak
SwiftUI 兼容,WebAssembly 构建 HTML
Star:1732 Issue:99 开发语言:Swift
SwiftUI-compatible framework for building browser apps with WebAssembly and native apps for other platforms

johnsundell/publish
用 swift 来写网站
Star:4050 Issue:29 开发语言:Swift
A static site generator for Swift developers

highlightjs/highlight.js
语法高亮
Star:20128 Issue:64 开发语言:JavaScript
JavaScript syntax highlighter with language auto-detection and zero dependencies.

sivan/heti
赫蹏(hètí)中文排版
Star:4864 Issue:17 开发语言:SCSS
赫蹏(hètí)是专为中文内容展示设计的排版样式增强。它基于通行的中文排版规范而来,可以为网站的读者带来更好的文章阅读体验。

kevquirk/simple.css
简单大方基础 CSS 样式
Star:2619 Issue:5 开发语言:CSS
Simple.css is a classless CSS template that allows you to make a good looking website really quickly.

mozilla-mobile/firefox-ios
Star:10907 Issue:1062 开发语言:Swift
Firefox for iOS

liviuschera/noctis
好看的代码编辑器配色主题
Star:388 Issue:19 开发语言:JavaScript
Noctis is a collection of light & dark themes with a well balanced blend of warm and cold colors

服务器

vapor/vapor
Star:21853 Issue:86 开发语言:Swift
💧 A server-side Swift HTTP web framework.

Lakr233/Rayon
SSH 机器管理,Swift 编写
Star:2190 Issue:21 开发语言:Swift
yet another SSH machine manager

系统

spevans/swift-project1
Swift编写内核,可在 Mac 和 PC 启动
Star:243 Issue:1 开发语言:Swift
A minimal bare metal kernel in Swift

Web 3.0

chaozh/awesome-blockchain-cn
区块链 awesome
Star:16629 Issue:15 开发语言:JavaScript
收集所有区块链(BlockChain)技术开发相关资料,包括Fabric和Ethereum开发资料

argentlabs/web3.swift
以太坊 Swift API,支持智能合约、ENS 和 ERC20
Star:439 Issue:14 开发语言:Swift
Ethereum Swift API with support for smart contracts, ENS & ERC20

chainfeeds/RSSAggregatorforWeb3
web3 的 rss feed 订阅源
Star:1502 Issue:4 开发语言:Python
Bootstrapping your personal Web3 info hub from more than 500 RSS Feeds.

Planetable/Planet
由 IPFS 和以太坊名称系统提供支持的去中心化博客和网站
Star:531 Issue:13 开发语言:Swift
Decentralized blogs and websites powered by IPFS and Ethereum Name System

Apple

apple/swift
Star:59937 Issue:6020 开发语言:C++
The Swift Programming Language

apple/swift-evolution
提案
Star:13440 Issue:42 开发语言:Markdown
This maintains proposals for changes and user-visible enhancements to the Swift Programming Language.

apple/swift-corelibs-foundation
Star:4624 Issue:607 开发语言:Swift
The Foundation Project, providing core utilities, internationalization, and OS independence

apple/swift-package-manager
Star:8819 Issue:513 开发语言:Swift
The Package Manager for the Swift Programming Language

apple/swift-markdown
Star:1840 Issue:17 开发语言:Swift
A Swift package for parsing, building, editing, and analyzing Markdown documents.

apple/sourcekit-lsp
Star:2599 Issue:32 开发语言:Swift
Language Server Protocol implementation for Swift and C-based languages

apple/swift-nio
Star:7023 Issue:170 开发语言:Swift
Event-driven network application framework for high performance protocol servers & clients, non-blocking.

apple/swift-syntax
解析、生成、转换 Swift 代码
Star:1842 Issue:24 开发语言:Swift
SwiftPM package for SwiftSyntax library.

apple/swift-crypto
CryptoKit 的开源实现
Star:1208 Issue:10 开发语言:C
Open-source implementation of a substantial portion of the API of Apple CryptoKit suitable for use on Linux platforms.

apple/swift-driver
用 Swift 语言重新实现的编译器的驱动程序库
Star:629 Issue:25 开发语言:Swift
Swift compiler driver reimplementation in Swift

apple/swift-numerics
用简单的方式用浮点型进行数值计算
Star:1405 Issue:45 开发语言:Swift
Advanced mathematical types and functions for Swift

apple/swift-atomics
Swift 的低级原子操作
Star:763 Issue:11 开发语言:Swift
Low-level atomic operations for Swift

apple/swift-async-algorithms
Combine 的官方开源替代
Star:1546 Issue:21 开发语言:Swift
Async Algorithms for Swift

计算机科学

raywenderlich/swift-algorithm-club
Star:26593 Issue:55 开发语言:Swift
Algorithms and data structures in Swift, with explanations!

扩展知识

trimstray/the-book-of-secret-knowledge
Star:71271 Issue:19 开发语言:
A collection of inspiring lists, manuals, cheatsheets, blogs, hacks, one-liners, cli/web tools and more.

rossant/awesome-math
Star:6046 Issue:7 开发语言:Python
A curated list of awesome mathematics resources

待分类

krzysztofzablocki/KZFileWatchers
Swift编写的观察本地或者网络上,比如网盘和FTP的文件变化
Star:1029 Issue:2 开发语言:Swift
A micro-framework for observing file changes, both local and remote. Helpful in building developer tools.

博客和资讯

  • Swift.org:Swift 官方博客
  • Release notes from iOS-Weekly:老司机 iOS 周报
  • iOS摸鱼周报:iOS 摸鱼周报
  • Michael Tsai:一名 macOS 开发者的博客
  • 少数派:少数派致力于更好地运用数字产品或科学方法,帮助用户提升工作效率和生活品质
  • OneV’s Den:上善若水,人淡如菊。这里是王巍 (onevcat) 的博客,用来记录一些技术和想法,主要专注于 Swift 和 iOS 开发。
  • SwiftLee:A weekly blog about Swift, iOS and Xcode Tips and Tricks
  • Swift with Majid:Majid’s blog about Swift development
  • 肘子的Swift记事本
  • 戴铭的博客 - 星光社:一个人走得快,一群人走的远
  • Swift by Sundell:Weekly Swift articles, podcasts and tips by John Sundell
  • FIVE STARS:Exploring iOS, SwiftUI & much more.
  • SwiftUI Weekly:The curated collection of links about SwiftUI. Delivered every Monday.
  • Not Only Swift Weekly:Xcode tips & tricks, Swift, SwiftUI, Combine, Firebase, computing and internet history, and - of course - some fun stuff.
  • SwiftlyRush Weekly:SwiftlyRush Weekly is a weekly curated publication full of interesting, relevant links, alongside industry news and updates. Subscribe now and never miss an issue.
  • iOS Dev Weekly:Subscribe to a hand-picked round-up of the best iOS development links every week. Curated by Dave Verwer and published every Friday. Free.
  • 阮一峰的网络日志:Ruan YiFeng’s Blog 科技爱好者周刊
  • The.Swift.Dev.:Weekly Swift articles
  • 爱范儿:让未来触手可及
  • 机核:不止是游戏

我写技术文章的一点心得

作者 戴铭
2021年7月24日 05:52

前言

非常感谢大家抽出生命中宝贵的一段时间来听我接下来的一大段关于写文章那些事的唠叨。写文章的好处看看《觉醒年代》就知道了。

这篇文章我不会写一些常说的技巧,比如文章的内容前后要有逻辑关系,内容之间有关联。所讲知识前后的层次要平,不要在某个部分挖掘过深。写作过程中牢牢抓住要表达的内容,不要过于偏离主题。类似这样的技巧不会说。都说一流的人讨论思想、普通人讨论事情、三流的人讨论人,那么为了提高文章高度,除了说些事情,我还打算加些思想的内容。

大家都习惯去阅读他人人生体验来体验不一样的人生,这样的方式和评头论足一样简单舒服,轻松爽快。而主动去对自己思想进行研究和开发,通过写作输出自己独特的经历和思考却是困难且难受的,但这样从0到1和从0到10的创造过程获得的乐趣却是前者的百倍甚至更多,这背后所遵循的原则是怎样的呢?

每个心得都会基于某些原则,以至于思路不会散架,而所有的原则都无法违背物热力学第二定律,也就是熵增定律(强烈建议先看知乎这篇介绍)。就连进化论都是遵循熵增定律。

对于写作的心得我提炼出独特性、真实感、故事性和新意四个点,其中的独特性和新意都是逆着熵增的过程,其过程是非常难受煎熬的,可能做了大量付出也没结果,因为逆熵增是非线性的,无法预测的,只有在偶然的机会才会有开挂的感觉。对于真实感和故事性属于线性积累,和阅读别人的文章一样,是很容易做到的事情,都是熵增过程,有必要,容易看到结果,但会有内耗,如果没有更多独特性的经历来逆熵,可输出的内容会越来越混乱,落后,渐渐无用。因此独特性、真实感、故事性和新意这四个点之间需要平衡与演进,才能够保持进化的活力。

接下来我就详细展开来跟你说说独特性、真实感、故事性和新意四个点,通过我以前写的一些文章来详细说明。如果你还不知道如何下笔,我还会介绍一个容易着手去做记录和分享输出的方法步骤,最后会从头到尾举个例子按照前面介绍的步骤演示如何写完一篇技术文章。特别是那些逆熵的过程,让你能够多些体感。

四个点

先分别介绍下这四个点。

独特性

独特性也就是自己的经历和体验,这个是独一无二的,文章的内容如果有更多的个人经历,作为读者也就能够体验到更多的生活。

行万里路,多去做不同事情,多尝试不同方法,也就能够获取到更多的经历。独特性是内容中最重要的部分,如果这四点重要性共分十层的话,我认为独特性就可以占到六层。

真实感

记得一个美剧编剧分享过他写编剧的经验,其中提到要写的题材,他至少会花上一年以上的时间去收集和整理相关资料。他认为只有把题材相关细节都吃透了,编排到剧里,观众才会感觉更真实,代入感才会强,身同感受才会有共鸣。如果观众感到假,感觉不到用心,那他怎么会去了解你想表达的内容呢。

因此真实感是表达内容的基础,而且是最费时的。相较于独特的个人经历,真实感是需要花费大量时间去调研作者以前不了解的东西。而这个过程也是了解别人经历的过程,可以学习到很多以前不知道的事情。

真实感是对独特性的扩充,是丰富和挖掘沉淀独特性的,可以占到两层,后面的故事性和新意各占一层。

故事性

我很喜欢金庸的小说,特别是射雕英雄传、神雕侠侣和倚天屠龙记这三部,起初对金庸其它小说兴趣不大,也可能拍的电视剧不是我的菜。后来抱着爱屋及乌的态度,我尝试着打开笑傲江湖小说的开头开始读,一下子就被吸引进去了。不得不佩服金庸写故事的能力,太强大了。故事一开始制造了一个令人无法解释的案件,你会非常好奇的一直看下去,很想知道到底发生了什么。小说都进行了很长的部分令狐冲才出现,主角出现前还能吸引你看下去,可见金庸讲故事能力有多厉害。

自从迷上金庸的小说后,我也会试着写些小故事,同样我会注重把一些自身独特的经历穿插到写的小故事里,这些故事我发到了我的博客上,有白龙班十中白芈花野

故事性是一种技巧,是线性的,很容易通过大量积累掌握好,最终是好是差还是强依赖于独特性和真实感。

新意

新意这个点非常关键和重要,也是演进的重要因素,你仔细想想看,很多深度高的文章其实底层知识都是差不多的,能够真正有翻天覆地突进的技术演进不会很频繁,而且这些技术往往都在硬件厂商和实验室中产生出来。对于已有底层知识的输出区别只是应用场景和组合运用技巧上有区别,精彩的发掘和效果奇佳的收益也能够获得掌声。因此技术知识和经验输出的形式也非常重要和关键,如果没有新意,大家势必会对那些知识感觉到疲倦,没人看,写作也就没有了动力。

关于新意可以看到淘系公众号最近使用了视频的方式来讲他们的技术,看起来就很有趣。这方面只有你想不到,没有你做不到,打开脑壳,充分发挥想象吧。

新意之所以只占一层,因为新意获得成功的概率较低,是非线性的,因此需要不断去尝试不同的方式。需要依赖天时地利人和以及前三个点都做的足够好了,新意才会取得非常好的效果。

我以前的文章

前面讲了四个我觉得写技术文章最重要的点,只是说了下理论上的逻辑,体感还不够强,下面我结合我以前写的文章我们一起来看看这些文章背后那些独特的经历吧。

A站 的 Swift 实践

《A站 的 Swift 实践》,当时发这篇文章时,关于 Swift 实践的文章也有很多,都是各厂自身实践经验,对于独特性这个点,开始想着把 A 站做过的事情说清楚就可以了,但是很多的经验和做的事情和其他厂做的差不多,这样写出来会没有什么特别的,所以需要着重说下做的和别人不一样的事情。A站比较有特色的是文章里提到的A站自研的声明式 UI Ysera 框架,这个是别人没有的,并且由于 Ysera 框架带来了和 SwiftUI 类似的优雅简洁,提升了整体开发的效率和体验。由于 A 站很早就进入了 Swift 开发模式,并且已有将近一半业务使用了 Swift 开发,所以 A 站相较其它厂走得更快些,对于 Swift 新特性运用的也更广,比如对于 Property Wrapper 的广泛应用,使得代码复杂度骤然降低。走得更远还表现在 Module 化上,A 站大半 Pod 都完成了 Module 化,这方面的经验也很多。

有了独特性,为了能够让阅读的人更有体感,需要对一些技术点进行进一步的描述,使得文章一方面能够让自己得到知识的总结沉淀,还能够对他人有用。这篇文章主要是在混编的内在原理上进行了剖析,这比只描述解决混编问题过程要更加通用些,同时也能起到授人以渔的目的。但掌握原理就需要去学习和提炼相关知识,所下的功夫也更大些。另外采用 Swift 的话,还有个绕不过去的担忧点需要面对,这就是 Swift 的动态性,Swift 这方面由于在 Swift 核心团队工作优先级中较低,相较于 OC 要弱和不成熟很多。所以关于动态化就要说清楚,说的全面点,最好是能够自己进行实验去验证,这个过程会往往枯燥漫长,需要较大的热情才能够完成。

关于故事性,故事性往往是用来引入读进去的一种办法,A 站的 Swift 实践这篇文章的开头通过讲述使用 Swift 的必要性、A 站为之付出的努力和收获、Swift 语言的演进的过程的方式尽量避开具体技术描述,而是使用通俗易懂的描述让读的人可以被轻松带入到文章中来。

深入剖析Auto Layout,分析iOS各版本新增特性

《深入剖析Auto Layout,分析iOS各版本新增特性》。写这个文章也是有着一段不同寻常的经历。那时刚到公司,所有布局都还是使用的 frame 方式,而 Auto Layout 苹果公司才推出不久,在另一位跟我一样新进公司熟悉 Auto Layout 同事的怂恿下,我打算在改版需求中使用 Auto Layout 来替换原有布局方式。但在需求开发刚开始时,那位熟悉 Auto Layout 的新同事突然离职了,我感觉失去了援手,但是我认可了这个技术,还是坚持使用它。期间碰到的苦难无数,布局思路带来了很多开发方式的改变,还有动画的结合会出现的各种效果不一致,其间公司老员工还不断劝我还是走老路比较稳妥。改版完后大部分主流程,包括首页发单、等待页、接单进行页都被改造成 Auto Layout。

更困难的事情还在后面呢,测试期间发现在 iOS6 上会出现各种崩溃、页面布局混乱、动画效果不一致等问题,我的 Bug 始终保持在 Bug 列表前十页。改 Bug 那些天,晚上调的眼发疼,深夜想的难入眠。线下 Bug 改完,上线后才是噩梦的开始,当时我们 App 的 iOS6 用户依旧很多,于是很多偶现崩溃被放大了,我的崩溃问题一直排在 Top1,虽然我很快找到了改好的办法,但是对于这几个偶现的问题还需要一个可靠可信服的解释,这样后面才能够让大家放心使用 Auto Layout。还记得当时周末坐在得实大厦窗户边的工位上,在查完和试完所有资料后依然无果时的无力感。本想着改回以前的 frame 布局算了,后又觉不甘。下几个周末跑到各大图书馆查看所有涉有 Auto Layout 的书,也是那个时候了解到了 VFL 语言。皇天不负有心人,WWDC 开始了,其中有个 Session 叫 Mysteries of Auto Layout,分为两个部分,把 Auto Layout 的原理讲得非常透彻了,至此,透过原理我也找到了问题的根因,并把他们记录在了文章中。这部分内容我还在一个沙龙做了分享,下面是当时分享的 Auto Layout 的原理部分的内容:

完整幻灯片参看这里

这些经验的总结在当时是非常新的,因为官方也是刚公布出其内部的原理,没有人能够更早的知道这些信息,估计也很少有人会考究这么多。有了这些由于一直坚持下来去找根因的经历才使得文章有了独特性。

当然,深入剖析 Auto Layout 这篇文章也加了 Auto Layout 的历史、生命周期、VFL 语言的介绍用来丰富内容的广度,以提升真实感,但你会发现独特性在这里显得尤为重要。

另外,在查找崩溃问题根因时,没有放弃,一直坚持的去找答案的过程也让我难忘。经常会听说到要去找自己热爱的事情,遵循自己所想。而实际上是那件热爱的事情是你愿意花很久甚至很多年需要克服痛苦,还能够继续忍耐,能忍他人所不能忍,赢过他人不是靠的热爱和能力,而是在万般艰难,别人都放弃而你坚持下来才赢的。巴菲特21年资产5000亿美元,其中4997亿美元是50岁之后赚到的,如果49岁那年他就不继续做了,那么他就不会有今年这样巨大的财富,就不会显示巨大的复利效应。

后来我还发现,不断坚持的一个窍门就是去庆祝大目标方向上的每个小小的成功,把这个小小的成功当成最后的成就那样去庆祝。

制作一个类似苹果VFL(Visual Format Language)的格式化语言来描述类似UIStackView那种布局思路,并解析生成页面

这篇文章 诞生的原因是我写了一个视图布局的库 AssembleView,通过这篇文章做了一个记录。这篇的独特性在于文章背后我特殊的经历。首先写 AssembleView 的起因在于之前大半年我使用自动布局写了大量的页面和一些动画,虽然有比系统更加简化的 Masonry 库可以使用,但是对于很早以前写过 H5 页面的我来说无论是从布局思路还有编写体验上,Masonry 依旧差的很远。苹果为自动布局发明的简洁 VFL 语言却没能用在更加先进的 UIStackView 布局思路上,于是在一次中午吃饭散步的过程中,我突然有了把 VFL 语言和 UIStackView 布局结合起来的想法,同时还想好了名字,叫做 AssembleView,也就是组装的视图的意思,心动不如行动,在接下来的一个需求周期中,我就着手一边开发 AssembleView 一边开发需求。每个需求只有一周的开发时间,当时需求只是更新评价的几个小页面部件,但为了将 AssembleView 运用进来,我把整个评价页面和功能进行了重写,包括标签云等复杂布局采用新库的重写。而这样的工作量仅在一周内完成了。

短时间完成 AssembleView 并应用到产品中,得益于 Deadline 的限制,设置时间节点,没有时间节点的目标那就是梦想,有了时间节点会让你保持一段时间专注,在限制的时间里,你没法去把事情做到方方面面都好,因此才会激发你,让你发挥自身的独特性,和别的不同,其实这种独特会让这件事情完成的更有价值。不要试着做最好的,而是力求做与众不同的。与众不同意味着创新,画草图和下笔写稿子都是创造的方式,这些过程不要去做雕琢、检查、取舍、反思这样的事情,而是释放自己的本能,去自由的发挥自己的积累和沉淀。艺术总是来自不完美,始于杂乱。

有了这样非同寻常的经历,使得这篇文章本身独特性的意义更大了。记录并分享,能够获得做着同样事情人的共鸣。

AssembleView 本身就是全新,因此从头到尾都是新意。

当时写这个库也是为了能够提高完成需求和维护需求的时间,有了精力才能够做更有趣有意义的事情嘛。五年后,苹果终于将 VFL 这种 DSL 语言运用 Swift 强大的 ResultBuilder 和不透明类型等特性进行了更好地完善,配合 Property Wrapper 和 Combine 还无缝衔接了先进的数据流架构,推出了 SwiftUI。

深入剖析 JavaScriptCore

《深入剖析 JavaScriptCore》 这篇文章要说独特性,那就是对 JavaScript 语言的好奇心。我很早就开始使用 JavaScript 来开发网站,工作和个人网站的前端都是依赖于这门语言,其实知情人都知道,选择 JavaScript 也是没有选择的选择。年轻时只顾着使用技术去做东西,也做了自己觉得非常有趣的程序,满足感十足,现在转向对其背后的机制技术好奇和感兴趣了。还有一个迫使自己去了解 JavaScript 引擎的原因是工作中做动态页面时需要用到对业务逻辑的解释执行处理。为了避免使用中出了问题会一脸懵,深入了解它显得很有必要。

光有想法是没有一点用的,JavaScriptCore 其实非常的庞大且复杂,当时能找到的大部分资料都是 Bridge 和 RN 的运用,好在开源了,了解内部的话还可以拉代码来看。但是直接埋进去看代码,代码量比较大,很容易 miss 掉其精妙之处。好在发现了 JavaScriptCore 项目核心开发者 Filip Pizlo,通过他的个人网站找到了大量 JavaScriptCore 的一手资料,没日没夜的啃内容,同时还试着动手去实现一些技术细节,最终了解和学习了很多解释器、虚机相关知识。获取一样东西带来的满足感是没有获取经验带来的满足感更深刻。我把学到的这些经验都记录在了这篇文章中,这使得文章的独特性更加深刻,真实感达到了满棚。

深入剖析 JavaScript 编译器/解释器引擎 QuickJS - 多了解些 JavaScript 语言

对于 JavaScript 引擎,我先前就看了 JavaScriptCore,为啥还要再去看 QuickJS 这个轻量的 JavaScript 引擎呢。写这篇文章动力主要还是对QuickJS如何使用精简高效的代码实现了那么复杂功能,还有极高的性能。QuickJS 基本是从头看到尾,一点一点的分析,整个过程也都记录了下来。但是我觉得记录源码的分析还不够,虽然这些分析使得文章的真实感很高,前提是读的人也会埋进代码里。为了提高文章的独特性和故事性,我在文章开头加入了一些 JavaScript 的一些背景内容,还有些当年使用前端技术的体会和经验。

只看代码不去修改和调试,往往会很枯燥,我在分析代码前,也写了些和 QuickJS 工程配置 makefile 相关的内容,并以 QuickJS 本身的 makefile 的用法进行举例说明。另外还手把手说明了怎么用 Xcode 来编译安装调试 QuickJS 代码,这些都是比较独特的内容。QuickJS 的核心代码基本都写在一个文件里,阅读分析时需要非常的专注,如果不专注,可能这篇文章也就没法写完。如果你花大量时间在家玩游戏、看电视剧和刷短视频,而不工作,那么就会有危机感和负罪感。但是如果会去工作,但是期间总会找着间隙去做其他事情,刷刷微博,看看朋友圈,瞅瞅新闻什么的,那就不会有负罪感,因为你会觉得你还是在工作着呢。没全力去工作,而在假装工作着,可比完全不工作的危害更大。一心一意的长时间去做工作外的事情,反而能够开眼界扩视野,从而反哺工作,工作的更好更开心。新时代就会有新机会,同时也会有新的要求,比如知识的获取从单一感官方式变成了动态的,多感官的方式。以前只是文字和图片的书和博客,新时代就是视频、直播和播客,新时代你有更多方式出现在大家面前,出现的更多就代表了成功。未来会有更多感官方式,而且更加的智能。获取知识的门槛低了,人群也就更广了,也可以理解为新的机会更大了,保持专注不设限去感受新时代,对自己不断做出新的要求,就会有新的机会。

深入剖析 iOS 编译 Clang / LLVM

这篇文章的原因主要来自在公司做的 App 安装包体积瘦身的事情,经过各种工具使用和分析后,总是找不到突破口。需求还在不断叠加,也没有好的思路。当你遇到困难时,做不成的人才会告诉你你也做不成,如果你真的想做成,有这个理想就要自己去守护他。遇到困难离成功才会更接近。那些困难来自于有限的资源,比如没人、没钱等,但是正是由于这些资源的限制,才会迫使你去创新,你会通过自己的热情还有毅力来寻找独特更有效的方法,所以由于有限资源带来的困难才会让你去突破、思变和进取。

就在百般无奈,各种资源条件受限的情况下。我想着看能不能把需要繁琐手动检查的动作试着写成程序自动完成。于是我用一个周末开发了查找无用方法工具,能够自动查找出工程中没有用到的方法,也兼顾了我们工程的一些运行时调用的方法检查。这样重复繁重的检查工作就变得轻松了很多。

工具开发完后,我发现这工具的实现并无相关成熟理论来进行支持,以后怎么完善和优化这个工具也没有一点思路。为此我还苦恼了蛮久的。

经过一位同事提醒,说大学有门编译原理的课里面就有讲怎么分析代码的,于是我就开始针对性的翻阅相关资料。于是乎,我发现了一片蓝海,这里面涉及到的技术不光是分析代码,还有很多以前不了解的程序怎么跑起来的细节,这里的知识就像可以无限递归的树,能够将你所有时间都吞没。这篇文章我更新添加内容的次数不下十次,每当get到了新的东西都忍不住记录下来。这期间动手去实践一些知识点,也遇到很多问题,解决这些问题的过程,对相关知识理解就更深入了。后来在17年的@Swift 大会上还做了 LLVM 相关内容的分享,下图是其中一张 Slide。

完整幻灯片参看这里

深入剖析 WebKit

为了完成网页到原生代码的转换,我开始学习 Web 的标准,而 WebKit 是苹果公司对 Web 标准实现,V8和 Flutter 渲染技术的源头,WebKit 的学习能够让我更完整的了解网页从请求到布局再到渲染的流程和使用的相关技术。WebKit的这篇文章我罗列了大量的 Web 规范资料,由于 WebKit 非常的庞大,架构也很复杂,文章里对架构也进行了详细的说明,对源码的结构做了详细的说明。全文按照一个页面从请求到最终渲染的流程顺序,依次对其关键环节里对应源代码和原理进行了详细的说明。完成这篇花费时间巨大,代码基本读了个遍。之后我对于前端技术有了更深的理解,特别是页面异步加载的流程和布局原理。

感觉这篇完全靠的是对前端技术的热情完成的。手冢治虫说过,那些投稿的人,都是热爱着漫画,把画出一部作品作为自己生命意义的人。所以他们才能获得成功,成为马拉松里跑到最后的人。热情可以增加25个 IQ 值。如果一个人仿佛开悟了的高僧,失去任何欲求、愿望、不甘、烦恨与伤痛,那么即使他去画漫画,即使基因再好,天赋再高,也只会画成佛教的禅画罢了。在比尔.布莱森在《人体简史》这本书中提到一个镜子相关的实验,实验来自一名防碎眼镜商人,在1980年创办了胚种精选择库(Repository for Germinal Choice),这个精子库只有诺贝尔获奖者和其他杰出知识权威的镜子。他想的是能够提供最好的精子生出天才婴儿,结果是在出生的200名儿童里,没有一个杰出天才,甚至连一个眼镜工程师都没能造出来,可见对做的事情有热情更加重要,而不仅仅只要基因好就行。

深入剖析 iOS 性能优化

性能这篇最重要的是独特性,开始只是针对日常开发性能需要注意的一些点进行了归纳总结,后来需要对启动项进行分析,于是做了分析的工具,其间我无意多查看了下 thread_basic_info_t 这个结构体里的字段,发现了 cpu_usage,觉得日后必有用,于是留了个心眼。后来负责性能的同学看了我的这篇文章,跑来找我,跟我说 App 连续几个版本都有线上反馈耗电太大,他们自己也很容易复现出来。这几个版本调整了定位频率,排查了各种怀疑的点,电量消耗依然很大。起初我也没有思路,instruments 也看不出问题来,于是我使用分析启动项的方法,查看运行中方法调用次数,排序来看谁调用的频繁,后来发现调用频繁的方法数量太多很难排查定位。

这时先前留意的 cpu_usage 字段起来关键的作用,通过定时刷新获取线程中 CPU 使用情况,连续高使用就揪出详细线程堆栈,后来小范围灰度上线检测,直接定位到了问题的堆栈,很快的解决了这个大难题。而且有了这个手段,后面也有了底气,在遇到问题也不会慌了,而且线下也可以使用这个方法进行压力测试,以免把问题带到了线上。这个方案也更新记录到了文章中,有了这个不寻常的经历,文章也就有了很强的独特性。

启动

关于启动我写了两篇文章。第一篇是《如何对 iOS 启动阶段耗时进行分析》,另一篇是《App 启动提速实践和一些想法》。说起这两篇的独特性,那绝对是独一无二。我负责的这场血淋淋的战役真可以说是毕生难忘。项目起因不用猜也可以想到,启动速度持续劣化,导致用户体验变差,落后对手一倍,提速困难重重。临危受命,当时想到的只有一个字,那就是干。

开始最难的还是定方向和定策略以及决策。明确了整体的思路,所有任务就开始并行跑起来了。由于项目的重要性不言而喻,因此投入资源巨大,不光是我的人都参与了进来,还有很多其他团队也一起加入。停下所有手上低优事情,握紧拳头全力打赢关键战役。要的就是能够速战速决,一旦拖延,不光是士气没了,结果没达成,还会留下一堆烂摊子难有资源去清理。

由于初期谋划的方案全面、稠密以及有效,多个团队通力合作配合奇佳,使得在三周内超预期达成了目标,不光是领先了对手一倍,还比大部分头部 App 都要快。这三周说长不长,说短也不短,大量的开发、调试、工具设计开发、数据分析、检测和验证工作集中式的进行,对体力和脑力都是极大的挑战,且压力巨大。

第一篇记录了前期的策划内容以及一些提效工具的开发过程。对这三周干的事情进行了沉淀,沉淀的是一次独特的成功经验。第二篇是在一年后写的,更多的是记录了这一年我对启动这件事情的思考,一年时间的经历也很多,还主负责过包体积的项目,所以内容就显得更加丰富了些,有记录些对性能和调试工具的研究。

第二篇文章里我提到我发现了一个宝藏男孩Michael Eisel,发现了很多二手资料都是源自他的博客。另外由于这一年也发现了性能防劣化中,自动化分析工具和能力相关技术了解的不够深入。于是专门去探索了下这方面的情况。对于目前为了保持双端一直 libimobiledevice,我发现了 Facebook 专门针对苹果系统开发的 idb,idb 做法明显更聪明些。

这些探究的过程至少是独特的。更独特的地方是文中写的那个A库多线程问题的排查经历。痛苦的经历我已在文中清晰详细的记录了,历时三天三夜,当大家试完所有情况,士气全无时,才柳暗花明又一村。全因苹果的一个 bug。经历这么一遭,对于 GCD 的队列排查定位问题难这点,我看国外对 iOS 并发开发方式吐槽的声音也很大,于是我很想了解多线程问题苹果未来会怎么处理。这就有了文中 Swift 并发提案部分的分析。当时这份提案还未进入正式流程(现在已经在 Swift 5.5正式发布了),未来并不明朗,我也担心会遗漏关键信息,于是对涉及相关的提案都进行了阅读,包括那些提案下所有的评论也都看了。

这两篇文章跨越了整整一年时间,这一年期间我基本没有写其他的文章,但是却沉淀了很多,所以第二篇实际上可写的内容非常多,一口气挑着重点的说了一大篇后,还删减了大量内容。写完第二篇我感觉到化繁为简的巨大好处。自己做的记录、素材和资料往往都是大量的,深究下去都是无穷无尽的感觉。因此需要从中提炼出自己的观点。从那么多内容中提炼出观点是需要足够的休息和放松,让你的潜意识主动来帮助你。这些休息和放松也可以是在日常的行为中,比如洗澡、去超市买东西、骑自行车、走路、锻炼、吃饭和睡觉等,特别是走路和睡觉持续时间长,最容易进入深度思考。不断给自己提问题进而更大量的阅读找答案,思考内在逻辑和联系。发散的找,专注的收敛提取观点,这样的观点是用钱买不到的。

通过大白话讲清楚,分享出去,这样的观点在他人接收时是自然地,意识不到其背后所花的时间和功夫,这就跟优秀的 App 一样,用起来是那么简单有效,丝毫不拖泥带水,用户也意识不到开发 App 所付出的脑动。这种化繁为简的过程也是将无序杂乱的东西清理掉,让你宝贵精炼的思想能够有地方存放。

灌篮高手中流川枫打篮球行云流水,天赋异禀。背后的努力谁又能知晓。我印象最深的一段是樱木花道为了取得晴子芳心,但始终技不如流川枫,总以为是天赋不够。一天晚上樱木花道很晚来到篮球馆,发现流川枫还在苦练,才发现原来白天看起来懒散傲慢的流川枫原来比谁都要刻苦,简单轻松从来都不是廉价的。

对于分享,有智慧的人都懂得给予越多收获越大。友情比金钱价值更高,就好像有一个开电影公司的朋友比拥有一家电影公司要好。分享不是要得到他人的认可,如果你知道这点,你拥有的能量就是无穷的,力量也是无敌的。

没怎么写过,那下一步怎么行动

看到这里,你一定会想“看你说了那么多,但我双手放在键盘前,脑袋还是一片空白,无从下手”。

如果想帮其他人,让他真的动手去做些什么事情,其实更应该是要让做这件事情变得容易很多倍,但方向是一样的,这样下次他就更好接受些。互联网开始发布内容门槛高,后来有了微博和朋友圈这种能够一句话就快速发布出去的产品后,大家发内容就比以前更多了。去读资料和文章,可以懂更多的知识,自身能力还是需要通过练习才能够有提升。想把事情做好,还是需要去做。

因此你应该更重视动手写,如果你不知道如何写,可能就不知道如何思考。有叛逆和逆向思维的人常常是爱问问题的人,爱自问爱思考,对那些已经共识正在运作的事物提出疑问,寻找和关注答案,这样才会有打破现状的意识。一些人小时候就能看到有这样的特点,因此在别人教你怎样怎样做时,不要太当回事,相信自己实践出来的答案。多听你喜欢人说话,多倾听,不断问还有没想说的。

还要从各种类型人那学习,甚至是和你观点不同的人。因为在每个人坚持的思想里,都会有他自己独特的经历和实践总结来的结论。通过他们的结论,你也可以自己去实践和验证形成自己的观点,这样就会有复利效应。做的结果其实并不重要,重要的是在做的过程中,你自己有没有变得更好。

你说的话,你的观点,你的评论都不能代表你,而是你所做的事情,花了很多时间做的事情那才是你。改变一个人的行为来改变思维,比改变一个人的思维来改变行为要容易的多很多。

因此,光看光听不动手写是没用的。那行动起来的话,怎么做更好些呢?

四个步骤

第一步,零散的想法、工作内容和看到的好的技术资料及时记录,先按照时间轴的方式记录。这一步是很容易操作的,几乎不用费脑,只需要机械的做记录就行,也不用考虑先前提到四个点里任何一个。

第二步,对于记录的内容进行分类,开始粒度可以粗一点,比如性能、架构、构建、编程语言、管理、成长、旅行和科技等,根据自身兴趣点和期望发展方向来就好。

第三步,做完一个项目,或者想对先前做的事情进行总结时,先一口气快速写出想表达的内容出来,这时写的内容体现出独特性,搭好骨架。然后针对写的内容中的一些技术点,进行真实感的完善。真实感的完善是需要很多素材和资料的,这时在第一步和第二步做的工作和积累就能够派上用场了。找到相关大分类进行细分来补充文章的血肉。

第四步,也是最后一步,可以充分发挥自己软实力和创造力,通过故事性和新意来披上文章的皮肤,让文章能够看起来更加完整和吸引人,提高阅读的体验。

完整完成这四个步骤并不容易,经常就会因为惰性半途而废。这时就需要 push 自己一把,方法的话,我这边的经验就是定目标,定时间节点。比如定好一个对外分享的时间,这样目标性更强,同时也有了约束和责任,自己的惰性在这一段时间内就能够得到很好的消减。

为了达成目的,彻底理清你想要啥,还需要清空干扰,方法很简单,除了当前最重要的事情,其它所有待做事情都记在备忘录里以便追踪防止遗漏。完成当前事情后,再去查看备忘录,然后定新目标新计划。

完成文章后可以通过下面八个问题来检查下文章的完成度。

  1. 我为什么做这件事?
  2. 谁已经做了?他们都是怎么做的?效果怎样?
  3. 我和他们做的不一样在哪?怎么想到的?能详细具体说出涉及相关知识点吗?(⭐️重点,写好了的话,其他问题可有可无)
  4. 我碰到了什么困难?
  5. 我怎么解决的?
  6. 做的有亮点吗?为什么是亮点?
  7. 做完后效果是怎样的?超预期地方在哪?
  8. 以后还有计划打算吗?为什么?

所用软件

下面是我写文章会用到的一些软件,以及我关注和用到的一些特性:

系统自带备忘录

  • 零散想法和灵感记录
  • 待做事项记录(一个一个直接删掉的感觉不错)
  • 聚焦想法思路,不用去考虑分类整理等

熊掌记

  • 本地文档管理(多设备同步收费)
  • 标签系统简化分类

Notion

  • 在线文档管理
  • 数据库方式管理,分类、检索和排序
  • 字段自定义添加,比如标签、类别、链接、标题等等都可以自定义
  • 基于数据库和自定义字段可生成看板、时间轴、日历、列表、表格、网格等不同视图样式查看。
  • 有chrome插件

VS Code

  • 本地文档管理(文件夹,Git支持可多端同步)
  • Markdown 插件支持(Markdown All in One、Pangu-Markdown、Markdown Preview Enhanced、Word Count CJK)

Obsidian

  • 本地文档管理(文件夹)
  • Markdown原生支持
  • 插件系统,比如有大纲和看板等插件可用
  • 双向链接与关系图谱

Procreate

  • 可以把在纸上的草图配上颜色

软件使用上,我会通过备忘录或熊掌记快速记录一些素材和想法,定期挪到 Notion 里,我是把 Notion 当做一个大仓库,写作的第二阶段整理分类我就是在 Notion 中完成的,充分利用 Notion 的自定义字段能力,对所有资料进行各种维度划分和归档。开始写文章时,初期会用 VS Code 来写,如果文章写长了就会打开 Obsidian 来继续写,主要是 Obsidian 的大纲效果比较好些。最后文章的配图我会使用 Procreate 来画,里面有辅助线,打开后可以很方便做参照,写图中文字就不容易偏了。

工具只是工具,记录的内容和自己的思想才是核心。我现在读书还是喜欢在纸上写笔记,特别有感触的才会提炼一些观点敲到备忘录中,比如我看了网飞(Nexflix)的《不拘一格》后提炼了一些观点做了记录,笔记如下:

制度都是围绕着怎么不阻碍所要的人发挥。比如假期自由安排、无审批、决策权非自上而下,而是在认识一致情况下松散耦合。要和不要什么样的人呢?不要的人:与人相处好,但能力平平工作狂,缺少判断力天资好,行动力强,但悲观、牢骚有才华的混蛋:特征听到赞美就自觉优秀对想法不明智的人,会进行嘲笑会侮辱天赋不如自己的人表现喜欢会上慷慨陈词,重复表达自己观点如没抓住他的要点,会打断别人的话别人发言,不赞同时会不听,做自己的事情别人啰嗦,没抓住要点,立刻打断总想着怎么做才能表现好,得奖金,缺少开放的认知空间为什么:管理花费精力多,讨论质量低,会排挤卓越员工。要的人:非凡创造力、工作出色(完成繁重任务)、合作好在放松状态下,会灵光乍现公司利益至上自觉追求成功,无论是否有奖金(已给予能力匹配市场最高价)当某一固定思维遇到瓶颈时,他总有办法摆脱瓶颈,或尝试不同角度看待问题在有才能,受爱戴的前提下,自己犯错大声说,成功小声说,让人感觉亲近、真诚和体贴。有良好的判断力为什么:优秀的人激励其他优秀的人,出色成果感染更多人才。只有公司里的员工都是上面提到的要的人时,公司的密度才高。这样的公司不是家庭而是专业运动队,运动队追求卓越,每个位置都是最佳人选;训练就是为了胜利,大家都能给予和接受反馈;成绩要好,不能只用努力就够了。书中详细介绍了网飞的制度由来,大量员工的实际案例,碰到了问题如何完善了制度。非常全面进行了制度介绍,甚至包含了进行创新的几个步骤的详细说明,还有网飞创始人里德是如何做到让大家认识一致的。最后是书中引用的小王子那段:如果你想造艘船,不要老催人去采木,忙着分配工作和发号施令。而是要激起他们对浩瀚无垠的大海的向往。

举个例子,怎么写这次WWDC21的见闻文章

光说不练,这样不好吧,那就现举个例子,看看怎么按照上面的四个步骤一步一步写一篇技术文章。那就以现在刚开完的 WWDC21 为主题,写个《WWDC21我的见闻》吧。

首先我们先做第一步,从 WWDC21 开始,我就将我看到的信息、还有看感兴趣 Session 中有用的点都记录了下来,只考虑是否要记,二不考虑其它任何事情。你可以看我WWDC21第一天的记录,我将其发到了我的博客和公众号上。后面几天我也不断的收集记录着零碎的信息。然后对这些记录进行分类。接下来再开始内容的撰写。

写 WWDC21 见闻录,你可以先想想着你想要什么内容,有没人提供,有的话可以直接链过来,没有的话可以自己去体会,去想,去经历,然后分享出来。

我会先写个总览,内容如下。

总览

WWDC21 官方通过一个页面汇总了发布的新技术,详见这里。WWDC21 里的代码范例官方都有提供和汇总,详见这里。WWDC21 期间苹果也列出了苹果公司之外围绕 WWDC 其它组织的学习、交流和娱乐的活动。

如果没有太多时间看 Session 视频,也可以直接看其他人的笔记,国外有WWDC NOTES,国内有老司机技术周刊的WWDC21 内参。往届内容也有人做了汇总

简单笔记可以查缺补漏,Alejandro Martinez 在这篇文章WWDC21 notes中对各种主题做了简单的记录,列出了关键字方便检索。

Session推荐

全部 Session,在这里查看。这里有份推荐清单。我也列了下我关注的 Session。如下:

SwiftUI 相关 Session:

Swift Concurrency 相关 Session:

DocC:

其它感兴趣的 Session:

Swift 的一些更新

Paul Hudson 的这篇What’s new in Swift 5.5? 已经把这些更新说的非常详细了,每个更新点都有对应的例子可以试。今年苹果公司推出 AttributedString 用来替代 OC 时代的 NSAttributedString。AttributedString 是值类型,可以直接在 SwiftUI 的 Text 里使用。AttributedString 还支持简单的 Markdown 语法,Markdown 单行没问题,多行功能受限。

DocC 是通过 Xcode 编译后生成的文档,使用 Product -> Build Documentation 就会生成DocC。在函数接口代码上使用 Shift+Cmd+A 快捷键就会创建文档模板,有参数和返回值的话也会将其提取出来,包括参数类型等,并生成标准文档格式,方便你进行内容编写。基本 Markdown 语法是支持的。详细的介绍可以看前面列出的官方 Session,或者看这篇文章How to document your project with DocC

今年重头戏 Swift Concurrency

ABI 稳定后,Swift 的核心团队可以开始关注 Swift 语言一直缺失的原生并发能力了。最初是由Chris Lattner在17年发的Swift并发宣言,从此开阔了大家的眼界。后来 Swift Evolution 社区讨论了十几个提案,几十个方案,以及几百页的设计文件,做了大量的改进,社区中用户积极的参与反馈,Chris 也一直在 Evolution 中积极的参与设计。

Swift Concurrency 的实现用了LLVM的协程把 async/await 函数转换为基于回调的代码,这个过程发生在编译后期,这个阶段你的代码都没法辨识了。异步的函数被实现为 coroutines,在每次异步调用时,函数被分割成可调用的函数部分和后面恢复的部分。coroutine 拆分的过程发生在生成LLVM IR阶段。Swift使用了哪些带有自定义调用约定的函数保证尾部调用,并专门为Swift进行了调整。

Swift Concurrency 不是建立在 GCD 上,而是使用的一个全新的线程池。GCD 中启动队列工作会很快在提起线程,一个队列阻塞了线程,就会生成一个新线程。基于这种机制 GCD 线程数很容易比 CPU 核心数量多,线程多了,线程就会有大量的调度开销,大量的上下文切换,会使 CPU 运行效率降低。而 Swift Concurrency 的线程数量不会超过 CPU 内核,将上下文切换放到同一个线程中去做。为了实现线程不被阻塞,需要通过语言特性来做。做法是,每个线程都有一个堆栈记录函数调用情况,一个函数占一个帧。函数返回后,这个函数所占的帧就会从堆栈弹出。await 的 async 函数被作为异步帧保存在堆上等待恢复,而不阻碍其它函数入栈执行。在 await 后运行的代码叫 continuation,continuation 会在要恢复时放回到线程的堆栈里。异步帧会根据需要放回栈上。在一个异步函数中调用同步代码将添加帧到线程的堆栈中。这样线程就能够一直向前跑,而不用创建更多线程减少调度。

Douglas 在 Swift 论坛里发的 Swift Concurrency 下个版本的规划贴 Concurrency in Swift 5 and 6,论坛里还有一个帖子是专门用来征集Swift Concurrency意见的,帖子本身列出了 Swift Concurrency 相关的所有提案,也提出欢迎有新提案发出来,除了这些提案可以看外,帖子回复目前已经过百,非常热闹,可以看出大家对 Swift Concurrency 的关注度相当的高。

非常多的人参与了 Swift Concurrency 才使其看起来和用起来那么简单。Doug Gregor 在参与 John Sundell 的播客后,发了很多条推聊 Swift Concurrency,可以看到参与的人非常多,可见背后付出的努力有多大。下面我汇总了 Doug Gregor 在推上发的一些信息,你通过这些信息也可以了解 Swift Concurrency 幕后信息,所做的事和负责的人。

@pathofshrines是 Swift Concurrency 整体架构师,包括低级别运行时和编译器相关细节。@illian是 async sequences、stream 和 Fundation 的负责人。@optshiftk对 UI 和并发交互的极好的洞察力带来了很棒的 async 接口,@phausler带来了 async sequences。Arnold Schwaighofer、@neightchan@typesanitizer还有 Tim Northover 实现了 async calling convention。

@ktosopl有很深厚的 actor、分布式计算和 Swift-on-Server 经验,带来了 actor 系统。Erik Eckstein 为 async 函数和actors建立了关键的优化和功能。

SwiftUI是@ricketson_@luka_bernardi完成的async接口。async I/O的接口是@Catfish_Man完成的。@slava_pestov处理了 Swift 泛型问题,还指导其他人编译器实现的细节。async 重构工具是Ben Barham 做的。大量代码移植到 async 是由@AirspeedSwift领导,由 Angela Laar,Clack Cole,Nicole Jacques 和@mishaldshah共同完成的。

@lorentey负责 Swift 接口的改进。@jckarter有着敏锐的语言设计洞察力,带来了语言设计经验和编译器及运行时实现技能。@mikeash 也参与了运行时开发中。操作系统的集成是@rokhinip完成的,@chimz提供了关于 Dispatch 和 OS 很好的建议,Pavel Yaskevich 和
@hollyborla进行了并发所需要关键类型检查器的改进。@kastiglione、Adrian Prantl和@fred_riss实现了调试。@etcwilde@call1cc实现了语义模型中的重要部分。

@evonox负责了服务器Linux 的支持。@compnerd将 Swift Concurrency 移植到了 Windows。

Swift Concurrency 模型简单,细节都被隐藏了,比 Kotlin 和 C++的 Coroutine 接口要简洁很多。比如 Task 接口形式就很简洁。Swift Concurrency 大体可分为 async/await、Async Sequences、结构化并发和 Actors。下面展开说下。

async/await

通过类似 throws 语法的 async 来指定函数为异步函数,异步函数才能够使用 await,使用异步函数要用 await。await 修饰在 suspension point 时当前线程可以让给其它任务执行,而不用阻塞当前线程,等 await 后面的函数执行完成再回来继续执行,这里需要注意的是回来执行不一定是在离开时的线程上。async/await 提案是SE-0296。如果想把现有的异步开发带到 async/await 世界,请使用 withCheckedThrowingContinuation。

async/await 还有一个非常明显的好处,就是不会再有[weak self] dance 了。

Async Sequences

AsyncSequence 的使用方式是 for-await-in 和 for-try-await-in,系统提供了一些接口,如下:

  • FileHandle.standardInput.bytes.lines
  • URL.lines
  • URLSession.shared.data(from: URL)
  • let (localURL, _ ) = try await session.download(from: url) 下载和get请求数据区别是需要边请求边存储数据以减少内存占用
  • let (responseData, response) = try await session.upload(for: request, from: data)
  • URLSession.shared.bytes(from: URL)
  • NotificationCenter.default.notifications

结构化并发

使用这些接口可以一边接收数据一边进行显示,AsyncSequence 的提案是SE-0298(Swift 5.5可用)。AsyncStream 是创建自己异步序列的最简单的方法,处理迭代、取消和缓冲。AsyncStream 正在路上,提案是SE-0314

Task 为一组并发任务创建一个运行环境,async let 可以让任务并发执行,结构化并发(Structured concurrency,提案在路上SE-0304)withTaskGroup 中 group.async 可以将并发任务进行分组。

Actors

我们写的程序会在进程中被拆成一个一个小指令,这些指令会在某刻会一个接一个同步的或者并发的执行。系统会用多个线程执行并行的任务,执行顺序是调度器来管理的,现代多核可以同时处理多个线程,当一个资源在多个线程上同时被更改时就会出问题。并发任务对数据资源操作容易造成数据竞争,以前需要手动放到串行队列、使用锁、调度屏障或 Atomics 的方式来避免。以前处理容易导致昂贵的上下文切换,过多线程容易导致线程爆炸,容易意外阻断线程导致后面代码没法执行,多任务相互的等待造成了死锁,block 和内存引用容易出错等等问题。

现在 Swift Concurrency 可以通过 actor 来创建一个区域,在这个区域会自动进行数据安全保护,保证一定时间只有一个线程访问里面数据,防止数据竞争。actor 内部对成员访问是同步的,成员默认是隔离的,actor 外部对 actor 内成员的访问只能是异步的,隐式同步以防止数据竞争。MainActor 继承自能确保全局唯一实例的 GlobalActor,保证任务在主线程执行,这样你就可以抛弃掉在你的 ViewModel 里写 DispatchQueue.main.async 了。

Actors 的概念通常被用于分布式计算,Actor 模型参看Wikipedia里的详细解释,Swift 中的实现效果也非常的理想。Actors 的提案SE-0306已在 Swift 5.5落实。

很多语言都支持 actors 还有 async/await,实现的方式也类似,actor 使用的不是锁,而是用的 async/await 这样能够在一个线程中切换上下文来避免线程空闲的线程模型。actor 还利用编译器,提前做会引起并发问题的检查。

actor 是遵循 Sendable 协议的,只有结构体和 final 类才能够遵循 Sendable,继承于 Sendable 协议的 Excutor 协议表示方法本身,SerialExecutor 表示以串行方式执行。actor 使用 C++写的,源码在这里,可以看到 actor 主要是通过控制各个 job 执行的状态的管理器。job 执行优先级来自 Task 对象,排队时需要确保高优 job 先被执行。全局 Executor 用来为 job 排队,通知 actor 拥有或者放弃线程,实现在这里。由于等待而放弃当前线程让其他 actor 执行的 actor,在收到全局 Executor 创建一个新的 job 的通知,使其可以进入一个可能不同线程,这个过程就是并发模型中描述的 Actor Reentrancy。

Swift Concurrency相关提案集合

所有相关提案清单如下:

学习路径

如果打算尝试 Swift Concurrency 的话,按照先后顺序,可以先看官方手册介绍文章Concurrency。再看Meet async/await in Swift这个Session,了解背后原理看Explore structured concurrency in Swift。动手照着试示例代码,看Paul的Swift Concurrency by Example这个系列。接着看Protect mutable state with Swift actors来了解 actors 怎么防止数据竞争。通过Discover concurrency in SwiftUI看 concurrency 如何在 SwiftUI 中使用,Use async/await with URLSession来看怎么在 URLSession 中使用 async/await。最后听听负责 Swift Concurrency 的 Doug Gregor 参加的一个播客的访谈,了解下 Swift Concurrency 背后的故事。

Swift Concurrency 和 Combine

由于 Swift Concurrency 的推出和大量的 Session 发布,特别是AsyncSequence的出现,以及正在路上的AsyncStream、AsyncThrowingStreamcontinuation提案(在Xcode 13.0 beta 3 AsyncStream 正式release),这些越来越多和 Combine 功能重叠的特性出现在 Swift Concurrency 蓝图里时,大家开始猜测是否 Combine 会被 Swift Concurrency 替代。关于未来是 Swift Concurrency 还是 Combine,我的感觉是,Combine 更侧重在响应式编程上,而响应式编程并不是所有开发人员都会接受的,而 Swift Concurrency 是所有人都愿意接受的开发方式,从 Swift Concurrency 推出后开发者使用的数量和社区反应火热程度来看都比 Combine 要大。在苹果对 Combine 有下一步动作之前,我还是更偏向 Swift Concurrency。

见闻写到这里,把独特性比作骨架,真实感比作血肉,故事性和新意比作皮肤,你会发现没有写出自己的经历的话,就像进击巨人里的那些小巨人,即使有了完整的皮肤,但骨头架子不大是不会有开头踢破大门的只有骨架和血肉的巨型大巨人那么强大且震撼有力。

那么接下来我就描写一些我在 WWDC21 期间独特的一些经历。

WWDC.playground直播活动

想想 WWDC21 过程中我还是有些经历,比如参加了苹果官方推荐的外围活动WWDC.playgournd by SwiftGG

连续看了5天活动直播,还参加了一天的 Live Coding 介绍 SwiftUI 的新特性。直播 Live Coding 准备的时间很少,而且以前我还没有现场当着几千人面写代码的经历,直播前一天晚上赶着通宵达旦看完了相关 Session,写了些代码样例测试,当天白天还开了一个很长的会,回家前和同事讨论一个技术问题时,我发现我嗓子还哑了。到家坐在桌前脚还抽筋了,你可想象到我当时内心有多崩溃。

在直播前,我还专门的给思琦先演练了一遍,其中在介绍 AsyncImage 处理失败、空白、成功还有默认情况时,编译器报错提示无法找到原因,还提示让我提交 bug 的错误信息。直播开始前一直没有找到原因,重新敲了一遍才解决,所以心里没底,直播开始时还一直担心这个问题会重现。直播时在写到这段时果然编译器错误又出现了,当时我脑袋一片空白,心中大呼救我。好在没多一会我突然发现先前一段演示的 placeholder 接口没有删掉,原因真的就是这个,删掉后就正常了,别提有多开心了。后面就轻松了很多。由于只有一天时间准备,很多内容准备了,当时一边敲代码一边说也漏说了很多,比如 AsyncImage 使用的是 URLSession,用的是 URLCache,还不能自定义缓存。Refreshable 只能用在 List 里。SwiftUI 和数组绑定的代码是可以兼容前一个版本的。

另外还有个 WWDC 期间很火的老系统UI挑战赛让我印象深刻,其中有个18岁小伙用 SwiftUI 开发了经典 iPhone4可用版本最火爆,Github 地址在这里

SwitUI 新特性太多了,直播没提到的还有 task modifier、separator、macOS 上的 table、Canvas、preview in landscape、@FocusState、more button 等等。当时直播有回放,可以在这里看。更完整详细介绍建议看前面提到 SwiftUI 相关 Session。

WWDC.playgournd 最后一天直播有场 WWDC21 学生挑战赛获奖者张紫怡的分享,她分享了怎样准备挑战赛的过程,通过详细的过程介绍,心得体会,还有思考,让大家了解到了她的热情和才华,而且分享的形式和效果非常有新意。最后一场的回放看这里。看完这场后,我打算在19号 SwiftGG 和快手中学合办的 WWDC 沙龙活动中使用一种不同的方式进行分享。原先打算的是使用先前写好的一个示例展示使用 SwiftUI 开发复杂应用如何快捷,同时介绍背后的技术。几天想来想去,反复推敲推翻,一直没有新思路。最后到了前一天,我有了个主意,可以使用 SwiftUI 来编写一个幻灯片程序来分享 SwiftUI 的内容啊,同时还能够分享这个幻灯片开发过程心得,这样才有独特性和真实感嘛。于是把准备了一年的内容都删了,就像当时启动那篇删得只剩一万字的文章一样,那篇文章发布前共删掉了四万个字。

WWDC沙龙活动

可想法总是很容易,实践起来却又是另一种情况。我对自制幻灯片的初步设想是第一能够前后翻页展示内容,第二能够支持和 Keynote 不一样的动画效果和页面美化,第三能够直接在幻灯片上进行一些 SwiftUI 功能的交互演示。

接下来就要开始实际去做了,我先拿出上周用铅笔在 A4 纸画的人草图加工来丰富展示,发现加工的时间来不及了,虽然现在加工速度比以前快了,但是时间太紧,还要写幻灯片程序呢。SwiftUI 开发确实快,每个页面我都写成一个 View,标题、大纲和示意图的组合我做成了通用 View,通过传入不同标题、大纲数组和图片数组来展示不同页面的内容,定义一个 ObservableObject 的类 GlobalStateInfo 作为 View Model 来存储需要的状态数据,比如当前在哪页,当前文字颜色,当前页背景颜色等,每个 View 里使用 @EnvironmentObject 就可以去获取和设置 GlobalStateInfo 了。

关于为了传递数据,是直接调用 EnvironmentObject,还是通过子视图传递 ObservedObject,两种方式哪个更好,在 WWDC21 的 Digital Lounges 里,苹果工程师的回答是两者用途不同。当大部分 View 都需要用到一些通用数据时,推荐使用 EnvironmentObject,因为没有实际使用 ObservableObject 的 View 不会被与之相关的代码搞乱。如果模型不是基于 View 层次结构的对象图,使用 ObservedObject。另外还有个 Digital Lounges 的问题,是问怎么从旧的 AppDelegate/SceneDelegate 生命周期转换到新的 SwiftUI 2 生命周期。苹果工程师说可以使用 UIApplicationDelegateAdaptor 属性包装器,SwiftUI 将实例化你的 UIApplicationDelegate 的一个实例,并以正常方式调用它。更详细的解答和其他的话题可以参看这篇SwiftUI Lounge QAs,内容都是 roblack 从 Digital Lounges 里摘出来的,WWDC21 那几天我也在 Digital Lounges(报名早)看大家和苹果工程师的互动,后来看别人说 Digital Lounges 的 SwiftUI 那场爆满,已经超负荷运转了,感觉苹果最近变得更开放了,很多苹果工程师都开通了 Twitter 账号在 WWDC 期间积极和大家互动。

为了使页面不单调,我打算每页大纲的颜色做些区分,发现11页每个都配一遍看效果时间太紧,于是我选择了一些背景色通过随机读取,每次看到的颜色都是不同的,由于都是一个一个手动选出来的,所以不同组合效果也不会太差。

现在前后翻页展示内容这个想法是完成了,这也是 SwiftUI 开发的优势,能够快速构建页面架子和简单的数据页面同步设置。但是第二个想法,完成起来就非常费时费力且不那么顺利了。

首先说下字体,系统默认字体很正式,以往我都是直接用 iPad 手写,但是这次时间紧没法一个字一个字的写了,所以我打算选择其它字体,View 的 .font 修改器可以选择其它字体,方法是 .font(Font.custom(“font-name”, size: 110))。如果直接在Finder里查看字体没法得到可用的字体名,需要使用 NSFontManager.shared.availableMembers 来获取可用字体名。

接下来是标题,以往做幻灯,经常讲到具体内容时,特别是细节时,容易让看的人忘记当前页主题是啥。如果标题太大,可展示内容就少了,及时这样,观看的人也容易忽视主题。因此,我打算把标题做成一个循环的动画,这样就可以在我展开说内容的时候,看的人即使走神了还能够注意到当前页主题。标题的动画主要是控制好动画的时间,不能太快,不然会过于吸引注意。

以前 keynote 的转场动画我基本都试过,每次来回都是那些,很难和其他人做出差异来。只能靠图和配色作区分。这次我利用每页的内容大纲进入效果来作为转场动画。我先将大纲列表放到 VStack 里,ForEach 里获取到下标,通过下标获取列表数组里的 Text View。之所有要得到下标而不是直接获取列表数组里的 Text View,其原因是还会将这个下标用在转场动画效果上,我希望大纲列表的内容是一个接一个进来的,需要这个下标值来做时间间隔。Animation 的效果使用的是 interpolatingSpring,我将 damping 参数设置为0.3,这样弹性效果更佳。列表内容进入的是 GeometryEffect 协议,用来替代 AnimatableModifier,通过 AnimatablePair 来设置移动位置新旧值。直接一个方块滑入略显单调,使用 CGAffineTransform 里的 c 参数可以设置将矩形进行变形,会有一种被拉进来的感觉。变形过程配合滑入动画再加上 interpolatingSpring 设置的弹性效果,会让转场更有动感。

并行执行的动画越丰富,转场感觉就会更好,我想着每页都做个不同的效果,使用 Shape 绘制一些图形做背景动画,这样会有新鲜感。当第一页和第二页弄完后已经天亮了,经过一个上午,下午就要分享了。我还没有困意,因为后面还有那么多页面没有做完区分转场的动画和配色,更别说 SwiftUI 功能的交互演示了。而且具体分享的内容我还没有整体串一遍逻辑。一天一夜完成这个项目时间还是太紧,当时想着要再能多一天时间就好了。2点开始分享,1点我在旁边一个小会议室把整个内容自己在心里试着说了一遍。分享内容包括了自制低版本兼容 AsyncImage 演示、SwiftUI 那些版本兼容问题、SwiftUI 背后关键技术简介、SwiftUI 生命周期、布局、Modifier、不透明返回类型、属性包装、Result Builder、Geometry、Preview用的技术。

其中 SwiftUI 内部运作的机制是每个 View 都有自己的 Identity,SwiftUI 会将给 State 和 StateObject 分配内存空间的 Storage 和 View 的 Identity 绑定起来,共存亡。当相同 Identity 的状态数据发生变化了或者和 View 依赖关系改变了,就会重新建立 View 和 RenderNode 的依赖关系,他们之间的关系是图结构,图结构可以降低依赖关系检查复杂度。最后渲染出来。总的来说 SwiftUI 运行原理有三个点最重要,Identifier、生命周期和依赖。视图的生命周期是 Identifier 来决定的,state 生命周期和视图的生命周期是相同的。在生命周期中,state 有变化的时候会做diff,diff和渲染效率提升是使用图型依赖结构,只渲染状态依赖的视图,如果按照 UIKit 那样的树形结构做diff,效率会特别差。

现在很多常用开源库都已经对SwiftUI做了适配,苹果公司自己的App,比如天气、相册、快捷指令、地图和相册都有用到SwiftUI。以下是SwiftUI用到的语法特性:

  • ResultBuilder
  • ViewBuilder
  • Trailing Closure
  • Opaque Type
  • Inline
  • PropertyWrapper
  • KeyPath
  • DynamicMemberLookup

如果你使用这些特性也能够再造一个兼容低版本的类似 SwiftUI 的框架。SwiftUI 最显现的 DSL 技术使用的就是 ResultBuilder 语法特性,Result Builder的提案SE-0289 里有详细的描述,通过 Result Builder 下面的方法可以自定义出一个简洁的 DSL 出来,提高特定业务开发效率。

  • buildBlock:构建基本语句的block组合结果。
  • buildExpression:可选,给表达式提供上下文类型信息。
  • buildOptional:对没有else的if语句支持。
  • buildEither:构建选择语句不同结果。通过条件结果折叠成一个结果,实现对if-else和switch语句的支持。
  • buildArray:将所有迭代结果合并成一个结果的方式实现对for…in语句的支持。
  • buildFinalResult:可选,可以调用顶层函数体的结果进行处理,产生最终的返回结果。
  • buildLimitedAvailability:会在if #available的block部分结果上调用,使result builder可以擦除类型信息。

这次的 WWDC 还专门有个 Session 讲解了怎么用 Result Builder 来做 DSL,这个 Session 是 Write a DSL in Swift using result builders

Swift 视图返回的类型是不固定的,因此使用了 Swift 的不透明类型语法特性来进行支持,支持其返回带有大量泛型参数的庞大类型,这个类型中还包括了 Result Builder 中的 if 条件类型值,支持多分支类型。Opaque Types的提案在这里SE-0244

跟着视图后面的点语法是 modifier,每个 modifier 都会在视图树中新建一个层,因此 modifier 的写的先后顺序不同,效果是不一样的。

对于数据的监听和响应使用的是 swift 里的属性包装语法特性,属性包装的提案是SE-0258。包装后数据的使用就方便了很多,对于不同属性包装类别的选择可以按照数据类型和应用场景来,对于值类型,如果是只读的数据可以什么都不加,如果数据是可读写的,使用@State,如果数据是需要在其他视图进行读写并自己也同步响应的,使用@Binding进行声明。对于对象类型的数据,指向对象的引用能发生变化要用@ObservedObject来声明,引用不可改变,那么就用@StateObject,使用环境传递对象用@EnvironmentObject。

完整的 WWDC沙龙活动回放可以扫下图中的二维码:


下面是当时现场演示的部分幻灯片,动画的效果可以看上面的视频回放:











至此,这篇WWDC21见闻就写完了,详细描写自己WWDC21期间的一些独特经历和其中涉及相关技术,这样会让文章的独特性和真实感有很大的提升。

对独特性和新意的思考

通篇看下来,你是不是感觉到故事性和真实性其实是非常容易做到的。可以理解为只要努力些,时间再长些,这两点就能够完成,且能线性得到提高。只埋头做事情比较容易和舒适,但一直这么干,熵就会越来越多,不可逆的无用能量无法排除。而独特性意味着你会去体验适应新的环境,去获取实践新的认知,去结识新的朋友碰撞新的思路,使得自己体验到不同以往的经验。新意成功几率很低,非线性的,类似于基因突变产生的进化,这和努力无关。新意和独特性一样属于逆熵过程,不能忽视,大跨步的进步需要对传统的颠覆。新意会带来新的独特经历形成一个新的循环,不去尝试就不会有新的机会。

如果把本文当成一篇笔记,其间又融入了写作心得;如果把本文当做一篇写作心得,其间又穿插了大量笔记内容。你说这是不是也是一种新意呢。

对于新意,我印象最深的还是权力的游戏的血色婚礼,神来之笔,当Joffrey正最可气,少狼主正得势时,剧情完全打破传统,效果非常震撼。凡人皆有一死,凡事皆有可能,于是乎对后面剧情的推进更加期待了。而这个新意是建立在整个剧对真实感上的毫不含糊,包括了扎实的世界观构建,服饰道具高度的还原,完全把观众带入了故事中。另外作者对古历史的专研和记者经历的结合产生出的鲜明的人物刻画和独特的剧情设计也是本剧的骨架支撑,独特性的体现。

Finally

今天我说的这些心得可以作为下笔“记录和分享”技术的一个契机,但是对于自己技术的成长,写文章并不是最终的目的,写作是你对自己思想的研究和开发。文章的上限是你的技术能力,文章只是让人了解你技术一种手段。因此更重要的是你做的技术是否有突破有演进,获得应用,并在产品中取得了好的效果。还有那些孤独着研究技术的时光,经历着一直努力着奋斗着却一直不被看见,得不到认同,也没有结果的岁月,还能够一直被自己的热情感动而不放弃去取得一点点进步带来的满足感。

❌
❌