问题
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