大多数浏览器和
Developer App 均支持流媒体播放。
-
SwiftUI 的新功能
这是有关 SwiftUI 的派对,而您就在邀请之列!和我们一起了解最新更新,一窥 UI 框架设计的未来。探索 SwiftUI 中的深层次自定义、布局设计的高级技巧、用于分享的精细策略,以及在自上而下设计 App 时可采用的稳健可靠的结构方法。我们还可以在运用最新的图形效果与探索 API 时一起享受一些惊喜乐趣。
资源
相关视频
WWDC22
-
下载
♪ 柔和乐器演奏的嘻哈音乐 ♪ ♪ Nick Teissler: 嗨 我是 Nick Franck Ndame Mpouli:我是 Franck 我们是 SwiftUI 工程师 Nick:今天 我们将介绍 “SwiftUI 的新功能” SwiftUI 与我们的操作系统一起成长 相互推动促进 开发者使用 SwiftUI 创造出来的东西 会继续让我们感到欢欣鼓舞 我们重视用户群体的各种反馈 所以我们会特别兴奋地分享 我们今年关注的重点 我们对今年的 API 做了更深入的研究 创造了更多的定制体验 引入了一些很棒的新图解技术 我们构建了新的 SwiftUI App 结构等等其他内容 我们能用 SwiftUI 构建反映我们平台未来的 构建设计和功能 从经典 App 的重新设计 到全新功能 再到深度系统集成 Apple 内部全面采用这种技术 进一步推动了 SwiftUI 的发展 这些新设计和新功能的实现 得益于 SwiftUI 提升改进了 在 Apple 中编写 App 的方式 今天 我们要庆祝 API 的成功 也庆祝 SwiftUI 的生日 我和 Franck 有幸成为这次 派对计划委员会的联合主席 让我来向各位开发者介绍 这次派对准备了哪些活动 我将向您介绍一个全新框架 叫做 Swift Charts 您可以用它 在我们的所有平台上创建数据可视化 我将展示 SwiftUI 的数据驱动的 强类型导航模型 和新的窗口功能技术 Franck 将带各位开发者 体验一系列新控件 以及对现有控件进行更深入的自定义 然后他将 向各位开发者们展示我们如何 使用 Transferable 协议将 共享带入 SwiftUI 的世界 结尾 我将聊聊 奇妙的新的图形 API 和先进的新布局 API 我们先从 Swift Charts 开始 Swift Charts 是 声明式框架 用来构建漂亮的状态驱动图表 SwiftUI 出色的 基本设计原则 和绘制数据的过程协调地组合在一起 从而创建了这个世界级的 数据可视化框架 Swift Charts 这是一个柱状图 绘制了我和 Franck 在派对开始前需要完成的任务数量 Swift Charts 只用了几行代码就构建了 华丽的 可定制的图表 与 SwiftUI 一样 Swift Charts 也选择了智能默认值 这一框架下也在 Y 轴值上显示了 令人满意的整数 条形柱状也提供了默认颜色
如果您了解 SwiftUI 您应该已经了解了 Swift 图表的 声明式 状态驱动语法 图表只是一些视图 像做列表和表格一样声明图表 通过提供数据 然后用这些数据 构建图表内容 这张图表选择了 BarMark 但是如果切换到 LineMark 并按类别分组 添加 foregroundStyle 可以看到更精确的图表内容 因为 Swift Charts 为每个类别绘制了单独折线 并自动为图表添加了图例 给这些图表增加一些独特性 是很有趣的 我可以在 LineMark 的 符号修饰符中 给折线添加一些数据点 这些修饰符与 SwiftUI 修饰符 没有什么不同 您甚至可以在图表中 使用 SwiftUI 视图 与 List 一样 Chart 的 data 参数 可以传递给 ForEach 这样就可以在图表构建器中 添加更多标记 比如显示日常目标的 RuleMark
SwiftUI 的精髓 再次闪耀光芒 Swift Charts 能处理 本地化 深色模式 和动态类型 当然 也适用于我们所有的平台 如果您想了解如何制作自己的图表 可以看看“Hello Swift Charts” 如果您对高级绘图技术感兴趣 可以观看“Raise the bar”讲座 接下来 我们来聊聊导航和窗口 SwiftUI 已经支持 最常见的 App 导航模式 例如沉浸式的导航压栈和出栈 扩展的 细节丰富的分屏浏览 和强大的多窗口体验
今年 SwiftUI 对这三种模式 都进行了着重的更新 我们先从堆栈开始 SwiftUI 引入了 新的容器视图 简称为 NavigationStack 用于支持压栈和出栈式导航 NavigationStack 包装了根内容视图 比如这个派对计划 App 的食品清单 如您所料 它与现有的 API 配合得非常好 比如 NavigationLink 和 navigationTitle() 当我们选择一个链接时 SwiftUI 将详细视图 推压堆栈顶部 在我们的 App 中 每个详细视图都包含更多链接 用于快速浏览相关食品
您可能只需要这种方法就够了 但是有一种新方法可以显示视图 并对呈现的状态进行编程控制 如果需要控制导航栈的状态 可以采用新的数据驱动 API 新的 navigationDestination() 修饰符 可以让我们将导航目标 与特定的数据类型关联起来
今年 我们教会了 NavigationLink 一个新技巧 可以取一个代表目标的值 而不是目标视图 点击链接时 就像从前一样 SwiftUI 将使用它的值的类型 来找到正确的目标 并将其推送到堆栈 因为我们现在使用数据来驱动堆栈 所以可以将当前导航路径 表示为显式状态 在这种情况下 导航路径只是一个 我们查看过的所有食物的数组 通过直接访问这个状态 添加一个按钮来快速跳转到 第一个选中的项目 再容易不过了 当视图被压入堆栈时 项目被添加到 selectedFoodItems 数组中 在按钮的操作中 我们可以从路径中 删掉除去第一个项目外的所有项目
轻按一下 就回到了开始的位置
接下来我们谈谈 多列导航的分屏浏览视图 我们正在引入另一个新容器 称为 NavigationSplitView 用于多列导航 NavigationSplitView 可以声明两列和三列布局 Party Planner 使用 简单的两栏布局 包含了派对规划任务的侧边栏列表 和一个随所选任务改变其内容的 详细视图 拆分视图与之前的新的 基于值的 NavigationLinks 搭配起来很好用 使用链接的值 来驱动列表的选择 NavigationSplitView 将自动在较小的类 或设备上 折叠成一个堆栈 是构建自适应的多平台 App 的 一个很好的工具 NavigationSplitView 和 NavigationStack 旨在协同工作 可以直接组合 来构建更复杂的导航结构 我们在 Party Planner App 中 使用它 来将详细列转换为 它自己的 独立的导航堆栈中 也展示了 macOS 上 对导航栈的新支持
我们谈论了不少关于食物的话题 但我听说 我的同事 Curt 在他的演讲 “SwiftUI 导航烹饪书中” 掀起了一场风暴 去看看演讲吧 了解更多有关导航堆栈 和导航拆分视图的信息 但是现在 让我们跳出条条框框 讨论一下新的场景 API 您可能已经熟悉 WindowGroup 用它构建 App 主界面很好用 可以生成多个窗口 允许以不同的视角 展示 App 的数据 今年 我们新增了窗口功能 您猜对了 它为您的 App 声明了一个独特的窗口 在这里 我添加了一个 Party Budget 窗口 显示了派对的总成本
默认情况下 该窗口是可用的 可以通过在 App 的 窗口菜单选择并显示 但我们可以通过指定一个 Command-0 快捷键 来更便捷快速地打开窗口 为了显得我是个 精打细算的派对策划者 我添加了一个工具栏按钮 点击一下也会显示此窗口 使用环境操作 openWindow 我现在可以通过编程 打开新的 SwiftUI 管理的窗口 事实上 今年我们添加了 一整套新的窗口定制 包括默认大小 位置 可调整性等修改器 我不希望派对预算窗口太大碍事 所以默认情况下 它是在角落的小窗口 但如果我调整它的位置或大小 SwiftUI 会在 App 启动时 显示之前的调整后状态 新的独立窗口场景很适合小辅助窗口 就好比 Mac 上的 这个小辅助窗口 但 Party Planner 是多平台的 App 我们需要针对小屏幕的更好设计 例如 在 iOS 平台上 我们选择将预算部分显示在 一个可调整大小的表格中 可以通过新的 presentationDetents() 修饰符实现 在这种情况下 我配置了一个可调整大小的表格 遵循两种不同的尺寸 一种是 250 点 另一种是系统定义的中等高度 今年 在不同平台之间 进行迭代很简单 在 Xcode 中实现多平台目标 增强基于 SwiftUI 的 App 一个目标可以部署到多个平台上 只需从 Xcode 工具栏的 下拉菜单中 选择您的平台 观看“Xcode 中的新功能” 然后看 “使用 Xcode 开发多平台 App” 以了解更多信息 最后一个新场景类型 我们来看向菜单栏 使用 macOS Ventura 您现在可以 完全在 SwiftUI 中构建 MenuBarExtras 这些可以在 App 中 与其他场景类型一起定义 并且在 App 运行时 总是在菜单栏中显示 或者 您可以只用 MenuBarExtra 就构建整个 App 这个方法非常有趣 可以让 macOS 上最简单的想法 变成现实 讲座“为您的 SwiftUI App 带来多个窗口” 有更多关于如何利用 所有新的场景类型和功能的细节 现在我们已经讲完了窗口控件 接下来 Franck 会讲讲 在窗口中放置控件 Franck:谢谢 Nick 今年 我们对所有用于 构建交互式内容的 API 进行了各种增强 我们有很多内容要讲 所以我们先来讲一讲 对表单的增强 macOS Ventura 带有全新的 系统设置 App 特点是流线型的导航结构 使用导航拆分视图和堆栈构建 这部分 Nick 刚刚 已经向我们介绍过 它还具有清新现代的界面风格 设置界面的控件较多 这种样式是专门设计来展示 包含许多控件的表单 风格一致且组织良好 我们在 Party Planner App 中 也采用了这种新设计 我们来看一下 活动详情视图还提供了 许多不同类型的控件 并将它们分组到不同的部分中 作为设置界面提供类似的功能 所以很适合用它来采用系统设置中的 新视觉风格
您可以在 macOS 上使用 新的分组 formStyle 来启用这个设计 多亏了 SwiftUI 声明式 API 的灵活性 表单中的内容和控件 会自动适应新的风格 例如 各段落将在标题下方 可视地对内容进行分组 控件将始终将其标签和值 与前后边缘对齐 一些控件也可能会 调整它们的视觉外观 例如 Toggle 开关 是滑动迷 您开关的样式 从而与布局和对齐保持一致 因为表单本身提供了 大量的视觉结构 其他控件采用了更轻的视觉外观 适应这种环境 并在翻转时显示更突出的控制支持 使用新的 LabeledContent 视图 SwiftUI 可以轻松将 其他类型的内容对齐到这种新样式 这种视图可用于构建新的控件 甚至只是显示一些只读信息 在这个案例中 我们将显示事件位置的一些文本 SwiftUI 会自动调整样式 并允许选择该文本
但 LabeledContent 也可以 包装任何类型的视图 比如 如果我们想使用自定义视图 来显示更完整的地址 现在在其他情况下 SwiftUI 也可以更智能地 将默认样式应用于文本 它将在控件的标签内分层格式化 多个文本片段 以形成标题和副标题 这种新的表单设计 在 macOS 上看起来很棒 但我们也可以 在 iOS 版本的 App 中 共享很多相同的代码
您还会注意到 iOS 上的一些改进设计 比如这些具有视觉风格的 弹出菜单选择器 是受 macOS 启发的 但与它们的交互和外观经过优化 与触摸式界面完美匹配 当然 同样的代码 在 iPad 的大屏幕上效果很好 再加上 Mac 您可以看到 SwiftUI 的 声明式模型如何帮助您 在构建共享接口时共享代码 让您在每个平台 都能同步看到派对内容 当然 我们也在改进控件 不仅仅是表单样式 所以让我们来快速浏览一下 Party Planner App 中使用的 其他一些新的控件功能 先从 iOS App 中的 New Activity 页面开始 文本框可以配置为 使用新的轴参数垂直展开 增加高度以适应文本 如果指定 文本框的高度限制为行限制 但是 lineLimit 修饰符 现在也支持更高级的行为 比如保留最小的文本框空间 并随着添加更多内容而扩展 一旦内容超过上限就会有滚动功能 在文本框下面 还有一个 新的 MultiDatePicker 控件示例 支持不连续的日期选择 帮助我们布置每日的派对活动
现在 也许您对这次讲座的派对主题 有复杂的感受 好消息是 您现在可以 在 SwiftUI 中 使用混合状态控件 来表达您的复杂感受 这里我们有一组 Toggle 可以折叠成 单个 Toggle 集合 每个内部 Toggle 采用单个绑定 而 Toggle 集合 采用所有绑定的集合 如果它们的值不匹配 则显示混合状态
选择器的工作方式相同 这个装饰物主题选择器通过改变值 来反映当前选择的装饰物 但是如果我们选择多个装饰 将使用混合状态指示器 显示所有装饰的主题 现在 我们切换回 iOS App 这里有一些按钮式 Toggle 用于选择事件标签 我们可以通过简单地添加一个 带边框的按钮样式 来区分每个 Toggle 像这样的按钮样式现在将应用于 支持类似按钮外观的任何控件 包括 Toggle 菜单和选择器 接下来是步进器 现在可以为步进器的值提供一种格式 在 macOS 上 格式化的步进器 将在可编辑字段中显示值 现在 watchOS 上 也有步进器 Apple Watch 上 有我最喜欢的新功能之一 辅助功能快捷键 是一种替代方法 通过握紧手来执行动作 可以像任何其他 UI 操作一样 来定义快捷键 使用按钮 我们为可见按钮 和与它等效的快捷键共享相同的代码 好了 我们刚刚讲了很多不同的控件 但是当然 控件不是交互的 唯一来源 那么让我们来看看更大的交互式容器 有什么新功能 比如表格和列表
我很高兴地告诉大家 iPadOS 现在支持表格 如您所期望的 iPadOS 上的表格 是使用我们去年为 macOS 引入 的相同的 Table API 定义的 这使得在平台之间共享代码很容易 案例中的邀请函表显示了三列 分别表示每个人的姓名 城市和邀请状态 这充分利用了 iPad 的大屏幕 但表格也能够适当地 呈现在紧凑的尺寸上 包括在 iPhone 上 在较小的屏幕空间中只显示主列 让我们切换上下文 在 macOS 上查看这个表 看起来很棒 但是谈到上下文 我想添加一些上下文菜单 来执行表中的常见操作 这是一个新的 基于选择的 contentMenu 修饰符的工作 修饰符采用选择类型 并将在任何支持选择的 兼容表或列表中启用 在菜单构建器中 您将获得一个 当前选择的集合 从而允许 您构建高级上下文菜单 可以对单个选定行 多个选定行进行操作 甚至可以对没有选定的行进行操作 例如单击表的空白区域时 上下文菜单直接显示表中的操作 这对速度和效率都有很大的帮助 但我也想让这些操作 更好地让用户发现 提高可发现性的好方法 是将常见的操作 显示为工具栏中的按钮 iPadOS 拥有 全新改进的工具栏设计 来帮助实现这种额外的优化 iPad 工具栏现在可以支持用户 自定义和重新排序 您的 App 可以通过 为每个工具栏项 提供显式标识符来实现 这与 macOS 上的 API 相同 这些标识符允许 SwiftUI 在 App 启动时 自动保存和恢复 自定义工具栏配置 请注意 在 iPadOS 上 并不是所有的工具栏项都允许定制 要配置可定制的操作 用新的 secondaryAction 工具栏项位置 默认情况下 会显示在工具栏的中心 或者显示在小尺寸类的溢出菜单中 好了 开派对的消息传开了 看起来参加的人数呈指数级增长 让我们通过添加搜索支持 来帮助表管理规模 SwiftUI 通过 可搜索的修饰符 已经支持基本的搜索功能 今年的新功能是搜索字段可以支持 标记化的输入和建议 以帮助构建更结构化的搜索查询 为了帮助过滤结果 SwiftUI 现在支持搜索范围 在 macOS 上 显示在工具栏下方的范围栏中 而在 iOS 上作为一个分段控件 出现在导航栏中 今年讲座 我们只是触及了 iPad 上 SwiftUI 的皮毛 请查看“iPad 上的 SwiftUI” 系列并了解更多信息 现在我们对活动的细节和后勤 有了更多的掌控 让我们分享一个 更让人兴奋的消息 与他人共享内容 以及跨 App 共享数据 是许多 App 的重要组成部分 利用这些功能 可以让 您的 App 更加融入到 用户的工作流程中 今年 有几个领域 可以让共享数据更加容易 我们从 PhotosPicker 开始 它是新的多平台和隐私保护 API 用于挑选照片和视频 要开派对 拍照肯定是必不可少的 我在 Party Planner App 中 添加了一项功能 可以为拍摄的照片 添加有趣的生日效果 新的 PhotosPicker 视图 可以放置在 App 的任何位置 激活后 会显示 标准的照片选择 UI 从用户的库中选择照片或视频 PhotosPicker 绑定到 选定的项目 它提供对实际照片和视频数据的访问
它还有额外丰富的配置选项 比如过滤内容的类型 首选照片编码等
这是我见过的最上镜的纸杯蛋糕 但是一个纸杯蛋糕远远不够 我们接下来给照片加上特效 现在我们有了定制的照片 准备用新的 ShareLink API 共享它 每个平台都有一个标准界面 让人们分享 您 App 中的内容 使用 watchOS 9 您现在还可以在手表 App 中 显示分享表 新的 ShareLink 视图支持 在 您的 App 中显示系统共享表 您可以简单地向它提供要共享的内容 和在共享表中使用的预览 它会自动创建一个 标准的共享图标按钮
点击后 它会显示标准的分享表 来发送内容 共享链接适应它们所应用的上下文 例如上下文菜单和跨平台 PhotosPicker 和 ShareLink等 都利用了新的 Transferable 协议 这是一种 Swift 优先声明的方式 来描述如何 在 App 之间传输类型 SwiftUI 使用可转移类型 来支持拖放等功能 这样就可以很容易地 将其他 App 中的图片 拖放到 Party Planner 库中 这利用了新的 dropDestination API 它接受一个有效负载类型 在本例中只是一个图像 完成块提供了接收到的 图像的集合以及拖放位置
许多标准类型 例如字符串和图像 已经符合 Transferable 所以 在我们的 App 中 需要开展工作并不多 但您可以很容易地进一步推进工作 并在自己的自定义类型中 实现 Transferable 需要这么做时 您的一致性声明了 适合您的类型的表示 例如使用 Codable 支持和自定义内容类型 要了解更多关于 Transferable 其他表示 以及高级技巧 查看“Meet Transferable”讲座 在我们准备纸杯蛋糕的时候 Nick 在布置所有的用品 Nick 你那边怎么样 Nick:快完成了 我在把这些派对喇叭 布置成完全自定义的布局 但我还需要一点时间 让我们先来聊聊图形 今年 ShapeStyle 有新的 API 来实现丰富的图形效果 我们要用这些 API 为这张宾客卡 增添一些派对流行元素 Color 具有新的渐变属性 这增加了从颜色派生的微妙渐变 这颜色与系统颜色搭配看起来很棒
ShapeStyle 也有个 新的阴影修改器 将它添加到白色前景样式中 为文本和符号添加阴影 这个阴影的细节棒极了 这个阴影已应用于 日历符号的每个元素
有了完整的一套 SF Symbols 和新的 SwiftUI ShapeStyle 扩展 您可以制作一些非常漂亮的图标
现在 是时候把 SF 符号网格带进派对了 我们将使用 SwiftUI 预览 快速迭代它 今年有一些非常棒的改进 一直以来 预览是 同时查看多个配置中的 视图的一种方便方式 在 Xcode 14 中 我们通过预览变量 让它变得比以往更容易 这使您可以在同一时间 以多种外观 类型大小或方向开发视图 而无需编写任何配置代码 我们可以再次使用相同的渐变 或者我们可以将其设置为椭圆渐变 给图像增添柔和的光芒 请预览一下深色和浅色外观效果
预览现在默认在实时模式下运行 要办生日派对 不跳舞可不行 让我们让 SF 符号跳起舞来吧 ♪ 电子舞曲 ♪ ♪
这些欢快的图标 其实没看起来那么简单 SwiftUI 将文本和图像动画 提升到了一个新高度 让我们再看一遍这段文本的慢动作 文本现在可以在 权重 样式甚至布局之间 进行漂亮的动画化 最棒的是 它利用了 SwiftUI 中使用的 相同的动画 API 最后我来聊聊 我最喜欢的 UI 编程部分 就是应用几何 或者我们称之为“Layout” SwiftUI 添加了 新的视图布局方式 Grid 是一种新的容器视图 它将视图安排在二维网格中 Grid 将预先测量它的子视图 以支持跨多列的单元格 并支持跨行和列的自动对齐 实际上 您之前已经了解了网格
使用 Grid GridRow 和 gridCellColumns 修饰符 您可以构建碎片网格布局 当然 就像 SwiftUI 中的 所有布局一样 它们是为合成而构建的 我们在第一个版本中 引入了 SwiftUI 的布局模型 提供了一个原始布局类型工具箱 来实现一些最常见的布局 大多数时候 您可以用这些 基本的布局类型来完成工作 有时 您需要命令式布局代码 大小 minX frame.origin.x 减去 frame.midX 除以 2 加 3 在这种情况下 您应该使用 新的 Layout 协议 有了它 您就拥有了 我们用来实现 SwiftUI 的 堆栈和网格的全部功能和灵活性 以构建属于您的一流抽象布局 我使用 Layout 为参加生日聚会客人构建了 这个定制的座位图布局 派对客人应该坐成一排 还是均匀分散? 有了 Layout 我们就不必选择了 您可以使用 Layout 协议 构建各种高效布局 以适应 视图层次结构的特定需求 要学习如何采用 Layout 以及其他新的 出色的布局技术 请查看 “使用 SwiftUI 编写自定义布局”讲座 我特别为您准备了 Layout 试用 使用新的 AnyLayout 类型 我可以在 Grid 布局 以及我编写的 自定义分散布局之间切换 随着本次讲座接近尾声 我们还有一个惊喜要告诉各位 开发者们 你们被邀请了 ♪ 邀请各位开发者 在本周和我们一起庆祝 SwiftUI 的生日 和所有的新 API 之前介绍的 API 中 还有很多细节有待探索 甚至还有更多 我们没时间介绍的 API Franck:享受派对 享受 WWDC 2022 Nick:我们要吃蛋糕啦 ♪
-
-
2:51 - Swift Charts: Required models and extensions
import Foundation import SwiftUI // MARK: - Party Planner Models enum PartyTask: String, Identifiable, CaseIterable, Hashable { case food = "Food" case music = "Music" case supplies = "Supplies" case invitations = "Invitations" case eventDetails = "Event Details" case activities = "Activities" case funProjection = "Fun Projection" case vips = "VIPs" case photosFilter = "Photos Filter" var name: String { rawValue } var color: Color { switch self { case .food: return palette[0] case .supplies: return palette[1] case .invitations: return palette[2] case .eventDetails: return palette[3] case .funProjection: return palette[4] case .activities: return palette[5] case .vips: return palette[6] case .music: return palette[7] case .photosFilter: return palette[8] } } var imageName: String { switch self { case .food: return "birthday.cake" case .supplies: return "party.popper" case .invitations: return "envelope.open" case .eventDetails: return "calendar.badge.clock" case .funProjection: return "gauge.medium" case .activities: return "bubbles.and.sparkles" case .vips: return "person.2" case .music: return "music.mic" case .photosFilter: return "camera.filters" } } var id: String { rawValue } var subtitle: String { switch self { case .food: return "Apps, 'Zerts and Cakes" case .supplies: return "Streamers, Plates, Cups" case .invitations: return "Sendable, Non-Transferable" case .eventDetails: return "Date, Duration, And Placement" case .funProjection: return "Beta — How Fun Will Your Party Be?" case .activities: return "Dancing, Paired Programing" case .vips: return "User Interactive Guests" case .music: return "Song Requests & Karaoke" case .photosFilter: return "Filtering and Mapping" } } var emoji: String { switch self { case .food: return "🎂" case .music: return "🎤" case .supplies: return "🎉" case .invitations: return "📨" case .eventDetails: return "🗓" case .funProjection: return "🧭" case .activities: return "💃" case .vips: return "⭐️" case .photosFilter: return "📸" } } } private let palette: [Color] = [ Color(red: 0.73, green: 0.20, blue: 0.20), Color(red: 0.95, green: 0.66, blue: 0.24), Color(red: 0.14, green: 0.29, blue: 0.49), Color(red: 0.46, green: 0.76, blue: 0.67), Color(red: 0.30, green: 0.33, blue: 0.22), Color(red: 0.49, green: 0.55, blue: 0.64), Color(red: 0.92, green: 0.53, blue: 0.30), Color(red: 0.20, green: 0.45, blue: 0.55), Color(red: 0.41, green: 0.45, blue: 0.45), Color(red: 0.87, green: 0.67, blue: 0.61) ] // MARK: - Swift Charts Models struct RemainingPartyTask: Identifiable { let category: PartyTask let date: Date let remainingCount: Int let id = UUID() } let remainingSupplies: [RemainingPartyTask] = [ RemainingPartyTask(category: .supplies, date: .daysAgo(4), remainingCount: 10), RemainingPartyTask(category: .supplies, date: .daysAgo(3), remainingCount: 11), RemainingPartyTask(category: .supplies, date: .daysAgo(2), remainingCount: 9), RemainingPartyTask(category: .supplies, date: .daysAgo(1), remainingCount: 4), RemainingPartyTask(category: .supplies, date: .daysAgo(0), remainingCount: 1), ] let remainingInvitations: [RemainingPartyTask] = [ RemainingPartyTask(category: .invitations, date: .daysAgo(4), remainingCount: 14), RemainingPartyTask(category: .invitations, date: .daysAgo(3), remainingCount: 13), RemainingPartyTask(category: .invitations, date: .daysAgo(2), remainingCount: 11), RemainingPartyTask(category: .invitations, date: .daysAgo(1), remainingCount: 6), RemainingPartyTask(category: .invitations, date: .daysAgo(0), remainingCount: 4), ] let remainingActivities: [RemainingPartyTask] = [ RemainingPartyTask(category: .activities, date: .daysAgo(4), remainingCount: 6), RemainingPartyTask(category: .activities, date: .daysAgo(3), remainingCount: 7), RemainingPartyTask(category: .activities, date: .daysAgo(2), remainingCount: 4), RemainingPartyTask(category: .activities, date: .daysAgo(1), remainingCount: 2), RemainingPartyTask(category: .activities, date: .daysAgo(0), remainingCount: 1), ] let remainingVenue: [RemainingPartyTask] = [ RemainingPartyTask(category: .eventDetails, date: .daysAgo(4), remainingCount: 4), RemainingPartyTask(category: .eventDetails, date: .daysAgo(3), remainingCount: 5), RemainingPartyTask(category: .eventDetails, date: .daysAgo(2), remainingCount: 7), RemainingPartyTask(category: .eventDetails, date: .daysAgo(1), remainingCount: 4), RemainingPartyTask(category: .eventDetails, date: .daysAgo(0), remainingCount: 2) ] let partyTasksRemaining: [RemainingPartyTask] = [remainingVenue, remainingActivities, remainingInvitations, remainingSupplies ].flatMap { $0 } // MARK: Date Utilities extension Date { static func daysAgo(_ daysAgo: Int) -> Date { Calendar.current.date(byAdding: .day, value: -daysAgo, to: Date())! } func daysEqual(_ other: Date) -> Bool { Calendar.current.dateComponents([.day], from: self, to: other).day == 0 } } extension Date { static let wwdc22: Date = DateComponents( calendar: .autoupdatingCurrent, timeZone: TimeZone(identifier: "PST"), year: 2022, month: 6, day: 6, hour: 9, minute: 41, second: 00).date! }
-
2:56 - Swift Charts: Bar Chart 1
Chart(partyTasksRemaining) { BarMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) } .padding()
-
3:33 - Swift Charts: Bar chart 2
var body: some View { Chart(partyTasksRemaining) { BarMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) } .padding() }
-
3:53 - Swift Charts: LineMark
var body: some View { Chart(partyTasksRemaining) { LineMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) .foregroundStyle(by: .value("Category", $0.category)) } .padding() }
-
4:08 - Swift Charts: Line Chart with Symbols
var body: some View { Chart(partyTasksRemaining) { LineMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) .foregroundStyle(by: .value("Category", $0.category)) .symbol(by: .value("Category", $0.category)) } .padding() }
-
4:39 - Swift Charts: Annotations
var body: some View { Chart { ForEach(partyTasksRemaining) { task in LineMark( x: .value("Date", task.date, unit: .day), y: .value("Tasks Remaining", task.remainingCount) ) .foregroundStyle(by: .value("Category", task.category)) .symbol(by: .value("Category", task.category)) .annotation(position: .leading) { Text("\(task.category.emoji)") } } RuleMark(y: .value("Value", 5)) .foregroundStyle(.red) .lineStyle(StrokeStyle(lineWidth: 2.0, dash: [4, 5])) .annotation(position: .top, alignment: .trailing) { VStack(alignment: .trailing) { Text("Today's Goal") Text("Status: ✔︎") } .font(.caption) .foregroundColor(.gray) .padding(.trailing, 2) } } }
-
6:15 - Food Models
import Foundation // MARK: Food Models /// A model representing a food with a price and quantity. struct FoodItem: Hashable, Identifiable, Codable, Equatable { let emoji: String let name: String var description: String = "" let price: Decimal var quantity: Int = 0 var id: String { name } } let donut = FoodItem(emoji: "🍩", name: "Doughnut", description: "Yeast, Old-fashioned, Cake, and the dubious Apple Fritter", price: 2.35, quantity: 6) let moonCake = FoodItem(emoji: "🥮", name: "Moon Cake", description: "Lotus seed paste — plenty of crust", price: 2.20, quantity: 4) let shavedIce = FoodItem(emoji: "🍧", name: "Shaved Ice", description: "Shave your own ice!", price: 3.25, quantity: 1) let cupcake = FoodItem(emoji: "🧁", name: "Cupcake", description: "Also goes by the name Cake Nano", price: 4.00, quantity: 5) let flan = FoodItem(emoji: "🍮", name: "Flan", description: "What's in a flan? That which we call milk, eggs, and sugar by any other name would taste just as sweet.", price: 6.50, quantity: 2) let taffy = FoodItem(emoji: "🍬", name: "Taffy", description: "Freshwater, actually.", price: 1.00, quantity: 11) let cake = FoodItem(emoji: "🎂", name: "Cake Cake", description: "The real deal", price: 15.00, quantity: 1) let cookie = FoodItem(emoji: "🍪", name: "Cookie Cake", description: "The ultimate dessert", price: 4.30, quantity: 1) let relatedFoods = [donut, moonCake, shavedIce, cupcake, flan, taffy, cake, cookie] extension Array where Element: Equatable { /// A quick-and-dirty way of getting a random few elements from an Array that don't include a single, /// particular element. /// - Parameters: /// - count: The number of desired random elements, must be less than `Array.count` /// - except: Filter out this particular element func random(_ count: Int, except: Element) -> [Element] { assert(count >= count) var copy = self copy.shuffle() copy.removeAll(where: { $0 == except }) return Array(copy[0..<count]) } } let partyFoods = [ FoodItem(emoji: "🍨", name: "Ice Cream", price: 3.50, quantity: 4), flan, taffy, donut, FoodItem(emoji: "🍉", name: "Watermelon", price: 3.65, quantity: 1), FoodItem(emoji: "🍒", name: "Cherries", price: 8.00, quantity: 1), cupcake, cookie, FoodItem(emoji: "🍥", name: "Fish Cake", price: 5.00, quantity: 2), moonCake, cake, FoodItem(emoji: "🍘", name: "Rice Cracker", price: 0.25, quantity: 16), FoodItem(emoji: "🥨", name: "Pretzels", price: 3.00, quantity: 3), shavedIce, FoodItem(emoji: "🥧", name: "Apple Pie", price: 4.10, quantity: 1) ]
-
6:21 - NavigationStack with view-based NavigationLinks
// MARK: NavigationStack with View-based NavigationLinks struct FoodsListView: View { fileprivate var foodItems = partyFoods @State private var selectedFoodItems: [FoodItem] = [] var body: some View { NavigationStack { List(foodItems) { item in NavigationLink { FoodDetailView(item: item) } label: { FoodRow(food: item) } } .navigationTitle("Party Food") } } } struct FoodRow: View { let food: FoodItem var body: some View { HStack { Text(food.emoji) .font(.system(size: 15)) .foregroundStyle(.secondary) Text(food.name) .font(.caption) .bold() Spacer() Text("\(food.quantity)") } } } struct FoodDetailView: View { let item: FoodItem var body: some View { ScrollView { VStack { HStack { Text(item.emoji) .font(.system(size: 30)) Text(item.name) .font(.title3) } .padding(.bottom, 4) Text(item.description) .font(.caption) Divider() RelatedFoodsView(relatedFoods: relatedFoods.random(3, except: item)) } } } } struct RelatedFoodsView: View { @State var relatedFoods: [FoodItem] var body: some View { VStack { Text("Related Foods") .background(.background, in: RoundedRectangle(cornerRadius: 2)) HStack { ForEach(relatedFoods) { food in NavigationLink { FoodDetailView(item: food) } label: { Text(food.emoji) } } } } } }
-
6:51 - NavigationStack with value-based NavigationLinks
// MARK: NavigationStack with Value-based Navigation Links struct FoodsListView: View { fileprivate var foodItems = partyFoods @State private var selectedFoodItems: [FoodItem] = [] var body: some View { NavigationStack(path: $selectedFoodItems) { List(foodItems) { item in NavigationLink(value: item) { FoodRow(food: item) } } .navigationTitle("Party Food") .navigationDestination(for: FoodItem.self) { item in FoodDetailView(item: item, path: $selectedFoodItems) } } } } struct FoodDetailView: View { let item: FoodItem @Binding var path: [FoodItem] var body: some View { ScrollView { VStack { HStack { Text(item.emoji) .font(.system(size: 30)) Text(item.name) .font(.title3) } .padding(.bottom, 4) Text(item.description) .font(.caption) Divider() RelatedFoodsView(relatedFoods: relatedFoods.random(3, except: item)) if path.count > 1 { Button("Back to First Item") { path.removeSubrange(1...) } } } } } } struct RelatedFoodsView: View { @State var relatedFoods: [FoodItem] var body: some View { VStack { Text("Related Foods") .background(.background, in: RoundedRectangle(cornerRadius: 2)) HStack { ForEach(relatedFoods) { food in NavigationLink(value: food) { Text(food.emoji) } } } } } }
-
8:16 - NavigationSplitView
// MARK: NavigationSplitView Demo struct PartyPlannerHome: View { @State private var selectedTask: PartyTask? var body: some View { NavigationSplitView { List(PartyTask.allCases, selection: $selectedTask) { task in NavigationLink(value: task) { TaskLabel(task: task) } .listItemTint(task.color) } } detail: { selectedTask.flatMap { $0.color } ?? .white } } } struct TaskLabel: View { let task: PartyTask var body: some View { Label { VStack(alignment: .leading) { Text(task.name) Text(task.subtitle) .font(.footnote) .foregroundStyle(.secondary) } } icon: { Image(systemName: task.imageName) .symbolVariant(.circle.fill) } } }
-
9:13 - Navigation split and stack composition
struct PartyPlannerHome: View { @State private var selectedTask: PartyTask? var body: some View { NavigationSplitView { List(PartyTask.allCases, selection: $selectedTask) { task in NavigationLink(value: task) { TaskLabel(task: task) } .listItemTint(task.color) } } detail: { if case .food = selectedTask { FoodsListView() } else { selectedTask.flatMap { $0.color } ?? .white } } } }
-
10:10 - Window
@main struct PartyPlanner: App { var body: some Scene { WindowGroup("Party Planner") { PartyPlannerHome() } Window("Party Budget", id: "budget") { Text("Budget View") } .keyboardShortcut("0") } }
-
10:42 - Open window
struct DetailView: View { @Environment(\.openWindow) var openWindow var body: some View { Text("Detail View") .toolbar { Button { openWindow(id: "budget") } label: { Image(systemName: "dollarsign") } } } }
-
11:00 - Window customizations
@main struct PartyPlanner: App { var body: some Scene { WindowGroup("Party Planner") { PartyPlannerHome() } Window("Party Budget", id: "budget") { Text("Budget View") } .keyboardShortcut("0") .defaultPosition(.topLeading) .defaultSize(width: 220, height: 250) } }
-
11:47 - Resizable sheets
struct PartyPlannerHome: View { @State private var selectedTask: PartyTask? @State private var presented: Bool = false var body: some View { NavigationSplitView { List(PartyTask.allCases, selection: $selectedTask) { task in NavigationLink(value: task) { TaskLabel(task: task) } .listItemTint(task.color) } } detail: { if case .food = selectedTask { FoodsListView() } else { selectedTask.flatMap { $0.color } ?? .white } } .sheet(isPresented: $presented) { Text("Budget View") .presentationDetents([.height(250), .medium]) .presentationDragIndicator(.visible) } } }
-
12:51 - Menu bar extras
@main struct PartyPlanner: App { var body: some Scene { Window("Party Budget", id: "budget") { Text("Budget View") } MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") { BulletinBoard() } .menuBarExtraStyle(.window) } } private let allPosts: [String] = [ "Did you know: On your third birthday, you are celebrating your 4.0 release.", ] struct BulletinBoard: View { @State var currentPostIndex: Int = 0 var currentPost: String { allPosts[currentPostIndex] } var body: some View { VStack(spacing: 16) { VStack(spacing: 12) { HStack(alignment: .firstTextBaseline) { Text("“") .font(.custom("Helvetica", size: 50).bold()) .baselineOffset(-23) .foregroundStyle(.tertiary) Text("Party Bulletin Board") .font(.headline.weight(.semibold)) .foregroundStyle(.secondary) Spacer() Text("June 6, 2022") .font(.headline.weight(.regular)) .foregroundStyle(.secondary) } .frame(height: 20) Text(currentPost) .font(.system(size: 18)) .multilineTextAlignment(.center) } .padding(.bottom, 4) Divider() HStack { Button { } label: { Label("Calendar", systemImage: "calendar") } Button { currentPostIndex = (currentPostIndex + 1) % allPosts.count } label: { Text("Previous") .frame(maxWidth: .infinity) } ShareLink(items: [currentPost]) } .labelStyle(.iconOnly) .controlSize(.large) } .padding(16) } }
-
12:58 - Menu bar extra app
@main struct MessageBoard: App { var body: some Scene { MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") { BulletinBoard() } .menuBarExtraStyle(.window) } }
-
14:25 - Grouped forms
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let address = "One Apple Park Way" @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location", value: address) DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { theme in Text(theme.rawValue.capitalized).tag(theme) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List(selection: $selectedDecorations) { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } } .formStyle(.grouped) } }
-
15:45 - Grouped forms with LabeledContent wrapping a view.
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let location = Location( firstLine: "One Apple Park Way", secondLine: "Cupertino, CA 95014") @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location") { AddressView(location) } DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { accent in Text(accent.rawValue.capitalized).tag(accent) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List(selection: $selectedDecorations) { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } } .formStyle(.grouped) } } struct AddressView: View { private let location: Location init(_ location: Location) { self.location = location } var body: some View { VStack { Text(location.firstLine) Text(location.secondLine) } } } struct Location { let firstLine: String let secondLine: String }
-
17:06 - Multiline text fields
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
17:20 - Multiline text fields with line limit
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) .lineLimit(5) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
17:23 - Multiline text fields with line limit range
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) .lineLimit(5...10) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
17:40 - MultiDatePicker
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
18:10 - Mixed-state toggles & pickers
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let location = Location( firstLine: "One Apple Park Way", secondLine: "Cupertino, CA 95014") @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location") { AddressView(location) } DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { accent in Text(accent.rawValue.capitalized).tag(accent) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List(selection: $selectedDecorations) { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } } .formStyle(.grouped) } } struct AddressView: View { private let location: Location init(_ location: Location) { self.location = location } var body: some View { VStack { Text(location.firstLine) Text(location.secondLine) } } } struct Location { let firstLine: String let secondLine: String }
-
18:53 - ButtonStyle composition & Steppers
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let location = Location( firstLine: "One Apple Park Way", secondLine: "Cupertino, CA 95014") @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var swiftastic = false @State private var wwdcParty = true @State private var offTheCharts = true @State private var oneMoreThing = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location") { AddressView(location) } DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { accent in Text(accent.rawValue.capitalized).tag(accent) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } Section("Hashtags") { VStack(alignment: .leading) { HStack { Toggle("#Swiftastic", isOn: $swiftastic) Toggle("#WWParty", isOn: $wwdcParty) } HStack { Toggle("#OffTheCharts", isOn: $offTheCharts) Toggle("#OneMoreThing", isOn: $oneMoreThing) } } .toggleStyle(.button) .buttonStyle(.bordered) } } .formStyle(.grouped) } } struct AddressView: View { private let location: Location init(_ location: Location) { self.location = location } var body: some View { VStack { Text(location.firstLine) Text(location.secondLine) } } } struct Location { let firstLine: String let secondLine: String }
-
19:33 - Accessibility Quick Actions
struct ContentView: View { @State private var isInCart: Bool = false var body: some View { VStack(alignment: .leading) { ItemDescriptionView() addToCartButton } .accessibilityQuickAction(style: .prompt) { addToCartButton } } var addToCartButton: some View { Button(isInCart ? "Remove from cart" : "Add to cart") { isInCart.toggle() } } } struct ItemDescriptionView: View { var body: some View { ScrollView { VStack { HStack { Text("🎈") .font(.title2) Text("Balloons") .font(.title3) Spacer() } .padding(.bottom, 4) Text( """ This is perhaps our funniest product! It is made up of a rubber fabric and comes in various unique colors. """) .font(.caption) } } } }
-
20:20 - Tables on iPadOS
struct ContentView: View { @StateObject private var attendeeStore = AttendeeStore() var body: some View { NavigationStack { Table(attendeeStore.attendees) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
21:12 - Context Menu
struct ContentView: View { @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
22:12 - Customizable toolbars
struct ContentView: View { @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
23:17 - Search Tokens
struct ContentView: View { public struct AttendeeToken: Identifiable, Equatable, Hashable { enum Guts { case name case location case status } let guts: Guts var query: String = .init() var id: String { self.systemImage } static let allCases: [AttendeeToken] = [.name, .location, .status] mutating func displayName(_ query: String) -> String { self.query = query switch guts { case .name: return "Name contains: \(query)" case .location: return "City contains: \(query)" case .status: return "Status contains: \(query)" } } var systemImage: String { switch guts { case .name: return "person" case .location: return "location.square" case .status: return "person.crop.circle.badge" } } static let name: AttendeeToken = .init(guts: .name) static let location: AttendeeToken = .init(guts: .location) static let status: AttendeeToken = .init(guts: .status) } @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() @State private var tokens: [AttendeeToken] = .init() @State private var query: String = .init() var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .searchable(text: $query, tokens: $tokens) { token in Label(token.query, systemImage: token.systemImage) } suggestions: { suggestions } .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } @ViewBuilder private var suggestions: some View { ForEach(attendeeStore.attendees) { Text($0.name) .foregroundColor(.black) } if !query.isEmpty { ForEach(AttendeeToken.allCases) { token in var _token = token Label(_token.displayName(query), systemImage: _token.systemImage) .searchCompletion(_token) } } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
23:28 - Search scopes
struct ContentView: View { enum AttendanceScope { case inPerson case online } public struct AttendeeToken: Identifiable, Equatable, Hashable { enum Guts { case name case location case status } let guts: Guts var query: String = .init() var id: String { self.systemImage } static let allCases: [AttendeeToken] = [.name, .location, .status] mutating func displayName(_ query: String) -> String { self.query = query switch guts { case .name: return "Name contains: \(query)" case .location: return "City contains: \(query)" case .status: return "Status contains: \(query)" } } var systemImage: String { switch guts { case .name: return "person" case .location: return "location.square" case .status: return "person.crop.circle.badge" } } static let name: AttendeeToken = .init(guts: .name) static let location: AttendeeToken = .init(guts: .location) static let status: AttendeeToken = .init(guts: .status) } @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() @State private var tokens: [AttendeeToken] = .init() @State private var query: String = .init() @State private var scope: AttendanceScope = .inPerson var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .searchable( text: $query, tokens: $tokens, scope: $scope ) { token in Label( token.query, systemImage: token.systemImage) } scopes: { Text("In Person").tag(AttendanceScope.inPerson) Text("Online").tag(AttendanceScope.online) } suggestions: { suggestions } .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } @ViewBuilder private var suggestions: some View { ForEach(attendeeStore.attendees) { Text($0.name) .foregroundColor(.black) } if !query.isEmpty { ForEach(AttendeeToken.allCases) { token in var _token = token Label(_token.displayName(query), systemImage: _token.systemImage) .searchCompletion(_token) } } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
24:45 - PhotosPicker
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */ } private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
25:51 - ShareLink
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */} private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
26:17 - Context Menu
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } } .contextMenu { Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button(role: .destructive) { viewModel.deleteCurrentPhoto() } label: { Label("Delete", systemImage: "trash") } } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */} func deleteCurrentPhoto() {} private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
26:50 - Drop destination
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } } .contextMenu { Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button(role: .destructive) { viewModel.deleteCurrentPhoto() } label: { Label("Delete", systemImage: "trash") } } .dropDestination(payloadType: Image.self) { receivedImages, location in guard let image = receivedImages.first else { return false } viewModel.imageState = .success(image) return true } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */} func deleteCurrentPhoto() {} private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
28:15 - Shape Styles: CalendarIcon
struct CalendarIcon: View { var body: some View { VStack { Image(systemName: "calendar") .font(.system(size: 80, weight: .medium)) Text("June 6") } .background(in: Circle().inset(by: -20)) .backgroundStyle( .blue .gradient ) .foregroundStyle(.white.shadow(.drop(radius: 1, y: 1.5))) .padding(20) } }
-
28:49 - Shape Styles: Icon Grid
struct Icon: View { let systemSymbolName: String let color: Color let shadow: ShadowStyle var foregroundColor: Color = .white var body: some View { VStack { Image(systemName: systemSymbolName) .resizable() .aspectRatio(1.0, contentMode: .fit) .padding(2) } .background(in: Circle().inset(by: -20)) .backgroundStyle( color .gradient ) .foregroundStyle(foregroundColor.shadow(shadow)) .padding(20) } } private let dropStyle = ShadowStyle.drop(radius: 1, y: 1.5) private let innerStyle = ShadowStyle.inner(radius: 1.5) let icons: [Icon] = [ Icon(systemSymbolName: "person", color: .red, shadow: dropStyle), Icon(systemSymbolName: "basketball", color: .orange, shadow: dropStyle), Icon(systemSymbolName: "globe.central.south.asia", color: .yellow, shadow: innerStyle), Icon(systemSymbolName: "carrot", color: .green, shadow: innerStyle, foregroundColor: .orange), Icon(systemSymbolName: "sailboat", color: .mint, shadow: innerStyle), Icon(systemSymbolName: "figure.open.water.swim", color: .teal, shadow: dropStyle), Icon(systemSymbolName: "ladybug.fill", color: .cyan, shadow: innerStyle), Icon(systemSymbolName: "calendar", color: .blue, shadow: dropStyle), Icon(systemSymbolName: "moon.stars", color: .indigo, shadow: dropStyle), Icon(systemSymbolName: "brain.head.profile", color: .purple, shadow: innerStyle), Icon(systemSymbolName: "birthday.cake", color: .pink, shadow: dropStyle), Icon(systemSymbolName: "house.circle.fill", color: .white, shadow: dropStyle), Icon(systemSymbolName: "lizard", color: .brown, shadow: dropStyle), Icon(systemSymbolName: "flag.checkered", color: .black, shadow: dropStyle), Icon(systemSymbolName: "character.book.closed", color: .gray, shadow: dropStyle), ] struct IconGrid: View { var body: some View { Grid(horizontalSpacing: 16, verticalSpacing: 16) { ForEach(0..<3) { i in GridRow { ForEach(0..<5) { j in icons[i * 5 + j] } } } } .background(.black.opacity(0.8)) } }
-
29:07 - Graphics: Dancing symbol grid
// MARK: - Dancing Symbol Grid struct SymbolSquare: View { let color: Color let imageName: String var image: some View { Image(systemName: imageName) .resizable() .aspectRatio(contentMode: .fit) .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } var body: some View { image .background { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill( .ellipticalGradient( color .gradient ) ) } } } /// If `true`, the party will commence. private let startTheParty = false private let partySymbols = ["party.popper", "balloon", "balloon.2", "birthday.cake"] struct DancingSymbolSquare: View { let color: Color let imageName: String /// Allows staggered dancing — doesn't look quite as nice. let seed: Int private let timer = Timer.publish(every: 0.234378662, on: .main, in: .default) @State private var cancellable: Cancellable? = nil @State private var heavy = false @State var fontSize = 20 as CGFloat var body: some View { SymbolSquare(color: color, imageName: imageName) .font(.body.weight(heavy ? .black : .thin)) .onReceive(timer) { date in if heavy { withAnimation(.easeOut(duration: 0.468757324 - 0.1)) { heavy.toggle() } } else { withAnimation(.easeIn(duration: 0.1)) { heavy.toggle() } } } .onAppear { if startTheParty { DispatchQueue.main.asyncAfter(deadline: .now() + Double(seed) * 0.25) { cancellable = timer.connect() } } } .drawingGroup(opaque: true) } } struct SymbolGrid: View { var body: some View { Grid { GridRow { DancingSymbolSquare(color: .yellow, imageName:partySymbols[0], seed: 0) DancingSymbolSquare(color: .green, imageName: partySymbols[1], seed: 0) } GridRow { DancingSymbolSquare(color: .indigo, imageName: partySymbols[2], seed: 0) DancingSymbolSquare(color: .purple, imageName: partySymbols[3], seed: 0) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } }
-
30:15 - Graphics: Text transitions
struct TextTransitionsView: View { @State private var expandMessage = true private let mintWithShadow: AnyShapeStyle = AnyShapeStyle(Color.mint.shadow(.drop(radius: 2))) private let primaryWithoutShadow: AnyShapeStyle = AnyShapeStyle(Color.primary.shadow(.drop(radius: 0))) var body: some View { Text("Happy Birthday SwiftUI!") .font(expandMessage ? .largeTitle.weight(.heavy) : .body) .foregroundStyle(expandMessage ? mintWithShadow : primaryWithoutShadow) .onTapGesture { withAnimation { expandMessage.toggle() }} .frame(maxWidth: expandMessage ? 160 : 250) .drawingGroup() .padding(20) .background(.pink.opacity(0.3), in: RoundedRectangle(cornerRadius: 6)) } }
-
31:16 - Layout: Grid
struct VIPDetailView: View { var body: some View { Grid { GridRow { NameHeadline() .gridCellColumns(2) } GridRow { CalendarIcon() SymbolGrid() } } .frame(width: 300, height: 300) } } struct NameHeadline: View { var body: some View { HStack { Color.green.background(in: RoundedRectangle(cornerRadius: 8)) .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(alignment: .leading) { Text("Franck Ndame Mpouli") .font(.title2) .foregroundStyle(.shadow(.drop(radius: 2, y: 3))) Text("Party Planning Committee").bold() } } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .background( .white.gradient, in: RoundedRectangle(cornerRadius: 12, style: .continuous) ) } } struct CalendarIcon: View { var body: some View { VStack { Image(systemName: "calendar") .font(.system(size: 80, weight: .medium)) Text("June 6") } .background(in: Circle().inset(by: -20)) .backgroundStyle( .blue .gradient ) .foregroundStyle(.white.shadow(dropStyle)) .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) } }
-
32:04 - Layout: Seating Chart Layout
// MARK: Custom Table Layout private let tableSize = CGSize(width: 130, height: 90) private let guestSize = CGSize(width: 40, height: 40) /// Which of 6 tables this view represents private struct TableViewLayoutKey: LayoutValueKey { static let defaultValue: Int? = nil } extension View { fileprivate func tableViewLayoutKey(_ value: Int) -> some View { return layoutValue(key: TableViewLayoutKey.self, value: value) } } /// Which of 36 guests this view represents private struct GuestViewLayoutKey: LayoutValueKey { static let defaultValue: Int? = 0 } extension View { /// Guests 1 - 36 fileprivate func guestViewLayoutKey(_ value: Int) -> some View { return layoutValue(key: GuestViewLayoutKey.self, value: value) } } let initials = [ "Ju", "As", "Ma", "As", "Ly", "Ga", "Ni", "Ar", "Ca", "Do", "Je", "Ca", "Em", "Ma", "Ze", "Jo", "Da", "Sh", "Sa", "Pl", "Pa", "Sc", "Ma", "Je", "Li", "Ma", "Ta", "Je", "Cu", "Lu", "Ra", "Na", "Sa", "Pa", "Le", "Pi", ] struct SeatingChartView: View { /// If true, the guests will be positioned in "pods" of tables. No table will touch another table. Otherwise /// the guests will side in two longs rows. @State private var usePods = true var body: some View { ZStack(alignment: .bottomTrailing) { GeometryReader { proxy in SeatingLayout(usePods: usePods).callAsFunction { TableView(tableNumber: 1) TableView(tableNumber: 2) TableView(tableNumber: 3) TableView(tableNumber: 4) TableView(tableNumber: 5) TableView(tableNumber: 6) ForEach(1..<37) { i in SeatedGuestOption2(guestNumber: i - 1) } } .animation(.default, value: proxy.size) } .background(.black.opacity(0.13)) Picker("Arrangement", selection: $usePods.animation()) { Text("Pods").tag(true) Text("Rows ").tag(false) } .fixedSize() .pickerStyle(.segmented) .padding() } } } /// heh. struct TableView: View { let tableNumber: Int var body: some View { ZStack(alignment: .bottomTrailing) { HStack { Image(systemName: "table.furniture") .background(.quaternary.shadow(.inner(radius: 1, y: 1.5)), in: Circle().inset(by: -8)) .padding(5) Text("Table \(tableNumber)") } .foregroundStyle(.secondary) .padding(8) .frame(width: tableSize.width, height: tableSize.height) #if os(macOS) || os(iOS) .background(.regularMaterial.shadow(.drop(radius: 1, y: 1.5)), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) #endif } .tableViewLayoutKey(tableNumber) } } private let colors: [Color] = [ .red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .black, .white, .brown, .red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .black, .white, .brown, .red, .orange, .yellow, .green, .mint, .teal, .cyan ] struct SeatedGuest: View { let guestNumber: Int var body: some View { Image(systemName: "person") .resizable() .aspectRatio(contentMode: .fit) .padding(9) .background(in: Circle()) .backgroundStyle( colors[guestNumber].gradient ) .foregroundStyle(guestNumber == 13 ? .black : .white) .frame(width: 40, height: 40) .guestViewLayoutKey(guestNumber + 1) } } struct SeatedGuestOption2: View { let guestNumber: Int var body: some View { Circle() .stroke(colors[guestNumber], style: StrokeStyle(lineWidth: 3)) .background(.white.gradient, in: Circle()) .frame(width: guestSize.width, height: guestSize.height) .guestViewLayoutKey(guestNumber + 1) .overlay { Text(initials[guestNumber]) .foregroundColor(.secondary) .font(.callout) } } } struct SeatingChartView_Previews: PreviewProvider { static var previews: some View { SeatingChartView() .frame(width: 600, height: 600) } } struct SeatingLayout: Layout { /// If true, the guests will be positioned in "pods" of tables. No table will touch another table. Otherwise /// the guests will side in two longs rows. let usePods: Bool struct Cache { /// The width proposed to the view. We assume a certain height, otherwise, overlapping views var width: CGFloat? } func sizeThatFits( proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout Cache ) -> CGSize { cache.width = proposal.width return proposal.replacingUnspecifiedDimensions() } func makeCache(subviews: Subviews) -> Cache { Cache() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { guard let width = cache.width else { return } /// Helper function: Place 6 guests around all edges of a table. func seat(_ guests: [LayoutSubview], around table: CGRect) { guests[0].place( at: .init( x: table.origin.x + 3 - guestSize.width, y: table.origin.y + (table.height / 2.0) - (guestSize.height / 2.0)), proposal: .infinity) guests[1].place( at: .init( x: table.origin.x + (table.width / 4.0) - guestSize.width / 2.0, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[2].place( at: .init( x: table.origin.x + table.width * 0.75 - guestSize.width / 2.0, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[3].place( at: .init( x: table.maxX - 5, y: table.origin.y + (table.height / 2.0) - (guestSize.height / 2.0)), proposal: .infinity) guests[4].place( at: .init( x: table.origin.x + table.width * 0.75 - guestSize.width / 2.0, y: table.maxY - 5), proposal: .infinity) guests[5].place( at: .init( x: table.origin.x + (table.width / 4.0) - guestSize.width / 2.0, y: table.maxY - 5), proposal: .infinity) } /// Helper function: Place 6 guests, dining hall style (not along the shorter sides of a table) func seat(_ guests: [LayoutSubview], along table: CGRect) { guests[0].place( at: .init( x: table.minX + tableSize.width / 3 - guestSize.width - 4, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[1].place( at: .init( x: table.minX + tableSize.width * 2/3 - guestSize.width - 4, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[2].place( at: .init( x: table.minX + tableSize.width - guestSize.width - 4, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[3].place( at: .init( x: table.minX + tableSize.width / 3 - guestSize.width - 4, y: table.maxY - 5), proposal: .infinity) guests[4].place( at: .init( x: table.minX + tableSize.width * 2/3 - guestSize.width - 4, y: table.maxY - 5), proposal: .infinity) guests[5].place( at: .init( x: table.minX + tableSize.width - guestSize.width - 4, y: table.maxY - 5), proposal: .infinity) } // Get tables let table1 = subviews.first(where: { $0[TableViewLayoutKey.self] == 1 })! let table2 = subviews.first(where: { $0[TableViewLayoutKey.self] == 2 })! let table3 = subviews.first(where: { $0[TableViewLayoutKey.self] == 3 })! let table4 = subviews.first(where: { $0[TableViewLayoutKey.self] == 4 })! let table5 = subviews.first(where: { $0[TableViewLayoutKey.self] == 5 })! let table6 = subviews.first(where: { $0[TableViewLayoutKey.self] == 6 })! // Get guests let table1Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 1 && guestNumber <= 6 } let table2Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 7 && guestNumber <= 12 } let table3Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 13 && guestNumber <= 18 } let table4Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 19 && guestNumber <= 24 } let table5Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 25 && guestNumber <= 30 } let table6Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 31 && guestNumber <= 36 } if usePods { let table1Origin = CGPoint(x: 60, y: 120) let table2Origin = CGPoint(x: 200, y: 280) let table3Origin = CGPoint(x: 50, y: 450) let table4Origin = CGPoint(x: 300, y: 120) let table5Origin = CGPoint(x: 440, y: 280) let table6Origin = CGPoint(x: 290, y: 450) table1.place(at: table1Origin, proposal: .infinity) table2.place(at: table2Origin, proposal: .infinity) table3.place(at: table3Origin, proposal: .infinity) table4.place(at: table4Origin, proposal: .infinity) table5.place(at: table5Origin, proposal: .infinity) table6.place(at: table6Origin, proposal: .infinity) seat(table1Guests, around: CGRect(origin: table1Origin, size: tableSize)) seat(table2Guests, around: CGRect(origin: table2Origin , size: tableSize)) seat(table3Guests, around: CGRect(origin: table3Origin, size: tableSize)) seat(table4Guests, around: CGRect(origin: table4Origin, size: tableSize)) seat(table5Guests, around: CGRect(origin: table5Origin , size: tableSize)) seat(table6Guests, around: CGRect(origin: table6Origin, size: tableSize)) } else { let table1Origin = CGPoint(x: width / 2.0 - 6 - tableSize.width * 1.5, y: 130) let table2Origin = CGPoint(x: table1Origin.x + tableSize.width + 6, y: 130) let table3Origin = CGPoint(x: table2Origin.x + tableSize.width + 6, y: 130) let table4Origin = CGPoint(x: width / 2.0 - 6 - tableSize.width * 1.5, y: 360) let table5Origin = CGPoint(x: table1Origin.x + tableSize.width + 6, y: 360) let table6Origin = CGPoint(x: table2Origin.x + tableSize.width + 6, y: 360) table1.place(at: table1Origin, proposal: .infinity) table2.place(at: table2Origin, proposal: .infinity) table3.place(at: table3Origin, proposal: .infinity) table4.place(at: table4Origin, proposal: .infinity) table5.place(at: table5Origin, proposal: .infinity) table6.place(at: table6Origin, proposal: .infinity) seat(table1Guests, along: CGRect(origin: table1Origin, size: tableSize)) seat(table2Guests, along: CGRect(origin: table2Origin , size: tableSize)) seat(table3Guests, along: CGRect(origin: table3Origin, size: tableSize)) seat(table4Guests, along: CGRect(origin: table4Origin, size: tableSize)) seat(table5Guests, along: CGRect(origin: table5Origin , size: tableSize)) seat(table6Guests, along: CGRect(origin: table6Origin, size: tableSize)) } } }
-
32:50 - AnyLayout invitation
import SwiftUI import GameplayKit import Combine @main struct InvitationApp: App { var body: some Scene { WindowGroup { PolygonDesignerView() .environmentObject(PolygonModel()) #if os(iOS) .statusBar(hidden: true) #endif .edgesIgnoringSafeArea(.all) } } } // MARK: Views /// A view that arranges polygons in a grid, or a custom, scattered layout. private struct DynamicPolygonView: View { @EnvironmentObject var model: PolygonModel @Binding var cycleLayouts: Bool private var sideLength: Int { Int(CGFloat(model.polygonGeometries.count).squareRoot()) } /// Timer whose ticking dictates how often to regenerate and animate-to a new scattered layout. /// - Note: The layout will only transition if `cycleLayouts` is `true`. private let layoutChangingTimer = Timer .publish(every: 1.2, on: .current, in: .default).autoconnect() /// Animation used to transition layouts private let animation = Animation.easeInOut(duration: 1.3) /// Timer that ticks at 128 beats per minute, matching the beat of the song in the WWDC session. let musicBeatTimer = Timer .publish(every: 0.234378662, tolerance: 0, on: .main, in: .default) @State private var musicBeatTimerCancellable: (any Cancellable)? = nil /// Whether or not the font should be rendered heavy. @State private var heavy: Bool = false @State private var scatteredLayout = newScatteredLayout( Date(timeIntervalSince1970: 0) ) /// By providing a seed value, the `ScatteredLayout` struct will know when to bust its cache and /// generate new layout data. private static func newScatteredLayout(_ seed: Date) -> ScatteredLayout { ScatteredLayout(count: PolygonModel.total, seed: seed.timeIntervalSinceReferenceDate, textAvoidanceRect: CGRect( x: 152, y: 245, width: 220, height: 40) ) } var body: some View { let layout = model.usesGridLayout ? AnyLayout(Grid(alignment: .center, horizontalSpacing: 0, verticalSpacing: 0)) : AnyLayout(scatteredLayout) ZStack(alignment: .center) { Label(title: { Text("You're Invited") }, icon: { Image(systemName: "party.popper.fill")}) .font(.system(size:100).weight(heavy ? .black : .thin)) .onTapGesture { musicBeatTimerCancellable = musicBeatTimer.connect() } .zIndex(-1) layout { ForEach((0..<sideLength), id: \.self) { row in GridRow { // GridRow is a no-op in non-Grid layouts ForEach((0..<sideLength), id: \.self) { column in let polygon = model .polygonGeometries[sideLength * row + column] PolygonView(polygonGeometry: polygon) .polygonViewLayoutKey(polygon) } } } } } .drawingGroup() .frame(maxWidth: .infinity, maxHeight: .infinity) .onReceive(musicBeatTimer) { date in if heavy { // Transitioning to a thin font happens slowly withAnimation(.easeOut(duration: 0.468757324 - 0.1)) { heavy.toggle() } } else { // Transitioning to thick happens quickly, to give the // appearance of a "strong" downbeat withAnimation(.easeIn(duration: 0.1)) { heavy.toggle() } } } .onReceive(layoutChangingTimer) { date in guard cycleLayouts else { return } withAnimation(animation) { scatteredLayout = DynamicPolygonView.newScatteredLayout(date) } } } } private struct PolygonDesignerView: View { @EnvironmentObject var model: PolygonModel @State var cycleLayouts = false @State var hideDesignerView = true var body: some View { ZStack(alignment: .bottom) { DynamicPolygonView(cycleLayouts: $cycleLayouts) .onTapGesture(count: 2) { withAnimation { hideDesignerView.toggle() } } ControlView(cycleLayouts: $cycleLayouts) .padding() .background(.thickMaterial) .offset(CGSize(width: 0, height: hideDesignerView ? 300 : 0)) } } } /// Tunes the parameters of a `PolygonModel` private struct ControlView: View { /// The instance `self` tunes the parameters of. @EnvironmentObject var model: PolygonModel /// Can be used by a parent view to cycle through instances of layouts. @Binding var cycleLayouts: Bool var body: some View { VStack { Button("Reset", action: model.reset) let layout = HStack() layout { Toggle("Tiled", isOn: Binding(get: { model.tiled }, set: { tile in // After toggled, wait 5 seconds, then transition back to a // scattered layout DispatchQueue.main.asyncAfter(deadline: .now() + 5) { withAnimation(.linear(duration: 1.4)) { model.usesGridLayout = false model.drawAsRandomPolygons = true } } withAnimation(.linear(duration: 1.8)) { model.usesGridLayout = tile model.drawAsRandomPolygons = !tile } })) Toggle("Cycle Layouts", isOn: $cycleLayouts) } } .padding(2) } } // MARK: PolygonView /// Wraps a ``Polygon`` shape applying a fill. private struct PolygonView: View { var polygonGeometry: PolygonGeometry var body: some View { Polygon(polygonGeometry: polygonGeometry) .fill(polygonGeometry.color) } } /// A Polygon shape that supports any number of sides as defined by `polygonGeometry` private struct Polygon: Shape { var polygonGeometry: PolygonGeometry typealias AnimatableData = AnimatableVector var animatableData: AnimatableVector { get { polygonGeometry.vectorPath } set { polygonGeometry.points = newValue.points } } func path(in rect: CGRect) -> Path { // Scale up the shape's path to fill as much space as it is given let path = polygonGeometry.path let boundingRect = path.boundingRect let xScale = rect.width / boundingRect.width let yScale = rect.height / boundingRect.height let translate = CGAffineTransform( translationX: -boundingRect.origin.x * xScale, y: -boundingRect.origin.y * yScale ) let scale = CGAffineTransform(scaleX: xScale, y: yScale) return path.applying(scale.concatenating(translate)) } func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { if proposal == .infinity { // If proposed infinite space, use the preferred, absolute size. return CGSize(width: polygonGeometry.sideLength, height: polygonGeometry.sideLength) } else { // If we don't have infinite space, assume we've been given all the // space the parent view can afford, and take all of it. return proposal.replacingUnspecifiedDimensions() } } } // MARK: ScatteredLayout private struct PolygonViewLayoutKey: LayoutValueKey { static let defaultValue: PolygonGeometry? = nil } extension View { fileprivate func polygonViewLayoutKey(_ value: PolygonGeometry) -> some View { return layoutValue(key: PolygonViewLayoutKey.self, value: value) } } /// ScatteredLayout assumes a certain standard size and lays out its views /// (tagged with `PolygonViewLayoutKey` data) such that they don't collide /// within that size. As the size grows, the shapes stay the same size, /// but get farther or closer. private struct ScatteredLayout: Layout { /// Cache data for a `ScatteredLayout`. struct Cache { /// Maps a `PolygonGeometry.id` to its position in a `standardSize` /// coordinate space. var rects: [UUID: CGRect] /// Used as a cache buster. var seed: TimeInterval? } /// The smallest size a view using this layout can be. private let minimumBaseSize: CGSize /// The base coordinate system this view assumes when laying out. private let standardSize: CGSize = CGSize(width: 500, height: 500) /// Clients can pass a value here and polygons won't be placed in that rect. var textAvoidanceRect: CGRect = .zero /// If different, we've been requested to bust the cache, and create a new /// one. /// - Note the cache can persist across different instances of a /// `ScatteredLayout` private let seed: TimeInterval func sizeThatFits( proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout Cache ) -> CGSize { let proposedSize = proposal .replacingUnspecifiedDimensions(by: minimumBaseSize) return CGSize( width: proposedSize.width .clamped( to: minimumBaseSize.width..<CGFloat.greatestFiniteMagnitude ), height: proposedSize.height .clamped( to: minimumBaseSize.height..<CGFloat.greatestFiniteMagnitude ) ) } init(count: Int, seed: TimeInterval, textAvoidanceRect: CGRect = .zero) { self.seed = seed minimumBaseSize = CGSize(width: CGFloat(count), height: CGFloat(count)) self.textAvoidanceRect = textAvoidanceRect } func makeCache(subviews: Subviews) -> Cache { var cache: Cache = Cache(rects: [:], seed: self.seed) var placedPolygons: [CGRect] = [] for subview in subviews { guard let polygon = subview[PolygonViewLayoutKey.self] else { // This is the title text view, skip it. continue } var subviewsPreferredSize = subview.sizeThatFits(.infinity) var counter = 20 while counter > 0 { counter -= 1 let randomX = CGFloat.random(in: 0..<standardSize.width) let randomY: CGFloat if randomX > textAvoidanceRect.minX && randomX < textAvoidanceRect.maxX { // Pick from either above or below the avoidance rect if Bool.random() { randomY = CGFloat.random( in: 0..<textAvoidanceRect.minY ) } else { randomY = CGFloat.random( in: textAvoidanceRect.maxY..<standardSize.height ) } } else { randomY = CGFloat.random(in: 0..<standardSize.height) } let origin = CGPoint(x: randomX, y: randomY) let rect = CGRect(origin: origin, size: subviewsPreferredSize) if placedPolygons.allSatisfy({ placed in !placed.intersects(rect) }) && !rect.intersects(textAvoidanceRect) { // The shape found a non-overlapping place to be. Lock in // it's position placedPolygons.append(rect) cache.rects[polygon.id] = CGRect(origin: origin, size: subviewsPreferredSize) break } else { if (counter == 0) { if rect.intersects(textAvoidanceRect) { subviewsPreferredSize = .zero } placedPolygons.append(rect) cache.rects[polygon.id] = CGRect(origin: origin, size: subviewsPreferredSize) } } } } return cache } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { // We have the frame value cached (via makeCache()) // for every view to be placed in a `standardSize` coordinate system. // Now we need to map that `standardSize` to the size was proposed. let proposedSize = proposal .replacingUnspecifiedDimensions(by: minimumBaseSize) let xProposedToBaseRatio = proposedSize.width / standardSize.width let yProposedToBaseRatio = proposedSize.height / standardSize.height for subview in subviews { guard let uuid = subview[PolygonViewLayoutKey.self]?.id, let rect = cache.rects[uuid] else { let desiredSize = subview.sizeThatFits(.zero) let centered = desiredSize.centered(in: bounds) subview.place( at: centered.origin, proposal: ProposedViewSize( width: desiredSize.width, height: desiredSize.height ) ) continue } let mappedPoint = CGPoint(x: rect.origin.x * xProposedToBaseRatio, y: rect.origin.y * yProposedToBaseRatio) subview.place(at: mappedPoint, proposal: ProposedViewSize(width: rect.size.width, height:rect.size.height) ) } } func updateCache(_ cache: inout Cache, subviews: Subviews) { // Bust the cache if we've been given a new seed value // or if our subviews have been swapped out from underneath us. if self.seed != cache.seed || !cache.rects.contains(where: { (key: UUID, value: CGRect) in subviews.first?[PolygonViewLayoutKey.self]?.id == key }) { cache = makeCache(subviews: subviews) return } } } /// This struct facilitates animation of point-based `Path`s so long as said /// source and destination `Path` have an equal number of vertices. private struct AnimatableVector: VectorArithmetic { static var zero: AnimatableVector = AnimatableVector(points: []) private(set) var points: [CGPoint] var magnitudeSquared: Double { let squared = points.map { point in CGPoint(x: point.x * point.x, y: point.y * point.y) } let sumOfSquares = squared.map { point in // dot product? sqrt(point.x + point.y) } let sum = sumOfSquares.reduce(0, +) return Double(sum) } /// Facilitates a valid `.zero` value, no matter the dimension of the vector subscript(safe index: Int) -> CGPoint { return (self.points.count <= index) ? .zero : points[index] } static func - (lhs: AnimatableVector, rhs: AnimatableVector) -> AnimatableVector { let negated = rhs.points.map { CGPoint(x: -$0.x, y: -$0.y) } return lhs + AnimatableVector(points: negated) } static func + (lhs: AnimatableVector, rhs: AnimatableVector) -> AnimatableVector { var output: [CGPoint] = [] for i in 0..<lhs.points.count { output.append(CGPoint(x: lhs[safe: i].x + rhs[safe: i].x, y:lhs[safe: i].y + rhs[safe: i].y )) } return AnimatableVector(points: output) } mutating func scale(by rhs: Double) { points = points.map { CGPoint(x: $0.x * CGFloat(rhs), y: $0.y * CGFloat(rhs)) } } } // MARK: Random Polygon Generation & Geometry private let mean: Float = 10 private let deviation: Float = 3 private let gaussian = GKGaussianDistribution( randomSource: GKARC4RandomSource(), mean: mean, deviation: deviation) /// Factory type for creating points describing a random Polygon private struct PolygonGeometry: Identifiable, Equatable, Hashable { /// The horizontal and vertical side lengths of the polygon's bounding box. let sideLength: CGFloat /// A constant count of the total points that comprise this /// `PolygonGeometry`'s path. Clients can set `points` to a new value, but /// the new value should have the same `count` for smooth `Path` animations let numberOfVertices: Int /// Supports animation of point-based `Path`s by providing an array of /// points that can be interpolated. var vectorPath: AnimatableVector { AnimatableVector(points: points) } /// If `false`, this instance will present itself as a rectangular shape /// (not necessarily with 4 vertices) that fills available space. private(set) var drawsAsPolygon: Bool = true /// Points describing the `Path` used to render `self`. var points: [CGPoint] { willSet { assert(points.count == polygonPathPoints.count) } } /// Delineate the path of the random polygon. private let polygonPathPoints: [CGPoint] let color: Color = [ Color(red: 0.73, green: 0.20, blue: 0.20), Color(red: 0.95, green: 0.66, blue: 0.24), Color(red: 0.14, green: 0.29, blue: 0.49), Color(red: 0.46, green: 0.76, blue: 0.67), Color(red: 0.30, green: 0.33, blue: 0.22), Color(red: 0.49, green: 0.55, blue: 0.64), Color(red: 0.92, green: 0.53, blue: 0.30), Color(red: 0.20, green: 0.45, blue: 0.55), Color(red: 0.41, green: 0.45, blue: 0.45), Color(red: 0.87, green: 0.67, blue: 0.61) ].randomElement()! private var spikiness: CGFloat = 0.2 private var irregularity: CGFloat = 0.2 let id = UUID() /// Owning `Shape` instances should use this to draw. var path: Path { Path(from: points) } init(pointsVector: [CGPoint], sideLength: CGFloat) { self.numberOfVertices = pointsVector.count self.points = pointsVector self.polygonPathPoints = points self.sideLength = sideLength } func drawn(asRandomizedPolygon: Bool) -> Self { var copy = self copy.drawsAsPolygon = asRandomizedPolygon copy.points = asRandomizedPolygon ? copy.polygonPathPoints : CGRect(x: 0, y: 0, width: 1, height: 1) .pointSequence(of: copy.numberOfVertices) return copy } func hash(into hasher: inout Hasher) { hasher.combine(id) } } /// A namespace around functionality to generate a path drawn in a 1x1 square /// with configurable "irregularity" and "spikiness". /// The closer both are to zero, the closer the generated polygon is to a /// [regular polygon](https://mathworld.wolfram.com/RegularPolygon.html) private enum UnitPolygonGeometryFactory { /// The maximum possible radius. A value of 0.5 restricts the algorithm /// to the unit square. private static let maxRadius: CGFloat = 0.5 /// A — by no means definitive — algorithm for creating an arbitrary /// polygon of `vertexCount` vertices /// - Parameters: /// - vertexCount: How many vertices (and edges) the polygon will have /// - irregularity: A subjective term for how "irregular" the polygon is. /// A fully regular polygon has all equal sides, assuming 0 `spikinesss`. /// - spikiness: A subjective term for how "spiky" the polygon is. /// A polygon with high spikiness will have more vertices closer and /// farther from where the vertex would be on a regular polygon. /// - Returns: An array of points representing the point-based path of /// the polygon static func random(vertexCount: Int, irregularity: CGFloat = 0.2, spikiness: CGFloat = 0.2) -> [CGPoint] { let floatVertices = CGFloat(vertexCount) // Irregularity is how much we're willing to allow the angular steps to // vary from "perfect". For example, in a regular (all sides equal) // six-sided polygon, each angular step is 2𝜋 / 6. Irregularity // defines the range that value can take, centered around a mean of // 2𝜋 / 6. We accept an irregularity between 0 and 1, and then // scale it for how much that represents out of a circle's radians. let scaledIrregularity = irregularity * 2.0 * CGFloat.pi / floatVertices // Spikiness describes how often we want to see values that are very // far from where a vertex of a regular polygon would be. For example, // a high positive spikiness might push a vertex radially very far from // the center, leading to a big "spike". Meanwhile, a spikiness of 0 // will yield more circular polygons. let denormalizedSpikiness = spikiness * maxRadius let gaussian = GKGaussianDistribution( randomSource: GKARC4RandomSource(), mean: Float(maxRadius * 1024), deviation: Float(denormalizedSpikiness * 1024)) // Generate the angular steps var raidanAngleSteps: [CGFloat] = [] // Both of these measured in radians let minimumSliceWidth = (2.0 * CGFloat.pi / floatVertices) - scaledIrregularity let maximumSliceWidth = (2.0 * CGFloat.pi / floatVertices) + scaledIrregularity var sum: CGFloat = 0 for _ in (0..<vertexCount) { let radians = CGFloat .random(in: minimumSliceWidth...maximumSliceWidth) raidanAngleSteps.append(radians) sum += radians } // Re-divide these steps so the point 0 and n+1 are the same. // I.e. if the random angle generation from the above loop yielded // more or less than 2𝜋 radians, reapportion those divisions to sum to // 2𝜋. let k = sum / (2 * CGFloat.pi) (0..<vertexCount).forEach { i in raidanAngleSteps[i] /= k } let maximumPossibleGaussianSample = CGFloat( gaussian.mean + Float(denormalizedSpikiness * 1024)*3 ) // Finally, make all of the normalized points within a 1x1 square // Unlike the unit circle of traditional geometry, because (0, 0) is in // the top left, (0.5, 0.5) is in the middle. Thus, positively // incrementing the angle moves us clockwise around the circle var points: [CGPoint] = [] let center = CGPoint(x: maxRadius, y: maxRadius) var cumulativeAngle: CGFloat = 0.0 for i in (0..<Int(vertexCount)) { // * 2 to keep the sample <= 0.5 (`maxRadius) let radiusForPoint = CGFloat(gaussian.nextInt()) / (maximumPossibleGaussianSample * 2) let x = center.x + radiusForPoint * cos(cumulativeAngle) let y = center.y + radiusForPoint * sin(cumulativeAngle) points.append(CGPoint(x: x, y: y)) cumulativeAngle += raidanAngleSteps[i] } return points } } // MARK: Observable Polygon Model /// A `PolygonModel` describes a collection of randomized ``Polygons`` that /// can be laid out by `AnyLayout` type. private class PolygonModel: ObservableObject { static let total = (maxSides - minSides + 1) * polygonsPerSideCount /// The minimum sides the randomly generated sides will have private static let minSides = 4 /// The maximum sides the randomly generated sides will have private static let maxSides = 7 /// The number of randomly generated polygons to make _per side length_. private static let polygonsPerSideCount = 32 /// All `PolygonGeometry`s that are laid out with `scatteredLayout` @Published var polygonGeometries: [PolygonGeometry] = makeGeometries() /// If `true`, `self` is expressing a grid layout with rectangular tiles. var tiled: Bool { usesGridLayout && !drawAsRandomPolygons } /// If `true`, ignore `scatteredLayout` and instead use a `Grid` layout @Published var usesGridLayout: Bool = false /// If `true`, `polygonGeometries` draw themselves as randomized polygons. /// If false, a rectangle that fills all available space. @Published var drawAsRandomPolygons: Bool = true { didSet { polygonGeometries = polygonGeometries.map { $0.drawn(asRandomizedPolygon: drawAsRandomPolygons) } } } /// Tunable by clients to experiment with different values. let spikiness: CGFloat = 0.2 /// Tunable by clients to experiment with different values. let irregularity: CGFloat = 0.2 /// Creates many ``PolygonGeometry`` instances with the given parameters. /// - Parameters: /// - irregularity: A subjective term for how "irregular" the polygon is. /// A fully regular polygon has all equal sides, assuming 0 `spikinesss`. /// - spikiness: A subjective term for how "spiky" the polygon is. /// A polygon with high spikiness will have more vertices closer and /// farther from where the vertex would be on a regular polygon. /// - Returns: An array of `n` polygons where `n` is defined by the /// `PolygonModel` class. private static func makeGeometries( irregularity: CGFloat = 0.3, spikiness: CGFloat = 0.3) -> [PolygonGeometry] { var scales: Array<CGFloat> = polygonSizeRatios .reduce(into: []) { partialResult, sizeRatio in let (size, percentage) = sizeRatio let scalesToMake = Int(ceil(percentage * CGFloat(total))) partialResult.append(contentsOf: (0..<scalesToMake) .map { _ in CGFloat.random(in: size.sizeRange) }) }.shuffled() return (minSides...maxSides).flatMap { vertexCount in return (0..<polygonsPerSideCount).map { _ in let unitPolygon = UnitPolygonGeometryFactory .random(vertexCount: vertexCount, irregularity: irregularity, spikiness: spikiness) let polygonGeometry = PolygonGeometry( pointsVector: unitPolygon, sideLength: scales.removeFirst()) return polygonGeometry } }.shuffled() } /// Complete remove and regenerate all model data. func reset() { polygonGeometries.removeAll(keepingCapacity: true) polygonGeometries = PolygonModel.makeGeometries( irregularity: irregularity, spikiness: spikiness ) } } private extension PolygonModel { /// Use a sampling of various sized polygons enum PieceSize: Hashable { case tiny case small case medium case large /// The range for the side length of the bounding rect of a polygon var sizeRange: ClosedRange<CGFloat> { switch self { case .tiny: return 16.0...25.0 case .small: return 25.0...40.0 case .medium: return 40.0...50.0 case .large: return 50.0...65.0 } } } /// This dictionary denotes the ratio of sizes to use. /// - warning: Should sum to 100. private static let polygonSizeRatios: [PieceSize: CGFloat] = [ .large: 0.15, .medium: 0.25, .small: 0.25, .tiny: 0.35 ] } // MARK: - Utility Extensions extension FloatingPoint { /// - returns an instance of `Self` clamped to the ``ClosedRange``. func clamped(to limits: ClosedRange<Self>) -> Self { return min(max(self, limits.lowerBound), limits.upperBound) } /// - returns an instance of `Self` clamped to the ``Range``. /// - note the value returned will be less than the provided upper bound, as /// is dictated by ``Range``. func clamped(to limits: Range<Self>) -> Self { return min(max(self, limits.lowerBound), limits.upperBound.nextDown) } } extension CGRect { /// Creates a rectangular sequence of `vertexCount `points denoting a /// rectangular path. /// - note This is helpful for animating a `Path` composed of `vertexCount` /// points into a ``Rectangle``. func pointSequence(of vertexCount: Int) -> [CGPoint] { // Start at a random corner. When many Polygons are using this // animation at once, if they all start at the same corner, an // unnatural uniformity of motion emerges. var startingPercent = [0, 0.25, 0.5, 0.75].randomElement()! var points: [CGPoint] = [] let extraPoints = vertexCount - 4 let (groups, remainder) = extraPoints .quotientAndRemainder(dividingBy: 3) for edge in 0...3 { points.append(pointAlongPerimeter(at: startingPercent)) for i in (0..<(edge == 3 ? remainder : groups)) { points.append(pointAlongPerimeter( at: startingPercent + 0.25 / CGFloat(groups + 1) * CGFloat(i))) } startingPercent += 0.25 startingPercent.formTruncatingRemainder(dividingBy: 1) } assert(points.count == vertexCount) return points } /// Returns the ``CGPoint`` that is `percent` along the path of `self`, /// with 0% mapping to the top-left corner, progressing clockwise. /// E.g. 50% would map to the bottom right corner if and only if `self` is /// a square. /// - Parameters: /// - percent: A percentage between `0.0` and `1.0` private func pointAlongPerimeter(at percent: CGFloat) -> CGPoint { let perimeter = size.width * 2 + size.height * 2 // Mark the four corners as percentages around the rect. For example, /// these values for a square would be 25%, 50%, 75%, 100% let topRight = size.width / perimeter let bottomRight = topRight + (size.height / perimeter) let bottomLeft = bottomRight + (size.width / perimeter) let topLeft = 1.0 switch percent { case 0..<topRight: return CGPoint( x: percent / topRight * size.width, y: minY) case topRight..<bottomRight: return CGPoint( x: maxX, y: (percent - topRight) / (bottomRight - topRight) * size.height) case bottomRight..<bottomLeft: return CGPoint( x: maxX - ((percent - bottomRight) / (bottomLeft - bottomRight) * size.width), y: maxY) case bottomLeft...topLeft: return CGPoint( x: minX, y: maxY - (percent - bottomLeft) / (topLeft - bottomLeft) * size.height ) default: preconditionFailure("Invalid percentage requested") } } } /// Returns a new `CGRect` with the same size as `self`, but centered in `other` /// vertically, and horizontally. extension CGSize { func centered(in other: CGRect) -> CGRect { CGRect(x: other.midX - width / 2.0, y: other.midY - height / 2.0, width: width, height: height) } } extension Path { /// Convenience for initializing a `Path` from an array of `CGPoint`s given /// the first point element is the `Path`'s first point. init(from points: [CGPoint]) { self.init() self.addLines(points) self.closeSubpath() } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。