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.
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))) | |
} | |
} | |
} |
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() | |
} | |
} |
extension NSAttributedString { | |
static func + (lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString { | |
let newAttributedString = lhs.mutableCopy() as! NSMutableAttributedString | |
newAttributedString.append(rhs) | |
return newAttributedString | |
} | |
} |
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