I have the following setup:
TableView
ScrollView
ContainerView
ScrollView lies on top of TableView and completely covers it, but they are siblings.
ScrollView scrolls left and right (paging enabled) and TableView scrolls up and down.
I want to scroll the right thing depending on scroll direction.
Tried subclassing ScrollView and overriding UIGestureRecognizerDelegate:
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
isUserInteractionEnabled = true
if let gR = gestureRecognizer as? UIPanGestureRecognizer{
let vel = gR.velocity(in: gR.view)
let x = fabs(vel.y) < fabs(vel.x)
isUserInteractionEnabled = x
return x
}
return true
}
That approach didn't work out properly.
I also tried
scrollView.addGestureRecognizer(tableView.panGestureRecognizer)
but that didn't work either because it removes panGestureRecognizer from TableView.
Related
I have a vertically scrollable UICollectionView with cells that have an added horizontal pan gesture.
Is there an advised way to disable the horizontal pan gesture while scrolling the UICollectionView? I ask because whenever I do scroll vertically, the slightest movements left or right also trigger the pan gesture and move the cell right or left.
I tried enabling/disabling the UICollectionView when the state of the gesture changed but it was difficult to scroll since a pan gesture would be read in almost every touch instance. I also tried doing something I found online in assessing the x/y velocity and determining whether to fulfill the pan gesture. This seemed to prevent any horizontal panning though..
And finally, I need to use pan versus swipe because I need to include conditional statements on a translation threshold when someone pans (i.e. didn't go x distance, return cell back to original position). If there is a way with swipe, I'm all ears!
Relevant code below:
func onPan(_ sender: UIPanGestureRecognizer) {
//Attempt 1
theCollectionView.isScrollEnabled = false
let cellView = sender.view!
let point = sender.translation(in: cellView)
cellView.center = CGPoint(x: parentView.center.x + point.x, y: cellView.center.y)
if sender.state == .ended {
//Attempt 1
homeCollectionView.isScrollEnabled = true
UIView.animate(withDuration: 0.2, animations: {
cellView.center = CGPoint(x: self.parentView.center.x, y: cellView.center.y)
})
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
//Attempt 2
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return abs((pan.velocity(in: pan.view)).x) > abs((pan.velocity(in: pan.view)).y)
}
I want to be able to move an instance of UIImageView from a UIScrollView to a UIView that is outside the containing UIScrollView.
I've got the panGesture working but a UIImageView shows only inside the containing UIScrollView when being dragged and gets hidden if going outside the containing UIScrollView as shown in the screenshot image.
I've tried something like someScrollView.sendSubview(toBack: self.view) to set the layer order and also imageView.layer.zIndex = .. but it doesn't seem to work in my case.
How do I achieve something as shown in the screenshot image so it can be dragged to a target UIView outside its containing view?
And also if possible, how can I create a new instance of UIImageView as the panGesture begins so the original images stay.
#IBOutlet weak var someScrollView: UIScrollView!
var letters: [String] = ["g","n","d"]
override func viewDidLoad() {
super.viewDidLoad()
someScrollView.addSubview(createLetters(letters))
someScrollView.sendSubview(toBack: self.view)
}
func createLetters(_ named: String]) -> [UIImageView] {
return named.map { name in
let letterImage = UIImageView()
letterImage.image = UIImage(named: "\(name)")
addPanGesture(image: letterImage)
return letterImage
}
}
func addPanGesture(image: UIImageView) {
let pan = UIPanGestureRecognizer(target: self, action: #selector(ViewController.handlePan(sender:)))
image.addGestureRecognizer(pan)
}
#objc func handlePan(sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: view)
if let imageView = sender.view {
imageView.center = CGPoint(x:imageView.center.x + translation.x,
y:imageView.center.y + translation.y)
}
sender.setTranslation(CGPoint.zero, in: self.view)
switch sender.state {
case .began:
...
}
}
First off, it's good practice to prioritize gesture recognizers - tell the scroll view's pan gesture that it won't receive touches on account of your pan gestures (Yours comes first).
someScrollView.panGestureRecognizer.require(toFail: yourGestureRecognizer)
Unclip scroll-view's subviews to it's bounds - It's subviews will be visible when dragged outside the scrollView's bounds.
scrollView.clipsToBounds = false
You can convert the frame of your dragged view to the scrollView and newParentView ancestor's coord' system, like this (assuming self.view is scrollView & newParentView's ancestor).
let pannedViewFrame = someScrollView.convert(pannedView, to: self.view)
Then, in your gesture recognizer's selector, you can test frame intersection of pannedViewFrame and newParentView.frame, like this:
// You have an frame intersection
if pannedViewFrame.intersects(newParentView.frame) {
}
Now, if you test intersection of frames when your gesture recognizer's state is .cancelled, .ended or .failed, then:
The panning has ended AND pannedView is within newParentView's bounds
Last step, just convert pannedViewFrame to newParentView.frame's coord' using the same trick and added pannedView as subview to newParentView.
Another solution is to remove the pannedView from scrollView when GR state is .began and add it to scrollView and newParentView's common ancestor. The rest is the same as i previously mentioned.
In order to let my panGesture (attached to an UIImageView) to be called, I need to make sure that the user is not scrolling in the horizontal ScrollView the image lies within. I am achieving this like so:
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:)))
panGesture.require(toFail: self.scrollView.panGestureRecognizer)
This works well but as the scrollView is horizontal only, it only allows the panGesture to work horizontally. I want to also pull the UIImage vertically, but nothing happens because obviously, it will only be called if the scroll view fails (can only fail horizontally).
Is there a way to make sure it so that the above works but also allows the panGesture to be called vertically no matter what?
Ok I solved this with a combination of a few things. It looks like a lot to add, but it will come in handy in other projects.
Step 1: Enable the horizontal scrolling UIScrollView to "fail" both horizontally and vertically.
Although I only want my UIScrollView to scroll horizontally, I still need it to scroll vertically so that it can fail (explanation coming). Failing enables me to "pull" the subviews out of the UIScrollView.
The height of scrollView itself is 40 so the contentSize of the it must have a larger height for it to be able to barely scroll vertically.
self.effectTotalHeight.constant = 41
self.scrollView.contentSize = CGSize(width: contentWidth, height: self.effectTotalHeight.constant)
Great! It scrolls vertically. But now the content rubber-bands (which we do not want). Against what others on SO say, do not go cheap and just disable bounce. (Especially if you want the bounce when scrolling horizontally!)
Note: I also realized only disabling Bounce Horizontally in StoryBoard does... well, nothing (bug?).
Step 2: Add UIScrollViewDelegate to your View Controller class and detect scrolls
Now I want to detect the scroll and make sure that when scrolling vertically, it does not actually scroll. To do this, the contentOffset.y position should not change even though you are scrolling. UIScrollViewDelegate provides a delegate function scrollViewDidScroll that gets called when scrolling. Just set it as:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollView.contentOffset.y = 0.0
}
In order for this to be called, you need to set the delegate of the scrollView to self as well:
self.scrollView.delegate = self
Keep in mind this controls all UIScrollViews so provide an if statement or switch statement if you want it to only affect specific ones. In my case, this view controller only has one UIScrollView so I did not put anything.
Yay! So now it only scrolls horizontally again but this method only keeps the contentOffset.y at 0. It does not make it fail. We do need it to because scrollView failing vertically is the key to enabling pan gesture recognizers (what lets you pull and drag etc.). So let's make it fail!
Step 3: Override UIScrollView default gesture recognition
In order to override any of the default gesture recognizers, we need to add UIGestureRecognizerDelegate as another delegate method to your View Controller class.
The scrollView now needs its own pan gesture recognizer handler so that we can detect gestures on it. You also need to set the delegate of the new scrollGesture to self so we can detect it:
let scrollGesture = UIPanGestureRecognizer(target: self, action: #selector(self.handleScroll(_:)))
scrollGesture.delegate = self
self.scrollView.addGestureRecognizer(scrollGesture)
Set up the handleScroll function:
func handleScroll(_ recognizer: UIPanGestureRecognizer) {
// code here
}
This is all good, but why did we set this all up? Remember we disabled the contentOffset.y but the vertical scroll was not failing. Now we need to detect the direction of the scroll and let it fail if vertical so that panHandle can be activated.
Step 4: Detect gesture direction and let it fail (failure is good!)
I extended the UIPanGestureRecognizer so that it can detect and emit directions by making the following public extension:
public enum Direction: Int {
case Up
case Down
case Left
case Right
public var isX: Bool { return self == .Left || self == .Right }
public var isY: Bool { return !isX }
}
public extension UIPanGestureRecognizer {
public var direction: Direction? {
let velo = velocity(in: view)
let vertical = fabs(velo.y) > fabs(velo.x)
switch (vertical, velo.x, velo.y) {
case (true, _, let y) where y < 0: return .Up
case (true, _, let y) where y > 0: return .Down
case (false, let x, _) where x > 0: return .Right
case (false, let x, _) where x < 0: return .Left
default: return nil
}
}
}
Now in order to use it correctly, you can get the recognizer's .direction inside of the handleScroll function and detect the emitted directions. In this case I am looking for either .Up or .Down and I want it to emit a fail. We do this by disabling the recognizer it, but then re-enabling it immediately after:
func handleScroll(_ recognizer: UIPanGestureRecognizer) {
if (recognizer.direction == .Up || recognizer.direction == .Down) {
recognizer.isEnabled = false
recognizer.isEnabled = true
}
}
The reason .isEnabled is immediately set to true right after false is because false will emit the fail, enabling the other gesture ("pulling out" (panning) its inner views), but to not be disabled forever (or else it will cease being called). By setting it back to true, it lets this listener be re-enabled right after emitting the fail.
Step 5: Let multiple gestures work by overriding each other
Last but not least, this is a very very important step as it allows both pan gestures and scroll gestures to work independently and not have one single one always override the other.
func gestureRecognizer(_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
return true
}
And that's that! This was a lot of research on SO (mostly finding what did not work) and experimentation but it all came together.
This was written with the expectation that you have already written the pan gesture recognizers of the objects inside of the scroll view that you are "pulling out" and how to handle its states (.ended, .changed etc.). I suggest if you need help with that, search SO. There are tons of answers.
If you have any other questions about this answer, please let me know.
I have a UINavigationController that has a bottom toolbar which I set it's height programatically like so:
navigationController?.toolbar.frame.size.height += 43.0
navigationController?.toolbar.frame.origin.y -= 43.0
navigationController?.hidesBarsOnTap = true
When I tap on my View to hide the bars and tap again to show them, the bottom bar returns to it's default state:
How can I preserve the height after the bar shows again?
Thanks a lot! :)
There's not a great way to do that, but you can do something like place a tapGestureRecognizer on self.view and count the number of taps.
Something like
var numTaps = 0
#IBAction func tapOnView(sender: UITapGestureRecognizer) {
self.numTaps++
if numTaps%2==0
{
self.navigationController?.toolbar.frame.size.height += 43.0
self.navigationController?.toolbar.frame.origin.y -= 43.0
}
}
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool{
return true
}
It is a little hackish but might work, might try with a slight delay to ensure you set the height after the toolbar's position is set.
Or try one of the answers provided Is there a way to change the height of a UIToolbar? and subclass uitoolbar to override sizeThatFits
Is there a way to determine the panning location of a UIPageViewController while sliding left/right? I have been trying to accomplish this but its not working. I have a UIPageViewController added as a subview and i can slide it horizontally left/right to switch between pages however i need to determine the x,y coordinates of where I am panning on the screen.
I figured out how to do this. Basically a UIPageViewController uses UIScrollViews as its subviews. I created a loop and set all the subviews that are UIScrollViews and assigned their delegates to my ViewController.
/**
* Set the UIScrollViews that are part of the UIPageViewController to delegate to this class,
* that way we can know when the user is panning left/right
*/
-(void)initializeScrollViewDelegates
{
UIScrollView *pageScrollView;
for (UIView* view in self.pageViewController.view.subviews){
if([view isKindOfClass:[UIScrollView class]])
{
pageScrollView = (UIScrollView *)view;
pageScrollView.delegate = self;
}
}
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
NSLog(#"Im scrolling, yay!");
}
My personal preference is not to rely too much on the internal structure of the PageViewController because it can be changed later which will break your code, unbeknownst to you.
My solution is to use a pan gesture recogniser. Inside viewDidLoad, add the following:
let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handler))
gestureRecognizer.delegate = yourDelegate
view.addGestureRecognizer(gestureRecognizer)
Inside your yourDelegate's definition, you should implement the following method to allow your gesture recogniser to process the touches
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
Now, you should be able to access the X/Y location of the user's touches:
func handler(_ sender: UIPanGestureRecognizer) {
let totalTranslation = sender.translation(in: view)
//...
}