大多数浏览器和
Developer App 均支持流媒体播放。
-
SwiftUI 中的新功能
现在正是使用 SwiftUI 开发 app 的最佳时机。了解 UI 框架的最新更新,包括列表、按钮和文本字段,并了解这些功能如何帮助您在 app 中更完整地采用 SwiftUI。了解如何使用画布视图、材料和符号增强功能创建美观且视觉效果丰富的图形。探索 macOS 上的多列表格、焦点和键盘交互改进,以及多平台搜索 API。同时,我们还将向您展示如何利用 Swift 并发功能、全新 AttributedString、格式样式、本地化等等功能。
资源
相关视频
WWDC21
- 为 Apple Watch 构建体能训练 app
- 为 Swift 和 SwiftUI 带来 Core Data 并发功能
- 优秀小组件的原则
- 向你的 SwiftUI app 添加丰富图形
- 在 SwiftUI 中引导和反映焦点
- 在 SwiftUI 中精心打造搜索体验
- 探索 SwiftUI 中的并发
- 揭开 SwiftUI 的神秘面纱
- 本地化您的 SwiftUI app
- 认识 macOS 快捷指令
- Foundation 中的新功能
- SF 符号的新功能
- Swift 中的新增功能
- Swift 并发功能:更新示例 App
- SwiftUI 中的 SF Symbols
- SwiftUI 辅助功能:超越基础功能
- watchOS 8 中的新功能
-
下载
♪低音音乐播放♪ ♪ 马特里克特森:欢迎来到 《SwiftUI的新变化》 我是马特 稍后泰勒会加入我的行列 本次座谈是关于SwiftUI Apple的声明式UI框架 SwiftUI还很年轻 但我们已经走了这么远 SwiftUI于2019年首次发布 引入了一种构建用户界面的 强大新方法 以声明式的、状态驱动的风格 我们在SwiftUI的第二个版本中 迈出了一大步 支持100%的SwiftUI应用程序 使用新的应用程序和场景API 今年 我们专注于支持 在你的应用中更深入地采用SwiftUI 且包含一系列丰富的新功能 现在 如果你还没有机会 亲自尝试SwiftUI 那也没关系! 只有你知道什么最适合你的应用 但就在你学习所有今年的 新功能时 这里有一些 你必须留意的小提示 摸索SwiftUI的好方法之一 就是用它来创造全新的功能 在现有应用程序中 例如它如何为 Notes中的新活动流提供支持 适用于iOS、iPadOS和macOS 或者macOS中的新头像选择器 也是用SwiftUI构建的 请记住 你可以将SwiftUI与现有的 UIKit或AppKit代码混合使用 SwiftUI也是将你的应用程序 扩展到新平台的有用工具 就像如何使用SwiftUI 在macOS上构建新的快捷方式 应用程序一样 使用SwiftUI 你可以轻松地在平台之间 共享通用代码 同时仍然为每个设备打造独特的体验 当你准备好重新设计你的应用程序时 这是引入SwiftUI 来提供帮助的最佳时机 全新的Apple Pay购买流程 通过使用SwiftUI重新设计后 这也被用来为macOS上 新的Help Viewer 和watchOS上的提示应用 带来新的风貌 最后 我们不能忘记 华丽的iOS新天气应用程序 也在SwiftUI中从头开始重建 这些只是SwiftUI如何帮助构建 下一代应用程序的几个例子 在本次座谈中 我们想分享一些很棒的新API 这让这一切成为了可能 我们将从改善如何通过 列表和网格构建内容集合的开始说起 接下来 我们将从列表更进一步 介绍新功能 来将你的数据驱动应用程序 提升到一个新的水平 第三 我们将展示一些用于驱动图形 和视觉效果的令人惊叹的新工具 我们将讨论对文本、键盘 和基于焦点的导航 最后 我们会给按钮带来一些爱 因此 让我们深入研究 从列表和网格开始 它们的关键功能是组织 并在SwiftUI应用程序中显示数据 今年 我们让编写丰富的交互式列表 和网格变得更加容易 让我们从一个有趣的开始 SwiftUI现在有内置支持 用于异步加载图像 SwiftUI使用新的AsyncImage视图 使加载这些图像变得容易 只要给它一个URL SwiftUI就会为你自动抓取 并显示远程图像 甚至提供默认占位符 AsyncImage 也可以自定义 例如 我们可以为加载的图像 添加修饰符并定义自定义占位符 就像我在这里添加 一些有趣的颜色一样 我们甚至可以添加 自定义动画和错误处理! AsyncImage适用于所有平台 我们希望你检查一下 AsyncImage立即加载其内容 但有时你的应用程序 需要根据请求加载内容 就像在显示提要时一样 这是在iOS和iPadOS上很好的一个 下拉刷新支持的使用实例 使用新的可刷新修饰符 此修饰符配置它通过环境 向下传递的刷新操作 iOS和iPadOS上的列表也使用此操作 自动添加下拉刷新 但你也可以使用它来 构建你自己的自定义刷新行为 你可能已经注意到这个新的 await关键词 它是在Swift 5.5中新的 并行语言功能之一 这表明了updateItems方法 是一个异步操作 这让我们可以在不阻塞UI的情况下 刷新我们的列表 另一个新的与并行相关的 SwiftUI功能 就是任务修饰符 此API可让你将异步任务 附加到视图的生命周期中 这意味着它会在视图 第一次加载时启动 并在视图被移除时自动取消 这是我们自动加载 第一批照片的好方法 这些新的并行修饰符表面上 看起来很简单 但可用于构建复杂的异步行为 进入你的应用程序 例如 在这里我设置了一个任务 用于在最新照片可用时加载它们 我只写了一个普通的for循环 但你会注意到这里使用的 await关键词 那是因为 newestCandidates 实际上是一个异步序行 这是Swift 5.5中的 另一个新的并行特性 这意味着我们将异步等待 最新的候选者 仅当下一个候选者可用时才迭代循环 这意味着我们实际上将大量功能 打包到这个单一的修饰符中 视图会启动一个任务 它一出现 就会异步侦听候选人 每次有新候选人可用时 就会更新列表 然后当视图消失时 自动取消任务 一切都不会阻塞我们应用程序的UI 有关Swift的并行性 还有很多东西要学习 以及如何在SwiftUI中利用它 所以我们准备了一些 其他的讲座来深入了解细节 《探索SwiftUI中的并行性》 将会解释并行性 与SwiftUI更新模型的关系 并演示我们刚刚讨论的一些新功能 在《Swift的并行性 更新范例应用程序》中 我们将通过异步模型代码 引导你升级现有项目 接下来 我们将为你提供 更新更好的方法 来在你的列表内容中构建交互性 在这个例子中 我写了一个简单的列表 来分享我的超级秘密藏身处的路线 这看起来不错 但文本不可编辑 让我们解决这个问题 我们可以通过将文本 替换为文本字段 来使文本可编辑 但是 文本字段需要与文本绑定 在我们列表的内容闭包中 我们只为每个元素 提供了一个简单的值 在我们的收藏中 而不是绑定 在这种情况下 弄清楚 如何把每一行绑定到集合元素 可能会很棘手 一种常见的方法 是迭代集合的索引 使用下标获取到该索引处的 元素的绑定 但我们不推荐使用这种技术 因为当任何变化发生时 SwiftUI会强制 重新加载整个列表 事实上 我们已经准备了完整的讲座 来更详细地讨论这个话题 要了解更多信息 我建议你观看 《揭开SwiftUI的面纱》 现在 让我们撤消这些更改 并看看更好的解决方案 今年 SwiftUI 提供了一种更简单的方法 来在一个集合中 访问单个元素的绑定 只需使用普通的美元符号运算符 将绑定到你的集合传递到列表中 并且SwiftUI将会对闭包内 每个单独元素传递绑定 只需要读取值的代码 可以和以前完全一样 就像你习惯的那样 但是现在我们可以使用 正常的绑定语法 轻松添加诸如文本字段 之类的交互式控件 这些都是我们习惯的 这意味着我终于可以填写 我之前忘记涵盖的 超级秘密门密码了 这种新语法是Swift语言的一部分 因此它可以在你期望的任何地方使用 不仅仅是列表 例如 我们可以在列表中的 ForEach视图中使用相同的技术 更好的是 你甚至可以向 SwiftUI支持的 任何先前版本 反向部署此代码 但我们不仅仅是让你 现有的代码更容易编写 列表也获得了一些很棒的新功能! 让我们从一些直观 自定义列表的新方法开始 使用新的listRowSeparatorTint 修饰符 你可以更改单个行分隔符的颜色 就像我在这里所做的那样 对齐每一行的分隔符 和图标颜色 SwiftUI也有一个等效的 节分隔符修饰符 不过 对于这个应用程序 所有这些分隔符似乎有点让人分心 我希望我的方向感觉像 一个单一的、统一的流程 也许我们应该尝试删除它们 我们现在可以这样做 使用新的listRowSeparator修饰符 将我们的分隔符配置为隐藏 现在我们的方向感觉不那么杂乱了 让我们看看我正在制作的 另一个应用程序…
这有助于漫画作者追踪 他们所有的超级英雄和恶棍 这个应用程序使用滑动操作 来快速方便地固定和删除字符 但不要用额外的控件来弄乱我们的UI 今年在SwiftUI中的新功能是 允许你使用新的swipeActions修饰符 定义完全订制的滑动操作 你可以像SwiftUI中的 任何其他类型的菜单一样 配置滑动操作 使用按钮来定义动作 你还可以通过添加新的色调修饰符 来自定义它们的颜色 我正在使用它 来让我的引脚动作变成黄色 默认情况下 SwiftUI 会在行的后沿显示滑动操作 但是你可以使用修改器的边缘参数 将它们切换到前沿 你甚至可以通过 添加多个修饰符 支持前导和尾随滑动操作 且具有不同的边缘配置 最后 swipeActions修饰符 在支持它们的每个平台上都可用 使你可以轻松地在多平台 应用程序中共享代码 说到其他平台 让我们检查一下 我的应用程序的macOS版本 它显示了一个多行界面 它利用了在Mac上可用的 额外的空间 而不是把我所有的数据都塞进侧边栏 我有一个概述选项卡 列出了我的所有字符 这让我可以将我的固定字符 保留在侧边栏中 不过 这个列表确实有点简单 让我们试着稍微修饰一下 这是我现有的代码 我目前正在使用插入列表样式 来平滑地适应我的窗口中的列表 我们能够使用 在今年的所有视图样式中 新的类似枚举的语法 在代码中完美地表达这种风格 这也是今年的新功能 插入列表样式获得了一个新技巧 现在可以通过使用 alternatesRowBackgrounds标志 修改样式来交替行的背景 我们的列表现在看起来好多了 每一行都清楚地与另一行区分开来 但是对于macOS应用程序 仍然感觉我们没有充分利用 我们窗口中的所有空间 因此 在下一环节中 让我们更进一步 从我们的应用中获得更多 为了帮助我们更好地利用 所有这些空间 让我们将列表升级为丰富的多列表! 只要靠四行 我现在就能以一份清单得到四份! 但最棒的是 这样一个中等复杂的表 可以用很少的代码声明 可以放在一张幻灯片上 那是因为表使用了 整个SwiftUI中你所习惯的 相同类型的声明式构造 就像使用列表一样 你可以从 单个内容集合创建表格 但与列表不同的是 表格由TableColumn组成 它们定义了每个可视行中的内容 这些行中的每一行都被 直观地标记并使用集合中的数据 来用一些快速方便的方式 定义他们的视觉内容 例如只显示文本 但是表格也是交互式的 支持单行和多行的行选择 就像在常规列表中一样 表还支持在行上可排序值的 关键路径的帮助下进行排序 现在 表格支持多种其他功能 包括多种不同的视觉样式 以及微调每一行的外观 但是让我们更多地讨论 你提供给表格或列表的数据 今年 我们针对SwiftUI对CoreData 获取请求的支持进行了几项新的增强 FetchRequests现在提供了 对其排序描述符的绑定 我们可以传递给表 允许我们编写一个 完全核心数据驱动的表 只需几行代码即可完成 选择和可排序的行 SwiftUI现在还提供分段提取请求 允许复杂的多分段列表 就像右边那样从一个请求中被驱动 在这个例子中 我们根据数据是否被固定 将数据划分为多个部分 我们使用多个 SortDescriptor来排行数据 首先将其拆分为pinned 和unpinned部分 再来是将 最近修改的字符排到最后 接下来 我们指定任何更改 都应设置动画 最后 我们根据 请求的结果 动态构建 列表的部分和行 总之 这个单一的请求 能够驱动右侧的动画列表 有关为macOS 构建应用程序的更多信息 使用表格 并将Core Data与SwiftUI整合 请务必查看这些其他讲座 《Mac上的SwiftUI》 两部分系列将带你一步一步 走过构建针对Mac优化的应用程序 还有《为Swift和SwiftUI 带来核心数据并行性》 将更详细地介绍新的核心数据 获取请求API 现在是时候退一步思考 我们如何帮助用户 在所有这些数据中 找到他们需要的东西 当然 我说的是搜索 搜索是我们所有平台的重要组成部分 它可以帮助用户在需要的时候 准确地找到他们需要的东西 你会在像Apple TV 这样的大设备上找到它 甚至一直到最小的设备 就像Apple Watch一样 所以既然搜索是一个多平台的问题 它需要一个能在所有这些设备上 扩展多平台的解决方案 幸运的是 将搜索添加到你的应用中 再简单不过了 只需添加可搜索修饰符 就像我们在NavigationView上 所做的那样 有了这个修改器 SwiftUI会自动将搜索字段 添加到你应用程序中的适当位置 并可选择显示建议 以适合平台和上下文的方式 修饰符绑定到搜索文本 允许你根据当前值过滤数据 现在 关于SwiftUI中的搜索 还有很多要说的 但幸运的是我们有一场座谈 能带你了解在多个平台上 如何考虑搜索功能 查看《运用SwiftUI中的搜索体验》 了解更多 到目前为止 我们已经探索了 如何使用列表和网格加载 显示、组织和搜索应用程序的数据 现在让我们谈谈如何在 你的应用程序之外共享这些数据 共享数据的最简单方法之一 就是将其拖出你的应用程序 在我的Heroes & Villains 应用程序中 我已经使用现有的onDrag修饰符 将详细信息屏幕上的字符图标 配置为可拖动的 今年的新功能 你现在可以对可拖动视图 添加自定义预览 拖动时会显示此预览 而不是视图 拖放由项目提供程序驱动 允许在不同进程之间 复制和共享数据 今年 SwiftUI提供了 更多使用项目提供程序的方法 与其他应用程序和服务集成 例如配置你的应用程序以支持 使用新的 importsItemProviders修饰符 从外部服务导入项目提供程序 在这个例子中 我们将视图配置为能够导入图像 并将它们作为附件 添加到我们的故事人物中 我们可以将此功能与新的 macOS功能配对 连续互通相机 通过添加“从设备导入”命令 到我们应用程序的主菜单 我们现在可以使用iPhone或iPad 只需拍摄照片即可导入 我们的Mac应用程序 让我们试试吧! View Builder超级英雄的象征 是她可靠的锤子 在她的个人资料中 加入那张照片会很棒 幸运的是 我碰巧这有一支! 在我的应用程序中 我可以访问 “文件”菜单中的 “从设备导入”命令 然后 我可以选择使用我的 iPhone拍照…
它会自动打开相机应用程序 以便我们可以快速拍照
使用我们之前展示的 importItemProviders修饰符 将新照片导入并添加到我的应用程序中 SwiftUI还支持 从我们的应用程序导出数据 导出数据允许你利用 其他服务 例如能够直接从你的应用程序中 触发快捷方式 在SwiftUI中 你可以使用新的 exportsItemProviders 修饰符导出数据 这会将你的应用程序的数据公开 给系统的其余部分 例如 允许它被 macOS上的服务和快捷方式所使用 让我们来看看这对于 使用该应用程序的人来说 是如何显示的 当我选择了我的固定字符之一时 我现在可以在 我的应用程序的“服务”菜单中 看到显示的快速操作 这是为最近的照片添加标题横幅的 便捷快捷方式 我可以用它 和朋友分享我最新的超级英雄想法 我发现这张很棒的照片 可用于我的Stylizer超级英雄 他恰好也是一只可爱的狗 我的自定义快捷方式 将这个有趣的横幅 添加到顶部并覆盖了英雄的名字 我的快捷方式还可以让我分享照片 我很乐意得到泰勒的反馈 因为他对酷炫的图形略知一二 我可以将泰勒添加为收件人 然后输入一条快速信息 并将其发送出去! 你怎么看 泰勒? 泰勒凯利:谢谢你 马特 它看起来很完美 它肯定会成为你的新联系人照片 这个可爱的图像是对 下一环节中的高级图形很好的延续 今年有许多令人兴奋的增强功能 从符号更新、材料和鲜艳度 到强大的新画布视图 首先是符号 SF Symbols是一种在整个应用程序中 添加精美图标的绝佳而简单的方法 今年不仅有很多新的 而且还带来了一些新的功能来让它们 在你的应用程序中的用起来 更容易、更具表现力 有两种新的渲染模式 可以让你更好地控制符号的样式 层级式选单使用当前前景样式 为符号着色 就像单色一样 但会自动添加多个级别的不透明度 以真正强调符号的关键元素 调色板为你提供更细粒度的控制 在具有自定义填充的 符号的各个图层上 查看《SF符号的新功能》 有关这些新模式的 更多信息和设计指南 与这些的完美搭配 是对SwiftUI中可用颜色集的更新 这些颜色针对它们出现的 所有不同配置进行了优化 亮色与暗色模式 模糊的特定外观 甚至是显示它们的特定平台 除了不同的颜色 符号还有许多不同的形状 许多符号都有修饰符 可以显示为实心 圆圈 还有更多 以前 你必须对这些变体进行硬编码 但更重要的是 你必须知道 在哪种情况下使用哪种正确的变体 iOS的《人机界面指南》 描述如何在标签栏中 填充变体应该是首选 所以你必须 特别在名称中包含该.fill修饰符 而今年你不必担心 SwiftUI将基于你使用它的上下文 自动为你选择正确的变体 你所要做的就是提供你想使用的 基本符号 并且不要过度指定 你想要的确切配置 你还可以获得更能重复使用的代码 例如 如果我们在macOS上 运行相同的代码 我们得到了该平台的正确变体:轮廓 了解在你自己的自定义视图中 如何利用这种自动支持 以及更多符号增强功能 请查看《SwiftUI中的SF符号》 现在有很多SF符号 所以我想构建一个很酷的可视化工具 来浏览所有这些 这对SwiftUI的新画布视图非常有用 画布支持实时模式绘图 类似于UIKit或AppKit中的drawRect 当组合大量不需要单独追踪 或失效的图形元素时 这是一个很棒的工具 在这里 我有一个画布显示 操作系统附带的每个SF符号 对总共3166个来说 它会将每一个都绘制到自己的框架中 画布适用于每个平台 由于画布和其他视图一样 我们还可以附加手势 辅助使用信息 并根据状态或环境更新它 例如采用暗色模式 在这里 我添加了一个 让我设置焦点的手势 来放大 我会根据这点更新每个符号的 框架和不透明度 现在我可以点击并拖动 每个符号都会顺利更新 随着光标在屏幕上移动 我们还可以通过利用新的 accessibilityChildren修饰符 来确保可以完全访问它 很酷的是你可以重用相同的视图 用你在SwiftUI中习惯的使用方式 来改进它在整个辅助功能的出现方式 在这种情况下 现在可以枚举符号 例如人们在浏览列表中的元素 在他们浏览时提到每个元素一样 这个修饰符不仅限于画布 但可以用于任何视图 来真正完善它的辅助使用体验 我们可以添加到画布的最后一件事是 使用新的TimelineView来随时更新 tvOS的一个改进是 让焦点在屏幕上动画地移动 就像一个屏幕保护程序 TimelineView是按时间表创建的… 在这种情况下 是动画时间表… 它提供了它正在渲染的当前时间 所以我们可以利用这段时间 来更新变换中的焦点 创建我们美丽的符号屏幕保护程序 这个TimelineView可以做更多的事情 Apple Watch的一个非常酷的功能 是它的Always On显示功能 以前 当你的应用进入 Always On状态时 它会因时间重叠而变得模糊 在watchOS 8上 你的应用现在默认变暗 并且你可以更好地控制它在 SwiftUI中的显示方式 为你提供所需的工具 其中之一是TimelineView 一旦手表进入Always On状态 TimelineView可以在未来的日期 预加载你的视图的显示 随着我们迈向未来 这些视图将自动显示在屏幕上 无需从后台获取你的应用程序 其中一个关键部分是 TimelineSchedule 在这个例子中 我用了 简单的everyMinute时间表 所以TimelineView将预加载 每一分钟的显示 在浏览器中显示下一个符号 还有其他几种时间表 可以帮助满足你的应用程序的需求 例如明确日期的集合 这对于何时在特定时间 发生的事件来说非常有用 现在 这种模式的另一个重要方面 就是隐藏用户敏感信息 因为可能会被其他人看见 我真的很想将我最喜欢的符号保密 并且通过简单地 添加privacySensitive修饰符 它将自动被编辑 当手表进入Always On状态时 请查看《watchOS 8的新功能》 来获取有关 Always On显示等的更多信息 而这个隐私敏感的修饰符 也适用于小部件 添加到锁定屏幕的小部件 将使用它来隐藏敏感信息 当设备仍处于锁定状态时 并会在设备解锁后显示 《优秀小部件的原则》将对此 进行更详细的介绍 以及为你的应用程序构建 精彩小部件的其他方法 Apple的所有平台 和应用程序都使用材料 来创造美丽的视觉效果 真正强调了它们的内容 现在你可以直接在 SwiftUI中创建它们! 我一直在尝试为我的符号浏览器 添加颜色和材料 我正在添加一个材料支持的覆盖层 以显示符号的数量 添加材质就像添加背景一样简单 我正在使用ultraThinMaterial 可以给它任何自定义形状来填充 这些材料自动带有预期的 充满鲜艳度的内容在它们之上混合 当在使用一级、二级、三级 甚至四级前景样式时 表情符号会自动排除在外 因此它们看起来完全符合预期 在Mac上像侧边栏和弹出框 这样的系统上下文 会自动具有模糊的材质背景 并且现在还将为其中的内容 提供预期的充满鲜明的外观 这些新材料与新的 safeAreaInset修饰符 配合使用效果很好 它允许你将内容放置在 可滚动视图的顶部并仍让内容位置 按预期开始和结束 《丰富的图形》座谈会深入探讨更多 在画布、材料等上面的细节 总结一下 补充定义这些漂亮的 自定义视图的新方法 是对Xcode中SwiftUI预览的一些增强 首先是一个新的预览方向修改器 允许指定iOS设备中的 预览方向 甚至可以混合和匹配不同方向的预览 其次是在预览中编辑和查看 你的应用中辅助使用方式的重大改进 属性编辑器现在有一个 辅助使用修饰符的精选列表 使优化视图的辅助使用行为 变得更加容易 还有一种全新的方式可以通过新的 “辅助功能预览”选项卡查看预览 你将看到辅助使用元素 及其属性的实时文本表示 这与支持辅助使用功能的 信息相同 但现在这能以你 更熟悉的格式呈现给你 请查看《SwiftUI辅助功能》的座谈 获取有关这方面的更多信息 以及更多有关 如何为你的应用程序 创造出令人惊叹的 辅助使用体验! 现在 接下来是对文本的一系列增强 与文本相关的控件和键盘导航 文本对每个应用程序来说 都是非常重要的 这是你的应用 与人们交流的主要方式之一 它通常是你编写的第一个视图 今年 它获得了 许多令人兴奋的新功能 从样式到本地化 再到交互和格式设置 首先是Markdown支持 文本现在可以直接内联包含 Markdown格式 这可以用来强调重点 链接… 可以与之互动… 甚至代码风格的演示 而这一切都建立在新的、强大的 Foundation中 基于Swift的AttributedString 除了Markdown支持 它带来了一整套丰富的 类型安全的属性 以及定义自己的属性 甚至是在Markdown 语法中使用它们的能力 有关这方面的更多信息 和惊人的新自动语法协议 请查看《Foundation中的新功能》 重要的是 文本还会对其内容 进行本地化 以便世界各地的人们 都可以使用你的应用 而新的Markdown支持也是如此 允许对语言敏感的属性 正确地被本地化 本地化的另一个重大改进 来自Xcode 13 它现在使用Swift编译器 从每一次 LocalizedStringKey的使用中 生成字符串和本地化目录 以及新的localizedString 和attributedString初始值设定项 要了解更多信息 以及其他本地化提示和技巧 查看《本地化你的 SwiftUI应用程序》 现在 除了这些新的文本显示方式 有一些新方法可以使文本更加动态 首先是一个重要的辅助功能 动态类型 SwiftUI从一开始 就支持动态类型 并且今年有一个新的API 来允许限制UI支持的类型大小范围 以免它变得太大或太小 这显示了在默认大尺寸下 我们的标题的样子 我个人使用动态类型 来为我的内容增加一些 额外的信息密度 这显示了标题如何以小字号 保持相同的大小 因为它最低限度被限制为大尺寸 在光谱的另一端 使用辅助使用大小确实会导致 我们的标题越来越大 但仅限于特大号 虽然macOS不支持动态类型 但它确实支持另一个重要的文本交互 可选择的文本 这允许人们对你的应用程序中 不可编辑的文本采取行动 并且 现在可以启用textSelection修饰符 该修饰符可以应用于任何视图 它适用于其中的所有文本… 在这个例子中 为现在应用于标题中的文本 我们还在iOS和iPadOS上 引入了这个修饰符 它允许在长按时复制或共享文本 最后 Foundation的新格式样式API 使格式化文本变得如此简单 但仍然允许精确的呈现 这里我们有一个应用默认格式的日期 这是一个仅显示时间的变体 如活动列表中使用的那样 最后 这一个扩展格式 能允许指定要显示的确切组件 我们的活动列表还包括将一组人 格式化为正确本地化的演示文稿 让我们快速了解一下 我们将我们的person值映射到一个 PersonNameComponents数组中 并使用列表格式样式对其进行格式化 并且对于列表中的每个成员 使用具有简短样式的 PersonNameComponent格式 只显示名字 最后 用“and”连词加入它 总之 打造出高性能 和类型安全的格式表达 且能正确处理任何的人数 TextField也获得了 对这些新格式样式的支持 允许你添加可编辑的格式化文本 具有与某些基础值的类型安全绑定 新的与会者字段会绑定到 PersonNameComponents值 并使用标准名称格式对其进行格式化 这负责解析输入 并生成结果人名 《Foundation的新功能》 也有详细介绍 这些新格式样式的强大功能 TextField现在还支持添加显式提示 与其标签分开 让用户知道一个领域 会期待什么样的内容 在macOS上 将TextField添加到窗体时 它们会像其他控件一样对齐标签 并使用提示作为其占位符内容 现在 文本字段的全部意义 在于添加文本 而键盘是我们执行此操作的工具 从iPhone上的软件键盘 到支持软件和硬件键盘的iPad 当然还有macOS 它总是有一个硬件键盘 今年有几项增强功能 可以让 使用键盘的体验更加出色 使用新的onSubmit修饰符 你可以在用户提交字段的文本时 轻松添加补充操作 例如按回车键 此修饰符提供了一些额外的灵活性 因为它甚至可以应用于整个控件形式 为了帮助给用户提示 在提交字段时 会发生什么样的动作 这有新的submitLabel修饰符 在软件键盘上 这将用作回车键的标签 最后 我们可以使用 新的键盘工具栏放置 向键盘添加附件视图 这些视图将显示在iOS和iPadOS 软件键盘上方的工具栏中 或在macOS上的触控栏中 这是一种让用户快速访问 键盘上方操作 而无需关闭它的好方法 以避免中断你应用程序的编辑体验 键盘还起到导航 和聚焦的另一个重要作用 并且此功能存在于每个平台上 从使用watchOS 到直接使用数字表冠输入 到使用Siri Remote 在tvOS上浏览内容 对于大多数事情 SwiftUI只关心哪些视图是可聚焦的 以及它如何在它们之间移动 但有时你可以进行额外的改进 以在你的应用程序中 创建更流畅的体验 为了帮助解决这个问题 SwiftUI有一个新的、强大的工具 叫做FocusState 这是一个属性包装器 既反映了焦点的状态 并提供对它的精确控制 最简单的是 它可以反映一个布尔值 这可以使用聚焦修饰符 绑定到一个可聚焦视图 当该视图聚焦时 该值将为真 否则为假 也可以写入此值来控制焦点 例如 响应某人按下按钮的动作 这个例子可以作为一个加速器 允许用户执行相关操作后 立即开始输入 这个布尔版本的完整形式很方便 它代表任何可散行的类型 此代码在功能上与上一张幻灯片相当 但具有更高的灵活性 让我们来看看吧 首先 我定义了我可能想知道的 重点字段的简单枚举 FocusState属性使用了该类型 来反映当前状态 要不要潜在地指出 那些全部都没有聚焦都可以 我们的焦点修饰符 仍然绑定到相同的焦点状态 但仅有在当它等于addAttendee时 最后 当我们想要聚焦该字段时 我们将焦点状态值 设置为addAttendee 这种新的灵活性允许添加附加功能 例如从以前构建工具栏的按钮 在每个字段之间移动焦点 并反映焦点是到达开头还是结尾 焦点状态还为iOS应用程序 提供了一种通过清除其值 来关闭软件键盘的好方法 如果你想了解更多其他信息 来改善你的应用程序中的焦点体验 看看今年的座谈 《在SwiftUI中直接 和反映焦点》 最后 我们将专注于按钮 因为按钮很重要 我们都知道一个 典型的按钮是什么样的 它因平台而异 它是最简单的方法之一 来允许人们与你的应用程序进行交互 尤其是在SwiftUI中 按钮用于很多事情 马特之前讨论了滑动操作 是如何由按钮组成的 今年的按钮有很多新变化 首先 SwiftUI现在在 iOS上有标准的边框按钮了 你只需添加一个buttonStyle修饰符 就可以使按钮有边框 就像我用这个添加按钮所做的那样 与其他样式修饰符一样 这可以添加到一组控件中 并适用于所有这些 它支持在你希望 给定按钮具有特定外观的情况下着色 但是对于这个UI 我喜欢使用强调色的默认外观 还内置了更多自定义功能 首先是控制大小和突出 我正在使用这些来自定义 代表标签的按钮 他们正在使用新的标准小控件尺寸 并具有更显眼的色调 来真正让他们脱颖而出 我们可以使用这些相同的修饰符 创建另一种常见的按钮 这些大尺寸的 现在被内置到了SwiftUI中 通过指定大控件大小 你将自动获得这些漂亮的 圆角矩形按钮 并给他们一种等级感 我修改了最重要的一个 来提高突出度 用高对比度的强调色填充它 辅助按钮仍然可以着色 但对比度较低 这些按钮几乎没有 使它们在iPad上也很棒的修饰符 文本标签具有最大宽度 以便让整体按钮灵活 但不会滑稽地变大 主按钮有一个 默认的操作键盘快捷键 所以当使用键盘的应用程序时 我可以快速按下回车键 将此按钮添加到我的jar中 现在 许多此类API 已经存在于macOS上 这使得为多个平台 构建应用程序变得更加容易 新增的一项功能 是增加了突出的色调支持 让你高雅地往你的应用程序 添加这些明亮的按钮 请注意不显眼的按钮 如这些添加按钮 不会显示任何色调 因为它们的镀铬 表示了它们在macOS上的交互性 我们学会了显眼度 我或许会很想把它应用到 我所有的添加按钮上 但是屏幕上有这么多显眼的按钮 会让人不知所措 最好将其保留用于单一的主要操作 较低的突出色调是在iOS上 添加色彩的绝佳选择 现在 这些新按钮样式中 我最喜欢的是 他们是否自动拥有预期的 按下和禁用状态 暗色模式支持 而且与动态类型当然是 完全可访问和兼容的 它们有助于在应用程序之间 提供一致性 而按钮的新API并不止于此 SwiftUI还为按钮添加了一流的支持 以及附加语义 例如将按钮标记为破坏性 这将自动为他们提供预期的红色调 一个可采用的、新的上下文 就是确认对话框 它让用户 确认对其数据有严重影响的操作 在iOS上 这显示为操作表 在iPad上显示为弹出框 在macOS上显示为警报 SwiftUI会自动处理 每个平台的设计敏感性 接下来 让我们谈谈不是 “大写B”按钮的按钮 目前 应用程序的添加按钮 只是添加到用户的默认jar中 但对于狂热的收藏家来说 我想对特定的jar添加支持 这是菜单按钮的完美用例 我们将使用相同的“Add”标签 但在单击按钮后 显示所有 可能的jar的菜单 然而 这些菜单按钮 在视觉上非常突出 我们可以使用今年添加的 新menuIndicator修饰符隐藏指标 即使没有指示器 这个按钮仍然会在点击时 显示一个菜单 但是对于这些按钮 理想情况下我们可以两全其美 只需单击一下 即可添加到默认jar中 并可以灵活地显示其他人的菜单 今年的新功能是自定义菜单的 主要操作 以帮助处理此类情况 默认情况下 带有主要操作的菜单 在macOS上具有两段式外观 按钮的主要部分触发显示菜单的 指示器中的主要操作 当指示器被隐藏时 它在视觉上再次看起来就像 我开始使用的按钮 但有行为上的区别 单击会触发主要操作 长按则会显示菜单 很棒的是 同样的事情 也适用于iOS! 现在这些菜单提供了很大的灵活性 真正迎合了你的应用程序 使用它们的需要 控件获得按钮样式的另一个新示例 就是Toggle 这将创建一个按钮 会在点击时 于视觉上打开和关闭 并且可以像任何其他切换一样使用 而加入这些新控件样式的 是一个将相关控件分组的容器 恰到好处地被称之为ControlGroup 在iOS上 组中的控件 在工具栏中组织得更紧密 在macOS上 有指示两个分组按钮的视觉启示 而总结以上这些 自然所有这些东西 都可以组合在一起 例如 这些标准的后退/前进按钮 是两个菜单的ControlGroup 这些菜单中的每个都有一个 primaryAction 会在单击时执行 一旦菜单被长按 他们会展示他们的内容 现在 只需对按钮和这些新样式 进行一些额外的自定义 你在应用程序中 使用这些控件的方式 就有很大的灵活性 我们在本次座谈中谈了很多 还有更多我们没有时间讨论 我们很高兴你能在自己的SwiftUI 应用程序中利用这些新功能 并在更多地方采用SwiftUI 谢谢你 祝你在2021年 能好好地休息! ♪
-
-
3:29 - AsyncImage
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
3:45 - AsyncImage with custom placeholder
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { randomPlaceholderColor() .opacity(0.2) } .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL } func randomPlaceholderColor() -> Color { placeholderColors.randomElement()! } let placeholderColors: [Color] = [ .red, .blue, .orange, .mint, .purple, .yellow, .green, .pink ]
-
4:00 - AsyncImage with custom animations and error handling
struct Contentiew: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url, transaction: .init(animation: .spring())) { phase in switch phase { case .empty: randomPlaceholderColor() .opacity(0.2) .transition(.opacity.combined(with: .scale)) case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .transition(.opacity.combined(with: .scale)) case .failure(let error): ErrorView(error) @unknown default: ErrorView() } } .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } struct ErrorView: View { var error: Error? init(_ error: Error? = nil) { self.error = error } var body: some View { Text("Error") // Display the error } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL } func randomPlaceholderColor() -> Color { placeholderColors.randomElement()! } let placeholderColors: [Color] = [ .red, .blue, .orange, .mint, .purple, .yellow, .green, .pink ]
-
4:24 - refreshable() modifier
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] func update() async { // Fetch new photos } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
4:58 - task() modifier
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } .task { await photoStore.update() } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] func update() async { // Fetch new photos } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
5:28 - task() modifier iterating over an AsyncSequence
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } .task { for await photo in photoStore.newestPhotos { photoStore.push(photo) } } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] var newestPhotos: NewestPhotos { NewestPhotos() } func update() async { // Fetch new photos from remote service } func push(_ photo: Photo) { photos.append(photo) } } struct NewestPhotos: AsyncSequence { struct AsyncIterator: AsyncIteratorProtocol { func next() async -> Photo? { // Fetch next photo from remote service } } func makeAsyncIterator() -> AsyncIterator { AsyncIterator() } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
7:02 - Non-interactive directions list
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List(directions) { direction in Label { Text(direction.text) } icon: { DirectionsIcon(direction) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
8:08 - Interactive directions list
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
8:49 - Interactive directions list using ForEach
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
9:09 - listRowSeparatorTint() modifier
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } .listRowSeparatorTint(direction.color) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
9:38 - listRowSeparator() modifier
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } .listRowSeparator(.hidden) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
10:08 - Swipe actions
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
10:27 - Swipe actions on the leading edge
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions(edge: .leading) { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
10:32 - Swipe actions on both edges
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions(edge: .leading) { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } .swipeActions(edge: .trailing) { Button(role: .destructive) { delete(character, in: characters) } label: { Label("Delete", systemImage: "trash") } Button { // Open "More" menu } label: { Label("More", systemImage: "ellipsis.circle") } .tint(Color(white: 0.8)) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
11:14 - Basic macOS list
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() var body: some View { List(selection: $selection) { ForEach(characters) { character in Label { Text(character.name) } icon: { CharacterIcon(character) } .padding(.leading, 4) } } .listStyle(.inset) .navigationTitle("All Characters") } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
11:35 - Inset list style alternating row backgrounds
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() var body: some View { List(selection: $selection) { ForEach(characters) { character in Label { Text(character.name) } icon: { CharacterIcon(character) } .padding(.leading, 4) } } .listStyle(.inset(alternatesRowBackgrounds: true)) .navigationTitle("All Characters") } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:13 - Tables
struct ContentView: View { @State private var characters = StoryCharacter.previewData var body: some View { Table(characters) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:49 - Tables with selection
struct ContentView: View { @State private var characters = StoryCharacter.previewData // Single selection @State private var singleSelection: StoryCharacter.ID? // Multiple selection @State private var multipleSelection: Set<StoryCharacter.ID>() var body: some View { Table(characters, selection: $singleSelection) { // or `$multipleSelection` TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:57 - Tables with selection and sorting
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() @State private var sortOrder = [KeyPathComparator(\StoryCharacter.name)] @State private var sorted: [StoryCharacter]? var body: some View { Table(sorted ?? characters, selection: $selection, sortOrder: $sortOrder) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } .onChange(of: characters) { sorted = $0.sorted(using: sortOrder) } .onChange(of: sortOrder) { sorted = characters.sorted(using: $0) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
13:15 - CoreData Tables
@FetchRequest(sortDescriptors: [SortDescriptor(\.name)]) private var characters: FetchedResults<StoryCharacter> @State private var selection = Set<StoryCharacter.ID>() Table(characters, selection: $selection, sortOrder: $characters.sortDescriptors) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) }
-
13:34 - Sectioned fetch requests
@SectionedFetchRequest( sectionIdentifier: \.isPinned, sortDescriptors: [ SortDescriptor(\.isPinned, order: .reverse), SortDescriptor(\.lastModified) ], animation: .default) private var characters: SectionedFetchResults<...> List { ForEach(characters) { section in Section(section.id ? "Pinned" : "Heroes & Villains") { ForEach(section) { character in CharacterRowView(character) } } } }
-
15:20 - searchable() modifier
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if characters.filterText.isEmpty { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: characters.unpinned) } } else { sectionContent(for: characters.filtered) } } .listStyle(.sidebar) .searchable(text: $characters.filterText) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: [StoryCharacter]) -> some View { ForEach(characters) { character in CharacterProfile(character) } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { all.prefix { $0.isPinned } } var unpinned: [StoryCharacter] { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } var filterText: String = "" var filtered: [StoryCharacter] { if filterText.isEmpty { return all } else { return all.filter { $0.name.contains(filterText) || $0.powers.contains(filterText) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
16:22 - Drag previews
struct ContentView: View { let character = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { CharacterIcon(character) .controlSize(.large) .padding() .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
16:48 - importsItemProviders() modifier
import UniformTypeIdentifiers @main private struct Catalog: App { var body: some Scene { WindowGroup { ContentView() } .commands { ImportFromDevicesCommands() } } } struct ContentView: View { @State private var character: StoryCharacter = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { VStack { CharacterIcon(character) .controlSize(.large) .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if let headerImage = character.headerImage { headerImage .resizable() .aspectRatio(contentMode: .fill) .frame(width: 150, height: 150) .mask(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } .padding() .importsItemProviders(StoryCharacter.headerImageTypes) { itemProviders in guard let first = itemProviders.first else { return false } async { character.headerImage = await StoryCharacter.loadHeaderImage(from: first) } return true } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var headerImage: Image? static var headerImageTypes: [UTType] { NSImage.imageTypes.compactMap { UTType($0) } } var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } static func loadHeaderImage(from itemProvider: NSItemProvider) async -> Image? { for type in Self.headerImageTypes.map(\.identifier) { if itemProvider.hasRepresentationConforming(toTypeIdentifier: type) { return await withCheckedContinuation { continuation in itemProvider.loadDataRepresentation(forTypeIdentifier: type) { data, error in guard let data = data, let image = NSImage(data: data) else { return } continuation.resume(returning: Image(nsImage: image)) } } } } return nil } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
18:17 - exportsItemProviders() modifier
import UniformTypeIdentifiers @main private struct Catalog: App { var body: some Scene { WindowGroup { ContentView() } .commands { ImportFromDevicesCommands() } } } struct ContentView: View { @State private var character: StoryCharacter = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { VStack { CharacterIcon(character) .controlSize(.large) .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if let headerImage = character.headerImage { headerImage .resizable() .aspectRatio(contentMode: .fill) .frame(width: 150, height: 150) .mask(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } .padding() .importsItemProviders(StoryCharacter.headerImageTypes) { itemProviders in guard let first = itemProviders.first else { return false } async { character.headerImage = await StoryCharacter.loadHeaderImage(from: first) } return true } .exportsItemProviders(StoryCharacter.contentTypes) { [character.itemProvider] } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var headerImage: Image? static var contentTypes: [UTType] { [.utf8PlainText] } static var headerImageTypes: [UTType] { NSImage.imageTypes.compactMap { UTType($0) } } var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } static func loadHeaderImage(from itemProvider: NSItemProvider) async -> Image? { for type in Self.headerImageTypes.map(\.identifier) { if itemProvider.hasRepresentationConforming(toTypeIdentifier: type) { return await withCheckedContinuation { continuation in itemProvider.loadDataRepresentation(forTypeIdentifier: type) { data, error in guard let data = data, let image = NSImage(data: data) else { return } continuation.resume(returning: Image(nsImage: image)) } } } } return nil } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
19:47 - Symbol rendering modes
struct ContentView: View { var body: some View { VStack { HStack { symbols } .symbolRenderingMode(.monochrome) HStack { symbols } .symbolRenderingMode(.multicolor) HStack { symbols } .symbolRenderingMode(.hierarchical) HStack { symbols } .symbolRenderingMode(.palette) .foregroundStyle(Color.cyan, Color.purple) } .foregroundStyle(.blue) .font(.title) } @ViewBuilder var symbols: some View { Group { Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "pc") Image(systemName: "phone.down.circle") Image(systemName: "hourglass") Image(systemName: "heart.fill") Image(systemName: "airplane.circle.fill") } .frame(width: 40, height: 40) } }
-
20:27 - Symbol variants
struct ContentView: View { var body: some View { VStack { HStack { symbols } HStack { symbols } .symbolVariant(.fill) } .foregroundStyle(.blue) } @ViewBuilder var symbols: some View { let heart = Image(systemName: "heart") Group { heart heart.symbolVariant(.slash) heart.symbolVariant(.circle) heart.symbolVariant(.square) heart.symbolVariant(.rectangle) } .frame(width: 40, height: 40) } }
-
20:42 - Tab symbol variants: iOS 13
struct TabExample: View { var body: some View { TabView { CardsView().tabItem { Label("Cards", systemImage: "rectangle.portrait.on.rectangle.portrait.fill") } RulesView().tabItem { Label("Rules", systemImage: "character.book.closed.fill") } ProfileView().tabItem { Label("Profile", systemImage: "person.circle.fill") } SearchPlayersView().tabItem { Label("Magic", systemImage: "sparkles") } } } } struct CardsView: View { var body: some View { Color.clear } } struct RulesView: View { var body: some View { Color.clear } } struct ProfileView: View { var body: some View { Color.clear } } struct SearchPlayersView: View { var body: some View { Color.clear } }
-
20:50 - Tab symbol variants
@main struct SnippetsApp: App { var body: some Scene { WindowGroup { #if os(iOS) TabExample() #else VStack{ Text("Open Preferences") Text("⌘,").font(.title.monospaced()) } .fixedSize() .scenePadding() #endif } #if os(macOS) Settings { TabExample() } #endif } } struct TabExample: View { var body: some View { TabView { CardsView().tabItem { Label("Cards", systemImage: "rectangle.portrait.on.rectangle.portrait") } RulesView().tabItem { Label("Rules", systemImage: "character.book.closed") } ProfileView().tabItem { Label("Profile", systemImage: "person.circle") } SearchPlayersView().tabItem { Label("Magic", systemImage: "sparkles") } } } } struct CardsView: View { var body: some View { Color.clear } } struct RulesView: View { var body: some View { Color.clear } } struct ProfileView: View { var body: some View { Color.clear } } struct SearchPlayersView: View { var body: some View { Color.clear } }
-
21:31 - Canvas
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let image = context.resolve(symbol.image) context.draw(image, in: rect.fit(image.size)) } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } }
-
22:03 - Canvas with gesture
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) @GestureState private var focalPoint: CGPoint? = nil var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform(around: focalPoint) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } .gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in focalPoint = value.location }) } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 300, zoom: CGFloat = 1.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } }
-
22:24 - Canvas with accessibility children
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) @GestureState private var focalPoint: CGPoint? = nil var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform(around: focalPoint) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } .gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in focalPoint = value.location }) .accessibilityLabel("Symbol Browser") .accessibilityChildren { List(symbols) { Text($0.name) } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 300, zoom: CGFloat = 1.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } }
-
22:48 - Canvas with TimelineView
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { TimelineView(.animation) { let time = $0.date.timeIntervalSince1970 Canvas { context, size in let metrics = gridMetrics(in: size) let focalPoint = focalPoint(at: time, in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform( around: focalPoint, at: time) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 200, zoom: CGFloat = 3.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`, based on a preset path indexed using `time`. func fishEyeTransform(around point: CGPoint, at time: TimeInterval) -> (frame: CGRect, opacity: CGFloat) { // Arbitrary zoom and radius calculation based on time let zoom = cos(time) + 3.0 let radius = ((cos(time/5) + 1)/2) * 150 + 150 return fishEyeTransform(around: point, radius: radius, zoom: zoom) } } /// Returns a focal point within `size` based on a preset path, indexed using `time`. func focalPoint(at time: TimeInterval, in size: CGSize) -> CGPoint { let offset: CGFloat = min(size.width, size.height)/4 let distance = ((sin(time/5) + 1)/2) * offset + offset let scalePoint = CGPoint(x: size.width / 2 + distance * cos(time / 2), y: size.height / 2 + distance * sin(time / 2)) return scalePoint }
-
24:10 - Privacy sensitive
Button { showFavoritePicker = true } label: { VStack(alignment: .center) { Text("Favorite Symbol") .foregroundStyle(.secondary) Image(systemName: favoriteSymbol) .font(.title2) .privacySensitive(true) } } .tint(.purple)
-
24:27 - Privacy sensitive (widgets)
VStack(alignment: .leading) { Text("Favorite Symbol") .textCase(.uppercase) .font(.caption.bold()) ContainerRelativeShape() .fill(.quaternary) .overlay { Image(systemName: favoriteSymbol) .font(.system(size: 40)) .privacySensitive(true) } }
-
25:03 - Materials
struct ColorList: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { ZStack { gradientBackground materialOverlay } } var materialOverlay: some View { VStack { Text("Symbol Browser") .font(.largeTitle.bold()) Text("\(symbols.count) symbols 🎉") .foregroundStyle(.secondary) .font(.title2.bold()) } .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16.0)) } var gradientBackground: some View { LinearGradient( gradient: Gradient(colors: [.red, .orange, .yellow, .green, .blue, .indigo, .purple]), startPoint: .leading, endPoint: .trailing) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
25:40 - Safe area inset
struct ContentView: View { let newSymbols = Array(repeating: Symbol("swift"), count: 645) let systemColors: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .brown] var body: some View { ScrollView { symbolGrid } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider() VStack(spacing: 0) { Text("\(newSymbols.count) new symbols") .foregroundStyle(.primary) .font(.body.bold()) Text("\(systemColors.count) system colors") .foregroundStyle(.secondary) } .padding() } .background(.regularMaterial) } } var symbolGrid: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 40, maximum: 40))]) { ForEach(0 ..< newSymbols.count, id: \.self) { index in newSymbols[index].image .foregroundStyle(.white) .frame(width: 40, height: 40) .background(systemColors[index % systemColors.count]) } } .padding() } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
26:03 - Preview orientation
struct ColorList_Previews: PreviewProvider { static var previews: some View { ColorList() .previewInterfaceOrientation(.portrait) ColorList() .previewInterfaceOrientation(.landscapeLeft) } } struct ColorList: View { let newSymbols = Array(repeating: Symbol("swift"), count: 645) let systemColors: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .brown] var body: some View { ScrollView { symbolGrid } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider() VStack(spacing: 0) { Text("\(newSymbols.count) new symbols") .foregroundStyle(.primary) .font(.body.bold()) Text("\(systemColors.count) system colors") .foregroundStyle(.secondary) } .padding() } .background(.regularMaterial) } } var symbolGrid: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 40, maximum: 40))]) { ForEach(0 ..< newSymbols.count, id: \.self) { index in newSymbols[index].image .foregroundStyle(.white) .frame(width: 40, height: 40) .background(systemColors[index % systemColors.count]) } } .padding() } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
27:06 - Hello, World!
Text("Hello, World!")
-
27:17 - Markdown Text: strong emphasis
Text("**Hello**, World!")
-
27:24 - Markdown Text: links
Text("**Hello**, World!") Text(""" Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)! """)
-
27:30 - Markdown Text: inline code
Text(""" Is this *too* meta? `Text("**Hello**, World!")` `Text(\"\"\"` `Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)!` `\"\"\")` """)
-
27:37 - AttributedString
struct ContentView: View { var body: some View { Text(formattedDate) } var formattedDate: AttributedString { var formattedDate: AttributedString = Date().formatted(Date.FormatStyle().day().month(.wide).weekday(.wide).attributed) let weekday = AttributeContainer.dateField(.weekday) let color = AttributeContainer.foregroundColor(.orange) formattedDate.replaceAttributes(weekday, with: color) return formattedDate } }
-
29:17 - Text selection
struct ContentView: View { var activity: Activity = .sample var body: some View { VStack(alignment: .leading, spacing: 0) { ActivityHeader(activity) Divider() Text(activity.info) .textSelection(.enabled) .padding() Spacer() } .background() .navigationTitle(activity.name) } } struct ActivityHeader: View { var activity: Activity init(_ activity: Activity) { self.activity = activity } var body: some View { VStack(alignment: alignment.horizontal, spacing: 8) { HStack(alignment: .firstTextBaseline) { #if os(macOS) Text(activity.name) .font(.title2.bold()) Spacer() #endif Text(activity.date.formatted(.dateTime.weekday(.wide).day().month().hour().minute())) .foregroundStyle(.secondary) } HStack(alignment: .firstTextBaseline) { Image(systemName: "person.2") Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) } } #if os(macOS) .padding() #else .padding([.horizontal, .bottom]) #endif .frame(maxWidth: .infinity, alignment: alignment) .background(activity.tint.opacity(0.1).ignoresSafeArea()) } private var alignment: Alignment { #if os(macOS) .leading #else .center #endif } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
29:28 - Text selection: view hierarchy
struct ContentView: View { var activity: Activity = .sample var body: some View { VStack(alignment: .leading, spacing: 0) { ActivityHeader(activity) Divider() Text(activity.info) .padding() Spacer() } .textSelection(.enabled) .background() .navigationTitle(activity.name) } } struct ActivityHeader: View { var activity: Activity init(_ activity: Activity) { self.activity = activity } var body: some View { VStack(alignment: alignment.horizontal, spacing: 8) { HStack(alignment: .firstTextBaseline) { #if os(macOS) Text(activity.name) .font(.title2.bold()) Spacer() #endif Text(activity.date.formatted(.dateTime.weekday(.wide).day().month().hour().minute())) .foregroundStyle(.secondary) } HStack(alignment: .firstTextBaseline) { Image(systemName: "person.2") Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) } } #if os(macOS) .padding() #else .padding([.horizontal, .bottom]) #endif .frame(maxWidth: .infinity, alignment: alignment) .background(activity.tint.opacity(0.1).ignoresSafeArea()) } private var alignment: Alignment { #if os(macOS) .leading #else .center #endif } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
30:03 - Text formatting: List
struct ContentView: View { var activity: Activity = .sample var body: some View { Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
30:43 - Text field formatting
struct ContentView: View { @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) } }
-
31:09 - Text field prompts and labels
struct ContentView: View { @State var activity: Activity = .sample var body: some View { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } .frame(minWidth: 250) .padding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
31:39 - Text field submission
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium) ) .onSubmit { activity.append(Person(newAttendee)) newAttendee = PersonNameComponents() } } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
31:59 - Text field submission: submit label
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium) ) .onSubmit { activity.append(Person(newAttendee)) newAttendee = PersonNameComponents() } .submitLabel(.done) } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
32:07 - Keyboard toolbar
struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: selectPreviousField) { Label("Previous", systemImage: "chevron.up") } .disabled(!hasPreviousField) Button(action: selectNextField) { Label("Next", systemImage: "chevron.down") } .disabled(!hasNextField) } } } private func selectPreviousField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue - 1)! } } private var hasPreviousField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue > 0 } else { return false } } private func selectNextField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue + 1)! } } private var hasNextField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue < Field.allCases.count } else { return false } } } private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:05 - Focus state
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var addAttendeeIsFocused: Bool var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($addAttendeeIsFocused) } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:16 - Focus state: setting focus
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var addAttendeeIsFocused: Bool var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } VStack(alignment: .leading) { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($addAttendeeIsFocused) ControlGroup { Button { addAttendeeIsFocused = true } label: { Label("Add Attendee", systemImage: "plus") } } .fixedSize() } } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:30 - Focus state: Hashable value
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var focusedField: Field? var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) .focused($focusedField, equals: .name) TextField("Location:", text: $activity.location) .focused($focusedField, equals: .location) DatePicker("Date:", selection: $activity.date) .focused($focusedField, equals: .date) } VStack(alignment: .leading) { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($focusedField, equals: .addAttendee) ControlGroup { Button { focusedField = .addAttendee } label: { Label("Add Attendee", systemImage: "plus") } } .fixedSize() } } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:03 - Focus state: back/forward controls
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: selectPreviousField) { Label("Previous", systemImage: "chevron.up") } .disabled(!canSelectPreviousField) Button(action: selectNextField) { Label("Next", systemImage: "chevron.down") } .disabled(!canSelectNextField) } } } private func selectPreviousField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue - 1)! } } private var canSelectPreviousField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue > 0 } else { return false } } private func selectNextField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue + 1)! } } private var canSelectNextField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue < Field.allCases.count } else { return false } } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:13 - Focus state: keyboard dismissal
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } } func endEditing() { focusedField = nil } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:55 - Bordered buttons
Button("Add") { // ... } .buttonStyle(.bordered)
-
35:03 - Bordered buttons: view hierarchy
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(0..<10) { _ in Button("Add") { //... } } } } .buttonStyle(.bordered) } }
-
35:09 - Bordered buttons: tinting
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(0..<10) { _ in Button("Add") { //... } } } } .buttonStyle(.bordered) .tint(.green) } }
-
35:16 - Control size and prominence
struct ContentView: View { var entry: ButtonEntry = .sample var body: some View { HStack { ForEach(entry.tags) { tag in Button(tag.name) { // ... } .tint(tag.color) } } .buttonStyle(.bordered) .controlSize(.small) .controlProminence(.increased) } } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
35:34 - Large buttons
struct ContentView: View { var body: some View { VStack { Button(action: addToJar) { Text("Add to Jar").frame(maxWidth: 300) } .controlProminence(.increased) .keyboardShortcut(.defaultAction) Button(action: addToWatchlist) { Text("Add to Watchlist").frame(maxWidth: 300) } .tint(.accentColor) } .buttonStyle(.bordered) .controlSize(.large) } private func addToJar() {} private func addToWatchlist() {} }
-
37:14 - Destructive buttons
struct ContentView: View { var entry: ButtonEntry = .sample var body: some View { ButtonEntryCell(entry) .contextMenu { Section { Button("Open") { // ... } Button("Delete...", role: .destructive) { // ... } } Section { Button("Archive") {} Menu("Move to") { ForEach(Jar.allJars) { jar in Button("\(jar.name)") { //addTo(jar) } } } } } } } struct ButtonEntryCell: View { var entry: ButtonEntry = .sample init(_ entry: ButtonEntry) { self.entry = entry } var body: some View { Text(entry.name) .padding() } } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
37:25 - Confirmation dialogs
struct ContentView: View { var entry: ButtonEntry = .sample @State private var showConfirmation: Bool = false var body: some View { ButtonEntryCell(entry) .contextMenu { Section { Button("Open") { // ... } Button("Delete...", role: .destructive) { showConfirmation = true // ... } } Section { Button("Archive") {} Menu("Move to") { ForEach(Jar.allJars) { jar in Button("\(jar.name)") { //addTo(jar) } } } } } .confirmationDialog( "Are you sure you want to delete \(entry.name)?", isPresented: $showConfirmation ) { Button("Delete", role: .destructive) { // delete the entry } } message: { Text("Deleting \(entry.name) will remove it from all of your jars.") } } } struct ButtonEntryCell: View { var entry: ButtonEntry = .sample init(_ entry: ButtonEntry) { self.entry = entry } var body: some View { Text(entry.name) .padding() } } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
37:59 - Menu buttons
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } .menuStyle(BorderedButtonMenuStyle()) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:10 - Menu buttons: hidden indicator
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } .menuStyle(BorderedButtonMenuStyle()) .menuIndicator(.hidden) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:31 - Menu buttons: primary action
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } primaryAction: { jarStore.addToDefaultJar(buttonEntry) } .menuStyle(BorderedButtonMenuStyle()) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} func addToDefaultJar(_ entry: ButtonEntry) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:42 - Menu buttons: primary action, indicator hidden
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } primaryAction: { jarStore.addToDefaultJar(buttonEntry) } .menuStyle(BorderedButtonMenuStyle()) .menuIndicator(.hidden) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} func addToDefaultJar(_ entry: ButtonEntry) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
39:01 - Toggle buttons
Toggle(isOn: $showOnlyNew) { Label("Show New Buttons", systemImage: "sparkles") } .toggleStyle(.button)
-
39:13 - Control group
ControlGroup { Button(action: archive) { Label("Archive", systemImage: "archiveBox") } Button(action: delete) { Label("Delete", systemName: "trash") } }
-
39:26 - Control group: back/forward control
struct ContentView: View { @State var current: String = "More buttons" @State var history: [String] = ["Text and keyboard", "Advanced graphics", "Beyond lists", "Better lists"] @State var forwardHistory: [String] = [] var body: some View { Color.clear .toolbar{ ToolbarItem(placement: .navigation) { ControlGroup { Menu { ForEach(history, id: \.self) { previousSection in Button(previousSection) { goBack(to: previousSection) } } } label: { Label("Back", systemImage: "chevron.backward") } primaryAction: { goBack(to: history[0]) } .disabled(history.isEmpty) Menu { ForEach(forwardHistory, id: \.self) { nextSection in Button(nextSection) { goForward(to: nextSection) } } } label: { Label("Forward", systemImage: "chevron.forward") } primaryAction: { goForward(to: forwardHistory[0]) } .disabled(forwardHistory.isEmpty) } .controlGroupStyle(.navigation) } } .navigationTitle(current) } private func goBack(to section: String) { guard let index = history.firstIndex(of: section) else { return } forwardHistory.insert(current, at: 0) forwardHistory.insert(contentsOf: history[...history.index(before: index)].reversed(), at: 0) history.removeSubrange(...index) current = section } private func goForward(to section: String) { guard let index = forwardHistory.firstIndex(of: section) else { return } history.insert(current, at: 0) history.insert(contentsOf: forwardHistory[...forwardHistory.index(before: index)].reversed(), at: 0) forwardHistory.removeSubrange(...index) current = section } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。