Search code examples
iosswiftuikituibutton

How to fix the behavior of the button image depending on the position of the finger?


I have a button and an action. I also have an image for the button in its normal state and when it is pressed. These are .svg images. My button is to turn the sound mute/unmute. So I have two images with the speaker and with the speaker crossed out. And if I click on the button, then the image should change. Everything works fine. The image changes when pressed and the action works normally. But the problem is that if I press the button, but do not remove my finger from the screen, but move it outside the button area, then my image will change to a crossed out speaker, and if I then remove my finger from the screen outside the button area, then in the end I will get that the image will change to a crossed out speaker, but the action will not be performed and the sound will continue to play. By moving my finger outside the button area, and then back into its area, image will change. How to fix this?

controller class

class ViewController: UIViewController {
    var button: SomeButtonWithImage = {
        let button = SomeButtonWithImage()
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()

     override func viewDidLoad() {
        super.viewDidLoad()

        button = SomeButtonWithImage()
        button.normalImage = "mute"
        button.selectedImage = "unmute"
        button.tag = index
        button.widthAnchor.constraint(equalToConstant: controlButtonsHeight).isActive = true
        button.heightAnchor.constraint(equalToConstant: controlButtonsHeight).isActive = true
        button.addTarget(self, action: #selector(self.rightButtonAction), for: .touchUpInside)
        rightStackView.addArrangedSubview(button)
    }
}

button class

class SomeButtonWithImage: UIButton {
    
    var buttonSelectedScale: CGFloat = 0.9
    var buttonScaleDownDuration: TimeInterval = 0.15
    var buttonScaleUpDuration: TimeInterval = 0.25
    public var normalImage: String = ""
    public var selectedImage: String = ""
    
    override var isHighlighted: Bool {
        didSet { if oldValue == false && isHighlighted { selected() }
            else if oldValue == true && !isHighlighted { deselected() }
        }
    }
        
    override init(frame: CGRect) {
        super.init(frame: frame)
        configuration = .plain()
        configuration?.baseBackgroundColor = .clear
        configurationUpdateHandler = { button in
            switch button.state {
            case .normal: button.configuration?.image = UIImage(named: self.normalImage)
            case .selected: button.configuration?.image = UIImage(named: self.selectedImage)
            default: break
            }
        }
    }
    
    required init?(coder: NSCoder) { fatalError("error") }
    
    func selected() { animateScale(to: buttonSelectedScale, duration: buttonScaleDownDuration) }
    func deselected() { animateScale(to: 1, duration: buttonScaleUpDuration); isSelected.toggle() }
        
    private func animateScale(to scale: CGFloat, duration: TimeInterval) {
        UIView.animate( withDuration: duration, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.5, options: [], animations: {
            self.transform = .init(scaleX: scale, y: scale)
        }, completion: nil)
    }
    
}

addition:

Why do I use configurationHandler instead of setImage(UIImage?, for: UIControlState)? If I use setImage when clicked the image will be covered with a gray background. I used this adjustsImageWhenHighlighted = false to solve the problem. But this is deprecated. That's why I started using the configuration. I know that with the status "deprecated" it still works. But I have warnings. And I wanted to find another way, this was the configuration.


Solution

  • Your problem come from use of isHighlighted's property, as Apple indicate in their docs:

    Controls automatically set and clear this state in response to appropriate touch events. You can change the value of this property as needed to apply or remove a highlight programmatically

    When your finger go inside button, isHighlighted will be true and vice-versa.

    In ViewController, you only add target to event .touchUpInside so your function will only be called when users lift their finger in side button.

    To fix your problem, we need to change button's image whenever it receive .touchUpInside event, instead of depend on isHighlighted to change button image, you should use isSelected and toggle it when button got touch up inside:

    class TestViewController: UIViewController {
        var button: SomeButtonWithImage = {
            let button = SomeButtonWithImage(frame: .init(x: 100, y: 200, width: 100, height: 100))
            return button
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .white
            button.normalImage = "mute"
            button.selectedImage = "unmute"
            button.isSelected = false // <- set intial isSelected to render image
            button.addTarget(self, action: #selector(self.rightButtonAction), for: .touchUpInside)
            view.addSubview(button)
        }
        
        @objc func rightButtonAction() {
            button.isSelected.toggle() // <-- toggle button isSelected here
        }
    }
    
    class SomeButtonWithImage: UIButton {
        
        var buttonSelectedScale: CGFloat = 0.9
        var buttonScaleDownDuration: TimeInterval = 0.15
        var buttonScaleUpDuration: TimeInterval = 0.25
        public var normalImage: String = ""
        public var selectedImage: String = ""
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            backgroundColor = .red
            configuration = .plain()
            configuration?.baseBackgroundColor = .clear
        }
        
        override var isSelected: Bool {
            didSet {
                if isSelected {
                    configuration?.image = UIImage(named: self.selectedImage)
                } else {
                    configuration?.image = UIImage(named: self.normalImage)
                }
            }
        }
        
        override var isHighlighted: Bool {
            didSet {
                print(isHighlighted)
            }
        }
        
        required init?(coder: NSCoder) { fatalError("error") }
        
        private func animateScale(to scale: CGFloat, duration: TimeInterval) {
            UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.5, options: [], animations: {
                self.transform = .init(scaleX: scale, y: scale)
            }, completion: nil)
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            super.touchesBegan(touches, with: event)
            animateScale(to: buttonSelectedScale, duration: buttonScaleDownDuration)
        }
        
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            super.touchesEnded(touches, with: event)
            animateScale(to: 1, duration: buttonScaleDownDuration)
        }
        
        override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
            super.touchesCancelled(touches, with: event)
            animateScale(to: 1, duration: buttonScaleDownDuration)
        }
    }