diff --git a/UILabel_Typography_Extensions.xcodeproj/project.pbxproj b/UILabel_Typography_Extensions.xcodeproj/project.pbxproj index efa9e8d..3ee8073 100644 --- a/UILabel_Typography_Extensions.xcodeproj/project.pbxproj +++ b/UILabel_Typography_Extensions.xcodeproj/project.pbxproj @@ -32,12 +32,12 @@ 55F236BB25912C430007BC69 /* Withable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F236BA25912C430007BC69 /* Withable.swift */; }; 55F236BF25912C710007BC69 /* UIKit+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F236BD25912C700007BC69 /* UIKit+Extensions.swift */; }; 55F236CA25912E690007BC69 /* PreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F236C925912E690007BC69 /* PreviewView.swift */; }; - 55F7E33225A03D8300E7F48B /* UILabel+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E33125A03D8300E7F48B /* UILabel+Observer.swift */; }; - 55F7E33E25A051FE00E7F48B /* UILabel+Grid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E33825A03FE500E7F48B /* UILabel+Grid.swift */; }; + 55F7E33225A03D8300E7F48B /* Typography+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E33125A03D8300E7F48B /* Typography+Observer.swift */; }; + 55F7E33E25A051FE00E7F48B /* Typography+Grid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E33825A03FE500E7F48B /* Typography+Grid.swift */; }; 55F7E34125A0A91E00E7F48B /* LoremIpsumViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E34025A0A91E00E7F48B /* LoremIpsumViewController.swift */; }; 55F7E34425A0AA5E00E7F48B /* GlyphViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E34325A0AA5E00E7F48B /* GlyphViewController.swift */; }; 55F7E34825A0AD7F00E7F48B /* AttributesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E34725A0AD7F00E7F48B /* AttributesViewController.swift */; }; - 55F7E34B25A0ADFA00E7F48B /* UILabel+Typograpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E34A25A0ADFA00E7F48B /* UILabel+Typograpy.swift */; }; + 55F7E34B25A0ADFA00E7F48B /* Views+Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7E34A25A0ADFA00E7F48B /* Views+Typography.swift */; }; 55F7F0A2259A6F03001BEF90 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F7F0A1259A6F03001BEF90 /* Typography.swift */; }; 55FE48F027DFE62800DCD799 /* UlLabel_Line_Height_Letter_Spacing_Extension_UIKit.png in Resources */ = {isa = PBXBuildFile; fileRef = 55FE48EF27DFE62800DCD799 /* UlLabel_Line_Height_Letter_Spacing_Extension_UIKit.png */; }; /* End PBXBuildFile section */ @@ -72,13 +72,13 @@ 55F236BD25912C700007BC69 /* UIKit+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIKit+Extensions.swift"; sourceTree = ""; }; 55F236C925912E690007BC69 /* PreviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewView.swift; sourceTree = ""; }; 55F236CE259135680007BC69 /* UILabel+Typography 1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Typography 1.swift"; sourceTree = ""; }; - 55F7E33125A03D8300E7F48B /* UILabel+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Observer.swift"; sourceTree = ""; }; + 55F7E33125A03D8300E7F48B /* Typography+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Typography+Observer.swift"; sourceTree = ""; }; 55F7E33425A03F9600E7F48B /* UILabel+Typography 2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Typography 2.swift"; sourceTree = ""; }; - 55F7E33825A03FE500E7F48B /* UILabel+Grid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Grid.swift"; sourceTree = ""; }; + 55F7E33825A03FE500E7F48B /* Typography+Grid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Typography+Grid.swift"; sourceTree = ""; }; 55F7E34025A0A91E00E7F48B /* LoremIpsumViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoremIpsumViewController.swift; sourceTree = ""; }; 55F7E34325A0AA5E00E7F48B /* GlyphViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlyphViewController.swift; sourceTree = ""; }; 55F7E34725A0AD7F00E7F48B /* AttributesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributesViewController.swift; sourceTree = ""; }; - 55F7E34A25A0ADFA00E7F48B /* UILabel+Typograpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Typograpy.swift"; sourceTree = ""; }; + 55F7E34A25A0ADFA00E7F48B /* Views+Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views+Typography.swift"; sourceTree = ""; }; 55F7F0A1259A6F03001BEF90 /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; 55FE48EF27DFE62800DCD799 /* UlLabel_Line_Height_Letter_Spacing_Extension_UIKit.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UlLabel_Line_Height_Letter_Spacing_Extension_UIKit.png; sourceTree = ""; }; /* End PBXFileReference section */ @@ -98,9 +98,9 @@ isa = PBXGroup; children = ( 55F7F0A1259A6F03001BEF90 /* Typography.swift */, - 55F7E34A25A0ADFA00E7F48B /* UILabel+Typograpy.swift */, - 55F7E33125A03D8300E7F48B /* UILabel+Observer.swift */, - 55F7E33825A03FE500E7F48B /* UILabel+Grid.swift */, + 55F7E33125A03D8300E7F48B /* Typography+Observer.swift */, + 55F7E33825A03FE500E7F48B /* Typography+Grid.swift */, + 55F7E34A25A0ADFA00E7F48B /* Views+Typography.swift */, 559795E425A259E400A757A3 /* UIColor+Styles.swift */, 551B425E259BCE40001310A9 /* UIFont+Extensions.swift */, 55A3EECD27D7F24C0002193A /* UIFont+Inspection.swift */, @@ -277,7 +277,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 55F7E34B25A0ADFA00E7F48B /* UILabel+Typograpy.swift in Sources */, + 55F7E34B25A0ADFA00E7F48B /* Views+Typography.swift in Sources */, 55F236A225912BBE0007BC69 /* AppDelegate.swift in Sources */, 55F7F0A2259A6F03001BEF90 /* Typography.swift in Sources */, 55F236CA25912E690007BC69 /* PreviewView.swift in Sources */, @@ -285,7 +285,7 @@ 55722E1F27DB63BC002DE7DC /* PlanetViewController.swift in Sources */, 559FB34E25A4B1A200CE795D /* LineHeightViewController.swift in Sources */, 5501DB9727D8240E009E0FDA /* UIButton+Styles.swift in Sources */, - 55F7E33225A03D8300E7F48B /* UILabel+Observer.swift in Sources */, + 55F7E33225A03D8300E7F48B /* Typography+Observer.swift in Sources */, 55F236BB25912C430007BC69 /* Withable.swift in Sources */, 55F7E34425A0AA5E00E7F48B /* GlyphViewController.swift in Sources */, 55F236BF25912C710007BC69 /* UIKit+Extensions.swift in Sources */, @@ -301,7 +301,7 @@ 5558DA4627DA1FA900A4A178 /* MockupViewController.swift in Sources */, 55F7E34825A0AD7F00E7F48B /* AttributesViewController.swift in Sources */, 55F236B725912BF20007BC69 /* MenuViewController.swift in Sources */, - 55F7E33E25A051FE00E7F48B /* UILabel+Grid.swift in Sources */, + 55F7E33E25A051FE00E7F48B /* Typography+Grid.swift in Sources */, 5501DB9127D81E70009E0FDA /* EmptyViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/UILabel_Typography_Extensions/Typography/UILabel+Grid.swift b/UILabel_Typography_Extensions/Typography/Typography+Grid.swift similarity index 73% rename from UILabel_Typography_Extensions/Typography/UILabel+Grid.swift rename to UILabel_Typography_Extensions/Typography/Typography+Grid.swift index 28185ec..aba9b06 100644 --- a/UILabel_Typography_Extensions/Typography/UILabel+Grid.swift +++ b/UILabel_Typography_Extensions/Typography/Typography+Grid.swift @@ -26,14 +26,38 @@ import UIKit +fileprivate enum Keys { + static var showGrid: UInt8 = 0 + fileprivate static let gridLayerName = "Grid" + fileprivate static let compositingFilter = "multiplyBlendMode" +} + +extension UITextView { + override open func layoutSubviews() { + super.layoutSubviews() + removeGridLayerIfNeeded() + addGridLayerIfNeeded() + } +} extension UILabel { - - fileprivate struct Keys { - - static var showGrid: UInt8 = 0 - } - + override open func layoutSubviews() { + super.layoutSubviews() + removeGridLayerIfNeeded() + addGridLayerIfNeeded() + } +} + +extension UITextField { + override open func layoutSubviews() { + super.layoutSubviews() + removeGridLayerIfNeeded() + addGridLayerIfNeeded() + } +} + +extension TypographyExtensions where Self: UIView { + public var showGrid: Bool { get { (objc_getAssociatedObject(self, &Keys.showGrid) as? NSNumber)?.boolValue ?? false @@ -43,20 +67,10 @@ extension UILabel { } } - fileprivate static let gridLayerName = "Grid" - - fileprivate static let compositingFilter = "multiplyBlendMode" - fileprivate var gridLayer: CALayer? { - layer.sublayers?.first(where: { $0.name == Self.gridLayerName }) + layer.sublayers?.first(where: { $0.name == Keys.gridLayerName }) } - - override open func layoutSubviews() { - super.layoutSubviews() - removeGridLayerIfNeeded() - addGridLayerIfNeeded() - } - + fileprivate func addGridLayerIfNeeded() { // Only if needed. @@ -66,18 +80,18 @@ extension UILabel { // Add. let gridLayer = CALayer() - gridLayer.name = Self.gridLayerName - gridLayer.compositingFilter = Self.compositingFilter + gridLayer.name = Keys.gridLayerName + gridLayer.compositingFilter = Keys.compositingFilter layer.addSublayer(gridLayer) // Draw until fits. let baselineOffset = abs(self.baselineOffset) var cursor = CGFloat.zero cursor += baselineOffset - while cursor + font.lineHeight - 2 < frame.size.height { + while cursor + optionalFont!.lineHeight - 2 < frame.size.height { addGridLayers(to: gridLayer, offset: cursor) - cursor += font.lineHeight - cursor += font.leading + cursor += optionalFont!.lineHeight + cursor += optionalFont!.leading cursor += baselineOffset cursor += baselineOffset } @@ -92,11 +106,11 @@ extension UILabel { fileprivate func addGridLayers(to gridLayer: CALayer, offset: CGFloat) { // Top down. - let baseline = font.ascender + offset - let descender = baseline - font.descender - let xHeight = baseline - font.xHeight - let capHeight = baseline - font.capHeight - let ascender = baseline - font.ascender + let baseline = optionalFont!.ascender + offset + let descender = baseline - optionalFont!.descender + let xHeight = baseline - optionalFont!.xHeight + let capHeight = baseline - optionalFont!.capHeight + let ascender = baseline - optionalFont!.ascender let baselineOffset = abs(self.baselineOffset) let top = ascender - baselineOffset @@ -144,19 +158,19 @@ extension UILabel { layer.strokeColor = stroke?.cgColor layer.lineDashPattern = dash layer.lineCap = .round - layer.compositingFilter = Self.compositingFilter + layer.compositingFilter = Keys.compositingFilter return layer } } -fileprivate extension UILabel { +fileprivate extension TypographyExtensions { var baselineOffset: CGFloat { guard let lineHeight = paragraphStyle?.maximumLineHeight, lineHeight != 0.0 else { return 0.0 } - return (lineHeight - font.lineHeight) / 2.0 + return (lineHeight - optionalFont!.lineHeight) / 2.0 } } diff --git a/UILabel_Typography_Extensions/Typography/UILabel+Observer.swift b/UILabel_Typography_Extensions/Typography/Typography+Observer.swift similarity index 94% rename from UILabel_Typography_Extensions/Typography/UILabel+Observer.swift rename to UILabel_Typography_Extensions/Typography/Typography+Observer.swift index 711dd05..3d42684 100644 --- a/UILabel_Typography_Extensions/Typography/UILabel+Observer.swift +++ b/UILabel_Typography_Extensions/Typography/Typography+Observer.swift @@ -26,16 +26,15 @@ import UIKit +fileprivate enum Keys { + static var observer: UInt8 = 0 +} -extension UILabel { +extension TypographyExtensions { - typealias TextObserver = Observer + typealias TextObserver = Observer typealias TextChangeAction = (_ oldValue: String?, _ newValue: String?) -> Void - - fileprivate struct Keys { - static var observer: UInt8 = 0 - } - + fileprivate var observer: TextObserver? { get { objc_getAssociatedObject(self, &Keys.observer) as? TextObserver diff --git a/UILabel_Typography_Extensions/Typography/Typography.swift b/UILabel_Typography_Extensions/Typography/Typography.swift index 2d7bf1c..837b78c 100644 --- a/UILabel_Typography_Extensions/Typography/Typography.swift +++ b/UILabel_Typography_Extensions/Typography/Typography.swift @@ -27,8 +27,16 @@ import UIKit +@objc public protocol KeyValueObservableTextContainer { + @objc dynamic var text: String? { get set } +} + +public protocol TextPropertiesHolder: AnyObject { + var paragraphStyle: NSParagraphStyle? { get } + var textAlignment: NSTextAlignment { get set } +} -public protocol TypographyExtensions: UILabel { +public protocol TypographyExtensions: NSObject, KeyValueObservableTextContainer, TextPropertiesHolder { /// Set the line height (points) on the underlying `NSAttributedString` (with /// vertical centering). The provided `lineHeight` value is set to the @@ -72,8 +80,239 @@ public protocol TypographyExtensions: UILabel { /// The trailing image for the reciever (setter lays out and sets `attributedText`). var trailingImage: Typography.Image? { get set } + + /// Just a wrapper for `UIFont!` or `UIFont?` + /// Fix for: https://forums.swift.org/t/protocol-conformance-and-implicitly-unwrapped-optionals/12310 + var optionalFont: UIFont? { get set } + + /// Just a wrapper for `NSAttributedString!` or `NSAttributedString?` + /// Fix for: https://forums.swift.org/t/protocol-conformance-and-implicitly-unwrapped-optionals/12310 + var optionalAttributedText: NSAttributedString? { get set } +} + +extension TypographyExtensions { + + public var paragraphStyle: NSParagraphStyle? { + getAttribute(.paragraphStyle) + } + + public var lineHeight: CGFloat? { + get { paragraphStyle?.maximumLineHeight } + set { + let lineHeight = newValue ?? optionalFont!.lineHeight + let adjustment = lineHeight > optionalFont!.lineHeight ? 2.0 : 1.0 + let baselineOffset = (lineHeight - optionalFont!.lineHeight) / 2.0 / adjustment + addAttribute(.baselineOffset, value: baselineOffset) + addAttribute( + .paragraphStyle, + value: (paragraphStyle ?? NSParagraphStyle()) + .mutable + .withProperty(lineHeight, for: \.minimumLineHeight) + .withProperty(lineHeight, for: \.maximumLineHeight) + ) + setupAttributeCacheIfNeeded() + } + } + + func setupAttributeCacheIfNeeded() { + onTextChange { [unowned self] oldText, newText in + + // Apply cached attributes (if any) in case text have just changed from empty. + if oldText.count == 0, + newText.count > 0, + let newText = newText { + let alignment = textAlignment + self.optionalAttributedText = NSAttributedString(string: newText, attributes: cachedAttributes) + self.textAlignment = alignment + } + + // Update attributed string layout due to (unknown) UIKit internals. + _ = self.optionalAttributedText + } + } + + public var letterSpacing: CGFloat? { + get { getAttribute(.kern) } + set { + setAttribute(.kern, value: newValue) + setupAttributeCacheIfNeeded() + } + } + + public var underline: NSUnderlineStyle? { + get { getAttribute(.underlineStyle) } + set { + setAttribute(.underlineStyle, value: newValue) + setupAttributeCacheIfNeeded() + } + } + + public var strikethrough: NSUnderlineStyle? { + get { getAttribute(.strikethroughStyle) } + set { + setAttribute(.strikethroughStyle, value: newValue) + setupAttributeCacheIfNeeded() + } + } + + public var leadingImage: Typography.Image? { + get { nil } + set { } + } + + public var trailingImage: Typography.Image? { + get { nil } + set { } + } +} + + +// MARK: Attributes (and caching) + +fileprivate extension NSAttributedString { + + var entireRange: NSRange { + NSRange(location: 0, length: self.length) + } + + func stringByAddingAttribute(_ key: NSAttributedString.Key, value: Any) -> NSAttributedString { + let changedString = NSMutableAttributedString(attributedString: self) + changedString.addAttribute(key, value: value, range: self.entireRange) + return changedString + } + + func stringByRemovingAttribute(_ key: NSAttributedString.Key) -> NSAttributedString { + let changedString = NSMutableAttributedString(attributedString: self) + changedString.removeAttribute(key, range: self.entireRange) + return changedString + } +} + +fileprivate enum Keys { + static var cache: UInt8 = 0 } +fileprivate extension TypographyExtensions { + + /// An attributed string property to cache typography even when the label text is empty. + var cache: NSAttributedString { + get { + objc_getAssociatedObject(self, &Keys.cache) as? NSAttributedString ?? NSAttributedString(string: "Placeholder") + } + set { + objc_setAssociatedObject(self, &Keys.cache, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Attributes of `attributedText` (if any). + var attributes: [NSAttributedString.Key: Any]? { + get { + if let attributedText = optionalAttributedText, + attributedText.length > 0 { + return attributedText.attributes(at: 0, effectiveRange: nil) + } else { + return nil + } + } + } + + /// Attributes of `cache`. + var cachedAttributes: [NSAttributedString.Key: Any] { + cache.attributes(at: 0, effectiveRange: nil) + } + + func addAttribute(_ key: NSAttributedString.Key, value: Any) { + optionalAttributedText = optionalAttributedText?.stringByAddingAttribute(key, value: value) + cache = cache.stringByAddingAttribute(key, value: value) + } + + func removeAttribute(_ key: NSAttributedString.Key) { + optionalAttributedText = optionalAttributedText?.stringByRemovingAttribute(key) + cache = cache.stringByRemovingAttribute(key) + } +} + + +fileprivate extension TypographyExtensions { + + /// Get attribute for the given key (if any). + func getAttribute( + _ key: NSAttributedString.Key + ) -> AttributeType? where AttributeType: Any { + return (attributes ?? cachedAttributes)[key] as? AttributeType + } + + /// Get `OptionSet` attribute for the given key (if any). + func getAttribute( + _ key: NSAttributedString.Key + ) -> AttributeType? where AttributeType: OptionSet { + if let attribute = (attributes ?? cachedAttributes)[key] as? AttributeType.RawValue { + return .init(rawValue: attribute) + } else { + return nil + } + } + + /// Add (or remove) attribute for the given key (if any). + func setAttribute( + _ key: NSAttributedString.Key, + value: AttributeType? + ) where AttributeType: Any { + if let value = value { + addAttribute(key, value: value) + } else { + removeAttribute(key) + } + } + + /// Add (or remove) `OptionSet` attribute for the given key (if any). + func setAttribute( + _ key: NSAttributedString.Key, + value: AttributeType? + ) where AttributeType: OptionSet { + if let value = value { + addAttribute(key, value: value.rawValue) + } else { + removeAttribute(key) + } + } +} + + +// MARK: Paragraph Style + +fileprivate extension NSParagraphStyle { + + var mutable: NSMutableParagraphStyle { + let mutable = NSMutableParagraphStyle() + mutable.setParagraphStyle(self) + return mutable + } +} + + +fileprivate extension NSMutableParagraphStyle { + + func withProperty( + _ value: ValueType, + for keyPath: ReferenceWritableKeyPath + ) -> NSMutableParagraphStyle { + self[keyPath: keyPath] = value + return self + } +} + +fileprivate extension Optional where Wrapped == String { + + var count: Int { + switch self { + case .none: + return 0 + case .some(let wrapped): + return wrapped.count + } + } +} public class Typography { diff --git a/UILabel_Typography_Extensions/Typography/UILabel+Typograpy.swift b/UILabel_Typography_Extensions/Typography/UILabel+Typograpy.swift deleted file mode 100644 index d7253e4..0000000 --- a/UILabel_Typography_Extensions/Typography/UILabel+Typograpy.swift +++ /dev/null @@ -1,255 +0,0 @@ -// -// UILabel+Typograpy.swift -// UILabel_Typography_Extensions -// -// Copyright © 2020. Geri Borbás. All rights reserved. -// https://twitter.com/Geri_Borbas -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import UIKit -import SwiftUI - - -extension Optional where Wrapped == String { - - var count: Int { - switch self { - case .none: - return 0 - case .some(let wrapped): - return wrapped.count - } - } -} - - -extension UILabel: TypographyExtensions { - - var paragraphStyle: NSParagraphStyle? { - getAttribute(.paragraphStyle) - } - - public var lineHeight: CGFloat? { - get { paragraphStyle?.maximumLineHeight } - set { - let lineHeight = newValue ?? font.lineHeight - let adjustment = lineHeight > font.lineHeight ? 2.0 : 1.0 - let baselineOffset = (lineHeight - font.lineHeight) / 2.0 / adjustment - addAttribute(.baselineOffset, value: baselineOffset) - addAttribute( - .paragraphStyle, - value: (paragraphStyle ?? NSParagraphStyle()) - .mutable - .withProperty(lineHeight, for: \.minimumLineHeight) - .withProperty(lineHeight, for: \.maximumLineHeight) - ) - setupAttributeCacheIfNeeded() - } - } - - func setupAttributeCacheIfNeeded() { - onTextChange { [unowned self] oldText, newText in - - // Apply cached attributes (if any) in case text have just changed from empty. - if oldText.count == 0, - newText.count > 0, - let newText = newText { - let alignment = textAlignment - self.attributedText = NSAttributedString(string: newText, attributes: cachedAttributes) - self.textAlignment = alignment - } - - // Update attributed string layout due to (unknown) UIKit internals. - _ = self.attributedText - } - } - - public var letterSpacing: CGFloat? { - get { getAttribute(.kern) } - set { - setAttribute(.kern, value: newValue) - setupAttributeCacheIfNeeded() - } - } - - public var underline: NSUnderlineStyle? { - get { getAttribute(.underlineStyle) } - set { - setAttribute(.underlineStyle, value: newValue) - setupAttributeCacheIfNeeded() - } - } - - public var strikethrough: NSUnderlineStyle? { - get { getAttribute(.strikethroughStyle) } - set { - setAttribute(.strikethroughStyle, value: newValue) - setupAttributeCacheIfNeeded() - } - } - - public var leadingImage: Typography.Image? { - get { nil } - set { } - } - - public var trailingImage: Typography.Image? { - get { nil } - set { } - } -} - - -// MARK: Attributes (and caching) - -fileprivate extension NSAttributedString { - - var entireRange: NSRange { - NSRange(location: 0, length: self.length) - } - - func stringByAddingAttribute(_ key: NSAttributedString.Key, value: Any) -> NSAttributedString { - let changedString = NSMutableAttributedString(attributedString: self) - changedString.addAttribute(key, value: value, range: self.entireRange) - return changedString - } - - func stringByRemovingAttribute(_ key: NSAttributedString.Key) -> NSAttributedString { - let changedString = NSMutableAttributedString(attributedString: self) - changedString.removeAttribute(key, range: self.entireRange) - return changedString - } -} - - -fileprivate extension UILabel { - - struct Keys { - static var cache: UInt8 = 0 - } - - /// An attributed string property to cache typography even when the label text is empty. - var cache: NSAttributedString { - get { - objc_getAssociatedObject(self, &Keys.cache) as? NSAttributedString ?? NSAttributedString(string: "Placeholder") - } - set { - objc_setAssociatedObject(self, &Keys.cache, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - /// Attributes of `attributedText` (if any). - var attributes: [NSAttributedString.Key: Any]? { - get { - if let attributedText = attributedText, - attributedText.length > 0 { - return attributedText.attributes(at: 0, effectiveRange: nil) - } else { - return nil - } - } - } - - /// Attributes of `cache`. - var cachedAttributes: [NSAttributedString.Key: Any] { - cache.attributes(at: 0, effectiveRange: nil) - } - - func addAttribute(_ key: NSAttributedString.Key, value: Any) { - attributedText = attributedText?.stringByAddingAttribute(key, value: value) - cache = cache.stringByAddingAttribute(key, value: value) - } - - func removeAttribute(_ key: NSAttributedString.Key) { - attributedText = attributedText?.stringByRemovingAttribute(key) - cache = cache.stringByRemovingAttribute(key) - } -} - - -fileprivate extension UILabel { - - /// Get attribute for the given key (if any). - func getAttribute( - _ key: NSAttributedString.Key - ) -> AttributeType? where AttributeType: Any { - return (attributes ?? cachedAttributes)[key] as? AttributeType - } - - /// Get `OptionSet` attribute for the given key (if any). - func getAttribute( - _ key: NSAttributedString.Key - ) -> AttributeType? where AttributeType: OptionSet { - if let attribute = (attributes ?? cachedAttributes)[key] as? AttributeType.RawValue { - return .init(rawValue: attribute) - } else { - return nil - } - } - - /// Add (or remove) attribute for the given key (if any). - func setAttribute( - _ key: NSAttributedString.Key, - value: AttributeType? - ) where AttributeType: Any { - if let value = value { - addAttribute(key, value: value) - } else { - removeAttribute(key) - } - } - - /// Add (or remove) `OptionSet` attribute for the given key (if any). - func setAttribute( - _ key: NSAttributedString.Key, - value: AttributeType? - ) where AttributeType: OptionSet { - if let value = value { - addAttribute(key, value: value.rawValue) - } else { - removeAttribute(key) - } - } -} - - -// MARK: Paragraph Style - -fileprivate extension NSParagraphStyle { - - var mutable: NSMutableParagraphStyle { - let mutable = NSMutableParagraphStyle() - mutable.setParagraphStyle(self) - return mutable - } -} - - -fileprivate extension NSMutableParagraphStyle { - - func withProperty( - _ value: ValueType, - for keyPath: ReferenceWritableKeyPath - ) -> NSMutableParagraphStyle { - self[keyPath: keyPath] = value - return self - } -} diff --git a/UILabel_Typography_Extensions/Typography/Views+Typography.swift b/UILabel_Typography_Extensions/Typography/Views+Typography.swift new file mode 100644 index 0000000..622de6c --- /dev/null +++ b/UILabel_Typography_Extensions/Typography/Views+Typography.swift @@ -0,0 +1,67 @@ +// +// UILabel+Typograpy.swift +// UILabel_Typography_Extensions +// +// Copyright © 2020. Geri Borbás. All rights reserved. +// https://twitter.com/Geri_Borbas +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import UIKit +import SwiftUI + + +extension UILabel: TypographyExtensions { + public var optionalFont: UIFont? { + get { font } + set { font = newValue } + } + + public var optionalAttributedText: NSAttributedString? { + get { attributedText } + set { attributedText = newValue } + } +} + +// Warning due to: https://forums.swift.org/t/optional-conformance-warnings-with-protocols/3530/2 +extension UITextView: TypographyExtensions { + public var optionalFont: UIFont? { + get { font } + set { font = newValue } + } + + public var optionalAttributedText: NSAttributedString? { + get { attributedText } + set { attributedText = newValue } + } +} + +extension UITextField: TypographyExtensions { + public var optionalFont: UIFont? { + get { font } + set { font = newValue } + } + + public var optionalAttributedText: NSAttributedString? { + get { attributedText } + set { attributedText = newValue } + } +} +