// // VDSLabel.swift // VDS // // Created by Matt Bruce on 7/28/22. // import Foundation import UIKit import VDSCoreTokens import Combine /// Label is a standard view used to draw text with applying Typography through ``TextStyle`` as well /// as other attributes using any implemetation of ``LabelAttributeModel``. @objcMembers @objc(VDSLabel) open class Label: UILabel, ViewProtocol, UserInfoable { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) initialSetup() } public override init(frame: CGRect) { super.init(frame: .zero) initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- /// Set of Subscribers for any Publishers for this Control. open var subscribers = Set() //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private enum TextSetMode { case text case attributedText } private var textSetMode: TextSetMode = .text private var initialSetupPerformed = false private var edgeInsets: UIEdgeInsets { textStyle.edgeInsets } private var tapGesture: UITapGestureRecognizer? { willSet { if let tapGesture = tapGesture, newValue == nil { removeGestureRecognizer(tapGesture) } else if let gesture = newValue, tapGesture == nil { addGestureRecognizer(gesture) } } } private var actions: [LabelAction] = [] { didSet { isUserInteractionEnabled = !actions.isEmpty if actions.isEmpty { tapGesture = nil } else { //add tap gesture if tapGesture == nil { let singleTap = UITapGestureRecognizer(target: self, action: #selector(textLinkTapped)) singleTap.numberOfTapsRequired = 1 tapGesture = singleTap } if actions.count > 1 { actions.sort { first, second in return first.range.location < second.range.location } } } } } //-------------------------------------------------- // MARK: - Private Models //-------------------------------------------------- private struct LabelAction { var range: NSRange var action: PassthroughSubject var frame: CGRect = .zero func performAction() { action.send() } init(range: NSRange, action: PassthroughSubject) { self.range = range self.action = action } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Key of whether or not updateView() is called in setNeedsUpdate() open var shouldUpdateView: Bool = true /// Will determine if a scaled font should be used for the font. open var useScaledFont: Bool = false { didSet { setNeedsUpdate() }} open var surface: Surface = .light { didSet { setNeedsUpdate() }} /// Array of LabelAttributeModel objects used in rendering the text. open var attributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() }} /// TextStyle used on the this label. open var textStyle: TextStyle = .defaultStyle { didSet { setNeedsUpdate() }} /// The alignment of the text within the label. open override var textAlignment: NSTextAlignment { didSet { setNeedsUpdate() }} open var userInfo = [String: Primitive]() /// Number of lines the label can render out, default is set to 0. open override var numberOfLines: Int { didSet { setNeedsUpdate() }} /// Line break mode for the label, default is set to word wrapping. open override var lineBreakMode: NSLineBreakMode { didSet { setNeedsUpdate() }} /// Text that will be used in the label. private var _text: String! override open var text: String! { didSet { _text = text textSetMode = .text setNeedsUpdate() } } ///AttributedText that will be used in the label. override open var attributedText: NSAttributedString? { didSet { textSetMode = .attributedText setNeedsUpdate() } } override open var font: UIFont! { didSet { if let font, initialSetupPerformed { textStyle = TextStyle.convert(font: font) } setNeedsUpdate() } } override open var textColor: UIColor! { didSet { if let textColor, initialSetupPerformed { textColorConfiguration = SurfaceColorConfiguration(textColor, textColor).eraseToAnyColorable() } setNeedsUpdate() } } /// Whether the View is enabled or not. /// Since the UILabel itselfs draws a different color for the "disabled state", I have to track /// local variable to deal with color and always enforce this UILabel is always enabled. private var _fakeIsEnabled: Bool = true open override var isEnabled: Bool { get { true } set { _fakeIsEnabled = newValue setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- /// Color configuration use for text color. This is a configuration that will provide a color based /// of local properties such as surface and isEnabled. open var textColorConfiguration: AnyColorable = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable(){ didSet { setNeedsUpdate() }} //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true //register for ContentSizeChanges NotificationCenter .Publisher(center: .default, name: UIContentSizeCategory.didChangeNotification) .sink { [weak self] notification in self?.setNeedsUpdate() }.store(in: &subscribers) backgroundColor = .clear numberOfLines = 0 lineBreakMode = .byTruncatingTail translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] isAccessibilityElement = true accessibilityTraits = .staticText textAlignment = .left setup() setNeedsUpdate() } } open func setup() { } open func reset() { shouldUpdateView = false surface = .light isEnabled = true attributes = nil textStyle = .defaultStyle textAlignment = .left text = nil attributedText = nil numberOfLines = 0 backgroundColor = .clear shouldUpdateView = true setNeedsUpdate() } open func updateView() { restyleText() //force a drawText setNeedsDisplay() setNeedsLayout() } open func updateAccessibility() { if isEnabled { accessibilityTraits.remove(.notEnabled) } else { accessibilityTraits.insert(.notEnabled) } } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// We are drawing using edgeInsets based off the textStyle. open override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: edgeInsets)) } /// We are applying action attributes after the views layout to ensure correct positioning of the text. open override func layoutSubviews() { super.layoutSubviews() applyActions() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func restyleText() { if textSetMode == .text { styleText(_text) } else { styleAttributedText(attributedText) } } private struct FakeEnabled: Enabling, Surfaceable { var surface: Surface var isEnabled: Bool } /// Var to deal with the UILabel.isEnabled property causing issues with /// textColor when it is false, I am now using a struct to draw and manage /// colors instead of this class itself and this class will always be enabled private var _textColor: UIColor { let fake = FakeEnabled(surface: surface, isEnabled: _fakeIsEnabled) return textColorConfiguration.getColor(fake) } private func styleText(_ newValue: String!) { defer { invalidateIntrinsicContentSize() } guard let newValue, !newValue.isEmpty else { // We don't need to use attributed text super.attributedText = nil super.text = newValue return } //clear out accessibility accessibilityElements?.removeAll() accessibilityCustomActions = [] //create the primary string let mutableText = NSMutableAttributedString.mutableText(for: newValue, textStyle: textStyle, useScaledFont: useScaledFont, textColor: _textColor, alignment: textAlignment, lineBreakMode: lineBreakMode) applyAttributes(mutableText) // Set attributed text to match typography super.attributedText = mutableText } private func styleAttributedText(_ newValue: NSAttributedString?) { defer { invalidateIntrinsicContentSize() } guard let newValue, !newValue.string.isEmpty else { // We don't need any additional styling super.attributedText = newValue return } //clear out accessibility accessibilityElements?.removeAll() accessibilityCustomActions = [] let mutableText = NSMutableAttributedString(attributedString: newValue) applyAttributes(mutableText) // Modify attributed text to match typography super.attributedText = mutableText } private func applyAttributes(_ mutableAttributedString: NSMutableAttributedString) { actions = [] if let attributes { mutableAttributedString.apply(attributes: attributes) } } private func applyActions() { actions = [] guard let attributedText else { return } let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) if let attributes { //loop through the models attributes for attribute in attributes { //see if the attribute is Actionable if let actionable = attribute as? any ActionLabelAttributeModel, mutableAttributedString.isValid(range: actionable.range) { //create a accessibleAction let customAccessibilityAction = customAccessibilityElement(text: mutableAttributedString.string, range: actionable.range, accessibleText: actionable.accessibleText) // creat the action let labelAction = LabelAction(range: actionable.range, action: actionable.action) // set the action of the accessibilityElement customAccessibilityAction?.accessibilityAction = { [weak self] in guard let self, isEnabled else { return } labelAction.performAction() } //create a wrapper for the attributes range, block and actions.append(labelAction) isUserInteractionEnabled = true } } if let accessibilityElements, !accessibilityElements.isEmpty { let staticText = AccessibilityActionElement(accessibilityContainer: self) staticText.accessibilityLabel = text staticText.accessibilityFrameInContainerSpace = bounds isAccessibilityElement = false self.accessibilityElements = accessibilityElements.compactMap{$0 as? UIAccessibilityElement}.filter { $0.accessibilityLabel != text } self.accessibilityElements?.insert(staticText, at: 0) } } } //-------------------------------------------------- // MARK: - Touch Events //-------------------------------------------------- @objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: self) if let action = actions.first(where: { isAction(for: location, inRange: $0.range) }) { action.performAction() } } public func isAction(for location: CGPoint) -> Bool { actions.contains(where: {isAction(for: location, inRange: $0.range)}) } public func isAction(for location: CGPoint, inRange targetRange: NSRange) -> Bool { guard let abstractContainer = abstractTextContainer() else { return false } let textContainer = abstractContainer.textContainer let layoutManager = abstractContainer.layoutManager let indexOfGlyph = layoutManager.glyphIndex(for: location, in: textContainer) let intrinsicWidth = intrinsicContentSize.width // Assert that tapped occured within acceptable bounds based on alignment. switch textAlignment { case .right: if location.x < bounds.width - intrinsicWidth { return false } case .center: let halfBounds = bounds.width / 2 let halfIntrinsicWidth = intrinsicWidth / 2 if location.x > halfBounds + halfIntrinsicWidth { return false } else if location.x < halfBounds - halfIntrinsicWidth { return false } default: // Left align if location.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(location) && NSLocationInRange(indexOfGlyph, targetRange) } /** 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() -> (textContainer: NSTextContainer, layoutManager: NSLayoutManager, textStorage: 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) } //-------------------------------------------------- // MARK: - Accessibility //-------------------------------------------------- private func customAccessibilityElement(text: String?, range: NSRange, accessibleText: String? = nil) -> AccessibilityActionElement? { guard let text = text, let abstractContainer = abstractTextContainer() else { return nil } let textContainer = abstractContainer.textContainer let layoutManager = abstractContainer.layoutManager let actionText = accessibleText ?? (text.isValid(range: range) ? NSString(string:text).substring(with: range) : text) var glyphRange = NSRange() // Convert the range for the substring into a range of glyphs layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange) let substringBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) // Create custom accessibility element let element = AccessibilityActionElement(accessibilityContainer: self) element.accessibilityLabel = actionText element.accessibilityTraits = .link element.accessibilityHint = "Double tap to open" element.accessibilityFrameInContainerSpace = substringBounds accessibilityElements = (accessibilityElements ?? []).compactMap{$0 as? UIAccessibilityElement}.filter { $0.accessibilityLabel != actionText } accessibilityElements?.append(element) return element } open var accessibilityAction: ((Label) -> Void)? open override var isAccessibilityElement: Bool { get { var block: AXBoolReturnBlock? // if #available(iOS 17, *) { // block = isAccessibilityElementBlock // } if block == nil { block = bridge_isAccessibilityElementBlock } if let block { return block() } else { return super.isAccessibilityElement } } set { super.isAccessibilityElement = newValue } } open override var accessibilityLabel: String? { get { var block: AXStringReturnBlock? // if #available(iOS 17, *) { // block = accessibilityLabelBlock // } if block == nil { block = bridge_accessibilityLabelBlock } if let block { return block() } else { return super.accessibilityLabel } } set { super.accessibilityLabel = newValue } } open override var accessibilityHint: String? { get { var block: AXStringReturnBlock? // if #available(iOS 17, *) { // block = accessibilityHintBlock // } if block == nil { block = bridge_accessibilityHintBlock } if let block { return block() } else { return super.accessibilityHint } } set { super.accessibilityHint = newValue } } open override var accessibilityValue: String? { get { var block: AXStringReturnBlock? // if #available(iOS 17, *) { // block = accessibilityHintBlock // } if block == nil { block = bridge_accessibilityValueBlock } if let block{ return block() } else { return super.accessibilityValue } } set { super.accessibilityValue = newValue } } open override func accessibilityActivate() -> Bool { guard isEnabled, isUserInteractionEnabled else { return false } // if #available(iOS 17, *) { // if let block = accessibilityAction { // block(self) // return true // } else if let block = accessibilityActivateBlock { // return block() // // } else if let block = bridge_accessibilityActivateBlock { // return block() // // } else { // return true // // } // // } else { if let block = accessibilityAction { block(self) return true } else if let block = bridge_accessibilityActivateBlock { return block() } else { return true } // } } }