// // 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, Initable, Resettable { //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- @Published public var model: ModelType private var cancellables = Set() private var shouldUpdate: Bool = false //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- @Proxy(\.model.attributes) open var attributes: [LabelAttributeModel]? @Proxy(\.model.fontCategory) open var fontCategory: FontCategory @Proxy(\.model.fontSize) open var fontSize: FontSize @Proxy(\.model.fontWeight) open var fontWeight: FontWeight @Proxy(\.model.textPosition) open var textPosition: TextPosition //can't use @Proxy here override open var text: String? { didSet { if model.text != oldValue { model.text = text } } } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- private var textColorConfiguration: DisabledSurfaceColorConfiguration = { let config = DisabledSurfaceColorConfiguration() config.disabled.lightColor = VDSColor.elementsSecondaryOnlight config.disabled.darkColor = VDSColor.elementsSecondaryOndark config.enabled.lightColor = VDSColor.elementsPrimaryOnlight config.enabled.darkColor = VDSColor.elementsPrimaryOndark return config } () //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public convenience init() { self.init(frame: .zero) self.model = ModelType() } public required convenience init(with model: ModelType) { self.init() self.model = model } public override init(frame: CGRect) { self.model = ModelType() super.init(frame: frame) setup() } required public init?(coder: NSCoder) { self.model = ModelType() super.init(coder: coder) setup() } //-------------------------------------------------- // MARK: - Public Functions //-------------------------------------------------- open func setup() { backgroundColor = .clear numberOfLines = 0 lineBreakMode = .byWordWrapping translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] accessibilityTraits = .staticText //setup shouldUpdate $model.sink { [weak self] viewModel in guard let self = self else { return } self.shouldUpdate = self.shouldUpdateView(viewModel: viewModel) print("shouldUpdate - \(Self.self): \(self.shouldUpdate)") }.store(in: &cancellables) //setup viewUpdate $model.debounce(for: .seconds(Constants.ModelStateDebounce), scheduler: RunLoop.main).sink { [weak self] viewModel in guard let self = self else { return } if self.shouldUpdate { self.updateView(viewModel: viewModel) self.shouldUpdate = false print("didUpdate - \(Self.self)") } }.store(in: &cancellables) } public func reset() { text = nil attributedText = nil textColor = .black font = FontStyle.RegularBodyLarge.font textAlignment = .left accessibilityCustomActions = [] accessibilityTraits = .staticText numberOfLines = 0 } //Modelable open func set(with model: ModelType) { self.model = model } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- /// Follow the SwiftUI View paradigm /// - Parameter viewModel: state open func shouldUpdateView(viewModel: ModelType) -> Bool { return viewModel.text != model.text || viewModel.disabled != model.disabled || viewModel.surface != model.surface || viewModel.font != model.font || viewModel.textPosition != model.textPosition } open func updateView(viewModel: ModelType) { textAlignment = viewModel.textPosition.textAlignment textColor = textColorConfiguration.getColor(viewModel) if let vdsFont = viewModel.font { font = vdsFont } else { font = FontStyle.defaultStyle.font } if let attributes = viewModel.attributes, let text = model.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) //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) //create a wrapper for the attributes range, block and actions.append(LabelAction(range: actionable.range, actionBlock: actionable.action, accessibilityID: customAccessibilityAction?.hashValue ?? -1)) } } //only enabled if enabled and has actions isUserInteractionEnabled = !viewModel.disabled && !actions.isEmpty //set the attributed text attributedText = mutableText } else { text = viewModel.text } } //-------------------------------------------------- // 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 actionBlock: Blocks.ActionBlock var accessibilityId: Int = 0 func performAction() { actionBlock() } init(range: NSRange, actionBlock: @escaping Blocks.ActionBlock, accessibilityID: Int = 0) { self.range = range self.actionBlock = actionBlock 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) -> 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 = 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 } }