本文首发于 WWDC23 10159 – Beyond scroll views

摘要:学习怎么运用 SwiftUI 的最新 API,将翻滚视图提升到一个全新的水平。本文会展现一些史无前例的自定义翻滚视图的办法,包括怎么处理安全区域和翻滚视图边距之间的联系、怎么与翻滚视图的内容偏移进行交互、怎么经过翻滚来为内容增加炫酷的效果。

为了在有限的设备屏幕上展现完好的运用功用,咱们通常需求依赖翻滚控件。而 SwiftUI 经过供给多种组件(如 List、Table、ScrollView)来轻松完成翻滚嵌入运用程序中。本文将要点介绍 ScrollView 组件,使咱们能够愈加深入地了解其特性和运用场景。 咱们将涉及以下内容:

WWDC23 10159 - Beyond scroll views

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:

WWDC23 10159 - Beyond scroll views

Content Offset

ScrollView 翻滚到的切当方位称为内容偏移量(Content Offset)。SwiftUI 现已供给了 ScrollViewReader 作为操控内容偏移量的办法。本年,SwiftUI 还将推出更多办法来影响和响应由 ScrollView 办理的内容偏移量。

WWDC23 10159 - Beyond scroll views
咱们将分为以下三点来叙述:

  1. 边距和安全区域:怎么影响边距,处理边距与安全区域的联系;
  2. 方针和方位:经过翻滚方针和翻滚方位,来办理 contentOffset;
  3. 翻滚过渡:经过翻滚转换,为运用程序增加一些真正的魅力。

一、边距和安全区域

本文的首要范例运用 Session 供给的示例代码。这是一个能够水平滑动的 header,咱们来一步步完善它的效果。 开始时,它由一个水平的 ScrollView 包括一个 lazy stack。

WWDC23 10159 - Beyond scroll views
首要,咱们来增加一些边距来使该视图更美观。

1. padding

有人或许第一时间会想到在 ScrollView 上增加填充修饰符 padding。可是请留意,这样会导致翻滚时依然看到左右边距,内容没有充满整个宽度。

WWDC23 10159 - Beyond scroll views

2. safeAreaPadding

如上图的裁剪问题,一般来说咱们不想缩进 ScrollView 自身宽度,而是想扩展 ScrollView 的内容边距。咱们能够运用新的安全区域填充修饰符 safeAreaPadding 来完成这一点。 safeAreaPadding 相似于 padding 修饰符,但它不是填充内容,而是将填充增加到安全区域。 现在如图 ScrollView 没有被裁剪,而是扩展到了整个宽度(你能够经过翻滚时的左边际看到比照上面 padding 效果图的差异)。

WWDC23 10159 - Beyond scroll views
而且它使下一个翻滚项在屏幕的右边际显现了出来,全体效果比 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 边距。

WWDC23 10159 - Beyond scroll views
最终,回到咱们比方中,运用 contentMargin 替代 safeAreaPadding:
WWDC23 10159 - Beyond scroll views

4. scrollClipDisabled

在持续之前,还有一个边距相关有意思的 API 了解一下。 默许情况下,ScrollView 会将其内容裁剪到其鸿沟。如图,暗影会被裁剪:

WWDC23 10159 - Beyond scroll views
咱们能够运用 scrollClipDisabled 修饰符来禁用这种行为,从而防止暗影被裁剪。把参数设置为 true 即可,参数 false 则是上图默许效果。
WWDC23 10159 - Beyond scroll views
这儿运用了简单的暗影效果,你还能够运用 clipShape(_:style:) 修饰符创立自定义剪切形状,依然有很好的处理效果。例如:
WWDC23 10159 - Beyond scroll views
最终,咱们看一下它是否会触发离屏烘托。如下图所示:① 本来视图不会触发离屏烘托;② 运用了 scrollClipDisabled 也没有触发离屏烘托;③ 为了比照阐明的确开了 Color Off-screen Rendered 选项,能够看到 blur(radius:) 触发了离屏烘托。(假如想更多的了解离屏烘托,能够阅读文章。)
WWDC23 10159 - Beyond scroll views

二、方针和方位

咱们现已对视图进行了一些边距调整,接下来咱们来看看操控 ScrollView 在松开手指后翻滚停留的方位。默许情况下, ScrollView 会运用规范的减速率和翻滚速度来核算应该中止的方针内容偏移量(contentOffset),可是它不考虑 ScrollView 巨细或其内容等要素,有时分这些要素非常重要。

1. scrollTargetBehavior

在 SwiftUI 中,咱们能够运用 scrollTargetBehavior 修饰符来改动 ScrollView 核算 contentOffset 的办法,它包括一个参数:

  • behavior:一个遵循 ScrollTargetBehavior 协议的类型。

咱们来看看两个现有的翻滚方针行为。 1. paging 如下图,咱们指定了分页行为 paging,现在 ScrollView 每次只滑动一页。这种分页行为是特殊的,它具有自定义的减速率并依据 ScrollView 自身的容器巨细挑选翻滚方位。

