SwiftUI滚动视图:如何修改.content.offset,也称为分页?

26

问题

如何修改scrollView的滚动目标?我正在寻找一种替代“传统”的scrollView委托方法的方法。

override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)

...在那里,我们可以通过targetContentOffset.pointee来修改目标scrollView.contentOffset,以创建自定义分页行为。

换句话说,我想在(水平)scrollView中创建分页效果。

我尝试过的是像这样的东西:

    ScrollView(.horizontal, showsIndicators: true, content: {
            HStack(alignment: VerticalAlignment.top, spacing: 0, content: {
                card(title: "1")
                card(title: "2")
                card(title: "3")
                card(title: "4")
            })
     })
    // 3.
     .content.offset(x: self.dragState.isDragging == true ? self.originalOffset : self.modifiedOffset, y: 0)
    // 4.
     .animation(self.dragState.isDragging == true ? nil : Animation.spring())
    // 5.
    .gesture(horizontalDragGest)

尝试

除了使用自定义的scrollView方法之外,我还尝试了以下方法:

  1. 创建一个scrollView,使内容区域大于屏幕空间以启用滚动。

  2. 创建DragGesture()以检测是否正在进行拖动。在.onChanged和.onEnded闭包中,我修改了我的@State值以创建所需的scrollTarget。

  3. 有条件地将原始值和新修改的值一起输入.content.offset(x: y:)修饰符中——取决于dragState作为缺少scrollDelegate方法的替代。

  4. 仅当拖动结束时才有条件添加动画效果。

  5. 将手势附加到scrollView上。

长话短说,它不起作用。 希望我已经表达清楚我的问题。

有什么解决方案吗?期待任何意见。谢谢!


这可能是吗?https://www.youtube.com/watch?v=95zP-M3ifOw - Just a coder
6个回答

23

我成功地使用@Binding索引实现了分页行为。解决方案可能看起来有些粗糙,我将解释我的解决方法。

我开始做错的一件事是将对齐方式设置为.leading而不是默认的.center,否则偏移量会失常。然后我结合了绑定和本地偏移状态。这有点违背"单一数据源"原则,但否则我不知道如何处理外部索引更改并修改我的偏移。

因此,我的代码如下:

struct SwiftUIPagerView<Content: View & Identifiable>: View {

    @Binding var index: Int
    @State private var offset: CGFloat = 0
    @State private var isGestureActive: Bool = false

    // 1
    var pages: [Content]

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        page
                            .frame(width: geometry.size.width, height: nil)
                    }
                }
            }
            // 2
            .content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
            // 3
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture().onChanged({ value in
                // 4
                self.isGestureActive = true
                // 5
                self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
            }).onEnded({ value in
                if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
                    self.index += 1
                }
                if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
                    self.index -= 1
                }
                // 6
                withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                // 7
                DispatchQueue.main.async { self.isGestureActive = false }
            }))
        }
    }
}
  1. 您可以将内容进行包装,我用它来制作“教程视图”。
  2. 这是一个在外部状态和内部状态之间切换的技巧。
  3. .leading 是必需的,如果您不想将所有偏移量转换为中心点。
  4. 将状态设置为本地状态更改。
  5. 从手势增量(* -1)加上先前的索引状态计算完整偏移。
  6. 最后根据手势预测的结束位置设置最终索引,同时将偏移量四舍五入。
  7. 重置状态以处理对索引的外部更改。

我已经在以下情境中测试过了。

    struct WrapperView: View {

        @State var index: Int = 0

        var body: some View {
            VStack {
                SwiftUIPagerView(index: $index, pages: (0..<4).map { index in TODOView(extraInfo: "\(index + 1)") })

                Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
                    ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
            }
        }
    }

其中TODOView是我的自定义视图,表示要实现的一个视图。

我希望我理解问题的要点,如果不是,请指明我应该关注哪个部分。同时,我欢迎任何建议以删除isGestureActive状态。


非常感谢。看起来很有前途。我最早可以在几天后进行测试。 - HelloTimo
哇..它对我有效。我尝试使用UIKit做同样的事情,但不知道如何设置scrollView的contentOffset, 因为这是无法从包装器中获取的..谢谢! - Hrabovskyi Oleksandr
我所找到的每个解决方案都将列表中的每个元素作为满屏幕宽度的单个页面放在scrollview中。我需要给用户一个视觉提示,告诉他们有更多可供选择的项目,就像在UIKit中的滚动视图一样。这很好,但并不符合具有非设备宽度页面的UIKit滚动视图的精神。 - Moebius

5

@gujci 你的解决方案很完美,对于更通用的使用情况,让它接受模型和视图构建器,就像这样(注意我在构建器中传递了几何尺寸):

struct SwiftUIPagerView<TModel: Identifiable ,TView: View >: View {

    @Binding var index: Int
    @State private var offset: CGFloat = 0
    @State private var isGestureActive: Bool = false

    // 1
    var pages: [TModel]
    var builder : (CGSize, TModel) -> TView

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        self.builder(geometry.size, page)
                    }
                }
            }
            // 2
            .content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
            // 3
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture().onChanged({ value in
                // 4
                self.isGestureActive = true
                // 5
                self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
            }).onEnded({ value in
                if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
                    self.index += 1
                }
                if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
                    self.index -= 1
                }
                // 6
                withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                // 7
                DispatchQueue.main.async { self.isGestureActive = false }
            }))
        }
    }
}

并且可以用作:

    struct WrapperView: View {

        @State var index: Int = 0
        @State var items : [(color:Color,name:String)] = [
            (.red,"Red"),
            (.green,"Green"),
            (.yellow,"Yellow"),
            (.blue,"Blue")
        ]
        var body: some View {
            VStack(spacing: 0) {

                SwiftUIPagerView(index: $index, pages: self.items.identify { $0.name }) { size, item in
                    TODOView(extraInfo: item.model.name)
                        .frame(width: size.width, height: size.height)
                        .background(item.model.color)
                }

                Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
                    ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
                }
                .pickerStyle(SegmentedPickerStyle())

            }.edgesIgnoringSafeArea(.all)
        }
    }

通过一些实用工具的帮助:

    struct MakeIdentifiable<TModel,TID:Hashable> :  Identifiable {
        var id : TID {
            return idetifier(model)
        }
        let model : TModel
        let idetifier : (TModel) -> TID
    }
    extension Array {
        func identify<TID: Hashable>(by: @escaping (Element)->TID) -> [MakeIdentifiable<Element, TID>]
        {
            return self.map { MakeIdentifiable.init(model: $0, idetifier: by) }
        }
    }

2

细节

  • Xcode 14
  • Swift 5.6.1

需求

  1. 我不想使用与UIKit的集成(仅使用纯洁SwiftUI)
  2. 我不想滚动到任何“ID”,我想要滚动到指定位置

解决方案

import SwiftUI


@available(iOS 14.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
struct ExtendedScrollView<Content>: View where Content: View {

    private let contentProvider: _AligningContentProvider<Content>
    // Main Idea from: https://github.com/edudnyk/SolidScroll/blob/main/Sources/SolidScroll/ScrollView.swift

    private var config: _ScrollViewConfig

    init(config: _ScrollViewConfig = _ScrollViewConfig(),
         @ViewBuilder content: () -> Content) {
        contentProvider = _AligningContentProvider(content: content(), horizontal: .center, vertical: .center)
        self.config = config
    }

    init(_ axes: Axis.Set = .vertical,
         showsIndicators: Bool = true,
         @ViewBuilder content: () -> Content) {
        var config = _ScrollViewConfig()
        config.showsHorizontalIndicator = axes.contains(.horizontal) && showsIndicators
        config.showsVerticalIndicator = axes.contains(.vertical) && showsIndicators
        self.init(config: config, content: content)
    }

    init(config: () -> _ScrollViewConfig,
         @ViewBuilder content: () -> Content) {
        self.init(config: config(), content: content)
    }

    var body: some View {
        _ScrollView(contentProvider: contentProvider, config: config)
    }
}

extension _ContainedScrollViewKey: PreferenceKey {}

// MARK: Track ScrollView Scrolling

struct TrackableExtendedScrollView: ViewModifier {

    let onChange: (_ScrollViewProxy?) -> Void
    func body(content: Content) -> some View {
        content
            .onPreferenceChange(_ContainedScrollViewKey.self, perform: onChange)
    }
}

extension View {
    func onScrollChange(perform: @escaping (_ScrollViewProxy?) -> Void) -> some View {
        modifier(TrackableExtendedScrollView(onChange: perform))
    }
}

使用示例

private var gridItemLayout = (0..<40).map { _ in
      GridItem(.fixed(50), spacing: 0, alignment: .leading)
}

// ....

ExtendedScrollView() {
    LazyHGrid(rows: gridItemLayout) {
        ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
            let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
            Text("\(index)")
                .frame(width: 50)
                .frame(maxHeight: .infinity)
        }
    }
}
.onScrollChange { proxy in
   // let offset = proxy?.contentOffset.y
}

