Search code examples
iosswiftuibuttonuikit

What is best practice for creating a reusable custom button views?


I have three buttons below that have the same UI, the only differences are the text for the labels and tap gesture actions. It looks like this:

What is the best practice for creating a reusable custom button view based on this situation?

So far I tried using: (1) custom button class but had difficulty implementing a stack view where I can configure the two labels in the button, (2) UIButton extension but an issue where tapping the button caused the app to crash

class SetActivityVC: UIViewController {
 
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupViews()
    }
    
    lazy var firstButton: UIButton = {
        let button = UIButton()
        
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapFirst))
        button.addGestureRecognizer(tapGesture)
        
        button.setBackgroundImage(Image.setButtonBg, for: .normal)
        button.addShadowEffect()
        
        let label = UILabel()
        label.text = "No Exercise"
        label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
        label.textColor = .black
        
        let subLabel = UILabel()
        subLabel.text = "no exercise or very infrequent"
        subLabel.font = UIFont.systemFont(ofSize: 12, weight: .regular)
        subLabel.textColor = .gray
        
        let stack = UIStackView(arrangedSubviews: [label, subLabel])
        stack.axis = .vertical
        stack.alignment = .center
        stack.isUserInteractionEnabled = true
        stack.addGestureRecognizer(tapGesture)
        
        button.addSubview(stack)
        stack.centerInSuperview()
        
        return button
    }()
    
    lazy var secondButton: UIButton = {
        let button = UIButton()
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapSecond))
        button.addGestureRecognizer(tapGesture)
        
        button.setBackgroundImage(Image.setButtonBg, for: .normal)
        button.addTarget(self, action: #selector(didTapSecond), for: .touchUpInside)
        button.addShadowEffect()
        
        let label = UILabel()
        label.text = "Light Exercise"
        label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
        label.textColor = .black
        
        let subLabel = UILabel()
        subLabel.text = "some light cardio/weights a few times per week"
        subLabel.font = UIFont.systemFont(ofSize: 12, weight: .regular)
        subLabel.textColor = .gray
        
        let stack = UIStackView(arrangedSubviews: [label, subLabel])
        stack.axis = .vertical
        stack.alignment = .center
        
        button.addSubview(stack)
        stack.centerInSuperview()
        
        return button
    }()
    
    lazy var thirdButton: UIButton = {
        let button = UIButton()
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapThird))
        button.addGestureRecognizer(tapGesture)
        
        button.setBackgroundImage(Image.setButtonBg, for: .normal)
        button.addTarget(self, action: #selector(didTapSecond), for: .touchUpInside)
        button.addShadowEffect()
        
        let label = UILabel()
        label.text = "Moderate Exercise"
        label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
        label.textColor = .black
        
        let subLabel = UILabel()
        subLabel.text = "lifting/cardio regularly but not super intense"
        subLabel.font = UIFont.systemFont(ofSize: 12, weight: .regular)
        subLabel.textColor = .gray
        
        let stack = UIStackView(arrangedSubviews: [label, subLabel])
        stack.axis = .vertical
        stack.alignment = .center
        
        button.addSubview(stack)
        stack.centerInSuperview()
        
        return button
    }()
    
    @objc func didTapFirst() {
        print("Tapped 1")
    }
    
    @objc func didTapSecond() {
        print("Tapped 2")
    }
    
    @objc func didTapThird() {
        print("Tapped 3")
    }
}

extension SetActivityVC {
    fileprivate func setupViews() {
        addViews()
        constrainViews()
    }
    
    fileprivate func addViews() {
        view.addSubview(firstButton)
        view.addSubview(secondButton)
        view.addSubview(thirdButton)
    }
    
    // Using TinyConstraints 
    fileprivate func constrainViews() {
        firstButton.centerXToSuperview()
        
        secondButton.centerXToSuperview()
        secondButton.topToBottom(of: firstButton, offset: screenHeight * 0.03)
        
        thirdButton.centerXToSuperview()
        thirdButton.topToBottom(of: secondButton, offset: screenHeight * 0.03)
    }
}

Solution

  • There's no universal answer because each situation is unique, but generally there are several common patterns:

    1. Implement a factory method that would create a button, set up all its properties and return it.
    2. Subclass UIButton and add new behavior and reasonable defaults.
    3. Subclass UIControl for something totally custom, like a control that is composed out several other views.

    Now, your particular problem seems to be implementing a reusable button with two differently styled lines of text inside.

    Adding labels as subviews to UIButton is something I definitely wouldn't recommend. This breaks accessiblity and you'll have to do a lot of work to support different button states like highlighted or disabled.

    Instead, I highly recommend to make use of a great feature of UIButton: it supports attributed strings for title, and titles can be multiline as well because you have access to the button's titleLabel property.

    Subclassing UIButton just for reasonable defaults and ease of setup seems like a good choice here:

    struct TwoLineButtonModel {
        let title: String
        let subtitle: String
        let action: () -> Void
    }
    
    final class TwoLineButton: UIButton {
        private var action: (() -> Void)?
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            addTarget(self, action: #selector(handleTap(_:)), for: .touchUpInside)
            setUpAppearance()
        }
    
        @available(*, unavailable)
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        func configure(with model: TwoLineButtonModel) {
            [.normal, .highlighted, .disabled].forEach {
                setAttributedTitle(
                    makeButtonTitle(
                        title: model.title,
                        subtitle: model.subtitle,
                        forState: $0
                    ),
                    for: $0
                )
            }
            action = model.action
        }
    
        @objc private func handleTap(_ sender: Any) {
            action?()
        }
    
        private func setUpAppearance() {
            backgroundColor = .yellow
            layer.cornerRadius = 16
            titleLabel?.numberOfLines = 0
            contentEdgeInsets = UIEdgeInsets(top: 16, left: 8, bottom: 16, right: 8)
        }
    
        private func makeButtonTitle(
            title: String,
            subtitle: String,
            forState state: UIControl.State
        ) -> NSAttributedString {
            let centeredParagraphStyle = NSMutableParagraphStyle()
            centeredParagraphStyle.alignment = .center
    
            let primaryColor: UIColor = {
                switch state {
                case .highlighted:
                    return .label.withAlphaComponent(0.5)
                case .disabled:
                    return .label.withAlphaComponent(0.3)
                default:
                    return .label
                }
            }()
    
            let secondaryColor: UIColor = {
                switch state {
                case .highlighted:
                    return .secondaryLabel.withAlphaComponent(0.3)
                case .disabled:
                    return .secondaryLabel.withAlphaComponent(0.1)
                default:
                    return .secondaryLabel
                }
            }()
    
            let parts = [
                NSAttributedString(
                    string: title + "\n",
                    attributes: [
                        .font: UIFont.preferredFont(forTextStyle: .title1),
                        .foregroundColor: primaryColor,
                        .paragraphStyle: centeredParagraphStyle
                    ]
                ),
                NSAttributedString(
                    string: subtitle,
                    attributes: [
                        .font: UIFont.preferredFont(forTextStyle: .body),
                        .foregroundColor: secondaryColor,
                        .paragraphStyle: centeredParagraphStyle
                    ]
                )
            ]
            let string = NSMutableAttributedString()
            parts.forEach { string.append($0) }
            return string
        }
    }
    

    The text styles and colors in my example may not exactly match what you need, but it's easily adjustable and you can take it from here. Move things that should be customizable into the view model while keeping the reasonable defaults as a private implementation. Look into tutorials on NSAttributedString if you're not yet familiar with it, it gives you a lot of freedom in styling texts.