// // VDSLabel.swift // VDS // // Created by Matt Bruce on 7/28/22. // import Foundation import UIKit import VDSColorTokens import Combine open class Label: UILabel, ModelHandlerable, Initable, Resettable { @Published public var model: LabelModel = DefaultLabelModel() private var cancellable: AnyCancellable? @Proxy(\.model.fontSize) public var fontSize: FontSize @Proxy(\.model.textPosition) public var textPosition: TextPosition @Proxy(\.model.fontWeight) public var fontWeight: FontWeight @Proxy(\.model.fontCategory) public var fontCategory: FontCategory @Proxy(\.model.surface) public var surface: Surface //Initializers required public convenience init() { self.init(frame: .zero) } public required convenience init(with model: LabelModel) { self.init() self.model = model set(with: model) } public override init(frame: CGRect) { super.init(frame: frame) setup() } required public init?(coder: NSCoder) { super.init(coder: coder) setup() } open func setup() { backgroundColor = .clear numberOfLines = 0 lineBreakMode = .byWordWrapping translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] accessibilityTraits = .staticText cancellable = $model.debounce(for: .seconds(Constants.ModelStateDebounce), scheduler: RunLoop.main).sink { [weak self] viewModel in self?.onStateChange(viewModel: viewModel) } } public func reset() { text = nil attributedText = nil textColor = .black font = FontStyle.RegularBodyLarge.font textAlignment = .left accessibilityCustomActions = [] accessibilityTraits = .staticText numberOfLines = 0 } private func getTextColor(for disabled: Bool, surface: Surface) -> UIColor { if disabled { if surface == .light { return VDSColor.elementsSecondaryOnlight } else { return VDSColor.elementsSecondaryOndark } } else { if surface == .light { return VDSColor.elementsPrimaryOnlight } else { return VDSColor.elementsPrimaryOndark } } } //functions private func onStateChange(viewModel: LabelModel) { textAlignment = viewModel.textPosition.textAlignment textColor = getTextColor(for: viewModel.disabled, surface: viewModel.surface) if let vdsFont = try? FontStyle.font(for: viewModel.fontCategory, fontWeight: viewModel.fontWeight, fontSize: viewModel.fontSize) { font = vdsFont } else { font = FontStyle.RegularBodyLarge.font } if let attributes = viewModel.attributes, let text = model.text, let font = font, let textColor = textColor { let startingAttributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: textColor] let mutableText = NSMutableAttributedString(string: text, attributes: startingAttributes) var hasActionable = false for attribute in attributes { attribute.setAttribute(on: mutableText) if let attributeActionable = attribute as? LabelAttributeActionable { hasActionable = true setTextLinkState(range: attributeActionable.range) { attributeActionable.action() } } } if hasActionable { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(textLinkTapped)) tapGesture.numberOfTapsRequired = 1 addGestureRecognizer(tapGesture) } attributedText = mutableText } else { text = viewModel.text } } //------------------------------------------------------ // MARK: - Multi-Action Text //------------------------------------------------------ /// Data store of the tappable ranges of the text. public var clauses: [ActionableClause] = [] { didSet { isUserInteractionEnabled = !clauses.isEmpty if clauses.count > 1 { clauses.sort { first, second in return first.range.location < second.range.location } } } } /// Used for tappable links in the text. public struct ActionableClause { public var range: NSRange public var actionBlock: Blocks.ActionBlock public var accessibilityID: Int = 0 public func performAction() { actionBlock() } public init(range: NSRange, actionBlock: @escaping Blocks.ActionBlock, accessibilityID: Int = 0) { self.range = range self.actionBlock = actionBlock self.accessibilityID = accessibilityID } } private func setTextLinkState(range: NSRange, actionBlock: @escaping Blocks.ActionBlock) { clauses.append(ActionableClause(range: range, actionBlock: actionBlock, accessibilityID: -1)) } @objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) { for clause in clauses { // This determines if we tapped on the desired range of text. if gesture.didTapAttributedTextInLabel(self, inRange: clause.range) { clause.performAction() return } } } /** Provides a text container and layout manager of how the text would appear on screen. They are used in tandem to derive low-level TextKit results of the label. */ public func abstractTextContainer() -> (NSTextContainer, NSLayoutManager, NSTextStorage)? { // Must configure the attributed string to translate what would appear on screen to accurately analyze. guard let attributedText = attributedText else { return nil } let paragraph = NSMutableParagraphStyle() paragraph.alignment = textAlignment let stagedAttributedString = NSMutableAttributedString(attributedString: attributedText) stagedAttributedString.addAttributes([NSAttributedString.Key.paragraphStyle: paragraph], range: NSRange(location: 0, length: attributedText.string.count)) let textStorage = NSTextStorage(attributedString: stagedAttributedString) let layoutManager = NSLayoutManager() let textContainer = NSTextContainer(size: .zero) layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) textContainer.lineFragmentPadding = 0.0 textContainer.lineBreakMode = lineBreakMode textContainer.maximumNumberOfLines = numberOfLines textContainer.size = bounds.size return (textContainer, layoutManager, textStorage) } //Modelable public func set(with model: LabelModel) { self.model = model } } // MARK: - extension UITapGestureRecognizer { func didTapAttributedTextInLabel(_ label: Label, inRange targetRange: NSRange) -> Bool { guard let abstractContainer = label.abstractTextContainer() else { return false } let textContainer = abstractContainer.0 let layoutManager = abstractContainer.1 let tapLocation = location(in: label) let indexOfGlyph = layoutManager.glyphIndex(for: tapLocation, in: textContainer) let intrinsicWidth = label.intrinsicContentSize.width // Assert that tapped occured within acceptable bounds based on alignment. switch label.textAlignment { case .right: if tapLocation.x < label.bounds.width - intrinsicWidth { return false } case .center: let halfBounds = label.bounds.width / 2 let halfIntrinsicWidth = intrinsicWidth / 2 if tapLocation.x > halfBounds + halfIntrinsicWidth { return false } else if tapLocation.x < halfBounds - halfIntrinsicWidth { return false } default: // Left align if tapLocation.x > intrinsicWidth { return false } } // Affirms that the tap occured in the desired rect of provided by the target range. return layoutManager.boundingRect(forGlyphRange: targetRange, in: textContainer).contains(tapLocation) && NSLocationInRange(indexOfGlyph, targetRange) } }