See more on my blog: https://sunshinejr.com/2020/04/30/different-approach-to-attributed-strings-in-swift/
Different approach to Attributed Strings in Swift.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import class UIKit.UIImage | |
struct AttributedString: ExpressibleByStringInterpolation { | |
enum Attribute: Hashable { | |
enum ImportanceLevel: String, Equatable { | |
case very | |
case enough | |
} | |
case important(ImportanceLevel = .enough) | |
case tappable(id: String = UUID().uuidString, (() -> Void)?) | |
// workarounds for finding items without associated types | |
var isTappable: Bool { | |
switch self { | |
case .tappable: return true | |
default: return false | |
} | |
} | |
var isImportant: Bool { | |
switch self { | |
case .important: return true | |
default: return false | |
} | |
} | |
static func == (lhs: AttributedString.Attribute, rhs: AttributedString.Attribute) -> Bool { | |
switch (lhs, rhs) { | |
case (.important, .important): | |
return true | |
case let (.tappable(id1, _), .tappable(id2, _)): | |
return id1 == id2 | |
default: | |
return false | |
} | |
} | |
func hash(into hasher: inout Hasher) { | |
switch self { | |
case let .important: | |
hasher.combine("important") | |
case let .tappable(id, _): | |
hasher.combine("tappable") | |
hasher.combine(id) | |
} | |
} | |
} | |
enum Part { | |
case text(String, attributes: Set<Attribute> = Set()) | |
case image(UIImage) | |
} | |
var children = [Part]() | |
var attributes = Set<Attribute>() | |
init(string: String, attributes: Set<Attribute>) { | |
children.append(.text(string, attributes: attributes)) | |
} | |
init(image: UIImage) { | |
children.append(.image(image)) | |
} | |
init(stringLiteral string: String) { | |
children.append(.text(string)) | |
} | |
init(unicodeScalarLiteral value: String) { | |
self.init(stringLiteral: value) | |
} | |
init(extendedGraphemeClusterLiteral value: String) { | |
self.init(stringLiteral: value) | |
} | |
init(stringInterpolation: StringInterpolation) { | |
children = stringInterpolation.attributedStrings | |
} | |
struct StringInterpolation: StringInterpolationProtocol { | |
var attributedStrings: [Part] | |
init(literalCapacity: Int, interpolationCount: Int) { | |
attributedStrings = [] | |
} | |
mutating func appendLiteral(_ literal: String) { | |
attributedStrings.append(.text(literal, attributes: [])) | |
} | |
mutating func appendInterpolation(_ image: UIImage) { | |
attributedStrings.append(.image(image)) | |
} | |
mutating func appendInterpolation(_ string: String, _ attributes: Attribute...) { | |
attributedStrings.append(.text(string, attributes: Set(attributes))) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
final class AttributedTextView: UITextView, UITextViewDelegate { | |
var attributesResolver: ((Set<AttributedString.Attribute>) -> [NSAttributedString.Key: Any])? | |
var defaultAttributes = [NSAttributedString.Key: Any]() | |
private var links = [String: () -> Void]() | |
override init(frame: CGRect, textContainer: NSTextContainer?) { | |
super.init(frame: frame, textContainer: textContainer) | |
setup() | |
} | |
init() { | |
super.init(frame: .zero, textContainer: nil) | |
setup() | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
private func setup() { | |
dataDetectorTypes = [.link] | |
linkTextAttributes = [:] | |
textContainerInset = .zero | |
textContainer.lineFragmentPadding = 0 | |
delegate = self | |
} | |
override func layoutSubviews() { | |
fitHeightToText() | |
super.layoutSubviews() | |
} | |
func set(attributedString: AttributedString) { | |
if let defaultFont = defaultAttributes[.font] as? UIFont { | |
font = defaultFont | |
} | |
if let defaultTextColor = defaultAttributes[.foregroundColor] as? UIColor { | |
textColor = defaultTextColor | |
} | |
let nsAttributedString = attributedString.children | |
.map { part in | |
switch part { | |
case let .image(image): | |
return NSAttributedString(attachment: centeredTextAttachment(image: image, font: font)) | |
case let .text(string, attributes: attributes): | |
return NSAttributedString(string: string, attributes: nsAttributes(from: attributes)) | |
} | |
} | |
.reduce(NSAttributedString(), +) | |
.mutableCopy() as! NSMutableAttributedString | |
if let paragraphStyle = defaultAttributes[.paragraphStyle] as? NSParagraphStyle { | |
let range = NSRange(location: 0, length: nsAttributedString.length) | |
nsAttributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range) | |
} | |
attributedText = nsAttributedString | |
fitHeightToText() | |
} | |
private func centeredTextAttachment(image: UIImage, font: UIFont?) -> NSTextAttachment { | |
let textAttachment = NSTextAttachment() | |
textAttachment.image = image | |
if let font = font { | |
let mid = font.descender + font.capHeight | |
textAttachment.bounds = CGRect(x: 0, y: font.descender - image.size.height / 2 + mid + 2, width: image.size.width, height: image.size.height).integral | |
} | |
return textAttachment | |
} | |
private func nsAttributes(from attributes: Set<AttributedString.Attribute>) -> [NSAttributedString.Key: Any] { | |
var nsAttributes = attributesResolver?(attributes) ?? [:] | |
if let tapAction = tapAction(from: attributes) { | |
let id = UUID().uuidString | |
links[id] = tapAction | |
nsAttributes[.link] = URL(string: id) | |
} | |
if nsAttributes[.font] == nil { | |
nsAttributes[.font] = font | |
} | |
if nsAttributes[.foregroundColor] == nil { | |
nsAttributes[.foregroundColor] = textColor | |
} | |
return nsAttributes | |
} | |
private func tapAction(from attributes: Set<AttributedString.Attribute>) -> (() -> Void)? { | |
var actions = [() -> Void]() | |
for attribute in attributes { | |
switch attribute { | |
case let .tappable(_, action) where action != nil: | |
actions.append(action!) | |
default: | |
break | |
} | |
} | |
guard actions.isNotEmpty else { return nil } | |
return { | |
actions.forEach { $0() } | |
} | |
} | |
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { | |
let url = URL.absoluteString | |
if let tapAction = links[url] { | |
tapAction() | |
return false | |
} | |
return true | |
} | |
private func fitHeightToText(maxHeight: CGFloat = .infinity) { | |
let size = CGSize(width: frame.width, height: maxHeight) | |
let estimatedSize = sizeThatFits(size) | |
if let heightConstraint = constraints.first(where: { $0.firstAttribute == .height }) { | |
if heightConstraint.constant != estimatedSize.height { | |
heightConstraint.constant = estimatedSize.height | |
} | |
} else { | |
let constraint = heightAnchor.constraint(equalToConstant: estimatedSize.height) | |
constraint.priority = UILayoutPriority(999.0) | |
constraint.isActive = true | |
} | |
layoutIfNeeded() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
extension NSAttributedString { | |
static func + (lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString { | |
let newAttributedString = lhs.mutableCopy() as! NSMutableAttributedString | |
newAttributedString.append(rhs) | |
return newAttributedString | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
final class MonopolyViewController: UIViewController { | |
private let gameRules: AttributedTextView = { | |
let textView = AttributedTextView() | |
textView.translatesAutoresizingMaskIntoConstraints = false | |
textView.isEditable = false | |
textView.isScrollEnabled = false | |
textView.defaultAttributes = [.font: UIFont.systemFont(ofSize: 14.0, weight: .regular), | |
.foregroundColor: UIColor.gray] | |
textView.attributesResolver = { attributes in | |
if let importantAttribute = attributes.first(where: { $0.isImportant }), attributes.contains(where: { $0.isTappable }) { | |
switch importantAttribute { | |
case let .important(level) where level == .very: | |
return [.foregroundColor: UIColor.blue, .underlineStyle: NSUnderlineStyle.single.rawValue, .font: UIFont.systemFont(ofSize: 24.0, weight: .bold)] | |
default: | |
return [.foregroundColor: UIColor.blue, .underlineStyle: NSUnderlineStyle.single.rawValue, .font: UIFont.systemFont(ofSize: 14.0, weight: .medium)] | |
} | |
} else { | |
return attributes.reduce(into: [NSAttributedString.Key: Any]()) { result, attribute in | |
switch attribute { | |
case let .important(level) where level == .very: | |
result[.foregroundColor] = UIColor.blue | |
result[.font] = UIFont.systemFont(ofSize: 16.0, weight: .bold) | |
case let .important(level): | |
result[.foregroundColor] = UIColor.blue | |
result[.font] = UIFont.systemFont(ofSize: 14.0, weight: .medium) | |
case .tappable: | |
result[.underlineStyle] = NSUnderlineStyle.single.rawValue | |
result[.font] = UIFont.systemFont(ofSize: 14.0, weight: .medium) | |
} | |
} | |
} | |
} | |
return textView | |
}() | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
view.addSubview(gameRules) | |
NSLayoutConstraint.activate([ | |
gameRules.safeAreaLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16.0), | |
gameRules.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16.0), | |
gameRules.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16.0), | |
gameRules.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16.0), | |
]) | |
let ruleImage = UIImage.from(color: .blue, size: CGSize(width: 14.0, height: 14.0), rounded: 7.0) | |
let openWikipedia: () -> Void = { | |
UIApplication.shared.openURL(URL(string: "https://simple.wikipedia.org/wiki/Monopoly_(game)")!) | |
} | |
gameRules.set(attributedString: """ | |
\("Monopoly", .important(.very)) | |
\(ruleImage) \("1.", .important()) Each player rolls the dice to see who goes first. | |
\(ruleImage) \("2.", .important()) Whenever you land on a land that no one owns, you can buy it from the bank | |
... | |
Learn more \("here", .tappable(openWikipedia), .important(.enough)) | |
""") | |
} | |
} | |
extension UIImage { | |
static func from(color: UIColor, size: CGSize, rounded: CGFloat) -> UIImage { | |
return UIGraphicsImageRenderer(size: size).image { rendererContext in | |
color.setFill() | |
rendererContext.fill(CGRect(origin: .zero, size: size)) | |
}.applying(cornerRadius: rounded) | |
} | |
func applying(cornerRadius: CGFloat) -> UIImage { | |
let imageView = UIImageView(image: self) | |
let layer = imageView.layer | |
layer.masksToBounds = true | |
layer.cornerRadius = cornerRadius | |
layer.cornerCurve = .continuous | |
return UIGraphicsImageRenderer(size: imageView.bounds.size).image { rendererContext in | |
layer.render(in: rendererContext.cgContext) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment