Reputation: 11
I have a search view, where user can search by a word or phrase and filter the result.
List {
ForEach(textsData) {text in
if text.sometext.localizedCaseInsensitiveContains(self.searchText) || self.searchText == "" {
NavigationLink(destination: TextView(text: text)) {
Text(text.sometext)
}
}
}
}
I would like to highlight with red color the searched text. Is there a way I can do it?
UPD: Let's assume the code is as following:
struct ContentView: View {
var texts = ["This is an example of large text block", "This block is rather small"]
var textsSearch = "large"
var body: some View {
List {
ForEach(self.texts, id: \.self) {text in
Text(text).padding().background(self.textsSearch == text ? Color.red : .clear)
}
}
}
}
And I would like to highlight only the word "large" in output:
This is an example of large text block
This block is rather small
UPD2: This answer worked good for me: SwiftUI: is there exist modifier to highlight substring of Text() view?
Upvotes: 1
Views: 1422
Reputation: 21
I had a similar issue, the text.foregroundColor
it wasn't enough for me. I need to change the background
color of the view. There isn't a easy way to accomplish this in SwiftUI so I created my own View
that adds this capability:
import SwiftUI
struct HighlightedText: View {
/// The text rendered by current View
let text: String
/// The textPart to "highlight" if [text] contains it
var textPart: String?
/// The <Text> views created inside the current view inherits Text params defined for self (HighlightedText) like font, underline, etc
/// Color used for view background when text value contans textPart value
var textPartBgColor: Color = Color.blue
/// Font size used to determine if the current text needs more than one line for render
var fontSize: CGFloat = 18
/// Max characters length allowed for one line, if exceeds a new line will be added
var maxLineLength = 25
/// False to disable multiline drawing
var multilineEnabled = true
/// True when the current [text] needs more than one line to render
@State private var isTruncated: Bool = false
public var body: some View {
guard let textP = textPart, !textP.isEmpty else {
// 1. Default case, [textPart] is null or empty
return AnyView(Text(text))
}
let matches = collectRegexMatches(textP)
if matches.isEmpty {
// 2. [textPart] has a value but is not found in the [text] value
return AnyView(Text(text))
}
// 3. There is at least one match for [textPart] in [text]
let textParts = collectTextParts(matches, textP)
if multilineEnabled && isTruncated {
// 4. The current [text] needs more than one line to render
return AnyView(renderTruncatedContent(collectLineTextParts(textParts)))
}
// 5. The current [text] can be rendered in one line
return AnyView(renderOneLineContent(textParts))
}
@ViewBuilder
private func renderOneLineContent(_ textParts: [TextPartOption]) -> some View {
HStack(alignment: .top, spacing: 0) {
ForEach(textParts) { item in
if item.highlighted {
Text(item.textPart)
.frame(height: 30, alignment: .leading)
.background(textPartBgColor)
} else {
Text(item.textPart)
.frame(height: 30, alignment: .leading)
}
}
}.background(GeometryReader { geometry in
if multilineEnabled {
Color.clear.onAppear {
self.determineTruncation(geometry)
}
}
})
}
@ViewBuilder
private func renderTruncatedContent(_ lineTextParts: [TextPartsLine]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lineTextParts)) { lineTextPartsItem in
HStack(alignment: .top, spacing: 0) {
ForEach(lineTextPartsItem.textParts) { textPartItem in
if textPartItem.highlighted {
Text(textPartItem.textPart)
.frame(height: 25, alignment: .leading)
.background(textPartBgColor)
} else {
Text(textPartItem.textPart)
.frame(height: 25, alignment: .leading)
}
}
}
}
}
}
private func charCount(_ textParts: [TextPartOption]) -> Int {
return textParts.reduce(0) { partialResult, textPart in
partialResult + textPart.textPart.count
}
}
private func collectLineTextParts(_ currTextParts: [TextPartOption]) -> [TextPartsLine] {
var textParts = currTextParts
var lineTextParts: [TextPartsLine] = []
var currTextParts: [TextPartOption] = []
while textParts.isNotEmpty {
let currItem = textParts.removeFirst()
let extraChars = charCount(currTextParts) + (currItem.textPart.count - 1) - maxLineLength
if extraChars > 0 && (currItem.textPart.count - 1) - extraChars > 0 {
let endIndex = currItem.textPart.index(currItem.textPart.startIndex, offsetBy: (currItem.textPart.count - 1) - extraChars)
currTextParts.append(
TextPartOption(
index: currTextParts.count,
textPart: String(currItem.textPart[currItem.textPart.startIndex..<endIndex]),
highlighted: currItem.highlighted
)
)
lineTextParts.append(TextPartsLine(textParts: currTextParts))
currTextParts = []
currTextParts.append(
TextPartOption(
index: currTextParts.count,
textPart: String(currItem.textPart[endIndex..<currItem.textPart.index(endIndex, offsetBy: extraChars)]),
highlighted: currItem.highlighted
)
)
} else {
currTextParts.append(currItem.copy(index: currTextParts.count))
}
}
if currTextParts.isNotEmpty {
lineTextParts.append(TextPartsLine(textParts: currTextParts))
}
return lineTextParts
}
private func collectTextParts(_ matches: [NSTextCheckingResult], _ textPart: String) -> [TextPartOption] {
var textParts: [TextPartOption] = []
// 1. Adding start non-highlighted text if exists
if let firstMatch = matches.first, firstMatch.range.location > 0 {
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[text.startIndex..<text.index(text.startIndex, offsetBy: firstMatch.range.location)]),
highlighted: false
)
)
}
// 2. Adding highlighted text matches and non-highlighted texts in-between
var lastMatchEndIndex: String.Index?
for (index, match) in matches.enumerated() {
let startIndex = text.index(text.startIndex, offsetBy: match.range.location)
if (match.range.location + textPart.count) > text.count {
lastMatchEndIndex = text.endIndex
} else {
lastMatchEndIndex = text.index(startIndex, offsetBy: textPart.count)
}
// Adding highlighted string
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[startIndex..<lastMatchEndIndex!]),
highlighted: true
)
)
if (matches.count > index + 1 ) && (matches[index + 1].range.location != (match.range.location + textPart.count)) {
// There is a non-highlighted string between highlighted strings
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[lastMatchEndIndex!..<text.index(text.startIndex, offsetBy: matches[index + 1].range.location)]),
highlighted: false
)
)
}
}
// 3. Adding end non-highlighted text if exists
if let lastMatch = matches.last, lastMatch.range.location < text.count {
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[lastMatchEndIndex!..<text.endIndex]),
highlighted: false
)
)
}
return textParts
}
private func collectRegexMatches(_ match: String) -> [NSTextCheckingResult] {
let pattern = NSRegularExpression.escapedPattern(for: match)
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: .regularExpression, locale: .current)
// swiftlint:disable:next force_try
return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive).matches(
in: text, options: .withTransparentBounds,
range: NSRange(location: 0, length: text.count)
)
}
private func determineTruncation(_ geometry: GeometryProxy) {
// Calculate the bounding box we'd need to render the
// text given the width from the GeometryReader.
let total = self.text.boundingRect(
with: CGSize(
width: geometry.size.width,
height: .greatestFiniteMagnitude
),
options: .usesLineFragmentOrigin,
attributes: [.font: UIFont.systemFont(ofSize: fontSize)],
context: nil
)
if total.size.height > geometry.size.height {
isTruncated = true
} else {
isTruncated = false
}
}
private struct TextPartOption: Identifiable {
let index: Int
let textPart: String
let highlighted: Bool
var id: String { "\(index)_\(textPart)" }
func copy(index: Int? = nil, textPart: String? = nil, highlighted: Bool? = nil) -> TextPartOption {
return TextPartOption(
index: index ?? self.index,
textPart: textPart ?? self.textPart,
highlighted: highlighted ?? self.highlighted
)
}
}
private struct TextPartsLine: Identifiable {
let textParts: [TextPartOption]
var id: String { textParts.reduce("") { partialResult, textPartOption in
"\(partialResult)_\(textPartOption.id)"
} }
}
}
Usage:
HighlightedText(
text: item.title,
textPart: searchQuery
)
.padding(.bottom, 2)
.foregroundColor(Color.secondaryDark)
.font(myFont)
Examples of the result:
- More than one list item result example
Upvotes: 2
Reputation: 1870
This should work. You can often add conditions directly into modifiers:
struct ContentView: View {
var texts = ["a", "b"]
var textsSearch = "a"
var body: some View {
List {
ForEach(self.texts, id: \.self) {text in
Text(text).padding().background(self.textsSearch == text ? Color.red : .clear)
}
}
}
}
Upvotes: 1