Glitch when interactively dismissing modal - ios

We have run into this issue when implementing interactive dismissal of a modal view controller (dragging modal down should dismiss it) via UIPercentDrivenInteractiveTransition.
Setup:
setup UIViewController embedded in UINavigationController with at least one button in UINavigationBar
modally present another UIViewController embedded in UINavigationController with at least one button in UINavigationBar
setup UIPanGestureRecognizer on modaly presented UINavigationController to drive UIPercentDrivenInteractiveTransition
drag modal down "holding" it by point on UINavigationBar
Issue:
while slowly dragging down, animation glitches causing modal view to jump up and down
glitch only appears when :
both UINavigationBars have at least one button on them
you "hold" modal by the point on UINavigationBar
Minimal example can be downloaded from github repo.
Has anyone come accross such an issue? Are there any workarounds? Is there some flaw in our setup?
Update
Issue has been simulated on running project above on iPhone 5 simulator with iOS 9.3, OSX 10.11.4, compiled with Xcode 7.3.1.
Update 2
Further investigation showed, that issue is probably not in the animation: For some reason in given setup pan.translationInView(view) is returning unexpected values which causes animation to jump.
Partial workaround
Based on Vladimir's idea we partially fixed the issue by overriding hitTest method of UINavigationBar:
class DraggableNavigationBar: UINavigationBar {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
guard let view = super.hitTest(point, withEvent: event) else { return nil }
if view is UIControl || pointIsInsideNavigationButton(point) {
return view
} else {
return nil
}
}
private func pointIsInsideNavigationButton(point: CGPoint) -> Bool {
return subviews
.filter { $0.frame.contains(point) }
.filter { String($0.dynamicType) == "UINavigationItemButtonView" }
.isEmpty == false
}
}

Very interesting glitch. I found a partial solution of this problem few days ago, and since nobody found a full solution, I'll post this, maybe it will be helpful.
If you override hitTest method of UINavigationBar you can get rid of this issue when you dragging modal by holding on UINavigationBar:
extension UINavigationBar {
override public func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
guard let view = super.hitTest(point, withEvent: event) else { return nil }
if view.isKindOfClass(UIControl) {
return super.hitTest(point, withEvent: event)
} else {
return nil
}
}
}
Unfortunately if you drag modal by holding on UIBarButtonItem on UINavigationBar, glitch still be present.
You can also try another approach.
As you noticed, pan.translationInView(view) returns incorrect values which causes animation to jump.
You need to compare this value to y coordinate of modal view during dragging. You can get this value by checking presentation layer of the modal view controller:
...
let translation = pan.translationInView(view)
if let layer = view.layer.presentationLayer() {
print(layer.frame.origin.y)
}
...
You can see that when pan.translationInView(view) starts to show wrong value, layer.frame.origin.y still will be correct in that moment. You can compare these two values and find the pattern when value is incorrect, and change it to correct by adding few points to translation.y value.

