SwiftUI滚动/列表滚动事件

4

最近我一直在尝试创建一个SwiftUI Scroll View的下拉刷新和加载更多功能,受到https://cocoapods.org/pods/SwiftPullToRefresh的启发。

我曾经苦于无法获得内容的偏移量和大小。但现在我正在努力寻找用户释放滚动视图以完成 UI 的事件。

以下是我的当前代码:

    struct PullToRefresh2: View {
        @State var offset : CGPoint = .zero
        @State var contentSize : CGSize = .zero
        @State var scrollViewRect : CGRect = .zero
        @State var items = (0 ..< 50).map { "Item \($0)" }
        @State var isTopRefreshing = false
        @State var isBottomRefreshing = false


        var top : CGFloat {
            return self.offset.y
        }
        private var bottomLocation : CGFloat {
            if contentSize.height >= scrollViewRect.height {
                return self.contentSize.height + self.top - self.scrollViewRect.height + 32
            }
            return top + 32
        }
        private var shouldTopRefresh : Bool {
            return self.top > 80
        }
        private var shouldBottomRefresh : Bool {
            return self.bottomLocation < -80 + 32
        }
        func watchOffset() -> Binding<CGPoint> {
            return .init(get: {
                return self.offset
            },set: {
                print("watched : offset= \($0)")
                self.offset = $0
            })
        }

        private func computeOffset() -> CGFloat {

            if isTopRefreshing {
                print("OFFSET: isTopRefreshing")
                return 32
            } else if isBottomRefreshing {
                if (contentSize.height+32) < scrollViewRect.height {
                    print("OFFSET: isBottomRefreshing 1")
                    return top
                } else if scrollViewRect.height > contentSize.height  {

                    print("OFFSET: isBottomRefreshing 2")
                    return 32 - (scrollViewRect.height - contentSize.height)
                } else {

                    print("OFFSET: isBottomRefreshing 3")
                    return scrollViewRect.height - contentSize.height - 32
                }
            }

            print("OFFSET: fall back->\(top)")
            return top
        }

        func watchScrollViewRect() -> Binding<CGRect> {
            return .init(get: {
                return self.scrollViewRect
            },set: {
                print("watched : scrollViewRect= \($0)")
                self.scrollViewRect = $0
            })
        }
        func watchContentSize() -> Binding<CGSize> {
            return .init(get: {
                return self.contentSize
            },set: {
                print("watched : contentSize= \($0)")
                self.contentSize = $0
            })
        }
        func newDragGuesture() -> some Gesture {
            return DragGesture()
                .onChanged { _ in
                    print("> drag changed")
                }
            .onEnded { _ in
                DispatchQueue.main.async {
                    print("> drag ended")
                    self.isTopRefreshing = self.shouldTopRefresh
                    self.isBottomRefreshing = self.shouldTopRefresh
                    withAnimation {
                        self.offset = CGPoint.init(x: self.offset.x, y: self.computeOffset())
                    }

                }
            }
        }
        @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

        var body: some View {
            VStack {
                Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
                    Text("Back")
                }
                ZStack {
                    OffsetScrollView(.vertical, showsIndicators: true,
                                     offset: self.watchOffset(),
                                     contentSize: self.watchContentSize(),
                                     scrollViewFrame: self.watchScrollViewRect())
                    {
                        VStack {
                            ForEach(self.items, id: \.self) { item in
                                HStack {
                                    Text("\(item)")
                                        .font(.system(Font.TextStyle.title))
                                        .fontWeight(.regular)
                                        //.frame(width: geo.size.width)
                                        //.background(Color.blue)
                                        .padding(.horizontal, 8)
                                    Spacer()
                                }
                                    //.background(Color.red)
                                    .padding(.bottom, 8)

                            }
                        }//.background(Color.clear)


                    }.edgesIgnoringSafeArea(.horizontal)
                        .background(Color.red)
     //.simultaneousGesture(self.newDragGuesture())
                    VStack {
                        ArrowShape()
                            .stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
                            .fill(Color.black)
                            .frame(width: 12, height: 16)
                            .padding(.all, 2)
                            //.animation(nil)
                            .rotationEffect(.degrees(self.shouldTopRefresh ? -180 : 0))
                            .animation(.linear(duration: 0.2))
                            .transformEffect(.init(translationX: 0, y: self.top - 32))
                            .animation(nil)
                            .opacity(self.isTopRefreshing ? 0 : 1)


                        Spacer()

                        ArrowShape()
                            .stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
                            .fill(Color.black)
                            .frame(width: 12, height: 16)
                            .padding(.all, 2)
                            //.animation(nil)
                            .rotationEffect(.degrees(self.shouldBottomRefresh ? 0 : -180))
                            .animation(.linear(duration: 0.2))
                            .transformEffect(.init(translationX: 0, y: self.bottomLocation))
                            .animation(nil)
                            .opacity(self.isBottomRefreshing ? 0 : 1)
                    }
    //                Color.init(.sRGB, white: 0.2, opacity: 0.7)
    //
    //                    .simultaneousGesture(self.newDragGuesture())
                }

                .clipped()
                .clipShape(Rectangle())


                Text("Offset: \(String(describing: self.offset))")
                Text("contentSize: \(String(describing: self.contentSize))")
                Text("scrollViewRect: \(String(describing: self.scrollViewRect))")

            }
        }
    }


    //https://zacwhite.com/2019/scrollview-content-offsets-swiftui/
    public struct OffsetScrollView<Content>: View where Content : View {

        /// The content of the scroll view.
        public var content: Content

        /// The scrollable axes.
        ///
        /// The default is `.vertical`.
        public var axes: Axis.Set

        /// If true, the scroll view may indicate the scrollable component of
        /// the content offset, in a way suitable for the platform.
        ///
        /// The default is `true`.
        public var showsIndicators: Bool
        /// The initial offset of the view as measured in the global frame
        @State private var initialOffset: CGPoint?

        /// The offset of the scroll view updated as the scroll view scrolls
        @Binding public var scrollViewFrame: CGRect
        @Binding public var offset: CGPoint
        @Binding public var contentSize: CGSize

        public init(_ axes: Axis.Set = .vertical,
                    showsIndicators: Bool = true,
                    offset: Binding<CGPoint> = .constant(.zero),
                    contentSize: Binding<CGSize> = .constant(.zero) ,
                    scrollViewFrame: Binding<CGRect> = .constant(.zero),
                    @ViewBuilder content: () -> Content) {
            self.axes = axes
            self.showsIndicators = showsIndicators
            self._offset = offset
            self._contentSize = contentSize
            self.content = content()
            self._scrollViewFrame = scrollViewFrame

        }
        public var body: some View {
            ZStack {

                GeometryReader { geometry in
                    Run {
                        let frame = geometry.frame(in: .global)
                        self.$scrollViewFrame.wrappedValue = frame
                    }
                }
                ScrollView(axes, showsIndicators: showsIndicators) {
                    ZStack(alignment: .leading) {
                        GeometryReader { geometry in
                            Run {
                                let frame = geometry.frame(in: .global)
                                let globalOrigin = frame.origin
                                self.initialOffset = self.initialOffset ?? globalOrigin
                                let initialOffset = (self.initialOffset ?? .zero)
                                let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y)
                                self.$offset.wrappedValue = offset
                                self.$contentSize.wrappedValue = frame.size
                            }
                        }
                        content
                    }
                }

            }
        }
    }


    struct Run: View {
        let block: () -> Void

        var body: some View {
            DispatchQueue.main.async(execute: block)
            return AnyView(EmptyView())
        }
    }



    extension CGPoint {
        func reScale(from: CGRect, to: CGRect) -> CGPoint {
            let x = (self.x - from.origin.x) / from.size.width * to.size.width + to.origin.x
            let y = (self.y - from.origin.y) / from.size.height * to.size.height + to.origin.y
            return .init(x: x, y: y)
        }
        func center(from: CGRect, to: CGRect) -> CGPoint {
            let x = self.x + (to.size.width - from.size.width) / 2 - from.origin.x + to.origin.x
            let y = self.y + (to.size.height - from.size.height) / 2 - from.origin.y + to.origin.y
            return .init(x: x, y: y)
        }
    }
    enum ArrowContentMode {
        case center
        case reScale
    }
    extension ArrowContentMode {
        func transform(point: CGPoint, from: CGRect, to: CGRect) -> CGPoint {
            switch self {
            case .center:
                return point.center(from: from, to: to)
            case .reScale:
                return point.reScale(from: from, to: to)
            }
        }
    }
    struct ArrowShape : Shape {
        let contentMode : ArrowContentMode = .center
        func path(in rect: CGRect) -> Path {
            var path = Path()


            let points = [
                CGPoint(x: 0, y: 8),
                CGPoint(x: 0, y: -8),
                CGPoint(x: 0, y: 8),
                CGPoint(x: 5.66, y: 2.34),
                CGPoint(x: 0, y: 8),
                CGPoint(x: -5.66, y: 2.34)
            ]
            let minX = points.min { $0.x < $1.x }?.x ?? 0
            let minY = points.min { $0.y < $1.y }?.y ?? 0

            let maxX = points.max { $0.x < $1.x }?.x ?? 0
            let maxY = points.max { $0.y < $1.y }?.y ?? 0


            let fromRect = CGRect.init(x: minX, y: minY, width: maxX-minX, height: maxY-minY)
            print("fromRect nx: ",minX,minY,maxX,maxY)
            print("fromRect: \(fromRect), toRect: \(rect)")

            let transformed = points.map { contentMode.transform(point: $0, from: fromRect, to: rect) }

            print("fromRect: transformed=>\(transformed)")

            path.move(to: transformed[0])
            path.addLine(to: transformed[1])
            path.move(to: transformed[2])
            path.addLine(to: transformed[3])
            path.move(to: transformed[4])
            path.addLine(to: transformed[5])


            return path
        }
    }

