普通视图

发现新文章,点击刷新页面。
昨天以前首页

Swift 的多平台策略,需要我们大家一起来建设 | 肘子的 Swift 周报 #091

作者 东坡肘子
2025年7月1日 08:00

issue91.webp

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

Swift 的多平台策略,需要我们大家一起来建设

继 2025 年 2 月 Swift 社区论坛发布关于启动 Android Community Workgroup 的消息数月后,Swift.org 于上周正式宣布成立官方 Android 工作组。这标志着由官方主导的 Swift 安卓平台支持正式启动,未来 Swift 开发者有望获得更完善的安卓适配工具链与开发体验。

不过,在欣喜之余,我们也应正视一个现实:对于绝大多数 Swift 开发者来说,长期以来的开发工作深度依赖苹果生态,日常所用 API 多与系统框架强耦合。尽管 Swift 社区和苹果已着手推进 Foundation 的纯 Swift 化改造,并陆续提供更多跨平台基础库,但这距离满足实际跨平台开发的需求仍有相当差距。

不久前,Swift Package Index 在原有对苹果平台和 Linux 的兼容性标识基础上,新增了对 Android 与 Wasm 平台的支持,侧面反映出社区对多平台适配的重视。我也借此机会让自己的两个库完成了对 Linux 的兼容。不过在适配过程中也深刻体会到,目前还缺乏一个便捷、统一的跨平台开发环境。虽然这两个库的适配较为简单,仅通过 GitHub Actions 就完成了编译测试和修复,但若将来需要支持更多平台,社区能否构建一个便利、安全的适配机制将变得至关重要。

近年来,Swift 在多平台战略上的推进明显提速,但若想真正成为跨平台开发者的主流选择,仅靠官方与苹果的努力还远远不够。我们每一位 Swift 开发者的参与同样不可或缺。Swift 越强大,Swift 开发者越受益。Swift 的多平台生态,需要我们共同建设!

前一期内容全部周报列表

原创

NotificationCenter.Message:Swift 6.2 并发安全通知的全新体验

NotificationCenter 作为 iOS 开发中的经典组件,为开发者提供了灵活的广播——订阅机制。然而,随着 Swift 并发模型的不断演进,传统基于字符串标识和 userInfo 字典的通知方式暴露出了诸多问题。为了彻底解决这些痛点,Swift 6.2 在 Foundation 中引入了全新的并发安全通知协议:NotificationCenter.MainActorMessageNotificationCenter.AsyncMessage。它们充分利用 Swift 的类型系统和并发隔离特性,让消息的发布与订阅在编译期就能得到验证,从根本上杜绝了“线程冲突”和“数据类型错误”等常见问题。

近期推荐

