SwiftUI Drag gesture in vertical ScrollView that contains horizontal ScrollView - ios

I want to implement a Pager that allows a user to move between two views by swiping left or right. Each of these views is a ScrollView that contains many elements (mostly VStacks and HStacks with Text elements, but there are also horizontal ScrollViews containing Text elements).
I attach a highPriorityGesture(DragGesture()) to the Pager and as the user drags I offset the views so that one view becomes visible and the other slides off the screen. In the drag gesture's .onEnded callback if translation.width predicatedTranslation.width exceeds a threshold then I switch the view (so that the user doesn't have to swipe through the whole screen, but only half of it) and if it doesn't exceed the threshold I make the view snap back to its initial position.
The problem appears when one of the two views embedded in the Pager contains a horizontal ScrollView. Then the highPriorityGesture(DragGesture()) misbehaves. What happens is the following:
the horizontal ScrollView scrolls to the left/right
the highPriorityGesture(DragGesture()) picks up that drag and .onChanged callback fires and updates the offset, but the .onEnded callback never fires
The result is that the Pager moves one view a bit to the left/right, but since the .onEnded callback doesn't fire it just leave that view there instead of making it move back to the initial position (so it might end in showing 95% of the first view and 5% of the second view).
I also tried using .gesture(DragGesture()) instead of highPriorityGesture(DragGesture()), but it still picks up the drag from the child ScrollView.
Is there any way of making the child horizontal ScrollView to exclusively handle the its drag?
EDIT:
It happens also if one of the two views is simply a vertical ScrollView without any nested horizontal ScrollViews. Somehow even though it is vertical it can be dragged diagonally.
struct Pager2View<T1: View, T2: View>: View {
let pageCount: Int
let pageOne: T1
let pageTwo: T2
#Binding var currentIndex: Int
#State private var translation: CGSize = .zero
#State private var offset: CGFloat = 0.0
func exceedsThreshold(gesture: DragGesture.Value, geometryWidth: CGFloat) -> Bool {
(abs(gesture.translation.width) > (geometryWidth / 2)) || (abs(gesture.predictedEndTranslation.width) > (geometryWidth / 2))
}
var body: some View {
GeometryReader { geometry in
HStack {
self.pageOne.frame(width: geometry.size.width)
self.pageTwo.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: self.offset)
.onChange(of: currentIndex) { value in
withAnimation {
if currentIndex == 0 {
self.translation.width = 0
} else {
self.translation.width = -geometry.size.width
}
}
}
.onChange(of: translation) { value in
withAnimation {
self.offset = max(-geometry.size.width, min(0, -geometry.size.width * CGFloat(currentIndex) + self.translation.width))
}
}
.highPriorityGesture(
DragGesture()
.onChanged { gesture in
self.translation = gesture.translation
}
.onEnded { gesture in
if exceedsThreshold(gesture: gesture, geometryWidth: geometry.size.width) {
if gesture.translation.width < 0 && exceedsThreshold(gesture: gesture, geometryWidth: geometry.size.width) && currentIndex == 0 {
withAnimation {
self.translation.width = 0
currentIndex = min(self.pageCount - 1, currentIndex + 1)
}
} else if gesture.translation.width > 0 && exceedsThreshold(gesture: gesture, geometryWidth: geometry.size.width) && currentIndex == 1 {
withAnimation {
self.translation.width = geometry.size.width
currentIndex = max(0, currentIndex - 1)
}
}
} else {
withAnimation(.interactiveSpring()) {
if currentIndex == 0 {
self.translation.width = 0
} else {
self.translation.width = -geometry.size.width
}
}
}
}
)
}
}
}

Related

How to animate view with velocity by DragGesture in SwiftUI?

Topic: I have DragGesture on my View and I want to change offset of the View by velocity value from DragGesture.
Details: I made custom sheet view and bind DragGesture to it. When user swipe down on the view it takes offset value from gesture value.translation.height. If swipe gesture is fast enough - view dissapear from screen by slide down animation.
Problem: Animation of disappearing has static speed value, but users swipe can be more faster or slower. As a result it looks like obstacle - view start moving with speed of drag gesture, but after gesture ended it change speed to value from animation.
Code of custom sheet view:
#State var offset = 0
#State var lastDragPosition: DragGesture.Value?
VStack {
Text("Sheet")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: offset)
.gesture(
DragGesture()
.onChanged(onDragChanged(value:))
.onEnded(onDragEnded(value:))
)
.ignoresSafeArea()
On view drag changed:
func onDragChanged(value: DragGesture.Value) {
if value.translation.height > 0 {
// Move view down by finger dragging
offset = value.translation.height
}
// Save last position for calculating velocity of gesture
lastDragPosition = value
}
On view drag ended:
func onDragEnded(value: DragGesture.Value) {
let timeDiff = value.time.timeIntervalSince(lastDragPosition!.time)
let speed: CGFloat = .init(
value.translation.height - lastDragPosition!.translation.height
) / CGFloat(timeDiff)
// If velocity of drag gesture high enough...
if speed > 300 {
// ...animate it to disappear
withAnimation(.interactiveSpring(
response: 0.27, // This value is hard coded!
dampingFraction: 0.78,
blendDuration: 0.25
)) {
offset = 800
}
return
} else {
// Else return view offset to initial state (top of the screen)
withAnimation(.easeOut(duration: 0.28)) {
offset = 0
}
}
}
I believe it is some kind of math issue. But maybe there is SwiftUI native solution?

