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)
}
}
There's no universal answer because each situation is unique, but generally there are several common patterns:
UIButton
and add new behavior and reasonable defaults.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.