UIPanGesture inside UIScrollView in Swift - ios

The problem in short, related to working with pan gesture inside a scrollView.
I have a canvas(which is an UIView itself but bigger in size) where i am drawing some UIView objects with pan gesture enabled over each of them(Each little UIView Objects I am talking about, are making using another UIView class).
Now the canvas can be bigger in height and width...which can be changed as per the user input.
So to achieve that I have placed the canvas inside a UIScrollView. Now the canvas is increasing or decreasing smoothly.
Those tiny UIView objects on the canvas can be rotated also.
Now the problem.
If I am not changing the canvas size(static) i.e. if its not inside the scrollview then each UIView objects inside the canvas are moving superbly and everything is working just fine with the following code.
If the canvas is inside the UIScrollView then the canvas can be scrollable right? Now inside the scrollview if I am panning the UIView objects on the canvas then those little UIView objects are not following the touch of the finger rather than its moving on another point when touch is moving on the canvas.
N.B. - Somehow I figured out that I need to disable the scrolling of the scrollview when any of the subviews are getting touch. For that thing I have implemented NSNotificationCenter to pass the signal to the parent viewController.
Here is the code.
This functions are defined inside the parent viewController class
func canvusScrollDisable(){
print("Scrolling Off")
self.scrollViewForCanvus.scrollEnabled = false
}
func canvusScrollEnable(){
print("Scrolling On")
self.scrollViewForCanvus.scrollEnabled = true
}
override func viewDidLoad() {
super.viewDidLoad()
notificationUpdate.addObserver(self, selector: "canvusScrollEnable", name: "EnableScroll", object: nil)
notificationUpdate.addObserver(self, selector: "canvusScrollDisable", name: "DisableScroll", object: nil)
}
This is the Subview class of the canvas
import UIKit
class ViewClassForUIView: UIView {
let notification: NSNotificationCenter = NSNotificationCenter.defaultCenter()
var lastLocation: CGPoint = CGPointMake(0, 0)
var lastOrigin = CGPoint()
var myFrame = CGRect()
var location = CGPoint(x: 0, y: 0)
var degreeOfThisView = CGFloat()
override init(frame: CGRect){
super.init(frame: frame)
let panRecognizer = UIPanGestureRecognizer(target: self, action: "detectPan:")
self.backgroundColor = addTableUpperViewBtnColor
self.multipleTouchEnabled = false
self.exclusiveTouch = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func detectPan(recognizer: UIPanGestureRecognizer){
let translation = recognizer.translationInView(self.superview!)
self.center = CGPointMake(lastLocation.x + translation.x, lastLocation.y + translation.y)
switch(recognizer.state){
case .Began:
break
case .Changed:
break
case .Ended:
notification.postNotificationName("EnableScroll", object: nil)
default: break
}
}
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
notification.postNotificationName("DisableScroll", object: nil)
self.superview?.bringSubviewToFront(self)
lastLocation = self.center
lastOrigin = self.frame.origin
let radians:Double = atan2( Double(self.transform.b), Double(self.transform.a))
self.degreeOfThisView = CGFloat(radians) * (CGFloat(180) / CGFloat(M_PI) )
if self.degreeOfThisView != 0.0{
self.transform = CGAffineTransformIdentity
self.lastOrigin = self.frame.origin
self.transform = CGAffineTransformMakeRotation(CGFloat(M_PI_4))
}
myFrame = self.frame
}
override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
notification.postNotificationName("EnableScroll", object: nil)
}
}
Now the scrollView is disabling its scroll perfectly whenever one of the UIView object is receiving touch over the canvas which is inside the scrollview but sometimes those UIView objects are not properly following the touch location over the canvas/screen.
I am using Swift 2.1 with Xcode 7 but anyone can tell me the missing things of mine or the solution using Objective-c/Swift?

