普通视图

发现新文章,点击刷新页面。
昨天 — 2025年6月6日iOS

SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效

2025年6月6日 11:20

在这里插入图片描述

概述

作为 Apple 开发中的全栈秃头老码农们,我们不但需要精通代码编写更需要有过硬的界面设计艺术功底。为了解决撸码与撸图严重脱节这一窘境,苹果从 iOS 13(macOS 11)开始引入了 SF Symbols 图形字符。

在这里插入图片描述

有了 SF Symbols,我们现在可以从系统内置“千姿百态”的图形字符库中毫不费力的恣意选取心爱的图像来装扮我们的 App 了。我们还可以更进一步为它们添加优美流畅的动画效果。

在本篇博文中,您将学到如下内容:

  1. 符号动画,小菜一碟!
  2. 自动触发动画
  3. 更顺畅的符号过渡特效
  4. 所见即所得:SF Symbols App
  5. 完整源代码

在 WWDC 24 中,苹果携手全新的 SF Symbols 6.0 昂首阔步而来,让小伙伴们的生猛撸码愈发如虎添翼。

那还等什么呢?让我们马上开始玩转符号动画之旅吧!

Let’s go!!!


1. 符号动画,小菜一碟!

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

在这里插入图片描述

2. 自动触发动画

除了一劳永逸的让动画重复播放以外,我们还可以自动地根据 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 状态为真时我们播放动画,当它为假时则停止动画。

3. 更顺畅的符号过渡特效

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)

在这里插入图片描述

4. 所见即所得:SF Symbols App

上面我们介绍了 SF Symbs 动画和过渡中诸多“妙计和花招”。

不过平心而论,某个或者某几个字符可能更适合某些特定的动画和过渡效果,那我们怎么才能用最快的速度找到它们最佳的动画“伴侣”呢?

除了通过撸码经验和 SF Symbols 官方文档以外,最快的方法恐怕就是使用 macOS 上的 SF Symbols App 了:

在这里插入图片描述

我们可以在 developer.apple.com/sf-symbols 下载 SF Symbols App。

还拿上面第一个例子中的字符来举例,我们可以在 SF Symbols App 中随意为它应用各种动画效果,直到满意为止:

在这里插入图片描述

我们再如法炮制换一个 AirPods “把玩”一番:

在这里插入图片描述

至此,我们完全掌握了 SwiftUI 中 SF Symbols 符号的动画和过渡特效,小伙伴们一起享受这干脆利落、丝般顺滑的灵动风味吧!

5. 完整源代码

本文对应的全部源代码在此,欢迎品尝:

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

使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用

2025年6月6日 11:13

在这里插入图片描述

概览

在今年的 WWDC 24 中,苹果将 SwiftData 升级为了 2.0 版本。其中对部分已有功能进行了增强的同时也加入了许多全新的特性,比如历史记录追踪(History Trace)、“墓碑”(Tombstone)等。

在这里插入图片描述

我们可以利用 History Trace 来跟踪 SwiftData 持久存储中数据的变化,利用令牌我们还可以进一步优化 SwiftData 的使用效率。

在本篇博文中,您将学到如下内容:

  1. 历史记录追踪机制简介
  2. 使用令牌(HistoryToken)过滤历史记录
  3. 删除过期的历史记录

相信有了令牌的加持,必将为 SwiftData 历史记录追踪锦上添花、百尺竿头!

闲言少叙,让我们马上开始 History Trace 的优化之旅吧!

Let‘s go!!!;)


1. 历史记录追踪机制简介

简单来说,今年苹果在 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()
        }
    }
}

在上面的代码中,我们主要做了这样几件事:

  • 利用模型上下文的 author 属性排除了非后台 ModelContent 修改的历史记录;
  • 通过 Change 记录的 changedPersistentIdentifier 属性抓取了修改后的托管对象;
  • 根据具体的 Change 类型(新增、更改和删除)来做出妥善的后续处理;

虽然上面的代码没有任何问题,不过需要注意的是历史追踪记录本身也是需要存储在持久数据库中的。这意味着:随着 History Trace 的持续监听这些追踪记录会让数据库的体积变得不堪重负,更尴尬的是这些过期的“累赘”往往已经没有再使用的价值了。

那么我们该如何是好呢?

别着急,HistoryToken 可以为我们解忧排愁!

2. 使用令牌(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
}

如上代码所示:我们在每次监听历史追踪记录后还不忘保存最后一个历史令牌。这样做的好处是,我们就可以在下一次抓取历史追踪记录时排除过期的记录了。

3. 删除过期的历史记录

虽然上面我们已经能够悠然自得的通过历史令牌来排除过期的历史追踪记录,但是这些“累赘”还仍然顽强的占据着 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-)

SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决

2025年6月6日 11:07

在这里插入图片描述

0. 问题现象

我们 watchOS 中的 App 和 Widgets 共享同一个 SwiftData 底层数据库,但是在 App 中对数据库所做的更改并不能被 Widgets 所感知。换句话说,App 更新了数据但在 Widgets 中却看不到。

在这里插入图片描述

如上图所示:我们的 App 在切换至后台之前会随机更新当前的驻场英雄,而驻场英雄会在 Widget 中显示。不过,目前我们的 Widget 中却并未识别到任何驻场英雄,这是怎么回事?又该如何解决呢?

在本篇博文中,您将学到如下内容:

  1. 问题现象
  2. 示例代码
  3. 推本溯源
  4. 解决之道

本文编译及运行环境:Xcode 16 + watchOS 11。


1. 示例代码

首先是 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)时我们做了两件事:

  1. 为 Model 随机选择一个驻场英雄,并将新的关系保存到持久存储中;
  2. 刷新 Widgets 时间线从而促使小组件界面的刷新;

最后,是我们 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 里却“涛声依旧”的显示“英雄都在放假”呢?

这样一个简单的代码逻辑却无法让我们得偿所愿,为什么呢?

2. 推本溯源

虽然上面代码简单的不要不要的,但其中有仍有几个关键“隐患”点在调试时需要排除:

  1. App 在进入后台前是否更新驻场英雄数据到持久存储上了?
  2. 在更新驻场英雄后是否确保 Widget 被及时刷新了?
  3. 刷新后的 Widget 是否可以确保与 App 共享同一个持久存储?

第一条很好排除,只需要在 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 对象并不会自动刷新来反映持久存储中数据的改变。这就是问题的根本原因!

3. 解决之道

在了然了问题的根源之后,解决起来就是小菜一碟了。

最简单的方法,我们只需将原来的惰性属性变为计算属性即可。这样一来,我们即可确保在每次访问 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-)

SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决

2025年6月6日 11:02

在这里插入图片描述

概述

原本在 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-)

使用 Time Profiler 查看关键函数调用耗时情况,从而分析和解决问题

2025年6月6日 10:14
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

