Created
December 20, 2019 18:37
-
-
Save rnapier/a37cdbf4aabb1e4a6b40436efc2c3114 to your computer and use it in GitHub Desktop.
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
// | |
// 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) } | |
} | |
@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
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:
Usage: