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
}
Related
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)
}
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
}
}
I have a UICollectionViewCell subclass which has a UIStackView on it.
The StackView is populated with UIButtons and everything looks fine
however the buttons are untappable.
If I layout the same buttons on a UIScrollView (for the sake of experiment) instead of on a stackview, the buttons respond fine so it seems like something with the stackview is causing the issue
Any ideas?
Here is the code of how I add buttons to the StackView
func prepareStackView(buttonsArray: Array<UIButton>) {
var rect:CGRect = (stackView?.frame)!
rect.size.width = btn.frame.size.width * buttonsArray.count
stackView?.frame = rect //The frame of the stackview is set as such so that it looks exactly like I want
//Add all the buttons
for btn in buttonsArray {
//The buttons already have their selectors set
stackView?.addArrangedSubview(btn)
}
}
Use this code for UIButton to respond to click event:
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
}
Helpful link:
Capturing touches on a subview outside the frame of its superview using hitTest:withEvent:
I have also encountered similar problem and able to resolve. Please check my post where I have described the scenario of this type of cause and tried to explain how I was able to resolve.
UIButton is not clickable after first click
I'm working on my view and I'm having an issue with getting a shadow around a button within the stack view. Most of the work I have done has been within the storyboard directly.
Here is the method I am using to apply the shadow to the view
func addShadow(to view: UIView) {
view.layer.shadowColor = shadowColor
view.layer.shadowOpacity = shadowOpacity
view.layer.shadowOffset = shadowOffset
if let bounds = view.subviews.first?.bounds {
view.layer.shadowPath = UIBezierPath(rect: bounds).cgPath
}
view.layer.shouldRasterize = true
}
and this is how I'm finding the button within the view from ViewController.swift
for subview in self.view.subviews {
if subview.isKind(of: UIButton.self) && subview.tag == 1 {
addShadow(to: subview)
}
}
I know the problem stems from the stack view and the UIView inside of the stack view that holds the button. (self.view > UIStackView > UIView > [UIButton, UILabel])
I know I could do this with recursion in the for-loop but I'm trying to be a little more precise to optimize performance and would prefer to add the shadows in one shot.
You have a few options:
add the shadow in the storyboard itself
add an outlet to the button, then add shadow in code
add the button to a collection, then enumerate over the collection adding shadows
recursively add the shadows (this isn't going to hit performance nearly as hard as you're thinking, adding the shadows hurts performance more than doing this recursively)
You are correct in that the button is a view on the stack view, so your for loop doesn't hit the button directly to add a shadow to it.
The easiest way to solve this is by far the recursive way, or something like this:
func addShadowsTo(subviews: [UIView]) {
for subview in subviews {
if subview.isKind(of: UIButton.self) && subview.tag == 1 {
addShadow(to: subview)
}
if let stackView = subview as? UIStackView {
addShadowToSubviews(subviews: stackView.subviews)
}
}
}
func viewDidload() {
super.viewDidLoad()
addShadowsTo(subviews: view.subviews)
}
If you want some instructions on how to do any of the other ways, just comment.
I am trying to create a UITableViewCell subclass containing two rounded views, one on top and one on bottom, that together end up as a rounded rectangular view inside the cell, with indented space on all 4 sides (set by auto layout constrains in the storyboard for the prototype cell). These cells are part of a tableview that is loaded into a UIContainerView which has its contents swapped out based on the selection of a selection control.
Here is what I want the cell to look like (blacked out):
Here is what it looks like briefly, when first loading:
Here is what it looks like after it first loads:
When I switch to a different tab, then come back, it renders the cell correctly.
I use this method in the parent view controller (adapted from this)
func cycleFromViewController(oldViewController: UIViewController, toViewController newViewController: UIViewController) {
oldViewController.willMoveToParentViewController(nil)
self.addChildViewController(newViewController)
self.addSubView(newViewController.view, toView:self.containerView!)
newViewController.view.alpha = 0
newViewController.view.layoutIfNeeded()
UIView.animateWithDuration(0.25, animations: {
newViewController.view.alpha = 1
oldViewController.view.alpha = 0
},
completion: { finished in
oldViewController.view.removeFromSuperview()
oldViewController.removeFromParentViewController()
newViewController.didMoveToParentViewController(self)
})
}
The parent view controller's viewDidLoad method is called like this:
override func viewDidLoad() {
... // grab data in a background network call, populating the array of model objects
self.currentSelectedViewController!.view.translatesAutoresizingMaskIntoConstraints = false
self.addChildViewController(self.currentSelectedViewController!)
self.addSubView(self.currentSelectedViewController!.view, toView: self.containerView)
self.refreshContainerView()
super.viewDidLoad()
}
refreshContainerView looks like this:
func refreshContainerView() {
let currentVC = self.currentSelectedViewController as! MyTableViewController
currentVC.modelObjectList = self.modelObjectList
self.label.hidden = true
self.button.hidden = true
currentVC.tableView.reloadData()
}
Here is my cell's layout subviews method:
override func layoutSubviews() {
super.layoutSubviews()
self.reminderView.backgroundColor = UIColor.grayColor()
if let aModel = self.model {
self.configureWithModel(aModel)
}
self.setMaskToView(self.topView, corners: UIRectCorner.TopLeft.union(UIRectCorner.TopRight))
self.setMaskToView(self.bottomView, corners: UIRectCorner.BottomLeft.union(UIRectCorner.BottomRight))
}
Any thoughts as to how to fix
1. the initial brief loading without the insets and
2. the final rendering of the initial load with the rounded corners on the right side not properly rendering?
This cell exists in a storyboard as a prototype, with the insets created via auto layout constraints. (a constant setting the top and bottom view's distance from the top, bottom, right and left as appropriate). Clearly these constraints work when the cell is reloaded, but not on the initial load for some reason that is escaping me.
Evidently the answer was fairly simple. The mask method was being called in layoutSubviews for the cell, the the views themselves did not yet have their bounds set. So I subclassed the view into a new RoundedView class, and added a var for the corners and a modified mask method:
class RoundedView: UIView {
var corners : UIRectCorner = []
override func layoutSubviews() {
self.setMaskForCorners(corners)
}
func setMaskForCorners(corners: UIRectCorner) {
let rounded = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: 10, height: 10))
let mask = CAShapeLayer()
mask.path = rounded.CGPath
self.layer.mask = mask
}
}
Then I changed the views to be that subclass and then call it like this:
self.topView.corners = UIRectCorner.TopLeft.union(UIRectCorner.TopRight)
self.bottomView.corners = UIRectCorner.BottomLeft.union(UIRectCorner.BottomRight)