我需要的是一种方法,可以在用户释放滚动视图时告诉我,并且如果下拉刷新箭头超过阈值并旋转,则滚动将移动到某个偏移量(比如32),隐藏箭头并显示一个ActivityIndicator。
注意:我尝试使用DragGesture,但是:
 * it wont work on the scroll view

 * OR block the scrolling on the scrollview content
1个回答

2
您可以使用 Introspect 来获取 UIScrollView,然后从中获取 UIScrollView.contentOffset 和 UIScrollView.isDragging 的 publisher,以获取这些值的更新,并用它们来操作您的 SwiftUI 视图。

struct Example: View {
    @State var isDraggingPublisher = Just(false).eraseToAnyPublisher()
    @State var offsetPublisher = Just(.zero).eraseToAnyPublisher()

    var body: some View {
        ...
        .introspectScrollView {
            self.isDraggingPublisher = $0.publisher(for: \.isDragging).eraseToAnyPublisher()
            self.offsetPublisher = $0.publisher(for: \.contentOffset).eraseToAnyPublisher()
        }
        .onReceive(isDraggingPublisher) { 
            // do something with isDragging change
        }
        .onReceive(offsetPublisher) { 
            // do something with offset change
        }
        ...        
}


如果您想查看示例,我在我的软件包ScrollViewProxy中使用此方法来获取偏移发布者。 点击链接

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