Where do you set the lastLocation? I think it would be better for you to use locationInView and compute the translation by yourself. Then save the lastLocation on every event that triggers the method.
Also you might want to handle the Cancel state as well to turn the scrolling back on.
All of this does seem a bit messy though. The notifications are maybe not the best idea in your case nor is putting the gesture recognizers on the subviews. I think you should have a view which handles all those small views; it should also have a gesture recognizer that can simultaneously interact with other recognizers. When the gesture is recognized it should check if any of the subviews are hit and decide if any of them should be moved. If it should be moved then use the delegate to report that the scrolling must be disabled. If not then cancel the recognizer (disable+enable does that). Also in most cases where you put something movable on the scrollview you usually want a long press gesture recognizer and not a pan gesture recognizer. Simply use that one and set some very small minimum press duration. Note that this gesture works exactly the same as the pan gesture but can have a small delay to be detected. It is very useful in these kind of situations.
Update (The architecture):
The hierarchy should be:
View controller -> Scrollview -> Canvas view -> Small views
The canvas view should contain the gesture recognizer that controls the small views. When the gesture begins you should check if any of the views are hit by its location by simply iterating through the subviews and check if their frame contains a point. If so it should start moving the hit small view and it should notify its delegate that it has began moving it. If not it should cancel the gesture recognizer.
As the canvas view has a custom delegate it is the view controller that should implement its protocol and assign itself to the canvas view as a delegate. When the canvas view reports that the view dragging has begin it should disable the scrollview scrolling. When the canvas view reports it has stopped moving the views it should reenable the scrolling of the scroll view.
Create this type of view hierarchy
Create a custom protocol of the canvas view which includes "did begin dragging" and "did end dragging"
When the view controller becomes active assign self as a delegate to the canvas view. Implement the 2 methods to enable or disable the scrolling of the scroll view.
The canvas view should add a gesture recognizer to itself and should contain an array of all the small movable subviews. The recognizer should be able to interact with other recognizers simultaneously which is done through its delegate.
The Canvas gesture recognizer target should on begin check if any of the small views are hit and save it as a property, it should also save the current position of the gesture. When the gesture changes it should move the grabbed view depending on the last and current gesture location and re-save the current location to the property. When the gesture ends it should clear the currently dragged view. On begin and end it should call the delegate to notify the change of the state.
Disable or enable the scrolling in the view controller depending on the canvas view reporting to delegate.
I think this should be all.

Related

How to make the background view of a collectionView pass taps below

I'm building a collectionview. Below of it I placed some buttons as shown in the picture.
What I want is to make the UICollectionView background pass taps below, so the desired buttons can receive taps.
I don't need to add Tap gesture recognizers to the background view (the problem I'm describing is just an example here), I need the buttons' actuons to be triggered directly when they're tapped.
I thought I could do this by making the background clear or disabling user interaction for the background view. While disabling it for the entire collection view works, this other way does not.
How can I make the background view of my collectionView be "invisible" so that taps go straight to the below buttons instead of going to the collectionview background?
The following is an example of my layout.
Assuming your collectionView and your buttons share the same superview, this should do the trick.
What you want to do is bypass the backgroundView and forward hits to the subviews underneath the collectionView.
Notice that we are picking the last subview with the matching criteria. That is because the last subview in the array is the closest to the user's finger.
class SiblingAwareCollectionView: UICollectionView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hit = super.hitTest(point, with: event)
guard hit === backgroundView else {
return hit
}
let sibling = superview?
.subviews
.filter { $0 !== self }
.filter { $0.canHit }
.last { $0.point(inside: convert(point, to: $0), with: event) }
return sibling ?? hit
}
}
If you look at the documentation for hitTest(_:with:) it says:
This method ignores view objects that are hidden, that have disabled user interactions, or have an alpha level less than 0.01.
For convenience, here is an extension to ensure we are playing by the rules:
extension UIView {
var canHit: Bool {
!isHidden && isUserInteractionEnabled && alpha >= 0.01
}
}

Intercepting tap even on the top UIView when multiple UIView are overlapping on top of each other