1. 分析 Time Profiler 的耗时情况

  • 闭包内部耗时较高

    • generateSingleLineChart 中,ForEach 循环内部的闭包花费了 884 毫秒,其中 .lineStyle(...) 调用占了 561 毫秒,说明对每个数据点的样式计算非常耗时。
    • 同理,generateLineChartgenerateGroupLineChart 中,也分别花费了 259 毫秒和 261 毫秒。这表明在遍历数据并生成 LineMark 时,SwiftUI 在计算各个修饰符(如 lineStyleforegroundStyle)的过程中开销较大。
  • 重复计算和视图重构

    • 每次调用这些生成图表的函数时,都需要重新计算 x 轴、y 轴的最小值、最大值、刻度值等,而且对每个数据点都要构建一个新的 LineMark。如果数据点较多,这些计算和视图构造操作叠加起来就会变得非常耗时。
  • 样式计算问题

    • 例如在 generateSingleLineChart 中,对每个数据点调用 viewModel.getLineColor(for:) 和创建 StrokeStyle 都消耗了大量时间。这提示我们,样式的计算和生成可能没有复用,每次都在重新构建。

2. 关于 NavigationLink 导航后返回页面时卡顿现象

  • 重绘整个视图
    • 当从 LineChartView 跳转到子页面后返回,SwiftUI 会重新调用 body 生成视图。即使数据没有改变,所有的闭包(如 generateSingleLineChartgenerateGroupLineChartgenerateLineChart 等)都会被重新执行,导致整个图表视图重新计算和绘制。
  • 视图重构导致的重复计算
    • 由于 SwiftUI 的声明式特性,每次视图出现时都会重新计算其内部状态,进而触发大量重复的计算操作(比如对时间范围、轴值和样式的计算),所以返回时会明显卡顿,耗时达到 56 秒。

3. 优化思路和解决方案

针对上述问题,可以从以下几个方面考虑优化:

a. 数据和样式的预计算与缓存
  • 提前计算图表数据范围和样式
    • x 轴和 y 轴的计算、刻度值生成、以及各个数据点对应的样式等计算移到 viewModel 中,提前计算好并缓存。这样在视图构建时,只需直接使用缓存结果,避免重复计算。
  • 缓存 StrokeStyle 和颜色
    • 如果 chartLineWidthlineCaplineJoin 等参数不会变化,可以将生成的 StrokeStyle 定义为常量,避免在 ForEach 内部重复创建。
b. 减少不必要的视图重构
  • 局部视图拆分和懒加载

    • 将复杂且耗时的图表部分拆分成独立的子视图,利用 SwiftUI@StateObject@ObservedObject 缓存数据,防止因父视图重新构建而全部重绘。
    • 如果页面上图表较多,可以考虑使用 LazyVStack 替换 VStack,使得只有当视图真正需要显示时才进行计算和绘制。
  • 视图缓存策略

    • 当数据不频繁变化时,可以考虑在视图中利用 .drawingGroup() 或其他缓存手段,将渲染结果缓存成图像,在下一次展示时直接使用缓存图像而不是重新绘制所有的线条。
c. 关注 SwiftUI 的绘图和布局机制
  • 审查 Chart 的内部实现
    • SwiftUIChart 组件可能在处理大量数据点时没有做到最优。如果可能,考虑对数据进行适当的抽样或者合并,减少绘制的元素数量。
  • 分析 View 重构触发机制
    • 检查是否存在不必要的状态更新或绑定,导致整个 LineChartView 被频繁重构。如果能做到局部状态隔离,只让真正需要更新的部分重绘,也会大大降低耗时。

总结

  1. 问题定位

    • 耗时主要在 ForEach 循环中生成各个 LineMark 的过程中,特别是 .lineStyle 的计算,以及在视图重构时重复计算各项轴值和样式。
    • 导航返回时,因为整个视图重新构建,所有的图表闭包都会再次执行,造成明显卡顿。
  2. 优化方案

    • 预计算和缓存:在 viewModel 中提前计算好各个图表需要的数据、刻度、样式等,避免在视图中重复计算。
    • 减少重复视图构建:将耗时较大的图表部分拆分为独立的子视图,并使用懒加载、缓存视图或 .drawingGroup() 技术来减少重绘。
    • 调整数据量:如果数据点非常多,可以考虑做数据抽样或聚合,降低绘图时的计算量。

MCP 与 Vibe Coding

2025年6月6日 10:07

最近 Twitter / X 上很多人开始讨论「MCP」「Vibe Coding」这种新兴概念,很多是程序员圈子里最新兴起的一些“工作流”和“写代码的新思维方式”总结出来的 buzzword

1️⃣ 什么是 MCP?


📌 MCP = Minimum Cognitive Programming

👉 中文大致可以理解为 “最小认知负担编程”,或者叫 “最小认知编程”


🔍 核心理念

  • 代码不追求「最优算法」或者「最完美设计」,而是追求「对当前自己(或团队)最容易理解和维护」的代码。

  • 写代码的时候,优先考虑 降低认知负担,而不是过度追求工程优雅 / 高度抽象。

  • 口号式表达:

    “If it works and you can read it next week, it's good code.”

    “能跑,能读,能维护,就行。”


MCP 背后的动因

1️⃣ 现在很多代码工程、框架、抽象过度复杂,维护者学习成本高

2️⃣ GPT / CopilotAI 辅助时代,更需要写「简单直观可读的代码」,AI 也能帮你“补全”复杂性

3️⃣ 工程实践发现大部分时间是“读代码”,不是写代码,MCP 提倡写 “别人和自己下周能看懂的代码”


🛠 MCP 实践方法

做法 对应 MCP 思路
优先用简单数据结构 少用花哨设计模式
减少抽象层级 不要无谓的加继承、多态
函数写短点 减少嵌套,易读优先
变量名取清晰 不用“高大上”名字,语义直观即可
不 premature optimization 不是核心瓶颈,先保证逻辑对、代码易读

🏷 一句话总结 MCP

👉 MCP = 优先写“认知负担小,易读,易维护”的代码,不刻意炫技,实用主义风格编程


2️⃣ 什么是 Vibe Coding?


📌 Vibe Coding = Vibe-driven coding style

👉 中文可以叫做:“氛围驱动型编程”“带节奏感的编程”


🔍 核心理念

  • 编程是一种创造性活动,要创造好的“氛围 / vibe”,提升开发者的愉悦感、心流状态 → 更高效、更快乐地编程。
  • Vibe Coding 不是传统工程术语,更像是一种开发者心态 / 工作流理念,强调 轻松、自由、创造感

Vibe Coding 常见表现

行为 说明
播放喜欢的背景音乐 营造 flow
一次写完一整段逻辑 保持 coding groove,减少打断
边写边 refactor,随心所欲 不被 rigid 规则卡死
不过度关注“架构正确性” 先爽,后整理
AI Copilot / ChatGPT 辅助流畅写 AI 帮补节奏感,提升 Vibe

🎨 Vibe Coding 背后的思潮

  • 反思“过度流程化、过度工具化”导致开发变枯燥 → 要让写代码重新变成 “创造活动”,而不是“苦活累活”。
  • AI 辅助写代码后,工程师更能 focus 在 “表达意图” 和 “flow” 上,而不是底层语法和繁琐设计。
  • 对抗程序员 burnout → 保持写代码的乐趣、节奏感。

🏷 一句话总结 Vibe Coding

👉 Vibe Coding = 营造写代码的 flow 和节奏感,优先保持创造力和愉悦感,开发过程“带 vibe”更高效更快乐。


