Revision control
Copy as Markdown
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
import Common
import UIKit
/// The view model used to configure a `CollapsibleCardView`
public struct CollapsibleCardViewModel {
public typealias ExpandState = CollapsibleCardView.ExpandButtonState
public let contentView: UIView
public let cardViewA11yId: String
public let title: String
public let titleA11yId: String
public let expandButtonA11yId: String
public let expandButtonA11yLabelExpand: String
public let expandButtonA11yLabelCollapse: String
public var expandState: ExpandState = .collapsed
public var expandButtonA11yLabel: String {
return expandState == .expanded ? expandButtonA11yLabelCollapse : expandButtonA11yLabelExpand
}
public var telemetryCallback: ((_ expandState: ExpandState) -> Void)?
// We need this init as by default the init generated by the compiler for the struct will be internal and
// can therefor not be used outside of the component library
public init(contentView: UIView,
cardViewA11yId: String,
title: String,
titleA11yId: String,
expandButtonA11yId: String,
expandButtonA11yLabelExpand: String,
expandButtonA11yLabelCollapse: String,
expandState: ExpandState = .collapsed,
telemetryCallback: ((_ expandState: ExpandState) -> Void)? = nil) {
self.contentView = contentView
self.cardViewA11yId = cardViewA11yId
self.title = title
self.titleA11yId = titleA11yId
self.expandButtonA11yId = expandButtonA11yId
self.expandButtonA11yLabelExpand = expandButtonA11yLabelExpand
self.expandButtonA11yLabelCollapse = expandButtonA11yLabelCollapse
self.expandState = expandState
self.telemetryCallback = telemetryCallback
}
}
public class CollapsibleCardView: ShadowCardView, UIGestureRecognizerDelegate {
private struct UX {
static let verticalPadding: CGFloat = 8
static let horizontalPadding: CGFloat = 8
static let titleHorizontalPadding: CGFloat = 8
static let expandButtonSize = CGSize(width: 20, height: 20)
static let margins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
}
public enum ExpandButtonState {
case collapsed
case expanded
var image: UIImage? {
switch self {
case .expanded:
return UIImage(named: StandardImageIdentifiers.Large.chevronUp)?.withRenderingMode(.alwaysTemplate)
case .collapsed:
return UIImage(named: StandardImageIdentifiers.Large.chevronDown)?.withRenderingMode(.alwaysTemplate)
}
}
var toggle: ExpandButtonState {
switch self {
case .expanded:
return .collapsed
case .collapsed:
return .expanded
}
}
}
// MARK: - Properties
private lazy var viewModel = CollapsibleCardViewModel(
contentView: rootView,
cardViewA11yId: "",
title: "",
titleA11yId: "",
expandButtonA11yId: "",
expandButtonA11yLabelExpand: "",
expandButtonA11yLabelCollapse: "",
expandState: .collapsed)
// UI
private lazy var rootView: UIStackView = .build { stackView in
stackView.axis = .vertical
stackView.spacing = UX.verticalPadding
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UX.margins
}
private lazy var headerView: UIView = .build { _ in }
private lazy var containerView: UIView = .build { _ in }
private var tapRecognizer: UITapGestureRecognizer!
lazy var titleLabel: UILabel = .build { label in
label.adjustsFontForContentSizeCategory = true
label.font = FXFontStyles.Bold.subheadline.scaledFont()
label.numberOfLines = 0
label.accessibilityTraits.insert(.header)
}
private lazy var expandButton: UIButton = .build { view in
view.setImage(self.viewModel.expandState.image, for: .normal)
view.addTarget(self, action: #selector(self.toggleExpand), for: .touchUpInside)
}
// MARK: - Inits
override init(frame: CGRect) {
super.init(frame: frame)
setupLayout()
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapHeader))
tapRecognizer.delegate = self
headerView.addGestureRecognizer(tapRecognizer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func configure(_ viewModel: ShadowCardViewModel) {
// the overridden method should not be used as it is lacking vital details to configure this card
fatalError("configure(:) has not been implemented.")
}
public func configure(_ viewModel: CollapsibleCardViewModel) {
self.viewModel = viewModel
containerView.subviews.forEach { $0.removeFromSuperview() }
containerView.addSubview(viewModel.contentView)
titleLabel.text = viewModel.title
titleLabel.accessibilityIdentifier = viewModel.titleA11yId
expandButton.accessibilityIdentifier = viewModel.expandButtonA11yId
expandButton.accessibilityLabel = viewModel.expandButtonA11yLabel
NSLayoutConstraint.activate([
viewModel.contentView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
viewModel.contentView.topAnchor.constraint(equalTo: containerView.topAnchor),
viewModel.contentView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
viewModel.contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
updateCardState(expandState: viewModel.expandState)
let parentViewModel = ShadowCardViewModel(view: rootView, a11yId: viewModel.cardViewA11yId)
super.configure(parentViewModel)
}
override public func applyTheme(theme: Theme) {
super.applyTheme(theme: theme)
titleLabel.textColor = theme.colors.textPrimary
expandButton.tintColor = theme.colors.iconPrimary
}
private func setupLayout() {
configure(viewModel)
headerView.addSubview(titleLabel)
headerView.addSubview(expandButton)
rootView.addArrangedSubview(headerView)
rootView.addArrangedSubview(containerView)
NSLayoutConstraint.activate([
headerView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor,
constant: UX.titleHorizontalPadding),
headerView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor,
constant: -UX.titleHorizontalPadding),
titleLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
titleLabel.topAnchor.constraint(equalTo: headerView.topAnchor),
titleLabel.trailingAnchor.constraint(equalTo: expandButton.leadingAnchor,
constant: -UX.horizontalPadding),
titleLabel.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
titleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: UX.expandButtonSize.height),
expandButton.topAnchor.constraint(greaterThanOrEqualTo: headerView.topAnchor),
expandButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
expandButton.bottomAnchor.constraint(lessThanOrEqualTo: headerView.bottomAnchor),
expandButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
expandButton.widthAnchor.constraint(equalToConstant: UX.expandButtonSize.width),
expandButton.heightAnchor.constraint(equalToConstant: UX.expandButtonSize.height),
containerView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor,
constant: UX.horizontalPadding),
containerView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor,
constant: -UX.horizontalPadding),
])
}
private func updateCardState(expandState: ExpandButtonState) {
let isCollapsed = expandState == .collapsed
viewModel.expandState = expandState
expandButton.setImage(viewModel.expandState.image, for: .normal)
expandButton.accessibilityLabel = viewModel.expandButtonA11yLabel
containerView.isHidden = isCollapsed
UIAccessibility.post(notification: .layoutChanged, argument: nil)
}
@objc
private func toggleExpand(_ sender: UIButton) {
updateCardState(expandState: viewModel.expandState.toggle)
viewModel.telemetryCallback?(viewModel.expandState)
}
@objc
func tapHeader(_ recognizer: UITapGestureRecognizer) {
updateCardState(expandState: viewModel.expandState.toggle)
viewModel.telemetryCallback?(viewModel.expandState)
}
}