WWDC23 10159 - Beyond scroll views
现在,在 iOS 上效果很好,但在 iPadOS 的大屏幕上或许会有一些问题: 咱们按照 paging 翻滚的办法呈现,每一页 iPad 能够包容两个 Hero 视图,因而每次翻滚都是两个视图,1 3 5 一向在左侧,而 2 4 6 无法对齐到左侧。
WWDC23 10159 - Beyond scroll views
2. viewAligned 咱们更期望将其对齐到单个视图,而不是对齐到 ScrollView 的容器巨细。 首要,viewAligned 对齐行为能够将 ScrollView 对齐到视图上。因而 ScrollView 需求知道哪些视图应该被考虑对齐,这些视图被称为翻滚方针。 然后,scrollTargetLayout 修饰符能够指定哪些视图成为翻滚方针。 在如下比方中,咱们指定对齐行为为 viewAligned ;运用 scrollTargetLayout 让 lazy stack 中的每个 Hero 视图都被视为翻滚方针(也能够将单个视图标记为方针)。(当运用 lazy stack 时,运用 scrollTargetLayout 非常重要,即便可见区域之外的视图尚未创立,布局也知道将要创立哪些视图,因而它能够确保 ScrollView 翻滚到正确的方位)
WWDC23 10159 - Beyond scroll views
如图,现在每次翻滚一个 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 修饰符很便利的完成。咱们来体会下这个过程吧。

WWDC23 10159 - Beyond scroll views
首要,咱们把 UI 简化为每个 Hero 是一个蓝色矩形。 把代码简化为只增加了一个固定高度的修饰符。它在设备上的宽度展现如下:
WWDC23 10159 - Beyond scroll views
然后,咱们在视图中增加上 containerRelativeFrame 修饰符,并指定了参数为水平轴。它使得该视图只占用其容器的宽度,此时,视图巨细会主动习惯容器的宽度。
WWDC23 10159 - Beyond scroll views
除了 axes 方向之外,containerRelativeFrame 还能够经过 count 和 spacing 两个参数创立相似网格布局的视图。 这儿咱们需求在 iOS 上展现一个,而在 iPad 上展现两个,咱们能够依据 horizontalSizeClass 判别个数。而且 horizontalSizeClass 现在适用于一切渠道,咱们不需求判别操作系统。 最终,咱们使高度与宽度成份额,去掉硬编码固定高度,运用 aspectRatio 修饰符来完成。效果展现如下:
WWDC23 10159 - Beyond scroll views
至此,咱们的布局和翻滚行为都完成了!

3. scrollPosition

接下来,咱们来观察当 ScrollView 滑动的时分,它的翻滚指示器很明显不美观,咱们来把它移除掉。

WWDC23 10159 - Beyond scroll views

scrollIndicators

咱们能够运用现有的 scrollIndicators 修饰符来完成移除翻滚指示器。如图,现在滑动时它现已躲藏了:

WWDC23 10159 - Beyond scroll views
在 Mac 上这儿的 hidden 或许无效,翻滚指示器或许依然会显现。因为考虑到运用鼠标时,假如没有翻滚指示器或许会使翻滚变得困难或不或许。所以 scrollIndicators 的默许行为是在运用更灵敏的输入设备(如触摸板)时躲藏指示器,但在衔接鼠标时答应指示器显现(留意 Apple 妙控鼠标属于灵敏设备,试试其他鼠标或许不属于灵敏设备)。
WWDC23 10159 - Beyond scroll views
咱们能够修正 scrollIndicators 的参数为 never ,这样就到达一向躲藏指示器,而不会考虑输入设备。如图:
WWDC23 10159 - Beyond scroll views

scrollIndicatorsFlash

(1) 在持续完善之前,关于翻滚指示器,咱们再介绍一个简单的修饰符 scrollIndicatorsFlash(onAppear:),它用来操控当翻滚视图第一次呈现时,是否闪耀其翻滚指示器。经过将 onAppear 参数设置为 true 即可。如图 gif,请留意翻滚指示器的改动:

WWDC23 10159 - Beyond scroll views
(2) 假如想运用过程操控翻滚指示器的显现,能够设置某个值改动时闪耀翻滚指示器,scrollIndicatorsFlash(trigger:) 修饰符帮能咱们完成。
WWDC23 10159 - Beyond scroll views
可是经测验,scrollIndicatorsFlash(onAppear:) 只能在笔直方向的视图有用,水平无效。scrollIndicatorsFlash(trigger:) 在笔直和水平方向都有用。

scrollPosition

现在 Mac 上的翻滚指示器躲藏了,咱们供给一个替代方案,运用户愈加便利直观的交互。例如增加两个按钮,让用户能够点击上一个或下一个来完成视图翻滚,UI 改动如下:

WWDC23 10159 - Beyond scroll views
接下来,咱们看看怎么在点击这两个按钮时操控 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 信息,使翻滚效果愈加直观。

WWDC23 10159 - Beyond scroll views
绑定会跟着 ScrollView 最左边的视图更改而主动更新。现在,咱们能够经过鼠标用户轻松浏览了。

三、翻滚过渡

最终咱们还有一个小细节需求完善,给翻滚的视图加一点过渡动画,使滑动时更明显的体会到当时翻滚的方针视图。咱们能够依据 ScrollView 中视图的方位来改动它。

scrollTransitions

在 SwiftUI 中,新增了 scrollTransitions 修饰符,能够轻松完成这个功用。 scrollTransition 很像普通的过渡效果,描绘了一个视图在呈现或消失时应该经历的改动:

  • 当一个视图呈现后,它处于 identity 阶段,此时不该运用自定义设置;
  • ScrollTransition 描绘的是与过渡效果相似的一组改动,将其作为视图进入 ScrollView 的可见区域然后脱离可见区域时运用。

让咱们在比方中体会下,当视图挨近 ScrollView 的边际时,把它的巨细能够略微缩小。 咱们增加 scrollTransition 修饰符,它需求 content 和 phase,使咱们能够依据 phase 指定内容的视觉改动。在这儿,咱们指定当视图不处于其 identity 时,缩小份额。 之前:

WWDC23 10159 - Beyond scroll views
之后:
WWDC23 10159 - Beyond scroll views

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 能够协助咱们快速的完成这些功用。