diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index 55652d7c..03aa3d87 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ EAB1D2CF28ABEF2B00DAE764 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2CE28ABEF2B00DAE764 /* Typography.swift */; }; EAB1D2E628AE842000DAE764 /* Publisher+Bind.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2E328AE842000DAE764 /* Publisher+Bind.swift */; }; EAB1D2EA28AE84AA00DAE764 /* UIControlPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2E928AE84AA00DAE764 /* UIControlPublisher.swift */; }; + EAC9257D29119B5400091998 /* TextLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC9257C29119B5400091998 /* TextLink.swift */; }; + EAC925832911B35400091998 /* TextLinkCaret.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC925822911B35300091998 /* TextLinkCaret.swift */; }; EAC925842911C63100091998 /* Colorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA5EEDF28F49DB3003B3210 /* Colorable.swift */; }; EAC9258C2911C9DE00091998 /* TextEntryField.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC925872911C9DE00091998 /* TextEntryField.swift */; }; EAC9258F2911C9DE00091998 /* EntryField.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC9258B2911C9DE00091998 /* EntryField.swift */; }; @@ -141,6 +143,8 @@ EAB1D2CE28ABEF2B00DAE764 /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; EAB1D2E328AE842000DAE764 /* Publisher+Bind.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+Bind.swift"; sourceTree = ""; }; EAB1D2E928AE84AA00DAE764 /* UIControlPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControlPublisher.swift; sourceTree = ""; }; + EAC9257C29119B5400091998 /* TextLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLink.swift; sourceTree = ""; }; + EAC925822911B35300091998 /* TextLinkCaret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLinkCaret.swift; sourceTree = ""; }; EAC925872911C9DE00091998 /* TextEntryField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextEntryField.swift; sourceTree = ""; }; EAC9258B2911C9DE00091998 /* EntryField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EntryField.swift; sourceTree = ""; }; EAD8D2C028BFDE8B006EB6A6 /* UIGestureRecognizer+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIGestureRecognizer+Publisher.swift"; sourceTree = ""; }; @@ -193,6 +197,16 @@ path = Button; sourceTree = ""; }; + EA0FC2BE2912D18200DF80B4 /* Buttons */ = { + isa = PBXGroup; + children = ( + 5FC35BE128D513EB004EBEAC /* Button */, + EAC9257E29119B5D00091998 /* TextLink */, + EAC925812911B34300091998 /* TextLinkCaret */, + ); + path = Buttons; + sourceTree = ""; + }; EA1F265F28B945070033E859 /* RadioSwatch */ = { isa = PBXGroup; children = ( @@ -261,7 +275,7 @@ isa = PBXGroup; children = ( EA4DB2FE28DCBC1900103EE3 /* Badge */, - 5FC35BE128D513EB004EBEAC /* Button */, + EA0FC2BE2912D18200DF80B4 /* Buttons */, EAF7F092289985E200B287F5 /* Checkbox */, EA3362412892EF700071C351 /* Label */, EA89200B28B530F0006B9984 /* RadioBox */, @@ -411,6 +425,22 @@ path = Publishers; sourceTree = ""; }; + EAC9257E29119B5D00091998 /* TextLink */ = { + isa = PBXGroup; + children = ( + EAC9257C29119B5400091998 /* TextLink.swift */, + ); + path = TextLink; + sourceTree = ""; + }; + EAC925812911B34300091998 /* TextLinkCaret */ = { + isa = PBXGroup; + children = ( + EAC925822911B35300091998 /* TextLinkCaret.swift */, + ); + path = TextLinkCaret; + sourceTree = ""; + }; EAC925852911C9DE00091998 /* TextFields */ = { isa = PBXGroup; children = ( @@ -594,6 +624,7 @@ EAC9258C2911C9DE00091998 /* TextEntryField.swift in Sources */, EA3362402892EF6C0071C351 /* Label.swift in Sources */, EAF7F0B3289B1ADC00B287F5 /* ActionLabelAttribute.swift in Sources */, + EAC925832911B35400091998 /* TextLinkCaret.swift in Sources */, EA33622E2891EA3C0071C351 /* DispatchQueue+Once.swift in Sources */, EA4DB2FD28D3D0CA00103EE3 /* AnyEquatable.swift in Sources */, EAA5EEB728ECC03A003B3210 /* ToolTipLabelAttribute.swift in Sources */, @@ -641,6 +672,7 @@ EA3361B8288B2AAA0071C351 /* ViewProtocol.swift in Sources */, EA3361BF288B2EA60071C351 /* Handlerable.swift in Sources */, EA3361A8288B23300071C351 /* UIColor.swift in Sources */, + EAC9257D29119B5400091998 /* TextLink.swift in Sources */, EA1F266628B945070033E859 /* RadioSwatchGroup.swift in Sources */, 5FC35BE328D51405004EBEAC /* Button.swift in Sources */, ); diff --git a/VDS/Components/Button/Button.swift b/VDS/Components/Buttons/Button/Button.swift similarity index 100% rename from VDS/Components/Button/Button.swift rename to VDS/Components/Buttons/Button/Button.swift diff --git a/VDS/Components/Buttons/TextLink/TextLink.swift b/VDS/Components/Buttons/TextLink/TextLink.swift new file mode 100644 index 00000000..b4eedd81 --- /dev/null +++ b/VDS/Components/Buttons/TextLink/TextLink.swift @@ -0,0 +1,103 @@ +// +// TextLink.swift +// VDS +// +// Created by Matt Bruce on 11/1/22. +// + +import Foundation +import UIKit +import VDSColorTokens +import VDSFormControlsTokens +import Combine + +@objc(VDSTextLink) +open class TextLink: Control { + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var heightConstraint: NSLayoutConstraint? + private var label = Label() + + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + open var text: String? { didSet { didChange() } } + + open var size: ButtonSize = .large { didSet { didChange() }} + + private var height: CGFloat { + switch size { + case .large: + return 44 + case .small: + return 32 + } + } + + //-------------------------------------------------- + // 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 override func initialSetup() { + super.initialSetup() + } + + open override func setup() { + super.setup() + + addSubview(label) + + //add tapGesture to self + publisher(for: UITapGestureRecognizer()).sink { [weak self] _ in + self?.sendActions(for: .touchUpInside) + }.store(in: &subscribers) + + //pin stackview to edges + label.topAnchor.constraint(equalTo: topAnchor).isActive = true + label.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + label.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + label.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + + heightConstraint = heightAnchor.constraint(equalToConstant: height) + heightConstraint?.isActive = true + } + + open override func reset() { + super.reset() + size = .large + accessibilityCustomActions = [] + accessibilityTraits = .staticText + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + open override func updateView() { + label.surface = surface + label.disabled = disabled + label.typograpicalStyle = size == .large ? TypographicalStyle.BodyLarge : TypographicalStyle.BodySmall + label.text = text ?? "" + label.attributes = [UnderlineLabelAttribute(location: 0, length: label.text!.count)] + heightConstraint?.constant = height + } + +} diff --git a/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift b/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift new file mode 100644 index 00000000..daed1a42 --- /dev/null +++ b/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift @@ -0,0 +1,304 @@ +// +// TextLinkCaret.swift +// VDS +// +// Created by Matt Bruce on 11/1/22. +// + +import Foundation +import UIKit +import VDSColorTokens +import VDSFormControlsTokens +import Combine + +public enum TextLinkCaretPosition: String, CaseIterable { + case left, right +} + +@objc(VDSTextLinkCaret) +open class TextLinkCaret: Control { + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var heightConstraint: NSLayoutConstraint? + + private var label = Label().with { + $0.typograpicalStyle = TypographicalStyle.BoldBodyLarge + } + + private var caretView = CaretView().with { + $0.size = CaretView.CaretSize.small(.vertical) + $0.lineWidth = 2 + } + + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + open var text: String? { didSet { didChange() } } + + open var iconPosition: TextLinkCaretPosition = .right { didSet { didChange() } } + + private var height: CGFloat { + 44 + } + + //-------------------------------------------------- + // 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 override func initialSetup() { + super.initialSetup() + } + + open override func setup() { + super.setup() + //add tapGesture to self + publisher(for: UITapGestureRecognizer()).sink { [weak self] _ in + self?.sendActions(for: .touchUpInside) + }.store(in: &subscribers) + + //constraints + heightAnchor.constraint(greaterThanOrEqualToConstant: height).isActive = true + + let size = caretView.size!.dimensions() + caretView.frame = .init(x: 0, y: 0, width: size.width, height: size.height) + addSubview(label) + label.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + label.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + label.topAnchor.constraint(equalTo: topAnchor).isActive = true + label.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + } + + //-------------------------------------------------- + // MARK: - Constraints + //-------------------------------------------------- + private var caretLeadingConstraint: NSLayoutConstraint? + private var caretTrailingConstraint: NSLayoutConstraint? + private var labelConstraint: NSLayoutConstraint? + + open override func reset() { + super.reset() + accessibilityCustomActions = [] + accessibilityTraits = .staticText + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + open override func updateView() { + + var updatedText = text ?? "" + + caretView.surface = surface + caretView.disabled = disabled + caretView.direction = iconPosition == .right ? CaretView.Direction.right : CaretView.Direction.left + + let image = caretView.getImage() + let location = iconPosition == .right ? updatedText.count + 1 : 0 + let textColor = label.textColorConfiguration.getColor(self) + let imageAttribute = ImageLabelAttribute(location: location, + length: 1, + image: image, + frame: .init(x: 0, y: 0, width: image.size.width, height: image.size.height), + tintColor: textColor) + label.surface = surface + label.disabled = disabled + label.text = iconPosition == .right ? "\(updatedText) " : " \(updatedText)" + label.attributes = [imageAttribute] + } + +} + +extension UIView { + public func getImage() -> UIImage { + let renderer = UIGraphicsImageRenderer(size: self.bounds.size) + let image = renderer.image { ctx in + self.drawHierarchy(in: self.bounds, afterScreenUpdates: true) + } + return image + } +} + +internal class CaretView: View { + //------------------------------------------------------ + // MARK: - Properties + //------------------------------------------------------ + private var caretPath: UIBezierPath = UIBezierPath() + + public var lineWidth: CGFloat = 1 { didSet{ didChange() } } + + public var direction: Direction = .right { didSet{ didChange() } } + + public var size: CaretSize? { didSet{ didChange() } } + + public var colorConfiguration: 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: - Constraints + //------------------------------------------------------ + + /// Sizes of CaretView are derived from InVision design specs. They are provided for convenience. + public enum CaretSize { + case small(Orientation) + case medium(Orientation) + case large(Orientation) + + /// Orientation based on the longest line of the view. + public enum Orientation { + case vertical + case horizontal + } + + /// Dimensions of container; provided by InVision design. + func dimensions() -> CGSize { + + switch self { + case .small(let o): + return o == .vertical ? CGSize(width: 6.9, height: 10.96) : CGSize(width: 10.96, height: 6.9) + + case .medium(let o): + return o == .vertical ? CGSize(width: 9.9, height: 16.96) : CGSize(width: 16.96, height: 9.9) + + case .large(let o): + return o == .vertical ? CGSize(width: 14.9, height: 24.96) : CGSize(width: 24.96, height: 14.9) + } + } + } + + //------------------------------------------------------ + // MARK: - Initialization + //------------------------------------------------------ + + public override init(frame: CGRect) { + super.init(frame: frame) + } + + public convenience init(lineWidth: CGFloat) { + self.init(frame: .zero) + self.lineWidth = lineWidth + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + fatalError("CaretView xib not supported.") + } + + required public convenience init() { + self.init(frame: .zero) + } + + public convenience init(size: CaretSize){ + let dimensions = size.dimensions() + self.init(frame: .init(x: 0, y: 0, width: dimensions.width, height: dimensions.height)) + self.size = size + } + + //------------------------------------------------------ + // MARK: - Setup + //------------------------------------------------------ + + override open func setup() { + super.setup() + defaultState() + } + + //------------------------------------------------------ + // MARK: - Drawing + //------------------------------------------------------ + + /// The direction the caret will be pointing to. + public enum Direction: Int { + case left + case right + case down + case up + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + + caretPath.removeAllPoints() + caretPath.lineJoinStyle = .miter + caretPath.lineWidth = lineWidth + + let inset = lineWidth / 2 + let halfWidth = frame.size.width / 2 + let halfHeight = frame.size.height / 2 + + switch direction { + case .up: + caretPath.move(to: CGPoint(x: inset, y: frame.size.height - inset)) + caretPath.addLine(to: CGPoint(x: halfWidth, y: inset)) + caretPath.addLine(to: CGPoint(x: frame.size.width, y: frame.size.height)) + + case .right: + caretPath.move(to: CGPoint(x: inset, y: inset)) + caretPath.addLine(to: CGPoint(x: frame.size.width - inset, y: halfHeight)) + caretPath.addLine(to: CGPoint(x: inset, y: frame.size.height - inset)) + + case .down: + caretPath.move(to: CGPoint(x: inset, y: inset)) + caretPath.addLine(to: CGPoint(x: halfWidth, y: frame.size.height - inset)) + caretPath.addLine(to: CGPoint(x: frame.size.width - inset, y: inset)) + + case .left: + caretPath.move(to: CGPoint(x: frame.size.width - inset, y: inset)) + caretPath.addLine(to: CGPoint(x: inset, y: halfHeight)) + caretPath.addLine(to: CGPoint(x: frame.size.width - inset, y: frame.size.height - inset)) + } + + let color = colorConfiguration.getColor(self) + color.setStroke() + caretPath.stroke() + } + + override func updateView() { + setNeedsDisplay() + } + + //------------------------------------------------------ + // MARK: - Methods + //------------------------------------------------------ + public func setLineColor(_ color: UIColor) { + setNeedsDisplay() + } + + public func defaultState() { + isOpaque = false + isHidden = false + backgroundColor = .clear + } + + /// Ensure you have defined a CaretSize with Orientation before calling. + public func setConstraints() { + + guard let dimensions = size?.dimensions() else { return } + + heightAnchor.constraint(equalToConstant: dimensions.height).isActive = true + widthAnchor.constraint(equalToConstant: dimensions.width).isActive = true + } +} diff --git a/VDS/Components/Label/Attributes/ImageLabelAttribute.swift b/VDS/Components/Label/Attributes/ImageLabelAttribute.swift index 9bba6287..ca62f602 100644 --- a/VDS/Components/Label/Attributes/ImageLabelAttribute.swift +++ b/VDS/Components/Label/Attributes/ImageLabelAttribute.swift @@ -12,11 +12,13 @@ public struct ImageLabelAttribute: AttachmentLabelAttributeModel { public enum Error: Swift.Error { case bundleNotFound case imageNotFound(String) + case imageNotSet } public var id = UUID() public var location: Int public var length: Int - public var imageName: String + public var imageName: String? + public var image: UIImage? public var frame: CGRect public var tintColor: UIColor public static func == (lhs: ImageLabelAttribute, rhs: ImageLabelAttribute) -> Bool { @@ -27,18 +29,34 @@ public struct ImageLabelAttribute: AttachmentLabelAttributeModel { return id == equatable.id && range == equatable.range && imageName == equatable.imageName } - public func getAttachment() throws -> NSTextAttachment { - guard let bundle = Bundle(identifier: "com.vzw.vds") else { - throw Error.bundleNotFound - } - - guard let image = UIImage(named: imageName, in: bundle, with: nil) else { - throw Error.imageNotFound(imageName) - } - + private func imageAttachment(image: UIImage) -> NSTextAttachment { let attachment = NSTextAttachment() attachment.image = image.withTintColor(tintColor) attachment.bounds = frame return attachment } + + public func getAttachment() throws -> NSTextAttachment { + + //get a local asset + if let imageName { + guard let bundle = Bundle(identifier: "com.vzw.vds") else { + throw Error.bundleNotFound + } + + guard let image = UIImage(named: imageName, in: bundle, with: nil) else { + throw Error.imageNotFound(imageName) + } + + return imageAttachment(image: image) + + } //get from set image + else if let image { + return imageAttachment(image: image) + + } else { + throw Error.imageNotSet + } + + } }