I have a UIView that fill the entire screen, then I'm adding multiple small circle UIView within that container view, I want those small circle's UIView to be draggable using UIPanGestureRecognizer. But sometimes they happen to be on top of each other making the top UIView not clickable at all, it always select the bottom ones.
In the container UIView I implemented hitTest to be able to select only those child views.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
for planet in self.myPlanets.values {
if let presentation = planet.layer.presentation(), presentation.frame.contains(point) {
return planet
}
}
return nil
}
How can I make the top view receive the click even instead of the bottom view?
I handled draggable views by creating a UIView subclass, adding a UIPanGestureRecognizer and updating based on its inputs.
Using this method, whichever view is on top will receive the touch and you don't have to override hitTest on the superview.
I also added a delegate to update constraints if the view is constrained to the superview. By setting the delegate the UIView or ViewController (whichever is delegate) can update the constraints for the views you want to move.
Here's a simple implementation:
// Delegate protocol for managing constraint updates if needed
protocol DraggableDelegate: class {
// tells the delegate to move
func moveByTranslation(_ change: CGPoint)
}
class DraggableView: UIView {
var dragDelegate: DraggableDelegate?
init() {
// frame is set later if needed by creator
super.init(frame: CGRect.zero)
configureGestureRecognizers()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// setup UIPanGestureRecognizer
internal func configureGestureRecognizers() {
let panGR = UIPanGestureRecognizer.init(target: self, action: #selector(didPan(_:)))
addGestureRecognizer(panGR)
}
#objc func didPan(_ panGR: UIPanGestureRecognizer) {
// get the translation
let translation = panGR.translation(in: self).applying(transform)
if let delegate = dragDelegate {
// tell delegate to move
delegate.moveByTranslation(translation)
} else {
// move self
self.center.x += translation.x
self.center.y += translation.y
}
// reset translation
panGR.setTranslation(CGPoint.zero, in: self)
}
}
Here's how I implemented the delegate callback in the view controller using this view, since my view used constraints:
/// Moves the tool tray when dragging is triggered via the pan gesture recognizer in the tool tray (an instance of DraggableView).
///
/// - Parameter change: The x/y change provided by the pan gesture recognizer.
func moveByTranslation(_ change: CGPoint) {
// only interested in the y axis movements for this example
// update the constraint that moves this view
let newPos = constraint_tooltray_yAxis.constant + change.y
// this function limited the movement of the view to keep it within bounds
updateToolTrayPosition(newPos)
}

UIView above presented SKScene - Why are touches always going through to the scene?

I have a confusing situation. First, here's the code:
class MyView: SKView {
...
public override func awakeFromNib() {
super.awakeFromNib()
let gameScene = MyGameScene(...)
// ...
presentScene(gameScene)
let view = UIView()
view.frame = bounds
view.isUserInteractionEnabled = true
addSubview(view)
}
...
}
class MyGameScene: SKScene {
...
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touch received")
}
...
}
I have a SKView that's presenting a SKScene. After presenting it, I add a UIView that's filling the screen. However, when I start the app and tap on the screen, "touch received" is printed, which means the touch got through to the scene, which should be behind the view. Why is that?
And what can I do to prevent it?
I also tried adding a gesture recogniser to the view. It doesn't prevent the issue, though (even though I set cancelsTouchesInView to true)
Thanks for any help.
Seems you're trying to achieve some kind of a HUD. SceneKit is not pure UIKit so you'll need to adapt your approach - some ideas:
UIButton should still work as mentioned here:
added button to sceneKit view but it has a lag
Tap gesture recogniser attached to SCNView:
https://stackoverflow.com/a/42982177/5329717
If you really need this particular UIView you could render it as an image and use a SKSpriteNode with SKTexture and handle touches there.
Not really recommended, but as a last resort you could add your UIView directly to UIWindow
Your issue gets fixed if you add the view to the superview of the SKView (i.e add it to the presenting view controller).
So you can try doing something along these lines:
let view = UIView()
view.frame = bounds
view.isUserInteractionEnabled = true
superview?.addSubview(view)
Alternatively you can add it to the window, but this is not really recommended.

Multiple gestures on same view (drag view out of scrollable view) not working the right way?

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.

Gesture recognizer on a circular view

In each cell of my collection view is a circular UIView. This has been achieved by creating a custom subclass of UIView, which I have called CircleView, and setting layer.cornerRadius = self.frame.size.width/2 in the subclass' awakeFromNib()
I want to add a gesture recognizer to each CircleView. I have done this in the collection view's cellForItemAtIndexPath:
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
cell.circleView.addGestureRecognizer(gestureRecognizer)
The problem is that the gesture recognizer is called whenever a tap occurs anywhere within the bounds of the original square UIView. I want to only recognize taps that occur within the circle.
I have tried to solve this issue in the following ways:
In the CircleView's awakeFromNib() I set self.clipsToBounds = true (no effect)
Also in the CircleView's awakeFromNib() I set layer.masksToBounds = true (no effect)
Thank you in advance for your ideas and suggestions.
You can override this method in CircleView:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let center = CGPoint(x: bounds.size.width/2, y: bounds.size.height/2)
return pow(center.x-point.x, 2) + pow(center.y - point.y, 2) <= pow(bounds.size.width/2, 2)
}
All touches not belonging to the circle will be ignored.
More details:
https://developer.apple.com/reference/uikit/uiview/1622469-hittest
https://developer.apple.com/reference/uikit/uiview/1622533-point
The main point is that you don't need to call neither hitTest nor pointInside methods, you just override them in your custom view and system will call them whenever it needs to know if a touch should be handled by this view.
In your case you've got a UITableViewCell with a CircleView in it, right? You've added a gesture recognizer to CircleView and overriden pointInside method, so a touch will be handled by CircleView itself if a touch point is inside the circle, otherwise event will be passed further, handled by cell and therefore didSelectRowAtIndexPath will be called.

Resources