Skip to content

Instantly share code, notes, and snippets.

@nkalvi
Created January 4, 2025 19:11
Show Gist options
  • Save nkalvi/1e72e647aa82558fdc2c969e168b7e36 to your computer and use it in GitHub Desktop.
Save nkalvi/1e72e647aa82558fdc2c969e168b7e36 to your computer and use it in GitHub Desktop.
Using TextRenderer to create highlighted text
//
// 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