本文首发于 WWDC23 10159 – Beyond scroll views
摘要:学习怎么运用 SwiftUI 的最新 API,将翻滚视图提升到一个全新的水平。本文会展现一些史无前例的自定义翻滚视图的办法,包括怎么处理安全区域和翻滚视图边距之间的联系、怎么与翻滚视图的内容偏移进行交互、怎么经过翻滚来为内容增加炫酷的效果。
为了在有限的设备屏幕上展现完好的运用功用,咱们通常需求依赖翻滚控件。而 SwiftUI 经过供给多种组件(如 List、Table、ScrollView)来轻松完成翻滚嵌入运用程序中。本文将要点介绍 ScrollView 组件,使咱们能够愈加深入地了解其特性和运用场景。 咱们将涉及以下内容:
ScrollView 根本介绍
Lazy Stack
首要,咱们来了解 ScrollView 的运用,它是一个能够让其包括的内容进行翻滚的构建块,参数包括:
- axes:翻滚方向;
- content:翻滚内容;当内容超出 ScrollView 的巨细时,其中部分内容将被裁剪,需求向上或向下滑动才能检查。ScrollView 可确保内容放置在安全区域内,经过将安全区域解析为边距来设置其内容。
其中,content 的加载逻辑如下: 默许情况下,ScrollView 会立即加载并取得其一切子 view 的信息,咱们能够运用 lazy stack 来按需加载和烘托其子视图,从而在加载大量子 view 时供给明显的功用提升。 如图,例如运用 VStack 时,操控台一次把全部的 Item init 打印出来。而运用 LazyVStack 时,操控台先是打印了第一屏呈现的 Item init,持续滑动时再打印屏幕显现到的 Item。阐明了 lazy stack 是按需初始化子视图的,从而对功用提升优化。(此特性从 WWDC20 提出的,具体能够了解官方的比照阐明文档。) VStack: LazyVStack:
Content Offset
ScrollView 翻滚到的切当方位称为内容偏移量(Content Offset)。SwiftUI 现已供给了 ScrollViewReader 作为操控内容偏移量的办法。本年,SwiftUI 还将推出更多办法来影响和响应由 ScrollView 办理的内容偏移量。 咱们将分为以下三点来叙述:
- 边距和安全区域:怎么影响边距,处理边距与安全区域的联系;
- 方针和方位:经过翻滚方针和翻滚方位,来办理 contentOffset;
- 翻滚过渡:经过翻滚转换,为运用程序增加一些真正的魅力。
一、边距和安全区域
本文的首要范例运用 Session 供给的示例代码。这是一个能够水平滑动的 header,咱们来一步步完善它的效果。 开始时,它由一个水平的 ScrollView 包括一个 lazy stack。 首要,咱们来增加一些边距来使该视图更美观。
1. padding
有人或许第一时间会想到在 ScrollView 上增加填充修饰符 padding。可是请留意,这样会导致翻滚时依然看到左右边距,内容没有充满整个宽度。
2. safeAreaPadding
如上图的裁剪问题,一般来说咱们不想缩进 ScrollView 自身宽度,而是想扩展 ScrollView 的内容边距。咱们能够运用新的安全区域填充修饰符 safeAreaPadding 来完成这一点。 safeAreaPadding 相似于 padding 修饰符,但它不是填充内容,而是将填充增加到安全区域。 现在如图 ScrollView 没有被裁剪,而是扩展到了整个宽度(你能够经过翻滚时的左边际看到比照上面 padding 效果图的差异)。 而且它使下一个翻滚项在屏幕的右边际显现了出来,全体效果比 padding 很多啦。
3. contentMargins
依据上面两个修饰符的体会,咱们了解了 填充 和 安全区域填充 的差异。咱们接着看安全区域与 ScrollView 的联系。 安全区域 通常由运用程序运转的设备决议,也能够来自 API,例如上面的安全区域填充 safeAreaPadding 或安全区域刺进 safeAreaInset 修饰符。ScrollView 会将安全区域解析为运用于其内容的边距,包括: ① 咱们增加的内容; ② ScrollView 担任的其他内容,例如翻滚指示器。 可是这两个 API 的运用都无法为不同类型的内容配置灵敏的内嵌布局,如以下代码,可见它们的参数不支持 for 咱们说到的不同内容。
// 1
.safeAreaPadding(.vertical, 50.0)
// 2
.safeAreaInset(edge: .top) {
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
.padding(.horizontal, 10)
.frame(height: 50)
}
新的 contentMargins 答应咱们给 ScrollView 的内容和翻滚指示器别离刺进边距。 留意检查内容和指示器的方位,别离对应了:① 给全体顶部加 50 边距;② 给翻滚的内容顶部加 50 边距;③ 给翻滚指示器顶部加 50 边距。 最终,回到咱们比方中,运用 contentMargin 替代 safeAreaPadding:
4. scrollClipDisabled
在持续之前,还有一个边距相关有意思的 API 了解一下。 默许情况下,ScrollView 会将其内容裁剪到其鸿沟。如图,暗影会被裁剪: 咱们能够运用 scrollClipDisabled 修饰符来禁用这种行为,从而防止暗影被裁剪。把参数设置为 true 即可,参数 false 则是上图默许效果。 这儿运用了简单的暗影效果,你还能够运用 clipShape(_:style:) 修饰符创立自定义剪切形状,依然有很好的处理效果。例如: 最终,咱们看一下它是否会触发离屏烘托。如下图所示:① 本来视图不会触发离屏烘托;② 运用了 scrollClipDisabled 也没有触发离屏烘托;③ 为了比照阐明的确开了 Color Off-screen Rendered 选项,能够看到 blur(radius:) 触发了离屏烘托。(假如想更多的了解离屏烘托,能够阅读文章。)
二、方针和方位
咱们现已对视图进行了一些边距调整,接下来咱们来看看操控 ScrollView 在松开手指后翻滚停留的方位。默许情况下, ScrollView 会运用规范的减速率和翻滚速度来核算应该中止的方针内容偏移量(contentOffset),可是它不考虑 ScrollView 巨细或其内容等要素,有时分这些要素非常重要。
1. scrollTargetBehavior
在 SwiftUI 中,咱们能够运用 scrollTargetBehavior 修饰符来改动 ScrollView 核算 contentOffset 的办法,它包括一个参数:
- behavior:一个遵循 ScrollTargetBehavior 协议的类型。
咱们来看看两个现有的翻滚方针行为。 1. paging 如下图,咱们指定了分页行为 paging,现在 ScrollView 每次只滑动一页。这种分页行为是特殊的,它具有自定义的减速率并依据 ScrollView 自身的容器巨细挑选翻滚方位。 现在,在 iOS 上效果很好,但在 iPadOS 的大屏幕上或许会有一些问题: 咱们按照 paging 翻滚的办法呈现,每一页 iPad 能够包容两个 Hero 视图,因而每次翻滚都是两个视图,1 3 5 一向在左侧,而 2 4 6 无法对齐到左侧。 2. viewAligned 咱们更期望将其对齐到单个视图,而不是对齐到 ScrollView 的容器巨细。 首要,viewAligned 对齐行为能够将 ScrollView 对齐到视图上。因而 ScrollView 需求知道哪些视图应该被考虑对齐,这些视图被称为翻滚方针。 然后,scrollTargetLayout 修饰符能够指定哪些视图成为翻滚方针。 在如下比方中,咱们指定对齐行为为 viewAligned ;运用 scrollTargetLayout 让 lazy stack 中的每个 Hero 视图都被视为翻滚方针(也能够将单个视图标记为方针)。(当运用 lazy stack 时,运用 scrollTargetLayout 非常重要,即便可见区域之外的视图尚未创立,布局也知道将要创立哪些视图,因而它能够确保 ScrollView 翻滚到正确的方位)。 如图,现在每次翻滚一个 Hero 视图,2 4 6 也能够主动对齐到左侧方位了。这样它在 iPad 上交互起来很多了。
ScrollTargetBehavior 协议
paging 和 viewAligned 的对齐行为是依据 ScrollTargetBehavior 协议构建的。SwiftUI 供给了这些常用行为,而且答应咱们遵循此协议并完成自己的自定义行为。只需求完成一个办法:
- updateTarget():更新方针。在核算翻滚应该完毕的方位时,SwiftUI 会调用此办法,并在其他情况下也会进行调用(比方当 ScrollView 的巨细发生改动时)。
例如以下代码,假如方针挨近 ScrollView 的顶部,且向上滑动了翻滚条,则会优先翻滚到 ScrollView 的精确顶部,从而修正供给的方针。这会决议 ScrollView 挑选不同的 contentOffset 作为翻滚的结尾。
struct GalleryScrollTargetBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
if target.rect.minY < (context.containerSize.height / 3.0),
context.velocity.dy < 0.0
{
target.rect.origin.y = 0.0
}
}
}
2. containerRelativeFrame
在前面的图中咱们有看到,本例的 Hero 视图在 iOS 竖屏上展现一个,而在 iPad 上是展现两个。要完成这一点,曾经或许需求运用 GeometryReader 读取 ScrollView 的宽度后核算。而现在,咱们能够运用 containerRelativeFrame 修饰符很便利的完成。咱们来体会下这个过程吧。 首要,咱们把 UI 简化为每个 Hero 是一个蓝色矩形。 把代码简化为只增加了一个固定高度的修饰符。它在设备上的宽度展现如下: 然后,咱们在视图中增加上 containerRelativeFrame 修饰符,并指定了参数为水平轴。它使得该视图只占用其容器的宽度,此时,视图巨细会主动习惯容器的宽度。 除了 axes 方向之外,containerRelativeFrame 还能够经过 count 和 spacing 两个参数创立相似网格布局的视图。 这儿咱们需求在 iOS 上展现一个,而在 iPad 上展现两个,咱们能够依据 horizontalSizeClass 判别个数。而且 horizontalSizeClass 现在适用于一切渠道,咱们不需求判别操作系统。 最终,咱们使高度与宽度成份额,去掉硬编码固定高度,运用 aspectRatio 修饰符来完成。效果展现如下: 至此,咱们的布局和翻滚行为都完成了!
3. scrollPosition
接下来,咱们来观察当 ScrollView 滑动的时分,它的翻滚指示器很明显不美观,咱们来把它移除掉。
scrollIndicators
咱们能够运用现有的 scrollIndicators 修饰符来完成移除翻滚指示器。如图,现在滑动时它现已躲藏了: 在 Mac 上这儿的 hidden 或许无效,翻滚指示器或许依然会显现。因为考虑到运用鼠标时,假如没有翻滚指示器或许会使翻滚变得困难或不或许。所以 scrollIndicators 的默许行为是在运用更灵敏的输入设备(如触摸板)时躲藏指示器,但在衔接鼠标时答应指示器显现(留意 Apple 妙控鼠标属于灵敏设备,试试其他鼠标或许不属于灵敏设备)。 咱们能够修正 scrollIndicators 的参数为 never ,这样就到达一向躲藏指示器,而不会考虑输入设备。如图:
scrollIndicatorsFlash
(1) 在持续完善之前,关于翻滚指示器,咱们再介绍一个简单的修饰符 scrollIndicatorsFlash(onAppear:),它用来操控当翻滚视图第一次呈现时,是否闪耀其翻滚指示器。经过将 onAppear 参数设置为 true 即可。如图 gif,请留意翻滚指示器的改动: (2) 假如想运用过程操控翻滚指示器的显现,能够设置某个值改动时闪耀翻滚指示器,scrollIndicatorsFlash(trigger:) 修饰符帮能咱们完成。 可是经测验,scrollIndicatorsFlash(onAppear:) 只能在笔直方向的视图有用,水平无效。scrollIndicatorsFlash(trigger:) 在笔直和水平方向都有用。
scrollPosition
现在 Mac 上的翻滚指示器躲藏了,咱们供给一个替代方案,运用户愈加便利直观的交互。例如增加两个按钮,让用户能够点击上一个或下一个来完成视图翻滚,UI 改动如下: 接下来,咱们看看怎么在点击这两个按钮时操控 ScrollView 滑动到适当方位。在 SwiftUI 的早期版本中,咱们能够运用 ScrollViewReader 来完成。简化代码如下:
@Binding var mainID: Palette.ID?
@State private var scrollOffset: CGFloat = 0
GeometryReader { geometryProxy in
VStack {
GalleryHeroHeader(palettes: palettes, mainID: $mainID)
ScrollViewReader { scrollProxy in
ScrollView(.horizontal) {
HStack(spacing: hSpacing) {
ForEach(palettes) { palette in
GalleryHeroView(palette: palette)
.id(palette.id) // 运用 id 属性标识视图
}
}
.background(
GeometryReader { proxy -> Color in
// 接纳 contentOffset 值
self.scrollOffset = proxy.frame(in: .global).minX
}
)
}
...
// 监听id改动: ①左右按钮点击时 ②手势滑动ScrollView中止时
.onChange(of: mainID) { _, _ in
withAnimation {
// 运用 id 翻滚到被标识的视图
scrollProxy.scrollTo(mainID, anchor: .leading)
}
}
.onChange(of: scrollOffset) { _, newValue in
// 中止翻滚时,依据 contentOffset 核算对应的 id
mainID = palettes[calIndex].id
}
}
}
}
// in GalleryHeroHeader
GalleryPaddle(edge: .leading) {
mainID = calPreviousID()
}
尽管现已简化了很多逻辑,代码仍是很复杂,而且要点还要处理两种改动 mainID 的办法冲突 Bug。 而在最新的 SwiftUI 中,新增了 scrollPosition 修饰符,它绑定了与包装标识符的状况,咱们能够将其传递给 ScrollView,然后 ScrollView 将从中读取并传递给 GalleryHeroHeader。在 GalleryHeroHeader 的 paddles 中,咱们能够在按钮点击时写入绑定。写入绑定后,ScrollView 会翻滚到具有该 mainID 的视图。而且直接滑动 ScrollView 时它会主动更新 mainID,没有两种办法的冲突问题。简化代码如下:
@Binding var mainID: Palette.ID?
VStack {
GalleryHeroHeader(palettes: palettes, mainID: $mainID)
ScrollView(.horizontal) { ... }
.scrollPosition(id: $mainID)
}
// in GalleryHeroHeader
GalleryPaddle(edge: .leading) {
mainID = calPreviousID()
}
如上核心代码真的很简单。scrollPosition 相似于视图对齐的 scrollTargetBehavior,都运用 scrollTargetLayout 来确定哪个视图要查找其标识值。 最终,scrollPosition 能够使咱们知道当时翻滚视图的身份。因而,咱们能够在标题视图中增加一个文本,显现当时翻滚的 Hero 信息,使翻滚效果愈加直观。 绑定会跟着 ScrollView 最左边的视图更改而主动更新。现在,咱们能够经过鼠标用户轻松浏览了。
三、翻滚过渡
最终咱们还有一个小细节需求完善,给翻滚的视图加一点过渡动画,使滑动时更明显的体会到当时翻滚的方针视图。咱们能够依据 ScrollView 中视图的方位来改动它。
scrollTransitions
在 SwiftUI 中,新增了 scrollTransitions 修饰符,能够轻松完成这个功用。 scrollTransition 很像普通的过渡效果,描绘了一个视图在呈现或消失时应该经历的改动:
- 当一个视图呈现后,它处于 identity 阶段,此时不该运用自定义设置;
- ScrollTransition 描绘的是与过渡效果相似的一组改动,将其作为视图进入 ScrollView 的可见区域然后脱离可见区域时运用。
让咱们在比方中体会下,当视图挨近 ScrollView 的边际时,把它的巨细能够略微缩小。 咱们增加 scrollTransition 修饰符,它需求 content 和 phase,使咱们能够依据 phase 指定内容的视觉改动。在这儿,咱们指定当视图不处于其 identity 时,缩小份额。 之前: 之后:
VisualEffect 协议
scrollTransitions 运用了一个名为 VisualEffect 的新协议。 VisualEffect 供给了一组用于视图内容的自定义选项,能够安全地作为布局函数运用。 例如上面动画运用的缩放 scaleEffect、ScrollView 的 contentOffset、旋转 rotationEffect 等,自定义这些效果就像运用视图修饰符相同简单。 可是并非一切视图修饰符都能够安全地用于 scrollTransition 中。例如,不支持自定义字体而且直接报错。任何影响 ScrollView 全体内容巨细的修饰符都不能在 scrollTransition 修饰符中运用。
.scrollTransition(axis: .horizontal) { content, phase in
content
.scaleEffect(
x: phase.isIdentity ? 1.0 : 0.75,
y: phase.isIdentity ? 1.0 : 0.75)
.rotationEffect(
.degrees(phase.isIdentity ? 0.0 : 90.0)
)
.offset(
x: phase.isIdentity ? 0.0 : 20.0,
y: phase.isIdentity ? 0.0 : 20.0
)
// .font(phase.isIdentity ? .body : .title2) // Value of type 'some VisualEffect' has no member 'font'
}
内容回忆
现在,咱们来回忆下本文的新增 API 以及它们的效果: 一、边距和安全区域:
- 经过 safeAreaPadding 和 contentMargin 自在的处理安全区域和翻滚视图边距之间的联系;
- 经过 scrollClipDisabled 能够防止剪切超出翻滚视图鸿沟的视图部分(例如示例中的暗影)。
二、方针和方位:
- 经过 scrollTargetBehaviors 的 paging 和 viewAligned 类型完成常见的翻滚行为。以及能够经过遵循 ScrollTargetBehavior 协议完成自定义翻滚行为;
- 经过 containerRelativeFrame 修饰符替代 GeometryReader 的核算逻辑,能够轻松的创立布局;
- 经过 scrollIndicatorsFlash 修饰符操控翻滚指示器的闪耀显现;
- 经过 scrollPosition 修饰符绑定到翻滚视图的状况,使咱们能够便利操作并得知当时翻滚的视图是哪一个,比 ScrollViewReader 更快捷。
三、翻滚过渡:
- 经过 scrollTransition 便利的获取翻滚视图状况,快捷的完成过渡动画。
总结
SwiftUI 的时长很短,前几年一向忙于完善更重要的 API,除了 WWDC2020 的 ScrollViewReader(并不便利)外没有很有力的更新。ScrollView 的运用很苦楚,甚至被戏称“ScrollView 阴间”。 而本年,ScrollView 从 布局展现、翻滚方位、过渡动画 三个方面做了强壮的改进,新增的以上 API 全面的考虑了开发者对 ScrollView 的运用需求。 尽管以上 API 很丰厚,可是在日常实践中咱们会遇到更多的场景需求。例如咱们常常需求监听翻滚偏移量 contentOffset、翻滚视图的粘性头部 Sticky Header 在本次更新中依然没有 API 能够供给快捷的完成办法。不过,咱们还能够运用一些优秀的第三方库,例如 ScrollKit 能够协助咱们快速的完成这些功用。