大多数浏览器和
Developer App 均支持流媒体播放。
-
滚动视图进阶
了解如何使用 SwiftUI 中的最新 API 将滚动视图提升到新的水平。我们将向你展示如何以前所未有的方式自定义滚动视图。探索安全区域和滚动视图边距之间的关系,了解如何与滚动视图的内容偏移量进行交互,并了解如何通过滚动过渡为内容增添一些独特风格。
章节
- 0:00 - Introduction to scroll views
- 2:01 - Margins and safe area
- 4:14 - Targets and positions
- 11:33 - Scroll transitions
资源
相关视频
WWDC23
-
下载
♪ ♪
Harry:大家好 我叫 Harry 是 SwiftUI 团队的一名工程师 欢迎来到讲座“滚动视图进阶” 我将介绍 SwiftUI 中 滚动视图的一些新改进 我们的设备所要处理的内容 很少能够固定于某个屏幕尺寸 为了处理这种复杂性 设备引入了滚动功能 这使得设备能够展示所有内容 即使它们无法在屏幕上完整显示 SwiftUI 提供了几个不同的组件 让你可以将滚动功能 集成到你的 App 中 今天 我将讲解其中一个组件: 滚动视图 滚动视图是一个构建块 可以让你的内容滚动 滚动视图有定义其 可滚动方向的轴 滚动视图有内容 当内容超出滚动视图的尺寸时 部分内容将被裁剪 用户需要滚动来展示它们 滚动视图通过将安全区域解析为边距 来确保内容位于安全区域内 滚动视图默认会立即加载全部内容 你可以使用 lazy stack 来更改这种行为
滚动视图内容在内容中的 确切滚动位置称为内容偏移量 SwiftUI 提供了 ScrollViewReader API 来控制内容偏移量 今年 SwiftUI 还引入了更多方法 来影响和响应滚动视图 所管理的内容偏移量 在本讲座中 我将首先解释 如何影响滚动视图的边距 以及其与安全区域的关系 然后 我将介绍 通过滚动目标和滚动位置 来管理滚动视图的内容偏移量 最后 我将展示如何通过滚动过渡 为你的 App 添加一些真实的效果 自从我开始制作 我的 Colors App 以来 用户们非常喜欢 向我展示他们喜欢的颜色组合 我希望能够展示其中一些组合 让其他用户也能享受到 为此 我一直在努力为 Colors App 添加一个画廊功能 我已经在实现画廊功能方面 取得了一些进展 在本次讲座中 我将会优化画廊的 展示区域的标题和内容
在我的画廊中 我使用水平的 ScrollView 包装了一个 lazy stack 首先 我将通过添加一些边距 使这个视图看起来更好一些 你的第一反应可能是给 滚动视图添加一些内边距 这将使滚动视图内部产生一个缩进 但请注意 现在在滚动时 其内容被裁剪了 我不想给滚动视图本身添加内边距 而是希望 扩展滚动视图的内容外边距 我可以使用新的 safeAreaPadding 修饰符来实现这一点 它的行为类似于 普通的 padding 修饰符 但不是给内容添加内边距 而是将内边距添加到安全区域 现在滚动视图的宽度被扩展 使得 接下来的内容可部分显示在屏幕上 在继续之前 我想简单谈谈与滚动视图 相关的安全区域 安全区域通常来自 你的 App 所运行的设备 它们也可以来自像 safeAreaPadding 或 safe area inset 修饰符 这样的 API 滚动视图将安全区域 解析为应用于其内容的边距 这包括你负责的内容 以及滚动视图负责的其他内容 如滚动指示器 这意味着通过修改安全区域 无法为不同类型的 内容配置不同的缩进
如果你想应用不同的缩进 你可以使用新的 contentMargins API 此 API 允许你将 缩进滚动视图的内容 与缩进滚动指示器分离开 或者将缩进指示器 与缩进内容分离开 回到我的画廊 我将把 safeAreaPadding 修饰符 换成 content margin API 现在我的视图已经应用了一些边距 但我希望控制在用户松开手指后 滚动视图将滚动到哪里
默认情况下 滚动视图使用标准减速率 以及滚动的速度 来计算滚动应该在哪里结束 它并没有考虑滚动视图本身 或其内容的大小 但有时这些因素是很重要的 在 SwiftUI 中 你可以使用 scrollTargetBehavior 修饰符 来更改滚动视图 计算目标内容偏移量的方式 此修饰符接受遵循 scrollTargetBehavior 协议的类型 在这里 我指定了 paging 行为 现在我的滚动视图每次滑动一页 paging 行为很特殊 它有自定义的减速率 并根据滚动视图本身的尺寸 来选择滚动的位置 这种滚动行为 在 iOS 设备上效果良好 但在较大的 iPadOS 屏幕上 可能显得过于夸张或不够合适 与其将滚动视图的对齐方式 与整个容器的尺寸相关联 我更希望将其对齐到 各个单独的视图上
viewAligned 行为会将 滚动视图对齐到视图 因此滚动视图 需要知道哪些视图应该用于对齐 这些视图称为滚动目标 有一组新的修饰符 可以让我指定哪些视图是滚动目标 在这里 我将使用 scrollTargetLayout 修饰符 使 lazy stack 中的 每个主视图都被视为滚动目标 你也可以使用 scroll target 修饰符 来标记单个视图作为目标 使用 lazy stack 时 重要的是要使用 scrollTargetLayout 修饰符 可见区域之外的视图尚未创建 但是布局系统已经知道 将会创建哪些视图 所以能够确保 滚动视图滚动到正确的位置
现在我的滚动视图 在 iPad 上看起来好多了 paging 和 viewAligned 行为 是基于新的 ScrollTargetBehavior 协议构建的 SwiftUI 为你提供了这些常见的行为 同时还允许你遵守此协议 并实现自己的自定义行为 就像你之前采用了 介绍过的布局协议一样 通过实现一个 必需的方法 updateTarget 使自定义类型符合 ScrollTargetBehavior 协议 SwiftUI 在计算 滚动结束的位置时调用此方法 而且也在其他环境中调用 比如滚动视图的大小发生变化时 自定义行为很容易 在这里 如果滚动视图的 目标偏移量靠近顶部 并且滚动是向上快速滑动的 那么我希望 将滚动视图正好滚动到顶部 这可以通过修改 提供的目标偏移量来实现 这将导致滚动视图 选择不同的内容偏移量 作为滚动的终点 通过这种方式 我可以插入自定义代码 影响滚动视图在哪里选择滚动
让我们回到我的画廊视图 我想谈谈布局 注意我的主视图是相对于 设备的整体宽度大小进行调整的 如果我们看一下 iPad 两个视图 可以均匀地适应设备的宽度 之前你可能需要使用 GeometryReader 来实现这一点 但今年 SwiftUI 使这一操作更为简便 它推出了一个新的 API 称为 containerRelativeFrame 修饰符
我会向你展示 我的主视图如何使用这个 API 首先 我从一组颜色视图 开始 并使用 frame 修饰符 指定了一个固定的高度 然后 我将 containerRelativeFrame 修饰符添加到我的视图中 在这里 我指定了水平轴 让视图的宽度与其容器相同 在我的案例中 容器是两侧的滚动视图 但它也可能是 导航分割视图中最近的列 或者你的 App 窗口 当容器的宽度发生变化时 视图的大小会自动更新 我可以通过提供数量和间距 来创建这些视图的网格式布局 并根据 horizontalSizeClass 条件化计数 使得视图在 iPad 上有两列 在手机上有一列 更好的是 由于 horizontalSizeClass 环境属性 现在适用于所有平台 我可以去掉 OS 条件 最后 我将使用 aspectRatio 修饰符 使高度和宽度成比例变化 而不是硬编码为固定高度 现在我已经完成了 画廊的布局和滚动行为 接下来我想做一些更改 你会注意到的 一个问题是滚动指示器 我想把它们去掉
我可以使用现有的 scrollIndicators API 来实现这一点 当我在 iPad 上 用手指滑动时 效果看起来很棒 但是我通常在 Mac 上使用画廊 在 Mac 上 如果使用鼠标或其他输入设备 我可能无法 轻松地进行水平轻扫手势 当我连接鼠标时 指示器是可见的 尽管我要求它们隐藏 没有滚动指示器 使用鼠标可能会很难或无法滚动 因此 scrollIndicators 修饰符的默认行为 是在使用更加灵活的输入设备 例如触摸板时 隐藏滚动指示器 但当连接鼠标时 允许滚动指示器显示 你可以为 scrollIndicators 修饰符提供 never 的值 这样无论输入设备如何 都始终隐藏指示器 但我的 App 仍然需要 支持使用鼠标的用户 所以我需要为他们提供 另一种滚动画廊的方式 我将使用一些视图 来代替滚动指示器 允许用户通过点击 来滚动到上一个或下一个视图 要开始构建这个功能 让我们稍微整理一下我的滚动视图 我将把滚动视图移到 一个包含顶部视图的 VStack 中
现在我会专注于顶部视图
我会将一些自定义的 箭头按钮视图添加到顶部视图中 在之前的 SwiftUI 版本中 我可能会使用 ScrollViewReader 来将其 传递给我的箭头按钮 并滚动到适当的视图 SwiftUI 中的新功能是 ScrollPosition 修饰符 这个修饰符将一个 绑定与封装标识符的状态关联起来 我将把这个绑定 传递给我的 scrollPosition 修饰符 然后滚动视图将从中读取 并应用到我的顶部视图 在头部视图的滚动手柄中 我可以像处理 任何其他状态一样写入绑定 当绑定被写入时 滚动视图将滚动到 具有该标识符的视图 就像视图对齐的 ScrollTargetBehavior 一样 ScrollPosition 修饰符 使用 scrollTargetLayout 修饰符 来确定要查询其标识值的视图
ScrollPosition 修饰符还允许我了解 当前滚动视图的标识 因此 我可以在 头部视图中添加一些文本 显示当前滚动的主图像的值 当滚动视图中 最前面的视图发生更改时 绑定将自动更新 现在使用鼠标的用户也 可以滚动我的画廊了 我想为此视图添加最后一点装饰 了解当前滚动到的视图很有用 同样地 有时我想根据视图在 滚动视图中的位置 对其进行视觉上的修改 SwiftUI 中的新 API ScrollTransitions 可以轻松实现这一点 ScrollTransition 很像普通的过渡 过渡描述了视图在出现或消失时 应该经历的变化 当视图出现时 它处于标识阶段 不该应用任何自定义 ScrollTransition 描述了 一组类似于过渡的更改 但是在视图进入滚动视图的 可见区域时 以及之后离开时 应用这些更改
默认情况下 当视图位于可见区域的中心时 它处于 ScrollTransition 的标识阶段 让我们在主视图中查看这一点 我将稍微简化这个示例 以便专注于 ScrollTransitions
当视图接近滚动视图的边缘时 我希望其尺寸缩小一些 我将首先添加 ScrollTransition 修饰符 此 API 接受内容和一个阶段 并允许你 根据阶段指定内容的视觉更改 此时我将在视图不处于其标识阶段时 指定一个缩小的比例
看起来很不错! ScrollTransitions 使用 SwiftUI 中的新协议 VisualEffect 该协议为视图内容 提供了一组安全使用的自定义功能 这些功能可以作为布局的函数 比如滚动视图的内容偏移量 其中许多可能对你来说很熟悉 你已经了解了 scaleEffect 你还可以像使用视图修饰符一样 自定义旋转或偏移等效果 但并非所有视图修饰符 都可在 ScrollTransition 中安全使用 例如 自定义字体 不受支持并且无法构建 在 ScrollTransition 修饰符中 不能使用会改变 滚动视图整体内容大小的任何内容 哇 我们介绍了很多内容 让我们简单复习一下
我们讨论了安全区域 和内容边距之间的区别 及与滚动视图的关系 我向你展示了如何使用 paging 和 viewAligned 的 ScrollTargetBehaviors 来影响滚动视图的行为 以及如何编写符合 ScrollTargetBehavior 协议的自定义实现 你学会了使用 containerRelativeFrame 修饰符 来相对于容器更轻松地创建布局 我使用 ScrollPosition 修饰符连接到滚动视图的状态 允许我以编程方式滚动 并获知当前滚动的视图是哪个 最后 我使用 ScrollTransition API 基于滚动视图的内容偏移量 创建了视觉效果 希望你享受学习有关 滚动视图的这些优化 谢谢 祝你度过愉快的 WWDC ♪ ♪
-
-
0:46 - ScrollView
struct Item: Identifiable { var id: Int } struct ContentView: View { @State var items: [Item] = (0 ..< 25).map { Item(id: $0) } var body: some View { ScrollView(.vertical) { LazyVStack { ForEach(items) { item in ItemView(item: item) } } } } } struct ItemView: View { var item: Item var body: some View { Text(item, format: .number) .padding(.vertical) .frame(maxWidth: .infinity) } }
-
2:29 - Basic Featured Section
struct ContentView: View { @State var palettes: [Palette] = [ .init(id: UUID(), name: "Example One"), .init(id: UUID(), name: "Example Two"), .init(id: UUID(), name: "Example Three"), ] var body: some View { ScrollView { GalleryHeroSection(palettes: palettes) } } } struct Palette: Identifiable { var id: UUID var name: String } struct GalleryHeroSection: View { var palettes: [Palette] var body: some View { GallerySection(edge: .top) { GalleryHeroContent(palettes: palettes) } label: { GalleryHeroHeader(palettes: palettes) } } } struct GallerySection<Content: View, Label: View>: View { var edge: Edge? = nil @ViewBuilder var content: Content @ViewBuilder var label: Label var body: some View { VStack(alignment: .leading) { label .font(.title2.bold()) content } .padding(.top, halfSpacing) .padding(.bottom, sectionSpacing) .overlay(alignment: .bottom) { if edge != .bottom { Divider().padding(.horizontal, hMargin) } } } var halfSpacing: CGFloat { sectionSpacing / 2.0 } var sectionSpacing: CGFloat { 20.0 } var hMargin: CGFloat { #if os(macOS) 40.0 #else 20.0 #endif } } struct GalleryHeroContent: View { var palettes: [Palette] var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: hSpacing) { ForEach(palettes) { palette in GalleryHeroView(palette: palette) } } } } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroView: View { var palette: Palette @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { colorStack .aspectRatio(heroRatio, contentMode: .fit) .containerRelativeFrame( [.horizontal], count: columns, spacing: hSpacing ) .clipShape(.rect(cornerRadius: 20.0)) } private var columns: Int { sizeClass == .compact ? 1 : regularCount } @ViewBuilder private var colorStack: some View { let offsetValue = stackPadding ZStack { Color.red .offset(x: offsetValue, y: offsetValue) Color.blue Color.green .offset(x: -offsetValue, y: -offsetValue) } .padding(stackPadding) .background() } var stackPadding: CGFloat { 20.0 } var heroRatio: CGFloat { 16.0 / 9.0 } var regularCount: Int { 2 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroHeader: View { var palettes: [Palette] var body: some View { Text("Featured") .padding(.horizontal, hMargin) } var hMargin: CGFloat { 20.0 } }
-
4:00 - Featured Section with Margins
struct ContentView: View { @State var palettes: [Palette] = [ .init(id: UUID(), name: "Example One"), .init(id: UUID(), name: "Example Two"), .init(id: UUID(), name: "Example Three"), ] var body: some View { ScrollView { GalleryHeroSection(palettes: palettes) } } } struct Palette: Identifiable { var id: UUID var name: String } struct GalleryHeroSection: View { var palettes: [Palette] var body: some View { GallerySection(edge: .top) { GalleryHeroContent(palettes: palettes) } label: { GalleryHeroHeader(palettes: palettes) } } } struct GallerySection<Content: View, Label: View>: View { var edge: Edge? = nil @ViewBuilder var content: Content @ViewBuilder var label: Label var body: some View { VStack(alignment: .leading) { label .font(.title2.bold()) content } .padding(.top, halfSpacing) .padding(.bottom, sectionSpacing) .overlay(alignment: .bottom) { if edge != .bottom { Divider().padding(.horizontal, hMargin) } } } var halfSpacing: CGFloat { sectionSpacing / 2.0 } var sectionSpacing: CGFloat { 20.0 } var hMargin: CGFloat { #if os(macOS) 40.0 #else 20.0 #endif } } struct GalleryHeroContent: View { var palettes: [Palette] var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: hSpacing) { ForEach(palettes) { palette in GalleryHeroView(palette: palette) } } } .contentMargins(.horizontal, hMargin) } var hMargin: CGFloat { 20.0 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroView: View { var palette: Palette @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { colorStack .aspectRatio(heroRatio, contentMode: .fit) .containerRelativeFrame( [.horizontal], count: columns, spacing: hSpacing ) .clipShape(.rect(cornerRadius: 20.0)) } private var columns: Int { sizeClass == .compact ? 1 : regularCount } @ViewBuilder private var colorStack: some View { let offsetValue = stackPadding ZStack { Color.red .offset(x: offsetValue, y: offsetValue) Color.blue Color.green .offset(x: -offsetValue, y: -offsetValue) } .padding(stackPadding) .background() } var stackPadding: CGFloat { 20.0 } var heroRatio: CGFloat { 16.0 / 9.0 } var regularCount: Int { 2 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroHeader: View { var palettes: [Palette] var body: some View { Text("Featured") .padding(.horizontal, hMargin) } var hMargin: CGFloat { 20.0 } }
-
7:42 - Featured Section + Container Relative Frame
struct ContentView: View { @State var palettes: [Palette] = [ .init(id: UUID(), name: "Example One"), .init(id: UUID(), name: "Example Two"), .init(id: UUID(), name: "Example Three"), ] var body: some View { ScrollView { GalleryHeroSection(palettes: palettes) } } } struct Palette: Identifiable { var id: UUID var name: String } struct GalleryHeroSection: View { var palettes: [Palette] var body: some View { GallerySection(edge: .top) { GalleryHeroContent(palettes: palettes) } label: { GalleryHeroHeader(palettes: palettes) } } } struct GallerySection<Content: View, Label: View>: View { var edge: Edge? = nil @ViewBuilder var content: Content @ViewBuilder var label: Label var body: some View { VStack(alignment: .leading) { label .font(.title2.bold()) content } .padding(.top, halfSpacing) .padding(.bottom, sectionSpacing) .overlay(alignment: .bottom) { if edge != .bottom { Divider().padding(.horizontal, hMargin) } } } var halfSpacing: CGFloat { sectionSpacing / 2.0 } var sectionSpacing: CGFloat { 20.0 } var hMargin: CGFloat { #if os(macOS) 40.0 #else 20.0 #endif } } struct GalleryHeroContent: View { var palettes: [Palette] var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: hSpacing) { ForEach(palettes) { palette in GalleryHeroView(palette: palette) } } .scrollTargetLayout() } .contentMargins(.horizontal, hMargin) .scrollTargetBehavior(.viewAligned) } var hMargin: CGFloat { 20.0 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroView: View { var palette: Palette @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { colorStack .aspectRatio(heroRatio, contentMode: .fit) .containerRelativeFrame( [.horizontal], count: columns, spacing: hSpacing ) .clipShape(.rect(cornerRadius: 20.0)) } private var columns: Int { sizeClass == .compact ? 1 : regularCount } @ViewBuilder private var colorStack: some View { let offsetValue = stackPadding ZStack { Color.red .offset(x: offsetValue, y: offsetValue) Color.blue Color.green .offset(x: -offsetValue, y: -offsetValue) } .padding(stackPadding) .background() } var stackPadding: CGFloat { 20.0 } var heroRatio: CGFloat { 16.0 / 9.0 } var regularCount: Int { 2 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroHeader: View { var palettes: [Palette] var body: some View { Text("Featured") .padding(.horizontal, hMargin) } var hMargin: CGFloat { 20.0 } }
-
9:46 - Featured Section + Scroll Position
struct ContentView: View { @State var palettes: [Palette] = [ .init(id: UUID(), name: "Example One"), .init(id: UUID(), name: "Example Two"), .init(id: UUID(), name: "Example Three"), ] var body: some View { ScrollView { GalleryHeroSection(palettes: palettes) } } } struct Palette: Identifiable { var id: UUID var name: String } struct GalleryHeroSection: View { var palettes: [Palette] @State var mainID: Palette.ID? = nil var body: some View { GallerySection(edge: .top) { GalleryHeroContent(palettes: palettes, mainID: $mainID) } label: { GalleryHeroHeader(palettes: palettes, mainID: $mainID) } } } struct GallerySection<Content: View, Label: View>: View { var edge: Edge? = nil @ViewBuilder var content: Content @ViewBuilder var label: Label var body: some View { VStack(alignment: .leading) { label .font(.title2.bold()) content } .padding(.top, halfSpacing) .padding(.bottom, sectionSpacing) .overlay(alignment: .bottom) { if edge != .bottom { Divider().padding(.horizontal, hMargin) } } } var halfSpacing: CGFloat { sectionSpacing / 2.0 } var sectionSpacing: CGFloat { 20.0 } var hMargin: CGFloat { #if os(macOS) 40.0 #else 20.0 #endif } } struct GalleryHeroContent: View { var palettes: [Palette] @Binding var mainID: Palette.ID? var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: hSpacing) { ForEach(palettes) { palette in GalleryHeroView(palette: palette) } } .scrollTargetLayout() } .contentMargins(.horizontal, hMargin) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $mainID) .scrollIndicators(.never) } var hMargin: CGFloat { 20.0 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroView: View { var palette: Palette @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { colorStack .aspectRatio(heroRatio, contentMode: .fit) .containerRelativeFrame( [.horizontal], count: columns, spacing: hSpacing ) .clipShape(.rect(cornerRadius: 20.0)) } private var columns: Int { sizeClass == .compact ? 1 : regularCount } @ViewBuilder private var colorStack: some View { let offsetValue = stackPadding ZStack { Color.red .offset(x: offsetValue, y: offsetValue) Color.blue Color.green .offset(x: -offsetValue, y: -offsetValue) } .padding(stackPadding) .background() } var stackPadding: CGFloat { 20.0 } var heroRatio: CGFloat { 16.0 / 9.0 } var regularCount: Int { 2 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroHeader: View { var palettes: [Palette] @Binding var mainID: Palette.ID? var body: some View { VStack(alignment: .leading, spacing: 2.0) { Text("Featured") Spacer().frame(maxWidth: .infinity) } .padding(.horizontal, hMargin) #if os(macOS) .overlay { HStack(spacing: 0.0) { GalleryPaddle(edge: .leading) { scrollToPreviousID() } Spacer().frame(maxWidth: .infinity) GalleryPaddle(edge: .trailing) { scrollToNextID() } } } #endif } private func scrollToNextID() { guard let id = mainID, id != palettes.last?.id, let index = palettes.firstIndex(where: { $0.id == id }) else { return } withAnimation { mainID = palettes[index + 1].id } } private func scrollToPreviousID() { guard let id = mainID, id != palettes.first?.id, let index = palettes.firstIndex(where: { $0.id == id }) else { return } withAnimation { mainID = palettes[index - 1].id } } var hMargin: CGFloat { 20.0 } } struct GalleryPaddle: View { var edge: HorizontalEdge var action: () -> Void var body: some View { Button { action() } label: { Label(labelText, systemImage: labelIcon) } .buttonStyle(.paddle) .font(nil) } var labelText: String { switch edge { case .leading: return "Backwards" case .trailing: return "Forwards" } } var labelIcon: String { switch edge { case .leading: return "chevron.backward" case .trailing: return "chevron.forward" } } } private struct PaddleButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .imageScale(.large) .labelStyle(.iconOnly) } } extension ButtonStyle where Self == PaddleButtonStyle { static var paddle: Self { .init() } }
-
12:34 - Featured Section + Scroll Transition
struct ContentView: View { @State var palettes: [Palette] = [ .init(id: UUID(), name: "Example One"), .init(id: UUID(), name: "Example Two"), .init(id: UUID(), name: "Example Three"), ] var body: some View { ScrollView { GalleryHeroSection(palettes: palettes) } } } struct Palette: Identifiable { var id: UUID var name: String } struct GalleryHeroSection: View { var palettes: [Palette] @State var mainID: Palette.ID? = nil var body: some View { GallerySection(edge: .top) { GalleryHeroContent(palettes: palettes, mainID: $mainID) } label: { GalleryHeroHeader(palettes: palettes, mainID: $mainID) } } } struct GallerySection<Content: View, Label: View>: View { var edge: Edge? = nil @ViewBuilder var content: Content @ViewBuilder var label: Label var body: some View { VStack(alignment: .leading) { label .font(.title2.bold()) content } .padding(.top, halfSpacing) .padding(.bottom, sectionSpacing) .overlay(alignment: .bottom) { if edge != .bottom { Divider().padding(.horizontal, hMargin) } } } var halfSpacing: CGFloat { sectionSpacing / 2.0 } var sectionSpacing: CGFloat { 20.0 } var hMargin: CGFloat { #if os(macOS) 40.0 #else 20.0 #endif } } struct GalleryHeroContent: View { var palettes: [Palette] @Binding var mainID: Palette.ID? var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: hSpacing) { ForEach(palettes) { palette in GalleryHeroView(palette: palette) } } .scrollTargetLayout() } .contentMargins(.horizontal, hMargin) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $mainID) .scrollIndicators(.never) } var hMargin: CGFloat { 20.0 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroView: View { var palette: Palette @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { colorStack .aspectRatio(heroRatio, contentMode: .fit) .containerRelativeFrame( [.horizontal], count: columns, spacing: hSpacing ) .clipShape(.rect(cornerRadius: 20.0)) .scrollTransition(axis: .horizontal) { content, phase in content .scaleEffect( x: phase.isIdentity ? 1.0 : 0.80, y: phase.isIdentity ? 1.0 : 0.80) } } private var columns: Int { sizeClass == .compact ? 1 : regularCount } @ViewBuilder private var colorStack: some View { let offsetValue = stackPadding ZStack { Color.red .offset(x: offsetValue, y: offsetValue) Color.blue Color.green .offset(x: -offsetValue, y: -offsetValue) } .padding(stackPadding) .background() } var stackPadding: CGFloat { 20.0 } var heroRatio: CGFloat { 16.0 / 9.0 } var regularCount: Int { 2 } var hSpacing: CGFloat { 10.0 } } struct GalleryHeroHeader: View { var palettes: [Palette] @Binding var mainID: Palette.ID? var body: some View { VStack(alignment: .leading, spacing: 2.0) { Text("Featured") Spacer().frame(maxWidth: .infinity) } .padding(.horizontal, hMargin) #if os(macOS) .overlay { HStack(spacing: 0.0) { GalleryPaddle(edge: .leading) { scrollToPreviousID() } Spacer().frame(maxWidth: .infinity) GalleryPaddle(edge: .trailing) { scrollToNextID() } } } #endif } private func scrollToNextID() { guard let id = mainID, id != palettes.last?.id, let index = palettes.firstIndex(where: { $0.id == id }) else { return } withAnimation { mainID = palettes[index + 1].id } } private func scrollToPreviousID() { guard let id = mainID, id != palettes.first?.id, let index = palettes.firstIndex(where: { $0.id == id }) else { return } withAnimation { mainID = palettes[index - 1].id } } var hMargin: CGFloat { 20.0 } } struct GalleryPaddle: View { var edge: HorizontalEdge var action: () -> Void var body: some View { Button { action() } label: { Label(labelText, systemImage: labelIcon) } .buttonStyle(.paddle) .font(nil) } var labelText: String { switch edge { case .leading: return "Backwards" case .trailing: return "Forwards" } } var labelIcon: String { switch edge { case .leading: return "chevron.backward" case .trailing: return "chevron.forward" } } } private struct PaddleButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .imageScale(.large) .labelStyle(.iconOnly) } } extension ButtonStyle where Self == PaddleButtonStyle { static var paddle: Self { .init() } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。