Xcode Coding Intelligence 逆向解析简报 (Reverse-Engineering Xcode's Coding Intelligence Prompt)

在 Xcode 26 中,苹果正式推出了备受期待的 AI 编码助手 —— Coding Intelligence。相较于市面上已有的 AI 编程工具,苹果在系统提示词(system prompt)的设计上是否有自己的哲学?Peter Friese 借助 Proxyman 对其进行了深入逆向分析。通过这些解析出的提示词内容,我们不仅可以了解 Coding Intelligence 的工作机制,也能窥见苹果对现代开发实践的倾向性,比如:强烈推荐使用 Swift Concurrency(async/await、actor)而非 Combine,测试建议使用 Swift Testing 框架与宏。这些设计细节,是苹果开发范式的重要指标。


SwiftUI 设计系统中的语义颜色设计 (SwiftUI Design System Considerations: Semantic Colors)

在构建 SwiftUI 设计系统 API 时,如何优雅地处理 语义颜色(Semantic Colors) 始终是一个令人头疼的问题。Magnus Jensen 在本文中系统梳理了常见方案的优缺点,并提出了一种基于宏(macro)的解决路径,力求实现 可读性强、类型安全、上下文感知 的色彩系统。如果你正打算为自己的 SwiftUI 项目设计一套结构清晰、可维护的风格体系,这篇文章值得一读。


iOS 内存效率指南系列 (Memory Efficiency in iOS)

随着项目复杂度的提升,开发者终将面对内存相关的问题:内存泄漏、系统警告,甚至因资源占用过高被系统强制终止。在这种情况下,如何诊断问题、控制内存占用,是对开发者经验与体系理解的深度考验。Anton Gubarenko 在两篇文章(内存优化篇)中,系统梳理了 iOS 应用内存使用的评估方式、诊断工具以及优化手段,构建出一套完整、实用的内存管理知识体系。


What is @concurrent in Swift 6.2?

从 Swift 最近的几个版本更新和 Xcode 26 的表现可以看出,Swift 团队正有意识地优化并发编程的开发体验。通过启用新的默认行为,开发者无需在一开始就理解所有细节,便能写出更安全的并发代码。@concurrent 的引入,正是这一策略下的产物之一。在 Donny Wals 的这篇文章中,他详细介绍了 @concurrent 的背景与用途。简单来说,@concurrent 是 Swift 6.2 引入的显式并发标记,主要用于在启用 NonIsolatedNonSendingByDefault 特性时,明确指定函数运行在全局执行器上,从而在需要时将工作负载转移到后台线程,避免阻塞调用者所在的 actor(如主线程)。

或许有人会质疑 Swift 是否又在“用新关键字补旧洞”,但从语言设计趋势来看,随着并发模型逐步完善,许多旧关键字的使用将逐渐被默认机制吸收、简化甚至隐藏。


Swift 与 Java 互操作 (Swift 6.2 Java interoperability in Practice)

Swift 与 Java 的互操作并非新鲜事物,但过往的解决方案往往过程复杂且容易出错。Swift 6.2 引入的 swift-java 包具有划时代意义——这是首次提供官方支持、与工具链深度集成、开发体验接近一等公民的互操作方案,标志着 Swift 和 Java 之间真正意义上的“无缝互通”正式到来。Artur Gruchała 通过一个完整的示例项目,详细演示了如何从 Swift 端调用 Java 方法、构建双语言协作的 CLI 应用,并深入分析了实际开发中容易踩坑的关键细节——特别是 classpath 配置等看似简单却至关重要的环节。


Kodeco 教程:迁移到 Swift 6 (Migrating to Swift 6 Tutorial)

Swift 6 引入了更严格的并发规则与更加结构化的编程范式。在迁移过程中,理解隔离域、Sendable 类型、默认行为,以及 @concurrent 的使用变得尤为重要。Audrey Tam 通过一个完整的 SwiftUI 示例项目(附项目源码),系统演示了从 Swift 5 迁移至 Swift 6.2 的全过程,涵盖 Xcode 设置、并发语义调整与数据隔离等核心环节,是一篇很具实用价值的迁移教程。


Modern Concurrency - Swift 6.2 Suite of Examples

如何在 async/await 中实现类似 Combine 的 throttle 操作?如何持续追踪 @Observable 属性的变化?如何构建支持多消费者的异步流?Lucas van Dongen 在这个开源项目中给出了系统性的实践示例。他汇集了 Swift 6.2 并发模型下的多种模式,演示了如何在实际项目中逐步替代 Combine,迁移到更现代、类型安全的并发范式。


是否升级应用的最低支持版本?(Considerations for New iOS Versions)

WWDC 25 中 Liquid Glass 的登场令人惊艳,但要同时支持两种视觉风格,对开发资源是一大考验。这也让很多开发者开始思考是否应放弃对旧系统的支持。David Smith 建议从两个角度判断:现有用户影响新用户流失。以他的 Widgetsmith 应用为例,当前仍有约 9% 的新增用户来自旧系统,一旦抬高最低支持版本将直接失去这部分潜在用户。他认为,只有当旧系统用户占比降至个位数时,再做版本升级才更合理——简化技术负担,不应以牺牲业务增长为代价

活动

AdventureX 25 游客指南

AdventureX 25 将于 2025 年 7 月 23 日至 27 日在杭州市湖畔创研中心与未来科技城学术交流中心举行。本指南包含活动行程介绍、参与方式、群聊福利、出行与住宿建议及注意事项等内容。不论你是来逛展、互动,还是寻找志同道合的伙伴,这份指南都将帮助你轻松规划行程~

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

Swift 的多平台策略,需要我们大家一起来建设 - 肘子的 Swift 周报 #91

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

继 2025 年 2 月 Swift 社区论坛发布关于启动 Android Community Workgroup 的消息数月后,Swift.org 于上周正式宣布成立官方 Android 工作组。这标志着由官方主导的 Swift 安卓平台支持正式启动,未来 Swift 开发者有望获得更完善的安卓适配工具链与开发体验。

Android生态震荡:Swift官方支持落地,跨平台开发迎来“原生级”方案

2025年6月30日 11:02

前言

Swift.org 在 2025年6-26日宣布成立 Android Workgroup,目标是:将Android确立为Swift的官方支持平台并持续维护

Establish and maintain Android as an officially supported platform for Swift.


为什么要做出这个决定?

原因1:跨平台需求高

  • 目前开发者的跨平台开发需求很高;
  • iOS 团队希望一套 Swift 业务逻辑可复用到 Android;
  • Server-Side Swift & Embedded 已验证多平台可行性。

原因2:竞品压力

Google的Kotlin Multiplatform 正快速发展,Flutter也逐渐成熟,成为跨端主流开发方案,官方希望能够提升 Swift 的竞争力。

下面将主要讲解:

  1. Swift支持的Android内容
  2. 关键技术实现
  3. 具体使用
  4. 对比主流跨平台方案
  5. 对开发者的影响

1. Swift支持的Android内容

“官方支持”绝非简单的“Swift代码能在Android编译”就算完成,包括:

1.1 编译器与工具链

  • 为Android的AArch64、x86-64和armv7架构生成预编译SDK
  • 与 NDK 链接脚本、libswiftCore、libdispatch 等系统库适配

1.2 持续集成系统

所有Swift的Pull Request将自动运行Android目标测试,防止平台兼容性退化

1.3 核心库适配

Foundation和Concurrency等核心库将针对Android的文件系统、线程模型差异进行专门优化

1.4 互操作桥接

设计Swift与Java/Kotlin之间的双向调用机制,打破语言壁垒

1.5 开发体验提升

  • 将提供Gradle/Bazel/SwiftPM插件支持
  • VS Code扩展也在开发路线图中

2. 关键技术实现:LLVM

Swift对Android的支持并非从零开始构建,Swift编译器从诞生之初就基于LLVM架构,而Android NDK从R13版本开始就完全转向基于LLVM的Clang编译器。

这种同源架构使得Swift编译器能够被“重定向”,为Android支持的CPU架构生成原生机器码。在具体实现上:

  • Swift直接链接Android的native日志系统(logcat),而非创建自己的日志机制
  • 通过Android NDK提供的LLVM工具链完成交叉编译
  • 运行时库适配Android的bionic libc和线程模型

3. 实战体验:Swift on Android初探

虽然完整支持还在路上,但开发者已经可以尝试Swift开发Android应用。以下是一个简单的互操作示例:

// Swift
@_cdecl("sayHello")
public func sayHello() -> UnsafePointer<CChar> {
    return strdup("Hello from Swift 🐦")
}

// kotlin
class HelloBridge {
    companion object {
        init { System.loadLibrary("hello") }
    }
    external fun sayHello(): String
}

通过JNI将Swift函数挂载到Kotlin,编译的.so文件打包进APK即可运行。

社区已有Tokamak UI框架等尝试支持Android,预示着更完整的解决方案即将到来。


4. 对比主流跨平台方案

对比主流的KMP、Flutter、RN方案


5. 对开发者的意义

对于iOS开发者:跨平台新机遇

  • 多端拓展:轻松将iOS应用扩展到Android平台,覆盖更广泛的用户群体
  • 业务逻辑100%复用:Model层、网络模块和核心算法可以直接共享,无需重写

对于Android开发者:新语言选项

  • 人才流动加速:熟悉Swift的iOS开发者将更容易参与Android项目开发
  • 性能敏感模块的新选择:可将Swift作为“更安全的C++”使用于高性能计算模块

社区与生态影响

  • 学习曲线降低:新人学一门Swift即可开发iOS、Android、服务端等多平台应用
  • 跨平台工具链成熟:Swift Package Manager将成为真正的跨平台依赖管理工具
  • 社区协作加强:Android工作组采用开放协作模式,任何开发者都可参与贡献

结语

这次不仅是Swift语言的一次跨界尝试,更是移动开发的一次生态震荡:苹果主导语言首次系统性拥抱 Google 生态,同时也是苹果对于跨端研发模式与 Kotlin / KMP 生态 的全新博弈。

参考链接: github.com/swiftlang/s… github.com/swiftlang/s…

swift的get和set,newValue和oldValue

2025年6月27日 10:38

计算属性和存储属性都长什么样子,一定要记忆深刻

存储属性

var name: String
var name = "a"
var property: Int = {
return 1
}()

计算属性

class sample {
    var no1 = 0.0, no2 = 0.0
    var length = 300.0, breadth = 150.0

    var middle: (Double, Double) {
        get{
            return (length / 2, breadth / 2)
        }
        set(axis){ //注意这里的axis只是给newValue显示指定了参数名
            no1 = axis.0 - (length / 2)
            no2 = axis.1 - (breadth / 2)
        }
    }
}
或者
var computedValue: Int {
    get { _backingValue }
    set {
        // 使用隐式 newValue 在计算属性的setter中,如果不显式指定参数名,则默认使用`newValue`作为参数名。但是,计算属性没有内置的旧值(oldValue)访问,因为计算属性本身不存储值
        print("新值: \(newValue)")
        _backingValue = newValue
    }
}

在计算属性的setter中,如果不显式指定参数名,则默认使用`newValue`作为参数名。但是,计算属性没有内置的旧值(oldValue)访问,因为计算属性本身不存储值那么如果我们需要一个旧的值呢?需要手动存储旧值

private var _storage = 0
private var _oldValue = 0 // 额外存储旧值
var computedWithOldValue: Int {
    get { _storage }
    set {
        _oldValue = _storage  // 保存当前值为旧值
        _storage = newValue   // 更新为新值

        print("旧值: \(_oldValue), 新值: \(newValue)")
    }
}

只读的计算属性

var metaInfo: [String:String] {
        return [
            "head": self.head,
            "duration":"\(self.duration)"
        ]
    }
或者
var name: String {
       get {
           return ""
       }
   }

注意只读的计算属性并不是我们之前认识的readonly:因为只读计算属性在本类/结构体中也不能赋值图片那么如何实现readonly呢?

private(setvar name: String

这个也很好理解,是有set方法是private的,所以在类/结构体外还是可以get的

struct test1{
    private(set) var members :[String] = []
}

注意一个private(set)的集合,是不能添加和删除元素的

var t = test1()
t.members.append("a") //Cannot use mutating member on immutable value: 'members' setter is inaccessible

也可以结合计算属性使用

private var rawValue: Double = 0

private(set) var calibratedValue: Double {
    get { rawValue * 1.25 }
    set { rawValue = newValue / 1.25 }
}

关于计算属性的几个点

  • 存储属性我们可以定义常量或者是变量,但是对于计算属性,必须定义为变量,并且计算属性在定义时必须包含类型
  • 对于计算属性来说,set方法是可选的,而get方法必不可少
  • let的存储属性没有set方法,只读的计算属性area也没有set方法;所以我们不能简单的通过有没有set方法来区分属性是计算属性还是存储属性

接下来继续看一下协议

  • 协议可以要求遵循协议的类型提供特定名称和类型的实例属性或类型属性。
  • 协议不指定属性是存储属性还是计算属性,它只指定属性的名称和类型
  • 在协议中,实例属性总是使用var声明为变量属性
  • 在协议中,始终使用static关键字作为类属性声明的前缀, 在类中实现时,可以使用classstatic关键字作类属性声明前缀(Class properties are only allowed within classes)
  • 协议还指定属性是可读的还是可读可写的

可读可写的属性在类型声明后通过写入{get set}表示可读属性通过写入{get}表示

protocol SomeProtocol {
    var mustBeSettable: Int { get set }
    var doesNotNeedToBeSettable: Int { get }
}

如果协议要求属性是只读的,这意味着遵循协议的类型必须提供该属性,并且至少允许外部读取(get)。但是,在实现时,该属性可以是存储属性或计算属性,甚至可以是可读可写的(即可写的),因为协议只要求至少可读,不禁止可写。如下图,mustBeSettable至少要满足SomeProtocol中的“可读可写”图片属性观察者属性观察器(didSet 和 willSet)是在属性的值被修改时触发的。这种修改必须通过显式的赋值语法完成

self.property = newValue

这种是观察不到的:

@State private var blurAmount = 0.0 {
    didSet {
        print("New value is \(blurAmount)")
    }
}
Slider(value: $blurAmount, in: 0...20)
    .onChange(of: blurAmount) { newValue in
      print("New value is \(newValue)")
    }

除了在声明语句中对属性赋值,其他对属性做赋值操作,必会触发观察器属性观察器(Property Observers)提供了两个特殊的关键字 newValue 和oldValue,用于在属性值变化时访问新值和旧值。它们分别用于 willSet 和 didSet 观察器中

var property: DataType = initialValue {
    willSet {
        // 使用 newValue 访问即将设置的值
        // 当前属性值仍是旧值
    }
    didSet {
        // 使用 oldValue 访问被覆盖的值
        // 当前属性值已是新值
    }
}

willSet中自带一个newValue的属性,oldValue用property自身即可访问,相同的didSet自带一个oldValue的属性,newValue用property自身即可访问

Xcode 14.3 和 iOS 16.4 为 SwiftUI 带来了哪些新功能?

2025年6月27日 10:07

在这里插入图片描述

0. 概览

今年年初,Apple 推出了最新的 Xcode 14.3 以及对应的 iOS 16.4 。

与此同时,它们对目前最新的 SwiftUI 4.0 也添加了一些新功能:

  • sheet 弹窗后部视图(Interact with a view Behind a sheet)可交互;
  • sheet 弹窗背景透明化;
  • 调整 sheet 弹窗顶角弧度;
  • 控制弹窗内滚动手势优先级;
  • 定制紧密(compact-size )尺寸下 sheet 弹窗大小;
  • Xcode 预览(Preview)模式下对调试输出的支持;

让我们依次来了解一下它们吧。

Let‘s go!!!;)


1. sheet 后部视图可交互

在 iOS 16.4 之前,SwiftUI 中 sheet 弹窗后,如果点击其后部的视图会导致弹窗立即被关闭,从而无法与弹窗后部的视图进行交互。

从 iOS 16.4 开始,我们可以为 sheet 弹窗应用 presentationBackgroundInteraction() 方法,以达到不关闭弹窗而与后部视图交互之目的:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isPresented = false
    @State private var number = 0
    
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            Button("Sheet") {
                isPresented = true
            }
            .buttonStyle(.borderedProminent)
            .padding()
            
            VStack {
                Button("产生随机数: \(number)"){
                    number = Int.random(in: 0..<10000000)
                }
                .foregroundColor(.white)
                .font(.title.weight(.black))
            }.padding(.top, 200)
        }
        .sheet(isPresented: $isPresented) {
            Text("大熊猫侯佩 @ csdn")
                .font(.headline)
                .presentationDetents([.height(120), .medium, .large])
                // 开启后部视图交互
                .presentationBackgroundInteraction(.enabled)
        }
    }
}

