UIButton setImage has no (immediate) effect

33 views Asked by At

Consider the following built in assertion:

button.isSelected = true
button.setImage(nil, for: .selected)
assert(button.image(for: .selected) === nil) 
//^^^^^^^^^^^^^^ this trips with === and ==

Why? Am I making an invalid assumption?

1

There are 1 answers

0
DonMag On BEST ANSWER

From Apple's docs:

Discussion

At a minimum, always set an image for the normal state when associating images to a button. If you don’t specify an image for the other states, the button uses the image associated with normal. If you don’t specify an image for the normal state, the button uses a system value.

What may not be immediately clear is that calling:

button.setImage(nil, for: .selected)

can also be read as: "did not specify an image for .selected state."

So, the button will use the image from .normal.

If you want to remove the image when setting the button state to .selected:

    button.isSelected = true
    button.setImage(nil, for: .normal)
    button.setImage(nil, for: .selected)

Here is some sample code to play with:

class SelectedButtonVC: UIViewController {
    
    let button = UIButton()
    
    var toggles: [UISwitch] = []
    var imgs: [String : UIImage] = [:]
    let titles: [String] = [
        "Normal", "Highlighted", "Selected",
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        var promptLabel: UILabel!
        var vSep: UIView!
        var hs: UIStackView!
        
        let stack = UIStackView()
        stack.axis = .vertical
        stack.spacing = 8
        
        let colors: [UIColor] = [
            .systemBlue, .systemRed, .systemGreen,
        ]
        let states: [UIControl.State] = [
            .normal, .highlighted, .selected,
        ]
        
        let largeConfig = UIImage.SymbolConfiguration(pointSize: 40, weight: .bold, scale: .large)
        
        for (t, c) in zip(titles, colors) {
            guard let f = t.first?.description else { fatalError() }
            guard let img = UIImage(systemName: "\(f.lowercased()).square.fill", withConfiguration: largeConfig)?.withTintColor(c, renderingMode: .alwaysOriginal) else {
                fatalError("Could not load system image for \(t.lowercased())")
            }
            imgs[t] = img
        }
        for (t, s) in zip(titles, states) {
            button.setTitle(t, for: s)
            button.setImage(imgs[t], for: s)
        }
        for (c, s) in zip(colors, states) {
            button.setTitleColor(c, for: s)
        }
        button.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
        button.layer.cornerRadius = 8
        button.layer.borderWidth = 1
        
        promptLabel = UILabel()
        promptLabel.text = "This is a button..."
        stack.addArrangedSubview(promptLabel)
        
        stack.addArrangedSubview(button)
        stack.setCustomSpacing(20.0, after: button)
        
        vSep = UIView()
        vSep.backgroundColor = .gray
        vSep.heightAnchor.constraint(equalToConstant: 2.0).isActive = true
        stack.addArrangedSubview(vSep)
        
        hs = UIStackView()
        hs.spacing = 8
        hs.alignment = .center
        promptLabel = UILabel()
        promptLabel.text = "Toggle button.isSelected:"
        hs.addArrangedSubview(promptLabel)
        let sw = UISwitch()
        sw.isOn = false
        toggles.append(sw)
        hs.addArrangedSubview(sw)
        stack.addArrangedSubview(hs)
        
        vSep = UIView()
        vSep.backgroundColor = .gray
        vSep.heightAnchor.constraint(equalToConstant: 2.0).isActive = true
        stack.addArrangedSubview(vSep)
        
        stack.setCustomSpacing(20.0, after: vSep)
        
        promptLabel = UILabel()
        promptLabel.text = "Toggle button images for states:"
        stack.addArrangedSubview(promptLabel)
        
        titles.forEach { t in
            hs = UIStackView()
            hs.spacing = 8
            hs.alignment = .center
            promptLabel = UILabel()
            promptLabel.text = t
            hs.addArrangedSubview(promptLabel)
            let v = UIImageView(image: imgs[t])
            v.contentMode = .scaleAspectFit
            v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
            hs.addArrangedSubview(v)
            let sw = UISwitch()
            sw.isOn = true
            toggles.append(sw)
            hs.addArrangedSubview(sw)
            stack.addArrangedSubview(hs)
        }
        
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            stack.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
            stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
            
            button.heightAnchor.constraint(equalToConstant: 60.0),
            
        ])
        
        toggles.forEach { sw in
            sw.addTarget(self, action: #selector(swTapped(_:)), for: .valueChanged)
        }
        
        button.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
    }
    
    @objc func swTapped(_ sender: UISwitch) {
        guard let idx = toggles.firstIndex(of: sender) else { return }
        switch idx {
        case 1:
            button.setImage(sender.isOn ? imgs[titles[0]] : nil, for: .normal)
            ()
        case 2:
            button.setImage(sender.isOn ? imgs[titles[1]] : nil, for: .highlighted)
            ()
        case 3:
            button.setImage(sender.isOn ? imgs[titles[2]] : nil, for: .selected)
            ()
        default:
            button.isSelected = sender.isOn
            ()
        }
    }
    
    @objc func btnTapped(_ sender: UIButton) {
        print("button.isSelected =", sender.isSelected)
    }
    
}

Looks like this:

enter image description here

When running, toggling the .isSelected switch will, as you might guess, set button.isSelected to true or false.

Toggling the button image state switches will either set the image to an image (if On) or set it to nil if Off.

Perhaps worth noting...

When a button has:

btn.isSelected = true

the normal properties (title, title color, image, etc) are used for the highlighted state.

This can be seen in the above example code by toggling .isSelected to On, and then tapping the button. We no longer see the "Highlighted" title or image.

If that is your intended behavior, great! If not, you may need to re-think your approach.