3️⃣ MCP + Vibe Coding 的结合(现在很多 Twitter 上就是这种玩法)

  • MCP 让代码「简单、可维护」 → 减少认知负担
  • Vibe Coding 让写代码过程「顺畅、有节奏」 → 提高创造体验
  • AI(Copilot / ChatGPT / Cursor 等)正好促进了这两种思路落地AI 写代码时,如果 prompt 简单明确 + 代码风格易读,AI 补全就更好;Vibe CodingAI 也能增强 flow

4️⃣ 我们怎么学 / 怎么用?


✅ 对工作实践的建议:

做法 MCP 思路 Vibe Coding 思路
日常代码多写「易读、简单」 用普通 for/if,不追求炫技语法 保持 coding flow,先写再调
配合 Copilot / ChatGPT 生成易懂 prompt,易维护代码 一边生成一边调整,保持节奏
少“过度设计” YAGNI 原则,不写用不到的抽象 先写 MVP,保持写的爽感
重视团队代码可维护性 别让自己下周都看不懂 不卷“架构洁癖”

5️⃣ 总结一句话:

MCP = 认知负担最小 → 易读、易维护的实用代码

Vibe Coding = 编程带节奏、保持 flow → 让写代码更有创造力和愉悦感


6️⃣ 参考学习资源

  • Twitter/X 上 #MCP #VibeCoding 标签,很多大佬会发心得
  • “AI 辅助开发工作流”MCP + Vibe Coding 最配合 AI 使用场景
  • Cursor / Copilot / GPT + IDE → 是 MCP + Vibe Coding 的典型工具场景


📚 针对我现在工作场景的 MCP + Vibe Coding 落地方案

我当前的工作场景(iOS + React Native + Swift/SwiftUI + 组件化 + Charts + SDK 工程 + 架构优化)


🎯 我当前典型场景特点

✅ iOS 开发(Swift / SwiftUI / UIKit / React Native

✅ 组件化架构改造期(拆分 IndicatorKit / MarketModule / RealtimeQuoteModule / StockDetailKit 等)

✅ 旧代码 OC/Swift 混杂,技术债多

Charts 图表复杂,易写成高复杂代码

✅ 有跨团队协作,需要代码清晰可维护

✅ 需要加快交付节奏,提升自信和 flow


🛠 1️⃣ 如何落地 MCP 思维(最小认知负担编程)


✅ a) 组件化拆分阶段

传统写法 MCP 优化思路
为了架构“完美”,抽象一堆协议 + 多层 wrapper MVP 阶段先写简单自包含组件,协议 / 抽象层逐步演进
IndicatorKit / MarketModule 拆分过度工程化 先拆物理模块 + 清晰 API,后期视需要再抽象复用层
为复用提前设计复杂泛型 优先“功能先跑通 + 接口直白”

✅ b) Charts 图表模块

传统写法 MCP 优化思路
复杂 config 驱动,ViewModel 太厚 ChartViewModel 分层 + 每张图有独立 config struct,保持易读
Charts 配置“动态拼拼凑凑” 统一简化模型,明确哪些参数“必要”,哪些是“可选”
过度用 Combine / 高阶 Publisher 嵌套 普通数据 flow + 明确 side effect,易维护优先

✅ c) OC/Swift 混合代码

传统写法 MCP 优化思路
急于“重构替换全部” 逐步“封装接口 + 兼容”迁移,阶段性成果可见
复杂 bridging 轻量 bridge,先保证可用可维护

✅ d) 日常写代码习惯调整

改进建议
函数写短(<= 20 行)
变量名直接、具备业务语义
避免 premature optimization
“能跑能读”优先,不急于设计过度通用性
UI 层用 SwiftUI 时,保持 View 纯净,逻辑放 ViewModel

🎵 2️⃣ 如何落地 Vibe Coding 思维(节奏感编程)


✅ 工具搭配(针对 iOS 场景)

工具 推荐理由
Cursor IDE + iOS 工程 作为副 IDEutility / demo,体验 Vibe Coding
ChatGPT + Codex 生成 Swift / SwiftUI / Charts 代码,提速
GitHub Copilot (Xcode plugin) Xcode 里直接 AI 补全,写 Swift / SwiftUI 体验 Vibe
Raycast + GPT 插件 快速查代码片段 / 生成代码
Music App + 固定 coding playlist 帮你 build flowVibe Coding 很推崇)

✅ 我的编码习惯调整

场景 推荐做法
日常组件开发 “先跑通 flow” → 再局部优化
复杂 Charts 图表交互 先实现交互完整 MVP → 再逐步优化性能 / 结构
新模块 API 设计 边写边聊 ChatGPTAI 辅助完善 API 设计
文档同步 AI 帮你“生成接口注释 / 使用示例”,提升文档质量
遇到复杂 legacy 代码 GPT 帮解释 → 保持节奏改造,不钻牛角尖

✅ 心态层面

👉 MCP + Vibe Coding = 给自己“减压 + 加 flow

👉 当前阶段不用急于追求“架构大师范”,先做“能跑能维护 + 组件清爽 + 节奏流畅”

我需要记住一句话

「架构是“演进”出来的,不是“设计”出来的,MCP + Vibe Coding 最适合架构重构期的风格。」


🏆 3️⃣ 总结关键策略

MCP → 减少认知负担,代码可读性第一

Vibe Coding → 编码过程保持 flow,先做 MVP,后期优化

结合 AI → 提速 + 降低心智成本

逐步打造属于自己风格的 “轻盈架构师” 路线



AI 圈 / 工程圈 “缩写撞车” MCP

📌 1️⃣ AI 圈的 MCP 是什么?

Model Context Protocol (MCP) 👉 是最近开源圈 / AI 圈热炒的一个开放协议

用途

  • AI 大模型(比如 GPT-4oClaudeLLaMA)能够通过标准协议 安全访问本地或远程资源

  • 典型场景包括:

    • 让大模型访问文件系统
    • 让大模型调用 API
    • 让大模型读写数据库
    • 做本地 agent 的插件协议(有点类似 “ChatGPT 插件协议”)

出处

  • 目前 GitHub 上有很多 Awesome MCP Servers 列表,收集各种 MCP server 实现。
  • 比如 github.com/awesome-mcp 这种列表。

📌 2️⃣ 之前讲的 MCP?

Minimum Cognitive Programming (MCP) 👉 是一种编程理念 / 编码风格,源自 Twitter / 开发者圈子提倡:

核心思想

  • 编写认知负担最小的代码
  • 保证代码可读、可维护
  • 不追求 “完美架构”,而追求 “写出来能看懂 + 能改 + 能交付”
  • 很适合 AI 辅助编程、现代敏捷开发场景

出处

  • Twitter / X 上很多工程师最近发 “MCP 风格编码” 的帖子,鼓励这种写法,尤其配合 Copilot / GPT 编码时代。

📌 3️⃣ 撞名字?

  • 两者名字恰好都是 MCP(缩写相同)
  • 但是领域完全不同
AI 圈的 MCP 之前讲的 MCP
Model Context Protocol Minimum Cognitive Programming
协议标准(AI agent 圈) 编程理念(工程圈 / 开发圈)
帮大模型调用外部资源 帮开发者降低代码复杂度
有 GitHub awesome 列表 多是 Twitter 圈理念流行
技术协议 编程思维方式

