阅读视图

发现新文章,点击刷新页面。

如何使用 SwiftUI 中 ScrollView 的滚动偏移

前言

WWDC 24 已经结束,我决定开始写一些关于 SwiftUI 框架即将推出的新特性的文章。今年,苹果继续填补空白,引入了对滚动位置更细粒度的控制。本周,我们将学习如何操作和读取滚动偏移。

使用 scrollPosition

SwiftUI 框架已经允许我们通过视图标识符跟踪和设置滚动视图的位置。这种方法效果不错,但不足以更准确地跟踪用户交互。

struct ContentView: View {
    @State private var position: Int?
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<100) { index in
                    Text(verbatim: index.formatted())
                        .id(index)
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $position)
    }
}

在上面的代码示例中,我们使用了视图标识符和 scrollPosition 修饰符来跟踪和设置滚动视图的位置。虽然这种方法效果不错,但在某些情况下,尤其是需要更精确的用户交互跟踪时,它可能不够用。为了弥补这一不足,SwiftUI 引入了新的 ScrollPosition 类型,使我们能够通过偏移量、滚动视图的边缘、视图标识符等组合滚动位置。

新的 ScrollPosition 类型

SwiftUI 框架引入了新的 ScrollPosition 类型,使我们能够通过偏移量、滚动视图的边缘、视图标识符等组合滚动位置。

struct ContentView: View {
    @State private var position = ScrollPosition(edge: .top)
    
    var body: some View {
        ScrollView {
            Button("Scroll to bottom") {
                position.scrollTo(edge: .bottom)
            }
            
            ForEach(1..<100) { index in
                Text(verbatim: index.formatted())
                    .id(index)
            }
            
            Button("Scroll to top") {
                position.scrollTo(edge: .top)
            }
        }
        .scrollPosition($position)
    }
}

如上例所示,我们定义了 position 状态属性,并使用 scrollPosition 视图修饰符将滚动视图与状态属性绑定。我们还放置了两个按钮,允许你快速滚动到滚动视图中的第一个或最后一个项目。ScrollPosition 类型提供了许多重载的 scrollTo 函数,使我们能够处理不同的情况。

为滚动添加动画

通过附加动画视图修饰符并传递 ScrollPosition 类型的实例作为 value 参数,我们可以轻松地为编程滚动添加动画。

struct ContentView: View {
    @State private var position = ScrollPosition(edge: .top)
    
    var body: some View {
        ScrollView {
            Button("Scroll to bottom") {
                position.scrollTo(edge: .bottom)
            }
            
            ForEach(1..<100) { index in
                Text(verbatim: index.formatted())
                    .id(index)
            }
            
            Button("Scroll to top") {
                position.scrollTo(edge: .top)
            }
        }
        .scrollPosition($position)
        .animation(.default, value: position)
    }
}

滚动到特定项目

我们添加了另一个按钮来将滚动视图的位置更改为随机项目。我们仍然使用 ScrollPosition 类型的 scrollTo 函数,但我们提供了一个可哈希的标识符。这个选项允许我们将位置更改为特定项目,通过使用 anchor 参数,我们可以选择所选视图的哪个点应该可见。

struct ContentView: View {
    @State private var position = ScrollPosition(edge: .top)
    
    var body: some View {
        ScrollView {
            Button("Scroll somewhere") {
                let id = (1..<100).randomElement() ?? 0
                position.scrollTo(id: id, anchor: .center)
            }
            
            ForEach(1..<100) { index in
                Text(verbatim: index.formatted())
                    .id(index)
            }
        }
        .scrollPosition($position)
        .animation(.default, value: position)
    }
}

滚动到特定偏移

最后但同样重要的是 scrollTo 函数的 point 参数重载,允许我们传递 CGPoint 实例以将视图滚动到内容的特定点。

struct ContentView: View {
    @State private var position = ScrollPosition(edge: .top)
    
    var body: some View {
        ScrollView {
            Button("Scroll to offset") {
                position.scrollTo(point: CGPoint(x: 0, y: 100))
            }
            
            ForEach(1..<100) { index in
                Text(verbatim: index.formatted())
                    .id(index)
            }
        }
        .scrollPosition($position)
        .animation(.default, value: position)
    }
}

如上例所示,我们使用带有 CGPoint 参数的 scrollTo 函数。它还提供重载,允许我们仅按 X 或 Y 轴滚动视图。

struct ContentView: View {
    @State private var position = ScrollPosition(edge: .top)
    
    var body: some View {
        ScrollView {            
            Button("Scroll to offset") {
                position.scrollTo(y: 100)
                position.scrollTo(x: 200)
            }
            
            ForEach(1..<100) { index in
                Text(verbatim: index.formatted())
                    .id(index)
            }
        }
        .scrollPosition($position)
        .animation(.default, value: position)
    }
}

读取滚动位置

我们学习了如何使用新的 ScrollPosition 类型操作滚动位置,这也允许我们读取滚动视图的位置。ScrollPosition 提供了可选的 edge、point 和 viewID 属性,以在你编程滚动时读取值。

每当用户与滚动视图交互时,这些属性将变为 nil。ScrollPosition 类型上的 isPositionedByUser 属性允许我们了解何时用户手势移动滚动视图内容。

提供一个可以运行示例

下面是一个可以运行的示例代码,演示如何读取和显示滚动视图的位置。我们将使用一个 Text 视图来显示当前滚动位置:

import SwiftUI

struct ContentView: View {
    @State private var position = ScrollPosition(edge: .top)
    @State private var scrollOffset: CGPoint?

    var body: some View {
        VStack {
            ScrollView {
                LazyVStack {
                    ForEach(0..<100) { index in
                        Text("Item \(index)")
                            .id(index)
                            .padding()
                            .background(Color.yellow)
                            .cornerRadius(10)
                            .padding(.horizontal)
                    }
                }
                .scrollPosition($position)
                .onScrollGeometryChange { geometry in
                    scrollOffset = geometry?.contentBounds.origin
                }
            }
            .animation(.default, value: position)
            
            if let offset = scrollOffset {
                Text("Scroll Offset: x = \(Int(offset.x)), y = \(Int(offset.y))")
                    .padding()
            } else {
                Text("Scroll Offset: not available")
                    .padding()
            }
        }
        .padding()
    }
}

在这个示例中,我们使用了 onScrollGeometryChange 修饰符来读取滚动视图的几何变化。每当滚动视图滚动时,geometry?.contentBounds.origin 将提供当前滚动位置的偏移量。我们将这个偏移量存储在 scrollOffset 状态属性中,并在视图底部显示当前的滚动位置。

总结

在本文中,我们深入探讨了 SwiftUI 框架中 ScrollView 的新特性,特别是如何通过 ScrollPosition 类型实现更精确的滚动控制。我们介绍了如何使用 ScrollPosition 类型进行滚动位置的设置和读取,包括使用偏移量、视图标识符等方式进行操作。此外,我们还展示了如何通过动画和事件处理来增强用户体验。通过这些新功能,开发者可以更灵活地控制滚动视图的行为,从而创建更加流畅和直观的用户界面。希望这些内容对你有所帮助。

SwiftUI 在 WWDC 24 之后的新变化

前言

WWDC 24 已经到来,我们有很多内容要讨论。每年,SwiftUI 都会通过引入更多功能来赶上 UIKit。今年也不例外。让我们深入了解 SwiftUI 框架引入的新功能。

我首先要提到的主要变化是 App、Scene 和 View 协议的 @MainActor 隔离。这可能会破坏你的代码,所以请记住这一点。

视图集合

SwiftUI 为 Group 和 ForEach 视图引入了新的重载,允许我们创建自定义容器,如 List 或 TabView。

struct AppStoreView<Content: View>: View {
    @ViewBuilder var content: Content
    
    var body: some View {
        VStack {
            Group(subviewsOf: content) { subviews in
                HStack {
                    if !subviews.isEmpty {
                        subviews[0]
                    }
                    
                    if subviews.count > 1 {
                        subviews[1]
                    }
                }
                
                if subviews.count > 2 {
                    VStack {
                        subviews[2...]
                    }
                }
            }
        }
    }
}

如上例所示,我们使用带有新初始化器的 Group 视图,允许我们访问通过 @ViewBuilder 闭包传递的内容视图的子视图。SwiftUI 引入了新的 SubviewSubviewsCollection 类型,提供了对真实视图的代理访问。

新的标签栏体验

使用新的 Tab 类型,SwiftUI 提供了新的可定制标签栏体验,带有流畅过渡到侧边栏。

enum Destination: Hashable {
    case home
    case search
    case settings
    case trends
}

struct RootView: View {
    @State private var selection: Destination = .home
    
    var body: some View {
        TabView {
            Tab("home", systemImage: "home", value: .home) {
                HomeView()
            }
            
            Tab("search", systemImage: "search", value: .search) {
                SearchView()
            }
            
            TabSection("Other") {
                Tab("trends", systemImage: "trends", value: .trends) {
                    TrendsView()
                }
                Tab("settings", systemImage: "settings", value: .settings) {
                    SettingsView()
                }
            }
            .tabViewStyle(.sidebarAdaptable)
        }
    }
}

如上例所示,我们使用新的 Tab 类型来定义标签。我们还在 TabSection 实例上使用 tabViewStyle 视图修饰符,将特定的标签部分分组并移动到侧边栏。

英雄动画

SwiftUI 引入了 matchedTransitionSourcenavigationTransition,我们可以在任何 NavigationLink 实例中配对使用。

struct HeroAnimationView: View {
    @Namespace var hero
    
    var body: some View {
        NavigationStack {
            NavigationLink {
                DetailView()
                    .navigationTransition(.zoom(sourceID: "myId", in: hero))
            } label: {
                ThumbnailView()
            }
            .matchedTransitionSource(id: "myId", in: hero)
        }
    }
}

这使我们能够在 NavigationStack 内从一个视图导航到另一个视图时,使用相同的标识符和命名空间创建平滑的过渡。

滚动位置

新的 ScrollPosition 类型与 scrollPosition 视图修饰符配对,允许我们读取 ScrollView 实例的精确位置。我们还可以使用它编程地滚动到滚动内容的特定点。

struct ScrollPositionExample: View {
    @State private var position: ScrollPosition = .init(point: .zero)
    
    var body: some View {
        ScrollView {
            ForEach(1..<1000) { item in
                Text(item.formatted())
            }
            
            Button("jump to top") {
                position = ScrollPosition(point: .zero)
            }
        }
        .scrollPosition($position)
    }
}

Entry 宏

新的 Entry 宏允许我们快速引入环境值、聚焦值、容器值等,无需样板代码。让我们看看在 Entry 宏之前我们如何定义环境值。

struct ItemsPerPageKey: EnvironmentKey {
    static var defaultValue: Int = 10
}

extension EnvironmentValues {
    var itemsPerPage: Int {
        get { self[ItemsPerPageKey.self] }
        set { self[ItemsPerPageKey.self] = newValue }
    }
}

现在,我们可以通过使用 Entry 宏来简化代码。

extension EnvironmentValues {
    @Entry var itemsPerPage: Int = 10
}

预览

新的 Previewable 宏允许我们在预览中引入状态,而无需将其包装到额外的包装视图中。

#Preview("toggle") {
    @Previewable @State var toggled = true
    return Toggle("Loud Noises", isOn: $toggled)
}

其他

SwiftUI 框架的下一版本包括许多新 API,如窗口推送、TextField 和 TextEditor 视图中的文本选择观察、搜索焦点监控、自定义文本渲染、新的 MeshGradient 类型等等,我无法在一篇文章中涵盖所有内容。

总结

在 WWDC 24 上,SwiftUI 再次通过引入更多新功能来提升其成熟度,以赶上 UIKit。今年的主要变化包括 @MainActor 隔离、视图集合的新重载、新的可定制标签栏体验、英雄动画、滚动位置的新功能以及新的 Entry 和 Previewable 宏。这些改进使开发者能够创建更灵活和高效的用户界面。SwiftUI还引入了许多新的API,如窗口推送、文本选择观察、搜索焦点监控等,使开发更加便捷和强大。

❌