在这里插入图片描述

2. sheet 背景透明化

从 iOS 16.4 开始,我们可以为 sheet 弹窗选择透明样式,更好的美化弹出窗口的显示效果。

如下代码所示,我们在 sheet 弹窗上应用了 presentationBackground(_: ) 修改器以实现透明磨砂效果:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetTransparency = false
    
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents([.height(120), .medium, .large])
                        // 或使用 .background 调用 presentationBackground() 方法效果相同
                        //.presentationBackground(.background)
                }
                
                Spacer()
                
                Button("透明弹出") {
                    isSheetTransparency = true
                }
                .sheet(isPresented: $isSheetTransparency) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents([.height(120), .medium, .large])
                        .presentationBackground(.ultraThinMaterial)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

3. sheet 顶部弧度调整

感觉 sheet 弹窗顶角生硬无弧度的小伙伴们有福了,从 iOS 16.4 开始,SwiftUI 开始支持调整 sheet 弹出窗口顶角的弧度了。

我们可以使用 .presentationCornerRadius() 修改器来实现这一功能:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetRadius = false
    
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents(.height(120), .medium, .large])
                }
                
                Spacer()
                
                Button("顶角圆润弧度弹出") {
                    isSheetRadius = true
                }
                .sheet(isPresented: $isSheetRadius) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents([.height(120), .medium, .large])
                        .presentationCornerRadius(30.0)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

4. sheet 滚动手势优先级调整

在 iOS 16.4 之前,如果我们 sheet 尺寸可变弹窗中包含滚动视图(比如 List,ScrollView 等),当用户在弹窗中滚动将会首先引起弹窗尺寸的改变,而不是其滚动内容的改变。

在 iOS 16.4 之后,我们可以调整 sheet 弹窗滚动手势优先级,以确保首先滚动其内容而不是改变弹窗尺寸。

这是通过 .presentationContentInteraction(.scrolls) 方法来实现的:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetScrollable = false
    
    var body: some View {
        
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    VStack(spacing: 16) {
                        Text("大熊猫侯佩 @ csdn")
                            .font(.headline)
                        List(0..<50, id: \.self){ i in
                            Text("Item \(i)")
                                .font(.subheadline)
                        }
                        .listStyle(.plain)
                    }
                    .padding()
                    .presentationDetents([.height(120), .medium, .large])
                }
                
                Spacer()
                
                Button("滚动高优先级弹出") {
                    isSheetScrollable = true
                }
                .sheet(isPresented: $isSheetScrollable) {
                    VStack(spacing: 16) {
                        Text("大熊猫侯佩 @ csdn")
                            .font(.headline)
                        List(0..<50, id: \.self){ i in
                            Text("Item \(i)")
                                .font(.subheadline)
                        }
                        .listStyle(.plain)
                    }
                    .padding()
                    .presentationDetents([.height(120), .medium, .large])
                    .presentationContentInteraction(.scrolls)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

5. 定制 sheet 在紧密尺寸下的大小

在 iOS 16.4 之前,如果在 iPhone 横屏时 sheet 弹窗,则弹出窗口将会铺满整个屏幕。

从 iOS 16.4 开始,我们可以为弹窗应用新的 .presentationCompactAdaptation(_: ) 修改器来改变横屏时弹窗的大小:

struct SheetView: View {
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        VStack(spacing: 16) {
            Text("大熊猫侯佩 @ csdn")
            Button("关闭"){
                dismiss()
            }
        }
    }
}

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetCompactSizeCustom = false
    
    var body: some View {
                
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    SheetView()
                        .padding()
                        .frame(width: 200)
                        .presentationDetents([.height(200), .medium, .large])
                }
                
                Spacer()
                
                Button("自定义尺寸弹出") {
                    isSheetCompactSizeCustom = true
                }
                .sheet(isPresented: $isSheetCompactSizeCustom) {
                    VStack(spacing: 16) {
                        Text("大熊猫侯佩 @ csdn")
                    }
                    .padding()
                    .frame(width: 350)
                    .presentationDetents([.height(200), .medium, .large])
                    .presentationCompactAdaptation(.sheet)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

6. Xcode 预览模式对调试输出的支持

Xcode 14.3 之前,我们在预览(Preview)模式中测试 SwiftUI 界面功能时无法观察调试语句( print 等方法)的输出结果,必须在模拟器或真机中运行才可以在 Xcode 底部调试小窗口中看到 print() 等方法的输出。

从 Xcode 14.3 开始,以预览模式运行 App 时也可以在调试窗口中看到调试语句的输出了,真是太方便了:

@available(iOS 16.4, *)
struct ContentView: View {
    
    var body: some View {
                
        ZStack(alignment: .center) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            Button("显示 debug 输出") {
                print("显示随机数: \(Int.random(in: 0..<10000000))")
            }
        }
    }
}

