// // VDSLabel.swift // VDS // // Created by Matt Bruce on 7/28/22. // import Foundation import UIKit import VDSColorTokens import Combine public class Label: LabelBase {} open class LabelBase: UILabel, ModelHandlerable, ViewProtocol, Resettable { //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- public var subject = PassthroughSubject() public var subscribers = Set() public var hasChanged: Bool = false //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- open var surface: Surface = .light { didSet { didChange() }} open var disabled: Bool = false { didSet { isEnabled = !disabled } } open var attributes: [any LabelAttributeModel]? { didSet { didChange() }} open var typograpicalStyle: TypographicalStyle = .defaultStyle { didSet { didChange() }} open var textPosition: TextPosition = .left { didSet { didChange() }} open override var isEnabled: Bool { get { !disabled } set { if disabled != !newValue { disabled = !newValue } isUserInteractionEnabled = isEnabled didChange() } } override open var text: String? { didSet { didChange() } } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- public var textColorConfiguration: AnyColorable = DisabledSurfaceColorConfiguration().with { $0.disabled.lightColor = VDSColor.elementsSecondaryOnlight $0.disabled.darkColor = VDSColor.elementsSecondaryOndark $0.enabled.lightColor = VDSColor.elementsPrimaryOnlight $0.enabled.darkColor = VDSColor.elementsPrimaryOndark }.eraseToAnyColorable() //-------------------------------------------------- // 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: - Public Functions //-------------------------------------------------- open func initialSetup() { backgroundColor = .clear numberOfLines = 0 lineBreakMode = .byWordWrapping translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] accessibilityTraits = .staticText setup() } open func setup() {} open func reset() { surface = .light disabled = false attributes = nil typograpicalStyle = .defaultStyle textPosition = .left text = nil attributedText = nil accessibilityCustomActions = [] accessibilityTraits = .staticText numberOfLines = 0 } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open override func layoutSubviews() { super.layoutSubviews() updateView() } open func updateView() { textAlignment = textPosition.textAlignment textColor = textColorConfiguration.getColor(self) font = typograpicalStyle.font if let text = text, let font = font, let textColor = textColor { //clear the arrays holding actions accessibilityCustomActions = [] actions = [] //create the primary string let startingAttributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: textColor] let mutableText = NSMutableAttributedString(string: text, attributes: startingAttributes) //set the local lineHeight/lineSpacing attributes setStyleAttributes(attributedString: mutableText) if let attributes = attributes { //loop through the models attributes for attribute in attributes { //add attribute on the string attribute.setAttribute(on: mutableText) //see if the attribute is Actionable if let actionable = attribute as? any LabelAttributeActionable{ //create a accessibleAction let customAccessibilityAction = customAccessibilityAction(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)) } } } //only enabled if enabled and has actions isUserInteractionEnabled = !disabled && !actions.isEmpty //set the attributed text attributedText = mutableText } } // MARK: - Private Attributes private func setStyleAttributes(attributedString: NSMutableAttributedString) { //get the range let entireRange = NSRange(location: 0, length: attributedString.length) //set letterSpacing if typograpicalStyle.letterSpacing > 0.0 { attributedString.addAttribute(.kern, value: typograpicalStyle.letterSpacing, range: entireRange) } //set lineHeight if typograpicalStyle.lineHeight > 0.0 { let lineHeight = typograpicalStyle.lineHeight let adjustment = lineHeight > font.lineHeight ? 2.0 : 1.0 let baselineOffset = (lineHeight - font.lineHeight) / 2.0 / adjustment let paragraph = NSMutableParagraphStyle().with { $0.maximumLineHeight = lineHeight $0.minimumLineHeight = lineHeight $0.alignment = textPosition.textAlignment $0.lineBreakMode = lineBreakMode } attributedString.addAttribute(.baselineOffset, value: baselineOffset, range: entireRange) attributedString.addAttribute( .paragraphStyle, value: paragraph, range: entireRange) } else if textPosition != .left { let paragraph = NSMutableParagraphStyle().with { $0.alignment = textPosition.textAlignment $0.lineBreakMode = lineBreakMode } attributedString.addAttribute( .paragraphStyle, value: paragraph, range: entireRange) } } //-------------------------------------------------- // MARK: - Actionable //-------------------------------------------------- private var tapGesture: UITapGestureRecognizer? { willSet { if let tapGesture = tapGesture, newValue == nil { removeGestureRecognizer(tapGesture) } else if let gesture = newValue, tapGesture == nil { addGestureRecognizer(gesture) } } } 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 } } private var actions: [LabelAction] = [] { didSet { 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 } } } } } @objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) { for actionable in actions { // This determines if we tapped on the desired range of text. if gesture.didTapAttributedTextInLabel(self, inRange: actionable.range) { actionable.performAction() return } } } //-------------------------------------------------- // MARK: - Accessibility For Actions //-------------------------------------------------- private func customAccessibilityAction(range: NSRange, accessibleText: String? = nil) -> UIAccessibilityCustomAction? { guard let text = text else { return nil } //TODO: accessibilityHint for Label // if accessibilityHint == nil { // accessibilityHint = MVMCoreUIUtility.hardcodedString(withKey: "swipe_to_select_with_action_hint") // } let actionText = accessibleText ?? NSString(string:text).substring(with: range) let accessibleAction = UIAccessibilityCustomAction(name: actionText, target: self, selector: #selector(accessibilityCustomAction(_:))) accessibilityCustomActions?.append(accessibleAction) return accessibleAction } @objc public func accessibilityCustomAction(_ action: UIAccessibilityCustomAction) { for actionable in actions { if action.hash == actionable.accessibilityId { actionable.performAction() return } } } 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 } }