📌 4️⃣ 总结一句话:

👉 AI agent 圈的 MCP = Model Context Protocol,是 AI Agent 圈的开放协议; 👉 之前讲的 MCP = Minimum Cognitive Programming,是工程师圈最近推崇的代码风格理念

昨天以前iOS

苹果审核被拒4.1-Copycats过审技巧实操

作者 iOS研究院
2025年6月5日 18:52

背景

最近后台有留言遭遇了苹果审核被拒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分钟可以顺利通过。

fixbug.png

第二步

如果解释无效,苹果的态度依旧强烈维持原判。那这种情况下考虑从代码层面入手,优先调整Logo、App名称、副标题。 在Appstore市场截图考虑模拟器截取,不使用UI设计的截图。

在主色调进行差异性整改,去修改App内部的功能。建议把Appstore元数据修改和App风格修改分两步走

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

图片

SwiftUI 6.0(iOS 18)新增的网格渐变色 MeshGradient 解惑

2025年6月5日 15:40

在这里插入图片描述

概述

在 SwiftUI 中,我们可以借助渐变色(Gradient)来实现更加灵动多彩的着色效果。从 SwiftUI 6.0 开始,苹果增加了全新的网格渐变色让我们对其有了更自由的定制度。

在这里插入图片描述

因为 gif 格式图片自身的显示能力有限,所以上面的动图无法传神的还原实际的美妙效果。强烈建议大家在模拟器或真机上运行本文中的示例代码。

在本篇博文中,您将学到如下内容:

  1. 渐变色的前世今生
  2. 动画加持,美轮美奂
  3. 综合运用

闲言少叙,让我们马上进入渐变色的世界吧!

Let‘s dive in!!!;)


1. 渐变色的前世今生

在 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 网格渐变色,并将其左上角、右上角、左下角、右下角的颜色依次设置为红色、绿色、蓝色以及黄色:

在这里插入图片描述

现在我们“静如处子”的网格渐变色貌似略显“呆滞”。别急,通过适当地调整其内部各个网格边框的基准点(或者颜色),我们可以让它行云流水般的“动如脱兔”。

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 种颜色)的原因,实际效果会相当丝滑顺畅。

现在,我们不但可以恣意描绘静态渐变色,利用些许动画我们还可以让它活灵活现的呈现“秾姿故薰欲醉眼,芳信暗传尝苦心”之意境,棒棒哒!💯

3. 综合运用

下面是一个将网格渐变色溶入到我们实际应用中的演示代码。在代码中我们做了这样几件事:

  • 用不同状态控制不同的动画效果
  • 使用 mask 将网格渐变色嵌入到文本视图中
  • 扩展 View 以实现更简洁的视图方法

全部源代码在此:

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 如何取得 @Environment 中 @Observable 对象的绑定?

2025年6月5日 15:34

在这里插入图片描述

概述

从 SwiftUI 5.0(iOS 17)开始,苹果推出了全新的 Observation 框架。它作为下一代内容改变响应者全面参与到数据流和事件流的系统中。

在这里插入图片描述

有了 Observation 框架的加持,原本需要多种状态类型的 SwiftUI 视图现在只需要 3 种即可大功告成,它们分别是:@State、@Environment 以及 @Bindable。

在 SwiftUI 中,我们往往会使用 @Environment 来完成视图继承体系中状态的非直接传递,但是在这种情况下我们却无法获取到它的绑定,造成些许不便。

在本篇博文中,我们就来谈谈如何解决这一问题:

  1. ObservableObject 与 @EnvironmentObject 的旧范儿
  2. 问题现象
  3. 解决之道

Let‘s go!!!;)


1. ObservableObject 与 @EnvironmentObject 的旧范儿

在 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 组合来传递跨视图继承体系的状态又会如何呢?

让我们一窥究竟。

2. 问题现象

现在将上面的代码修改为 @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 运行时提出“严正警告”:

在这里插入图片描述

那么,对此我们还能做些什么呢?

3. 解决之道

一种方法是写一个视图包装器,然后将 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-)

SwiftUI 6.0(iOS 18)将 Sections 也考虑进自定义容器子视图布局(下)

2025年6月5日 15:28

在这里插入图片描述

概述

在 WWDC 24 新推出的 SwiftUI 6.0 中,苹果对于容器内部子视图的布局有了更深入的支持。为了能够未雨绸缪满足实际 App 中所有可能的情况,我们还可以再接再厉,将 Sections 的支持也考虑进去。

在这里插入图片描述

SwiftUI 6.0 对容器子视图布局的增强支持可以认为是一个小巧的容器自定义布局(Custome Layout)简化器。

在本篇博文中,您将学到如下内容:

    1. 再进一步:支持段(Sections)的容器子视图布局
    • 5.1 更小的容器组织单位:Section
    • 5.2 支持 Sections 的 ForEach
    • 5.3 支持 Sections 的 Group

有了全新容器子视图布局机制的加持,现在对于任何需要适配自定义容器行为的情况我们都可以游刃有余、从容应对了!

那还等什么呢?Let‘s go!!!


5. 再进一步:支持段(Sections)的容器子视图布局

5.1 更小的容器组织单位:Section

在实际 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 吧。

5.2 支持 Sections 的 ForEach

说出来大家可能不信,对之前 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())
    }
}

编译并在预览中看一看效果吧:

在这里插入图片描述

5.3 支持 Sections 的 Group

类似的,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-)

SwiftUI 6.0(iOS 18)将 Sections 也考虑进自定义容器子视图布局(上)

2025年6月5日 15:22

在这里插入图片描述

概述

在 WWDC 24 新推出的 SwiftUI 6.0 中,苹果对于容器内部子视图的布局有了更深入的支持。为了能够未雨绸缪满足实际 App 中所有可能的情况,我们还可以再接再厉,将 Sections 的支持也考虑进去。

在这里插入图片描述

SwiftUI 6.0 对容器子视图布局的增强支持可以认为是一个小巧的容器自定义布局(Custome Layout)简化器。

在本篇博文中,您将学到如下内容:

  1. SwiftUI 6.0 容器内容的遍历和重新组合
  2. SwiftUI 6.0 中新的容器子视图布局考量
  3. 容器子视图的遍历
  4. 容器子视图的重新组合

有了全新容器子视图布局机制的加持,现在对于任何需要适配自定义容器行为的情况我们都可以游刃有余、从容应对了!

那还等什么呢?Let‘s go!!!;)


1. SwiftUI 6.0 容器内容的遍历和重新组合

苹果在 SwiftUI 4.0(iOS 16)中推出了自定义容器布局(Custom Layout)功能,有了它我们即可放心大胆的创建具有独特外观和行为的容器了:

在这里插入图片描述

Layout 协议弥补了 SwiftUI 不能自定义容器布局之遗憾,将容器中子视图渲染位置的自由度发挥到了极致。

不过,有时候我们仅仅希望稍微调整一下现有容器子视图的布局,比如换成系统默认的现成容器(VStack、HStack、ZStack 等)。在这种情况下使用自定义容器布局 Layout 略显大材小用了。

