Swift 新设计、新案例、新体验 - 肘子的 Swift 周报 #87
在 WWDC 2025 开幕前夕,Swift 官网迎来了全新改版。新设计致力于提升亲和力,突出 Swift 的技术优势,让初学者更容易上手。作为一门与苹果生态紧密关联的编程语言,新网站的视觉风格也自然融入了浓郁的苹果元素。
在 WWDC 2025 开幕前夕,Swift 官网迎来了全新改版。新设计致力于提升亲和力,突出 Swift 的技术优势,让初学者更容易上手。作为一门与苹果生态紧密关联的编程语言,新网站的视觉风格也自然融入了浓郁的苹果元素。
作为 Apple 开发中的全栈秃头老码农们,我们不但需要精通代码编写更需要有过硬的界面设计艺术功底。为了解决撸码与撸图严重脱节这一窘境,苹果从 iOS 13(macOS 11)开始引入了 SF Symbols 图形字符。
有了 SF Symbols,我们现在可以从系统内置“千姿百态”的图形字符库中毫不费力的恣意选取心爱的图像来装扮我们的 App 了。我们还可以更进一步为它们添加优美流畅的动画效果。
在本篇博文中,您将学到如下内容:
在 WWDC 24 中,苹果携手全新的 SF Symbols 6.0 昂首阔步而来,让小伙伴们的生猛撸码愈发如虎添翼。
那还等什么呢?让我们马上开始玩转符号动画之旅吧!
Let’s go!!!
SF Symbols 是兼容 Apple 多个平台的一套系统、完整、优美的图形字符库。从伴随着 SwiftUI 1.0(iOS 13)横空出世那年算起,到现在已经进化到 SF Symbols 6.0 版本了。
它的 Apple 官方网站在此: SF Symbols,大家可以前去观赏其中的细枝末节。
目前,最新的 SF Symbols 6.0 内置了超过 6000 枚风格各异的图形字符,等待着小伙伴们的顽皮“采摘”。
SF Symbols 字符库不仅仅包含静态字符图像,我们同样可以在 SwiftUI 和 UIKit 中轻松将其升华为鲜活的动画(Animations)和过渡(Transitions)效果。
下面,我们在 SwiftUI 中仅用一个 symbolEffect() 视图修改器即让字符栩栩如生了:
Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
.symbolEffect(.wiggle, options: .repeat(.continuous))
我们还可以恣意改变动画的种类,比如从 wiggle 改为 variableColor 效果:
Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
.symbolEffect(.variableColor, options: .repeat(.continuous))
我们甚至可以更进一步,细粒度定制 variableColor 动画效果的微妙细节:
Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
.symbolEffect(.variableColor.cumulative, options: .repeat(.continuous))
除了一劳永逸的让动画重复播放以外,我们还可以自动地根据 SwiftUI 视图中的状态来触发对应的动画。
如下代码所示,只要 animTrigger 状态发生改变,我们就播放 wiggle 动画 2 次(每次间隔 2 秒):
VStack {
Image(systemName: "bell.circle")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolRenderingMode(.hierarchical)
.frame(width: 150)
.symbolEffect(.wiggle, options: .repeat(.periodic(2, delay: 2)), value: animTrigger)
Button("触发动画") {
animTrigger.toggle()
}
}
我们还可以用 symbolEffect() 修改器的另一个重载版本,来手动控制动画的开始和停止:
VStack {
Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 150)
.symbolEffect(.wiggle, options: .repeat(.continuous), isActive: animPlaying)
Button(animPlaying ? "停止动画" : "开始动画") {
animPlaying.toggle()
}
}
如上代码所示,当 animPlaying 状态为真时我们播放动画,当它为假时则停止动画。
SF Symbols 字符图形库除了提供变幻莫测的海量动画以外,还弥补了强迫症码农们对于不同字符切换过渡时僵硬、不自然的“心结”。
比如,在下面的代码中我们根据 notificationsEnabled 是否开启,切换显示了不同的图形字符:
@State var notificationsEnabled = false
Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 66)
但是,这样做却释放出一些“行尸走肉”的气息,让用户非常呲楞:
所幸的是,利用 contentTransition() 视图修改器我们可以将其变得行云流水、一气呵成:
Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
.resizable()
.aspectRatio(contentMode: .fit)
.contentTransition(.symbolEffect(.replace))
.frame(width: 66)
我们还可以用 symbolVariant() 修改器来重构上面的代码,效果保持不变:
Image(systemName: "bell")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolVariant(!notificationsEnabled ? .slash : .none )
.contentTransition(.symbolEffect(.replace))
.frame(width: 66)
通过 symbolRenderingMode() 修改器,我们还能在过渡特效基础之上再应用字符的其它特殊渲染效果,比如分层:
Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
.frame(width: 66)
当然,如果我们愿意的话同样可以更加细粒度地定制过渡的类型(downUp):
Image(systemName: "bell")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolVariant(!notificationsEnabled ? .slash : .none )
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace.downUp))
.frame(width: 66)
上面我们介绍了 SF Symbs 动画和过渡中诸多“妙计和花招”。
不过平心而论,某个或者某几个字符可能更适合某些特定的动画和过渡效果,那我们怎么才能用最快的速度找到它们最佳的动画“伴侣”呢?
除了通过撸码经验和 SF Symbols 官方文档以外,最快的方法恐怕就是使用 macOS 上的 SF Symbols App 了:
我们可以在 developer.apple.com/sf-symbols 下载 SF Symbols App。
还拿上面第一个例子中的字符来举例,我们可以在 SF Symbols App 中随意为它应用各种动画效果,直到满意为止:
我们再如法炮制换一个 AirPods “把玩”一番:
至此,我们完全掌握了 SwiftUI 中 SF Symbols 符号的动画和过渡特效,小伙伴们一起享受这干脆利落、丝般顺滑的灵动风味吧!
本文对应的全部源代码在此,欢迎品尝:
import SwiftUI
struct ContentView: View {
@State var notificationsEnabled = false
@State var animPlaying = false
@State var animTrigger = false
var body: some View {
NavigationStack {
Form {
LabeledContent(content: {
Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 66)
}, label: {
Text("生硬的过渡")
})
.frame(height: 100)
LabeledContent(content: {
//Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
Image(systemName: "bell")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolVariant(!notificationsEnabled ? .slash : .none )
.contentTransition(.symbolEffect(.replace))
.frame(width: 66)
}, label: {
Text("流畅的过渡")
})
.frame(height: 100)
LabeledContent(content: {
Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
.frame(width: 66)
}, label: {
Text("按层过渡")
})
.frame(height: 100)
LabeledContent(content: {
//Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
Image(systemName: "bell")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolVariant(!notificationsEnabled ? .slash : .none )
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace.downUp))
.frame(width: 66)
}, label: {
Text("downUP 按层过渡")
})
.frame(height: 100)
HStack {
VStack {
Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 150)
.symbolEffect(.wiggle, options: .repeat(.continuous), isActive: animPlaying)
Button(animPlaying ? "停止动画" : "开始动画") {
animPlaying.toggle()
}
}
VStack {
Image(systemName: "bell.circle")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolRenderingMode(.hierarchical)
.frame(width: 150)
.symbolEffect(.wiggle, options: .repeat(.periodic(2, delay: 2)), value: animTrigger)
Button("触发动画") {
animTrigger.toggle()
}
}
}
.buttonStyle(.borderless)
.frame(height: 100)
.padding()
}
.font(.title2)
.navigationTitle("符号动画与过渡演示")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Text("大熊猫侯佩 @ \(Text("CSDN").foregroundStyle(.red.gradient))")
.foregroundStyle(.gray)
.font(.headline.weight(.heavy))
}
ToolbarItem(placement: .primaryAction) {
Button("开启或关闭通知") {
withAnimation {
notificationsEnabled.toggle()
}
}
}
}
}
}
}
#Preview {
ContentView()
}
在本篇博文中,我们讨论了如何在 SwiftUI 中花样玩转 SF Symbols 符号动画和过渡特效的各种“姿势”,我们最后还介绍了 macOS 中 SF Symbols App 的“拔刀相助”让撸码更加轻松!
感谢观赏,再会了!8-)
在今年的 WWDC 24 中,苹果将 SwiftData 升级为了 2.0 版本。其中对部分已有功能进行了增强的同时也加入了许多全新的特性,比如历史记录追踪(History Trace)、“墓碑”(Tombstone)等。
我们可以利用 History Trace 来跟踪 SwiftData 持久存储中数据的变化,利用令牌我们还可以进一步优化 SwiftData 的使用效率。
在本篇博文中,您将学到如下内容:
相信有了令牌的加持,必将为 SwiftData 历史记录追踪锦上添花、百尺竿头!
闲言少叙,让我们马上开始 History Trace 的优化之旅吧!
Let‘s go!!!;)
简单来说,今年苹果在 WWDC 24 上新祭出的历史记录追踪(History Trace)可以让我们更加轻松的监控 SwiftData 持久存储中数据的变化。如此一来,我们即可以非常 nice 的同步模型上下文之间、进程之间以及系统组件与 App 之间的数据变化了。
历史记录追踪(History Trace)机制专门用来查询 SwiftData 模型数据更改的历史记录,主要来说它有以下几种用途:
在 SwiftData 2.0 中,苹果为模型上下文(ModelContext)新增了 fetchHistory() 以及一系列相关的方法专门为历史记录追踪功能而服务:
利用它们我们只需寥寥几行代码即可监听数据变动,仿佛“樽前月下”:
let mainContext = self.modelContext
var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
historyDesc.predicate = #Predicate { trans in
trans.author == "BG"
}
let transactions = try! mainContext.fetchHistory(historyDesc)
for trans in transactions {
for change in trans.changes {
let modelID = change.changedPersistentIdentifier
guard let changedItem = mainContext.model(for: change.changedPersistentIdentifier) as? Item else { continue }
switch change {
case .insert(let historyInsert):
print("find insert")
// 处理记录插入
case .update(let historyUpdate):
print("find update")
// 处理记录更新
case .delete(let historyDelete):
print("find del")
// 处理记录删除
@unknown default:
fatalError()
}
}
}
在上面的代码中,我们主要做了这样几件事:
虽然上面的代码没有任何问题,不过需要注意的是历史追踪记录本身也是需要存储在持久数据库中的。这意味着:随着 History Trace 的持续监听这些追踪记录会让数据库的体积变得不堪重负,更尴尬的是这些过期的“累赘”往往已经没有再使用的价值了。
那么我们该如何是好呢?
别着急,HistoryToken 可以为我们解忧排愁!
准确的说,历史令牌(HistoryToken)其实是一种协议:
因为我们往往都是与 SwiftData 中的默认存储(Store)打交道,所以我们需要使用系统提供的遵循 HistoryToken 协议的实体类型:DefaultHistoryToken。
默认历史令牌本身包含历史追踪记录发生的时间,并且其本身遵守可比较(Comparable)协议,所以我们可以比较两个令牌来判断它们的时效性。
@State var historyToken: DefaultHistoryToken?
private func handleChangeInMainContext() {
let mainContext = modelContext
var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
if let token = historyToken {
historyDesc.predicate = #Predicate { trans in
// 排除旧令牌对应的历史记录
trans.author == "BG" && trans.token > token
}
} else {
historyDesc.predicate = #Predicate { trans in
trans.author == "BG"
}
}
let transactions = try! mainContext.fetchHistory(historyDesc)
for trans in transactions {
for change in trans.changes {
// 处理具体的历史追踪记录
}
}
// 保存最后一个历史令牌
historyToken = transactions.last?.token
}
如上代码所示:我们在每次监听历史追踪记录后还不忘保存最后一个历史令牌。这样做的好处是,我们就可以在下一次抓取历史追踪记录时排除过期的记录了。
虽然上面我们已经能够悠然自得的通过历史令牌来排除过期的历史追踪记录,但是这些“累赘”还仍然顽强的占据着 SwiftData 持久存储数据库的宝贵空间。长此以往,这些无用的历史记录可能会让我们的 App 臃肿不堪。
其实,SwiftData 提供了专门的 deleteHistory() 方法来删除指定的历史追踪记录:
一般情况下,在过去监听中已经被抓取过的历史追踪记录我们都可以统统删掉:
extension ModelContext {
func deleteTransactions(before token: DefaultHistoryToken) throws {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
descriptor.predicate = #Predicate {
// 删除早于指定 token 的所有历史追踪记录
$0.token < token
}
try self.deleteHistory(descriptor)
}
}
可以看到,我们删除历史记录的代码非常简单直接:只需将数据库中比指定 token 要早的历史追踪记录删除即可。
现在,我们适配 SwiftData 的 App 在使用 History Trace 时变得又快又好,底层的数据库也始终保持着苗条的身材,棒棒哒!!!
在本篇博文中,我们讨论了如何使用令牌进一步优化 SwiftData 2.0 中历史记录追踪机制的使用;我们随后还介绍了删除数据库中无用追踪记录的方法。
感谢观赏,再会啦!8-)
我们 watchOS 中的 App 和 Widgets 共享同一个 SwiftData 底层数据库,但是在 App 中对数据库所做的更改并不能被 Widgets 所感知。换句话说,App 更新了数据但在 Widgets 中却看不到。
如上图所示:我们的 App 在切换至后台之前会随机更新当前的驻场英雄,而驻场英雄会在 Widget 中显示。不过,目前我们的 Widget 中却并未识别到任何驻场英雄,这是怎么回事?又该如何解决呢?
在本篇博文中,您将学到如下内容:
本文编译及运行环境:Xcode 16 + watchOS 11。
首先是 SwiftData 数据模型:
import Foundation
import SwiftData
@Model
class Hero {
var hid: UUID
var name: String
var power: Int
var residentCount: Int = 0
var timestamp: Date
init(name: String, power: Int) {
self.hid = UUID()
self.name = name
self.power = power
timestamp = .now
}
func update() {
timestamp = .now
}
private static let HeroInfos: [(name: String, power: Int)] = [
("黑悟空", 10000),
("钢铁侠", 5000),
("灭霸他爸", 500000),
]
@MainActor
static func spawnHeros(forPreview: Bool = true) {
let container = forPreview ? ModelContainer.preview : .shared
let context = container.mainContext
if !forPreview {
let desc = FetchDescriptor<Hero>()
if try! context.fetchCount(desc) > 0 {
return
}
}
for hero in HeroInfos {
let new = Hero(name: hero.name, power: hero.power)
context.insert(new)
}
try! context.save()
}
}
@Model
class Model {
private static let UniqID = UUID(uuidString: "3788ABA9-043C-4D34-B119-5D69D486CBBA")!
var mid: UUID
@Relationship(deleteRule: .nullify)
var residentHero: Hero?
init(mid: UUID) {
self.mid = mid
self.residentHero = nil
}
@MainActor
static var shared: Model = {
let context = ModelContainer.auto.mainContext
let predicate = #Predicate<Model> { model in
model.mid == UniqID
}
let desc = FetchDescriptor(predicate: predicate)
if let result = try! context.fetch(desc).first {
return result
} else {
let new = Model(mid: UniqID)
context.insert(new)
try! context.save()
return new
}
}()
// 随机产生驻场英雄
@MainActor
func chooseResidentHero() {
let context = ModelContainer.auto.mainContext
let desc = FetchDescriptor<Hero>(sortBy: [.init(\Hero.power)])
if let hero = try! context.fetch(desc).randomElement() {
residentHero = hero
hero.residentCount += 1
try! context.save()
}
}
}
可以看到,我们的 App 由 Hero 和 Model 两种数据模型构成。其中,在 Model 里我们以关系(@Relationship)的形式将驻场英雄字段 residentHero 连接到 Hero 类型上。
接下来是 watchOS App 主视图的源代码:
struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
@Environment(\.modelContext) var modelContext
var body: some View {
NavigationStack {
Group {
// 具体实现从略...
}
.navigationTitle("英雄集合")
}
.onChange(of: scenePhase) {_, new in
if new == .inactive {
Model.shared.chooseResidentHero() // 1
WidgetCenter.shared.reloadAllTimelines() // 2
}
}
}
}
从上面的代码能够看到,当 App 切换至非活动状态(inactive)时我们做了两件事:
最后,是我们 watchOS Widget 界面的源代码:
struct IncValueWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
if let residentHero = Model.shared.residentHero {
VStack(alignment: .leading) {
HStack {
Label(residentHero.name, systemImage: "person.and.background.dotted")
.foregroundStyle(.red)
.minimumScaleFactor(0.5)
Spacer()
Text("已驻场 \(residentHero.residentCount) 次")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
HStack {
Text("战斗力 \(residentHero.power)")
.minimumScaleFactor(0.5)
Spacer()
Button(intent: EnhancePowerIntent()) {
Image(systemName: "bolt.ring.closed")
}
.tint(.green)
}
}
} else {
ContentUnavailableView("英雄都放假了...", systemImage: "xmark.seal")
}
}
.fontWeight(.heavy)
}
}
可以看到当 Widget 的界面刷新后,我们尝试从共享 Model 实例的 residentHero 关系中读取出对应的驻场英雄,然后将其显示在小组件中。
在 Xcode 预览中差不多是这个样子滴:
然而,现在执行的结果是:App 明明更新了共享 Model 中的驻场英雄,但是 Widget 里却“涛声依旧”的显示“英雄都在放假”呢?
这样一个简单的代码逻辑却无法让我们得偿所愿,为什么呢?
虽然上面代码简单的不要不要的,但其中有仍有几个关键“隐患”点在调试时需要排除:
第一条很好排除,只需要在 App 对应的代码行上设置断点然后观察其执行结果即可。
第二条需要在 Widget 界面视图中设置断点,然后用调试器附着到小组件执行进程上观察即可。
经过测试可以彻底排除前两个潜在“故障点”。福尔摩斯曾经说过:“当你排除一切不可能的情况。剩下的,不管多难以置信,那都是事实”
所以,问题的原因一定是 App 和 Widget 之间没有正确同步它们的底层数据。
回到共享 Model 静态属性的代码中,可以看到我们的 shared 属性其实是一个惰性(lazy)属性:
@MainActor
static var shared: Model = {
let context = ModelContainer.auto.mainContext
let predicate = #Predicate<Model> { model in
model.mid == UniqID
}
let desc = FetchDescriptor(predicate: predicate)
if let result = try! context.fetch(desc).first {
return result
} else {
let new = Model(mid: UniqID)
context.insert(new)
try! context.save()
return new
}
}()
这意味着:当它被求过值后,后续的访问不会再重新计算这个值了。
当我们在 Widget 里第一次访问它时,其 residentHero 关系字段中还未包含对应的驻场英雄。当 App 更新了驻场英雄后,Widget 中原来的 Model.shared 对象并不会自动刷新来反映持久存储中数据的改变。这就是问题的根本原因!
在了然了问题的根源之后,解决起来就是小菜一碟了。
最简单的方法,我们只需将原来的惰性属性变为计算属性即可。这样一来,我们即可确保在每次访问 Model 的共享单例时它的内容都会得到及时的刷新:
@MainActor
static var liveShared: Model {
let context = ModelContainer.auto.mainContext
let predicate = #Predicate<Model> { model in
model.mid == UniqID
}
let desc = FetchDescriptor(predicate: predicate)
if let result = try! context.fetch(desc).first {
return result
} else {
let new = Model(mid: UniqID)
context.insert(new)
try! context.save()
return new
}
}
如上代码所示,我们将之前的惰性属性变为了“活泼”的计算属性,这样 Widget 每次访问的 Model 共享实例都会是“最新鲜”的:
struct IncValueWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
if let residentHero = Model.liveShared.residentHero {
// 原代码从略...
} else {
ContentUnavailableView("英雄都放假了...", systemImage: "xmark.seal")
}
}
.fontWeight(.heavy)
}
}
编译并再次运行 App,当切换至对应 Widget 后可以看到我们的驻场英雄闪亮登场啦:
至此,我们解决了博文开头那个问题,棒棒哒!💯
在本篇博文中,我们讨论了 SwiftData 共享数据库在 App 中做出的改变,却无法被 对应 Widgets 感知的问题。我们随后找出了问题的原因并“一发入魂”将其完美解决。
感谢观赏,再会啦!8-)
原本在 iOS 17 中运行良好的 SwiftUI 代码突然在 iOS 18 无法正常工作了,具体表现为原来视图中的的点击手势无法响应。
这是怎么回事呢?
且看分解!Let’s go!!!;)
从下面的演示代码可以看到,我们在 List 容器的 ForEach 视图上添加了点击手势:
List {
ForEach(showingDialogues, id: \.self) { dialog in
Text(dialog)
.font(.title3.weight(.bold))
.listRowSeparator(.hidden)
}
.onTapGesture {
if index < dialogues.count {
showingDialogues.append(dialogues[index])
index += 1
} else {
isShowQuizView = true
}
}
}
这在 iOS 17 中工作正常,但是升级至 iOS 18 后点击却无任何反应,检查发现是点击手势的回调并没有被触发。
解决非常简单,我们只需将原来直接附着在 ForEach 上的手势修改器移动至外层(比如 List 上)即可:
List {
ForEach(showingDialogues, id: \.self) { dialog in
Text(dialog)
.font(.title3.weight(.bold))
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.onTapGesture {
if index < dialogues.count {
showingDialogues.append(dialogues[index])
index += 1
} else {
isShowQuizView = true
}
}
究其原因,我们注意到:之前旧代码中视图在显示时 showingDialogues 状态数组的内容为空。
@State var showingDialogues = [String]()
这在 iOS 18 之前的系统里,ForEach 仍会产生可点击范围,但是在 iOS 18 中并不会。
我们猜测原因是 iOS 18 对视图可见性检查更加严格了。因为按照常理来说当 ForEach 构造器对应的集合为空时其不应该再产生可点击的范围,所以这是 iOS 18 行为更加严谨的表现。
希望本篇博文可以一解大家的燃眉之急。
在本篇博文中,我们讨论了 iOS 18 中的 SwiftUi ForEach 视图点击逻辑和之前略有不同的情况,并给出解决方法。这可能是 SwiftUI 在 iOS 18 系统中变得更加严谨了。
感谢观赏!再会啦!8-)
1.59 s 15.4%1.31 s closure #6 in LineChartView.generateSingleLineChart(from:)
579.00 ms 5.6%490.00 ms closure #6 in LineChartView.generateGroupLineChart(from:)
524.00 ms 5.0%423.00 ms closure #1 in closure #4 in LineChartView.generateLineChart(from:)
269.00 ms 2.6%221.00 ms closure #1 in closure #6 in LineChartView.generateOtherLineChart(from:)
92.00 ms 0.8%69.00 ms closure #1 in closure #1 in closure #4 in LineChartView.ChartSectionView.body.getter
1.06 s 21.2%884.00 ms closure #6 in LineChartView.generateSingleLineChart(from:)
let chartContent = ForEach(data) { model in
// LineMark for data
226 ms LineMark(
43 ms x: .value("Time", model.timestamp),
49 ms y: .value("Value", model.value)
)
166 ms .foregroundStyle(viewModel.getLineColor(for: model.name))
561 ms .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
}
325.00 ms 6.5%259.00 ms closure #1 in closure #4 in LineChartView.generateLineChart(from:)
return Chart {
ForEach(data) { model in
102 ms LineMark(
18 ms x: .value("Time", model.timestamp),
18 ms y: .value("Value", model.value)
)
185 ms .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
}
}
306.00 ms 6.1%261.00 ms closure #6 in LineChartView.generateGroupLineChart(from:)
let chartContent = ForEach(data) { model in
// LineMark for data
58 ms LineMark(
10 ms x: .value("Time", model.timestamp),
6 ms y: .value("Value", model.value)
)
67 ms .foregroundStyle(by: .value("Field", model.name))
158 ms .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
}
144.00 ms 2.8%123.00 ms closure #1 in closure #6 in LineChartView.generateOtherLineChart(from:)
return Chart {
ForEach(data) { model in
25 ms LineMark(
7 ms x: .value("Time", model.timestamp),
6 ms y: .value("Value", model.value)
)
23 ms .foregroundStyle(model.color)
77 ms .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
}
}
42.00 ms 0.8%42.00 ms initializeWithCopy for LineChartData
闭包内部耗时较高
generateSingleLineChart
中,ForEach
循环内部的闭包花费了 884
毫秒,其中 .lineStyle(...)
调用占了 561 毫秒,说明对每个数据点的样式计算非常耗时。generateLineChart
和 generateGroupLineChart
中,也分别花费了 259
毫秒和 261
毫秒。这表明在遍历数据并生成 LineMark
时,SwiftUI
在计算各个修饰符(如 lineStyle
、foregroundStyle
)的过程中开销较大。重复计算和视图重构
x
轴、y
轴的最小值、最大值、刻度值等,而且对每个数据点都要构建一个新的 LineMark
。如果数据点较多,这些计算和视图构造操作叠加起来就会变得非常耗时。样式计算问题
generateSingleLineChart
中,对每个数据点调用 viewModel.getLineColor(for:)
和创建 StrokeStyle
都消耗了大量时间。这提示我们,样式的计算和生成可能没有复用,每次都在重新构建。LineChartView
跳转到子页面后返回,SwiftUI
会重新调用 body
生成视图。即使数据没有改变,所有的闭包(如 generateSingleLineChart
、generateGroupLineChart
、generateLineChart
等)都会被重新执行,导致整个图表视图重新计算和绘制。SwiftUI
的声明式特性,每次视图出现时都会重新计算其内部状态,进而触发大量重复的计算操作(比如对时间范围、轴值和样式的计算),所以返回时会明显卡顿,耗时达到 5
到 6
秒。针对上述问题,可以从以下几个方面考虑优化:
x
轴和 y
轴的计算、刻度值生成、以及各个数据点对应的样式等计算移到 viewModel
中,提前计算好并缓存。这样在视图构建时,只需直接使用缓存结果,避免重复计算。chartLineWidth
、lineCap
、lineJoin
等参数不会变化,可以将生成的 StrokeStyle
定义为常量,避免在 ForEach
内部重复创建。局部视图拆分和懒加载
SwiftUI
的 @StateObject
或 @ObservedObject
缓存数据,防止因父视图重新构建而全部重绘。LazyVStack
替换 VStack
,使得只有当视图真正需要显示时才进行计算和绘制。视图缓存策略
.drawingGroup()
或其他缓存手段,将渲染结果缓存成图像,在下一次展示时直接使用缓存图像而不是重新绘制所有的线条。SwiftUI
的 Chart
组件可能在处理大量数据点时没有做到最优。如果可能,考虑对数据进行适当的抽样或者合并,减少绘制的元素数量。LineChartView
被频繁重构。如果能做到局部状态隔离,只让真正需要更新的部分重绘,也会大大降低耗时。问题定位:
ForEach
循环中生成各个 LineMark
的过程中,特别是 .lineStyle
的计算,以及在视图重构时重复计算各项轴值和样式。优化方案:
viewModel
中提前计算好各个图表需要的数据、刻度、样式等,避免在视图中重复计算。.drawingGroup()
技术来减少重绘。最近 Twitter / X 上很多人开始讨论「MCP」「Vibe Coding」这种新兴概念,很多是程序员圈子里最新兴起的一些“工作流”和“写代码的新思维方式”总结出来的 buzzword
。
👉 中文大致可以理解为 “最小认知负担编程”,或者叫 “最小认知编程”。
代码不追求「最优算法」或者「最完美设计」,而是追求「对当前自己(或团队)最容易理解和维护」的代码。
写代码的时候,优先考虑 降低认知负担,而不是过度追求工程优雅 / 高度抽象。
口号式表达:
→ “If it works and you can read it next week, it's good code.”
→ “能跑,能读,能维护,就行。”
1️⃣ 现在很多代码工程、框架、抽象过度复杂,维护者学习成本高
2️⃣ GPT / Copilot
等 AI
辅助时代,更需要写「简单直观可读的代码」,AI
也能帮你“补全”复杂性
3️⃣ 工程实践发现大部分时间是“读代码”,不是写代码,MCP
提倡写 “别人和自己下周能看懂的代码”
做法 | 对应 MCP 思路 |
---|---|
优先用简单数据结构 | 少用花哨设计模式 |
减少抽象层级 | 不要无谓的加继承、多态 |
函数写短点 | 减少嵌套,易读优先 |
变量名取清晰 | 不用“高大上”名字,语义直观即可 |
不 premature optimization | 不是核心瓶颈,先保证逻辑对、代码易读 |
👉 MCP = 优先写“认知负担小,易读,易维护”的代码,不刻意炫技,实用主义风格编程。
👉 中文可以叫做:“氛围驱动型编程”,“带节奏感的编程”。
Vibe Coding
不是传统工程术语,更像是一种开发者心态 / 工作流理念,强调 轻松、自由、创造感。行为 | 说明 |
---|---|
播放喜欢的背景音乐 | 营造 flow
|
一次写完一整段逻辑 | 保持 coding groove ,减少打断 |
边写边 refactor ,随心所欲 |
不被 rigid 规则卡死 |
不过度关注“架构正确性” | 先爽,后整理 |
用 AI Copilot / ChatGPT 辅助流畅写 |
AI 帮补节奏感,提升 Vibe
|
focus
在 “表达意图” 和 “flow
” 上,而不是底层语法和繁琐设计。👉 Vibe Coding = 营造写代码的 flow 和节奏感,优先保持创造力和愉悦感,开发过程“带 vibe”更高效更快乐。
MCP
让代码「简单、可维护」 → 减少认知负担Vibe Coding
让写代码过程「顺畅、有节奏」 → 提高创造体验AI(Copilot / ChatGPT / Cursor
等)正好促进了这两种思路落地 → AI
写代码时,如果 prompt
简单明确 + 代码风格易读,AI
补全就更好;Vibe Coding
时 AI
也能增强 flow
。做法 |
MCP 思路 |
Vibe Coding 思路 |
---|---|---|
日常代码多写「易读、简单」 | 用普通 for/if ,不追求炫技语法 |
保持 coding flow ,先写再调 |
配合 Copilot / ChatGPT 用 |
生成易懂 prompt ,易维护代码 |
一边生成一边调整,保持节奏 |
少“过度设计” |
YAGNI 原则,不写用不到的抽象 |
先写 MVP ,保持写的爽感 |
重视团队代码可维护性 | 别让自己下周都看不懂 | 不卷“架构洁癖” |
MCP = 认知负担最小 → 易读、易维护的实用代码
Vibe Coding = 编程带节奏、保持 flow
→ 让写代码更有创造力和愉悦感
MCP
+ Vibe Coding
最配合 AI
使用场景MCP
+ Vibe Coding
的典型工具场景我当前的工作场景(iOS + React Native + Swift/SwiftUI + 组件化 + Charts + SDK 工程 + 架构优化)
✅ iOS 开发(Swift / SwiftUI / UIKit / React Native
)
✅ 组件化架构改造期(拆分 IndicatorKit / MarketModule / RealtimeQuoteModule / StockDetailKit
等)
✅ 旧代码 OC/Swift
混杂,技术债多
✅ Charts
图表复杂,易写成高复杂代码
✅ 有跨团队协作,需要代码清晰可维护
✅ 需要加快交付节奏,提升自信和 flow
传统写法 |
MCP 优化思路 |
---|---|
为了架构“完美”,抽象一堆协议 + 多层 wrapper
|
MVP 阶段先写简单自包含组件,协议 / 抽象层逐步演进 |
IndicatorKit / MarketModule 拆分过度工程化 |
先拆物理模块 + 清晰 API ,后期视需要再抽象复用层 |
为复用提前设计复杂泛型 | 优先“功能先跑通 + 接口直白” |
传统写法 | MCP 优化思路 |
---|---|
复杂 config 驱动,ViewModel 太厚 |
ChartViewModel 分层 + 每张图有独立 config struct ,保持易读 |
Charts 配置“动态拼拼凑凑” |
统一简化模型,明确哪些参数“必要”,哪些是“可选” |
过度用 Combine / 高阶 Publisher 嵌套 |
普通数据 flow + 明确 side effect ,易维护优先 |
传统写法 | MCP 优化思路 |
---|---|
急于“重构替换全部” | 逐步“封装接口 + 兼容”迁移,阶段性成果可见 |
复杂 bridging 层 |
轻量 bridge ,先保证可用可维护 |
改进建议 |
---|
函数写短(<= 20 行) |
变量名直接、具备业务语义 |
避免 premature optimization
|
“能跑能读”优先,不急于设计过度通用性 |
UI 层用 SwiftUI 时,保持 View 纯净,逻辑放 ViewModel
|
工具 | 推荐理由 |
---|---|
Cursor IDE + iOS 工程 | 作为副 IDE 写 utility / demo ,体验 Vibe Coding
|
ChatGPT + Codex | 生成 Swift / SwiftUI / Charts 代码,提速 |
GitHub Copilot (Xcode plugin) |
Xcode 里直接 AI 补全,写 Swift / SwiftUI 体验 Vibe
|
Raycast + GPT 插件 | 快速查代码片段 / 生成代码 |
Music App + 固定 coding playlist | 帮你 build flow (Vibe Coding 很推崇) |
场景 | 推荐做法 |
---|---|
日常组件开发 | “先跑通 flow” → 再局部优化 |
复杂 Charts 图表交互 |
先实现交互完整 MVP → 再逐步优化性能 / 结构 |
新模块 API 设计 |
边写边聊 ChatGPT ,AI 辅助完善 API 设计 |
文档同步 |
AI 帮你“生成接口注释 / 使用示例”,提升文档质量 |
遇到复杂 legacy 代码 |
先 GPT 帮解释 → 保持节奏改造,不钻牛角尖 |
👉 MCP + Vibe Coding
= 给自己“减压 + 加 flow
”
👉 当前阶段不用急于追求“架构大师范”,先做“能跑能维护 + 组件清爽 + 节奏流畅”
我需要记住一句话:
「架构是“演进”出来的,不是“设计”出来的,
MCP + Vibe Coding
最适合架构重构期的风格。」
✅ MCP → 减少认知负担,代码可读性第一
✅ Vibe Coding → 编码过程保持 flow,先做 MVP,后期优化
✅ 结合 AI → 提速 + 降低心智成本
✅ 逐步打造属于自己风格的 “轻盈架构师” 路线
→ Model Context Protocol (MCP)
👉 是最近开源圈 / AI
圈热炒的一个开放协议:
用途:
让 AI
大模型(比如 GPT-4o
、Claude
、LLaMA
)能够通过标准协议 安全访问本地或远程资源。
典型场景包括:
API
agent
的插件协议(有点类似 “ChatGPT
插件协议”)出处:
GitHub
上有很多 Awesome MCP Servers 列表,收集各种 MCP server
实现。→ Minimum Cognitive Programming (MCP)
👉 是一种编程理念 / 编码风格,源自 Twitter
/ 开发者圈子提倡:
核心思想:
AI
辅助编程、现代敏捷开发场景出处:
Twitter / X
上很多工程师最近发 “MCP
风格编码” 的帖子,鼓励这种写法,尤其配合 Copilot / GPT
编码时代。MCP
(缩写相同)AI 圈的 MCP | 之前讲的 MCP |
---|---|
Model Context Protocol | Minimum Cognitive Programming |
协议标准(AI agent 圈) | 编程理念(工程圈 / 开发圈) |
帮大模型调用外部资源 | 帮开发者降低代码复杂度 |
有 GitHub awesome 列表 | 多是 Twitter 圈理念流行 |
技术协议 | 编程思维方式 |
👉 AI agent 圈的 MCP = Model Context Protocol,是 AI Agent 圈的开放协议; 👉 之前讲的 MCP = Minimum Cognitive Programming,是工程师圈最近推崇的代码风格理念。
在 Swift 6.1.1+ 中启用预构建 Swift-Syntax,可显著加快宏项目的编译速度,提升开发效率,仅需简单配置 Xcode 或命令行参数
最近后台有留言遭遇了苹果审核被拒Guideline 4.1 - Design - Copycats
,需要咨询解惑。
苹果给出的原文如下:
This app or its metadata appears to still misrepresenting itself as another
popular app or game already available on the App Store,
from a developer's website or distribution source, or from a third-party platform.
简单来说就是和Appstore知名度较高的产品撞车了,被苹果判断为抄袭行为。
说得再直白一点,抄作业的时候把仅供参考也抄上去了。
一般侵权事件多半出现于迭代的产品
,至少代码不是4.3(a)就证明代码层面是过关了,相对于4.3(a)来说可以说是不值一提。
大概有两种作案心理心理,第一种是自己的之前下架的产品想重回巅峰,心存侥幸。第二种是把头部产品的内容、色调通通照搬。
不要急于修改,要先良性沟通。 从自身设计、布局、玩法去解释差异性,向苹果审核员晓之以理,动之以情,并且直言不讳的告诉审核团队自己在本次修改中,修改了哪些内容。在Logo可以考虑上传UI的设计稿作为佐证说明
。
如果第一步正常触发审核且在进入正在审核前收到邮件
,那么快的话可能30~40分钟可以顺利通过。
如果解释无效,苹果的态度依旧强烈维持原判。那这种情况下考虑从代码层面入手,优先调整Logo、App名称、副标题
。 在Appstore市场截图考虑模拟器截取,不使用UI设计的截图。
在主色调进行差异性整改,去修改App内部的功能。建议把Appstore元数据修改和App风格修改分两步走。
遵守规则,方得长治久安
,最后祝大家大吉大利,今晚过审!
# 有幸和Appstore审核人员进行了一场视频会议特此记录。
更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」。
在 SwiftUI 中,我们可以借助渐变色(Gradient)来实现更加灵动多彩的着色效果。从 SwiftUI 6.0 开始,苹果增加了全新的网格渐变色让我们对其有了更自由的定制度。
因为 gif 格式图片自身的显示能力有限,所以上面的动图无法传神的还原实际的美妙效果。强烈建议大家在模拟器或真机上运行本文中的示例代码。
在本篇博文中,您将学到如下内容:
闲言少叙,让我们马上进入渐变色的世界吧!
Let‘s dive in!!!;)
在 SwiftUI 中小伙伴们时常会用渐变色(或称为阶梯色)来装扮我们的界面。
在 SwiftUI 1.0(iOS 13)中有 3 种渐变色类型,它们分别是:线性渐变色 LinearGradient、辐射渐变色 RadialGradient、以及角度渐变色 AngularGradient。
而在 SwiftUI 3.0(iOS 15)中,苹果又添加了一款椭圆渐变色 EllipticalGradient:
为了能够更加轻松的使用单一颜色的渐变色,苹果从 SwiftUI 4.0(iOS 16)开始干脆将其直接“融入”到 Color 的实例中去了:
我们可以这样使用它:
Text("Hello Panda")
.foregroundStyle(.red.gradient)
在 WWDC 24 中,苹果再接再厉为 SwiftUI 6.0(iOS 18)添加了全新渐变色风格:网格渐变色(MeshGradient ):
别被它的名字所吓到,其实它只是用纵横交错的方格来进一步细粒度控制颜色渐变的自由度,仅此而已。
使用网格渐变色很简单,我们只需创建一个 MeshGradient 实例即可:
MeshGradient(
width: 2,
height: 2,
points: [.init(x: 0, y: 0),.init(x: 1, y: 0), .init(x: 0, y: 1), .init(x: 1, y: 1)],
colors: [.red, .green, .blue, .yellow]
)
如上代码所示:我们创建了一个 2 x 2 网格渐变色,并将其左上角、右上角、左下角、右下角的颜色依次设置为红色、绿色、蓝色以及黄色:
现在我们“静如处子”的网格渐变色貌似略显“呆滞”。别急,通过适当地调整其内部各个网格边框的基准点(或者颜色),我们可以让它行云流水般的“动如脱兔”。
上面说过,要想动画网格渐变色很简单。我们只需使用若干状态来实时的描述 MeshGradient 中每个网格边框的相对位置以及网格内的颜色即可。
首先,我们创建一个 positions 数组来表示每个网格的边框,注意这是一个 3 x 3 网格:
@State var positions: [SIMD2<Float>] = [
.init(x: 0, y: 0), .init(x: 0.2, y: 0), .init(x: 1, y: 0),
.init(x: 0, y: 0.7), .init(x: 0.1, y: 0.5), .init(x: 1, y: 0.2),
.init(x: 0, y: 1), .init(x: 0.9, y: 1), .init(x: 1, y: 1)
]
接下来,我们利用定时器连续调整 positions 里所有非顶角网格边框的相对位置。排除顶角网格的原因是:我们不想让整个网格渐变色在顶点被裁剪:
具体实现代码如下所示:
let timer = Timer.publish(every: 1/9, on: .current, in: .common).autoconnect()
let colors: [Color] = [
.purple, .red, .yellow,
.blue, .green, .orange,
.indigo, .teal, .cyan
]
func randomizePosition(
currentPosition: SIMD2<Float>,
xRange: (min: Float, max: Float),
yRange: (min: Float, max: Float)
) -> SIMD2<Float> {
let updateDistance: Float = 0.01
let newX = if Bool.random() {
min(currentPosition.x + updateDistance, xRange.max)
} else {
max(currentPosition.x - updateDistance, xRange.min)
}
let newY = if Bool.random() {
min(currentPosition.y + updateDistance, yRange.max)
} else {
max(currentPosition.y - updateDistance, yRange.min)
}
return .init(x: newX, y: newY)
}
MeshGradient(
width: 3,
height: 3,
points: positions,
colors: colors
)
.animation(.bouncy, value: positions)
.onReceive(timer, perform: { _ in
positions[1] = randomizePosition(
currentPosition: positions[1],
xRange: (min: 0.2, max: 0.9),
yRange: (min: 0, max: 0)
)
positions[3] = randomizePosition(
currentPosition: positions[3],
xRange: (min: 0, max: 0),
yRange: (min: 0.2, max: 0.8)
)
positions[4] = randomizePosition(
currentPosition: positions[4],
xRange: (min: 0.3, max: 0.8),
yRange: (min: 0.3, max: 0.8)
)
positions[5] = randomizePosition(
currentPosition: positions[5],
xRange: (min: 1, max: 1),
yRange: (min: 0.1, max: 0.9)
)
positions[7] = randomizePosition(
currentPosition: positions[7],
xRange: (min: 0.1, max: 0.9),
yRange: (min: 1, max: 1)
)
})
.animation(.bouncy, value: positions)
.ignoresSafeArea()
编译并在 Xcode 预览中运行一见分晓:
再次重申:上面动图“颗粒感”很强是因为 gif 图片本身对颜色限制(最多显示 256 种颜色)的原因,实际效果会相当丝滑顺畅。
现在,我们不但可以恣意描绘静态渐变色,利用些许动画我们还可以让它活灵活现的呈现“秾姿故薰欲醉眼,芳信暗传尝苦心”之意境,棒棒哒!💯
下面是一个将网格渐变色溶入到我们实际应用中的演示代码。在代码中我们做了这样几件事:
全部源代码在此:
import SwiftUI
extension View {
@ViewBuilder
func scaleEffect(_ ratio: CGFloat) -> some View {
scaleEffect(x: ratio, y: ratio)
}
}
struct ContentView: View {
@State var bgAnimStart = false
@State var shadowAnimStart = false
@State var positions: [SIMD2<Float>] = [
.init(x: 0, y: 0), .init(x: 0.2, y: 0), .init(x: 1, y: 0),
.init(x: 0, y: 0.7), .init(x: 0.1, y: 0.5), .init(x: 1, y: 0.2),
.init(x: 0, y: 1), .init(x: 0.9, y: 1), .init(x: 1, y: 1)
]
let timer = Timer.publish(every: 1/9, on: .current, in: .common).autoconnect()
let colors1: [Color] = [
.purple, .red, .yellow,
.blue, .green, .orange,
.indigo, .teal, .cyan
]
let colors2: [Color] = [
.black, .red, .blue,
.black, .teal, .blue,
.blue, .red, .black
]
func randomizePosition(
currentPosition: SIMD2<Float>,
xRange: (min: Float, max: Float),
yRange: (min: Float, max: Float)
) -> SIMD2<Float> {
let updateDistance: Float = 0.01
let newX = if Bool.random() {
min(currentPosition.x + updateDistance, xRange.max)
} else {
max(currentPosition.x - updateDistance, xRange.min)
}
let newY = if Bool.random() {
min(currentPosition.y + updateDistance, yRange.max)
} else {
max(currentPosition.y - updateDistance, yRange.min)
}
return .init(x: newX, y: newY)
}
func createMeshGradientView(_ colors: [Color]) -> some View {
MeshGradient(
width: 3,
height: 3,
points: positions,
colors: colors
)
.animation(.bouncy, value: positions)
.onReceive(timer, perform: { _ in
positions[1] = randomizePosition(
currentPosition: positions[1],
xRange: (min: 0.2, max: 0.9),
yRange: (min: 0, max: 0)
)
positions[3] = randomizePosition(
currentPosition: positions[3],
xRange: (min: 0, max: 0),
yRange: (min: 0.2, max: 0.8)
)
positions[4] = randomizePosition(
currentPosition: positions[4],
xRange: (min: 0.3, max: 0.8),
yRange: (min: 0.3, max: 0.8)
)
positions[5] = randomizePosition(
currentPosition: positions[5],
xRange: (min: 1, max: 1),
yRange: (min: 0.1, max: 0.9)
)
positions[7] = randomizePosition(
currentPosition: positions[7],
xRange: (min: 0.1, max: 0.9),
yRange: (min: 1, max: 1)
)
})
}
let text = Text("Hello Panda")
.font(.system(size: 108, weight: .heavy, design: .rounded))
.foregroundStyle(.red.gradient)
var body: some View {
NavigationStack {
ZStack {
createMeshGradientView(colors1)
//.blur(radius: 30.0)
.opacity(0.8)
text
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(0.01)
.background {
createMeshGradientView(colors2)
.mask {
text
.scaleEffect(bgAnimStart ? 1.1 : 1.0)
.rotationEffect(.degrees(bgAnimStart ? -10 : 0))
}
.shadow(color: shadowAnimStart ? .green : .black, radius: 10)
}
}
.ignoresSafeArea()
.navigationTitle("Mesh Gradient 演示")
.toolbar {
Text("大熊猫侯佩 @ \(Text("CSDN").foregroundStyle(.red))")
.foregroundStyle(.primary.secondary)
.font(.headline)
}
}
.task {
withAnimation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true)) {
shadowAnimStart = true
}
withAnimation(.snappy(duration: 0.66, extraBounce: 15.0).repeatForever(autoreverses: true)) {
bgAnimStart = true
}
}
}
}
#Preview {
ContentView()
}
在本篇博文中,我们讨论了 SwiftUI 6.0(iOS 18)中全新网格渐变色 MeshGradient 的使用,并随后介绍如何利用酷炫的动画升华它的动态效果。
感谢观看,再会啦!8-)
从 SwiftUI 5.0(iOS 17)开始,苹果推出了全新的 Observation 框架。它作为下一代内容改变响应者全面参与到数据流和事件流的系统中。
有了 Observation 框架的加持,原本需要多种状态类型的 SwiftUI 视图现在只需要 3 种即可大功告成,它们分别是:@State、@Environment 以及 @Bindable。
在 SwiftUI 中,我们往往会使用 @Environment 来完成视图继承体系中状态的非直接传递,但是在这种情况下我们却无法获取到它的绑定,造成些许不便。
在本篇博文中,我们就来谈谈如何解决这一问题:
Let‘s go!!!;)
在 SwiftUI 5.0 之前以 @EnvironmentObject 方式跨继承传递状态的视图中,我们可以轻易的获取对应对象的绑定,如下代码所示:
class OldModel: ObservableObject {
@Published var isSheeting = false
}
struct Home: View {
@EnvironmentObject var oldModel: OldModel
var body: some View {
Text("Home")
.sheet(isPresented: $oldModel.isSheeting) {
Text("Good to go!!!")
}
}
}
从上面代码可以看到,用 @EnvironmentObject 修饰的模型类型 oldModel 会“自动”产生对应的绑定形态 $oldModel,这样我们就可以很方便的将其绑定传递到需要的视图中去。
那么,用 Observation 框架中新的 @Observable 和 @Environment 组合来传递跨视图继承体系的状态又会如何呢?
让我们一窥究竟。
现在将上面的代码修改为 @Observable 和 @Environment 组合的方式来传递环境变量:
@Observable
class Model {
var isSheeting = false
}
struct ContentView: View {
@Environment(Model.self) var model
var body: some View {
Text("Main")
.sheet(isPresented: $model.isSheeting) {
Text("Sheeting View")
}
}
}
那么问题来了,此时编译器会大声抱怨:根本没有 $model 这样的东西存在!
可见使用 @Environment(Model.self) 定义的状态对象没有自动生成对应的值绑定,即使 Model 绝对是可观察的(意味着背后一定潜伏着绑定“幽灵”)。
诚然,一种看似简单的解决方法就是使用 Swift 5.9 中新的内嵌 @State 语法:
struct ContentView: View {
@Environment(Model.self) var model
var body: some View {
@State var stateModel = model
VStack {
Text("Main")
Button("Sheeting") {
stateModel.isSheeting = true
}
}
.sheet(isPresented: $stateModel.isSheeting) {
Text("Sheeting View")
}
}
}
不过这种方式会导致 @State 状态处在创建视图的“外部”,可能会将其变为常量从而阻止实际值的更新。当然,这只是一种潜在的可能性,也可能我们 App 运行的毫无问题。不过,无论如何调试器都会在 App 运行时提出“严正警告”:
那么,对此我们还能做些什么呢?
一种方法是写一个视图包装器,然后将 Model 对象在其中转换为绑定的形式:
struct ContentView: View {
@Environment(Model.self) var model
@ViewBuilder
func createBody() -> some View {
let bindableModel = Bindable(model)
VStack {
Text("Main")
Button("Sheeting") {
bindableModel.wrappedValue.isSheeting = true
}
}
.sheet(isPresented: bindableModel.isSheeting) {
Text("Sheeting View")
}
}
var body: some View {
createBody()
}
}
因为我们将原来的 @Environment 状态显式转换成了可绑定状态,所以在编译和运行时都没有任何问题。
其实,按照这种思路我们可以再进一步简化实现代码:
struct ContentView: View {
@Environment(Model.self) var model
var body: some View {
let bindableModel = Bindable(model)
VStack {
Text("Main")
Button("Sheeting") {
bindableModel.wrappedValue.isSheeting = true
}
}
.sheet(isPresented: bindableModel.isSheeting) {
Text("Sheeting View")
}
}
}
如上代码所示,我们还是使用内联变量定义。不过所不同的是,这次我们创建的是 model 对应的可绑定值而不是状态值。所以这次运行不会有任何问题,因为我们没有在外部创建“孤苦伶仃”的 @State 状态。
或者我们干脆一步到位,直接在 body 中使用“原汁原味”的 @Bindable 宏:
struct ContentView: View {
@Environment(Model.self) var model
var body: some View {
@Bindable var bindableModel = model
VStack {
Text("Main")
Button("Sheeting") {
bindableModel.isSheeting = true
}
}
.sheet(isPresented: $bindableModel.isSheeting) {
Text("Sheeting View")
}
}
}
现在,我们成功的将 @Environment 中 @Observable 对象的绑定抽取出来,并且彻底摆脱了中间讨厌 wrappedValue 的横插一刀,棒棒哒!💯
希望本文在某些情景下会给小伙伴们一些启迪,若是如此深感欣慰。
在本篇博文中,我们讨论了为什么不能在 SwiftUI 中 @Environment 的 @Observable 对象上使用绑定(Binding),我们随后讨论了如何巧妙地解决这一问题。
感谢观赏,再会啦!8-)
在 WWDC 24 新推出的 SwiftUI 6.0 中,苹果对于容器内部子视图的布局有了更深入的支持。为了能够未雨绸缪满足实际 App 中所有可能的情况,我们还可以再接再厉,将 Sections 的支持也考虑进去。
SwiftUI 6.0 对容器子视图布局的增强支持可以认为是一个小巧的容器自定义布局(Custome Layout)简化器。
在本篇博文中,您将学到如下内容:
有了全新容器子视图布局机制的加持,现在对于任何需要适配自定义容器行为的情况我们都可以游刃有余、从容应对了!
那还等什么呢?Let‘s go!!!
在实际 App 布局的“摆放”中,我们往往会进一步用段(Sections)来划分容器中的海量内容,这在 List 或 Form 等 SwiftUI 内置容器里是司空见惯的事情:
struct ContentView: View {
var body: some View {
Form {
Section("概述") {
Color.yellow
.frame(height: 100)
Text("Hello World")
}
Section("高级") {
Color.red
.frame(height: 100)
Toggle("成为黑悟空", isOn: .constant(true))
.tint(.pink)
.padding(.horizontal)
}
Section("其它") {
Color.green
.frame(height: 100)
}
}
.font(.largeTitle.bold())
}
}
在上面的代码中,我们将之前容器中所有子视图都划分到 3 个独立的 Section 中去了:
那么问题来了:我们的自定义容器能否识别传入内容中的 Sections?
答案是肯定的!
可能聪明的小伙伴们都已经猜到了,之前我们讨论过的 SwiftUI 6.0 中 ForEach 和 Group 的新构造器都有支持 Sections 的重载形式:
下面我们就看看如何利用它们让自定义容器也支持 Sections 吧。
说出来大家可能不信,对之前 Carousel 容器的实现稍加修改,我们即可从容让它支持 Sections:
struct Carousel<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView {
LazyVStack {
ForEach(sections: content) { section in
VStack {
section.header
ScrollView(.horizontal) {
LazyHStack {
section.content
.containerRelativeFrame(.horizontal)
.frame(minHeight: 100)
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
.contentMargins(16)
section.footer
}
}
}
}
}
}
从上面代码中我们可以看到,ForEach(sections:) 闭包传入的实参是一个 SectionConfiguration 类型。如果我们希望的话,还可以使用 SectionConfiguration 的 content 属性进一步分离段中的每一个子视图:
下面的代码演示了这种用法:
ForEach(sections: content) { section in
VStack {
section.header
ScrollView(.horizontal) {
LazyHStack {
ForEach(section.content) { subview in
subview
.containerRelativeFrame(.horizontal)
.frame(minHeight: 100)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
.contentMargins(16)
section.footer
}
}
现在,对于容器中包含若干 Sections 的情况,我们的 Carousel 也可以“应付自如”了:
struct ContentView: View {
var body: some View {
Carousel {
Section("概述") {
Color.yellow
.frame(height: 100)
Text("Hello World")
}
Section("高级") {
Color.red
.frame(height: 100)
Toggle("成为黑悟空", isOn: .constant(true))
.tint(.pink)
.padding(.horizontal)
}
Section("其它") {
Color.green
.frame(height: 100)
}
}
.font(.largeTitle.bold())
}
}
编译并在预览中看一看效果吧:
类似的,Group 同样对 Sections 提供了完善的支持。
如您所愿,我们也只需在 Magazine 容器的原有实现上“略施小计”,即可大功告成:
struct Magazine<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView {
LazyVStack {
Group(sections: content) { sections in
if !sections.isEmpty {
GroupBox(label: sections[0].header) {
ZStack {
sections[0].content.frame(minHeight: 100)
}
}
}
if sections.count > 1 {
ForEach(sections[1...]) { section in
section.header
ScrollView(.horizontal) {
LazyHStack {
section.content
.containerRelativeFrame(.horizontal)
.frame(minHeight: 100)
}
}
.contentMargins(16)
section.footer
}
}
}
}
}
}
}
从上面的代码可以看到,我们对容器中的第一个段做了特殊对待。
还是那个 ContentView,只不过里面的容器现在是 Magazine “当家做主”了:
struct ContentView: View {
var body: some View {
Magazine {
Section("概述") {
Color.yellow
.frame(height: 100)
Text("Hello World")
}
Section("高级") {
Color.red
.frame(height: 100)
Toggle("成为黑悟空", isOn: .constant(true))
.tint(.pink)
.padding(.horizontal)
}
Section("其它") {
Color.green
.frame(height: 100)
}
}
.font(.largeTitle.bold())
}
}
运行代码,看看我们支持 Sections 崭新的 Magazine 吧:
至此,我们自定义容器已然完全支持 Sections 啦!小伙伴们还不赶紧给自己一个大大的赞!棒棒哒!💯
在本篇博文中,我们介绍了 SwiftUI 6.0(iOS 18)如何让自定义容器支持 Sections 布局,超简单的哦!
感谢观赏,再会吧!8-)
在 WWDC 24 新推出的 SwiftUI 6.0 中,苹果对于容器内部子视图的布局有了更深入的支持。为了能够未雨绸缪满足实际 App 中所有可能的情况,我们还可以再接再厉,将 Sections 的支持也考虑进去。
SwiftUI 6.0 对容器子视图布局的增强支持可以认为是一个小巧的容器自定义布局(Custome Layout)简化器。
在本篇博文中,您将学到如下内容:
有了全新容器子视图布局机制的加持,现在对于任何需要适配自定义容器行为的情况我们都可以游刃有余、从容应对了!
那还等什么呢?Let‘s go!!!;)
苹果在 SwiftUI 4.0(iOS 16)中推出了自定义容器布局(Custom Layout)功能,有了它我们即可放心大胆的创建具有独特外观和行为的容器了:
Layout 协议弥补了 SwiftUI 不能自定义容器布局之遗憾,将容器中子视图渲染位置的自由度发挥到了极致。
不过,有时候我们仅仅希望稍微调整一下现有容器子视图的布局,比如换成系统默认的现成容器(VStack、HStack、ZStack 等)。在这种情况下使用自定义容器布局 Layout 略显大材小用了。
于是乎,在 SwiftUI 6.0(iOS 18)中苹果给了我们另一套“组合拳”:容器内容的遍历(ForEach)和再组合(Group)。
简单来说,在 SwiftUI 6.0 中我们可以使用 ForEach 和 Group 的新构造器来实现容器内容的遍历(探囊取物)和重新组合(聚沙成塔)。前者可以帮助我们方便的将 @ViewBuilder 传入的内容分解为单个容器的子元素,而后者则能让我们在容器整体布局的重构上一展拳脚。
在 SwiftUI 6.0 之前,如果我们想实现一个自定义的 Card 容器可能会这样做:
struct Card<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
VStack {
content
}
.padding()
.background(Material.regular, in: .rect(cornerRadius: 8))
.shadow(radius: 4)
}
}
然后这样来使用它:
struct ContentView: View {
var body: some View {
Card {
Text("Hello, World!")
Text("My name is Majid Jabrayilov")
}
}
}
在上面的代码中,我们利用泛型结构将实际的容器内容通过 @ViewBuilder 语法块传递给了 Card 主体。但是,我们无法再更进一步去获取传入容器内部的子元素了,这意味着此时对容器子元素外观的细粒度定制无异于“敲冰求火”。
当然,我们可以通过其它手段来间接达到获取和区分容器内部子元素之目的,但这会使得代码逻辑变得根牙盘错、晦涩难懂。
上面这种尴尬局面在 SwiftUI 6.0 中立刻变得烟消云散了,这得益于 ForEach 全新的 ForEach(subviews:) 构造器。
我们现在可以从容的遍历容器内部的每个独立元素视图了:
struct Carousel<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(subviews: content) { subview in
subview
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 15))
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(16)
}
}
struct ContentView: View {
var body: some View {
Carousel {
Color.yellow
Color.orange
Color.red
Color.blue
Color.green
}
.frame(height: 200)
}
}
在上面的代码中,我们通过 ForEach 的新构造器将传入 Carousel 容器内容的每个子视图都“抽取”出来单独应用外观样式。
注意 ForEach 新构造器闭包中传入实参的类型是 SubView 结构,它同时遵守 View 和 Identifiable 协议,这意味着我们可以把它当做一个普通且“特立独行”的 SwiftUI 视图来渲染。
上面 Carousel 容器的显示效果如下所示:
上面貌似看起来没什么大不了的,不过利用 ForEach 新构造器实现自定义容器的神奇才刚刚开始。现在我们可以恣意向 Carousel 传递任意“异构”的子视图啦:
struct ContentView: View {
var body: some View {
Carousel {
Color.yellow
Text("Hello World")
Color.red
Toggle("成为黑悟空", isOn: .constant(true))
.tint(.pink)
.padding(.horizontal)
Color.green
}
.font(.largeTitle.bold())
.frame(height: 200)
}
}
编译并在 Xcode 预览中即可立见分晓:
虽然遍历容器内容“很好很强大”,但在某些场景中与其遍历每个单独的容器元素,我们更希望先获得它们的一个整体(集合)然后再按需求重新组织它们的布局。
这时,我们可以利用 Group 的新构造器来优雅的完成这一功能:
从上面的定义中可以看到:Group(subviews:) 闭包中传入的实参类型是 SubviewsCollection。
从它遵循以下几个协议可以看出,它是一个集合,并且每个元素类型都是 SubView:
Group 新构造器的作用和 ForEach 的正相反:前者是“聚沙成塔”,后者则是“拆塔成沙”。
在下面的代码中,我们创建了一个名为 Magazine 的新容器,并用 Group 新的构造器按照实际子视图的数量完成了自定义容器布局:
struct Magazine<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView {
Group(subviews: content) { subviews in
if !subviews.isEmpty {
subviews[0]
.padding(.horizontal)
.containerRelativeFrame(.vertical) { length, _ in
return length / 3
}
}
if subviews.count > 1 {
ScrollView(.horizontal) {
LazyHStack {
ForEach(subviews[1...], id: \.id) { subview in
subview
.containerRelativeFrame([.horizontal, .vertical]) { length, type in
switch type {
case .horizontal:
length
case .vertical:
200
}
}
.clipShape(.rect(cornerRadius: 15))
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(16)
}
}
}
}
}
在 Magazine 的实现中,我们还利用 containerRelativeFrame 修改器方法将容器内部各个子视图的尺寸设置为特定的大小(相对容器本身或自定义值)。
还用之前那个 ContentView 一试身手吧:
struct ContentView: View {
var body: some View {
Carousel {
Color.yellow
Text("Hello World")
Color.red
Toggle("成为黑悟空", isOn: .constant(true))
.tint(.pink)
.padding(.horizontal)
Color.green
}
.font(.largeTitle.bold())
.frame(height: 200)
}
}
运行可以发现,现在我们容器中第一个子视图被置顶突出显示,剩余所有的子视图横列滚动显示:
在下一篇博文中,我们将继续自定义布局的探寻之旅,来学习如何让它们支持 SwiftUI 中的 Sections。
在本篇博文中,我们介绍了 SwiftUI 6.0(iOS 18)中对自定义容器布局的增强支持,使我们能够自如做到“探囊取物”和“聚沙成塔”。
感谢观赏,再会吧!8-)
数字世界的创作和内容,最重要的是程序和视频。程序负责逻辑、互动;视频负责信息的表达、感知。
2024年2月 Sora 概念发布,6月可灵发布,AI 视频开始发展。同年 6月 Claude 3.5 Sonnet 发布,8月 Cursor 接入,AI Coding 开始狂奔。
回想起来只过了一年时间,但已经经历了很多的变化,想探讨一下这两个核心 AI 领域的演化可能性。
AI Coding 短期已经达到生产可用,能显著提升程序员 Coding 效率,长期还是 AGI 本身核心的能力,有现在也有未来,自然是最热门的方向,Cursor / Windsurf / Lovable / Augment 层出不穷。
AI Coding 除了给程序员提效,也开始覆盖到其他互联网从业者,设计师/产品/运营/自媒体 等,让原本不会写程序的人 0 门槛通过 AI Coding 做出 demo 和场景,带来 0-1 的新体验。
但目前这些应用,核心是提效。没有 AI Coding,程序员也能写出一样的程序,产品/设计师等也能跟程序员合作快速做出 demo,从产物的形态/目的角度看,做出来的东西没有本质的变化,只是有了 AI Coding,效率提高了一个级别。
AI 视频过去一年 可灵/即梦/Runway/Veo 等模型持续进化,指令遵循、画面稳定性一直在增强,在一些场景达到了生产可用,提升了 CG 制作、商品广告等视频场景的生产效率。
AI 视频也覆盖到广大自媒体用户,以前需要一个 CG 团队才能做出来的视频,现在一个人可以创作出来,例如橘猫/风格化视频等,给创作者带来 0-1 的新体验,发布到小红书/抖音等平台获利,AI 视频部分代替了基于摄像头实拍的内容,成为新的一种生产力工具。
AI Coding 和 AI 视频作为生产力工具毋庸置疑会带来巨大的价值,也是现在正在发生和快速增长的。
不确定的是,随着能力的持续提升,使用的门槛的持续降低,AI Coding 和 AI 视频的使用人群是否能进一步覆盖更广到亿级大众用户,带来生产力目的以外新的东西,催生新的偏社交/娱乐的内容平台?
从历史上找,有两个可类比的领域:
拍摄
3D打印
从类比上并没有特别适配的案例,但不妨碍 AI Coding 和 AI 视频有自己的大众化和演进路径,我们先看看如果工具要大众化和催生新平台,需要什么条件。
一个工具要大众化,门槛持续降低是必要的但不充分,创作的核心是消费,大部分人有创作欲,但纯粹的创作欲是小众,创作欲更多是社交认同、利益驱动。如果不能分享,大家不会好好拍照,如果没有利益,短视频创作者不会那么多。所以工具要大众化,核心还是创作的内容有高的消费价值,包括消费频次。
但即使人人都创作和分享,也不一定需要新的平台承接,创作产物的形态没有变化,消费场景(硬件/环境)没有变化,原来的社交/内容平台也足以承接。要诞生新的平台,还是得有不同的消费场景或不同的内容形态,导致原来的平台没法很好地承接。我们不考虑新硬件的情况下,主要就看内容形态。
沿着消费价值和内容形态,看看 AI Coding 和 AI 视频的情况。
AI Coding 的产物归类到源头可能就三种:工具、内容容器、游戏。我们拆开来设想一下:
有足够的个性化差异的可交互产品/游戏,是 AI Coding 可能的出圈点。比如,以个人形象为主角的、融入了自身经历的小游戏;比如,一个可以在里面不断做个性化扩建的自由世界,像“我的世界”。如果有这些新的形态,就会催生一个新的内容平台去承接这一类产品。
AI 视频的产物应用涵盖太广,难以细拆,但近期也看到一些大众化和新形态的可能性:
日常心情表达是 AI 视频很能大众化的场景,消费价值和消费频次高,但催生不了新平台,生成的视频都会回到原来的内容/社交平台上。可交互视频这种衍生的形态,才会需要一个新的平台去承接。
看下来无论是 AI Coding 还是 AI 视频,交互 都是新内容形态的关键点。
因为这波 AI 浪潮是生成式 AI,生成的产物都是业界已有的形态,如果只看生成的产物,在没有新的硬件设备、使用环境等其他变量的情况下,只会有生产效率的提升,很难诞生新的内容形态和平台。
生成式 AI 真正独特的地方,是生成的过程。需要用户频繁通过生成产生交互的场景,才会是新的内容场景,才能产生新的内容形态。
AI Coding 和 AI 视频都有在各自领域里通过交互产生新的内容形态的可能。另一种可能是,这两者做进一步的结合,逻辑+画面都实时生成,不断创造的可玩的虚拟世界,可能又能回到元宇宙的概念。
这些新的形态和玩法,可能会像当时 Snapchat 刚出来时大家看不懂,难以理解,但就是能戳中年轻一代的诉求,值得探索和期待。
在开发 iOS 应用时,开发者常会遇到因 macOS 系统 沙盒机制(Sandbox) 导致的文件写入权限问题,典型错误如:
Sandbox: rsync.samba(...) deny(1) file-write-create ...
这类错误通常发生在构建 Flutter、React Native 或集成第三方库(如 RxCocoa、Realm、MobileVLCKit)的项目中。本文将从原理、解决方案和实践技巧三个方面,帮助你快速定位并解决问题。
macOS 的沙盒机制是系统安全策略的一部分,限制应用程序对文件系统、网络等资源的访问权限。Xcode 在构建 iOS 应用时,默认启用沙盒保护,防止恶意行为。但某些场景下(如 Flutter 插件、第三方库的构建脚本),沙盒限制可能导致文件写入失败。
.xcframework
、dSYM
文件),沙盒可能阻止写入。rsync
)可能因权限不足失败。ENABLE_USER_SCRIPT_SANDBOXING
设置。这是最直接的解决方案,适用于大多数场景:
ENABLE_USER_SCRIPT_SANDBOXING
。No
(适用于所有 Build Configurations:Debug/Release)。CleverPushNotificationServiceExtension
)等。沙盒错误可能由旧缓存或依赖残留导致:
rm -rf ~/Library/Developer/Xcode/DerivedData
flutter clean
flutter pub get
cd ios
pod deintegrate
pod install --repo-update
参考资料:
如今,Xcode Playground 似乎已经偏离了最初的设计初衷,而 VSCode 的配置对初学者来说又显得颇为复杂。在这样的背景下,如何轻松地搭建一个适合学习和测试 Swift 语言的开发环境?也许本文介绍的 Notepad.exe 能为你提供满意的答案。
如果你有知道更好的开源学习项目请留言给我,我会更新到我得文章中,让更多爱学习的朋友看到!
简介:RxSwift 官方仓库提供了多个示例,展示了如何在实际项目中使用 RxSwift 进行异步操作和数据流处理。
特点:
适合人群:希望深入理解 RxSwift 基础和高级用法的开发者。
简介:这是一个精心整理的 RxSwift 学习资源合集,包含了开源应用、库、教程和社区资源。
特点:
适合人群:希望通过多个项目和教程全面学习 RxSwift 的开发者。
简介:该项目展示了如何使用 Clean Architecture、MVVM 架构和 RxSwift 构建 iOS 应用。
特点:
适合人群:希望学习如何在实际项目中应用 Clean Architecture 和 RxSwift 的开发者。
简介:这是 Raywenderlich.com 上 RxSwift 教程的配套项目,涵盖了从基础到高级的多个示例。
特点:
适合人群:希望系统学习 RxSwift 的开发者。
简介:该项目展示了如何结合 MVVM 架构和 RxSwift 构建 iOS 应用,使用了 SnapKit 进行布局。
特点:
适合人群:希望学习如何在实际项目中应用 MVVM 和 RxSwift 的开发者。
简介:SwiftHub 是一个使用 RxSwift 和 MVVM-C 架构构建的 GitHub iOS 客户端,展示了如何在实际应用中使用这些技术。
特点:
适合人群:希望学习如何构建复杂应用并应用 RxSwift 和 MVVM-C 架构的开发者。
简介:该项目包含了多个 RxSwift 教程的示例,涵盖了基础、网络请求、多线程等内容。
特点:
适合人群:希望通过多个小项目逐步学习 RxSwift 的开发者。
如果还有更好的学习项目欢迎留言,我会更新到我得文章中,让更多的人看到的!
这是一个功能丰富的 SwiftUI 项目,展示了如何构建一个完整的应用程序。项目涵盖了用户界面设计、数据管理、网络请求等多个方面,非常适合初学者学习。
该项目提供了一个完整的 SwiftUI 应用示例,包含网络请求、下拉刷新、上拉加载更多、数据增删改查、图片上传和预览等功能。项目还包含了服务端代码,使用 Go 语言编写,模拟真实的项目场景。
这是一个 SwiftUI 示例、技巧和技术集合,旨在帮助开发者构建应用程序、解决问题,并了解 SwiftUI 的实际工作方式。项目主要内容来源于 hackingwithswift.com,适合想要深入了解 SwiftUI 的开发者。
这是苹果在 WWDC22 发布的官方示例项目,展示了如何使用 SwiftUI 构建一个完整的应用程序。项目涵盖了以下内容:
使用 NavigationSplitView 管理视图
使用 Charts 展示趋势数据
使用 WeatherService 获取天气数据
实现了 Live Activities 和 Dynamic Island
该项目展示了如何在 SwiftUI 中应用 Clean Architecture 架构,涵盖了以下内容:
使用 SwiftData 进行数据持久化
实现网络请求
依赖注入
单元测试
适合希望构建可维护、可测试的 SwiftUI 应用的开发者。
该项目集合了 50 多个 SwiftUI 示例项目,包括:
3D 柱状图
饼图
贪吃蛇游戏
表情符号识别游戏
Reddit 客户端
每个项目都附有详细的教程,适合希望通过实践学习 SwiftUI 的开发者。
该项目由著名的 Swift 教程作者 Paul Hudson 创建,包含多个小型 SwiftUI 示例项目,如:
新闻阅读器
记事本
记分板
待办事项列表
适合初学者快速上手 SwiftUI。
这是一个简单的天气应用示例,包含以下功能:
适合初学者练习 SwiftUI 的基本布局和导航。
该项目展示了一个完整的应用流程,包括:
引导页面
登录/注册/忘记密码页面
主界面
收藏页面
退出登录功能
适合希望了解完整应用流程的开发者。
该项目包含了 30 个使用 SwiftUI 构建的应用示例,涵盖了各种常见的 UI 组件和功能,适合希望通过大量练习掌握 SwiftUI 的开发者。
该项目包含多个 SwiftUI 示例项目,包括:
适合希望通过实际项目学习 SwiftUI 的开发者。
我们在之前多篇博文中已经介绍过 SwiftUI 6.0(iOS 18)新增的自定义容器布局机制。现在,如何利用它们对容器内容进行“探囊取物”和“聚沙成塔”,我们已然胸有成竹了。
然而,除了上述鬼工雷斧般的新技巧之外,SwiftUI 6.0 其实还提供了能更进一步增加容器布局自由度的新利器:自定义容器值(Container Values)。
在本篇博文中,您将学到如下内容:
相信 SwiftUI 6.0 中全新的自定义容器值能够让容器布局更加“脱胎换骨”、灵动自由。
那还等什么呢?让我们继续前一篇的容器大冒险吧!Let's go!!!;)
我们首先需要定义自己的 Container Values。正如上篇博文所说过的,这可以通过 @Entry 宏来轻松完成:
extension ContainerValues {
@Entry var isNeedHighlightPrefix = false
}
为了方便起见,我们顺手在视图扩展中创建一个 highlightPrefix() 辅助方法:
extension View {
func highlightPrefix(_ enable: Bool = true) -> some View {
containerValue(\.isNeedHighlightPrefix, enable)
}
}
接着,我们率先在 NiceListContainer 容器 body 中对醒目显示做出支持:
struct NiceListContainer<Content: View>: View {
@ViewBuilder var content: Content
private var hightlight: some View {
RoundedRectangle(cornerRadius: 8)
.foregroundStyle(.red.gradient)
.frame(width: 5.0)
.padding(.vertical)
.shadow(radius: 3.0)
}
var body: some View {
List {
ForEach(subviews: content) { subview in
HStack {
if subview.containerValues.isNeedHighlightPrefix {
hightlight
}
subview
}
}
}
}
}
可以看到上面代码其实很简单:我们通过容器子视图的 containerValues 访问了它的 isNeedHighlightPrefix 自定义容器值,容易的简直不要不要的。
最后,我们只需在主视图中为所有必要的容器元素应用醒目显示效果即可:
struct ContentView: View {
var body: some View {
NiceListContainer {
Group {
Text("Hello")
.foregroundStyle(.green)
.highlightPrefix()
Text("World")
.foregroundStyle(.red)
.highlightPrefix()
Text("大熊猫侯佩")
.foregroundStyle(.brown)
.font(.system(size: 55, weight: .black))
Image(systemName: "globe.europe.africa")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(.orange.gradient)
HStack {
Text("战斗力")
Slider(value: .constant(10))
.padding()
}
.tint(.pink)
.highlightPrefix()
}
.font(.title.weight(.heavy))
}
}
}
编译在预览中看一下成果吧:
正是我们想要的!棒棒哒!💯
除了在子视图上应用自定义容器值以外,我们还可以同样在容器的段(Section)上应用它们。
也就是说:除了在 SubView 以外,我们在 SectionConfiguration 中也可以通过 containerValues 属性访问自定义容器值。
假设在容器中有若干个 Section,我们需要将某些 Section 置顶显示,其余的段则顺其自然。和前面类似,我们需要让容器的使用者来决定哪些 Section 放在顶部(需要注意的是:置顶 Section 出现的位置不一定在 @ViewBuilder 闭包代码的前面)。
首先,新建段置顶功能对应的自定义容器值和视图扩展:
extension ContainerValues {
@Entry var isOnTheTop = false
}
extension View {
func onTheTop(_ enable: Bool = true) -> some View {
containerValue(\.isOnTheTop, enable)
}
}
接着,实现我们 NiceListContainer 容器的主体布局:
struct NiceListContainer<Content: View>: View {
@ViewBuilder var content: Content
private var hightlight: some View {
RoundedRectangle(cornerRadius: 8)
.foregroundStyle(.red.gradient)
.frame(width: 5.0)
.padding(.vertical)
.shadow(radius: 3.0)
}
var body: some View {
List {
Group(sections: content) { sections in
let onTheTops = sections.filter(\.containerValues.isOnTheTop)
let others = sections.filter { !$0.containerValues.isOnTheTop }
if !onTheTops.isEmpty {
ForEach(onTheTops) { section in
Section {
HStack {
Image(systemName: "star")
.foregroundStyle(.yellow.gradient)
.font(.largeTitle)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(subviews: section.content) { subview in
HStack {
if subview.containerValues.isNeedHighlightPrefix {
hightlight
}
subview
}
}
}
}
}
} header: {
if let header = section.header.first {
HStack {
Image(systemName: "teddybear.fill")
.font(.largeTitle)
.foregroundStyle(.cyan)
header
}
}
}
}
}
if !others.isEmpty {
ForEach(others) { section in
Section {
ForEach(subviews: section.content) { subview in
HStack {
if subview.containerValues.isNeedHighlightPrefix {
hightlight
}
subview
}
}
} header: {
if let header = section.header.first {
header
}
}
}
}
}
}
}
}
在上面的代码中,我们通过检查容器中段的 containerValues.isOnTheTop 容器值,过滤出所有需要显示在顶部和所有其它的 Section。注意,在 NiceListContainer 容器中我们仍然同时支持子视图上的 isNeedHighlightPrefix 容器值。
最后,我们只需在想要置顶的 Section 上应用 onTheTop() 修改器即可:
struct ContentView: View {
var body: some View {
NiceListContainer {
Group {
Section("欢迎语") {
Text("Hello")
.foregroundStyle(.green)
.highlightPrefix()
Text("World")
.foregroundStyle(.red)
.highlightPrefix()
}
Section("主人入住") {
Group {
Text("大熊猫侯佩")
Text("🐼 Hopy")
}
.foregroundStyle(.brown)
.font(.system(size: 55, weight: .black))
.highlightPrefix()
}
.onTheTop()
Section("图片") {
Image(systemName: "globe.europe.africa")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(.orange.gradient)
}
Section("其它") {
HStack {
Text("战斗力")
Slider(value: .constant(10))
.padding()
}
.tint(.pink)
.highlightPrefix()
}
}
.font(.title.weight(.heavy))
}
}
}
如上代码所示:现在我们在需要顶部显示的 Section 上(可以不止一个)应用了 onTheTop() 置顶修改器,并在 Section 中的某些子视图上应用了之前的醒目显示效果。
运行代码,在预览中见证一下我们美美哒的成果吧:
至此,我们彻底掌握了 SwiftUI 6.0 中定制容器元素的解析与重组,并利用自定义容器值让容器布局更加随心所欲、逍遥自在!棒棒哒!💯
在本篇博文中,我们补全了 SwiftUI 6.0(iOS 18)定制容器的最后一块拼图:自定义容器值(Container Values),并利用它们让容器布局自由度更进一步,小伙伴们值得拥有!
感谢观赏,再会啦!8-)