From 5820401c500fb15373ec957aca1dd2452f08cd31 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 4 Aug 2022 10:24:43 -0500 Subject: [PATCH] refactored label Signed-off-by: Matt Bruce --- VDS.xcodeproj/project.pbxproj | 12 ++ VDS/Classes/ModelColorHelper.swift | 59 ++++++ VDS/Components/Label/Label.swift | 223 +++++++------------- VDS/Extensions/UILabel.swift | 41 ++++ VDS/Extensions/UITapGestureRecognizer.swift | 47 +++++ 5 files changed, 234 insertions(+), 148 deletions(-) create mode 100644 VDS/Classes/ModelColorHelper.swift create mode 100644 VDS/Extensions/UILabel.swift create mode 100644 VDS/Extensions/UITapGestureRecognizer.swift diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index ab288fd6..ac6245c0 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -55,6 +55,9 @@ EAF7F0AF289B144C00B287F5 /* LabelAttributeUnderline.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0AE289B144C00B287F5 /* LabelAttributeUnderline.swift */; }; EAF7F0B1289B177F00B287F5 /* LabelAttributeColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0B0289B177F00B287F5 /* LabelAttributeColor.swift */; }; EAF7F0B3289B1ADC00B287F5 /* LabelAttributeAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0B2289B1ADC00B287F5 /* LabelAttributeAction.swift */; }; + EAF7F0B5289C126F00B287F5 /* UILabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0B4289C126F00B287F5 /* UILabel.swift */; }; + EAF7F0B7289C12A600B287F5 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0B6289C12A600B287F5 /* UITapGestureRecognizer.swift */; }; + EAF7F0B9289C139800B287F5 /* ModelColorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0B8289C139800B287F5 /* ModelColorHelper.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -117,6 +120,9 @@ EAF7F0AE289B144C00B287F5 /* LabelAttributeUnderline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelAttributeUnderline.swift; sourceTree = ""; }; EAF7F0B0289B177F00B287F5 /* LabelAttributeColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelAttributeColor.swift; sourceTree = ""; }; EAF7F0B2289B1ADC00B287F5 /* LabelAttributeAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelAttributeAction.swift; sourceTree = ""; }; + EAF7F0B4289C126F00B287F5 /* UILabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UILabel.swift; sourceTree = ""; }; + EAF7F0B6289C12A600B287F5 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = ""; }; + EAF7F0B8289C139800B287F5 /* ModelColorHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelColorHelper.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -220,6 +226,8 @@ EA33622D2891EA3C0071C351 /* DispatchQueue+Once.swift */, EA3361A7288B23300071C351 /* UIColor.swift */, EA33623D2892EE950071C351 /* UIDevice.swift */, + EAF7F0B4289C126F00B287F5 /* UILabel.swift */, + EAF7F0B6289C12A600B287F5 /* UITapGestureRecognizer.swift */, ); path = Extensions; sourceTree = ""; @@ -251,6 +259,7 @@ EAF7F09D289AAEC000B287F5 /* Constants.swift */, EA3361B5288B2A410071C351 /* Control.swift */, EAF7F09F289AB7EC00B287F5 /* View.swift */, + EAF7F0B8289C139800B287F5 /* ModelColorHelper.swift */, ); path = Classes; sourceTree = ""; @@ -438,6 +447,7 @@ buildActionMask = 2147483647; files = ( EA3362322891F2ED0071C351 /* FontStyles.swift in Sources */, + EAF7F0B5289C126F00B287F5 /* UILabel.swift in Sources */, EAF7F0A6289B0CE000B287F5 /* Resetable.swift in Sources */, EA3361C328902D960071C351 /* Toggle.swift in Sources */, EAF7F0A0289AB7EC00B287F5 /* View.swift in Sources */, @@ -454,6 +464,7 @@ EA33624728931B050071C351 /* Initable.swift in Sources */, EAF7F0A4289B017C00B287F5 /* LabelAttributeModel.swift in Sources */, EAF7F0B1289B177F00B287F5 /* LabelAttributeColor.swift in Sources */, + EAF7F0B9289C139800B287F5 /* ModelColorHelper.swift in Sources */, EA3361BD288B2C760071C351 /* TypeAlias.swift in Sources */, EAF7F09A2899B17200B287F5 /* CATransaction.swift in Sources */, EAF7F0A2289AFB3900B287F5 /* Errorable.swift in Sources */, @@ -467,6 +478,7 @@ EAF7F0962899861000B287F5 /* CheckboxModel.swift in Sources */, EA3361AA288B25E40071C351 /* Disabling.swift in Sources */, EA3361B6288B2A410071C351 /* Control.swift in Sources */, + EAF7F0B7289C12A600B287F5 /* UITapGestureRecognizer.swift in Sources */, EA3362452892F9130071C351 /* Labelable.swift in Sources */, EA3361AD288B26190071C351 /* DataTrackable.swift in Sources */, EA33623E2892EE950071C351 /* UIDevice.swift in Sources */, diff --git a/VDS/Classes/ModelColorHelper.swift b/VDS/Classes/ModelColorHelper.swift new file mode 100644 index 00000000..e308e6dd --- /dev/null +++ b/VDS/Classes/ModelColorHelper.swift @@ -0,0 +1,59 @@ +// +// ModelColorHelper.swift +// VDS +// +// Created by Matt Bruce on 8/4/22. +// + +import Foundation +import UIKit + +public class ModelColor { + public var disabledSurfaceLight: UIColor = .clear + public var disabledSurfaceDark: UIColor = .clear + public var enabledSurfaceLight: UIColor = .clear + public var enabledSurfaceDark: UIColor = .clear + + public func getColor(_ viewModel: ModelType) -> UIColor { + var color: UIColor + if viewModel.disabled { + if viewModel.surface == .light { + color = disabledSurfaceLight + } else { + color = disabledSurfaceDark + } + } else { + if viewModel.surface == .light { + color = enabledSurfaceLight + } else { + color = enabledSurfaceDark + } + } + return color + } +} + +public class ModelColorHelper { + public var onColor = ModelColor() + public var offColor: ModelColor? + public var errorOnColor: ModelColor? + public var errorOffColor: ModelColor? + + public init (){} + + public func getColor(model: ModelType, isOn: Bool = true, isError: Bool = false) -> UIColor { + if isOn { + if isError { + return errorOnColor?.getColor(model) ?? onColor.getColor(model) + } else { + return onColor.getColor(model) + } + } else { + if isError { + return errorOffColor?.getColor(model) ?? (offColor?.getColor(model) ?? onColor.getColor(model)) + } else { + return offColor?.getColor(model) ?? onColor.getColor(model) + } + } + } +} diff --git a/VDS/Components/Label/Label.swift b/VDS/Components/Label/Label.swift index 398c9dc4..c4405146 100644 --- a/VDS/Components/Label/Label.swift +++ b/VDS/Components/Label/Label.swift @@ -12,9 +12,15 @@ import Combine open class Label: UILabel, ModelHandlerable, Initable, Resettable { + //-------------------------------------------------- + // MARK: - Combine Properties + //-------------------------------------------------- @Published public var model: LabelModel = DefaultLabelModel() private var cancellable: AnyCancellable? + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- @Proxy(\.model.fontSize) public var fontSize: FontSize @@ -30,7 +36,9 @@ open class Label: UILabel, ModelHandlerable, Initable, Resettable { @Proxy(\.model.surface) public var surface: Surface - //Initializers + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- required public convenience init() { self.init(frame: .zero) } @@ -51,6 +59,9 @@ open class Label: UILabel, ModelHandlerable, Initable, Resettable { setup() } + //-------------------------------------------------- + // MARK: - Public Functions + //-------------------------------------------------- open func setup() { backgroundColor = .clear numberOfLines = 0 @@ -73,7 +84,45 @@ open class Label: UILabel, ModelHandlerable, Initable, Resettable { accessibilityTraits = .staticText numberOfLines = 0 } + + //Modelable + open func set(with model: LabelModel) { + self.model = model + } + + //-------------------------------------------------- + // MARK: - State + //-------------------------------------------------- + /// Follow the SwiftUI View paradigm + /// - Parameter viewModel: state + open func onStateChange(viewModel: LabelModel) { + textAlignment = viewModel.textPosition.textAlignment + textColor = getTextColor(for: viewModel.disabled, surface: viewModel.surface) + + if let vdsFont = try? FontStyle.font(for: viewModel.fontCategory, fontWeight: viewModel.fontWeight, fontSize: viewModel.fontSize) { + font = vdsFont + } else { + font = FontStyle.RegularBodyLarge.font + } + + if let attributes = viewModel.attributes, let text = model.text, let font = font, let textColor = textColor { + let startingAttributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: textColor] + let mutableText = NSMutableAttributedString(string: text, attributes: startingAttributes) + for attribute in attributes { + attribute.setAttribute(on: mutableText) + if let actionable = attribute as? LabelAttributeActionable{ + actions.append(actionable) + } + } + attributedText = mutableText + } else { + text = viewModel.text + } + } + //-------------------------------------------------- + // MARK: - Private Functions + //-------------------------------------------------- private func getTextColor(for disabled: Bool, surface: Surface) -> UIColor { if disabled { if surface == .light { @@ -89,164 +138,42 @@ open class Label: UILabel, ModelHandlerable, Initable, Resettable { } } } - - //functions - private func onStateChange(viewModel: LabelModel) { - textAlignment = viewModel.textPosition.textAlignment - textColor = getTextColor(for: viewModel.disabled, surface: viewModel.surface) - - if let vdsFont = try? FontStyle.font(for: viewModel.fontCategory, fontWeight: viewModel.fontWeight, fontSize: viewModel.fontSize) { - font = vdsFont - } else { - font = FontStyle.RegularBodyLarge.font - } - if let attributes = viewModel.attributes, let text = model.text, let font = font, let textColor = textColor { - let startingAttributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: textColor] - let mutableText = NSMutableAttributedString(string: text, attributes: startingAttributes) - var hasActionable = false - for attribute in attributes { - attribute.setAttribute(on: mutableText) - if let attributeActionable = attribute as? LabelAttributeActionable { - hasActionable = true - setTextLinkState(range: attributeActionable.range) { - attributeActionable.action() + //-------------------------------------------------- + // MARK: - Actionable + //-------------------------------------------------- + private var tapGesture: UITapGestureRecognizer? + private var actions: [LabelAttributeActionable] = [] { + didSet { + isUserInteractionEnabled = !actions.isEmpty + if actions.isEmpty { + if let tapGesture = tapGesture { + removeGestureRecognizer(tapGesture) + } + } else { + //add tap gesture + if tapGesture == nil { + let singleTap = UITapGestureRecognizer(target: self, action: #selector(textLinkTapped)) + singleTap.numberOfTapsRequired = 1 + addGestureRecognizer(singleTap) + tapGesture = singleTap + } + if actions.count > 1 { + actions.sort { first, second in + return first.range.location < second.range.location } } } - - if hasActionable { - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(textLinkTapped)) - tapGesture.numberOfTapsRequired = 1 - addGestureRecognizer(tapGesture) - } - - attributedText = mutableText - } else { - text = viewModel.text } - - } - - //------------------------------------------------------ - // MARK: - Multi-Action Text - //------------------------------------------------------ - - /// Data store of the tappable ranges of the text. - public var clauses: [ActionableClause] = [] { - didSet { - isUserInteractionEnabled = !clauses.isEmpty - if clauses.count > 1 { - clauses.sort { first, second in - return first.range.location < second.range.location - } - } - } - } - - /// Used for tappable links in the text. - public struct ActionableClause { - public var range: NSRange - public var actionBlock: Blocks.ActionBlock - public var accessibilityID: Int = 0 - - public func performAction() { - actionBlock() - } - - public init(range: NSRange, actionBlock: @escaping Blocks.ActionBlock, accessibilityID: Int = 0) { - self.range = range - self.actionBlock = actionBlock - self.accessibilityID = accessibilityID - } - } - - private func setTextLinkState(range: NSRange, actionBlock: @escaping Blocks.ActionBlock) { - clauses.append(ActionableClause(range: range, actionBlock: actionBlock, accessibilityID: -1)) } @objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) { - - for clause in clauses { + for actionable in actions { // This determines if we tapped on the desired range of text. - if gesture.didTapAttributedTextInLabel(self, inRange: clause.range) { - clause.performAction() + if gesture.didTapAttributedTextInLabel(self, inRange: actionable.range) { + actionable.action() return } } } - - /** - 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() -> (NSTextContainer, NSLayoutManager, 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) - } - - //Modelable - public func set(with model: LabelModel) { - self.model = model - } -} - -// MARK: - -extension UITapGestureRecognizer { - - func didTapAttributedTextInLabel(_ label: Label, inRange targetRange: NSRange) -> Bool { - - guard let abstractContainer = label.abstractTextContainer() else { return false } - let textContainer = abstractContainer.0 - let layoutManager = abstractContainer.1 - - let tapLocation = location(in: label) - let indexOfGlyph = layoutManager.glyphIndex(for: tapLocation, in: textContainer) - let intrinsicWidth = label.intrinsicContentSize.width - - // Assert that tapped occured within acceptable bounds based on alignment. - switch label.textAlignment { - case .right: - if tapLocation.x < label.bounds.width - intrinsicWidth { - return false - } - case .center: - let halfBounds = label.bounds.width / 2 - let halfIntrinsicWidth = intrinsicWidth / 2 - - if tapLocation.x > halfBounds + halfIntrinsicWidth { - return false - } else if tapLocation.x < halfBounds - halfIntrinsicWidth { - return false - } - default: // Left align - if tapLocation.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(tapLocation) && NSLocationInRange(indexOfGlyph, targetRange) - } } diff --git a/VDS/Extensions/UILabel.swift b/VDS/Extensions/UILabel.swift new file mode 100644 index 00000000..2911eacd --- /dev/null +++ b/VDS/Extensions/UILabel.swift @@ -0,0 +1,41 @@ +// +// UILabel.swift +// VDS +// +// Created by Matt Bruce on 8/4/22. +// + +import Foundation +import UIKit + +extension UILabel { + /** + 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() -> (NSTextContainer, NSLayoutManager, 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) + } +} diff --git a/VDS/Extensions/UITapGestureRecognizer.swift b/VDS/Extensions/UITapGestureRecognizer.swift new file mode 100644 index 00000000..8cdf8d08 --- /dev/null +++ b/VDS/Extensions/UITapGestureRecognizer.swift @@ -0,0 +1,47 @@ +// +// UITapGesture.swift +// VDS +// +// Created by Matt Bruce on 8/4/22. +// + +import Foundation +import UIKit + +extension UITapGestureRecognizer { + + public func didTapAttributedTextInLabel(_ label: Label, inRange targetRange: NSRange) -> Bool { + + guard let abstractContainer = label.abstractTextContainer() else { return false } + let textContainer = abstractContainer.0 + let layoutManager = abstractContainer.1 + + let tapLocation = location(in: label) + let indexOfGlyph = layoutManager.glyphIndex(for: tapLocation, in: textContainer) + let intrinsicWidth = label.intrinsicContentSize.width + + // Assert that tapped occured within acceptable bounds based on alignment. + switch label.textAlignment { + case .right: + if tapLocation.x < label.bounds.width - intrinsicWidth { + return false + } + case .center: + let halfBounds = label.bounds.width / 2 + let halfIntrinsicWidth = intrinsicWidth / 2 + + if tapLocation.x > halfBounds + halfIntrinsicWidth { + return false + } else if tapLocation.x < halfBounds - halfIntrinsicWidth { + return false + } + default: // Left align + if tapLocation.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(tapLocation) && NSLocationInRange(indexOfGlyph, targetRange) + } +}