I don't have a complete solution but I was able to reduce the glitch by certain amount. I could reproduce this issue on iPhone 5s with iOS 9.3.2 [by dragging down the screen holding Navigation Bar]
The problem seems to be in the UIView.animateWithDuration block of DismissalAnimator. By commenting out the delay and options i.e keeping them to the defaults you can reduce the jumping of the view. You could also try and check for diffrent UIViewAnimationOptions for which you get minimum jump.
UIView.animateWithDuration(0.3,
animations: {
dismissedView.frame = finalFrame
},
completion: { _ in
let didComplete = !transitionContext.transitionWasCancelled()
transitionContext.completeTransition(didComplete)
}
)
There is a question which seems to be dealing with same issue you are facing. And the responses varying from disabling auto layout, putting layoutIfNeeded in animation block [tried, both didn't work].

Related

CollectionView Touch is now working when placed on top of tabBar Swift

I have added a collectionView on top of a UITabBar but its touch is not working.The screeshot for the tabBar and collectionView
The code is attached below, I want the collectionView to be touchable. Here quickAccessView is the UIView that contains the collectionView. For constraints I'm using snapKit
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
self.tabBar.bringSubviewToFront(quickAccessView)
}
private func setupQuickAccessView(){
print("this is tabBar's height", self.tabBar.frame.size.height)
self.tabBar.frame.size.height = 150
print("this is new tabBar's height", self.tabBar.frame.size.height)
self.tabBar.addSubview(quickAccessView)
quickAccessView.clipsToBounds = true
}
private func addQuickAccessViewConstraints(){
quickAccessView.snp.makeConstraints { make in
make.right.left.equalTo(self.tabBar.safeAreaLayoutGuide)
make.height.equalTo(76)
make.bottom.equalTo(self.tabBar.snp.bottom).offset(-80)
}
}
this is after modification that Aman told
The UITabBarController
final class MainTabBarController: UITabBarController {
private lazy var quickAccessView: QuickAccessView = .fromNib()
var quickAccessSupportedTabBar: QuickAccessSupportedTabBar {
self.tabBar as! QuickAccessSupportedTabBar // Even code is crashing here
}
// Even code is crashing here
override func viewDidLoad() {
super.viewDidLoad()
self.tabBar.backgroundColor = .white
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.view.frame = self.quickAccessView.bounds
setupUI()
}
}
extension MainTabBarController{
private func setupUI(){
setupQuickAcessView()
addQuickAcessViewConstraints()
}
}
// MARK: - Setting Up Quick Access view
extension MainTabBarController {
private func setupQuickAcessView(){
self.quickAccessSupportedTabBar.addSubview(quickAccessView)
}
private func addQuickAcessViewConstraints(){
quickAccessView.snp.makeConstraints { make in
make.left.right.equalTo(self.quickAccessSupportedTabBar.safeAreaLayoutGuide)
make.height.equalTo(66)
make.bottom.equalTo(self.quickAccessSupportedTabBar.snp.top)
}
}
}
the UItabBar and here it is throwing error and I too am confuse that how to access it and convert it to points
class QuickAccessSupportedTabBar: UITabBar {
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// if `quickAccessView` is visible, then convert `point` to its coordinate-system
// and check if it is within its bounds; if it is, then ask `quickAccessView`
// to perform the hit-test; you may skip the `isHidden` check, in-case this view
// is always present in your app; I'm assuming based on your screenshot that
// the user can dismiss / hide the `quickAccessView` using the cross icon
if !quickAccessView.isHidden {
// Convert the point to the target view's coordinate system.
// The target view isn't necessarily the immediate subview
let targetPoint = quickAccessView.convert(point, from: self)
if quickAccessView.bounds.contains(targetPoint) {
// The target view may have its view hierarchy, so call its
// hitTest method to return the right hit-test view
return quickAccessView.hitTest(targetPoint, with: event)
}
}
// else execute tabbar's default implementation
return super.hitTest(point, with: event)
}
}
I think what may be happening here is that since you've added quickAccessView as tab bar's subview, it is not accepting touches. This would be so because the tabbar's hist test will fail in this scenario.
To get around this, instead of using a UITabBar, subclass UITabBar, let us call it ToastyTabBar for reference. See the code below:
class ToastyTabBar: UITabBar {
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// if `quickAccessView` is visible, then convert `point` to its coordinate-system
// and check if it is within its bounds; if it is, then ask `quickAccessView`
// to perform the hit-test; you may skip the `isHidden` check, in-case this view
// is always present in your app; I'm assuming based on your screenshot that
// the user can dismiss / hide the `quickAccessView` using the cross icon
if !quickAccessView.isHidden {
// Convert the point to the target view's coordinate system.
// The target view isn't necessarily the immediate subview
let targetPoint = quickAccessView.convert(point, from: self)
if quickAccessView.bounds.contains(targetPoint) {
// The target view may have its view hierarchy, so call its
// hitTest method to return the right hit-test view
return quickAccessView.hitTest(targetPoint, with: event)
}
}
// else execute tabbar's default implementation
return super.hitTest(point, with: event)
}
}
Set this as the class of your tabBar (both in the storyboard and the swift file), and then see if that solves it for you.
You'll have to figure out a way to make quickAccessView accessible to the tabbar for the hit test check. I haven't advised on that above because I'm not familiar with your class hierarchy, and how and where you set stuff up, but this should be trivial.
If this solves it for you, please consider marking this as the answer, and if it does not then please share a little more info here about where you're having the problem.
Edit (for someone using a UITabBarController):
In response to your comment about "how to access UITabBar class from UITabBarController" here's how I would go about it.
I'm assuming you have a storyboard with the UITabBarController.
The first step (ignore this step if you already have a UITabBarController custom subclass) is that you need to subclass UITabBarController. Let us call this class ToastyTabBarController for reference. Set this class on the UITabBarController in your storyboard using the identity inspector pane in xcode.
The second step is to set the class of the UITabBar in your storyboard as ToastyTabBar (feel free to name it something more 'professional' 😊).
This is to be done in the same storyboard, in your UITabBarController scene itself. It will show the tabBar under your UITabBarController, and you can set the custom class on it using the identity inspector pane just like earlier.
The next step is to expose a computed property on your custom UITabBarController class, as shown below.
var toastyTabBar: ToastyTabBar {
self.tabBar as! ToastyTabBar
}
And that's it. Now you have a property on your UITabBarController subclass which is of ToastyTabBar type and you can use this new property, toastyTabBar, however you require.
Hope this helps.

