Skip to content

Instantly share code, notes, and snippets.

@rnapier
Created December 20, 2019 18:37
Show Gist options
  • Save rnapier/a37cdbf4aabb1e4a6b40436efc2c3114 to your computer and use it in GitHub Desktop.
Save rnapier/a37cdbf4aabb1e4a6b40436efc2c3114 to your computer and use it in GitHub Desktop.
//
// TextStyle.swift
//
// Created by Rob Napier on 12/20/19.
// Copyright © 2019 Rob Napier. All rights reserved.
//
import SwiftUI
public struct TextStyle {
// This type is opaque because it exposes NSAttributedString details and requires unique keys.
// It can be extended, however, by using public static methods.
// Properties are internal to be accessed by StyledText
internal let key: NSAttributedString.Key
internal let apply: (Text) -> Text
private init(key: NSAttributedString.Key, apply: @escaping (Text) -> Text) {
self.key = key
self.apply = apply
}
}
// Public methods for building styles
public extension TextStyle {
static func foregroundColor(_ color: Color) -> TextStyle {
TextStyle(key: .init("TextStyleForegroundColor"), apply: { $0.foregroundColor(color) })
}
static func bold() -> TextStyle {
TextStyle(key: .init("TextStyleBold"), apply: { $0.bold() })
}
}
public struct StyledText {
// This is a value type. Don't be tempted to use NSMutableAttributedString here unless
// you also implement copy-on-write.
private var attributedString: NSAttributedString
private init(attributedString: NSAttributedString) {
self.attributedString = attributedString
}
public func style<S>(_ style: TextStyle,
ranges: (String) -> S) -> StyledText
where S: Sequence, S.Element == Range<String.Index>
{
// Remember this is a value type. If you want to avoid this copy,
// then you need to implement copy-on-write.
let newAttributedString = NSMutableAttributedString(attributedString: attributedString)
for range in ranges(attributedString.string) {
let nsRange = NSRange(range, in: attributedString.string)
newAttributedString.addAttribute(style.key, value: style, range: nsRange)
}
return StyledText(attributedString: newAttributedString)
}
}
public extension StyledText {
// A convenience extension to apply to a single range.
func style(_ style: TextStyle,
range: (String) -> Range<String.Index> = { $0.startIndex..<$0.endIndex }) -> StyledText {
self.style(style, ranges: { [range($0)] })
}
}
extension StyledText {
public init(verbatim content: String, styles: [TextStyle] = []) {
let attributes = styles.reduce(into: [:]) { result, style in
result[style.key] = style
}
attributedString = NSMutableAttributedString(string: content, attributes: attributes)
}
}
extension StyledText: View {
public var body: some View { text() }
public func text() -> Text {
var text: Text = Text(verbatim: "")
attributedString
.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length),
options: [])
{ (attributes, range, _) in
let string = attributedString.attributedSubstring(from: range).string
let modifiers = attributes.values.map { $0 as! TextStyle }
text = text + modifiers.reduce(Text(verbatim: string)) { segment, style in
style.apply(segment)
}
}
return text
}
}
struct ContentView: View {
var body: some View {
StyledText(verbatim: "👩‍👩‍👦someText1")
.style(.highlight(), ranges: { [$0.range(of: "eTex")!, $0.range(of: "1")!] })
.style(.bold())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
// An internal convenience extension that could be defined outside this pacakge.
// This wouldn't be a general-purpose way to highlight, but shows how a caller could create
// their own extensions
extension TextStyle {
static func highlight() -> TextStyle { .foregroundColor(.red) }
}
@cci-rmirza
Copy link

cci-rmirza commented Jun 3, 2021

SO doesn't let me to edit this answer because the queue is full. So I'm going to write it here:

It crashes when the provided range cannot be found (returns nil). To prevent this crash I propose the following modification:

public struct StyledText {
    // This is a value type. Don't be tempted to use NSMutableAttributedString here unless
    // you also implement copy-on-write.
    private var attributedString: NSAttributedString

    private init(attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }

    public func style<S>(_ style: TextStyle,
                         ranges: (String) -> S) -> StyledText
        where S: Sequence, S.Element == Range<String.Index>?  //  <-- HERE
    {

        // Remember this is a value type. If you want to avoid this copy,
        // then you need to implement copy-on-write.
        let newAttributedString = NSMutableAttributedString(attributedString: attributedString)

        for range in ranges(attributedString.string).compactMap({ $0 }) {    //  <-- HERE
            let nsRange = NSRange(range, in: attributedString.string)
            newAttributedString.addAttribute(style.key, value: style, range: nsRange)
        }

        return StyledText(attributedString: newAttributedString)
    }
}

Usage:

struct ContentView: View {
  var body: some View {
    StyledText(verbatim: "👩‍👩‍👦someText1")
      .style(.highlight(), ranges: { [$0.range(of: "eTex"), $0.range(of: "1"),  $0.range(of: "some gibberish")] })
      .style(.bold())
  }
}

@rnapier
Copy link
Author

rnapier commented Jun 3, 2021

@rufmirza, thank you for the addition. It took me quite some time to decide whether I liked it or not. I generally resist adding Optionals unless they have a clear meaning (the fact that this adds extra code to ignore nil values demonstrates the common problem with adding Optionals). Typically I would leave it to the caller do this compactMap. But this use case is probably common enough to make this ergonomic improvement worth it. I still wouldn't do it this way in my own code, but I accepted the edit on SO. I expect it will be helpful to people.

Thanks again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment