I have a tap gesture on a UILabel who's translation is being animated. Whenever you tap on the label during the animation there's no response from the tap gesture.
Here's my code:
label.addGestureRecognizer(tapGesture)
label.userInteractionEnabled = true
label.transform = CGAffineTransformMakeTranslation(0, 0)
UIView.animateWithDuration(12, delay: 0, options: UIViewAnimationOptions.AllowUserInteraction, animations: { () -> Void in
label.transform = CGAffineTransformMakeTranslation(0, 900)
}, completion: nil)
Gesture code:
func setUpRecognizers() {
tapGesture = UITapGestureRecognizer(target: self, action: "onTap:")
}
func onTap(sender : AnyObject) {
print("Tapped")
}
Any ideas? Thanks :)
Note added for 2021:
These days this is dead easy, you just override hitTest.
How to detect touches in a view which is moving
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pf = layer.presentation()!.frame
// note, that is in the space of our superview
let p = self.convert(point, to: superview!)
if pf.contains(p) { return self }
return nil
}
It's that easy
Related tip -
Don't forget that in most cases if an animation is running, you will, of course, almost certainly want to cancel it. So, say there's a "moving target" and you want to be able to grab it with your finger and slide it somewhere else, naturally in that use case your code in your view controller will look something like ..
func sliderTouched() {
if alreadyMoving {
yourPropertyAnimator?.stopAnimation(true)
yourPropertyAnimator = nil
}
etc ...
}
You will not be able to accomplish what you are after using a tapgesture for 1 huge reason. The tapgesture is associated with the frame of the label. The labels final frame is changed instantly when kicking off the animation and you are just watching a fake movie(animation). If you were able to touch (0,900) on the screen it would fire as normal while the animation is occuring. There is a way to do this a little bit different though. The best would be to uses touchesBegan. Here is an extension I just wrote to test my theory but could be adapted to fit your needs.For example you could use your actual subclass and access the label properties without the need for loops.
extension UIViewController{
public override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
guard let touch = touches.first else{return}
let touchLocation = touch.locationInView(self.view)
for subs in self.view.subviews{
guard let ourLabel = subs as? UILabel else{return}
print(ourLabel.layer.presentationLayer())
if ourLabel.layer.presentationLayer()!.hitTest(touchLocation) != nil{
print("Touching")
UIView.animateWithDuration(0.4, animations: {
self.view.backgroundColor = UIColor.redColor()
}, completion: {
finished in
UIView.animateWithDuration(0.4, animations: {
self.view.backgroundColor = UIColor.whiteColor()
}, completion: {
finished in
})
})
}
}
}
}
You can see that it is testing the coordinates of the CALayer.presentationLayer()..That's what I was calling the movies. To be honest, I have still not wrapped my head completely around the presentation layer and how it works.
I was stuck on this problem for hours, and could not understand why the tapping did not work on an animated label which slides out of screen after 3 seconds delay.
Well said agibson007, about the animation works like a fake movie's playback, the 3-second delay controls the payback of the movie, yet the label's frame is changed as soon as the animation begins without a delay. So the tapping (which depends on the label's frame at its original position) would not work.
My solution was changing the 3-second delay to a timeout function like -
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
[weak self] in
self?.hideLabel()
}
So that keeps the tapping works during the delay, and allow animation runs inside the hideLabel() call after the delay.
If you want to see the animation, you need to put it in the onTap handler.
let gesture = UITapGestureRecognizer(target: self, action: #selector(onTap(sender:))
gesture.numberOfTapsRequired = 1
label.addGestureRecognizer(gesture)
label.userInteractionEnabled = true
label.transform = CGAffineTransformMakeTranslation(0, 0)
UIView.animateWithDuration(12, delay: 3, options: [.allowUserInteraction], animations: { () -> Void in
label.transform = CGAffineTransformMakeTranslation(0, 900)
}, completion: nil)
func onTap(sender: AnyObject)
{
print("Tapped")
}
For your tap gesture to work, you have to set the number of taps. Add this line:
tapGesture.numberOfTapsRequired = 1
(I'm assuming that tapGesture is the same one you call label.addGestureRecognizer(tapGesture) with)
Below is a more generic answer based on the answer from #agibson007 in Swift 3.
This didn't solve my issue immediately, because I had additional subviews covering my view. If you have trouble, try changing the extension type and writing print statements for touchLocation to find out when the function is firing. The description in the accepted answer explains the issue well.
extension UIViewController {
open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let touchLocation = touch.location(in: self.view)
for subview in self.view.subviews {
if subview.tag == VIEW_TAG_HERE && subview.layer.presentation()?.hitTest(touchLocation) != nil {
print("[UIViewController] View Touched!")
// Handle Action Here
}
}
}
}
Related
I have this simple code:
func tappedButton() {
self.button.alpha = 1.0
UIView.animate(withDuration: 1.0, delay: 4.0, options: .curveLinear, animations: {
self.button.alpha = 0.0
}) { _ in }
}
This function aims at showing a button again for 4 seconds before hiding it (with a 1 second animation). However, while the button is completely visible for these 4 seconds, tapping it doesn't work anymore.
Thanks for your help.
As per the documentation in for the method hittest(_:with:) of UIView https://developer.apple.com/documentation/uikit/uiview/1622469-hittest
This method ignores view objects that are hidden, that have disabled user interactions, or have an alpha level less than 0.01. This method does not take the view’s content into account when determining a hit. Thus, a view can still be returned even if the specified point is in a transparent portion of that view’s content.
This means that any view, particularly a button, with alpha 0.0 would not be touched.
However, the problem here is that the button is still visible, at least for you. This odd behavior occurs because the actual alpha value of the button is already setted to 0.0 when the animations starts. Animations work by changing the visual hierachy and transition the difference with the parameters you give to the function. In your case, you have two states: a view with a visible button visible and another view without the button. Only the visual part is animated but the corresponding values are already setted. A solution would be:
func tappedButton() {
self.button.alpha = 1.0
UIView.animate(withDuration: 1.0, delay: 4.0, options: .curveLinear, animations: { [weak self] in
self?.button.alpha = 0.01
}) { [weak self] _ in self?.button.alpha = 0.0 }
}
EDIT: This solution seems like a hack but works. I use this approach because the completion handler is always called with a true value.
func tapped() {
let duration = 1.0
let delay = 2.0
let delayDuration = delay + duration
UIView.animate(withDuration: duration, delay: delay, options: [.curveLinear, .allowUserInteraction], animations: { [weak self] in
self?.saveButton.alpha = 0.1
})
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + delayDuration, execute: { [weak self] in
self?.saveButton.alpha = 0.0
})
}
You need to use allUserInteraction in the options and also check for touches. The animation is added immediately and although you see the button to the system it is already hidden. What does this mean? It means you are watching a movie. But at least with userInteraction enabled you can check for touch events. This is great but how do we know the button is really showing or not? Well you have to use two different checks most likely. One that checks the true UIView alpha of the button and one check that checks the opacity on the presentation layer. I have never fully looked at the link between UIView animations and Core Animation except that I think UIView animations are a wrapper for Core Animations. UIView animations definitely update the view model immediately. So an alpha animation is most likely interpreted into an opacity animation on the layer. Armed with this we can check the opacity of the presentation layer on touches and see that the button is being clicked even if the view model thinks the alpha is 0. This check on the presentation layer will work as long as the opacity is above 0. So here you go.
import UIKit
class ViewController: UIViewController {
lazy var testButton : UIButton = {
let v = UIButton(frame: CGRect(x: 20, y: 50, width: self.view.bounds.width - 40, height: 50))
v.backgroundColor = UIColor.blue
v.addTarget(self, action: #selector(buttonClicked), for: .touchUpInside)
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(testButton)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 1.0, delay: 4.0, options: .allowUserInteraction, animations: {
self.testButton.alpha = 0
}, completion: nil)
//test the layer
//test the layer for opacity
if let presentation = testButton.layer.presentation()?.animation(forKey: "opacity"){
print("the animation is already added so normal clicks won't work")
}
}
#objc func buttonClicked(){
print("clicked")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
if let touch = touches.first{
let location = touch.location(in: self.view)
if self.testButton.frame.contains(location){
//but what you might not know is the animation is probably already running
//and so the check above misses this
if let buttonPres = testButton.layer.presentation(),
let _ = buttonPres.animation(forKey: "opacity"),
let opacity = buttonPres.value(forKey: "opacity") as? CGFloat{
if opacity > 0{
buttonClicked()
}
}
}
}
}
}
Edited
See the comment section with Nathan for the latest project. There is only problem remaining: getting the right button.
Edited
I want to have a UIView that the user can rotate. That UIView should contain some UIButtons that can be clicked. I am having a hard time because I am using a UIControl subclass to make the rotating view and in that subclass I have to disable user interactions on the subviews in the UIControl (to make it spin) which may cause the UIButtons not be tappable. How can I make a UIView that the user can spin and contains clickable UIButtons? This is a link to my project which gives you a head start: it contains the UIButtons and a spinnable UIView. I can however not tap the UIButtons.
Old question with more details
I am using this pod: https://github.com/joshdhenry/SpinWheelControl and I want to react to a buttons click. I can add the button, however I can not receive tap events in the button. I am using hitTests but they never get executed. The user should spin the wheel and be able to click a button in one of the pie's.
Get the project here: https://github.com/Jasperav/SpinningWheelWithTappableButtons
See the code below what I added in the pod file:
I added this variable in SpinWheelWedge.swift:
let button = SpinWheelWedgeButton()
I added this class:
class SpinWheelWedgeButton: TornadoButton {
public func configureWedgeButton(index: UInt, width: CGFloat, position: CGPoint, radiansPerWedge: Radians) {
self.frame = CGRect(x: 0, y: 0, width: width, height: 30)
self.layer.anchorPoint = CGPoint(x: 1.1, y: 0.5)
self.layer.position = position
self.transform = CGAffineTransform(rotationAngle: radiansPerWedge * CGFloat(index) + CGFloat.pi + (radiansPerWedge / 2))
self.backgroundColor = .green
self.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside)
}
#IBAction func pressed(_ sender: TornadoButton){
print("hi")
}
}
This is the class TornadoButton:
class TornadoButton: UIButton{
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
let prespt = self.superview!.layer.convert(suppt, to: pres)
if (pres.hitTest(suppt)) != nil{
return self
}
return super.hitTest(prespt, with: event)
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
return (pres.hitTest(suppt)) != nil
}
}
I added this to SpinWheelControl.swift, in the loop "for wedgeNumber in"
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 2, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
wedge.addSubview(wedge.button)
This is where I thought I could retrieve the button, in SpinWheelControl.swift:
override open func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let p = touch.location(in: touch.view)
let v = touch.view?.hitTest(p, with: nil)
print(v)
}
Only 'v' is always the spin wheel itself, never the button. I also do not see the buttons print, and the hittest is never executed. What is wrong with this code and why does the hitTest not executes? I rather have a normal UIBUtton, but I thought I needed hittests for this.
Here is a solution for your specific project:
Step 1
In the drawWheel function in SpinWheelControl.swift, enable user interaction on the spinWheelView. To do this, remove the following line:
self.spinWheelView.isUserInteractionEnabled = false
Step 2
Again in the drawWheel function, make the button a subview of the spinWheelView, not the wedge. Add the button as a subview after the wedge, so it will appear on top of the wedge shape layer.
Old:
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 0.45, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
wedge.addSubview(wedge.button)
spinWheelView.addSubview(wedge)
New:
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 0.45, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
spinWheelView.addSubview(wedge)
spinWheelView.addSubview(wedge.button)
Step 3
Create a new UIView subclass that passes touches through to its subviews.
class PassThroughView: UIView {
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
}
}
Step 4
At the very beginning of the drawWheel function, declare the spinWheelView to be of type PassThroughView. This will allow the buttons to receive touch events.
spinWheelView = PassThroughView(frame: self.bounds)
With those few changes, you should get the following behavior:
(The message is printed to the console when any button is pressed.)
Limitations
This solution allows the user to spin the wheel as usual, as well as tap any of the buttons. However, this might not be the perfect solution for your needs, as there are some limitations:
The wheel cannot be spun if the users touch down starts within the bounds of any of the buttons.
The buttons can be pressed while the wheel is in motion.
Depending on your needs, you might consider building your own spinner instead of relying on a third-party pod. The difficulty with this pod is that it is using the beginTracking(_ touch: UITouch, with event: UIEvent?) and related functions instead of gesture recognizers. If you used gesture recognizers, it would be easier to make use of all the UIButton functionality.
Alternatively, if you just wanted to recognize a touch down event within the bounds of a wedge, you could pursue your hitTest idea further.
Edit: Determining which button was pressed.
If we know the selectedIndex of the wheel and the starting selectedIndex, we can calculate which button was pressed.
Currently, the starting selectedIndex is 0, and the button tags increase going clockwise. Tapping the selected button (tag = 0), prints 7, which means that the buttons are "rotated" 7 positions in their starting state. If the wheel started in a different position, this value would differ.
Here is a quick function to determine the tag of the button that was tapped using two pieces of information: the wheel's selectedIndex and the subview.tag from the current point(inside point: CGPoint, with event: UIEvent?) implementation of the PassThroughView.
func determineButtonTag(selectedIndex: Int, subviewTag: Int) -> Int {
return subviewTag + (selectedIndex - 7)
}
Again, this is definitely a hack, but it works. If you are planning to continue to add functionality to this spinner control, I would highly recommend creating your own control instead so you can design it from the beginning to fit your needs.
I was able to tinker around with the project and I think I have the solution to your problem.
In your SpinWheelControl class, you are setting the userInteractionEnabled property of the spinWheelViews to false. Note that this is not what you exactly want, because you are still interested in tapping the button which is inside the spinWheelView. However, if you don't turn off user interaction, the wheel won't turn because the child views mess up the touches!
To solve this problem, we can turn off the user interaction for the child views and manually trigger only the events that we are interested in - which is basically touchUpInside for the innermost button.
The easiest way to do that is in the endTracking method of the SpinWheelControl. When the endTracking method gets called, we loop through all the buttons manually and call endTracking for them as well.
Now the problem about which button was pressed remains, because we just sent endTracking to all of them. The solution to that is overriding the endTracking method of the buttons and trigger the .touchUpInside method manually only if the touch hitTest for that particular button was true.
Code:
TornadoButton Class: (the custom hitTest and pointInside are no longer needed since we are no longer interested in doing the usual hit testing; we just directly call endTracking)
class TornadoButton: UIButton{
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if let t = touch {
if self.hitTest(t.location(in: self), with: event) != nil {
print("Tornado button with tag \(self.tag) ended tracking")
self.sendActions(for: [.touchUpInside])
}
}
}
}
SpinWheelControl Class: endTracking method:
override open func endTracking(_ touch: UITouch?, with event: UIEvent?) {
for sv in self.spinWheelView.subviews {
if let wedge = sv as? SpinWheelWedge {
wedge.button.endTracking(touch, with: event)
}
}
...
}
Also, to test that the right button is being called, just set the tag of the button equal to the wedgeNumber when you are creating them. With this method, you will not need to use the custom offset like #nathan does, because the right button will respond to the endTracking and you can just get its tag by sender.tag.
The general solution would be to use a UIView and place all your UIButtons where they should be, and use a UIPanGestureRecognizer to rotate your view, calculate speed and direction vector and rotate your view. For rotating your view I suggest using transform because it's animatable and also your subviews will be also rotated. (extra: If you want to set direction of your UIButtons always downward, just rotate them in reverse, it will cause them to always look downward)
Hack
Some people also use UIScrollView instead of UIPanGestureRecognizer. Place described View inside the UIScrollView and use UIScrollView's delegate methods to calculate speed and direction then apply those values to your UIView as described. The reason for this hack is because UIScrollView decelerates speed automatically and provides better experience. (Using this technique you should set contentSize to something very big and relocate contentOffset of UIScrollView to .zero periodically.
But I highly suggest the first approach.
As for my opinion, you can use your own view with few sublayers and all other stuff you need.
In this case u will get full flexibility but you also should write a little bit more code.
If you like this option u can get something like on gif below (you can customize it as u wish - add text, images, animations etc):
Here I show you 2 continuous pan and one tap on purple section - when tap is detected6 bg color changed to green
To detect tap I used touchesBegan as shown below.
To play with code for this you can copy-paste code below in to playground and modify as per your needs
//: A UIKit based Playground for presenting user interface
import UIKit import PlaygroundSupport
class RoundView : UIView {
var sampleArcLayer:CAShapeLayer = CAShapeLayer()
func performRotation( power: Float) {
let maxDuration:Float = 2
let maxRotationCount:Float = 5
let currentDuration = maxDuration * power
let currrentRotationCount = (Double)(maxRotationCount * power)
let fromValue:Double = Double(atan2f(Float(transform.b), Float(transform.a)))
let toValue = Double.pi * currrentRotationCount + fromValue
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = fromValue
rotateAnimation.toValue = toValue
rotateAnimation.duration = CFTimeInterval(currentDuration)
rotateAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
rotateAnimation.isRemovedOnCompletion = true
layer.add(rotateAnimation, forKey: nil)
layer.transform = CATransform3DMakeRotation(CGFloat(toValue), 0, 0, 1)
}
override func layoutSubviews() {
super.layoutSubviews()
drawLayers()
}
private func drawLayers()
{
sampleArcLayer.removeFromSuperlayer()
sampleArcLayer.frame = bounds
sampleArcLayer.fillColor = UIColor.purple.cgColor
let proportion = CGFloat(20)
let centre = CGPoint (x: frame.size.width / 2, y: frame.size.height / 2)
let radius = frame.size.width / 2
let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle
let startAngle:CGFloat = 45
let cPath = UIBezierPath()
cPath.move(to: centre)
cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
sampleArcLayer.path = cPath.cgPath
// you can add CATExtLayer and any other stuff you need
layer.addSublayer(sampleArcLayer)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let point = touches.first?.location(in: self) {
if let layerArray = layer.sublayers {
for sublayer in layerArray {
if sublayer.contains(point) {
if sublayer == sampleArcLayer {
if sampleArcLayer.path?.contains(point) == true {
backgroundColor = UIColor.green
}
}
}
}
}
}
}
}
class MyViewController : UIViewController {
private var lastTouchPoint:CGPoint = CGPoint.zero
private var initialTouchPoint:CGPoint = CGPoint.zero
private let testView:RoundView = RoundView(frame:CGRect(x: 40, y: 40, width: 100, height: 100))
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
testView.layer.cornerRadius = testView.frame.height / 2
testView.layer.masksToBounds = true
testView.backgroundColor = UIColor.red
view.addSubview(testView)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(MyViewController.didDetectPan(_:)))
testView.addGestureRecognizer(panGesture)
}
#objc func didDetectPan(_ gesture:UIPanGestureRecognizer) {
let touchPoint = gesture.location(in: testView)
switch gesture.state {
case .began:
initialTouchPoint = touchPoint
break
case .changed:
lastTouchPoint = touchPoint
break
case .ended, .cancelled:
let delta = initialTouchPoint.y - lastTouchPoint.y
let powerPercentage = max(abs(delta) / testView.frame.height, 1)
performActionOnView(scrollPower: Float(powerPercentage))
initialTouchPoint = CGPoint.zero
break
default:
break
}
}
private func performActionOnView(scrollPower:Float) {
testView.performRotation(power: scrollPower)
} } // Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
I'm having a little trouble getting my UITapGestureRecognizer to register touches from the user. The showMenu() function is supposed to display a partially transparent blackView to the user, and I've implemented the gesture recognizer so that when the user taps on blackView, dismiss() is called and makes the view fade.
In my XCode Console, I don't receive the print message "dismissed" as designated in the code.
Thanks in advance!
func showMenu(){
print("show")
if let window = UIApplication.sharedApplication().keyWindow{
blackView.backgroundColor = UIColor(white: 0, alpha: 0.5)
blackView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismiss)))
window.addSubview(blackView)
blackView.frame = window.frame
blackView.alpha = 0
//Added this line
blackView.userInteractionEnabled = true
UIView.animateWithDuration(0.5){
self.blackView.alpha = 1
}
}
}
func dismiss(){
print("dismiss")
UIView.animateWithDuration(0.5){
self.blackView.alpha = 0
}
}
EDIT: I've checked to see if any views were on top stealing the taps using the View Hierarchy Debug tool but there doesn't seem to be anything above blackView, I've also explicitly set blackView to allow User Interaction.
EDIT: A screenshot of the View Hierarchy Debug interface.
If you change the alpha view to zero it stops getting touch events.
You could try to change view's background color to transparent, instead of changing view's alpha, that way your view won't be visible and you get the events
Add this to your code. It recognises if there has been 1 touch and then you can call your dismiss method.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first?.tapCount
if touch == 1 {
// Call Dismiss
dismiss()
}
}
So I have quite a simple task: animate a view to appear (height goes from zero to full height), then it has to disappear animatedly (collapse, height goes to zero). It looks like this:
Implementation is very simple: we give the view a fixed height constraint, then animate it 0->fullHeight, fullHeight->0.
notificationView.heightConstraint.constant = intrinsicHeight
UIView.animateWithDuration(0.25, animations: {
view.layoutIfNeeded()
}) { (finished) in
guard finished else { return }
notificationView.heightConstraint.constant = 0
UIView.animateWithDuration(0.25, delay: 3, options: .AllowUserInteraction, animations: {
notificationView.superview!.layoutIfNeeded()
}, completion: nil)
}
The trouble is that a UIButton contained in the notification doesn't receive any touches because notification's height is set to 0.
I guess, an alternative would be to use NSTimer or dispatch_after, but that would make things a little bit more complicated and I don't want that.
The question is: can I preserve user interaction using current approach?
You are correct. The animation is updating the model values for your button's frame as soon as you call animateWithDuration so even though your button is still on screen it is only responding to hits in an area that is off screen. What you want is to do hit testing on the presentation layer of the button, which will have the value of the button's frame mid-flight.
One option is to subclass UIButton and override hitTest like:
override func hitTest(point: CGPoint, withEvent event: UIEvent!) -> UIView!
{
let superviewPoint = convertPoint(point, toView: superview)
let point = layer.presentationLayer.convertPoint(superviewPoint,
fromLayer: superview.layer)
return super.hitTest(point, withEvent: event)
}
This is what Apple suggests in this video.
Another suggestion here, is to override touchesEnded or touchesBegan in your view controller and explicitly call hitTest on the presentationLayer of your button like so:
override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?)
{
guard let touch = touches.first else{ return }
let point = touch.locationInView(self.notificationView)
if ((self.button.layer.presentationLayer()?.hitTest(point)) != nil)
{
// We're touching where the button is presented, so call your action
self.buttonPress(self.button)
}
}
This has the drawback of your button not highlighting on touch down.
For these to work you need the .AllowUserInteraction option enabled on your animation, which you are already doing.
Another option is to add the 3 second delay using Grand Central Dispatch, instead of specifying it in the animation:
notificationView.heightConstraint.constant = intrinsicHeight
UIView.animateWithDuration(0.25, animations: {
view.layoutIfNeeded()
}) { (finished) in
guard finished else { return }
notificationView.heightConstraint.constant = 0
delay(3) {
UIView.animateWithDuration(0.25, delay: 0, options: .AllowUserInteraction, animations: {
notificationView.superview!.layoutIfNeeded()
}, completion: nil)
}
}
where delay is a helper function defined as:
func delay(delay:NSTimeInterval, _ f:()->()) {
dispatch_after(
dispatch_time(
DISPATCH_TIME_NOW,
Int64(delay * Double(NSEC_PER_SEC))
),
dispatch_get_main_queue(), f)
}
I have created an interactive transition. My func animateTransition(transitionContext: UIViewControllerContextTransitioning) is quite normal, I get the container UIView, I add the two UIViewControllers and then I do the animation changes in a UIView.animateWithDuration(duration, animations, completion).
I add a UIScreenEdgePanGestureRecognizer to my from UIViewController. It works well except when I do a very quick pan.
In that last scenario, the app is not responsive, still on the same UIViewController (the transition seems not to have worked) but the background tasks run. When I run the Debug View Hierarchy, I see the new UIViewController instead of the previous one, and the previous one (at least its UIView) stands where it is supposed to stand at the end of the transition.
I did some print out and check points and from that I can say that when the problem occurs, the animation's completion (the one in my animateTransition method) is not reached, so I cannot call the transitionContext.completeTransition method to complete or not the transition.
I could see as well that the pan goes sometimes from UIGestureRecognizerState.Began straight to UIGestureRecognizerState.Ended without going through UIGestureRecognizerState.Changed.
When it goes through UIGestureRecognizerState.Changed, both the translation and the velocity stay the same for every UIGestureRecognizerState.Changed states.
EDIT :
Here is the code:
animateTransition method
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
let containerView = transitionContext.containerView()
let screens: (from: UIViewController, to: UIViewController) = (transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!, transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!)
let parentViewController = presenting ? screens.from : screens.to
let childViewController = presenting ? screens.to : screens.from
let parentView = parentViewController.view
let childView = childViewController.view
// positionning the "to" viewController's view for the animation
if presenting {
offStageChildViewController(childView)
}
containerView.addSubview(parentView)
containerView.addSubview(childView)
let duration = transitionDuration(transitionContext)
UIView.animateWithDuration(duration, animations: {
if self.presenting {
self.onStageViewController(childView)
self.offStageParentViewController(parentView)
} else {
self.onStageViewController(parentView)
self.offStageChildViewController(childView)
}}, completion: { finished in
if transitionContext.transitionWasCancelled() {
transitionContext.completeTransition(false)
} else {
transitionContext.completeTransition(true)
}
})
}
Gesture and gesture handler:
weak var fromViewController: UIViewController! {
didSet {
let screenEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: "presentingViewController:")
screenEdgePanRecognizer.edges = edge
fromViewController.view.addGestureRecognizer(screenEdgePanRecognizer)
}
}
func presentingViewController(pan: UIPanGestureRecognizer) {
let percentage = getPercentage(pan)
switch pan.state {
case UIGestureRecognizerState.Began:
interactive = true
presentViewController(pan)
case UIGestureRecognizerState.Changed:
updateInteractiveTransition(percentage)
case UIGestureRecognizerState.Ended:
interactive = false
if finishPresenting(pan, percentage: percentage) {
finishInteractiveTransition()
} else {
cancelInteractiveTransition()
}
default:
break
}
}
Any idea what might happen?
EDIT 2:
Here are the undisclosed methods:
override func getPercentage(pan: UIPanGestureRecognizer) -> CGFloat {
let translation = pan.translationInView(pan.view!)
return abs(translation.x / pan.view!.bounds.width)
}
override func onStageViewController(view: UIView) {
view.transform = CGAffineTransformIdentity
}
override func offStageParentViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(-view.bounds.width / 2, 0)
}
override func offStageChildViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(view.bounds.width, 0)
}
override func presentViewController(pan: UIPanGestureRecognizer) {
let location = pan.locationInView((fromViewController as! MainViewController).tableView)
let indexPath = (fromViewController as! MainViewController).tableView.indexPathForRowAtPoint(location)
if indexPath == nil {
pan.state = .Failed
return
}
fromViewController.performSegueWithIdentifier("chartSegue", sender: pan)
}
I remove the "over" adding lines => didn't fix it
I added updateInteractiveTransition in .Began, in .Ended, in both => didn't fix it
I turned on shouldRasterize on the layer of the view of my toViewController and let it on all the time => didn't fix it
But the question is why, when doing a fast interactive gesture, is it not responding quickly enough
It actually works with a fast interactive gesture as long as I leave my finger long enough. For example, if I pan very fast on more than (let say) 1cm, it's ok. It's not ok if I pan very fast on a small surface (let say again) less than 1cm
Possible candidates include the views being animated are too complicated (or have complicated effects like shading)
I thought about a complicated view as well but I don't think my view is really complicated. There are a bunch of buttons and labels, a custom UIControl acting as a segmented segment, a chart (that is loaded once the controller appeared) and a xib is loaded inside the viewController.
Ok I just created a project with the MINIMUM classes and objects in order to trigger the problem. So to trigger it, you just do a fast and brief swipe from the right to the left.
What I noticed is that it works pretty easily the first time but if you drag the view controller normally the first time, then it get much harder to trigger it (even impossible?). While in my full project, it doesn't really matter.
When I was diagnosing this problem, I noticed that the gesture's change and ended state events were taking place before animateTransition even ran. So the animation was canceled/finished before it even started!
I tried using GCD animation synchronization queue to ensure that the updating of the UIPercentDrivenInterativeTransition doesn't happen until after `animate:
private let animationSynchronizationQueue = dispatch_queue_create("com.domain.app.animationsynchronization", DISPATCH_QUEUE_SERIAL)
I then had a utility method to use this queue:
func dispatchToMainFromSynchronizationQueue(block: dispatch_block_t) {
dispatch_async(animationSynchronizationQueue) {
dispatch_sync(dispatch_get_main_queue(), block)
}
}
And then my gesture handler made sure that changes and ended states were routed through that queue:
func handlePan(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .Began:
dispatch_suspend(animationSynchronizationQueue)
fromViewController.performSegueWithIdentifier("segueID", sender: gesture)
case .Changed:
dispatchToMainFromSynchronizationQueue() {
self.updateInteractiveTransition(percentage)
}
case .Ended:
dispatchToMainFromSynchronizationQueue() {
if isOkToFinish {
self.finishInteractiveTransition()
} else {
self.cancelInteractiveTransition()
}
}
default:
break
}
}
So, I have the gesture recognizer's .Began state suspend that queue, and I have the animation controller resume that queue in animationTransition (ensuring that the queue starts again only after that method runs before the gesture proceeds to try to update the UIPercentDrivenInteractiveTransition object.
Have the same issue, tried to use serialQueue.suspend()/resume(), does not work.
This issue is because when pan gesture is too fast, end state is earlier than animateTransition starts, then context.completeTransition can not get run, the whole animation is messed up.
My solution is forcing to run context.completeTransition when this situation happened.
For example, I have two classes:
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if !swipeInteractor.interactionInProgress {
DispatchQueue.main.asyncAfter(deadline: .now()+transitionDuration) {
if context.transitionWasCancelled {
toView?.removeFromSuperview()
} else {
fromView?.removeFromSuperview()
}
context.completeTransition(!context.transitionWasCancelled)
}
}
...
}
...
}
interactionInProgress is set to true when gesture began, set to false when gesture ends.
I had a similar problem, but with programmatic animation triggers not triggering the animation completion block. My solution was like Sam's, except instead of dispatching after a small delay, manually call finish on the UIPercentDrivenInteractiveTransition instance.
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
private var swipeInteractor: SwipeInteractor
..
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
...
if !swipeInteractor.interactionInProgress {
swipeInteractor.finish()
}
...
UIView.animateWithDuration(...)
}
}