vds_ios/VDS/Components/Tilelet/Tilelet.swift
Matt Bruce baf9136e26 code refactor into setDefaults()
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-08-17 14:36:59 -05:00

628 lines
26 KiB
Swift

//
// Tilet.swift
// VDS
//
// Created by Matt Bruce on 12/19/22.
//
import Foundation
import Foundation
import VDSCoreTokens
import UIKit
import Combine
/// Tilelet can be configured with a background image and limited text to
/// support quick scanning and engagement. A Tilelet is fully clickable and
/// while it can include an arrow CTA, it does not require one in order to
/// function.
@objcMembers
@objc(VDSTilelet)
open class Tilelet: TileContainerBase<Tilelet.Padding> {
/// Enum used to describe the padding choices used for this component.
public enum Padding: String, DefaultValuing, Valuing, CaseIterable {
case small
case large
public static var defaultValue: Self { .large }
public var value: CGFloat {
switch self {
case .small:
return UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space4X
case .large:
return UIDevice.isIPad ? VDSLayout.space4X : VDSLayout.space6X
}
}
fileprivate var titleLockupBottomSpacing: CGFloat {
switch self.value {
case VDSLayout.space3X:
return VDSLayout.space4X
case VDSLayout.space4X:
return VDSLayout.space6X
case VDSLayout.space4X:
return VDSLayout.space8X
default:
return VDSLayout.space4X
}
}
}
//--------------------------------------------------
// MARK: - Initializers
//--------------------------------------------------
required public init() {
super.init(frame: .zero)
}
public override init(frame: CGRect) {
super.init(frame: .zero)
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
}
//--------------------------------------------------
// MARK: - Enums
//--------------------------------------------------
/// Enum to represent the Vertical Layout of the Text.
public enum TextPosition: String, CaseIterable {
case top
case middle
case bottom
}
/// Enum to represent the Width of the Text.
public enum TextWidth {
case value(CGFloat)
case percentage(CGFloat)
}
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
private var stackView = UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .vertical
$0.distribution = .fill
$0.spacing = 5
}
private var titleLockupContainerView = UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
}
private var badgeContainerView = UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
}
private let iconContainerView = UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.backgroundColor = .clear
}
private var backgroundColorSurface: Surface {
backgroundColorConfiguration.getColor(self).surface
}
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
/// Title lockup positioned in the contentView.
open var titleLockup = TitleLockup().with {
$0.standardStyleConfiguration = .init(styleConfigurations: [
.init(deviceType: .iPhone,
titleStandardStyles: [.bodySmall],
spacingConfigurations: [
.init(otherStandardStyles: [.bodySmall],
topSpacing: VDSLayout.space1X,
bottomSpacing: VDSLayout.space1X)
]),
.init(deviceType: .iPhone,
titleStandardStyles: [.bodyMedium],
spacingConfigurations: [
.init(otherStandardStyles: [.bodyMedium],
topSpacing: VDSLayout.space1X,
bottomSpacing: VDSLayout.space1X)
]),
.init(deviceType: .iPhone,
titleStandardStyles: [.bodyLarge],
spacingConfigurations: [
.init(otherStandardStyles: [.bodyLarge],
topSpacing: VDSLayout.space1X,
bottomSpacing: VDSLayout.space1X)
]),
.init(deviceType: .iPhone,
titleStandardStyles: [.titleSmall],
spacingConfigurations: [
.init(otherStandardStyles: [.bodySmall, .bodyMedium],
topSpacing: VDSLayout.space2X,
bottomSpacing: VDSLayout.space2X)
]),
.init(deviceType: .iPhone,
titleStandardStyles: [.titleMedium, .titleLarge],
spacingConfigurations: [
.init(otherStandardStyles: [.bodySmall, .bodyMedium, .bodyLarge],
topSpacing: VDSLayout.space2X,
bottomSpacing: VDSLayout.space2X)
]),
.init(deviceType: .iPhone,
titleStandardStyles: [.titleXLarge],
spacingConfigurations: [
.init(otherStandardStyles: [.bodyLarge, .bodySmall, .bodyMedium, .titleMedium],
topSpacing: VDSLayout.space3X,
bottomSpacing: VDSLayout.space3X)
]),
.init(deviceType: .iPad,
titleStandardStyles: [.bodySmall],
spacingConfigurations: [
.init(otherStandardStyles: [.bodySmall],
topSpacing: VDSLayout.space2X,
bottomSpacing: VDSLayout.space2X)
]),
.init(deviceType: .iPad,
titleStandardStyles: [.bodyMedium],
spacingConfigurations: [
.init(otherStandardStyles: [.bodyMedium],
topSpacing: VDSLayout.space1X,
bottomSpacing: VDSLayout.space1X)
]),
.init(deviceType: .iPad,
titleStandardStyles: [.bodyLarge],
spacingConfigurations: [
.init(otherStandardStyles: [.bodyLarge],
topSpacing: VDSLayout.space1X,
bottomSpacing: VDSLayout.space1X)
]),
.init(deviceType: .iPad,
titleStandardStyles: [.titleSmall, .titleMedium],
spacingConfigurations: [
.init(otherStandardStyles: [.bodySmall, .bodyMedium, .bodyLarge],
topSpacing: VDSLayout.space2X,
bottomSpacing: VDSLayout.space2X)
]),
.init(deviceType: .iPad,
titleStandardStyles: [.titleLarge],
spacingConfigurations: [
.init(otherStandardStyles: [.bodyLarge, .bodySmall, .bodyMedium, .titleSmall],
topSpacing: VDSLayout.space3X,
bottomSpacing: VDSLayout.space3X)
]),
.init(deviceType: .iPad,
titleStandardStyles: [.titleXLarge],
spacingConfigurations: [
.init(otherStandardStyles: [.titleMedium, .bodyLarge],
topSpacing: VDSLayout.space3X,
bottomSpacing: VDSLayout.space4X)
])
])
}
/// Badge positioned in the contentView.
open var badge = Badge().with {
$0.fillColor = .red
}
/// Descriptive Icon positioned in the contentView.
open var descriptiveIcon = Icon()
/// Directional Icon positioned in the contentView.
open var directionalIcon = Icon()
private var _textWidth: TextWidth?
/// If provided, width of Button components will be rendered based on this value. If omitted, default button widths are rendered.
open var textWidth: TextWidth? {
get { _textWidth }
set {
if let newValue {
switch newValue {
case .percentage(let percentage):
if percentage >= 10 && percentage <= 100.0 {
_textWidth = newValue
}
case .value(let value):
if value > 44.0 {
_textWidth = newValue
}
}
} else {
_textWidth = nil
}
setNeedsUpdate()
}
}
/// Determines where the text aligns vertically.
open var textPostion: TextPosition = .top { didSet { setNeedsUpdate() } }
/// If set, this is used to render the badge.
open var badgeModel: BadgeModel? { didSet { setNeedsUpdate() } }
/// If set, this is used to render the titleLabel of the TitleLockup.
open var titleModel: TitleModel? { didSet { setNeedsUpdate() } }
/// If set, this is used to render the subTitleLabel of the TitleLockup.
open var subTitleModel: SubTitleModel? { didSet { setNeedsUpdate() } }
/// If set, this is used to render the eyebrowLabel of the TitleLockup.
open var eyebrowModel: EyebrowModel? { didSet { setNeedsUpdate() } }
//only 1 Icon can be active
private var _descriptiveIconModel: DescriptiveIcon?
/// If set, this is used to render the descriptive icon.
open var descriptiveIconModel: DescriptiveIcon? {
get { _descriptiveIconModel }
set {
_descriptiveIconModel = newValue;
_directionalIconModel = nil
setNeedsUpdate()
}
}
private var _directionalIconModel: DirectionalIcon?
/// If set, this is used to render the directional icon.
open var directionalIconModel: DirectionalIcon? {
get { _directionalIconModel }
set {
_directionalIconModel = newValue;
_descriptiveIconModel = nil
setNeedsUpdate()
}
}
//--------------------------------------------------
// MARK: - Constraints
//--------------------------------------------------
internal var titleLockupWidthConstraint: NSLayoutConstraint?
internal var titleLockupTrailingConstraint: NSLayoutConstraint?
internal var titleLockupTopConstraint: NSLayoutConstraint?
internal var titleLockupBottomConstraint: NSLayoutConstraint?
internal var titleLockupTopGreaterThanConstraint: NSLayoutConstraint?
internal var titleLockupBottomGreaterThanConstraint: NSLayoutConstraint?
internal var titleLockupCenterYConstraint: NSLayoutConstraint?
internal var titleLockupTitleLabelBottomConstraint: NSLayoutConstraint?
//Truncation constraints
internal var badgeLabelHeightGreaterThanConstraint: NSLayoutConstraint?
internal var titleLockupEyebrowLabelHeightGreaterThanConstraint: NSLayoutConstraint?
internal var titleLockupTitleLabelHeightGreaterThanConstraint: NSLayoutConstraint?
internal var titleLockupSubTitleLabelHeightGreaterThanConstraint: NSLayoutConstraint?
//--------------------------------------------------
// MARK: - Overrides
//--------------------------------------------------
/// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations.
open override func setup() {
super.setup()
color = .black
aspectRatio = .none
addContentView(stackView)
//badge
badgeContainerView.addSubview(badge)
badge
.pinTop()
.pinLeading()
.pinBottom()
badge.trailingAnchor.constraint(lessThanOrEqualTo: badgeContainerView.trailingAnchor).isActive = true
titleLockupContainerView.addSubview(titleLockup)
titleLockup
.pinLeading()
titleLockupTopConstraint = titleLockup.topAnchor.constraint(equalTo: titleLockupContainerView.topAnchor)
titleLockupTopConstraint?.activate()
titleLockupBottomConstraint = titleLockupContainerView.bottomAnchor.constraint(equalTo: titleLockup.bottomAnchor)
titleLockupBottomConstraint?.activate()
titleLockupTrailingConstraint = titleLockup.trailingAnchor.constraint(equalTo: titleLockupContainerView.trailingAnchor)
titleLockupTrailingConstraint?.activate()
titleLockupBottomGreaterThanConstraint = titleLockupContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: titleLockup.bottomAnchor)
titleLockupTopGreaterThanConstraint = titleLockup.topAnchor.constraint(greaterThanOrEqualTo: titleLockupContainerView.topAnchor)
titleLockupCenterYConstraint = titleLockup.centerYAnchor.constraint(equalTo: titleLockupContainerView.centerYAnchor)
iconContainerView.addSubview(descriptiveIcon)
iconContainerView.addSubview(directionalIcon)
descriptiveIcon
.pinLeading()
.pinTop()
.pinBottom()
directionalIcon
.pinTrailing()
.pinTop()
.pinBottom()
badge.bottomAnchor.constraint(equalTo: badge.label.bottomAnchor, constant: 2).activate()
/**
Truncation:
If a Tilelet has only a Title or a Subtitle, then the Title or Subtitle is truncated and appended with an ellipsis when there is not enough space to display the full text.
If a Tilelet has both Title and Subtitle, then only Subtitle will be truncated.
If a Tilelet has Badge, Title and Subtitle, then Subtitle will be truncated first and Badge will be truncated second. Title will be truncated last (lowest priority).
Atleast one line text based on priority
Minimum bottom space below Badge is 4px; less than 4px results in truncation.
*/
let labelPriority = UILayoutPriority.defaultHigh.rawValue
titleLockup.titleLabel.setContentCompressionResistancePriority(UILayoutPriority(labelPriority), for: .vertical)
badge.label.setContentCompressionResistancePriority(UILayoutPriority(labelPriority-1), for: .vertical)
titleLockup.subTitleLabel.setContentCompressionResistancePriority(UILayoutPriority(labelPriority-2), for: .vertical)
titleLockup.eyebrowLabel.setContentCompressionResistancePriority(UILayoutPriority(labelPriority-3), for: .vertical)
titleLockup.titleLabel.setContentHuggingPriority(UILayoutPriority(labelPriority), for: .vertical)
badge.label.setContentHuggingPriority(UILayoutPriority(labelPriority-1), for: .vertical)
titleLockup.subTitleLabel.setContentHuggingPriority(UILayoutPriority(labelPriority-2), for: .vertical)
titleLockup.eyebrowLabel.setContentHuggingPriority(UILayoutPriority(labelPriority-3), for: .vertical)
/**
Added these constraints for:
At fixed width & height if all the labels(Badge, Eyebrow, Title, Subtitle) are having more number of lines then we should display atleast one line of content per label instead of pushing labels out of bounds.
So adding minimum single line height constraint
*/
badgeLabelHeightGreaterThanConstraint = badge.label.heightGreaterThanEqualTo(constant: badge.label.minimumLineHeight)
badgeLabelHeightGreaterThanConstraint?.priority = .defaultHigh
badgeLabelHeightGreaterThanConstraint?.activate()
titleLockupEyebrowLabelHeightGreaterThanConstraint = titleLockup.eyebrowLabel.heightGreaterThanEqualTo(constant: titleLockup.eyebrowLabel.minimumLineHeight)
titleLockupEyebrowLabelHeightGreaterThanConstraint?.priority = .defaultHigh
titleLockupEyebrowLabelHeightGreaterThanConstraint?.activate()
titleLockupTitleLabelHeightGreaterThanConstraint = titleLockup.titleLabel.heightGreaterThanEqualTo(constant: titleLockup.titleLabel.minimumLineHeight)
titleLockupTitleLabelHeightGreaterThanConstraint?.priority = .defaultHigh
titleLockupTitleLabelHeightGreaterThanConstraint?.activate()
titleLockupSubTitleLabelHeightGreaterThanConstraint = titleLockup.subTitleLabel.heightGreaterThanEqualTo(constant: titleLockup.subTitleLabel.minimumLineHeight)
titleLockupSubTitleLabelHeightGreaterThanConstraint?.priority = .defaultHigh
titleLockupSubTitleLabelHeightGreaterThanConstraint?.activate()
}
open override func setDefaults() {
super.setDefaults()
aspectRatio = .none
color = .black
textWidth = nil
textPostion = .top
//models
badgeModel = nil
titleModel = nil
subTitleModel = nil
descriptiveIconModel = nil
directionalIconModel = nil
directionalIcon.bridge_accessibilityLabelBlock = { [weak self] in
guard let self, let directionalIconModel else { return nil }
return directionalIconModel.accessibleText
}
descriptiveIcon.bridge_accessibilityLabelBlock = { [weak self] in
guard let self, let descriptiveIconModel else { return nil }
return descriptiveIconModel.accessibleText
}
}
/// Used to make changes to the View based off a change events or from local properties.
open override func updateView() {
super.updateView()
updateBadge()
updateTitleLockup()
updateIcons()
updateTextPositionAlignment()
setNeedsLayout()
}
/// Used to update any Accessibility properties.
open override var accessibilityElements: [Any]? {
get {
var views = [AnyObject]()
// grab the available views in order
if badgeModel != nil {
views.append(badge)
}
if titleModel != nil || subTitleModel != nil || eyebrowModel != nil {
let titleLockupViews = gatherAccessibilityElements(from: titleLockup)
views.append(contentsOf: titleLockupViews)
}
if descriptiveIconModel != nil {
views.append(descriptiveIcon)
} else if directionalIconModel != nil {
views.append(directionalIcon)
}
containerView.setAccessibilityLabel(for: views)
// get the views to return
var items = [Any]()
if containerView.isAccessibilityElement {
if !accessibilityTraits.contains(.button) && !accessibilityTraits.contains(.link) {
containerView.accessibilityTraits = .button
} else {
containerView.accessibilityTraits = accessibilityTraits
}
items.append(containerView)
}
//append all other accessible views to traverse
items.append(contentsOf: views)
return items
}
set {}
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
private func updateBadge() {
if let badgeModel {
badge.text = badgeModel.text
badge.fillColor = badgeModel.fillColor
badge.numberOfLines = badgeModel.numberOfLines
badge.surface = backgroundColorSurface
badge.maxWidth = badgeModel.maxWidth
badgeLabelHeightGreaterThanConstraint?.constant = badge.label.minimumLineHeight
if badgeContainerView.superview == nil {
stackView.insertArrangedSubview(badgeContainerView, at: 0)
setNeedsLayout()
}
} else {
removeFromSuperview(badgeContainerView)
}
}
private func updateTitleLockup() {
var showTitleLockup = false
if let eyebrowModel, !eyebrowModel.text.isEmpty {
showTitleLockup = true
}
if let titleModel, !titleModel.text.isEmpty {
showTitleLockup = true
}
if let subTitleModel, !subTitleModel.text.isEmpty {
showTitleLockup = true
}
if showTitleLockup {
//flip the surface for the titleLockup
titleLockup.surface = backgroundColorSurface
//titleLockup
if let textWidth {
titleLockupTrailingConstraint?.isActive = false
titleLockupTrailingConstraint = titleLockup.trailingAnchor.constraint(lessThanOrEqualTo: titleLockupContainerView.trailingAnchor)
titleLockupTrailingConstraint?.isActive = true
titleLockupWidthConstraint?.isActive = false
switch textWidth {
case .value(let value):
titleLockupWidthConstraint = titleLockup.widthAnchor.constraint(equalToConstant: value)
case .percentage(let percentage):
titleLockupWidthConstraint = NSLayoutConstraint(item: titleLockup,
attribute: .width,
relatedBy: .equal,
toItem: contentView,
attribute: .width,
multiplier: percentage / 100,
constant: 0.0)
}
titleLockupWidthConstraint?.isActive = true
} else {
titleLockupTrailingConstraint?.isActive = false
titleLockupTrailingConstraint = titleLockup.trailingAnchor.constraint(equalTo: titleLockupContainerView.trailingAnchor)
titleLockupTrailingConstraint?.isActive = true
titleLockupWidthConstraint?.isActive = false
}
//set models
titleLockup.eyebrowModel = eyebrowModel?.toTitleLockupEyebrowModel()
titleLockup.titleModel = titleModel?.toTitleLockupTitleModel()
titleLockup.subTitleModel = subTitleModel?.toTitleLockupSubTitleModel()
if titleLockupContainerView.superview == nil {
stackView.insertArrangedSubview(titleLockupContainerView, at: badgeContainerView.superview == nil ? 0 : 1)
setNeedsLayout()
}
} else {
removeFromSuperview(titleLockupContainerView)
}
titleLockupEyebrowLabelHeightGreaterThanConstraint?.constant = titleLockup.eyebrowLabel.minimumLineHeight
titleLockupTitleLabelHeightGreaterThanConstraint?.constant = titleLockup.titleLabel.minimumLineHeight
titleLockupSubTitleLabelHeightGreaterThanConstraint?.constant = titleLockup.subTitleLabel.minimumLineHeight
}
private func updateIcons() {
//icons
var showIconContainerView = false
if let descriptiveIconModel {
descriptiveIcon.name = descriptiveIconModel.name
if let color = descriptiveIconModel.iconColor?.uiColor {
descriptiveIcon.color = color
}
descriptiveIcon.size = descriptiveIconModel.size
descriptiveIcon.surface = backgroundColorSurface
showIconContainerView = true
}
if let directionalIconModel {
directionalIcon.name = directionalIconModel.iconType.iconName
if let color = directionalIconModel.iconColor?.uiColor {
directionalIcon.color = color
}
directionalIcon.size = directionalIconModel.size.value
directionalIcon.surface = backgroundColorSurface
showIconContainerView = true
}
//iconContainer
descriptiveIcon.isHidden = descriptiveIconModel == nil
directionalIcon.isHidden = directionalIconModel == nil
if showIconContainerView {
//spacing before iconContainerView
var view: UIView?
if badgeContainerView.superview != nil {
view = badgeContainerView
}
if titleLockupContainerView.superview != nil {
view = titleLockupContainerView
}
if let view {
stackView.setCustomSpacing(padding.titleLockupBottomSpacing, after: view)
}
if iconContainerView.superview == nil {
stackView.addArrangedSubview(iconContainerView)
setNeedsDisplay()
}
} else {
removeFromSuperview(iconContainerView)
}
}
private func updateTextPositionAlignment() {
guard width != nil && (aspectRatio != .none || height != nil) else { return }
switch textPostion {
case .top:
titleLockupTopConstraint?.activate()
titleLockupTopGreaterThanConstraint?.deactivate()
titleLockupBottomConstraint?.deactivate()
titleLockupBottomGreaterThanConstraint?.activate()
titleLockupCenterYConstraint?.deactivate()
case .middle:
titleLockupTopConstraint?.deactivate()
titleLockupTopGreaterThanConstraint?.activate()
titleLockupBottomConstraint?.deactivate()
titleLockupBottomGreaterThanConstraint?.activate()
titleLockupCenterYConstraint?.activate()
case .bottom:
titleLockupTopConstraint?.deactivate()
titleLockupTopGreaterThanConstraint?.activate()
titleLockupBottomConstraint?.activate()
titleLockupBottomGreaterThanConstraint?.deactivate()
titleLockupCenterYConstraint?.deactivate()
}
}
}
extension Label {
///To calculate label single line height
fileprivate var minimumLineHeight: CGFloat { textStyle.lineHeight }
}