于是乎,在 SwiftUI 6.0(iOS 18)中苹果给了我们另一套“组合拳”:容器内容的遍历(ForEach)和再组合(Group)。

2. SwiftUI 6.0 中新的容器子视图布局考量

简单来说,在 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 主体。但是,我们无法再更进一步去获取传入容器内部的子元素了,这意味着此时对容器子元素外观的细粒度定制无异于“敲冰求火”。

当然,我们可以通过其它手段来间接达到获取和区分容器内部子元素之目的,但这会使得代码逻辑变得根牙盘错、晦涩难懂。

3. 容器子视图的遍历

上面这种尴尬局面在 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 预览中即可立见分晓:

在这里插入图片描述

4. 容器子视图的重新组合

虽然遍历容器内容“很好很强大”,但在某些场景中与其遍历每个单独的容器元素,我们更希望先获得它们的一个整体(集合)然后再按需求重新组织它们的布局。

这时,我们可以利用 Group 的新构造器来优雅的完成这一功能:

在这里插入图片描述

从上面的定义中可以看到:Group(subviews:) 闭包中传入的实参类型是 SubviewsCollection。

在这里插入图片描述

从它遵循以下几个协议可以看出,它是一个集合,并且每个元素类型都是 SubView:

  • BidirectionalCollection
  • Collection
  • Copyable
  • RandomAccessCollection
  • Sequence
  • View

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

AI Coding 与 AI 视频:从生产力工具到大众内容平台

作者 bang
2025年6月5日 19:08

数字世界的创作和内容,最重要的是程序和视频。程序负责逻辑、互动;视频负责信息的表达、感知。

2024年2月 Sora 概念发布,6月可灵发布,AI 视频开始发展。同年 6月 Claude 3.5 Sonnet 发布,8月 Cursor 接入,AI Coding 开始狂奔。

回想起来只过了一年时间,但已经经历了很多的变化,想探讨一下这两个核心 AI 领域的演化可能性。

现状:作为生产力工具的 AI

AI Coding

AI Coding 短期已经达到生产可用,能显著提升程序员 Coding 效率,长期还是 AGI 本身核心的能力,有现在也有未来,自然是最热门的方向,Cursor / Windsurf / Lovable / Augment 层出不穷。

AI Coding 除了给程序员提效,也开始覆盖到其他互联网从业者,设计师/产品/运营/自媒体 等,让原本不会写程序的人 0 门槛通过 AI Coding 做出 demo 和场景,带来 0-1 的新体验。

但目前这些应用,核心是提效。没有 AI Coding,程序员也能写出一样的程序,产品/设计师等也能跟程序员合作快速做出 demo,从产物的形态/目的角度看,做出来的东西没有本质的变化,只是有了 AI Coding,效率提高了一个级别。

AI 视频

AI 视频过去一年 可灵/即梦/Runway/Veo 等模型持续进化,指令遵循、画面稳定性一直在增强,在一些场景达到了生产可用,提升了 CG 制作、商品广告等视频场景的生产效率。

AI 视频也覆盖到广大自媒体用户,以前需要一个 CG 团队才能做出来的视频,现在一个人可以创作出来,例如橘猫/风格化视频等,给创作者带来 0-1 的新体验,发布到小红书/抖音等平台获利,AI 视频部分代替了基于摄像头实拍的内容,成为新的一种生产力工具。

AI Coding 和 AI 视频作为生产力工具毋庸置疑会带来巨大的价值,也是现在正在发生和快速增长的。

不确定的是,随着能力的持续提升,使用的门槛的持续降低,AI Coding 和 AI 视频的使用人群是否能进一步覆盖更广到亿级大众用户,带来生产力目的以外新的东西,催生新的偏社交/娱乐的内容平台?

大众化和新平台的可能性

类比

从历史上找,有两个可类比的领域:

拍摄

  • 拍摄从早期专业人士才能操作,到现在大众化的手机拍摄,创作工具能力极大提升,带来门槛极大降低,普惠到大众,催生新的内容和平台。
  • 现在专业摄影仍然继续存在于电影电视/广告/艺术等行业,而大众化的拍摄存在于日常生活,脱离了生产力,催生了 Instagram、抖音、朋友圈等这些新的内容和社交平台。
  • 可以想象,AI Coding 和 AI 视频随着门槛降低也可能可以覆盖到大众,催生新的平台。但拍摄的演进过程中伴随了其他巨大的变量:设备和环境的迁移。电脑、互联网、手机设备,与工具的低门槛一起催生了现在的形态,AI 创作只有门槛的降低,缺乏其他大的变量。

3D打印

  • 3D 打印技术的发展,让从以前工厂才能生产实体制品,到 3D 打印的技术发展让每个人都能生产实物制品,3D打印的产物有社交价值(分享/炫耀)、实用价值(3D 打印常制作出工具解决问题),但目前没有内容价值,还是只存在于小众圈子内。
  • 与 AI 创作类似的是,3D 打印的发展是纯工具低门槛化,没有其他变量;差别是 3D 打印是实体世界,与数字世界的规模化扩展性差异巨大。

从类比上并没有特别适配的案例,但不妨碍 AI Coding 和 AI 视频有自己的大众化和演进路径,我们先看看如果工具要大众化和催生新平台,需要什么条件。

消费价值与内容形态

一个工具要大众化,门槛持续降低是必要的但不充分,创作的核心是消费,大部分人有创作欲,但纯粹的创作欲是小众,创作欲更多是社交认同、利益驱动。如果不能分享,大家不会好好拍照,如果没有利益,短视频创作者不会那么多。所以工具要大众化,核心还是创作的内容有高的消费价值,包括消费频次。

但即使人人都创作和分享,也不一定需要新的平台承接,创作产物的形态没有变化,消费场景(硬件/环境)没有变化,原来的社交/内容平台也足以承接。要诞生新的平台,还是得有不同的消费场景或不同的内容形态,导致原来的平台没法很好地承接。我们不考虑新硬件的情况下,主要就看内容形态。

沿着消费价值内容形态,看看 AI Coding 和 AI 视频的情况。

AI Coding

AI Coding 的产物归类到源头可能就三种:工具、内容容器、游戏。我们拆开来设想一下:

  1. 工具:可供大众使用的工具可复用性高,个性化程度低,当前程序员和APP/Web/小程序的量级已经足够大,更多的人群进来用 AI Coding 做小工具,在硬件设备和环境没有变化的情况下不会有太多新的花样,很难出现质变,消费价值和创作频次都不会很高。
  2. 内容容器:个人网站、简历、报告、方案等是这一类。借助 Coding 让内容呈现形态多样化,这也是初期可能最容易人人参与创作出来的东西。但这类的核心是内容而不是代码,只是在内容呈现上可能有个20%的体验提升,Coding 的作用不会太大。要说这一类有什么一定要 Coding 才能满足,那就是交互,如果重交互的内容场景和需求足够多,是有希望的。
  3. 游戏:游戏的核心是代码,人们对游戏玩法有无限的需求,用户消费频次够高,范围够广,这可能是最有希望的品类。产物不一定是现在形态下的一个个独立小游戏,可能是更看重创作和生成过程的游戏,同时又有消费价值,具体是什么不知道。只是游戏相对复杂,当前 Coding 能力还不够,还没看到苗头。