How to get the current element in the screen to programmatically scroll from buttons without getting broken by the ScrollViewReader Index

I've been working on a GridView with programmatical scroll support, I've been using ScrollViewReader to programmatically scroll to the top & bottom of the GridView, but I have a trouble, I added two extra buttons to scroll 3 elements per touch, and it's working but there's new trouble with it.
If the user manually scrolls using the ScrollView, the ScrollViewReader shall not work, I mean, the extra buttons to scroll 3 elements per button touch shall not work ('cause the Index is not the current element on the Screen), I need to know the current element in the screen, or a better way to make my idea, here's my code
var body: some View {
ScrollView([.horizontal, .vertical]) {
ScrollViewReader { proxy in
Section(header: HeaderView(screenSize: $screenSize).border(Color("ApplicationTint"), width: 4)) {
ForEach(Elements.indices) { index in
Row(screenSize: $screenSize, elements: Elements[index])
.id(index)
Divider()
}
}
.onChange(of: viewModel.gridIndex, perform: { value in
withAnimation(.spring()) {
proxy.scrollTo(value, anchor: .leading)
}
})
}
}
Also, the buttons code.
Button {
viewModel.gridIndex = 0
} label: {
Image(systemName: "arrow.up.to.line")
}
Button {
viewModel.gridIndex -= 1
} label: {
Image(systemName: "arrow.up")
}
Button {
viewModel.gridIndex += 1
} label: {
Image(systemName: "arrow.down")
}
Button {
// viewModel.gridIndex = 0
viewModel.gridIndex = 0 + viewModel.totalgridIndex
} label: {
Image(systemName: "arrow.down.to.line")
}

SwiftUI DragGesture onEnded does not fire in some cases

In my app I have a swipeable card view that can be dragged left or right.
When the user drags the card left or right, the card's horizontal offset is updated. When the user releases the drag, the card moves off the screen (either left or right, depending on the drag direction) or the card goes back to the initial position if the horizontal offset did not exceed the threshold.
It works well, but if the user touches the card view with another finger while dragging and then takes his/her fingers off screen, the card position freezes, and it doesn't either move off the screen or go back to the initial position. I debugged the code and it turns out that in that case the DragGesture().onEnded event does not fire.
I am looking for any hints on how I can detect this situation.
Here is the code:
If I had something like isTouchingScreen state, I would be able to solve this.
EDIT: Here is a minimal example where the problem manifests itself.
import SwiftUI
struct ComponentPlayground: View {
#State private var isDragging: Bool = false
#State private var horizontalOffset: CGFloat = .zero
var background: Color {
if abs(horizontalOffset) > 100 {
return horizontalOffset < 0 ? Color.red : Color.green
} else {
return Color.clear
}
}
var body: some View {
GeometryReader { geometry in
Color.white
.cornerRadius(15)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
.overlay(background.cornerRadius(15))
.rotationEffect(.degrees(Double(horizontalOffset / 10)), anchor: .bottom)
.offset(x: horizontalOffset, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
self.isDragging = true
self.horizontalOffset = gesture.translation.width
}
.onEnded { gesture in
self.isDragging = false
if abs(horizontalOffset) > 100 {
withAnimation {
self.horizontalOffset *= 5
}
} else {
withAnimation {
self.horizontalOffset = .zero
}
}
}
)
.frame(width: 300, height: 500)
}
}
}
struct ComponentPlayground_Previews: PreviewProvider {
static var previews: some View {
VStack {
Spacer()
HStack(alignment: .center) {
ComponentPlayground()
}
.frame(width: 300, height: 500)
Spacer()
}
}
}
Since there is no update on the issue from Apple, I will share my workaround for this problem.
Workaround still does not provide drag functionality similar to UIPanGesture from UIKit, but still...
First I've created two GestureStates variables and keep updating them in my DragGesture:
#GestureState private var stateOffset: CGSize = CGSize.zero
#GestureState private var isGesturePressed: Bool = false
let dragGesture = DragGesture()
.updating($stateOffset) { value, gestureState, transaction in
gestureState = CGSize(width: value.translation.width, height: value.translation.height)
}
.updating($isGesturePressed) { value, gestureState, transaction in
gestureState = true
}
By updating "isGesturePressed"(by making it true while gesture is active) I will know if the gesture is actually pressed or not. And while it pressed, I do my animation code with stateOffset:
SomeView()
.offset(someCustomOffset)
.gesture(dragGesture)
.onChange(of: stateOffset) { _ in
if isGesturePressed {
withAnimation { Your Animation Code here
}
} else {
withAnimation { Your Animation Code here for when the gesture is no longer pressed. This is basically my .onEnd function now.
}
}
As I said above. It will not provide the usually UX (e.g. Twitter side menu), cause our animation just immeadiatly ends when another finger presses the screen during the on-going animation. But at least it is no longer stuck mid-way.
There are definitely more elegant and better written workarounds for this, but I hope it may help someone.

SwiftUI Drag to Dismiss ScrollView

I'd like to have a drag to dismiss scroll view in SwiftUI, where if you keep dragging when it's at the top of the content (offset 0), it will instead dismiss the view.
I'm working to implement this in SwiftUI and finding it to be rather difficult. It seems like I can either recognize the DragGesture or allowing scrolling, but not both.
I need to avoid using UIViewRepresentable and solve this using pure SwiftUI or get as close as possible. Otherwise it can make developing other parts of my app difficult.
Here's an example of the problem I'm running into:
import SwiftUI
struct DragToDismissScrollView: View {
enum SeenState {
case collapsed
case fullscreen
}
#GestureState var dragYOffset: CGFloat = 0
#State var scrollYOffset: CGFloat = 0
#State var seenState: SeenState = .collapsed
var body: some View {
GeometryReader { proxy in
ZStack {
Button {
seenState = .fullscreen
} label: {
Text("Show ScrollView")
}
/*
* Works like a regular ScrollView but provides updates on the current yOffset of the content.
* Can find code for OffsetAwareScrollView in link below.
* Left out of question for brevity.
* https://gist.github.com/robhasacamera/9b0f3e06dcf27b54962ff0e077249e0d
*/
OffsetAwareScrollView { offset in
self.scrollYOffset = offset
} content: {
ForEach(0 ... 100, id: \.self) { i in
Text("Item \(i)")
.frame(maxWidth: .infinity)
}
}
.background(Color.white)
// If left at the default minimumDistance gesture isn't recognized
.gesture(DragGesture(minimumDistance: 0)
.updating($dragYOffset) { value, gestureState, _ in
// Only want to start dismissing if at the top of the scrollview
guard scrollYOffset >= 0 else {
return
}
gestureState = value.translation.height
}
.onEnded { value in
if value.translation.height > proxy.frame(in: .local).size.height / 4 {
seenState = .collapsed
} else {
seenState = .fullscreen
}
})
.offset(y: offsetForProxy(proxy))
.animation(.spring())
}
}
}
func offsetForProxy(_ proxy: GeometryProxy) -> CGFloat {
switch seenState {
case .collapsed:
return proxy.frame(in: .local).size.height
case .fullscreen:
return max(dragYOffset, 0)
}
}
}
Note: I've tried a lot solutions for the past few days (none that have worked), including:
Adding a delay to the DragGesture using the method mentioned here: https://stackoverflow.com/a/59961959/898984
Adding an empty onTapGesture {} call before the DragGesture as mentioned here: https://stackoverflow.com/a/60015111/898984
Removing the gesture and using the offset provided from the OffsetAwareScrollView when it's > 0. This doesn't work because as the ScrollView is moving down the offset decreases as the OffsetAwareScrollView catches up to the content.

SwiftUI ScrollView with Tap and Drag gesture

I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:
The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
Long pressing an entry should activate a drag gesture, so items can be moved around.
The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).
Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.
Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?
Thanks for helping me out!
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#GestureState var dragging = CGSize.zero
var body: some View {
Circle()
.frame(width: 100, height: 100)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragging, body: {t, state, _ in
state = t.translation
}))
.offset(dragging)
}
}
I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#State private var offset = CGSize.zero
#State private var isDragging = false
#GestureState var isTapping = false
var body: some View {
// Gets triggered immediately because a drag of 0 distance starts already when touching down.
let tapGesture = DragGesture(minimumDistance: 0)
.updating($isTapping) {_, isTapping, _ in
isTapping = true
}
// minimumDistance here is mainly relevant to change to red before the drag
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isDragging = false
}
}
let pressGesture = LongPressGesture(minimumDuration: 1.0)
.onEnded { value in
withAnimation {
isDragging = true
}
}
// The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
let combined = pressGesture.sequenced(before: dragGesture)
// The new combined gesture is set to run together with the tapGesture.
let simultaneously = tapGesture.simultaneously(with: combined)
return Circle()
.overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
.frame(width: 100, height: 100)
.foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
.offset(offset)
.gesture(simultaneously)
}
}
For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.
OpenScrollView for SwiftUI on Github
Credit to
https://stackoverflow.com/a/59897987/12764795
http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui

Resources