Why "isFirstResponder" for UITouchEvent is returning false. When I touch a UIView

I having trouble in understanding First Responder. My Understanding is as follows.
First Responder is the first object in the responder chain which gets first oppurtunity to handle Any UIEvent.
UIKit starts finding the location of the tap. It uses a hit test API of the UIView that traverses to the deepest level of the hierarchy to find the specific touch. After finding the view, it assigns the first responder of the touch event to that view.
So when we find the view on which touch occurs then checking isFirstResponder to that view is still false. Can someone explain me? Why isFirstResponder still holding false? Even if this is the first view whose touch began is calling.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if touches.first?.view?.isFirstResponder == true {
print("Top View is firstResponder ")
}
super.touchesBegan(touches, with: event)
}
I have checked with the above code by overriding this method on top view which added on UIViewController View.
First responders follow a hierarchical chain and while you may tap on a view, you have to consider the view controller going higher up, or the sub elements of the view going deeper into the hierarchy. Apple's guide on first responders might help clarify this chain. https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/using_responders_and_the_responder_chain_to_handle_events
because this is the touched view but not the first responder in view,
UIKit manages the responder chain dynamically, using predefined rules.
UIKit dispatches some types of events, such as motion events, displaying the object's input view to the first responder.
try to add a text field in the view, touches began will not fire when selecting uitextfield, and while the keyboard appears tap anywhere in the view touches began will fire and the text field is still the first reponder.
The system’s keyboard is the most obvious example of an input view. When the user taps a UITextField, it becomes the first responder and displays its input view (the system keyboard).
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print(touches.first?.view?.firstResponder)
super.touchesBegan(touches, with: event)
}
extension UIView {
var firstResponder: UIView? {
guard !isFirstResponder else { return self }
for subview in subviews {
if let firstResponder = subview.firstResponder {
return firstResponder
}
}
return nil
}
}
check this
Get the current first responder without using a private API

iOS Button Visible, Not Clickable

I have a view in my storyboard that by default the alpha is set to 0. In certain cases the Swift file sets the alpha to 1. So either hidden or not. Before this view just contained 2 labels. I'm trying to add 2 buttons to the view.
For some reason the buttons aren't clickable at all. So when you tap it normally buttons change color slightly before you releasing, or while holding down on the button. But that behavior doesn't happen for some reason and the function connected to the button isn't being called at all.
It seems like an issue where something is overlapping or on top of the button. The button is totally visible and enabled and everything but not clickable. I tried Debug View Hierarchy but everything looks correct in that view.
Any ideas why this might be happening?
EDIT I tried making a class with the following code and in interface builder setting the container view to be that class.
class AnotherView: UIView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for view in self.subviews {
if view.isUserInteractionEnabled, view.point(inside: self.convert(point, to: view), with: event) {
return true
}
}
return false
}
}
Go with hitTest(_:with:) method. When we call super.hitTest(point, with: event), the super call returns nil, because user interaction is disabled. So, instead, we check if the touchPoint is on the UIButton, if it is, then we can return UIButton object. This sends message to the selector of the UIButton object.
class AnotherView: UIView {
#IBOutlet weak var button:UIButton!
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if self.button.frame.contains(point) {
return button
}
return view
}
#IBAction func buttnTapped(sender:UIButton) {
}
}

UIControl touches not behaving correctly on left side of VC

