How to disable paging of UIPageViewController only for landscape orientation? - ios

I have a scroll transition style UIPageViewController that needs to disable paging only when device is in landscape orientation. But paging should be enabled in portrait orientation.
I have encountered similar questions here in SO but not my specific need. Some of them are:
How do I Disable the swipe gesture of UIPageViewController?
Disable Page scrolling in UIPageViewController
Disable/enable scrolling in UIPageViewController
Restrict UIPageViewController (with TransitionStyleScroll) pan gesture to a certain area
All of the above points to either completely disabling or restricting pan gesture to a certain area.
Now if I take the approach of completely disabling:
I will need to track device orientation change
Disable when orientation is set to landscape
Again enable when orientation is changed to portrait
If I take the approach of restricting to a certain area:
I will need to find that certain area
That certain area (described in previous point) needs to be calculated
differently for portrait & landscape orientation
Certain area for portrait orientation needs to be the area of the
whole UIPageViewController bounds
Certain area for landscape orientation needs to be a very minimum area
(whose frame could be 0, 0, 1, 1) where user won't be able to
perform pan operation. This frame calculation needs to be very
precise because my UIPageViewController takes the whole bounds of
the main screen in landscape orientation.
Then again may need to track device orientation change for different
calculation of the certain area
There are some techniques where the authors suggest:
pvc.dataSource = nil // prevents paging
pvc.dataSource = `a valid dataSource object` // enables paging
So, manual enable + disable again. Track orientation change and enable/disable.
This isn't safe to use for my specific use case as there is a possibility of assigning data source multiple times.
There are other approaches which, I think, can't be modified to fit the use case.
Is there a shortcut way to achieve what I need?

Answering to my own question as I've already achieved what I needed to.
Subclassing UIPageViewController is the easiest way. We have to find the underlying UIScrollView that is used by the page view controller to handle its pan gesture related work. We will add another UIPanGestureRecognizer to that internal scroll view. This pan gesture recognizer won't perform any action essentially but it will block the internal pan gesture recognizer to be recognized for landscape orientation only.
Sample implementation:
class CustomPageViewController: UIPageViewController, UIGestureRecognizerDelegate {
override func viewDidLoad() {
if let underlyingScrollView = view.subviews.compactMap({ $0 as? UIScrollView })
.first {
let pangestureRecognizer = UIPanGestureRecognizer()
pangestureRecognizer.delegate = self
underlyingScrollView.addGestureRecognizer(pangestureRecognizer)
// at this point, the underlying scroll view will have two pan gesture
// recognizer side by side. We have the control of our added pan gesture
// recognizer through the delegate. We can conditionally recognize it or not
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer)
-> Bool {
// Returning true from here means, page view controller will behave as it is
// Returning false means, paging will be blocked
// As I needed to block paging only for landscape orientation, I'm just returning
// if orientation is in portrait or not
return UIApplication.shared.statusBarOrientation.isPortrait
}
}

Related

Pan view using UIPanGestureRecognizer within a functional UIScrollView

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.

iOS: What velocity threshold makes a pan gesture a flick?

In handling a UIPanGestureRecognizer in iOS, guidance such as that found here
https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/handling_uikit_gestures/handling_pan_gestures?language=objc
and
https://material.io/guidelines/patterns/gestures.html#gestures-drag-swipe-or-fling-details
advises using the velocity property to distinguish a normal drag from a swipe or a flick/fling. Nowhere does it say what a typical threshold is. For the sake of example, say we're dragging a thumbnail (44x44 points) across an iOS screen. Fine-tuning aside, above what velocity y-value would you consider the pan gesture to be a flick/fling?
Context: I'm trying to implement the iOS behavior you see in iOS 11 on an iPhone X, where swiping upward on the bar flings an app back to its home icon, except I'm doing it on cells being flung back to a UICollectionView.
After doing some research I found that Apple uses velocity of 300 to detect flicking in ScrollView.
extension TestViewController: UIScrollViewDelegate {
func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
print(scrollView.panGestureRecognizer.velocity(in: view)) // if velocity > 300, UIScrollView will scroll to next page
}
}

Swipe down scroll view to dismiss view controller

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
}

Handling UIPanGestureRecognizer gestures for multiple Views (one covers the other)

Sorry for such a long question, but felt I should convey what I have tried.
I've got a view viewA within a navigation controller. I am then adding a subview viewB (that contains a UITableView) to viewA and offsetting its origin height so that it covers only half the screen (with the other half overflowing off out the bottom of the screen). I want to be able to then drag this viewB upwards but it get stopped when it hits the bottom of the navigation bar and similarly get stopped when dragged back down when it hits the origin offset point. This I have achieved successfully.
However, I want the UITableView interaction to only be enabled when viewB is in its upper position and thus not respond to gestures in any other position. Essentially, dragging viewB up so that it completely covers viewA should enable interaction with the UITableView.
The tricky part here is that I want it to do the following:
If viewB is in its upper position so that it is covering the screen, the UITableView content offset is 0 (i.e. we are at the top of the table) and the user makes a pan gesture downwards, the gesture should not interact with the UITableView but should move viewB downwards.
Any other pan gesture in the above condition should be an interaction with the UITableView.
If viewB is in its upper position so that it is covering the screen, the UITableView content offset is NOT at 0 (i.e. we are NOT at the top of the table) and the user makes a pan gesture downwards, the gesture should interact with the UITableView.
I've been very close to achieving this but I can't get it quite right.
Attempts So Far
I'm using a UIPanGestureRecognizer to handle the dragging of the view. I have tried adding this to:
viewB with the UITableView user interaction initially disabled. This allows me to drag viewB up and down without interfering with the UITableView. Once viewB is in its upper position I enable UITableView user interaction which then correctly allows me to interact with the UITableView without moving viewB.
However, by enabling UITableView user interaction, this means touches never reach the UIPanGestureRecognizer, meaning I can never detect for the scenario described in point (1.) above and thus can't re-disable UITableView user interaction to make viewB movable again.
Maybe it is possible to do it this way by overriding the gesture recognition methods used by the UITableView? If this is possible can anyone point me in the right direction?
a new view added in front of the UITableView. I thought maybe I could forward the touch gestures to the UITableView behind it when necessary but I still haven't found a way to do this.
All I have been able to do is disable the gesture recognizer which allows me to interact with the UITableView, but then I have the same issue as above. I can't detect when to re-enable it.
the UITableView within viewB. This seemed to be the most promising way so far. By setting the return values of the following methods I can enable and disable recognition of either viewB and the UITableView.
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldReceiveTouch touch: UITouch) -> Bool {
if pulloverVC.view.frame.origin.y == bottomNavbarY &&
pulloverVC.tableView?.contentOffset.y == 0 { // need to add gesture direction check to this condition
viewBisAtTop = true
return false // disable pullover control
}
return true // enable pullover control
}
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if (gestureRecognizer as! UIPanGestureRecognizer).velocityInView(view).y < 0 && viewBisAtTop { // gesture direction check not wanted here
return true // enable tableview control
}
viewBisAtTop = false
return false // disable tableview control
}
The top method is called first when a gesture is made (I have checked with print statements) followed by the bottom method. By making different combinations of true/false for the 2 methods I can alternate interaction between viewB and the UITableView.
To detect whether the user is swiping downwards I am calling velocityInView() on the recognizer (as shown in the bottom method). I was intending on making this check in the top methods if statement and I think this would work, however, although velocityInView() works fine in the bottom method, it does not in the top one (velocity is always 0).
I have scoured SO for some solution and find many similar queries about gesture handling for views that cover each other, but these all seem to be regarding one gesture type, e.g. pinch, on one view, and another type, e.g. pan, on the other. In my case the gesture type is the same for both.
Maybe someone has a clever idea? Or maybe this is actually very simple to do and I have made this incredibly complicated? xD
Managed to get this working.
Of the methods described in my question above I removed the top one keeping just this (it has a few changes):
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if ((gestureRecognizer as! UIPanGestureRecognizer).velocityInView(view).y < 0
|| pulloverVC.tableView.contentOffset.y > 0)
&& pulloverVC.view.frame.origin.y == bottomNavbarY {
return true // enable tableview control
}
return false
}
The if statement checks that the covering UITableView is in its upper position AND that either the user is is not dragging downwards or the table content is offset (we are not at the top of the table). If this is true, then we return true to enable the tableview.
After this method is called, the standard method implemented to handle my pan gesture is called. In here I have an if statement that sort of checks the opposite to above, and if that's true, it prevents control over the covering viewB from moving:
func handlePanGesture(recognizer: UIPanGestureRecognizer) {
let gestureIsDraggingFromTopToBottom = (recognizer.velocityInView(view).y > 0)
if pulloverVC.view.frame.origin.y != bottomNavbarY || (pulloverVC.view.frame.origin.y == bottomNavbarY && gestureIsDraggingFromTopToBottom && pulloverVC.tableView.contentOffset.y == 0) {
...
This now keeps the UITableView interaction off unless its parent view viewB is in the correct position, and when it is, disables the movement of viewB so that only interaction with the UITableView works.
Then when, we are at the top of the table, and drag downwards, interaction with the UITableView is re-disabled and interaction with its parent view viewB is re-enabled.
A wordy post and answer, but if someone can make sense of what I'm saying, hopefully it will help you.

touches methods not getting called on UIView placed inside a UIScrollView

I have a Custom Scroll View, subclassing UIScrollView. I have added a scroll view in my viewcontroller nib file and changed its class to CustomScrollView. Now, this custom scroll view (made from xib) is added as a subview on self.view.
In this scroll view, I have 3 text fields and 1 UIImageView(named signImageView) added from xib. On clicking UIImageView (added a TapGestureRecogniser), a UIView named signView is added on the custom scroll view. I want to allow User to sign on this view, So I have created a class Signature.m and .h, subclassing UIView and implemented the touches methods (touchesBegan, touchesMoved and touchesEnded) and initialised the signView as follows:
signView = [[Signature alloc]initWithFrame:signImageView.frame];
[customScrollView addSubview:signView];
But when I start signing on the signView, the view gets scrolled and hence the touches methods don't get called.
I have tried adding signView on self.view instead of custom scroll view, but in that case the view remains glued to a fixed position when I start scrolling. (Its frame remains fixed in this case)
Try setting canCancelContentTouches of the scrollView to NO and delaysContentTouches to YES.
EDIT:
I see that similiar question was answered here Drag & sweep with Cocoa on iPhone (the answer is exactly the same).
If the user tap-n-holds the signView (for about 0.3-0.5 seconds) then view's touchesBegan: method gets fired and all events from that moment on go to the signView until touchesEnded: is called.
If user quickly swipes trough the signView then UIScrollView takes over.
Since you already have UIView subclassed with touchesBegan: method implemented maybe you could somehow indicate to user that your app is prepared for him to sign ('green light' equivalent).
You could also use touchesEnded: to turn off this green light.
It might be better if you add signImageView as as subView of signView (instead of to customScrollView) and hide it when touchesBegan: is fired). You would add signView to customScrollview at the same place where you add signImageView in existing code instead.
With this you achieve that there is effectively only one subView on that place (for better touch-passing efficiency. And you could achieve that green light effect by un-hiding signImageView in touchesBegan:/touchesEnded:
If this app-behaviour (0.3-0.5s delay) is unacceptable then you'd also need to subclass UIScrollView. There Vignesh's method of overriding UIScrollView's touchesShouldBegin: could come to the rescue. There you could possibly detect if the touch accoured in signView and pass it to that view immediately.
When ever you add a scrollview in your view hierarchy it swallows all touches.Hence you are not getting the touches began. So to get the touches in your signon view you will have to pass the touches to signon view. This is how you do it.
We achieved this with a UIScrollView subclass that disables the pan gesture recogniser for a list of views that you provide.
class PanGestureSelectiveScrollView: UIScrollView {
var disablePanOnViews: [UIView]?
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let disablePanOnViews = disablePanOnViews else {
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
let touchPoint = gestureRecognizer.location(in: self)
let isTouchingAnyDisablingView = disablePanOnViews.first { $0.frame.contains(touchPoint) } != nil
if gestureRecognizer === panGestureRecognizer && isTouchingAnyDisablingView {
return false
}
return true
}
}

Resources