diff --git a/VDS/Components/Label/Label.swift b/VDS/Components/Label/Label.swift index 15ed4b45..dccc0353 100644 --- a/VDS/Components/Label/Label.swift +++ b/VDS/Components/Label/Label.swift @@ -389,60 +389,100 @@ open class Label: UILabel, ViewProtocol, UserInfoable { } } + //-------------------------------------------------- + // MARK: - Touch Events + //-------------------------------------------------- @objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) { - for actionable in actions { - // This determines if we tapped on the desired range of text. - let location = gesture.location(in: self) - if didTapActionInLabel(location, inRange: actionable.range) { - actionable.performAction() - return - } + 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 { - for actionable in actions { - if didTapActionInLabel(location, inRange: actionable.range) { - return true - } - } - return false + actions.contains(where: {isAction(for: location, inRange: $0.range)}) } - private func didTapActionInLabel(_ location: CGPoint, inRange targetRange: NSRange) -> Bool { + 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 - guard let attributedText else { return false } + 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: bounds.size) - let textStorage = NSTextStorage(attributedString: attributedText) + let textContainer = NSTextContainer(size: .zero) + layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) - - let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - - guard let _ = attributedText.attribute(NSAttributedString.Key.action, at: characterIndex, effectiveRange: nil) as? String, characterIndex < attributedText.length else { return false } - return true + + 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 attributedText else { return nil } + 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) - // 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 @@ -456,10 +496,6 @@ open class Label: UILabel, ViewProtocol, UserInfoable { return element } - - //-------------------------------------------------- - // MARK: - Accessibility - //-------------------------------------------------- open var accessibilityAction: ((Label) -> Void)? private var _isAccessibilityElement: Bool = false diff --git a/VDS/Extensions/UITapGestureRecognizer.swift b/VDS/Extensions/UITapGestureRecognizer.swift index 612a11ca..4461ae06 100644 --- a/VDS/Extensions/UITapGestureRecognizer.swift +++ b/VDS/Extensions/UITapGestureRecognizer.swift @@ -12,23 +12,11 @@ extension UITapGestureRecognizer { /// Determines if the touch event has a action attribute within the range given /// - Parameters: - /// - label: UILabel in question + /// - label: Label in question /// - targetRange: Range to look within /// - Returns: Wether the range in the label has an action - public func didTapActionInLabel(_ label: UILabel, inRange targetRange: NSRange) -> Bool { - - guard let attributedText = label.attributedText else { return false } - - let layoutManager = NSLayoutManager() - let textContainer = NSTextContainer(size: label.bounds.size) - let textStorage = NSTextStorage(attributedString: attributedText) - layoutManager.addTextContainer(textContainer) - textStorage.addLayoutManager(layoutManager) - - let location = location(in: label) - let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - - guard let _ = attributedText.attribute(NSAttributedString.Key.action, at: characterIndex, effectiveRange: nil) as? String, characterIndex < attributedText.length else { return false } - return true + public func didTapActionInLabel(_ label: Label, inRange targetRange: NSRange) -> Bool { + let tapLocation = location(in: label) + return label.isAction(for: tapLocation, inRange: targetRange) } }