// // VDSLabel.swift // VDS // // Created by Matt Bruce on 7/28/22. // import Foundation import UIKit import VDSColorTokens import Combine @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 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 { if actions.isEmpty { tapGesture = nil isUserInteractionEnabled = true accessibilityTraits = .staticText } 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 accessibilityId: Int = 0 func performAction() { action.send() } init(range: NSRange, action: PassthroughSubject, accessibilityID: Int = 0) { self.range = range self.action = action self.accessibilityId = accessibilityID } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Key of whether or not updateView() is called in setNeedsUpdate() open var shouldUpdateView: Bool = true /// Determines if the label should use its own attributedText property instead of rendering the attributedText propert /// based of other local properties, such as textStyle, textColor, surface, etc... The default value is false. open var useAttributedText: Bool = false /// 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 var textPosition: TextPosition = .left { 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. override open var text: String? { didSet { useAttributedText = false attributes = nil setNeedsUpdate() } } /// Whether the View is enabled or not. open override var isEnabled: Bool { didSet { 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 { //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 = .byWordWrapping translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] accessibilityTraits = .staticText setup() setNeedsUpdate() } } open func setup() {} open func reset() { shouldUpdateView = false surface = .light isEnabled = true attributes = nil textStyle = .defaultStyle textPosition = .left text = nil attributedText = nil numberOfLines = 0 backgroundColor = .clear shouldUpdateView = true setNeedsUpdate() } open func updateView() { if !useAttributedText { if let text = text { accessibilityCustomActions = [] //create the primary string let mutableText = NSMutableAttributedString.mutableText(for: text, textStyle: textStyle, useScaledFont: useScaledFont, textColor: textColorConfiguration.getColor(self), alignment: textPosition.textAlignment, lineBreakMode: lineBreakMode) applyAttributes(mutableText) //set the attributed text attributedText = mutableText //force a drawText setNeedsDisplay() setNeedsLayout() layoutIfNeeded() } } } open func updateAccessibility() { accessibilityLabel = text } //-------------------------------------------------- // 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() } /// Addig custom accessibillty actions from the collection of attributes. open override func accessibilityActivate() -> Bool { guard let accessibleActions = accessibilityCustomActions else { return false } for actionable in actions { for action in accessibleActions { if action.hash == actionable.accessibilityId { actionable.performAction() return true } } } return false } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func applyAttributes(_ mutableAttributedString: NSMutableAttributedString) { actions = [] if let attributes = attributes { //loop through the models attributes for attribute in attributes { //add attribute on the string attribute.setAttribute(on: mutableAttributedString) } } } private func applyActions() { actions = [] guard let attributedText else { return } let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) if let attributes = attributes { //loop through the models attributes for attribute in attributes { //see if the attribute is Actionable if let actionable = attribute as? any ActionLabelAttributeModel{ //create a accessibleAction let customAccessibilityAction = customAccessibilityAction(text: mutableAttributedString.string, range: actionable.range, accessibleText: actionable.accessibleText) //create a wrapper for the attributes range, block and actions.append(LabelAction(range: actionable.range, action: actionable.action, accessibilityID: customAccessibilityAction?.hashValue ?? -1)) } } if let accessibilityElements, !accessibilityElements.isEmpty { let staticText = UIAccessibilityElement(accessibilityContainer: self) staticText.accessibilityLabel = text staticText.accessibilityTraits = .staticText staticText.accessibilityFrameInContainerSpace = bounds isAccessibilityElement = false self.accessibilityElements = accessibilityElements.compactMap{$0 as? UIAccessibilityElement}.filter { $0.accessibilityLabel != text } self.accessibilityElements?.insert(staticText, at: 0) } } } @objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) { for actionable in actions { // This determines if we tapped on the desired range of text. if gesture.didTapActionInLabel(self, inRange: actionable.range) { actionable.performAction() return } } } private func customAccessibilityAction(text: String?, range: NSRange, accessibleText: String? = nil) -> UIAccessibilityCustomAction? { guard let text = text, let attributedText else { return nil } let actionText = accessibleText ?? NSString(string:text).substring(with: range) // Calculate the frame of the substring let layoutManager = NSLayoutManager() let textContainer = NSTextContainer(size: bounds.size) let textStorage = NSTextStorage(attributedString: attributedText) layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) 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 = UIAccessibilityElement(accessibilityContainer: self) element.accessibilityLabel = actionText element.accessibilityTraits = .link element.accessibilityFrameInContainerSpace = substringBounds //TODO: accessibilityHint for Label // element.accessibilityHint = MVMCoreUIUtility.hardcodedString(withKey: "swipe_to_select_with_action_hint") accessibilityElements = (accessibilityElements ?? []).compactMap{$0 as? UIAccessibilityElement}.filter { $0.accessibilityLabel != actionText } accessibilityElements?.append(element) let accessibleAction = UIAccessibilityCustomAction(name: actionText, target: self, selector: #selector(accessibilityCustomAction(_:))) accessibilityCustomActions?.append(accessibleAction) return accessibleAction } @objc private func accessibilityCustomAction(_ action: UIAccessibilityCustomAction) { for actionable in actions { if action.hash == actionable.accessibilityId { actionable.performAction() return } } } }