在这里插入图片描述

7. 总结

在本篇博文中,我们介绍了在 Xcode 14.3 和 iOS 16.4 中 SwiftUI 为开发者带来的新方法和新功能,解决了诸多燃眉之急的问题,小伙伴们不想赶快尝试一下吗?🚀

感谢观赏,再会!8-)

iOS 17(SwiftUI 5.0)带来的图表(Charts)新类型:“大饼”与“甜甜圈”

2025年6月27日 10:00

在这里插入图片描述

概览

WWDC 2023 为我们带来了 iOS 17,也为我们带来了 SwiftUI 5.0。

在 SwiftUI 新版中,Apple 增加了很多重磅功能,也对原有功能做了大幅度升级。

对于 Charts 框架, 新增了饼图(Pie)类型并且加入了图表元素的原生选择功能。

在这里插入图片描述

在本篇博文中,就让我们一起来看看 SwiftUI 5.0 中这些激动人心的新功能吧!

  1. "大饼"与"甜甜圈"
  2. 图表元素的选中
  3. 填上 WWDC 23 视频中的“坑”

Let's go!!!:)


1. "大饼"与"甜甜圈"

SwiftUI 5.0 在 4.0 众多图表类型基础之上,增加了全新的 饼图(Pie) 类型,我们可以通过它来更形象的展示图表数据。


注意:本文中的代码需要 Xcode 15 beta 版才能编译和运行。


下面是 SwiftUI 4.0 Charts 条状图的展示:

在这里插入图片描述

代码如下:

@Model
final class Item {
    var name: String
    var power: Int
    var timestamp: Date
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
        timestamp = Date.now
    }
}

Chart(items) { item in
    BarMark(x: .value("power", item.power), stacking: .normalized)
        .foregroundStyle(by: .value("name", item.name))
}
.chartLegend(.hidden)

想改为使用新饼图类型非常简单,只需将上面的 BarMark 换为 SectorMark 即可:

SectorMark(angle: .value("power", item.power))

在这里插入图片描述

我们可以调整每块“大饼”的空隙大小(angularInset)和圆角的弧度(cornerRadius):

SectorMark(angle: .value("power", item.power),angularInset: 3.0)
    .cornerRadius(10)

在这里插入图片描述

值得注意的是:Charts 中饼图数据改变的动画效果做的也非常生动,SwiftUI 会自动根据状态的变化来合成自然的动画,无需多写半行代码。

不过,“大饼”虽好,“甜甜圈”更佳!

小孩子才做选择,光有“大饼”怎么行,我们连“甜甜圈”也统统都要了🍩!

实现“甜甜圈”(饼图空心)效果也很容易,我们只需调整 SectorMark 构造器中 innerRadius 属性的值即可:

SectorMark(angle: .value("power", item.power),
           innerRadius: .ratio(innerRadius),
           angularInset: 3.0
)

在这里插入图片描述

好诱人的“甜甜圈”哦,有没有想吃的欲望呢?;)

2. 图表元素的选中

除了加入新图表类型以外,SwiftUI 5.0 中 Charts 终于可以支持原生选择啦!

现在,我们无需再手动计算是图表中哪个元素被选中了,一切回归简洁:

struct LocationDetailsChart: View {
  @Binding var rawSelectedDate: Date?

  var body: some View {
    Chart {
      ForEach(data) { series in
        ForEach(series.sales, id: \.day) { element in
          LineMark(
            x: .value("Day", element.day, unit: .day),
            y: .value("Sales", element.sales)
          )
        }
        .foregroundStyle(by: .value("City", series.city))
        .symbol(by: .value("City", series.city))
        .interpolationMethod(.catmullRom)
      }
    }
    .chartXSelection(value: $rawSelectedDate)
  }
}

如上代码所示,我们使用 chartXSelection(value:) 修改器方法将当前选中的数据放入指定的绑定($rawSelectedDate)中。

在这里插入图片描述

除了选择单个图表元素,我们还可以选择一段范围内的元素集合:

Chart(data) { series in
  ForEach(series.sales, id: \.day) { element in
    LineMark(
      x: .value("Day", element.day, unit: .day),
      y: .value("Sales", element.sales)
    )
  }
  ...
}
.chartXSelection(value: $rawSelectedDate)
.chartXSelection(range: $rawSelectedRange)

在这里插入图片描述

那么问题来了,能不能选中 SwiftUI 5.0 图表新饼图类型的“大饼”元素呢?答案是肯定的!

下面是官方视频中对应的代码:

Chart(data, id: \.name) { element in
  SectorMark(
    angle: .value("Sales", element.sales),
    innerRadius: .ratio(0.618),
    angularInset: 1.5
  )
  .cornerRadius(5)
  .foregroundStyle(by: .value("Name", element.name))
  .opacity(element.name == selectedName ? 1.0 : 0.3)
}
.chartAngleSelection(value: $selectedAngle)

类似的, 通过 chartAngleSelection(value:) 修改器方法实现了饼图元素的选中:

在这里插入图片描述

不过,单从这段代码我们还是无法了解饼图元素选中的实现细节,比如:selectedAngle 是什么?它是如何转换成 selectedName 的呢?

为什么  在此要“犹抱琵琶半遮面”隐藏相关的细节呢?这不禁让我预感到它会是一个“坑”!

“坑”中的实现很可能在 iOS 17 正式版中会有所不同,所以  才会这样“遮遮掩掩”。


想要了解更多相关的内容,请移步如下链接观赏:


3. 填上 WWDC 23 视频中的“坑”

WWDC 23 中对应内容的官方视频在下面,想要了解来龙去脉的小伙伴们可以“肆意”观赏:

尽管官方视频中的代码对如何完成饼图元素选中功能“闪烁其词”,但我们可以自己发挥“主观能动性”来大胆推测一下它的实现细节:即自己搞定“甜甜圈”的选中功能。

首先我们要搞清楚的是, chartAngleSelection 方法参数中的绑定值到底是个啥:

public func chartAngleSelection<P>(_ binding: Binding<P?>) -> some View where P : Plottable

我们可以通过监视 angleValue 的值,来看看它是如何跟随我们点击而变化的:

struct ContentView: View {
    // 省略其它状态定义...
    @Query private var items: [Item]
    @State private var angleValue: Int?
    
    var body: some View {
        NavigationView {
            List {
                Chart(items) { item in
                    SectorMark(angle: .value("power", item.power),
                               innerRadius: .ratio(innerRadius),
                               angularInset: 3.0
                    )
                    .cornerRadius(10)
                    .foregroundStyle(by: .value("name", item.name))
                }
                .chartLegend(.hidden)
                .chartAngleSelection($angleValue)
                .onChange(of: angleValue){ old,new in
                // 探查 angleValue 的真正面目...
                    print("new angle value: \(new)")
                }.padding(.vertical, 50)
                
                ForEach(items) { ... }
            }
            .navigationTitle("饼图演示")
        }
    }
}

在这里插入图片描述

如上图所示:chartAngleSelection($angleValue) 方法中的绑定是一个数量值(定义成浮点数类型也可以),我们还发现 angleValue 在 0° 位置附近点击时值越小,而在 360° 位置点击时值越大。

