// // Tilet.swift // VDS // // Created by Matt Bruce on 12/19/22. // import Foundation import Foundation import VDSTokens 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. @objc(VDSTilelet) open class Tilelet: TileContainerBase { /// Enum used to describe the padding choices used for this component. public enum Padding: String, DefaultValuing, 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().with { $0.isAccessibilityElement = true } /// Directional Icon positioned in the contentView. open var directionalIcon = Icon().with { $0.isAccessibilityElement = true $0.name = .rightArrow } 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() aspectRatio = .none color = .black 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() } /// Resets to default settings. open override func reset() { shouldUpdateView = false aspectRatio = .none color = .black //models badgeModel = nil titleModel = nil subTitleModel = nil descriptiveIconModel = nil directionalIconModel = nil shouldUpdateView = true setNeedsUpdate() } /// 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() ///Content-driven height Tilelets - Minimum height is configurable. ///if width != nil && (aspectRatio != .none || height != nil) then tilelet is not self growing, so we can apply text position alignments. if width != nil && (aspectRatio != .none || height != nil) { updateTextPositionAlignment() } setNeedsLayout() } /// Used to update any Accessibility properties. open override var accessibilityElements: [Any]? { get { var elements = [Any]() if let superElements = super.accessibilityElements { elements.append(contentsOf: superElements) } if badgeModel != nil { elements.append(badge) } if titleModel != nil || subTitleModel != nil || eyebrowModel != nil { elements.append(titleLockup) } if descriptiveIconModel != nil { elements.append(descriptiveIcon) } if directionalIconModel != nil { elements.append(directionalIcon) } return elements } 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 descriptiveIcon.color = descriptiveIconModel.color descriptiveIcon.size = descriptiveIconModel.size descriptiveIcon.surface = backgroundColorSurface descriptiveIcon.accessibilityLabel = descriptiveIconModel.accessibleText showIconContainerView = true } if let directionalIconModel { directionalIcon.name = directionalIconModel.iconType.iconName directionalIcon.color = directionalIconModel.color directionalIcon.size = directionalIconModel.size directionalIcon.surface = backgroundColorSurface directionalIcon.accessibilityLabel = directionalIconModel.accessibleText 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() { 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 } }