完整示例

实现细节

  • 第一列和第一行始终显示在屏幕上
  • 有3个“CollectionView”:
    1. 第一行的“CollectionView”
    2. 第一列的“CollectionView”
    3. 主要内容的“CollectionView”
  • 所有“CollectionView”都同步(如果您滚动一个“CollectionView”,另一个也会滚动)

不要忘记在此处粘贴解决方案代码

import SwiftUI
import Combine

struct ContentView: View {
    private let columWidth: CGFloat = 50
    private var gridItemLayout0 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
    private var gridItemLayout1 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]

    private var gridItemLayout = (0..<40).map { _ in
        GridItem(.fixed(50), spacing: 0, alignment: .leading)
    }

    @State var text: String = "scrolling not detected"
    @State private var scrollViewProxy1: _ScrollViewProxy?
    @State private var tableContentScrollViewProxy: _ScrollViewProxy?
    @State private var tableHeaderScrollViewProxy: _ScrollViewProxy?

    private let numberOfColumns = 50
    private let numberOfRows = 40

    let headerColor = Color(0xEE9B00)
    let firstColumnColor = Color(0x0A9396)
    let headerTextColor = Color(.white)

    let horizontalSpacing: CGFloat = 6
    let verticalSpacing: CGFloat = 0
    let firstColumnWidth: CGFloat = 100
    let columnWidth: CGFloat = 60

    var body: some View {
        VStack(spacing: 0)  {
            Text("First column and row are sticked to the content")
                .foregroundColor(.gray)
            Text(text)
            HStack {
                Rectangle()
                    .frame(width: firstColumnWidth-2)
                    .foregroundColor(.clear)
                buildFirstCollectionViewRow()
            }
            .frame(height: 50)


            HStack(alignment: .firstTextBaseline, spacing: horizontalSpacing) {
                buildFirstCollectionViewColumn()
                buildCollectionViewContent()
            }
        }
    }

    @ViewBuilder
    private func buildFirstCollectionViewRow() -> some View {
        ExtendedScrollView() {

            LazyHGrid(rows: gridItemLayout1, spacing: horizontalSpacing) {
                ForEach((0..<numberOfColumns), id: \.self) {
                    let color = $0%2 == 0 ? Color(0x005F73) : Color(0xCA6702)
                    Text("Value\($0)")
                        .frame(width: columnWidth)
                        .frame(maxHeight: .infinity)
                        .foregroundColor(headerTextColor)
                        .background(color)
                        .font(.system(size: 16, weight: .semibold))
                }
            }
        }
        .onScrollChange { proxy in
            if tableHeaderScrollViewProxy != proxy { tableHeaderScrollViewProxy = proxy }
            guard proxy?.isScrolling ?? false else { return }
            if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
               let offset = proxy?.contentOffset.x {
                tableContentScrollViewProxy?.contentOffset.x = offset
            }

            text = "scrolling: header"
        }
    }
}

// MARK: Collection View Elements

extension ContentView {

