The Problem
I have a UIScrollView containing a UIView that I wish to allow the user to pan using a UIPanGestureRecognizer.
In order for this to work as desired, users should be able to pan the view with one finger, but also be able to pan the scroll view with another finger - doing both at the same time (using one finger for each).
However, the scroll view ceases to work when the user is panning a view contained within it. It cannot be panned until the view's pan gesture ends.
Attempted Workaround
I tried to work around this by enabling simultaneous scrolling of both the pan view and the UIScrollView that contains it by overriding the following UIGestureRecognizerDelegate method:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
However, this makes it so that panning the view also moves the scroll view. Each element's panning gesture should be independent of the other, not linked.
Demo Project
I have created a simple demo project that should demonstrate this, here:
https://github.com/jeffc-dev/ScrollViewPannerTest
This project contains a scroll view with a square view that should be able to be panned independently of its containing scroll view, but can not.
Why I'm Doing This
The point of this is to make it easier/quicker for a user to find a destination to pan the view to. The is somewhat analogous to rearranging icons in Springboard: You can use one finger to pan an app icon while simultaneously panning between pages with another finger, quickly finding a place to drop it. I'm not using a paged scroll view - just a normal one - and I want it to be a seamless panning gesture (I don't need/want the user to have to enter a 'wiggle mode') but the basic principle is the same.
UPDATE: DonMag helpfully came up with the idea of using a UILongPressGestureRecognizer to move the view out of the scroll view for panning, which does seem promising. However, if I went that route I think I'd need to seamlessly transition to using a UIPanGestureRecognizer after doing so (as I do use some pan gesture recognizer-specific functionality).
I'm sure there are different ways to do this, but here is one approach...
Instead of using a UIPanGesture I used a UILongPressGesture.
When the gesture begins, we move the view from the scrollView to its superview. While we continue to press the view and drag it around, it is now independent of the scrollView. When we end the gesture (lift the finger), we add the view back to the scrollView.
While dragging, we can use a second finger to scroll the content of the scroll view.
The main portion of the code looks like this:
#objc func handleLongPress(_ g: UILongPressGestureRecognizer) -> Void {
switch g.state {
case .began:
// get our superview and its superview
guard let sv = superview as? UIScrollView,
let ssv = sv.superview
else {
return
}
theScrollView = sv
theRootView = ssv
// convert center coords
let cvtCenter = theScrollView.convert(self.center, to: theRootView)
self.center = cvtCenter
curCenter = self.center
// add self to ssv (removes self from sv)
ssv.addSubview(self)
// start wiggling anim
startAnim()
// inform the controller
startCallback?(self)
case .changed:
guard let thisView = g.view else {
return
}
// get the gesture point
let point = g.location(in: thisView.superview)
// Calculate new center position
var newCenter = thisView.center;
newCenter.x += point.x - curCenter.x;
newCenter.y += point.y - curCenter.y;
// Update view center
thisView.center = newCenter
curCenter = newCenter
// inform the controller
movedCallback?(self)
default:
// stop wiggle anim
stopAnim()
// convert center to scroll view (original superview) coords
let cvtCenter = theRootView.convert(curCenter, to: theScrollView)
// update center
self.center = cvtCenter
// add self back to scroll view
theScrollView.addSubview(self)
// inform the controller
endedCallback?(self)
}
}
I forked your GitHub repo and added a new controller to demonstrate: https://github.com/DonMag/ScrollViewPannerTest
You'll see that it is just a Starting Point for this approach. The view being dragged (actually, in this demo, you can use two fingers to drag two views at the same time) uses closures to inform the controller about the dragging...
Currently, "drag/drop" does not affect any other subviews in the scrollView. The only closure that does anything is the "ended" closure, at which point the controller re-calcs the scrollView's contentSize. The "moved" closure could be used to re-position views -- but that's another task.
Related
I'm new to coding so I'm trying some small projects in swift. Right now, I'm trying to make a text box inside the ViewController move when the user drags it along the screen. For the text box, I am currently using a UITextField but I have no idea how to program its movement according to drag.
You'll want to add a UIPanGestureRecognizer to your view. There's all sorts of built in gesture recognizers for detecting various gestures like a tap or in this case a pan (drag). You can check them out here: https://developer.apple.com/documentation/uikit/uigesturerecognizer
Here we'll create a pan gesture recognizer, and add it to our view. Assume myView is your UITextField. A good place to do this is in your view controller's viewDidLoad() method.
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(sender:)))
myView.addGestureRecognizer(pan)
The moment your finger touches the screen, we say that a touch sequence has begun. The touch sequence ends when there are no more fingers on the screen. The pan gesture will determine if this touch sequence looks like a pan, and if so, the method handlePan will be called at various stages. Here, the gesture itself will be passed into the method, which we use to determine translation and move our view accordingly. Add this as a method of your view controller.
#objc func handlePan(sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: sender.view)
self.myView.center.x += translation.x
self.myView.center.y += translation.y
sender.setTranslation(CGPoint.zero, in: sender.view)
}
The first line gets the translation in the view which the gesture is attached to (myView). We then adjust myView's position based on this translation, and then we set the translation to zero. This is so that the next time this method is called, the translation will be a delta relative to the previous call.
The property sender.state will tell you the state the gesture is currently in, for example, .began, .changed, .ended. Since a pan is a continuous gesture, our method will be called many times, whenever there's a finger movement.
I have a UIView that contains many UIView subviews. When the user tries to pan gesture one of the subviews, I want them to be able to drag it anywhere else on the screen.
However, I can only drag these smaller views within the bigger UIView they are contained in. When the user first tries to pan gesture the smaller view, is there any way to programmatically create a second UIView on top and then drag around the second UIView instead? The second UIView would be completely new from the smaller view that was first touched.
This is the handler function I have so far, just for reference. I don't know where I would programmatically create the second UIView, though. Any help would be greatly appreciated:
#objc func handlePanGesture(sender: UIPanGestureRecognizer) {
// get translation
var translation = sender.translation(in: view)
sender.setTranslation(CGPoint.zero, in: view)
// drag around current UIView
var newView = sender.view as! UIView
newView.center = CGPoint(x: newView.center.x+translation.x, y: newView.center.y+translation.y)
newView.isMultipleTouchEnabled = true
newView.isUserInteractionEnabled = true
if sender.state == UIGestureRecognizer.State.began {
// add something you want to happen when the Label Panning has started
}
if sender.state == UIGestureRecognizer.State.ended {
// add something you want to happen when the Label Panning has ended
}
if sender.state == UIGestureRecognizer.State.changed {
// add something you want to happen when the Label Panning has been change ( during the moving/panning )
} else {
// or something when it's not moving
}
}
Is there any way to be able to drag the smaller views anywhere while maintaining the same layout I have right now
Because of the way touch works, it is impossible by default for a view to be touched when it reaches the boundaries of its superview. But you can overcome that limitation by overriding hitTest in the superview.
A very common pattern that enables dragging of views outside of their superview, is to create a snapshot or clone of the original view as soon as the gesture begins, you can of course hide the original view so to the user it feels like the same UI element is being dragged. It's a pattern used by Apple in their drag and drop sample code here
https://developer.apple.com/documentation/uikit/drag_and_drop/adopting_drag_and_drop_in_a_custom_view
Have a look at line 36 in ViewController+Drag.swift for the relevant code.
I have a custom UIView that consists of 9 equally-sized subviews. It essentially looks like a tic-tac-toe board.
I've added a UIPanGestureRecognizer to this custom view. Each time the user pans over one of the subviews, I want to take an action. For example, the user could pan over the first 3 (of the 9) subviews in one gesture, and in this case I'd want to take 3 actions.
I could try to do some fancy math and figure out the frame of each subview, then figure out when the gesture crosses from one subview to another. However, I feel like there should be a more elegant way to get a callback when a gesture touches a new UIView. Does a functionality like this exist?
I was able to find a more elegant way by using hitTest, which returns the lowest subview with user interaction enabled. I defined the callback for the pan gesture recognizer as such:
var panSelected = Set<UILabel>()
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
let view = recognizer.view
let loc = recognizer.location(in: view)
if let gridLabel = view?.hitTest(loc, with: nil) as? UILabel {
if !panSelected.contains(gridLabel) {
// my code here
}
}
try to use stack view to group views have same gesture, & using
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(stackViewTapped))
myStackView.addGestureRecognizer(tapGesture)
if there's different method needs to be implemented, just add another stack view.
I use a ImageScrollView from here, which is basically a UIScrollView to allow pinch to zoom into a picture. I now wanted to add the possibility to swipe down the picture to dismiss the view controller. I created a UIPanGestureRecognizer and it works fine if zoom scale is at the minimum value (so the whole picture is visible without zoom). But how can I skip the pan gesture recognizer if the zoom scale is above the minimum value? Because it lays on top of the ImageScrollView, I can't scroll in the picture because scrolling gesture is fetched by the UIPanGestureRecognizer. Any idea how to solve this?
For show image like whatsApp imageView functionality you go with the apple framework QuikLook. It will automatically handle Zoom, Dismiss the image while Swipe etc.
It also support for the documents. It will reduce your effort a lot
It's too easy to handle this operation by adding a trigger on swipe action (gesture calling method).
When zoomScale > minimumValue; set returnstatement
A simple example with Swift 4:
let zoomScale: 1.0
let minimumValue: 0.5
func handlePanGesture(gesture: UIPanGestureRecognizer) {
if (#<set pan gesture down moving condition>#) {
if (zoomScale > minumumValue) {
return
}
}
// perform your next operations
}
I have a UIScrollView that's around 600 pixels in height and 320 in width. So I'm allowing the user to scroll vertically.
I'm also trying to capture horizontal swipes on the view. The problem seems to be that when a user swipes horizontally with some accidental vertical movement, the UIScrollView scrolls and my touchesEnded delegate method never gets called.
Here's my code:
- (void)touchesEnded: (NSSet *)touches withEvent: (UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint currentPosition = [touch locationInView:self];
if (currentPosition.x + 35 < gestureStartPoint.x)
{
NSLog(#"Right");
}
else if (currentPosition.x - 35 > gestureStartPoint.x)
{
NSLog(#"Left");
}
else if (!self.dragging)
{
[self.nextResponder touchesEnded: touches withEvent:event];
}
[super touchesEnded: touches withEvent: event];
}
Does anyone know how I can get this to work even when there is vertical drag involved?
UIScrollView tries to figure out which touches to pass through to its contents, and which are scrolls, based on movement immediately after a touch begins. Basically, if the touch appears to be a scroll right away, it handles that gesture and the contents never see it; otherwise, the gesture gets passed through (with a very short delay for the first touch).
In my experience, I've been able to capture horizontal swipes in the contents of a UIScrollView that handled vertical-only scrolling -- it basically just worked by default for me. I did this by setting the contentSize to be the same as the width of the scroll view's frame, which is enough information to tell the UIScrollView that it won't be handling horizontal scrolling.
It sounds like you're having trouble with the default behavior, though. One hardware gotcha is that it's very hard to simulate a finger swipe on a laptop's trackpad. If I were you, I would test out the default UIScrollView setup using either a mouse or, preferably, on the device itself. I found that these input methods work much better for conveying swipes.
If that doesn't work, here is a very pertinent paragraph from Apple's UIScrollView docs:
Because a scroll view has no scroll bars, it must know whether a touch signals an intent to scroll versus an intent to track a subview in the content. To make this determination, it temporarily intercepts a touch-down event by starting a timer and, before the timer fires, seeing if the touching finger makes any movement. If the time fires without a significant change in position, the scroll view sends tracking events to the touched subview of the content view. If the user then drags their finger far enough before the timer elapses, the scroll view cancels any tracking in the subview and performs the scrolling itself. Subclasses can override the touchesShouldBegin:withEvent:inContentView:, pagingEnabled, and touchesShouldCancelInContentView: methods (which are called by the scroll view) to affect how the scroll view handles scrolling gestures.
In summary, you could try what they suggest by subclassing UIScrollView and overriding the suggested methods.
If #Tyler's method doesn't work, try putting a view right over the UIScrollView, and handle any horizontal swipes in that view's touchesBegan, and pass vertical ones to the next responder. You can be a little fuzzy and handle anything that has more of a horizontal movement than vertical as a horizontal swipe, and pass the more pure vertical swipes to the UISCrollView (via nextResponder).
Swift 4 solution
Register two gesture recognizers in viewDidLoad() in your view controller
let leftGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
leftGesture.direction = .left
leftGesture.delegate = self
view.addGestureRecognizer(leftGesture)
let rightGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
rightGesture.direction = .right
rightGesture.delegate = self
view.addGestureRecognizer(rightGesture)
Implement swipe action
#objc func handleSwipe(_ sender: UISwipeGestureRecognizer) {
// do swipe left/right based on sender.direction == .left / .right
}
In order to have simultaneous horizontal swipe and vertical scroll, let your view controller implement UIGestureRecognizerDelegate
class MyViewController: UIViewController, UIGestureRecognizerDelegate
...
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
The last step fixes the problem that when you move more than +/- 5 pixels on the y-axis the Scroll view takes over and starts scrolling vertically.