ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift
shenlei 83dfa40299 完善搜索定位与内存监测回归验证
本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。

主要改动:

1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。

2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。

3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。

4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
2026-07-09 18:46:04 +09:00

635 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
struct RDEPUBReaderSearchSection: Equatable {
struct Item: Equatable {
let matchIndex: Int
let previewText: String
let isCurrent: Bool
}
let title: String
let items: [Item]
}
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
var onSearchSubmit: ((String) -> Void)?
var onSearchTextChanged: ((String) -> Void)?
var onSearchPrevious: (() -> Void)?
var onSearchNext: (() -> Void)?
var onSelectMatch: ((Int) -> Void)?
var onClose: (() -> Void)?
let textField: UITextField = {
let field = UITextField()
field.placeholder = "搜索"
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
field.returnKeyType = .search
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.clearButtonMode = .whileEditing
field.isAccessibilityElement = true
if #available(iOS 13.0, *) {
field.accessibilityTraits = .searchField
}
return field
}()
private let backgroundButton = UIButton(type: .custom)
let panelView = UIView()
private let grabberView = UIView()
private let searchRowView = UIView()
private let searchFieldContainer = UIView()
private let searchIcon = UIImageView()
private let searchFieldDivider = UIView()
private let cancelButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyStateLabel = UILabel()
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel()
private var searchSections: [RDEPUBReaderSearchSection] = []
private var keyword = ""
private var currentMatchIndex: Int?
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.search.bar"
shouldGroupAccessibilityChildren = false
isAccessibilityElement = false
setupSubviews()
setupConstraints()
setupActions()
updateLegacyNavigationEnabled(false)
showInitialState()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Only claim touches that fall within the panelView or the
// accessibility-only navigation buttons. Taps on the dimmed scrim
// area pass through to the reader content below, allowing chrome
// toggle taps to work.
let panelPoint = convert(point, to: panelView)
if panelView.point(inside: panelPoint, with: event) {
return super.hitTest(point, with: event)
}
// Also claim touches on the accessibility-only navigation buttons
if previousButton.frame.contains(point)
|| nextButton.frame.contains(point)
|| countLabel.frame.contains(point) {
return super.hitTest(point, with: event)
}
return nil
}
override func lineFrame(in bounds: CGRect) -> CGRect {
.zero
}
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
let overlayColor = isDarkBackground
? UIColor(white: 0.12, alpha: 0.92)
: UIColor(white: 0.08, alpha: 0.82)
let panelColor = isDarkBackground
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
let rowColor = isDarkBackground
? UIColor(white: 0.18, alpha: 1)
: UIColor(white: 0.14, alpha: 0.96)
let cardColor = isDarkBackground
? UIColor(white: 0.12, alpha: 1)
: UIColor(white: 0.10, alpha: 0.98)
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
let textColor = UIColor(white: 0.96, alpha: 1)
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
backgroundColor = .clear
backgroundButton.backgroundColor = overlayColor
panelView.backgroundColor = panelColor
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
searchRowView.backgroundColor = rowColor
searchFieldContainer.backgroundColor = .clear
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
searchIcon.tintColor = secondaryTextColor
cancelButton.tintColor = textColor
cancelButton.setTitleColor(textColor, for: .normal)
textField.textColor = textColor
textField.tintColor = UIColor.systemBlue
textField.keyboardAppearance = isDarkBackground ? .dark : .default
textField.attributedPlaceholder = NSAttributedString(
string: "搜索",
attributes: [.foregroundColor: secondaryTextColor]
)
emptyStateLabel.textColor = secondaryTextColor
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
previousButton.tintColor = textColor
nextButton.tintColor = textColor
countLabel.textColor = textColor
countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
tableView.reloadData()
}
var presentedView: UIView {
panelView
}
func updateMatchCount(current: Int, total: Int) {
countLabel.text = "\(current)/\(total)"
textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0)
}
func showNoResults() {
currentMatchIndex = nil
updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
}
func showSearching() {
updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "搜索中..."
}
func restoreKeyword(_ keyword: String) {
textField.text = keyword
self.keyword = keyword
}
func updateResults(
sections: [RDEPUBReaderSearchSection],
keyword: String,
currentMatchIndex: Int?
) {
self.keyword = keyword
self.searchSections = sections
self.currentMatchIndex = currentMatchIndex
let total = sections.reduce(0) { $0 + $1.items.count }
if let currentMatchIndex, total > 0 {
updateMatchCount(current: currentMatchIndex + 1, total: total)
} else {
updateMatchCount(current: 0, total: total)
}
if total == 0 {
showNoResults()
return
}
emptyStateLabel.isHidden = true
tableView.isHidden = false
tableView.reloadData()
scrollToCurrentMatchIfNeeded()
}
private func setupSubviews() {
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
panelView.translatesAutoresizingMaskIntoConstraints = false
grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
searchIcon.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
cancelButton.translatesAutoresizingMaskIntoConstraints = false
tableView.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
previousButton.translatesAutoresizingMaskIntoConstraints = false
nextButton.translatesAutoresizingMaskIntoConstraints = false
countLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundButton)
addSubview(panelView)
panelView.addSubview(grabberView)
panelView.addSubview(searchRowView)
panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel)
searchRowView.addSubview(searchFieldContainer)
searchRowView.addSubview(searchFieldDivider)
searchRowView.addSubview(cancelButton)
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
if #available(iOS 13.0, *) {
searchIcon.image = UIImage(systemName: "magnifyingglass")
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
} else {
previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal)
}
searchIcon.contentMode = .scaleAspectFit
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
panelView.layer.cornerRadius = 28
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
grabberView.layer.cornerRadius = 3
searchRowView.layer.cornerRadius = 22
searchFieldContainer.layer.cornerRadius = 22
searchRowView.clipsToBounds = true
cancelButton.setTitle("取消", for: .normal)
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
emptyStateLabel.textAlignment = .center
emptyStateLabel.numberOfLines = 0
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
nextButton.accessibilityIdentifier = "epub.reader.search.next"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
// 0.02 0.01CALayer.opacity Float320.01
// 0.01 UIKit
previousButton.alpha = 0.02
nextButton.alpha = 0.02
countLabel.alpha = 0.02
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
tableView.dataSource = self
tableView.delegate = self
tableView.showsVerticalScrollIndicator = false
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
grabberView.widthAnchor.constraint(equalToConstant: 92),
grabberView.heightAnchor.constraint(equalToConstant: 6),
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
searchRowView.heightAnchor.constraint(equalToConstant: 52),
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
searchIcon.widthAnchor.constraint(equalToConstant: 24),
searchIcon.heightAnchor.constraint(equalToConstant: 24),
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
// /UI panel
//
//
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
])
}
private func setupActions() {
textField.delegate = self
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
}
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled
nextButton.isEnabled = enabled
}
private func showInitialState() {
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "输入关键词开始搜索"
}
private func scrollToCurrentMatchIfNeeded() {
guard let currentMatchIndex else { return }
for (sectionIndex, section) in searchSections.enumerated() {
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
DispatchQueue.main.async { [weak self] in
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
return
}
}
}
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
searchSections[indexPath.section].items[indexPath.row]
}
@objc private func textFieldDidReturn() {
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !keyword.isEmpty else { return }
onSearchSubmit?(keyword)
textField.resignFirstResponder()
}
@objc private func textFieldDidChange() {
guard textField.markedTextRange == nil else { return }
onSearchTextChanged?(textField.text ?? "")
}
@objc private func previousAction() {
onSearchPrevious?()
}
@objc private func nextAction() {
onSearchNext?()
}
@objc private func closeAction() {
onClose?()
}
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
searchSections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchSections[section].items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
for: indexPath
)
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
return cell
}
let item = item(at: indexPath)
cell.configure(
previewText: item.previewText,
keyword: keyword,
isCurrent: item.isCurrent
)
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let container = UIView()
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
label.textColor = UIColor(white: 0.96, alpha: 1)
label.text = searchSections[section].title
label.numberOfLines = 2
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
])
return container
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
40
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
116
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
onSelectMatch?(item(at: indexPath).matchIndex)
}
}
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
func textFieldShouldClear(_ textField: UITextField) -> Bool {
DispatchQueue.main.async { [weak self] in
self?.onSearchTextChanged?("")
}
return true
}
}
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
static var highlightTextColor = UIColor.systemBlue
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
private let cardView = UIView()
private let previewLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupSubviews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewLabel.attributedText = nil
}
func configure(previewText: String, keyword: String, isCurrent: Bool) {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
previewLabel.attributedText = attributedPreviewText(
previewText,
keyword: keyword,
isCurrent: isCurrent
)
}
private func setupSubviews() {
cardView.translatesAutoresizingMaskIntoConstraints = false
previewLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(cardView)
cardView.addSubview(previewLabel)
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
previewLabel.numberOfLines = 0
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
])
}
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
let normalizedText = previewText
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 8
let attributed = NSMutableAttributedString(
string: normalizedText,
attributes: [
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
.foregroundColor: Self.primaryTextColor,
.paragraphStyle: paragraphStyle
]
)
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !searchKeyword.isEmpty else { return attributed }
let nsText = normalizedText as NSString
var searchRange = NSRange(location: 0, length: nsText.length)
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
while searchRange.length > 0 {
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
let nextLocation = foundRange.location + max(foundRange.length, 1)
guard nextLocation < nsText.length else { break }
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
}
return attributed
}
}