How can I detect when a ScrollView is being dragged?
Within my ScrollView I have an #Binding scrollPositionOffset variable that I watch with .onChange(of:) and then programmatically scroll to that position using ScrollViewReader.scrollTo(). This works great, but I need to also update scrollPositionOffset when I scroll the ScrollView directly. I'm struggling to do that as this would trigger the .onChange(of:) closure and get into a loop.
My solution is to conditionally call ScrollViewReader.scrollTo() only when I have a localScrolling variable set to false. I've tried to set this using DragGesture.onChanged and .onEnded, but this isn't the same as the drag gesture that causes the scroll, so .onEnded never fires.
What I think I need is a #GestureRecognizer for ScrollView similar to UIScrollView's isDragging or isTracking (I'm aware I could use UIScrollView, but I don't know how, and that seems like it might be more work!! I'd accept an answer that shows me how to drop that into a SwiftUIView too)
Context (in case anyone has a cleaner solution to my actual scenario):
I have a ScrollView that I'm programmatically scrolling to create an effect like the Minimap view within Xcode (i.e. I have a zoomed-out view adjacent to the ScrollView, and dragging the minimap causes the ScrollView to scroll).
This works great when I use the minimap, but I'm struggling to get the reverse to happen: moving the position of the ScrollView to update the minimap view.
Code
#Binding var scrollPositionOffset: CGFloat
let zoomMultiplier:CGFloat = 1.5
var body: some View{
ScrollViewReader { scrollViewProxy in
GeometryReader{ geometry in
ScrollView {
ZStack(alignment:.top){
//The content of my ScrollView
MagnifierView()
.frame(height: geometry.size.height * zoomMultiplier)
//I'm using this as my offset reference
Rectangle()
.frame(height:10)
.alignmentGuide(.top) { _ in
geometry.size.height * zoomMultiplier * -scrollPositionOffset
}
.id("scrollOffset")
}
}
.onAppear(){
scrollViewProxy.scrollTo("scrollOffset", anchor: .top)
}
.onChange(of: scrollPositionOffset, perform: { _ in
//Only call .scrollTo() if the view isn't already being scrolled by the user
if !localScrolling {
scrollViewProxy.scrollTo("scrollOffset", anchor: .top)
}
})
.gesture(
DragGesture()
.onChanged{gesture in
localScrolling = true
let offset = gesture.location.y/(zoomMultiplier * geometry.size.height)
scrollPositionOffset = offset
}
.onEnded({gesture in
//Doesn't ever fire when scrolling
localScrolling = false
})
)
}
}
}
Using ScrollViewStyle:
struct CustomScrollView: ScrollViewStyle {
#Binding var isDragging: Bool
func make(body: AnyView, context: Context) -> some View {
body
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: ScrollViewCoordinator {
var parent: CustomScrollView
init(parent: CustomScrollView) {
self.parent = parent
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
parent.isDragging = false
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
parent.isDragging = true
}
}
}
struct TestView: View {
#State var isDragging = false
var body: some View {
ScrollView {
}.scrollViewStyle(CustomScrollView(isDragging: $isDragging))
}
}
Related
I want to set the Zoom scale of my PKCanvasView()
I tried adding this function:
func setCanvasZoom() {
canvasView.zoomScale = 2.0
print("CanvasView: \(canvasView.zoomScale)")
}
But that function always throws 1.0 in the console...
I am calling the setCanvasView() function, after my ScrollView appeared:
ZStack {
ScrollView {
VStack {
CanvasView(canvasView: $canvasView)
}
}.onAppear(perform: {
setCanvasZoom()
})
}
The canvasView variable is declared like that:
#State var canvasView = PKCanvasView()
Do you guys have any Idea why this happens?
I am triggering animation after unhiding the view unfortunately animation is not working unless I tap twice
struct ContentView: View {
#State var animate = false
#State var isViewHidden: Bool = true
var body: some View {
VStack {
ZStack {
Circle()
.fill(.blue).opacity(0.25).frame(width: 40, height: 40).offset(y: self.animate ? 0 : 60)
.hides(isViewHidden)
}
.animation((Animation.linear(duration: 1.5).repeatForever(autoreverses: true))
, value: self.animate ? 0 : 60)
Spacer()
Button("Tap here") {
self.isViewHidden = false
self.animate.toggle()
}
}
.padding()
}
}
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
if isHidden {
hidden()
} else {
self
}
}
}
SwiftUI uses a before view and an after view to animate. You are introducing the view to animate at the same time you are updating self.animate, so Swift doesn't have a before view to use for the animation.
Change your View extension to this:
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
self.opacity(isHidden ? 0 : 1)
}
}
This leaves the view onscreen at all times, but just hides it by making it invisible. That way, the view is there to animate from the start.
I'm trying to make my ScrollView:
Not bounce when the content is smaller than the screen
Bounce when the content overflows the screen
Here's my code:
struct ContentView: View {
init() {
UIScrollView.appearance().alwaysBounceVertical = false
}
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.frame(height: 300) /// is smaller than the screen
.padding()
}
}
}
I tried setting UIScrollView.appearance().alwaysBounceVertical = false, but the scroll view still bounces:
If I do UIScrollView.appearance().bounces = false, it stops bouncing. However, if I make the rectangle taller than the screen, it also stops bouncing (which I don't want).
Doesn't bounce (yay!)
... but doesn't bounce when the content overflows the screen
How can I disable bouncing, but only when the content is smaller than scroll view's bounds?
Well, when using SwiftUI-Introspect, setting alwaysBounceVertical to false actually works. I'm not exactly sure why setting the appearance as you did doesn't work though...
Anyway, here is some working example code:
struct ContentView: View {
#State private var count = 5
var body: some View {
VStack {
Picker("Count", selection: $count) {
Text("5").tag(5)
Text("100").tag(100)
}
.pickerStyle(SegmentedPickerStyle())
ScrollView {
VStack {
ForEach(1 ... count, id: \.self) { i in
Text("Item: \(i)")
}
}
.frame(maxWidth: .infinity)
}
.introspectScrollView { scrollView in
scrollView.alwaysBounceVertical = false
}
}
}
}
Result (unfortunately you can't see my mouse trying to drag the shorter list):
From iOS 16.4 onwards, there is a new modifier for called .scrollBounceBehavior that can be used to prevent ScrollView from bouncing when the content is smalled than the screen.
Example:
struct ContentView: View {
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.frame(height: 300)
.padding()
}
.scrollBounceBehavior(.basedOnSize)
}
}
Made a small extension based on George's answer using SwiftUI-Introspect:
extension View {
func disableBouncingWhenNotScrollable() -> some View {
introspectScrollView { scrollView in
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
}
}
}
Usage:
ScrollView {
// content
}
.disableBouncingWhenNotScrollable()
I'm struggling to implement TapGesture and LongPressGesture simultaneously in a ScrollView. Everything works fine with .onTapGesture and .onLongPressGesture, but I want that the opacity of the button gets reduced when the user taps on it, like a normal Button().
However, a Button() doesn't have an option to do something on a long press for whatever reason. So I tried to use .gesture(LongPressGesture() ... ). This approach works and shows the tap indication. Unfortunately, that doesn't work with a ScrollView: you can't scroll it anymore!
So I did some research and I found out that there has to be a TapGesture before the LongPressGesture so ScrollView works properly. That's the case indeed but then my LongPressGesture doesn't work anymore.
Hope somebody has a solution...
struct ContentView: View {
var body: some View {
ScrollView(.horizontal){
HStack{
ForEach(0..<5){ _ in
Button()
}
}
}
}
}
struct Button: View{
#GestureState var isDetectingLongPress = false
#State var completedLongPress = false
var body: some View{
Circle()
.foregroundColor(.red)
.frame(width: 100, height: 100)
.opacity(self.isDetectingLongPress ? 0 : 1)
// That works, but there is no indication for the user that the UI recognized the gesture
// .onTapGesture {
// print("Tapped!")
// }
// .onLongPressGesture(minimumDuration: 0.5){
// print("Long pressed!")
// }
// The approach (*) shows the press indication, but the ScrollView is stuck because there is no TapGesture
// If I add a dummy TapGesture, the LongPressGesture won't work anymore but now the ScrollView works as expected
//.onTapGesture {}
// (*)
.gesture(LongPressGesture()
.updating(self.$isDetectingLongPress) { currentstate, gestureState,
transaction in
gestureState = currentstate
}
.onEnded { finished in
self.completedLongPress = finished
}
)
}
}
I've tried many combinations of trying onTapGesture + LongPressGesture + custom timings and animations and many work /almost/ but leave minor annoyances. This is what I found that works perfectly. Tested on iOS 13.6.
With this solution your scroll view still scrolls, you get the button depression animation, long pressing on the button works too.
struct MainView: View {
...
Scrollview {
RowView().highPriorityGesture(TapGesture()
.onEnded { _ in
// The function you would expect to call in a button tap here.
})
}
}
struct RowView: View {
#State var longPress = false
var body: some View {
Button(action: {
if (self.longPress) {
self.longPress.toggle()
} else {
// Normal button code here
}
}) {
// Buttons LaF here
}
// RowView code here.
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in
self.longPress = true
})
}
}
I have a view like below. I want to find out if it is the view which is displayed on the screen. Is there a function to achieve this?
struct TestView: View {
var body: some View {
Text("Test View")
}
}
You could use onAppear on any kind of view that conforms to View protocol.
struct TestView: View {
#State var isViewDisplayed = false
var body: some View {
Text("Test View")
.onAppear {
self.isViewDisplayed = true
}
.onDisappear {
self.isViewDisplayed = false
}
}
func someFunction() {
if isViewDisplayed {
print("View is displayed.")
} else {
print("View is not displayed.")
}
}
}
PS: Although this solution covers most cases, it has many edge cases that has not been covered. I'll be updating this answer when Apple releases a better solution for this requirement.
You can check the position of view in global scope using GeometryReader and GeometryProxy.
struct CustomButton: View {
var body: some View {
GeometryReader { geometry in
VStack {
Button(action: {
}) {
Text("Custom Button")
.font(.body)
.fontWeight(.bold)
.foregroundColor(Color.white)
}
.background(Color.blue)
}.navigationBarItems(trailing: self.isButtonHidden(geometry) ?
HStack {
Button(action: {
}) {
Text("Custom Button")
} : nil)
}
}
private func isButtonHidden(_ geometry: GeometryProxy) -> Bool {
// Alternatively, you can also check for geometry.frame(in:.global).origin.y if you know the button height.
if geometry.frame(in: .global).maxY <= 0 {
return true
}
return false
}
As mentioned by Oleg, depending on your use case, a possible issue with onAppear is its action will be performed as soon as the View is in a view hierarchy, regardless of whether the view is potentially visible to the user.
My use case is wanting to lazy load content when a view actually becomes visible. I didn't want to rely on the view being encapsulated in a LazyHStack or similar.
To achieve this I've added an extension onBecomingVisible to View that has the same kind of API as onAppear, but will only call the action when the view intersects the screen's visible bounds.
public extension View {
func onBecomingVisible(perform action: #escaping () -> Void) -> some View {
modifier(BecomingVisible(action: action))
}
}
private struct BecomingVisible: ViewModifier {
#State var action: (() -> Void)?
func body(content: Content) -> some View {
content.overlay {
GeometryReader { proxy in
Color.clear
.preference(
key: VisibleKey.self,
// See discussion!
value: UIScreen.main.bounds.intersects(proxy.frame(in: .global))
)
.onPreferenceChange(VisibleKey.self) { isVisible in
guard isVisible else { return }
action?()
action = nil
}
}
}
}
struct VisibleKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) { }
}
}
Discussion
I'm not thrilled by using UIScreen.main.bounds in the code! Perhaps a geometry proxy could be used for this instead, or some #Environment value – I've not thought about this yet though.