Skip to content

Instantly share code, notes, and snippets.

@sunshinejr
Last active November 24, 2022 16:14
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sunshinejr/36ac3adc243355273877840eb6f57aef to your computer and use it in GitHub Desktop.
Save sunshinejr/36ac3adc243355273877840eb6f57aef to your computer and use it in GitHub Desktop.
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