Created
January 4, 2025 19:11
-
-
Save nkalvi/1e72e647aa82558fdc2c969e168b7e36 to your computer and use it in GitHub Desktop.
Using TextRenderer to create highlighted text
This file contains hidden or 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
// | |
// HighlightedText_TextRenderingApp.swift | |
// | |
// Based on: https://alexanderweiss.dev/blog/2024-06-24-using-textrenderer-to-create-highlighted-text | |
// 2025-01-04 | |
import SwiftUI | |
@main | |
struct HighlightedText_TextRenderingApp: App { | |
var body: some Scene { | |
WindowGroup { | |
ContentView() | |
} | |
} | |
} | |
struct ContentView: View { | |
@State private var searchText: String = "" | |
var body: some View { | |
NavigationStack { | |
Section { | |
List( | |
sample.filter { self.searchText.isEmpty || $0.localizedStandardContains(self.searchText) }, | |
id: \.self | |
) { item in | |
HighlightedText(text: item, highlightedText: self.searchText) | |
.listRowBackground(Color.clear) | |
} | |
} header: { | |
VStack(spacing: 20) { | |
Link( | |
"Using TextRenderer to create highlighted text", | |
destination: URL(string: "https://alexanderweiss.dev/blog/2024-06-24-using-textrenderer-to-create-highlighted-text")! | |
) | |
.font(.headline) | |
Link( | |
"Creating visual effects with SwiftUI", | |
destination: URL( | |
string: "https://developer.apple.com/documentation/swiftui/creating-visual-effects-with-swiftui" | |
)! | |
) | |
} | |
.padding() | |
} | |
.searchable(text: self.$searchText) | |
} | |
} | |
} | |
#Preview { | |
ContentView() | |
} | |
// MARK: - String extension | |
extension String { | |
/// Find all ranges of the given substring | |
/// | |
/// - Parameters: | |
/// - substring: The substring to find ranges for | |
/// - Returns: Array of all ranges of the substring | |
func localizedStandardRanges(of substring: String) -> [Range<Index>] { | |
var ranges: [Range<Index>] = [] | |
while let range = self[(ranges.last?.upperBound ?? self.startIndex)..<self.endIndex] | |
.localizedStandardRange(of: substring) | |
{ | |
ranges.append(range) | |
} | |
return ranges | |
} | |
/// Find all remaining ranges given `ranges` | |
/// | |
/// - Parameters: | |
/// - ranges: A set of ranges | |
/// - Returns: All the ranges that are not part of `ranges` | |
func remainingRanges(from ranges: [Range<Index>]) -> [Range<Index>] { | |
var result = [Range<Index>]() | |
// Sort the input ranges to process them in order | |
let sortedRanges = ranges.sorted { $0.lowerBound < $1.lowerBound } | |
// Start from the beginning of the string | |
var currentIndex = self.startIndex | |
for range in sortedRanges { | |
if currentIndex < range.lowerBound { | |
// Add the range from currentIndex to the start of the current range | |
result.append(currentIndex..<range.lowerBound) | |
} | |
// Move currentIndex to the end of the current range | |
currentIndex = range.upperBound | |
} | |
// If there's remaining text after the last range, add it as well | |
if currentIndex < self.endIndex { | |
result.append(currentIndex..<self.endIndex) | |
} | |
return result | |
} | |
} | |
// MARK: - HighlightTextRenderer | |
struct HighlightTextRenderer: TextRenderer { | |
// MARK: - Private Properties | |
private let style: any ShapeStyle | |
// MARK: - Initializer | |
init(style: any ShapeStyle = .yellow) { | |
self.style = style | |
} | |
// MARK: - TextRenderer | |
func draw(layout: Text.Layout, in context: inout GraphicsContext) { | |
for run in layout.flattenedRuns { | |
if run[HighlightAttribute.self] != nil { | |
// The rect of the current run | |
let rect = run.typographicBounds.rect | |
// Make a copy of the context so that individual slices | |
// don't affect each other. | |
let copy = context | |
// Shape of the highlight, can be customised | |
let shape = RoundedRectangle(cornerRadius: 4, style: .continuous).path(in: rect) | |
// Style the shape | |
copy.fill(shape, with: .style(self.style)) | |
// Draw | |
copy.draw(run) | |
} else { | |
let copy = context | |
copy.draw(run) | |
} | |
} | |
} | |
} | |
struct HighlightAttribute: TextAttribute {} | |
extension Text.Layout { | |
/// A helper function for easier access to all runs in a layout. | |
var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> { | |
self.flatMap { line in | |
line | |
} | |
} | |
} | |
// MARK: - HighlightedText | |
struct HighlightedText: View { | |
// MARK: - Private Properties | |
private let text: String | |
private let highlightedText: String? | |
private let shapeStyle: (any ShapeStyle)? | |
// MARK: - Initializer | |
init(text: String, highlightedText: String? = nil, shapeStyle: (any ShapeStyle)? = nil) { | |
self.text = text | |
self.highlightedText = highlightedText | |
self.shapeStyle = shapeStyle | |
} | |
// MARK: - Body | |
var body: some View { | |
if let highlightedText, !highlightedText.isEmpty { | |
let text = self.highlightedTextComponent(from: highlightedText).reduce(Text("")) { partialResult, component in | |
return partialResult + component.text | |
} | |
text.textRenderer(HighlightTextRenderer(style: self.shapeStyle ?? .yellow)) | |
} else { | |
Text(self.text) | |
} | |
} | |
/// Extract the highlighted text components | |
/// | |
/// - Parameters | |
/// - highlight: The part to highlight | |
/// - Returns: Array of highlighted text components | |
private func highlightedTextComponent(from highlight: String) -> [HighlightedTextComponent] { | |
let highlightRanges: [HighlightedTextComponent] = self.text | |
.localizedStandardRanges(of: highlight) | |
.map { HighlightedTextComponent(text: Text(self.text[$0]).customAttribute(HighlightAttribute()), range: $0) } | |
let remainingRanges = self.text | |
.remainingRanges(from: highlightRanges.map(\.range)) | |
.map { HighlightedTextComponent(text: Text(self.text[$0]), range: $0) } | |
let result = (highlightRanges + remainingRanges).sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) | |
print(result) | |
return result | |
} | |
} | |
private struct HighlightedTextComponent { | |
let text: Text | |
let range: Range<String.Index> | |
} | |
let sample = | |
""" | |
Lorem ipsum dolor sit amet, consectetur adipiscing elit. | |
Fusce dictum ipsum mattis leo euismod facilisis id eu tortor. | |
Donec scelerisque ante nec sodales iaculis. | |
Nunc eu leo volutpat, gravida ligula vehicula, interdum ligula. | |
Nullam consectetur nisl et dui tristique venenatis. | |
Aenean non lectus tristique, gravida dui ac, volutpat turpis. | |
Curabitur eu magna in dolor posuere pharetra. | |
Nullam maximus quam vitae enim fringilla interdum. | |
Mauris mollis enim lacinia neque mattis, et ultricies ligula tempor. | |
Duis maximus leo vel risus semper ultricies. | |
Integer id magna suscipit, egestas nisi sit amet, hendrerit enim. | |
Nullam eu augue vel augue convallis rhoncus. | |
Mauris blandit nibh quis ullamcorper consectetur. | |
Suspendisse a mi placerat, mattis lorem porta, aliquam justo. | |
Sed commodo est sed eleifend ultrices. | |
Mauris malesuada dui id dapibus cursus. | |
""".components(separatedBy: .newlines) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment