UIView Partially Outside superView Not Receiving Touches - ios

Issue: The viewWithGesture contains the viewUserSees, and is draggable within the blue containerView. However, the viewWithGesture is a subView of the containerView, so when the viewWithGesture is at an extreme (illustrated here - half in and half out of the containerView), only half of the viewWithGesture responds to touches, making it very hard to drag.
Note: I realize I should redo all the math that keeps it in the container and move it outside of the containerView, but I am very curious to learn how to do this the "worse" way.
I have researched this a bunch and tried to implement hittest() and pointInside(), but so far I have managed to just make the app crash spectacularly.
Is there a good, relatively clean way to let the user grab from outside the containerView? (swift3 if possible)
EDIT: The green box is transparent and half of it is in the containerView and half is not.

In order for a view to receive a touch, the view and all its ancestors must return true from pointInside:withEvent:.
Normally, pointInside:withEvent: returns false if the point is outside the view's bounds. Since a touch in the green area is outside the container view's bounds, the container view returns false, so the touch won't hit the gesture view.
To fix this, you need to create a subclass for the container view and override its pointInside:withEvent:. In your override, return true if the point is in the container view's bounds or in the gesture view's bounds. Perhaps you can be lazy (especially if your container view doesn't have many subviews) and just return true if the point is in any subview's bounds.
class ContainerView: UIView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if super.point(inside: point, with: event) { return true }
for subview in subviews {
let subviewPoint = subview.convert(point, from: self)
if subview.point(inside: subviewPoint, with: event) { return true }
}
return false
}
}

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
}
}

Why does clipsToBounds prevent the subviews to be touched?

Let's say you have a superview that has a smaller size than its subview. You set the clipsToBound property of the superview to false. If you tap on the protruding area of the subview that is outside of the bounds of the superview, why does the hit test return nil?
My understanding is that the hit test starts from the subview and work its way up to the superview. So why does the property of the superview that is to be tested later than the subview matter? Or does the hit test start from the root to tree views like the view controller -> the main view -> subviews?
I found a custom hit-test from here, which does allow you to tap on the subview's area outside of the bounds of the superview, but I'm not sure why reversing the order of subviews make a difference(it works, I'm just not sure why). My example even only has one subview.
class ViewController: UIViewController {
let superview = CustomSuperview(frame: CGRect(origin: .zero, size: .init(width: 100, height: 100)))
let subview = UIView(frame: CGRect(origin: .zero, size: .init(width: 200, height: 200)))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.superview)
self.superview.backgroundColor = .red
self.superview.clipsToBounds = false
self.superview.addSubview(self.subview)
self.subview.backgroundColor = .blue
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
self.subview.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UIGestureRecognizer) {
print("tapped")
}
}
class CustomSuperview: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if clipsToBounds || isHidden || alpha == 0 {
return nil
}
for subview in subviews.reversed() {
let subPoint = subview.convert(point, from: self)
if let result = subview.hitTest(subPoint, with: event) {
return result
}
}
return nil
}
}
My understanding is that the hit test starts from the subview and work its way up to the superview.
Then your understanding is completely wrong. Let’s fix that. First, let's clear up some other misunderstandings:
The reversed is a total red herring; it has nothing to do with it. The subviews are always tested in reverse order, because a subview in front needs to take precedence over a subview behind.
The clipsToBounds is a total red herring too. All it does is change whether you can see a subview outside its superview; it does not have any effect on whether you can touch a subview outside its superview.
Okay, so how does this work? Let's take view V which contains a subview A. Let's suppose that A is outside V. Assume you can see A, and you tap A.
Now hit-testing begins. But here's the thing: it starts at the level of the window, and works its way down the view hierarchy. So the window starts by interrogating its subviews; there is just one, the main view of the view controller.
So now we recurse, and the main view of the view controller interrogates its subviews. One of those is V. "Hey, V, was the tap inside you?" "No!" (You have to agree that that is the correct answer, because we already said that A is outside V.)
So the main view of the view controller gives up on V, and never finds out that the tap was on A, because we never recursed down that far. So it reports back up the chain: "The tap was not on any of my subviews, so I have to report that the tap was on me." The tap has fallen through to the view controller's main view.
But you can change that behaviour by overriding the implementation of hitTest:
override func hitTest(_ point: CGPoint, with e: UIEvent?) -> UIView? {
if let result = super.hitTest(point, with:e) {
return result
}
for sub in self.subviews.reversed() {
let pt = self.convert(point, to:sub)
if let result = sub.hitTest(pt, with:e) {
return result
}
}
return nil
}

UIViews behind "pass through" UIView isn't VO accessible

I have a case where a transparent UIViewController is presented over a map view.
ViewController.view is transparent and it's a "pass-through" view. This means that all of ViewController's touches are passed to the map view that is behind the VC.
"Pass through" behaviour is implemented by
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subview in subviews {
if !subview.isHidden && subview.alpha > 0 && subview.isUserInteractionEnabled && subview.point(inside: convert(point, to: subview), with: event) {
return true
}
}
return false
}
All touches and gestures work perfectly, but I found that VoiceOver doesn't work at all. MapView isn't VO accessible. I can't select any mapView element with VO.
I did some research and found that VO uses a private method:
[UIViewAccessibility(SafeCategory) _accessibilityHitTest:withEvent:]
And looks like this method doesn't use the overridden pointInside method. So does anyone know how to fix it in a safe way?
You can make VoiceOver ignore the view by setting view.isAccessibilityElement = NO;.
Alternately, you can make view conform to UIAccessibilityContainer protocol. If for some reason that doesn’t work, you can set view.accessibilityElements = #[mapView, ...];

Transfer gestures on a UIView to a UITableView in Swift - iOS

I have a layout with a UIView at the top of the page and, right below it, I have a UITableView.
What I am wanting to do is to transfer the gesture interactions on the UIView to the UITableView, so when the user makes a drag up/down on the UIView, the UITableView scrolls vertically.
I tried the following code
tableView.gestureRecognizers?.forEach { uiView.addGestureRecognizer($0) }
but it removed the gestureRecognizers from the UITableView somehow :/
Obs.: the UIView cannot be a Header of the UIScrollView
That's Tricky
What is problem ?
Your top view is not allowed to pass through view behind it...
What would be possible solutions
pass all touches to view behind it (Seems to not possible or very tough practically )
Tell window to ignore touches on top view (Easy one)
Second option is better and easy.
So What you need to do is create subclass of UIView and override
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
and return nil if you found same view on hitTest action
Here Tested and working example
class PassThroughME : UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event) == self ? nil : self
}
}
That's it now use PassThroughME either by adding class to your view in storyboard or programmatically whatever way you have added your view
Check image i have black color view with 0.7 alpha on top still i am able to scroll
Hope it is helpful

Scrollview only detect scrolling from certain areas?

I want to have a scrollview that covers the entire screen. However I want to know if it is possible to only allow scrolling to be detected from part of the scrollview. For example you have a full screen scroll view the top half of the screen should detect scrolling but the bottom half should not. I know if you change the alpha to 0 the scrollview doesn't scroll anymore, would a possible solution be to change the alpha of part of the scrollview? is that even possible? any thoughts or ideas?
You can subclass from UIScrollView and override method touchesShouldBegin.
You should check at what point touches and allow it or not.
class ScrollView: UIScrollView {
override func touchesShouldBegin(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) -> Bool {
if (touches.first?.location(in: self).y)! > self.bounds.height / 2 {
// In bottom part
return true
}
// In top part
return false
}
}

Resources