经过验证可得:angleValue 最大值就是 items 中所有元素 power 值的和!据此,我们可以轻松写一个从 angleValue 值找到对应选中 item 的方法:

private func findSltItem() -> Item? {
    guard let slt = angleValue else { return nil }
    
    var sum = 0
    // 若 angleValue 小于第一个 item.power ,则表示选择的是图表中首张“大饼”!
    var sltItem = items.first
    for item in items {
        sum += item.power
        // 试探正确选中的饼图元素
        if sum >= slt {
            sltItem = item
            break
        }
    }
    return sltItem
}

我们现在可以根据饼图中当前选中的 angleValue 值,轻松找到对应的 Item 了:

struct ContentView: View {
    // 省略其它状态定义...
    @Query private var items: [Item]
    @State private var angleValue: Int?
    @State private var sltItem: Item?
    
    var body: some View {
        NavigationView {
            List {
                Chart(items) { item in
                    SectorMark(angle: .value("power", item.power),
                               innerRadius: .ratio(innerRadius),
                               angularInset: 3.0
                    )
                    .cornerRadius(10)
                    .foregroundStyle(by: .value("name", item.name))
                    .opacity(sltItem?.id == item.id ? 1.0 : 0.3)
                }
                .onChange(of: angleValue){ old,new in
                    withAnimation {
                        if let item = findSltItem() {
                            if item == sltItem {
                                // 点击已被选中的元素时取消选择
                                sltItem = nil
                            }else{
                                sltItem = item
                            }
                        }
                    }
                }.padding(.vertical, 50)
                
                ForEach(items) {...}
            }
            .navigationTitle("饼图演示")
        }
    }
}

效果如下:

在这里插入图片描述

看来为  WWDC 官方代码填坑的感觉也很不错哦😘💯

总结

在本篇博文中,我们介绍了 WWDC 23 最新 SwiftUI 5.0(iOS 17)中关于图表的新体验,学习了如何创建饼图(Pie)和实现 Charts 元素的选中功能,小伙伴们还不赶快操练起来!

感谢观赏,再会!8-)

Swift 5.9 与 SwiftUI 5.0 中新 Observation 框架应用之深入浅出

2025年6月25日 16:03

在这里插入图片描述

0. 概览

Swift 5.9 一声炮响为我们带来全新的宏(Macro)机制,也同时带来了干霄凌云的 Observation 框架。

在这里插入图片描述

Observation 框架可以增强通用场景下的使用,也可以搭配 SwiftUI 5.0 而获得双剑合璧的更强威力。

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

  1. @Observable 宏
  2. 通用情境下如何观察 Observable 对象?
  3. Observable 对象与 SwiftUI 珠联璧合
  4. 被“抛弃的” @EnvironmentObject
  5. 在视图中将不可变 Observable 对象转换为可变对象的妙招

那么,就让我们赶快进入 Observation 奇妙的世界吧!

Let‘s go!!!;)


1. @Observable 宏

简单来说,Observation 框架为我们提供了集鲁棒性(robust)、安全性、高性能等三大特性为一身的 Swift 全新观察者设计模式。

它的核心功能在于:监视对象状态,并在改变时做出反应!

在这里插入图片描述

在 Swift 5.9 中,我们可以非常轻松的通过 @Observable 宏将普通类“转化为”可观察(Observable)类。自然,它们的实例都是可观察的:

@Observable
final class Hero {
    var name: String
    var power: Int
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
    }
}

@Observable
final class Model {
    var title: String
    var createAt: Date?
    var heros: [Hero]
    
    init(title: String, heros: [Hero]) {
        self.title = title
        self.createAt = Date.now
        self.heros = heros
    }
}

如上代码所示,我们定义了两个可观察类 Model 和 Hero,就是这么简单!

2. 通用情境下如何观察 Observable 对象?

在一个对象成为可观察之后,我们可以通过 withObservationTracking() 方法随时监听它状态的改变:

在这里插入图片描述

我们可以将对象需要监听的属性放在 withObservationTracking() 的 apply 闭包中,当且仅当( Hero 中其它属性的改变不予理会)这些属性发生改变时其 onChange 闭包将会被调用:

let hero = Hero(name: "大熊猫侯佩", power: 5)

func watching() {
    withObservationTracking({
        NSLog("力量参考值:\(hero.power)")
    }, onChange: {
        NSLog("改变之前的力量!:\(hero.power)")
        watching()
    })
}

watching()

hero.name = "地球熊猫"
hero.power = 11
hero.power = 121

以上代码输出如下:

在这里插入图片描述

使用 withObservationTracking() 方法有 3 点需要注意:

  1. 它默认只会被调用 1 次,所以上面为了能够重复监听,我们在 onChange 闭包里对 watching() 方法再次进行了调用;
  2. withObservationTracking() 方法的 apply 闭包不管如何都会被调用 1 次,即使其监听的属性从未改变过;
  3. 在监听闭包中只能得到属性改变前的旧值;

目前,上面测试代码在 Xcode 15 的 Playground 中编译会报错,提示如下:

error: test15.playground:8:13: error: external macro implementation type 'ObservationMacros.ObservableMacro' could not be found for macro 'Observable()' final class Hero { ^

Observation.Observable:2:180: note: 'Observable()' declared here @attached(member, names: named(_$observationRegistrar), named(access), named(withMutation)) @attached(memberAttribute) @attached(extension, conformances: Observable) public macro Observable() = #externalMacro(module: "ObservationMacros", type: "ObservableMacro")

小伙伴们可以把它们放在 Xcode 的 Command Line Tool 项目中进行测试:

在这里插入图片描述


3. Observable 对象与 SwiftUI 珠联璧合

要想发挥 Observable 对象的最大威力,我们需要 SwiftUI 来一拍即合。

在 SwiftUI 中,我们无需再显式调用 withObservationTracking() 方法来监听改变,如虎添翼的 SwiftUI 已为我们自动完成了所有这一切!

struct ContentView: View {
    let model = Model(title: "地球超级英雄", heros: [])

    var body: some View {        
        NavigationStack {
            Form {
                LabeledContent(content: {
                    Text(model.title)
                }, label: {
                    Text("藏匿点名称")
                })
                
                LabeledContent(content: {
                    Text(model.createAt?.formatted(date: .omitted, time: .standard) ?? "无")
                }, label: {
                    Text("更新时间")
                })
                
                Button("刷新") {
                    // SwiftUI 会自动监听可观察对象的改变,并刷新界面
                    model.title = "爱丽丝仙境兔子洞"
                    model.createAt = Date.now
                }
            }.navigationTitle(model.title)
        }
    }
}

注意,上面代码中 model 属性只是一个普通的 let 常量,即便如此 model 的改变仍会反映到界面上:

在这里插入图片描述

4. 被“抛弃的” @EnvironmentObject

有了 Swift 5.9 中新 Observation 框架加入游戏,在 SwiftUI 5.0 中 EnvironmentObject 再无用武之地,我们仅用 Environment 即可搞定一切!

早在 SwiftUI 1.0 版本时,其就已经提供了 Environment 对应的构造器:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct Environment<Value> : DynamicProperty {...}

有了新 Observation 框架的入驻,结合其 Observable 可观察对象,Environment 可以再次大放异彩:

struct HeroListView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        List(model.heros) { hero in
            HStack {
                Text(hero.name)
                    .font(.headline)
                Spacer()
                Text("\(hero.power)")
                    .font(.subheadline)
                    .foregroundStyle(.gray)
            }
        }
    }
}

struct ContentView: View {
    @State var model = Model(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])

    var body: some View {        
        NavigationStack {
            Form {
                NavigationLink(destination: HeroListView().environment(model)) {
                    Text("查看所有英雄")
                }
            }.navigationTitle(model.title)
        }
    }
}