有足够的个性化差异的可交互产品/游戏,是 AI Coding 可能的出圈点。比如,以个人形象为主角的、融入了自身经历的小游戏;比如,一个可以在里面不断做个性化扩建的自由世界,像“我的世界”。如果有这些新的形态,就会催生一个新的内容平台去承接这一类产品。

AI 视频

AI 视频的产物应用涵盖太广,难以细拆,但近期也看到一些大众化和新形态的可能性:

  1. 日常表达:AI 视频是想象力的相机。我们的日常表达,一部分通过摄像头记录和分享,另一部分心情的传达,比较难通过摄像头,在以前更多通过文字,以后可能更多通过 AI 这个想象力的相机。它能把你本来只能用文字描述的心情和感想,转成更容易引起共鸣的画面。近期在抖音上火了一阵的 像素风 AI 视频,就是这种感觉。(印象较深的是,勇士队输掉季后赛的那天,看到了一个像素风视频很好表达了郁闷/不甘的心情,很有共鸣,这种心情用真实的图片视频和文字都很难表达)
  2. 可交互视频:最近看到 odyssey 发布了可交互视频,40ms 生成一帧,根据用户行动实时生成下一帧,体验上像玩游戏一样。可交互视频可能是 AI 视频生成新形态的关键,它不一定是非常实时的交互,比如看一个剧,可以自己修改剧情走向,看到视频里的一个场景,可以进去这个场景无限扩充看它整个空间,都是可能的场景。

日常心情表达是 AI 视频很能大众化的场景,消费价值和消费频次高,但催生不了新平台,生成的视频都会回到原来的内容/社交平台上。可交互视频这种衍生的形态,才会需要一个新的平台去承接。

新形态的核心:交互

看下来无论是 AI Coding 还是 AI 视频,交互 都是新内容形态的关键点。

因为这波 AI 浪潮是生成式 AI,生成的产物都是业界已有的形态,如果只看生成的产物,在没有新的硬件设备、使用环境等其他变量的情况下,只会有生产效率的提升,很难诞生新的内容形态和平台。

生成式 AI 真正独特的地方,是生成的过程。需要用户频繁通过生成产生交互的场景,才会是新的内容场景,才能产生新的内容形态。

AI Coding 和 AI 视频都有在各自领域里通过交互产生新的内容形态的可能。另一种可能是,这两者做进一步的结合,逻辑+画面都实时生成,不断创造的可玩的虚拟世界,可能又能回到元宇宙的概念。

这些新的形态和玩法,可能会像当时 Snapchat 刚出来时大家看不懂,难以理解,但就是能戳中年轻一代的诉求,值得探索和期待。

xcode 16.2报错 Sandbox: rsync.samba(xxxx)解决方案

作者 90后晨仔
2025年6月4日 23:26

解决方案来源

在开发 iOS 应用时,开发者常会遇到因 macOS 系统 沙盒机制(Sandbox) 导致的文件写入权限问题,典型错误如:

Sandbox: rsync.samba(...) deny(1) file-write-create ...

Snip20250604_4.png

这类错误通常发生在构建 Flutter、React Native 或集成第三方库(如 RxCocoa、Realm、MobileVLCKit)的项目中。本文将从原理、解决方案和实践技巧三个方面,帮助你快速定位并解决问题。


一、问题原理

1. 沙盒机制的作用

macOS 的沙盒机制是系统安全策略的一部分,限制应用程序对文件系统、网络等资源的访问权限。Xcode 在构建 iOS 应用时,默认启用沙盒保护,防止恶意行为。但某些场景下(如 Flutter 插件、第三方库的构建脚本),沙盒限制可能导致文件写入失败。

2. 常见触发场景

  • Flutter/React Native 项目:构建过程中需生成临时文件(如 .xcframeworkdSYM 文件),沙盒可能阻止写入。
  • CocoaPods/Carthage 集成:依赖库的构建脚本(如 rsync)可能因权限不足失败。
  • Xcode 版本升级:Xcode 15+ 引入更严格的沙盒策略,部分旧配置失效。
  • 多 Targets 项目:未统一配置所有 Target 的 ENABLE_USER_SCRIPT_SANDBOXING 设置。

二、通用解决方案

1. 禁用用户脚本沙盒

这是最直接的解决方案,适用于大多数场景:

操作步骤

  1. 打开 Xcode 项目。
  2. 进入 Project Navigator → 选择项目 → Build Settings
  3. 搜索 ENABLE_USER_SCRIPT_SANDBOXING
  4. 将值设为 No(适用于所有 Build Configurations:Debug/Release)。

示意图

Xcode 设置 ENABLE_USER_SCRIPT_SANDBOXING.png

注意事项

  • 需同时修改所有 Target:包括主工程(Runner)、Pods 工程(如 CleverPushNotificationServiceExtension)等。
  • 重启 Xcode:修改后需重启 Xcode 生效。

2. 清理缓存并重新安装依赖

沙盒错误可能由旧缓存或依赖残留导致:

操作步骤

  1. 删除 DerivedData
    rm -rf ~/Library/Developer/Xcode/DerivedData
    
  2. 清理 Flutter 缓存(针对 Flutter 项目):
    flutter clean
    flutter pub get
    
  3. 重新安装 CocoaPods 依赖
    cd ios
    pod deintegrate
    pod install --repo-update
    

参考资料

Notepad.exe:轻巧的 Swift 代码编辑器

作者 Fatbobman
2025年6月4日 22:30

如今,Xcode Playground 似乎已经偏离了最初的设计初衷,而 VSCode 的配置对初学者来说又显得颇为复杂。在这样的背景下,如何轻松地搭建一个适合学习和测试 Swift 语言的开发环境?也许本文介绍的 Notepad.exe 能为你提供满意的答案。

RxSwift 开源学习项目汇总

作者 90后晨仔
2025年6月4日 18:57

如果你有知道更好的开源学习项目请留言给我,我会更新到我得文章中,让更多爱学习的朋友看到!


🔝 1.RxSwift 官方示例项目

  • 地址ReactiveX/RxSwift

  • 简介:RxSwift 官方仓库提供了多个示例,展示了如何在实际项目中使用 RxSwift 进行异步操作和数据流处理。

  • 特点

    • 涵盖了 KVO 观察、异步操作、UI 事件等多个方面。
    • 提供了丰富的操作符使用示例。
  • 适合人群:希望深入理解 RxSwift 基础和高级用法的开发者。


📚 2.RxSwift 学习资料合集

  • 地址LeoMobileDeveloper/awesome-rxswift

  • 简介:这是一个精心整理的 RxSwift 学习资源合集,包含了开源应用、库、教程和社区资源。

  • 特点

    • 收录了多个开源项目,如 RxTodo、RxChat、RxGithub 等。
    • 提供了丰富的教程和学习资料链接。
  • 适合人群:希望通过多个项目和教程全面学习 RxSwift 的开发者。


