SwiftUI 滚动/列表滚动事件

Posted

技术标签:

【中文标题】SwiftUI 滚动/列表滚动事件【英文标题】:SwiftUI Scroll/List Scrolling Events 【发布时间】:2019-12-10 12:22:18 【问题描述】:

最近我一直在尝试创建一个拉动(刷新,加载更多)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),并隐藏箭头和显示一个 ActivityIndi​​cator。

注意:我尝试使用 DragGesture 但是:

 * it wont work on the scroll view

 * OR block the scrolling on the scrollview content

【问题讨论】:

【参考方案1】:

您可以使用Introspect 获取 UIScrollView,然后从中获取 UIScrollView.contentOffset 和 UIScrollView.isDragging 的发布者,以获取可用于操作 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。

【讨论】:

以上是关于SwiftUI 滚动/列表滚动事件的主要内容,如果未能解决你的问题,请参考以下文章

当 Swiftui Picker 滚动其列表时触发的事件

SwiftUI Drag 事件如何限制仅检测水平/垂直滚动

SwiftUI:如何让滚动视图包含完整列表长度

滚动视图中的内容作为列表项在滚动(swiftui)时消失,为啥?

SwiftUI 在列表滚动期间停止更新

滚动列表时 SwiftUI 崩溃