// // TitleLockup.swift // VDS // // Created by Matt Bruce on 12/19/22. // import Foundation import UIKit import VDSCoreTokens import Combine /// Title Lockup ensures the readability of words on the screen /// with approved built in text size configurations. @objcMembers @objc(VDSTitleLockup) open class TitleLockup: View { //-------------------------------------------------- // 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 used to describe the alignment of the text. public enum TextAlignment: String, EnumSubset { case left, center public var defaultValue: VDS.TextAlignment { .left } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var otherStandardStyle: OtherStandardStyle { if let subTitleModel, !subTitleModel.text.isEmpty { return subTitleModel.otherStandardStyle } else if let eyebrowModel, !eyebrowModel.text.isEmpty { return eyebrowModel.standardStyle } else { return .bodyLarge } } ///This logic applies when the type style and size used for the title and subtitle/eyebrow is exactly the same (not including the type weight). This should be automatically detected. private var isUniformSize: Bool { otherStandardStyle.value == titleModel?.standardStyle.value } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Aligns TitleLockup's subcomponent's text open var textAlignment: TextAlignment = .left { didSet { setNeedsUpdate() } } //first row /// Label used to render the eyebrow model. open var eyebrowLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentHuggingPriority(.required, for: .vertical) } /// Model used in rendering the eyebrow label. open var eyebrowModel: EyebrowModel? { didSet { setNeedsUpdate() } } //second row /// Label used to render the title model. open var titleLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentHuggingPriority(.required, for: .vertical) $0.accessibilityTraits.insert([.header]) } /// Model used in rendering the title label. open var titleModel: TitleModel? { didSet { setNeedsUpdate() } } //third row /// Label used to render the subtitle model. open var subTitleLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentHuggingPriority(.required, for: .vertical) } /// Model used in rendering the subtitle label. open var subTitleModel: SubTitleModel? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- // Sizes are from InVision design specs. /// Configuration that will set the spacing between labels based off their textStyles. open var standardStyleConfiguration: StandardStyleConfigurationProvider = StandardStyleConfigurationProvider(styleConfigurations: [ .init(deviceType: .iPad, titleStandardStyles: [.bodySmall], spacingConfigurations: [ .init(otherStandardStyles: [.bodySmall], topSpacing: VDSLayout.space1X, bottomSpacing: VDSLayout.space1X) ]), .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], spacingConfigurations: [ .init(otherStandardStyles: [.bodySmall, .bodyMedium, .bodyLarge, .titleSmall], topSpacing: VDSLayout.space2X, bottomSpacing: VDSLayout.space2X) ]), .init(deviceType: .iPad, titleStandardStyles: [.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) ]), .init(deviceType: .iPad, titleStandardStyles: [.title2XLarge, .featureXSmall], spacingConfigurations: [ .init(otherStandardStyles: [.bodyLarge], topSpacing: VDSLayout.space3X, bottomSpacing: VDSLayout.space6X), .init(otherStandardStyles: [.titleMedium, .titleLarge], topSpacing: VDSLayout.space4X, bottomSpacing: VDSLayout.space6X), ]), .init(deviceType: .iPad, titleStandardStyles: [.featureSmall, .featureMedium], spacingConfigurations: [ .init(otherStandardStyles: [.titleLarge, .titleMedium], topSpacing: VDSLayout.space4X, bottomSpacing: VDSLayout.space6X), .init(otherStandardStyles: [.bodyLarge], topSpacing: VDSLayout.space3X, bottomSpacing: VDSLayout.space6X), ]), .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: .iPhone, titleStandardStyles: [.title2XLarge, .featureXSmall], spacingConfigurations: [ .init(otherStandardStyles: [.bodyLarge, .bodyMedium, .titleMedium], topSpacing: VDSLayout.space3X, bottomSpacing: VDSLayout.space4X) ]), .init(deviceType: .iPhone, titleStandardStyles: [.featureSmall], spacingConfigurations: [ .init(otherStandardStyles: [.titleLarge, .bodyLarge], topSpacing: VDSLayout.space3X, bottomSpacing: VDSLayout.space4X) ]), .init(deviceType: .iPhone, titleStandardStyles: [.featureMedium], spacingConfigurations: [ .init(otherStandardStyles: [.titleLarge, .titleXLarge], topSpacing: VDSLayout.space4X, bottomSpacing: VDSLayout.space6X), .init(otherStandardStyles: [.bodyLarge], topSpacing: VDSLayout.space3X, bottomSpacing: VDSLayout.space6X) ]), ]) //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open override var accessibilityElements: [Any]? { get { var elements = [Any]() if eyebrowModel != nil { elements.append(eyebrowLabel) } if titleModel != nil { elements.append(titleLabel) } if subTitleModel != nil { elements.append(subTitleLabel) } return elements.count > 0 ? elements : nil } set {} } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false eyebrowModel = nil titleModel = nil subTitleModel = nil shouldUpdateView = true setNeedsUpdate() } var labelViews = [UIView]() /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() //remove all labels eyebrowLabel.removeFromSuperview() titleLabel.removeFromSuperview() subTitleLabel.removeFromSuperview() //set local vars let allLabelsTextAlignment = textAlignment.value.value var topSpacing: CGFloat = 0.0 var bottomSpacing: CGFloat = 0.0 //get the spacing based on the title style and other style used for eyebrow and subtitle if let titleModel, let config = standardStyleConfiguration.spacing(for: titleModel.standardStyle, otherStandardStyle: otherStandardStyle) { topSpacing = config.topSpacing bottomSpacing = config.bottomSpacing } //set a previousView to keep track of the stack //to deal with anchoring/spacing var previousView: UIView? //see if the eyebrow should exist if let eyebrowModel, !eyebrowModel.text.isEmpty { eyebrowLabel.textAlignment = allLabelsTextAlignment eyebrowLabel.text = eyebrowModel.text eyebrowLabel.textColorConfiguration = eyebrowModel.textColor.configuration eyebrowLabel.attributes = eyebrowModel.textAttributes eyebrowLabel.numberOfLines = eyebrowModel.numberOfLines eyebrowLabel.surface = surface //When uniform size is true if let titleModel, isUniformSize { if titleModel.isBold { //When uniform size is true and the title is bold, //the eyebrow is always regular weight and the secondary color. eyebrowLabel.textStyle = otherStandardStyle.value.regular } else { //When uniform size is true and the title is regular weight //the eyebrow is always bold and uses the primary color. eyebrowLabel.textStyle = otherStandardStyle.value.bold } } else { eyebrowLabel.textStyle = eyebrowModel.isBold ? otherStandardStyle.value.bold : otherStandardStyle.value.regular } addSubview(eyebrowLabel) eyebrowLabel .pinTop() .pinLeading() .pinTrailing() previousView = eyebrowLabel } //see if the title should exist if let titleModel, !titleModel.text.isEmpty { titleLabel.textAlignment = allLabelsTextAlignment titleLabel.textStyle = titleModel.textStyle titleLabel.textColorConfiguration = titleModel.textColor.configuration titleLabel.text = titleModel.text titleLabel.attributes = titleModel.textAttributes titleLabel.numberOfLines = titleModel.numberOfLines titleLabel.surface = surface titleLabel.lineBreakMode = titleModel.lineBreakMode addSubview(titleLabel) titleLabel .pinTop(previousView?.bottomAnchor ?? self.topAnchor, eyebrowLabel == previousView ? topSpacing : 0) .pinLeading() .pinTrailing() previousView = titleLabel } //see if the subtitle should exist if let subTitleModel, !subTitleModel.text.isEmpty { subTitleLabel.textAlignment = allLabelsTextAlignment subTitleLabel.textStyle = otherStandardStyle.value.regular subTitleLabel.textColorConfiguration = subTitleModel.textColor.configuration subTitleLabel.text = subTitleModel.text subTitleLabel.attributes = subTitleModel.textAttributes subTitleLabel.numberOfLines = subTitleModel.numberOfLines subTitleLabel.surface = surface subTitleLabel.lineBreakMode = subTitleModel.lineBreakMode addSubview(subTitleLabel) subTitleLabel .pinTop(previousView?.bottomAnchor ?? self.topAnchor, (eyebrowLabel == previousView || titleLabel == previousView) ? bottomSpacing : 0) .pinLeading() .pinTrailing() previousView = subTitleLabel } //pin the last view to the bottom of this view previousView?.pinBottom(anchor: bottomAnchor, priority: UILayoutPriority(700)) //debugging for borders eyebrowLabel.debugBorder(show: hasDebugBorder, color: .green) titleLabel.debugBorder(show: hasDebugBorder, color: .green) subTitleLabel .debugBorder(show: hasDebugBorder, color: .green) } }