创建可重用的自定义按钮视图的最佳做法是什么?

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

我下面有三个按钮具有相同的 UI,唯一的区别是标签的文本和点击手势操作。它看起来像这样:

根据这种情况创建可重复使用的自定义按钮视图的最佳做法是什么?

到目前为止,我尝试使用:(1) 自定义按钮 class 但难以实现堆栈视图,我可以在该视图中配置按钮中的两个标签,(2) UI 按钮扩展但是点击按钮导致应用程序崩溃的问题

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)
    }
}

没有通用的答案,因为每种情况都是独一无二的,但通常有几种常见的模式:

  1. 实现一个工厂方法来创建一个按钮,设置它的所有属性并return它。
  2. 子类 UIButton 并添加新行为和合理的默认值。
  3. 子类 UIControl 用于完全自定义的内容,例如由多个其他视图组成的控件。

现在,您的特定问题似乎是实现一个可重复使用的按钮,其中包含两行不同样式的文本。

将标签作为子视图添加到 UIButton 是我绝对不会推荐的。这会破坏可访问性,您将不得不做很多工作来支持不同的按钮状态,例如突出显示或禁用。

相反,我强烈建议您使用 UIButton 的一个很棒的功能:它支持标题的属性字符串,而且标题也可以是多行的,因为您可以访问按钮的 titleLabel 属性.

子类化 UIButton 只是为了合理的默认值和易于设置似乎是一个不错的选择:

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: [=10=]
                ),
                for: [=10=]
            )
        }
        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([=10=]) }
        return string
    }
}

我的示例中的文本样式和颜色可能与您的需要不完全匹配,但它很容易调整,您可以从这里获取。将应该可定制的东西移到视图模型中,同时将合理的默认值保留为私有实现。如果您还不熟悉 NSAttributedString 上的教程,它会为您提供很大的文本样式设置自由度。