I have a UIControl (subclass of UIView), and when I create it and align it to the left side in my view controller, the "beginTrackingWithTouch" fails to get called when I touch the view. It will only get called when I release the touch. What is weird is that pointInside(point: CGPoint...) method gets called immediately when I touch the UIControl, and what is even weirder is that when I align this UIControl view on the right side of the view controller, it works fine--beginTrackingWithTouch is called immediately when the view is touched, not when released. In addition, beginTrackingWithTouch is called the same time endTrackingWithTouch is called. Through some testing, it works fine until the view is 20 px from the left side, then this strange issue occurs again.
Is there a reason why the UIControl continueTrackingWithTouch fails to register if it is put on the far left side of a view controller? Is this Apple's way of preventing left hand scroll? There is absolutely nothing on the left side which is blocking the UIControl.
//In public class CustomScrollBar : UIControl
//This method gets called everytime when UIControl (red area in picture) is touched
override public func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool {
return CGRectContainsPoint(handleHitArea, point)
}
//Only gets called when UIControl is touched and let go. It will not get called until your finger lifts off the screen.
override public func beginTrackingWithTouch(touch: UITouch, withEvent event: UIEvent?) -> Bool {
self.scrollerLine.hidden = true
if let delegate = self.delegate {
delegate.beginTrackingWithTouch()
}
guard self.isHandleVisible else{
return false
}
self.lastTouchLocation = touch.locationInView(self)
self.isHandleDragged = true
self.setNeedsLayout()
return true
}
//Image below: UIControl view is on the left side (light blue). If I move this on the far right side, the methods register fine.
Navigation controller has a built in back gesture recognizer, set it to false. Make sure that it is set in viewDidAppear
self.navigationController!.interactivePopGestureRecognizer!.enabled = false

Touch events are delayed near left screen edge on iOS 9 only. How to fix it?

I am developing a keybaord extension for iOS. On iOS 9 the keys react imediatelly except for keys along left edge of the keyboard. Those react with around 0.2 second delay. The reason is that the touches are simply delivered with this delay to the UIView that is root view of my keyboard. On iOS 8 there is no such delay.
My guess is that this delay is cause by some logic that is supposed to recognize gesture for opening "running apps screen". That is fine but the delay on a keyboard is unacceptable. Is there any way how to get those events without delay? Perhaps just setting delaysTouchesBegan to false on some UIGestureRecognizer?
This is for anyone using later versions of iOS (this is working on iOS 9 and 10 for me). My issue was caused by the swipe to go back gesture interfering with my touchesBegan method by preventing it from firing on the very left edge of the screen until either the touch was ended, or the system recognised the movement to not be that of the swipe to go back gesture.
In your viewDidLoad function in your controller, simply put:
self.navigationController?.interactivePopGestureRecognizer?.delaysTouchesBegan = false
The official solution since iOS11 is overriding preferredScreenEdgesDeferringSystemGestures of your UIInputViewController.
https://developer.apple.com/documentation/uikit/uiviewcontroller/2887512-preferredscreenedgesdeferringsys
However, it doesn't seem to work on iOS 13 at least. As far as I understand, that happens due to preferredScreenEdgesDeferringSystemGestures not working properly when overridden inside UIInputViewController, at least on iOS 13.
When you override this property in a regular view controller, it works as expected:
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
return [.left, .bottom, .right]
}
That' not the case for UIInputViewController, though.
UPD: It appears, gesture recognizers will still get .began state update, without the delay. So, instead of following the rather messy solution below, you can add a custom gesture recognizer to handle touch events.
You can quickly test this adding UILongPressGestureRecognizer with minimumPressDuration = 0 to your control view.
Another solution:
My original workaround was calling touch down effects inside hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?, which is called even when the touches are delayed for the view.
You have to ignore the "real" touch down event, when it fires about 0.4s later or simultaneously with touch up inside event. Also, it's probably better to apply this hack only in case the tested point is inside ~20pt lateral margins.
So for example, for a view with equal to screen width, the implementation may look like:
let edgeProtectedZoneWidth: CGFloat = 20
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
guard result == self else {
return result
}
if point.x < edgeProtectedZoneWidth || point.x > bounds.width-edgeProtectedZoneWidth
{
if !alreadyTriggeredFocus {
isHighlighted = true
}
triggerFocus()
}
return result
}
private var alreadyTriggeredFocus: Bool = false
#objc override func triggerFocus() {
guard !alreadyTriggeredFocus else { return }
super.triggerFocus()
alreadyTriggeredFocus = true
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
alreadyTriggeredFocus = false
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
alreadyTriggeredFocus = false
}
...where triggerFocus() is the method you call on touch down event. Alternatively, you may override touchesBegan(_:with:).
If you have access to the view's window property, you can access these system gesture recognizers and set delaysTouchesBegan to false.
Here's a sample code in swift that does that
if let window = view.window,
let recognizers = window.gestureRecognizers {
recognizers.forEach { r in
// add condition here to only affect recognizers that you need to
r.delaysTouchesBegan = false
}
}
Also relevant: UISystemGateGestureRecognizer and delayed taps near bottom of screen

Resources