0

My question:

Is it possible to subclass UIButton in such a way that you have to press the button three times before it actually calls the function?

Context

I am a referee so during a match I need to keep track of the remaining time and both teams’ scores. Because I was tired of using both paper and a stopwatch I decided to make an app to handle it for me. I’m a programmer after all so why not.

Since it’s impractical to keep my phone in my hands the whole time I always put my phone (on which the app is running) in my pocket. I only take it out when I need to change the score or when it beeps (signalling the time’s up). To prevent my phone from accidentally pressing one of the buttons while it’s in my pocket I made sure you have to press the buttons three times in a row to make sure you really intended to press it. I did this by declaring variables keeping track of how many times it’s been pressed in the last second for each button I have on screen. But this also means I have to have as many variables extra as the amount of buttons on screen and when the functions are called I first have to check how many times it has been pressed before to determine whether or not to execute the code. It works but I ended up with some really messy code. I was hoping it could be done better by subclassing UIButton.

Developer
  • 406
  • 1
  • 4
  • 18
  • 1
    Does this answer your question? [how to init a UIButton subclass?](https://stackoverflow.com/questions/27079681/how-to-init-a-uibutton-subclass) – nvidot Aug 02 '20 at 14:28
  • @nvidot I have seen that post. I do understand how subclassing works so I know how to add variables and override initializers. What I don’t know is how to make it such that the IBAction connected to a button only gets calls after you pressed the button three times in a row. – Developer Aug 02 '20 at 14:32
  • Put a counter in the action/method. – Magnas Aug 02 '20 at 14:56
  • @Magnas Well, I think I already did what you mean (see context). It works, but it is really messy. – Developer Aug 02 '20 at 14:59
  • Wouldn't it be better to have a front-facing "lock" screen with one button that requires e.g. a two-second long tap gesture before unlocking the viewController to expose all of your control buttons? – Magnas Aug 02 '20 at 15:04
  • @Magnas I could do that but I chose to do it using a button you have to tap three times in a row because I need to be really quick and when I need to add a point to one team’s score I only need to have acces to that one button. It’s more time efficient this way. – Developer Aug 02 '20 at 15:13
  • “ But this also means I have to have as many variables extra as the amount of buttons on screen and when the functions are called I first have to check how many times it has been pressed before to determine whether or not to execute the code. It works but I ended up with some really messy code. I was hoping it could be done better by subclassing UIButton.” this doesnt really sound too messy and IMO would be the best way to do it – ICW Aug 02 '20 at 15:39

3 Answers3

2

If you want to avoid the messy code, one option is to subclass UIButton from UIKit and implement the three taps in a row detection mechanism provided by @vacawama.

If you want to keep it simple, and as far as you only need to track the last button for which 3 taps occurs, then you only need one counter associated with the button for which you are counting and the last time it was tapped.

As soon as the button change or the time interval is too long, the tracked button change and the counter goes back to 1.

When a row of three taps on the same button is detected, you fire the event towards the original target and reset the counter to 0.

import UIKit

class UIThreeTapButton : UIButton {

    private static var sender: UIThreeTapButton?
    private static var count = 0
    private static var lastTap = Date.distantPast

    private var action: Selector?
    private var target: Any?

    override func addTarget(_ target: Any?,
                            action: Selector,
                            for controlEvents: UIControl.Event) {
        if controlEvents == .touchUpInside {
            self.target = target
            self.action = action
            super.addTarget(self,
                            action: #selector(UIThreeTapButton.checkForThreeTaps),
                            for: controlEvents)
        } else {
            super.addTarget(target, action: action, for: controlEvents)
        }

    }

    @objc func checkForThreeTaps(_ sender: UIThreeTapButton, forEvent event: UIEvent) {
        let now = Date()
        if UIThreeTabButton.sender == sender &&
            now.timeIntervalSince(UIThreeTapButton.lastTap) < 0.5 {
            UIThreeTapButton.count += 1
            if UIThreeTapButton.count == 3 {
                _ = (target as? NSObject)?.perform(action, with: sender, with: event)
                UIThreeTapButton.count = 0
                UIThreeTapButton.lastTap = .distantPast
            }
        } else {
            UIThreeTapButton.sender = sender
            UIThreeTapButton.lastTap = now
            UIThreeTapButton.count = 1
        }
    }
}
nvidot
  • 1,262
  • 1
  • 7
  • 20
1

This is tricky, but doable. Here is an implementation of ThreeTapButton that is a subclass of UIButton. It provides special handling for the event .touchUpInside by passing that to a ThreeTapHelper object. All other events are passed through to the UIButton superclass.

// This is a helper object used by ThreeTapButton
class ThreeTapHelper {
    private var target: Any?
    private var action: Selector?
    private var count = 0
    private var lastTap = Date.distantPast


    init(target: Any?, action: Selector?) {
        self.target = target
        self.action = action
    }

    @objc func checkForThreeTaps(_ sender: ThreeTapButton, forEvent event: UIEvent) {
        let now = Date()
        if now.timeIntervalSince(lastTap) < 0.5 {
            count += 1
            if count == 3 {
                // Three taps in a short time have been detected.
                // Call the original Selector, forward the original
                // sender and event.
                _ = (target as? NSObject)?.perform(action, with: sender, with: event)
    
                count = 0
                lastTap = .distantPast
            }
        } else {
            lastTap = now
            count = 1
        }
    }
}
    

class ThreeTapButton: UIButton {
    private var helpers = [ThreeTapHelper]()
    
    // Intercept `.touchUpInside` when it is added to the button, and
    // have it call the helper instead of the user's provided Selector
    override func addTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) {
        if controlEvents == .touchUpInside {
            let helper = ThreeTapHelper(target: target, action: action)
            helpers.append(helper)
            super.addTarget(helper, action: #selector(ThreeTapHelper.checkForThreeTaps), for: controlEvents)
        } else {
            super.addTarget(target, action: action, for: controlEvents)
        }
    }
}

To use this, either:

  1. Use ThreeTapButton in place of UIButton in your code for programmatically created buttons.

or

  1. Change the class to ThreeTapButton in the Identity Inspector for Storyboard buttons.

Here is an example of a Storyboard button:

class ViewController: UIViewController {

    @IBAction func tapMe3(_ sender: ThreeTapButton) {
        print("tapped 3 times")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

}

In this case, the @IBAction was connected with the .touchUpInside event.


Limitations:

This is a basic first stab at this problem. It has the following limitations:

  • It only works with .touchUpInside. You can easily change that to another event if you prefer.
  • It is hardcoded to look for 3 taps. This could be made more general.
vacawama
  • 150,663
  • 30
  • 266
  • 294
  • the controller could set ThreeTagHelper as 'normal' action handler of the button. Rest wouldnt change but it would work for all controls without any subclass, no? but apart from that id do sth like it too – Daij-Djan Aug 02 '20 at 17:40
  • to avoid an ivar, the object could use associative storage ;) we never even need the pointer, so itd be purely for keeping it alive – Daij-Djan Aug 02 '20 at 17:43
  • 1
    @Daij-Djan, I think we need the subclass in order for this to work for Storyboard buttons, right? Could you tell me what you mean by associative storage? What advantage would it have besides eliminating the ivar? – vacawama Aug 02 '20 at 21:02
  • oh yeah for storyboards.. I havent used those in a while :D makes sense. :) – Daij-Djan Aug 04 '20 at 03:36
0

I would simply try the following, without even subclassing. I would use the tag of each button to multiplex 2 informations: number of repetition of taps, time since last tap.

Multiplexed value could be 1000_000 * nbTaps + time elapsed (measured in tenths of seconds) since the beginning of the play (that will work for more than a full day).

So, a second tap 10 minutes after beginning of play will set the tag to 2000_000 + 6000 = 2006_000 When creating buttons (at beginning of play, in viewDidLoad), set their tag to 0.

In the IBAction,

  • demultiplex the tag to find nbTaps and timeOfLastTap
  • compute the new value of timeElapsed (in tenths of seconds)
  • if difference with timeOfLastTap is more than 10 (1 second), reset tag to 1000_000 + timeElapsed (so nbTaps is 1) and return
  • if difference is less than 10, test nbTaps (as stored in tag)
  • if less than 2, then just reset tag to (nbTaps + 1) * 1000_000 + timeElapsed and return
  • if nbTaps >= 2, here you are.
  • reset tag to timeElapsed (in tenths of seconds) (nbTaps is now zero)
  • perform the IBAction you need.
claude31
  • 874
  • 6
  • 8