vds_ios/VDS/Components/Label/Label.swift
Matt Bruce 09886ffe26 added setDefaults() into protocol and implemented
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-08-09 14:35:51 -05:00

626 lines
22 KiB
Swift

//
// 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<AnyCancellable>()
//--------------------------------------------------
// 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<Void, Never>
var frame: CGRect = .zero
func performAction() {
action.send()
}
init(range: NSRange, action: PassthroughSubject<Void, Never>) {
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
//--------------------------------------------------
private func initialSetup() {
if !initialSetupPerformed {
initialSetupPerformed = true
shouldUpdateView = false
setup()
setDefaults()
shouldUpdateView = true
setNeedsUpdate()
}
}
open func setup() {
//register for ContentSizeChanges
NotificationCenter
.Publisher(center: .default, name: UIContentSizeCategory.didChangeNotification)
.sink { [weak self] notification in
self?.setNeedsUpdate()
}.store(in: &subscribers)
translatesAutoresizingMaskIntoConstraints = false
isAccessibilityElement = true
}
open func setDefaults() {
backgroundColor = .clear
accessibilityTraits = .staticText
accessibilityCustomActions = []
surface = .light
isEnabled = true
attributes = nil
textStyle = .defaultStyle
lineBreakMode = .byTruncatingTail
textAlignment = .left
text = nil
attributedText = nil
numberOfLines = 0
}
open func reset() {
shouldUpdateView = false
setDefaults()
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
}
// }
}
}