🧱 3.iOS Clean Architecture MVVM + RxSwift 示例

  • 地址kwontaewan/iOS-Clean-Architecture-MVVM-RxSwift

  • 简介:该项目展示了如何使用 Clean Architecture、MVVM 架构和 RxSwift 构建 iOS 应用。

  • 特点

    • 采用分层架构,代码结构清晰。
    • 集成了 Moya 进行网络请求,使用 Kingfisher 进行图片加载。
  • 适合人群:希望学习如何在实际项目中应用 Clean Architecture 和 RxSwift 的开发者。


📖 4.RxSwift 教程配套项目

  • 地址kodecocodes/rxs-materials

  • 简介:这是 Raywenderlich.com 上 RxSwift 教程的配套项目,涵盖了从基础到高级的多个示例。

  • 特点

    • 每个章节都有对应的示例项目,便于学习和实践。
    • 涵盖了 RxSwift 的各个方面,包括操作符、调度器、错误处理等。
  • 适合人群:希望系统学习 RxSwift 的开发者。


🎨 5.MVVM + RxSwift 示例应用

  • 地址alexey-savchenko/MVVM-RxSwift-sample-app

  • 简介:该项目展示了如何结合 MVVM 架构和 RxSwift 构建 iOS 应用,使用了 SnapKit 进行布局。

  • 特点

    • 实现了相册、帖子和评论等功能。
    • 使用了 Coordinator 模式进行导航管理。
  • 适合人群:希望学习如何在实际项目中应用 MVVM 和 RxSwift 的开发者。


📱 6.SwiftHub - GitHub iOS 客户端

  • 地址khoren93/SwiftHub

  • 简介:SwiftHub 是一个使用 RxSwift 和 MVVM-C 架构构建的 GitHub iOS 客户端,展示了如何在实际应用中使用这些技术。

  • 特点

    • 实现了 GitHub 的多个功能模块,如仓库浏览、搜索等。
    • 采用了 Clean Architecture 和 Coordinator 模式。
  • 适合人群:希望学习如何构建复杂应用并应用 RxSwift 和 MVVM-C 架构的开发者。


📘 7.RxSwift 教程示例合集

  • 地址DroidsOnRoids/RxSwiftExamples

  • 简介:该项目包含了多个 RxSwift 教程的示例,涵盖了基础、网络请求、多线程等内容。

  • 特点

    • 每个示例都有详细的说明,便于理解和学习。
    • 涵盖了从基础到高级的多个主题。
  • 适合人群:希望通过多个小项目逐步学习 RxSwift 的开发者。


SwiftUI 值得学习的一些项目汇总

作者 90后晨仔
2025年6月4日 18:51

如果还有更好的学习项目欢迎留言,我会更新到我得文章中,让更多的人看到的!

🚀 推荐的 SwiftUI 开源项目

1.GeekMadeBySwiftUI

这是一个功能丰富的 SwiftUI 项目,展示了如何构建一个完整的应用程序。项目涵盖了用户界面设计、数据管理、网络请求等多个方面,非常适合初学者学习。

2.ZYSwiftUIFrame

该项目提供了一个完整的 SwiftUI 应用示例,包含网络请求、下拉刷新、上拉加载更多、数据增删改查、图片上传和预览等功能。项目还包含了服务端代码,使用 Go 语言编写,模拟真实的项目场景。

3.swiftui-example

这是一个 SwiftUI 示例、技巧和技术集合,旨在帮助开发者构建应用程序、解决问题,并了解 SwiftUI 的实际工作方式。项目主要内容来源于 hackingwithswift.com,适合想要深入了解 SwiftUI 的开发者。

4.Food Truck(苹果官方示例)

这是苹果在 WWDC22 发布的官方示例项目,展示了如何使用 SwiftUI 构建一个完整的应用程序。项目涵盖了以下内容:

  • 使用 NavigationSplitView 管理视图

  • 使用 Charts 展示趋势数据

  • 使用 WeatherService 获取天气数据

  • 实现了 Live Activities 和 Dynamic Island

2.Clean Architecture SwiftUI

该项目展示了如何在 SwiftUI 中应用 Clean Architecture 架构,涵盖了以下内容:

  • 使用 SwiftData 进行数据持久化

  • 实现网络请求

  • 依赖注入

  • 单元测试

适合希望构建可维护、可测试的 SwiftUI 应用的开发者。 


3.Fun SwiftUI Projects

该项目集合了 50 多个 SwiftUI 示例项目,包括:

  • 3D 柱状图

  • 饼图

  • 贪吃蛇游戏

  • 表情符号识别游戏

  • Reddit 客户端

每个项目都附有详细的教程,适合希望通过实践学习 SwiftUI 的开发者。


4.Simple SwiftUI

该项目由著名的 Swift 教程作者 Paul Hudson 创建,包含多个小型 SwiftUI 示例项目,如:

  • 新闻阅读器

  • 记事本

  • 记分板

  • 待办事项列表

适合初学者快速上手 SwiftUI。


5.SwiftUI Weather App

这是一个简单的天气应用示例,包含以下功能:

  • 主界面
  • 每日天气列表
  • 天气详情页面

适合初学者练习 SwiftUI 的基本布局和导航。 


6.SwiftUI App by Mindinventory

该项目展示了一个完整的应用流程,包括:

  • 引导页面

  • 登录/注册/忘记密码页面

  • 主界面

  • 收藏页面

  • 退出登录功能

适合希望了解完整应用流程的开发者。


7.SwiftUI 30 Projects

该项目包含了 30 个使用 SwiftUI 构建的应用示例,涵盖了各种常见的 UI 组件和功能,适合希望通过大量练习掌握 SwiftUI 的开发者。


8.SwiftUI Projects by MattWong

该项目包含多个 SwiftUI 示例项目,包括:

  • 宝可梦图鉴
  • 搜索功能
  • 列表和详情页面

适合希望通过实际项目学习 SwiftUI 的开发者。


📚 学习建议

  • 从基础开始:如果你是初学者,建议先从简单的项目入手,逐步了解 SwiftUI 的布局、视图和数据绑定等基础知识。
  • 逐步深入:在掌握基础知识后,可以尝试更复杂的项目,如 ZYSwiftUIFrame,学习如何处理网络请求、数据管理等实际开发中常见的问题。
  • 参考官方文档:苹果官方的 SwiftUI 文档 是学习 SwiftUI 的权威资料,建议结合项目实践进行学习。

SwiftUI 6.0(iOS 18)自定义容器值(Container Values)让容器布局渐入佳境(下)

2025年6月4日 13:24

在这里插入图片描述

概述

我们在之前多篇博文中已经介绍过 SwiftUI 6.0(iOS 18)新增的自定义容器布局机制。现在,如何利用它们对容器内容进行“探囊取物”和“聚沙成塔”,我们已然胸有成竹了。

在这里插入图片描述

然而,除了上述鬼工雷斧般的新技巧之外,SwiftUI 6.0 其实还提供了能更进一步增加容器布局自由度的新利器:自定义容器值(Container Values)。

在本篇博文中,您将学到如下内容:

  1. 自定义容器值如何让容器布局更加无拘无束?
  2. 水到渠成:在 Section 上应用自定义容器值

相信 SwiftUI 6.0 中全新的自定义容器值能够让容器布局更加“脱胎换骨”、灵动自由。

那还等什么呢?让我们继续前一篇的容器大冒险吧!Let's go!!!;)


