Search code examples
iosswiftuikituibuttonaddsubview

Subview on UIButton always appears behind the label and image


I have a label which is supposed to be a badge for unread notifications count, but since refactoring to use UIButton.Configuration the label always gets rendered behind the button label and image, no matter what.

This is the desired layout:

enter image description here

But I get this for some reasonenter image description here

The code is following:

Configuration for UIButton

private func getNotificationButtonConfiguration() -> UIButton.Configuration {
        var container = AttributeContainer()
        container.font = .sansSemiBold(size: 14)
        container.foregroundColor = .warmGray
        
        var configuration = UIButton.Configuration.plain()
        configuration.contentInsets = .zero
        configuration.attributedTitle = AttributedString("Notifications".localize, attributes: container)
        configuration.imagePadding = 3
        
        return configuration
    }

Then after successfully getting the number of unread notifications I call this block to show the badge

private func showBadge(withCount count: Int) {
        let badge = UILabel.badgeLabel(withCount: count)
        badge.tag = 9830384
        notificationButton.addSubview(badge)
        
        let size: CGSize = (badge.text! as NSString).size(withAttributes: [NSAttributedString.Key.font: UIFont.sansBold(size: 12)])
        
        badge.snp.makeConstraints { make in
            make.trailing.equalTo(notificationButton.snp.leading).offset(25)
            make.top.equalTo(notificationButton.snp.top)
            make.width.equalTo(size.width + 10)
            make.height.equalTo(16)
        }
    }

Function to generate the badge label is

static func badgeLabel(withCount count: Int) -> UILabel {
        let badgeCount = UILabel(frame: CGRect(x: 0, y: 0, width: 16, height: 16))
        badgeCount.translatesAutoresizingMaskIntoConstraints = false
        badgeCount.layer.cornerRadius = badgeCount.bounds.size.height / 2
        badgeCount.textAlignment = .center
        badgeCount.layer.masksToBounds = true
        badgeCount.textColor = .white
        badgeCount.font = .sansBold(size: 12)
        badgeCount.backgroundColor = .heartRed
        badgeCount.text = String(count)
        return badgeCount
    }

I've tried calling notificationButton.bringSubviewToFront(badge) inside showBadge function, but it did not have any effect on layout.

Any ideas are greatly appreciated!

Adding a subview on UIButton sends that subview to back all the time instead of it being rendered over button label and image.

Button initialization:

let notificationButton = UIButton(type: .custom)
notificationButton.setImage(UIImage(named: "icon_bell_filled"), for: .normal)
notificationButton.configuration = getNotificationButtonConfiguration()
notificationButton.titleLabel?.tag = 200
notificationButton.addTarget(self, action: #selector(openNotificationsButtonAction), for: .touchUpInside)

Solution

  • As you mentioned in your comment, you could wrap the button and badge in a UIView

    Alternatively, you could subclass UIButton and add attach/detach methods.

    Quick example:

    class BadgedButton: UIButton {
        
        public weak var theBadgeView: UIView!
        
        public func attachBadge(_ v: UIView) -> Bool {
            // we must have a superview!
            guard let sv = self.superview else { return false }
            if let curBadge = theBadgeView {
                curBadge.removeFromSuperview()
            }
            v.translatesAutoresizingMaskIntoConstraints = false
            sv.addSubview(v)
            let sz: CGSize = v.intrinsicContentSize
            NSLayoutConstraint.activate([
                v.trailingAnchor.constraint(equalTo: self.leadingAnchor, constant: 25.0),
                v.topAnchor.constraint(equalTo: self.topAnchor, constant: 0.0),
                v.widthAnchor.constraint(equalToConstant: sz.width + 10.0),
                v.heightAnchor.constraint(equalToConstant: 16.0),
            ])
            self.theBadgeView = v
            return true;
        }
        
        public func detachBadge() {
            if let curBadge = self.theBadgeView {
                curBadge.removeFromSuperview()
            }
        }
        
        override func didMoveToSuperview() {
            // remove the badge view when self is removed from superview
            if nil == self.superview,
               let curBadge = self.theBadgeView
            {
                curBadge.removeFromSuperview()
            }
        }
        
    }
    
    class BadgeTestVC: UIViewController {
        
        var btn: BadgedButton!
        
        var badgeCount: Int = 0
        
        let incLabel = UILabel()
        let decLabel = UILabel()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            var cfg = getNotificationButtonConfiguration()
            btn = BadgedButton(configuration: cfg)
            
            btn.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(btn)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                btn.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                btn.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            ])
            
            [incLabel, decLabel].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
                v.numberOfLines = 0
                v.textAlignment = .center
            }
            incLabel.text = "Tap here to\nIncrement\nBadge Count"
            decLabel.text = "Tap here to\nDecrement\nBadge Count"
            incLabel.backgroundColor = .cyan
            decLabel.backgroundColor = .yellow
            NSLayoutConstraint.activate([
                decLabel.topAnchor.constraint(equalTo: g.centerYAnchor, constant: 0.0),
                decLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                decLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
                
                incLabel.topAnchor.constraint(equalTo: g.centerYAnchor, constant: 0.0),
                incLabel.leadingAnchor.constraint(equalTo: decLabel.trailingAnchor, constant: 20.0),
                incLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
                incLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
    
                incLabel.widthAnchor.constraint(equalTo: decLabel.widthAnchor),
            ])
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            let p = t.location(in: self.view)
            if decLabel.frame.contains(p) {
                badgeCount -= 1
            }
            if incLabel.frame.contains(p) {
                badgeCount += 1
            }
            badgeCount = max(badgeCount, 0)
            showBadge(withCount: badgeCount)
        }
        
        private func getNotificationButtonConfiguration() -> UIButton.Configuration {
            var container = AttributeContainer()
            container.font = .systemFont(ofSize: 14.0, weight: .bold) //.sansSemiBold(size: 14)
            container.foregroundColor = .gray // .warmGray
            
            var configuration = UIButton.Configuration.plain()
            configuration.contentInsets = .zero
            configuration.attributedTitle = AttributedString("Notifications", attributes: container)
            configuration.imagePadding = 3
            
            if let img = UIImage(systemName: "bell.fill") {
                configuration.image = img
            }
            
            return configuration
        }
        
        private func showBadge(withCount count: Int) {
            if count == 0 {
                btn.detachBadge()
                return
            }
            let badge = UILabel.badgeLabel(withCount: count)
            badge.tag = 9830384
            if !btn.attachBadge(badge) {
                print("Attach failed!")
            }
        }
        
    }
    
    extension UILabel {
    
        static func badgeLabel(withCount count: Int) -> UILabel {
            let badgeCount = UILabel(frame: CGRect(x: 0, y: 0, width: 16, height: 16))
            badgeCount.translatesAutoresizingMaskIntoConstraints = false
            badgeCount.layer.cornerRadius = badgeCount.bounds.size.height / 2
            badgeCount.textAlignment = .center
            badgeCount.layer.masksToBounds = true
            badgeCount.textColor = .white
            badgeCount.font = .systemFont(ofSize: 12.0, weight: .bold) //.sansBold(size: 12)
            badgeCount.backgroundColor = .red // .heartRed
            badgeCount.text = String(count)
            return badgeCount
        }
    
    }
    

    Looks like this when running:

    enter image description here

    tapping once on Increment:

    enter image description here

    and tapping 12 times:

    enter image description here

    The attachBadge(...) method removes an "already attached" view (label) before adding the new one.

    It could easily be optimized to "update" the label text, if desired.