【问题标题】:SwiftUI ScrollView: How to modify .content.offset aka Paging?SwiftUI ScrollView:如何修改 .content.offset aka Paging?
【发布时间】:2019-11-23 11:20:49
【问题描述】:

问题

如何修改滚动视图的滚动目标?我正在寻找一种替代“经典”滚动视图委托方法

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

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

或者换句话说:我确实想在(水平)滚动视图中创建分页效果。

我尝试过的,即。是这样的:

    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 值以创建所需的滚动目标。

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

    李>
  4. 添加了仅在拖动结束时有条件地执行的动画。

  5. 将手势附加到滚动视图。

长话短说。它不起作用。 我希望我明白我的问题是什么。

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

【问题讨论】:

标签: scrollview swiftui ios13


【解决方案1】:

我已经设法通过@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 状态的建议。

【讨论】:

  • 非常感谢。看起来很有希望。我最早可以在几天内测试它。
  • 哇.. 它对我有用。我尝试使用 UIKit 做同样的事情,但不知道如何设置 scrollView 的 contentOffset,这是包装器无法实现的。谢谢!
  • 这个和我遇到的每个解决方案都将列表中的每个元素作为滚动视图中的单个页面,填充屏幕的宽度。我需要给用户一个视觉提示,通过 SHOWING THEM 可以选择更多项目,就像 UIKit 中的滚动视图一样。这确实很棒,但不包含具有非设备宽度页面的 UIKit 滚动视图的精神。
【解决方案2】:

@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) }
        }
    }

【讨论】:

    【解决方案3】:

    @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) }
                    })
                )
            }
        }
    }
    

    【讨论】:

      【解决方案4】:

      据我所知,swiftUI 中的滚动不支持任何可能有用的东西,例如 scrollViewDidScrollscrollViewWillEndDragging。我建议使用经典的 UIKit 视图来制作非常自定义的行为,并使用酷炫的 SwiftUI 视图来做任何更容易的事情。我已经尝试了很多,它确实有效!看看这个guide。希望有帮助

      【讨论】:

        【解决方案5】:

        另一种解决方案是使用 UIViewRepresentative 将 UIKit 集成到 SwiftUI 中,它将 UIKit 组件与 SwiftUI 链接起来。有关其他线索和资源,请参阅 Apple 建议您如何与 UIKit 交互:Interfacing with UIKit。他们有一个很好的例子,显示图像和曲目选择索引之间的页面。

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

        【讨论】:

        • Apple 示例使用 UIPageViewController 使滚动视图中的每个视图占据整个屏幕宽度。我要设计的 UI 在滚动视图中的项目彼此相邻,因此用户知道他/她可以滚动。我沿着 UIPageViewController 路径走下去,最终浪费了 2-3 个小时的研究。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2021-11-30
        • 1970-01-01
        • 2021-08-01
        • 2021-07-25
        • 2020-09-23
        • 1970-01-01
        相关资源
        最近更新 更多