SwiftUI Scroll/List Scrolling Events

爱⌒轻易说出口 提交于 2021-02-18 17:21:27

问题


lately I have been trying to create a pull to (refresh, load more) swiftUI Scroll View !!, inspired by https://cocoapods.org/pods/SwiftPullToRefresh

I was struggling to get the offset and the size of the content. but now I am struggling to get the event when the user releases the scroll view to finish the UI.

here is my current code:

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

what I need is a way to tell when the user releases the scrollview, and if the pull to refresh arrow passed the threshold and was rotated, the scroll will move to a certain offset (say 32), and hide the arrow and show an ActivityIndicator.

NOTE: I tried using DragGesture but:

 * it wont work on the scroll view

 * OR block the scrolling on the scrollview content

回答1:


You can use Introspect to get the UIScrollView, then from that get the publisher for UIScrollView.contentOffset and UIScrollView.isDragging to get updates on those values which you can use to manipulate your SwiftUI views.


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
        }
        ...        
}

If you want to look at an example; I use this method to get the offset publisher in my package ScrollViewProxy.



来源:https://stackoverflow.com/questions/59267131/swiftui-scroll-list-scrolling-events

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!