现在,即使跨越多重层级关系我们也可以只通过 @Environment 而不用 @EnvironmentObject 来完成状态的间接传递了,是不是很赞呢?👍🏻

5. 在视图中将不可变 Observable 对象转换为可变对象的妙招

介绍了以上这许多,就还剩一个主题没有涉及:Observable 对象的可变性!

为了能够在子视图中更改对应的可观察对象,我们可以用 @Bindable 修饰传入的 Observable 对象:

struct HeroView: View {
    @Bindable var hero: Hero
    
    var body: some View {
        Form {
            TextField("名称", text: $hero.name)
            
            TextField("力量", text: .init(get: {
                String(hero.power) 
            }, set: {
                hero.power = Int($0) ?? 0
            }))
        }
    }
}

不过,对于之前 @Environment 那个例子来说,如何达到子视图能够修改传入的 @Environment 可观察对象呢?

别急,我们可以利用称为“临时可变(Temporary Variable)”的技术将原先不可变的可观察对象改为可变:

extension Hero: Identifiable {
    var id: String {
        name
    }
}

struct HeroListView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        // 在 body 内将 model 改为可变
        @Bindable var model = model
        
        VStack {
            List(model.heros) { hero in
                HStack {
                    Text(hero.name)
                        .font(.headline)
                    Spacer()
                    Text("\(hero.power)")
                        .font(.subheadline)
                        .foregroundStyle(.gray)
                }
            }.safeAreaInset(edge: .bottom) {
            // 绑定可变 model 中的状态以修改英雄名称
                TextField("", text: $model.heros[0].name)
                    .padding()
            } 
        }
    }
}

运行效果如下:

在这里插入图片描述

“临时可变”这一技术可以用于视图中任何化“不变”为“可变”的场景中,当然对于直接视图间对象的传递,我们可以使用 @Bindable 这一更为“正统”的方法。

6. 总结

在本篇博文中,我们讨论了在 Swift 5.0 和 SwiftUI 5.0 中大放异彩 Observation 框架的使用,并就诸多技术细节问题给与了详细的介绍,愿君喜欢。

感谢观赏,再会!8-)

SwiftUI 4.0:两种方式实现子视图导航功能

2025年6月25日 15:58

在这里插入图片描述

0. 概览

从 SwiftUI 4.0 开始,觉悟了的苹果毅然抛弃了已“药石无效”的 NavigationView,改为使用全新的 NavigationStack 视图。

诚然,NavigationStack 从先进性来说比 NavigationView 有不小的提升,若要如数家珍得单开洋洋洒洒的一篇来介绍。


关于 SwiftUI 中旧 NavigationView 视图种种人神共愤的“诟病”和弊端,请移步我的其它专题博文观赏:


不过,这些都不是本篇的主旨。在本篇中我们将尝试一起另辟蹊径来完成两种截然不同的导航机制。

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

  1. NavigationStack
  2. NavigationSplitView 导航之“假象”
  3. 洞若观火:在 iPad 上的比较

无需等待,Let’s go!!!;)


1. NavigationStack

从 SwiftUI 4.0 开始, 引入的新 NavigationStack 导航器终于不再以分散杂乱的数据作为导航触发媒介,而是将有序的数据集合作为导航跳转的核心来对待!(所以,一步跳回根视图成了雕虫小技)

其中,NavigationStack 导航器重要的组成部分 NavigationLink 除了为了兼容性暂时保留的传统跳转方式以外,主打以状态本身的值作为导航的基石。这样,对于不同类型状态触发的跳转,我们可以干净而从容的分别处理:

下面举一例。

首先,定义简单的数据结构,Alliance 中包含若干 Hero:

@Observable
final class Hero {
    var name: String
    var power: Int
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
    }
}

extension Hero: Identifiable {
    var id: String {
        name
    }
}

extension Hero: Hashable {
    static func == (lhs: Hero, rhs: Hero) -> Bool {
        lhs.name == rhs.name && lhs.power == rhs.power
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(power)
    }
}


@Observable
final class Alliance: Hashable {
    static func == (lhs: Alliance, rhs: Alliance) -> Bool {
        lhs.title == rhs.title
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
    
    var title: String
    var createAt: Date?
    var heros: [Hero]
    
    init(title: String, heros: [Hero]) {
        self.title = title
        self.createAt = Date.now
        self.heros = heros
    }
}

接下来是与之对应的子视图,注意驱动 NavigationLink 导航的是 Hero 本身,而不是什么“莫名奇妙”的条件变量:

struct HeroDetailView: View {
    let hero: Hero
    
    var body: some View {
        VStack {
            Text("力量: \(hero.power)")
                .font(.largeTitle)
            
        }.navigationTitle(hero.name)
    }
}

struct HeroListView: View {
    @Environment(Alliance.self) var model
    
