A Global Double-Tap Guard with Method Swizzling, and the Crash I Missed

Blocking repeat taps on hundreds of UIKit buttons with one runtime hook, the test page I built first, a crash I only found on re-testing, and how to fix it.

On this page
  1. Why I hooked the action instead of editing buttons
  2. The test page came first
  3. What I wrote
  4. The crash I missed
  5. Other problems with this design

Here’s a problem to think about first.

You’re working in an old iOS project with hundreds of buttons, many of them in xib and storyboard files. The requirement: after any button is tapped, block further taps for one second. The project mixes Objective-C and Swift, and you don’t want to touch every screen.

How would you do it?

Why I hooked the action instead of editing buttons

In the project I worked on, the storyboards and xibs alone held 301 buttons, and there were more created in code. Adding a guard to each one means editing hundreds of places and hoping nobody forgets the next one.

Every target-action tap goes through sendAction(_:to:for:) on UIControl. If that method is hooked once, existing buttons get the behavior without any edits. That’s the same reason I like a low-intrusion change in an old codebase: one line in AppDelegate.m turns it on.

[UIButton enableDebounce];

The test page came first

Before writing the hook, I built a small test page: one button, plus a table that recorded each tap’s number, time, and the interval since the previous tap. Mashing a button and watching it not misbehave doesn’t tell you much. A column of intervals does.

What I wrote

The guard itself is an extension on UIControl. It disables the control and enables it again after the delay.

extension UIControl {
    @objc static var debounceDelay: Double = 1

    @objc func debounce(delay: Double = UIControl.debounceDelay, siblings: [UIControl] = []) {
        let controls = [self] + siblings
        controls.forEach { $0.isEnabled = false }
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
            controls.forEach { $0.isEnabled = true }
        }
    }
}

siblings lets one tap lock a group: if two buttons do competing things, tapping one can disable both.

The hook swaps the implementation, runs the guard, then calls the original. Because the methods are swapped, debounceSendAction inside the method is really the original sendAction.

extension UIButton {
    private static let swizzle: Void = {
        let originalSelector = #selector(UIButton.sendAction(_:to:for:))
        let swizzledSelector = #selector(UIButton.debounceSendAction(_:to:for:))

        guard let original = class_getInstanceMethod(UIButton.self, originalSelector),
              let swizzled = class_getInstanceMethod(UIButton.self, swizzledSelector) else { return }

        method_exchangeImplementations(original, swizzled)
    }()

    @objc func debounceSendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
        debounce()
        debounceSendAction(action, to: target, for: event)
    }

    @objc static func enableDebounce() {
        _ = swizzle
    }
}

A static let initializes only once, so calling enableDebounce() twice can’t swap the methods back.

The crash I missed

Don’t copy the code above. It has a bug I only found when I re-tested it for this post.

UIButton doesn’t define sendAction(_:to:for:). It inherits it from UIControl, so class_getInstanceMethod returns the method that belongs to UIControl, and the exchange changes it for every control. A UISwitch then runs the new implementation too. That implementation calls debounceSendAction, which only exists on UIButton:

-[UISwitch debounceSendAction:to:for:]: unrecognized selector sent to instance

I reproduced this on the simulator with a minimal program using the same hook, with a target-action UISwitch. I didn’t re-run it in the original app. My test page recorded taps on one button, so its records couldn’t show a problem on other controls.

The usual fix is to give UIButton its own copy of the method first, then swap only that copy:

extension UIButton {
    private static let safeSwizzle: Void = {
        let originalSelector = #selector(UIButton.sendAction(_:to:for:))
        let swizzledSelector = #selector(UIButton.debounceSendAction(_:to:for:))

        guard let original = class_getInstanceMethod(UIButton.self, originalSelector),
              let swizzled = class_getInstanceMethod(UIButton.self, swizzledSelector) else { return }

        let didAdd = class_addMethod(
            UIButton.self, originalSelector,
            method_getImplementation(swizzled), method_getTypeEncoding(swizzled)
        )
        if didAdd {
            class_replaceMethod(
                UIButton.self, swizzledSelector,
                method_getImplementation(original), method_getTypeEncoding(original)
            )
        } else {
            method_exchangeImplementations(original, swizzled)
        }
    }()
}

With this version, in the same minimal program, the guard ran for UIButton and a subclass, and UISwitch and UISegmentedControl were left alone. That program has no running app, so it shows which controls get hooked, not a full tap in a real screen.

Other problems with this design

  • It re-enables controls it shouldn’t. The delay always sets isEnabled = true. If a screen disabled a button during that second, for example because a form became invalid, the guard turns it back on.
  • Buttons flicker. Disabling a control changes how it looks, so every tap dims the button for a second, whether or not the action takes that long.
  • Time isn’t state. A request that takes three seconds can still be sent again after one. For that, the button has to know the work is still running.
  • It’s invisible. Nothing at the call site says a global hook is involved. The next person debugging a dead button has to know to look for it.

This code stayed on its feature branch, and I can’t find it on the release branch. So what I can say is how I approached the problem, not that it ran in production.