4. 自定义容器值如何让容器布局更加无拘无束?

我们首先需要定义自己的 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))
        }
    }
}

编译在预览中看一下成果吧:

在这里插入图片描述

正是我们想要的!棒棒哒!💯

5. 水到渠成:在 Section 上应用自定义容器值

除了在子视图上应用自定义容器值以外,我们还可以同样在容器的段(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-)

SwiftUI 6.0(iOS 18)自定义容器值(Container Values)让容器布局渐入佳境(上)

2025年6月4日 13:19

在这里插入图片描述

概述

我们在之前多篇博文中已经介绍过 SwiftUI 6.0(iOS 18)新增的自定义容器布局机制。现在,如何利用它们对容器内容进行“探囊取物”和“聚沙成塔”,我们已然胸有成竹了。

在这里插入图片描述

然而,除了上述鬼工雷斧般的新技巧之外,SwiftUI 6.0 其实还提供了能更进一步增加容器布局自由度的新利器:自定义容器值(Container Values)。

在本篇博文中,您将学到如下内容:

  1. SwiftUI 6.0 中容器内容的遍历
  2. SwiftUI 6.0 之前的解决之道
  3. 什么是 SwiftUI 6.0 全新的自定义容器值(Container Values)

相信 SwiftUI 6.0 中全新的自定义容器值能够让容器布局更加“脱胎换骨”、灵动自由。

那还等什么呢?让我们马上开始新的容器大冒险吧!Let's go!!!;)


1. SwiftUI 6.0 中容器内容的遍历

从 SwiftUI 6.0(iOS 18)开始,苹果为 ForEach 和 Group 视图增加了全新的构造器,使它们能够分别实现解析容器单个元素和“鸟瞰”容器整体内容的功能:

在这里插入图片描述在这里插入图片描述

我们可以将它们看成是 SwiftUI 4.0 中自定义容器布局(Layout)的一个简化版本。

下面,请允许我们先写一个非常简单的自定义容器 NiceListContainer 来小试拳脚一番:

struct NiceListContainer<Content: View>: View {
    @ViewBuilder var content: Content
    
    var body: some View {
        List {
            ForEach(subviews: content) { subview in
                subview
            }
        }
    }
}

如下代码所示,使用 NiceListContainer 容器很简单:

struct ContentView: View {
    var body: some View {
        NiceListContainer {
            Group {
                Text("Hello")
                    .foregroundStyle(.green)
                
                Text("World")
                    .foregroundStyle(.red)
                
                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)
            }
            .font(.title.weight(.heavy))
        }
    }
}

我们在上面的演示代码中做了这样几件事:

  • 用 @ViewBuilder 语法将任意子元素放到了 NiceListContainer 容器中;
  • 使用 Group 将这些子元素聚为一组。这不会影响 NiceListContainer 主体中 ForEach(subviews:) 的解析,因为 ForEach 可以将组(Group)中的内容“解开”作为单独的容器元素来遍历;

编译并在 Xcode 预览里可以看到,我们成功的将 NiceListContainer 传入闭包中的每个子视图作为 List 中单个行呈现出来了:

在这里插入图片描述

现在假设我们要实现这样一种功能:在 NiceListContainer 容器中指定子视图的左侧(Leading)加上红色竖线以醒目用户。那么,我们怎么才能让 NiceListContainer 容器知晓哪些子视图需要醒目显示呢?

在下面的代码中,我们假想了这种行为。我们使用臆造的 highlightPrefix() 修改器来向 NiceListContainer 容器表明我们想在这些视图上增加醒目显示的意图:

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

那么,我们应该怎样实现上面的 highlightPrefix() 修改器方法呢?大家可以先自己尝试一下。

2. SwiftUI 6.0 之前的解决之道

聪明的小伙伴们可能已经有了一些头绪:我们需要一种方法从容器中的子视图向容器传递消息。这有点像 SwiftUI 中的环境变量,但 @Environment 是从顶向下而不是从底部向上传递消息的。

在 SwiftUI 6.0 之前,我们可以使用 Preference 机制将与子视图绑定的 ID 向上层传递,然后在上层的容器视图中归拢这些 ID,并在这些 ID 对应的视图上应用特殊效果。

另一种办法是,我们可以用 SwiftUI 4.0 Layout 中的自定义布局值来将消息传递给父视图:

在这里插入图片描述

大致的实现如下代码所示:


struct Rotation: LayoutValueKey {
    static let defaultValue: Binding<Angle>? = nil
}

struct ContentView: View {

    // ...

    @State var rotations: [Angle] = Array<Angle>(repeating: .zero, count: 16)
    
    var body: some View {
        
        WheelLayout(radius: radius, rotation: angle) {
            ForEach(0..<16) { idx in
                RoundedRectangle(cornerRadius: 8)
                    .fill(colors[idx%colors.count].opacity(0.7))
                    .frame(width: 70, height: 70)
                    .overlay { Text("\(idx+1)") }
                    .rotationEffect(rotations[idx])
                    // 将自定义的 Rotation 初始值传递到 WheelLayout 中去
                    .layoutValue(key: Rotation.self, value: $rotations[idx])
            }
        }
 
        // ...
}

在上面代码中,我们使用 layoutValue() 修改器将 Rotation 对应 LayoutValueKey 键的值向上传递给了 WheelLayout 容器。

在 WheelLayout 容器中,我们通过子视图的 LayoutValueKey 键语法糖计算了每个子视图适合的 Rotation 值:

struct WheelLayout: Layout {

    // ...

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) 
    {
        let angleStep = (Angle.degrees(360).radians / Double(subviews.count))

        for (index, subview) in subviews.enumerated() {
            let angle = angleStep * CGFloat(index) + rotation.radians
            
            // ...
            
            DispatchQueue.main.async {
            // 计算每个子视图对应的旋转值
                subview[Rotation.self]?.wrappedValue = .radians(angle)
            }
        }
    }
}

虽然上面这些方法可行,但总觉得有些莫可名状。有没有更简单、更优雅的方法呢?

答案自然是:确定、一定、以及肯定!

3. 什么是 SwiftUI 6.0 全新的自定义容器值(Container Values)

从 SwiftUI 6.0 开始,苹果为定制容器布局新增了自定义容器值(Container Values)的概念。

在这里插入图片描述

简单来说,我们可以使用自定义容器值将所需的状态值附加到容器中指定的子视图上,然后传递到容器的解析和再组合中。

利用 SwiftUI 6.0 中全新的 @Entry 宏,我们还可以进一步简化 Container Values 的定义。

在这里插入图片描述

细心的小伙伴们可能发现了,在上图中的 Entry 宏貌似从 iOS 13(SwiftUI 1.0)就开始支持了,如果不是苹果“犯浑”的话,原因可能是苹果决定把这个特性大幅度向前兼容吧。

在下一篇博文中,我们就来看看如何在 SwiftUI 6.0 中优雅的使用 Container Values 吧。

总结

在本篇博文中,我们介绍了如何用 SwiftUi 6.0 全新的自定义容器机制解析容器子元素,并初步介绍了何为 SwiftUI 6.0 全新的自定义容器值(Container Values)。

感谢观赏,再会啦!8-)

❌
❌