Why is there a "lag" in iOS gesture detection near the edges of the screen?
A few weeks ago I encountered a strange problem when working on an iOS application that seemed really strange at first sight.
The task
The task was simple. In the chat detail screen I had to add a voice recording button next to the message input. When the user started holding the button (.touchDown
) the voice recording should have started, releasing the button (.touchUpInside
) should have finish the recording and sliding to side (.touchDragOutside
) should have canceled the voice recording.
The message input was located in the bottom right corner of the screen when keyboard was not shown
and moved up with the keyboard
The problem
When the keyboard was visible the quick recording button worked as expected, but when the quick recording button was at the bottom of the screen there was a “lag”, a delay of about 1 second, between touching the button and the .touchDown
even firing.
Overriding touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
in the quick recording button class showed that this method also experienced the “lag”, so it was not related to the way I used the .touchDown
event.
Why was the gesture detection on the button “lagging” when then button was positioned at the bottom of the screen but not when it was located further up the screen?
The reason
Turns out, the “lag” is caused by iOS waiting if the user is actually performing a system gesture (like swipe from the bottom of the screen) instead of tapping the button. In other words, the iOS gestures take precedence over gestures defined in your application.
You can override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
in the view controller to tell iOS that the gestures in your application take precedence in some of the corners. When I set it to [.bottom, .right]
there was no lag. I do not know why [.bottom]
was not enough, I guess there is some other iOS gesture for swiping from the right edge of the screen and the button was located in the bottom right corner.
Always just setting [.bottom, .right]
had some negative effects, I had to swipe twice to perform the iOS swipe from bottom gesture. Also my view hierarchy is a bit complicated in that application (the message input is in a “message composer” view controller that is embedded into the “chat detail” view controller that is in a split view) and overriding var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
only worked on the topmost view controller, so quite “far” away from the actual quick recording button.
My complete solution
The key seemed to be to set var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
to [.bottom, .right]
only when the user was going to press quick the recording button, like in func point(inside point: CGPoint, with event: UIEvent?) -> Bool
and then setting it back to []
when the user released the button.
This worked because func point(inside point: CGPoint, with event: UIEvent?) -> Bool
got called without any “lag” twice immediately when the user started holding the quick recording button. The first execution of this method adjusted var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
and the .touchDown
event got then fired almost immediately with no noticeable “lag”. I then reset var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
when the user stopped holding the button.
I decided to use notifications not to introduce any tight coupling between the button and the root view controller. I defined a new notification
extension Notification.Name {
static let shouldIgnoreSystemGestures = Notification.Name("shouldIgnoreSystemGestures")
}
The func point(inside point: CGPoint, with event: UIEvent?) -> Bool
method on the quick recording button posted the notification to set var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
as mentioned
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let margin: CGFloat = 5
let area = bounds.insetBy(dx: -margin, dy: -margin)
let res = area.contains(point)
NotificationCenter.default.post(name: .shouldIgnoreSystemGestures, object: res)
return res
}
And the .touchUpInside
and .touchDragOutside
posted the notification to set it back
addTarget(self, action: #selector(QuickRecordingButton.revertSystemGesture(sender:)), for: .touchUpInside)
addTarget(self, action: #selector(QuickRecordingButton.revertSystemGesture(sender:)), for: .touchDragOutside)
@objc func revertSystemGesture(sender _: UIButton) {
NotificationCenter.default.post(name: .shouldIgnoreSystemGestures, object: false)
}
The topmost view controller reacted t the notification
NotificationCenter.default.addObserver(self, selector: #selector(DashboardViewController.checkShouldIgnoreSystemGestures(_:)), name: .shouldIgnoreSystemGestures, object: nil)
@objc func checkShouldIgnoreSystemGestures(_ notification: Notification) {
guard let value = notification.object as? Bool else {
return
}
shouldIgnoreSystemGestures = value
}
by setting a private property backing the var preferredScreenEdgesDeferringSystemGestures: UIRectEdge
private var shouldIgnoreSystemGestures = false {
didSet {
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
}
}
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
return shouldIgnoreSystemGestures ? [.bottom, .right] : []
}