    var body: some View {        
        VStack {
            List(model.heros) { hero in
                NavigationLink(value: hero) {
                    HStack {
                        Text(hero.name)
                            .font(.headline)
                        Spacer()
                        Text("\(hero.power)")
                            .font(.subheadline)
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

接着是主视图:

struct ContentView: View {
    @State var model = Alliance(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])

    var body: some View {
        NavigationStack {
            Form {
                NavigationLink("查看所有英雄", value: model)
            }
            .navigationDestination(for: Alliance.self) { model in
                HeroListView()
                    .environment(model)
            }
            .navigationDestination(for: Hero.self) { hero in
                HeroDetailView(hero: hero)
            }
            .navigationTitle(model.title)
        }
    }
}

从上面源代码中,我们可以看到几处有趣的地方:

  1. 子视图 HeroListView 和主视图 ContentView 都包含了 NavigationLink,但它们驱动状态的类型不一样(分别是 Hero 和 Model),这样不同的驱动源被清晰的区分开了;
  2. 放置 NavigationLink 和实际发生导航跳转的目标位置是分开的(通过 navigationDestination() 修改器),后者被放在了一起便于集中管理;

正是这些新的导航特性确保了导航逻辑代码清楚且集中,为日后自己或其它秃头码农来维护打下夯实基础:

在这里插入图片描述

以上就是第一种导航方法,即利用 NavigationStack + navigationDestination() 修改器方法来合作完成跳转功能。

2. NavigationSplitView 导航之“假象”

可能有的小伙伴们没太在意,SwiftUI 4.0 除了 NavigationStack 以外还新加入了另一个鲜为人知的导航器 NavigationSplitView!使用它,我们可以抛弃 navigationDestination() 去实现完全相同的导航功能。

我们对之前代码略作修改,看看能促成什么新奇的“玩法”:

struct HeroListView: View {
    @Environment(Alliance.self) var model
    @Binding var selection: Hero?
    
    var body: some View {        
        VStack {
            List(model.heros, selection: $selection) { hero in
                NavigationLink(value: hero) {
                    HStack {
                        Text(hero.name)
                            .font(.headline)
                        Spacer()
                        Text("\(hero.power)")
                            .font(.subheadline)
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

struct ContentView: View {
    @State var model = Alliance(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])
    
    @State private var selection: Hero?

    var body: some View {
        NavigationSplitView(sidebar: {
            HeroListView(selection: $selection)
                .environment(model)
                .navigationTitle("新导航方式")
        }, detail: {
            if let selection {
                HeroDetailView(hero: selection)
            } else {
                ContentUnavailableView("No Hero", systemImage: "person.badge.key.fill", description: Text("还未选中任何英雄!"))

            }
        })
    }
}

可以看到,修改后的代码与之前有几处不同:

  1. 使用 NavigationSplitView 而不是 NavigationStack;
  2. 没有使用任何 navigationDestination() 修改器方法;
  3. 向 List 构造器传入了 selection 参数,以判断用户选择了哪个 Hero;
  4. 根据 selection 的值驱动 NavigationSplitView 构造器 detail 闭包完成跳转功能;

简单来说:当用户选中任意 Hero 后,通过设置 NavigationSplitView 构造器 detail 闭包中的视图,我们完成了导航机制。

代码执行结果和之前几乎完全相同,这么神奇!?

可惜,你们看到的全是“假象”!!!

3. 洞若观火:在 iPad 上的比较

其实,设置 NavigationSplitView 构造器 detail 闭包的内容原本并不会造成导航跳转,它的原意是在大屏设备上更方便的利用大尺寸屏幕来浏览内容。

编译上面 NavigationSplitView 的实现,在 iPad 上运行看看效果:

在这里插入图片描述

看到了吗?第二种导航机制在大屏设备上原本并不是真正用来导航跳转的,只是在 iPhone 等小屏设备上它的行为退化成了导航!

而第一种导航实现是彻头彻尾、如假包换的“真”导航:

在这里插入图片描述

到底哪种方法更好一些?小伙伴们自有“仁者见仁智者见智”的看法,欢迎大家随后的讨论。

至此,我们完成了文章开头的目标,棒棒哒!!!💯

4. 总结

在本篇博文中,我们在 SwiftUI 4.0 里通过两种不同方式实现了相同的子视图导航功能,任君选择。

感谢观赏,再会!8-)

SwiftUI 代码调试之都是“变心”惹的祸

2025年6月25日 15:53

在这里插入图片描述

0. 概览

这是一段非常简单的 SwiftUI 代码,我们将 Item 数组传递到子视图并在子视图中对其进行修改,修改的结果会立即在主视图中反映出来。

在这里插入图片描述

不幸的是,当我们修改 Item 名称时却发现不能连续输入:每次敲一个字符键盘都会立即收起并且原输入焦点会马上丢失,这是怎么回事呢?

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

  1. 不该发生的错误
  2. 无效的尝试:用子视图包装
  3. 寻根究底
  4. 解决之道

该问题这是初学者在 SwiftUI 开发中常常会犯的一个错误,不过看完本篇之后相信大家都会对此自胸有成竹!

废话不再,Let‘s fix it!!!;)


1. 不该发生的错误

照例我们先看一下源代码。

例子中我们创建了 Item 结构用来作为 Model 中的“真相之源”。


想要了解更多 SwiftUI 编程和“真相之源”奥秘的小伙伴们,请观赏我专题专栏中的如下文章:


注意,我们让 Item 遵守了 Identifiable 协议,这样可以更好的适配 SwiftUI 列表中的显示:

struct Item: Identifiable {
    
    var id: String {
        name
    }
    
    var name: String
    var count: Int
}

let g_items: [Item] = [
    .init(name: "宇宙魔方", count: 11),
    .init(name: "宝石手套", count: 1),
    .init(name: "大黄蜂", count: 1)
]

接下来是主视图 ItemListView,可以看到我们将 items 状态传递到子视图的 ForEach 循环中去了:

struct ItemListView: View {
    @State var items = g_items
    
    private var total: Int {
        items.reduce(0) { $0 + $1.count}
    }
    
    private var desc: [String] {
        items.reduce([String]()) { $0 + [$1.name]}
    }
    
    var body: some View {
        NavigationStack {
            // 子视图 ForEach 循环...
            ForEach($items) { $item in
// 代码马上来...
}
            
            VStack {
                Text(desc.joined(separator: ","))
                    .font(.title3)
                    .foregroundStyle(.pink)
                HStack {
                    Text("宝贝总数量:\(total)")
                        .font(.headline)
                    
                    Spacer().frame(width: 20)
                    
                    Button("所有 +1"){
                        for idx in items.indices {
                            guard items[idx].count < 100 else { continue}
                            
                            items[idx].count += 1
                        }
                    }
                    .font(.headline)
                    .buttonStyle(.borderedProminent)
                }
            }.offset(y: 200)
        }
    }
}

最后是 ForEach 循环中的内容,如下所示我们用单个 item 的值绑定来实现修改其内容的目的:

ForEach($items) { $item in
    HStack {
        
        TextField("输入项目名称", text: $item.name)
            .font(.title2.weight(.heavy))
        
        
        Text("数量:\(item.count)")
            .foregroundStyle(.gray)
        
        Slider(value: .init(get: {
            Double(item.count)
        }, set: {
            item.count = Int($0)
        }), in: 0.0...100.0)
    }
}
.padding()

这样一段看起来“天衣无缝”的代码为什么会出现在更改 Item 名称时键盘反复关闭、输入焦点丢失的问题呢?

2. 无效的尝试:用子视图包装

我们首先猜测是子视图中 Item 名称的更改导致了父视图的“冗余”刷新,从而引起键盘不正确被重置。


更多 SwiftUI 和 Swift 代码调试的例子,请观赏我专题专栏中的博文:


因为键盘所属的视图发生重建所以键盘本身也会被重置,那么如何验证我们的猜测呢?一种方式是使用如下的调试技术:

在这里我们假设病根果真如此。那么一种常用的解决办法立即浮现于脑海:我们可以将引起刷新的子视图片段包装在新的 View 结构中,这样做到原因是 SwiftUI 渲染器足够智能可以只刷新子视图而不是父视图中大段内容的更改。


更详细的原理请参考如下链接:


So,让我撸起袖子开动起来!

首先,将 ForEach 循环中编辑单个 Item 的 View 包装为一个新的视图 ItemEditView:

struct ItemEditView: View {
    @Binding var item: Item
    
    var body: some View {
        HStack {
            
            TextField("输入项目名称", text: $item.name)
                .font(.title2.weight(.heavy))
            
            
            Text("数量:\(item.count)")
                .foregroundStyle(.gray)
            
            Slider(value: .init(get: {
                Double(item.count)
            }, set: {
                item.count = Int($0)
            }), in: 0.0...100.0)
        }
    }
}

接着,我们将 ForEach 循环本身用一个新视图取代:

struct EditView: View {
    
    @Binding var items: [Item]
    
    var body: some View {
        ForEach($items) { $item in
            ItemEditView(item: $item)
        }
        .padding()
    }
}

最后,我们所要做的就是将父视图 ItemListView 中的 ForEach 循环变为 EditView 视图:

NavigationStack {
    EditView(items: $items)

    // 其它代码不变...
}

再次运行代码...不幸的是问题依旧:

在这里插入图片描述

看来这并不是简单父视图“过度”刷新的问题,一定是有什么不应有的行为触发了父视图的刷新,到底是什么呢?

3. 寻根究底

问题一定出在 ForEach 循环里!

回顾之前 Item 的定义,我们用 Identifiable 协议满足 ForEach 对子项目唯一性的挑剔,我们用 Item.name 构建了 id 属性。

当 Model 元素遵守 Identifiable 协议时,应该确保在任意时刻所有 Item 的 id 属性值都是唯一的!从目前来看,上述代码在修改 Item 名称时并没有发生重名的情况(虽然可能发生),所以对于唯一性是没有问题的。


当然在实际代码中用户很可能会输入重复的 Item 名称,所以还是不可接收的。

不过,这段代码在这里只是作为例子来向大家展示解决问题的推理过程,所以不必深究 ;)


但是 id 还有另一个重要的特征:稳定性

一般的,当 Identifiable 实体对象的 id 属性改变时,SwiftUI 会认为其不再是同一个对象,而立即刷新其所对应的视图界面。

所以,正如大家所看到的那样:每次用户输入 name 中的新字符时,键盘会被立即关闭焦点也随即丢失!

4. 解决之道

知道了问题原因,解决起来就很容易了。

我们只需要在 Item 生命周期中保证 id 的稳定性就可以了,这意味着不能再用 name 值作为 id 的“关联”值:

struct Item: Identifiable {
    let id = UUID()
    
    var name: String
    var count: Int
}

如上代码所示,我们在 Item 创建时为 id 生成一个唯一的 UUID 对象,这可以保证两点:

  • 任意时刻 Item 的唯一性;
  • 任意 Item 在其生命周期中的稳定性;

有了如上修改之后,我们再来运行代码看看结果:

在这里插入图片描述

可以看到,现在我们可以毫无问题的连续输入 Item 的名字了,焦点不会再丢失,一切回归正常,棒棒哒!!!💯

总结

在本篇博文中,我们讨论了 SwiftUI 开发中一个非常常见的问题,并借助一步步溯本回原的推理找到症结根本之所在,最后一发入魂将其完美解决!相信小伙伴们都能由此受益匪浅。

感谢观赏,再会!8-)

消失的它:摆脱 SwiftUI 中“嵌入视图数量不能超过 10 个”限制的秘密

2025年6月24日 21:56

在这里插入图片描述

概览

SwiftUI 带来了描述性界面布局的新玩法,让我们可以超轻松的创建复杂应用界面。但是在早期 SwiftUI 中有一个“著名”的限制大家知道么?那就是 @ViewBuilder 中嵌入子视图数量不能超过 10 个!

不过,从 Swift 5.9 开始这一“桎梏”已悄然消失的无影无踪。

在这里插入图片描述

这个限制为什么已然烟消云散?早期的限制又是如何产生的呢?

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

  1. 不能超过 10 个,你是来逗我的吗?
  2. “值与类型形参包”
  3. SwiftUI 的新实现
  4. 为何不能用泛型数组?

想知道事件的前因后果么?那还等什么呢?

Let‘s go!!!;)


1. 不能超过 10 个,你是来逗我的吗?

在 Swift 5.5 中增加了 some 关键字,让 SwiftUI 能够用简洁类型来描述海量复合视图。这还不算完,可能  觉得视图组合的手法还是太麻烦,随即又祭出 @ViewBuilder 来进一步简化 SwiftUI 的视图构建。


其实,SwiftUI 视图的 body 计算属性已被 @ViewBuilder 默默修饰着,我们能够轻松自在,全靠 @ViewBuilder 为我们负重前行:

@ViewBuilder var body: Self.Body { get }

更多 ViewBuilder 实现细节的讨论,请小伙伴们移步 Swift 官方社区观赏:


@ViewBuilder 其实是结果构建器(Result Builder,Swift 5.4)在 SwiftUI 中的一个实现。结果构建器可以被视为一种嵌入式领域特定语言(DSL),用于将收集的内容组合成最终的结果。

这就是我们可以这样创建 SwiftUI 复合视图的原因:

@ViewBuilder func lot(_ needDetails: Bool) -> some View {
    Text("Hello World")
        .font(.title)
    if needDetails {
        Text("大熊猫侯佩 @ csdn")
            .font(.headline)
            .foregroundStyle(.gray)
    }
}

在这里插入图片描述

那么,ViewBuilder 在内部是如何处理传入不定数量视图的呢?

ViewBuilder 为了满足 Result Builder 的语义,必须实现其规定的一系列方法:

在这里插入图片描述


取决于大家要实现 DSL 语言的完整性和复杂性,我们可以选择实现尽可能少或全部这些方法。

讨论如何用 Result Builder 来实现自己的 DSL 语言超出了本文的范畴,感兴趣的小伙伴们可以移步下面的链接观赏进一步内容:

想了解更多 Swift 语言开发的知识,小伙伴们可以到我的专题专栏中进行系统性学习:


而对于简单 View 的合成,ViewBuilder 竟然采用了一种最“蠢”的方式:为每种“可能”的情况手动定义一个方法。

于是乎,就有了下面这一大坨泛型方法:

在这里插入图片描述

正如小伙伴们所猜的那样,这些方法中最大可传入的参数数量就是 10 (c0-c9),所以这就是“桎梏”的根本原因:我们在 @ViewBuilder 中最多只能包含 10 个子视图。

static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View, C8: View, C9: View {
    return .init((c0, c1, c2, c3, c4, c5, c6, c7, c8, c9))
}

对于超过 10 个视图的情况,我们只能“八仙过海各显神通”的尝试绕过它。

比如一种办法是:将 10 个以上的视图塞到多个 Group 中去。

2. “值与类型形参包”

从 Swift 5.9 开始,苹果似乎认识到之前的做法比较“二”,所以推出了新的“值与类型形参包”(Value and Type parameter packs)机制。

该机制专门用于处理不确定数量泛型参数的方法:

func eachFirst<each T: Collection>(_ item: repeat each T) -> (repeat (each T).Element?) {
    return (repeat (each item).first)
}

比如在上面代码中,我们用 each 和 repeat each 分别修饰了泛型参数的形参和结果部分。

eachFirst() 方法的作用是将所有传入集合的第一个元素组成一个新的元组。现在 eachFirst() 泛型方法可以接受任意个类型为 Collection 的参数,同时返回同样数量 Collection.Element? 类型元素的元组。

我们可以这样调用 eachFirst() 方法:

let numbers = [0, 1, 2]
let names = ["Antoine", "Maaike", "Sep"]
let firstValues = eachFirst(numbers, names)
print(firstValues) 
// (Optional(0), Optional("Antoine"))

看到了么?不管传入参数有多少个、不管它们是什么类型(至少必须是 Collection),eachFirst() 方法都可以正常工作。

有了“值与类型形参包”,我们处理泛型方法的灵活性提升一个新层级!

3. SwiftUI 的新实现

在 Swift 5.9 中,SwiftUI 用新“值与类型形参包”机制重写了 ViewBuilder 的实现。

不像之前每种情况“傻傻的”写一个对应的 buildBlock() 方法,现在只需一个带 each/repeat each 的 buildBlock() 方法足矣:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@resultBuilder public struct ViewBuilder {

    public static func buildBlock<each Content>(_ content: repeat each Content) -> TupleView<(repeat each Content)> where repeat each Content : View
}

如上代码所示,我们现在可以向 @ViewBuilder 传递任意数量的视图了:

struct ContentView: View {
    var body: some View {
        Group {
            Text("1")
            Text("2")
            Text("3")
            Text("4")
            Text("5")
            Text("6")
            Text("7")
            Text("8")
            Text("9")
            Text("10")
            Text("11")
            Text("12")
        }
        .foregroundStyle(.white)
        .background {
            Circle()
                .fill(Color.blue.opacity(0.5))
                .frame(width: 35)
        }
        .shadow(radius: 5.0)
        .padding()
        .font(.title2.weight(.bold))
    }
}

是不是很赞呢?棒棒哒💯

4. 为何不能用泛型数组?

有些小伙伴可能觉得,为什么之前 eachFirst() 方法不能用泛型数组的方式来实现呢?用泛型数组不就可以传入任意数量的集合参数了吗?

我们来试一下:

func eachFirst<T: Collection>(collections: [T]) -> [T.Element?] {
    collections.map(\.first)
}

实际运行就会发现,如果用泛型数组则无法传入不同类型元素的集合:

在这里插入图片描述

这就是为什么上面代码报错的原因了。

有时候我们希望 eachFirst() 泛型方法中至少要带一个形参,这可以用类似下面的方式来实现:

func eachFirst<FirstT: Collection, each T: Collection>(_ firstItem: FirstT, _ item: repeat each T) -> (FirstT.Element?, repeat (each T).Element?) {
    return (firstItem.first, repeat (each item).first)
}

let numbers = [0, 1, 2]
let names = ["Antoine", "Maaike", "Sep"]
let booleans = [true, false, true]
let doubles = [3.3, 4.1, 5.6]

let firstValues = eachFirst(numbers, names, booleans, doubles)
print(firstValues) 
// (Optional(0), Optional("Antoine"), Optional(true), Optional(3.3))

总结

在本篇博文中,我们讨论了 SwiftUI 中“嵌入视图数量不能超过 10 个”这一限制的原因,并介绍了从 Swift 5.9+ 开始这一限制为什么最终消失了?

感谢观赏,再会!8-)

❌
❌