在 SwiftUI 5.0 中,苹果大幅强化了 ScrollView 功用。新增了大量新颖、完善的 API。本文将对这些新功用进行介绍,期望能够让它们更多、更早的帮助到有需要的开发者。
能够在 此处 获取完整的演示代码
原文宣布在我的博客wwww.fatbobman.com
欢迎订阅我的大众号:【肘子的Swift记事本】
contentMargins
public func contentMargins(_ edges: Edge.Set = .all, _ length: CGFloat?, for placement: ContentMarginPlacement = .automatic) -> some View
为可翻滚容器的内容或翻滚指示器(Scroll Indicator)增加外边距(Margin)。
- 不限于 ScrollView,支撑一切可翻滚容器(包括 List、TextEditor 等)。
- 将可翻滚容器内的一切子视图视为一个整体,并为其增加 margin。之前在 List 或 TextEditor 中完结类似操作是好不容易的。
- 默认的 ContentMarginPlacement(.automatic)将导致指示器与内容之间的长度不共同。如果想保持长度共同,应运用
.scrollContent
。 - 适用于作用域内的一切可翻滚容器。
struct ContentMarginsForScrollView: View { @State var text = "Hello world" var body: some View { VStack { ScrollView(.horizontal) { HStack { CellView(color: .yellow) // a custom overlay view for easy display of auxiliary information .idView("leading") ForEach(0 ..< 5) { i in CellView() .idView(i) } CellView(color: .green) .idView("trailing") } } // Also affected by contentMargins TextEditor(text: $text) .border(.red) .padding() .contentMargins(.all, 30, for: .scrollContent) } // Applies to all scrollable containers within the scope .contentMargins(.horizontal, 50, for: .scrollContent) } }

safeAreaPadding
为视图的安全区域增加内嵌。在某些场景下,其作用与 safeAreaInset 十分相似。例如,鄙人面的代码中,为 ScrollView 的 leading 方向增加安全区域的两种方法作用是共同的。
struct SafeAreaPaddingDemo: View { var body: some View { VStack { ScrollView { ForEach(0 ..< 20) { i in CellView(width: nil) .idView(i) } } .safeAreaPadding(.leading,20) // .safeAreaInset(edge: .leading){ // Color.clear.frame(width:20) // } } } }
- 该特点不仅适用于可翻滚视图,而适用于一切类型的视图。
- 它只会影响最近的一个视图。
- 关于全面屏的额外安全区域,safeAreaInset 和 safeAreaPadding 的处理逻辑不共同。
例如,下面的两种完结中,ScrollView 的底部空间是不同的。
运用 safeAreaInset:
ScrollView { ForEach(0 ..< 20) { i in CellView(width: nil) .idView(i) } } .safeAreaInset(edge: .bottom){ Text("Bottom View") .font(.title3) .foregroundColor(.indigo) .frame(maxWidth: .infinity, maxHeight: 40) .background(.green.opacity(0.6)) }

运用 safeAreaPadding:
ZStack(alignment: .bottom) { ScrollView { ForEach(0 ..< 20) { i in CellView(width: nil) .idView(i) } } .safeAreaPadding(.bottom, 40) Text("Bottom View") .font(.title3) .foregroundColor(.indigo) .frame(maxWidth: .infinity, maxHeight: 40) .background(.green.opacity(0.6)) }

阅览 掌握 SwiftUI 的 Safe Area 一文,了解更多有关安全区域的内容。
scrollIndicatorsFlash
操控翻滚指示器
运用 scrollIndicatorsFlash(onAppear: true)
能够在翻滚视图出现时使其翻滚指示器时间短闪耀。
运用 scrollIndicatorsFlash(trigger:)
能够在供给的值更改时,修饰符作用域范围内的一切可翻滚容器的翻滚指示器时间短闪耀。
struct ScrollIndicatorsFlashDemo: View { @State private var items = (0 ..< 50).map { Item(n: $0) } var body: some View { VStack { Button("Remove First") { guard !items.isEmpty else { return } items.removeFirst() }.buttonStyle(.bordered) ScrollView { ForEach(items) { item in CellView(width: 100, debugInfo: "\(item.n)") .idView(item.n) .frame(maxWidth:.infinity) } } .animation(.bouncy, value: items.count) } .padding(.horizontal,10) .scrollIndicatorsFlash(onAppear: true) .scrollIndicatorsFlash(trigger: items.count) } }

scrollClipDisable
scrollClipDisable 用于操控是否对翻滚内容运用裁剪以习惯翻滚容器的鸿沟。
当 scrollClipDisable 为 false 时,翻滚内容会被裁剪以习惯翻滚容器鸿沟。任何超出鸿沟的部分将不会显现。
当 scrollClipDisable 为 true 时,翻滚内容不会被裁剪。它能够延伸超出翻滚容器的鸿沟,从而显现更多内容。
- 仅适用于 ScrollView
- 适用于作用域内的一切可翻滚容器
struct ScrollClipDisableDemo: View { @State private var disable = true var body: some View { VStack { Toggle("Clip Disable", isOn: $disable) .padding(20) ScrollView { ForEach(0 ..< 10) { i in CellView() .idView(i) .shadow(color: .black, radius: 50) } } } .scrollClipDisabled(disable) } }

scrollTargetLayout
此修饰符用于合作下文介绍的 scrollTargetBehavior( ViewAlignedScrollTargetBehavior 形式)
或 scrollPosition(id:)
运用。
应将此修饰符运用于 ScrollView 中包括主要重复内容的布局容器,如 LazyHStack 或 VStack。
@State private var isEnabled = true ScrollView { LazyVStack { ForEach(items) { item in CellView(width: 200, height: 140) .idView(item.n) } } .scrollTargetLayout(isEnabled: isEnabled) }
scrollPosition(initialAnchor:)
运用此修饰符能够指定翻滚视图内容最初可见部分的锚点。它只影响翻滚视图的初始状况,一次性设置。通常用于完结类似初始状况从底部显现的 IM 运用、从 trailing 开始显现数据等情况。经过 UnitPoint 能够一起设置两个轴向的初始方位。
struct ScrollPositionInitialAnchorDemo: View { @State private var show = false @State private var position: Position = .leading var body: some View { VStack { Toggle("Show", isOn: $show) Picker("Position", selection: $position) { ForEach(Position.allCases) { p in Text(p.rawValue).tag(p) } } .pickerStyle(.segmented) if show { ScrollView(.horizontal) { LazyHStack { ForEach(0 ..< 10000) { i in CellView(debugInfo: "\(i)") .idView(i) } } } .scrollPosition(initialAnchor: position.unitPoint) } } .padding() } enum Position: String, Identifiable, CaseIterable { var id: UnitPoint { unitPoint } case leading, center, trailing var unitPoint: UnitPoint { switch self { case .leading: .leading case .center: .center case .trailing: .trailing } } } }

尽管运用此修饰符完结初始定位十分容易,但当数据集很大时,仍然会有较严重的功用问题。可采用 优化在 SwiftUI List 中显现大数据集的呼应功率 一文中介绍的方法来处理。
scrollPostion(id:)
运用此修饰符能够让翻滚视图翻滚到特定的方位。能够将其理解为 ScrollViewReader 的简化版别。
- 仅适用于 ScrollView
- 当 ForEach 中的数据源遵从 Identifiable 协议时,无需显式运用
id
修饰符设置标识 - 与 scrollTargetLayout 合作运用,能够获取当时的翻滚方位(视图标识)
- 不支撑锚点设定,固定锚点为子视图的 center
- 正如 优化在 SwiftUI List 中显现大数据集的呼应功率 一文所说到的,当数据集很大时,也会出现功用问题。
struct ScrollPositionIDDemo: View { @State private var show = false @State private var position: Position = .trailing @State private var items = (0 ..< 500).map { Item(n: $0) } @State private var id: UUID? var body: some View { VStack { Picker("Position", selection: $position) { ForEach(Position.allCases) { p in Text(p.rawValue).tag(p) } } .pickerStyle(.segmented) Text(id?.uuidString ?? "").fixedSize().font(.caption2) ScrollView(.horizontal) { LazyHStack { ForEach(items) { item in CellView(debugInfo: "\(item.n)") .idView(item.n) } } } .scrollPosition(id: $id) .scrollTargetLayout() } .animation(.default, value: id) .padding() .frame(height: 300) .task(id: position) { switch position { case .leading: id = items.first!.id case .center: id = items[250].id case .trailing: id = items.last!.id } } } }

对应的 ScrollViewReader 版别:
ScrollViewReader { proxy in ScrollView(.horizontal) { LazyHStack { ForEach(items) { item in CellView(debugInfo: "\(item.n)") .idView(item.n) .id(item.id) } } } .task(id: position) { switch position { case .leading: proxy.scrollTo(items.first!.id) case .center: proxy.scrollTo(items[250].id) case .trailing: proxy.scrollTo(items.last!.id) } } }
ScrollViewReader 和 scrollPostion(id:) 的内部完结原理应该差不多。可是,ScrollViewReader 可用于 List 中,还可设置锚点。scrollPostion(id:) 与 scrollTargetLayout 合作运用时,可获取当时翻滚方位(标识)。
scrollTargetBehavior
scrollTargetBehavior 用于设置 ScrollView 的翻滚行为:分页还是与子视图对齐。
运用 .scrollTargetBehavior(.paging)
能够使 ScrollView 分页翻滚,每次翻滚一页(即 ScrollView 的可视尺度)。
LazyVStack { ForEach(items) { item in CellView(width: 200, height: 140) .idView(item.n) } } .scrollTargetBehavior(.paging)

当设置为 .scrollTargetBehavior(.viewAligned) 时,需要与 scrollTargetLayout 一起运用。翻滚中止时,容器顶端将与子视图的顶部对齐(在垂直形式下)。开发者能够经过操控 scrollTargetLayout 的启用与否来开关 viewAligned 的行为。
struct ScrollTargetBehaviorDemo: View { @State var items = (0 ..< 100).map { Item(n: $0) } @State private var isEnabled = true var body: some View { VStack { Toggle("Layout enable", isOn: $isEnabled).padding() ScrollView { LazyVStack { ForEach(items) { item in CellView(width: 200, height: 95) .idView(item.n) } } .scrollTargetLayout(isEnabled: isEnabled) } .border(.red, width: 2) } .scrollTargetBehavior(.viewAligned) .frame(height: 300) .padding() } }

经过 .scrollTargetBehavior(.viewAligned(limitBehavior:))
咱们能够界说对齐翻滚方针行为的机制。
-
.automatic
是默认行为,在紧凑的水平尺度类中受限,否则不受限。 -
.always
始终限制可翻滚视图的数量。 -
.never
不限制可翻滚视图的数量。
一起,经过 ViewAlignedScrollTargetBehavior ,开发者还能够根据系统供给的方针覆盖翻滚视图的翻滚方位( 尚未细心研究完结细节 )。
NamedCoordinateSpace.scrollView
在 SwiftUI 5 中,苹果新增了 NamedCoordinateSpace 类型,便使用户命名坐标系,并供给了预置的 .scrollView 坐标系(仅支撑 ScrollView)。经过这个坐标系,开发者能够十分容易地获取子视图与翻滚视图之间的方位联系。使用这些信息,咱们能够轻松地完结许多作用,尤其是合作另一个新 API,visualEffect 修饰符。
struct CoordinatorDemo: View { var body: some View { ScrollView { ForEach(0 ..< 30) { _ in CellView() .overlay( GeometryReader { proxy in if let distanceFromTop = proxy.bounds(of: .scrollView)?.minY { Text(distanceFromTop * -1, format: .number) } } ) } } .border(.blue) .contentMargins(30, for: .scrollContent) } }

与运用 .coordinateSpace(.named("MyScrollView"))
设置的坐标系不同,预设的 .scrollView
坐标系能够正确处理 contentMargins
创建的 margin。
ScrollView { ForEach(0 ..< 30) { _ in CellView() .overlay( GeometryReader { proxy in if let distanceFromTop = proxy.bounds(of: .named("MyScrollView"))?.minY { Text(distanceFromTop * -1, format: .number) } } ) } } .border(.blue) .contentMargins(30, for: .scrollContent) // margin not recognized .coordinateSpace(.named("MyScrollView"))

bounds(of coordinateSpace: NamedCoordinateSpace) -> CGRect?
是今年新增的 API,用于获取指定坐标空间的鸿沟矩形。
scrollTransition
其实,在许多场景下,咱们并不需要经过 NamedCoordinateSpace.scrollView
获取十分准确的方位联系。苹果为咱们供给了另一个 API,能够简化上述进程。
当子视图滑入和滑出包括它的翻滚视图的可视区域时,scrollTransition
会对该视图运用给定的过渡动画,并在不同阶段之间滑润地过渡。
目前界说了三种阶段状况(Phase
):
-
topLeading
: 视图滑入翻滚容器的可见区域 -
identity
: 表示视图目前在可见区域中 -
bottomTrailing
: 视图滑出翻滚容器的可见区域
scrollTransition
的 transition
闭包要求你返回一个符合 VisualEffect 协议的类型(VisualEffect
协议界说了一种不影响视图布局的作用类型,苹果现已让许多 Modifier 符合了该协议)。
struct ScrollTransitionDemo: View { @State var clip = false var body: some View { ZStack(alignment: .bottom) { ScrollView { ForEach(0 ..< 30) { i in CellView() .idView(i) .scrollTransition(.animated) { content, phase in content .scaleEffect(phase != .identity ? 0.6 : 1) .opacity(phase != .identity ? 0.3 : 1) } } } .frame(height: 300) .scrollClipDisabled(clip) Toggle("Clip", isOn: $clip) .padding(16) } } }

能够将 scrollTransition 视为 NamedCoordinateSpace.scrollView 和 visualEffect(视图修饰符)的减缩版别,用于更便利地完结作用。
总结
我彻底没有想到,在 SwiftUI 5 中,苹果对 ScrollView 进行了全面增强。值得赞赏的是,他们不仅供给了一些一直等待的功用,而且在 API 的设计和完结完结度上都十分超卓。
就我个人而言,在 SwiftUI 5 中,ScrollView 的原生计划现已能够满意大多数需求,因此咱们将看到更多人采用 ScrollView + LazyStack 的组合方法。
欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行交流。
订阅下方的 邮件列表,能够及时取得每周最新文章。
原文宣布在我的博客wwww.fatbobman.com
欢迎订阅我的大众号:【肘子的Swift记事本】