Sergey Kirgizov
Sergey Kirgizov

Reputation: 11

SwiftUI highlight word in search result

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

Answers (2)

Valeriu Gavriluta
Valeriu Gavriluta

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

- One Item result example

Upvotes: 2

Simon
Simon

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

Related Questions