    @ViewBuilder
    private func buildFirstCollectionViewColumn() -> some View {
        ExtendedScrollView() {
            LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
                ForEach((0..<numberOfRows), id: \.self) {
                    Text("multi line text \($0)")
                        .foregroundColor(.white)
                        .lineLimit(2)
                        .frame(width: firstColumnWidth)
                        .font(.system(size: 16, weight: .semibold))
                        .frame(maxHeight: .infinity)
                        .background(firstColumnColor)
                        .border(.white)
                }
            }
        }
        .frame(width: firstColumnWidth)
        .onScrollChange { proxy in
            if scrollViewProxy1 != proxy { scrollViewProxy1 = proxy }
            guard proxy?.isScrolling ?? false else { return }
            if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
               let offset = proxy?.contentOffset.y {
                tableContentScrollViewProxy?.contentOffset.y = offset
            }
            text = "scrolling: 1st column"
        }
    }

    @ViewBuilder
    private func buildCollectionViewContent() -> some View {
        ExtendedScrollView() {
            LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
                ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
                    let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
                    Text("\(index)")
                        .frame(width: columnWidth)
                        .frame(maxHeight: .infinity)
                        .background(color)
                        .border(.white)
                }
            }
        }
        .onScrollChange { proxy in
            if tableContentScrollViewProxy != proxy { tableContentScrollViewProxy = proxy }
            guard proxy?.isScrolling ?? false else { return }
            if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
               let offset = proxy?.contentOffset.y {
                self.scrollViewProxy1?.contentOffset.y = offset
            }

            if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
               let offset = proxy?.contentOffset.x {
                self.tableHeaderScrollViewProxy?.contentOffset.x = offset
            }
            text = "scrolling: content"
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension Color {
    init(_ hex: UInt, alpha: Double = 1) {
        self.init(
            .sRGB,
            red: Double((hex >> 16) & 0xFF) / 255,
            green: Double((hex >> 8) & 0xFF) / 255,
            blue: Double(hex & 0xFF) / 255,
            opacity: alpha
        )
    }
}

完整示例演示

enter image description here


2

@gujci,感谢你提供这个有趣的例子。我已经尝试过并删除了isGestureActive状态。完整的例子可以在我的gist中找到。

struct SwiftUIPagerView<Content: View & Identifiable>: View {

    @State private var index: Int = 0
    @State private var offset: CGFloat = 0

    var pages: [Content]

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        page
                            .frame(width: geometry.size.width, height: nil)
                    }
                }
            }
            .content.offset(x: self.offset)
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture()
                .onChanged({ value in
                    self.offset = value.translation.width - geometry.size.width * CGFloat(self.index)
                })
                .onEnded({ value in
                    if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
                        var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
                        nextIndex += self.index
                        self.index = nextIndex.keepIndexInRange(min: 0, max: self.pages.endIndex - 1)
                    }
                    withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                })
            )
        }
    }
}

1
据我所知,SwiftUI 中的滚动视图尚不支持任何潜在有用的功能,例如scrollViewDidScrollscrollViewWillEndDragging。我建议使用经典的UIKit视图来实现非常自定义的行为和酷炫的SwiftUI视图来实现更简单的内容。我尝试过很多次,它确实有效!请查看此guide。希望能帮到你。

0

另一种解决方案是使用UIViewRepresentative将UIKit集成到SwiftUI中,该方法将UIKit组件与SwiftUI链接起来。有关更多线索和资源,请参阅苹果建议您如何与UIKit接口:与UIKit接口。他们有一个很好的示例,展示了如何在图像之间进行页面翻页和跟踪选择索引。

编辑:在他们(苹果)实现影响滚动而不是整个视图的某种内容偏移之前,这是他们建议的解决方案,因为他们知道SwiftUI的初始版本不会包含所有UIKit功能。


Apple的示例使用UIPageViewController,使得滚动视图中的每个视图占据整个屏幕的宽度。我想设计的UI是让滚动视图中的项目相互靠近,这样用户就知道他/她可以滚动。我走了UIPageViewController的路线,最终浪费了2-3个小时的研究时间。 - Moebius
@Moebius 你解决了如何只使用scrollview实现这个功能吗? - batman

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接