Постановка задачи
Дано: многострочный текст.
Найти: красиво оформленный фон.
«Да это же на часок», — подумал я. — Нужно всего лишь поставить backgroundColor в attributedText». Но этого оказалось недостаточно. Дело в том, что стандартное выделение — это закрашенный прямоугольник. Некрасиво. Решение — нужен кастом! Тернистый путь его создания натолкнул меня на мысль описать процесс, дабы будущим поколениям не пришлось столько страдать. Заинтересовавшихся прошу под кат.
Принятие
Первым действием было классическое обращение в интернет с вопросом правильной реализации. В ответ нашлось немного предложений. В самых адекватных все сходилось к переопределению метода fillBackgroundRectArray. В обычной жизни он отвечает за покраску текста при задействовании свойства attributedText, упомянутого выше. Чтобы посмотреть, что это за зверь, я попробовал готовое решение в надежде, что задача все-таки на пару часов. Не получилось. Решение творило что попало.
Документация — наше все
Решив больше так не делать, я обратился к документации. Отсюда стало понятно, что UITextView — это простой наследник UIScrollView для текста, которым рулят три товарища:
- NSTextStorage — по сути обертка над NSMutableAtributedString, нужен для хранения текста и его атрибутов;
- NSTextContainer — NSObject, отвечающий за геометрическую фигуру, в которой представлен текст. По умолчанию это прямоугольник, закастомить можно что угодно;
- NSLayoutManager — управляет первыми двумя: берет текст, ставит отступы, делит на абзацы и в том числе отвечает за необходимую нам закраску.
Алгоритмы — это круто
В итоге задача сводится к созданию кастомного NSLayoutManadger’a и переопределению в нем нужного метода.
class SelectionLayoutManager: NSLayoutManager { override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) { }
В основной реализации fillBackgroundRectArray получает прямоугольники слов и закрашивает их. Так как предоставляемые фигуры, как правило, являются отдельными частями одной строки со стыком где попало, от них пришлось отказаться. Отсюда подзадача раз: определить правильные прямоугольники, которые начинаются в начале линии и неразрывны до ее конца.
Метод, разрешающий эту задачу, представляет собой следующий алгоритм: он проходит в цикле по словам абзаца и проверяет, вместится ли в одну линию строка, если к ней добавить следующее слово? Если нет, то это отдельная полная строка, переходим на следующую. Если да — берём следующее слово и проверяем условие снова. Да, реализация крайне проста, была задумка сделать рекурсивный метод, но руки пока не дошли. В комментарии прошу ваши оптимизированные варианты.
private func detectLines(from string: String, width: CGFloat, font: UIFont) -> [String] { var strings: [String] = [] var cumulativeString = "" let words = string.components(separatedBy: CharacterSet.whitespaces) for word in words { let checkingString = cumulativeString + word if checkingString.size(withFont: font).width < width { cumulativeString.append(word) } else { if cumulativeString.isNotEmpty { strings.append(cumulativeString) } if word.size(withFont: font).width < width { cumulativeString = word } else { var stringsFromWord: [String] = [] var handlingWord = word while handlingWord.isNotEmpty { let fullFillString = detectFullFillString(from: handlingWord, width: width, font: font) stringsFromWord.append(fullFillString) handlingWord = word.replacingOccurrences(of: stringsFromWord.reduce("") { $0 + $1 }, with: "") } stringsFromWord.removeLast() strings.append(contentsOf: stringsFromWord) let remainString = word.replacingOccurrences(of: stringsFromWord.reduce("") { $0 + $1 }, with: "") cumulativeString = remainString } } } if cumulativeString.isNotEmpty { strings.append(cumulativeString) } return strings }
Стоит отметить особый случай слов, которые сами по себе в одну линию не помещаются. UITextView работает с ними очень просто — перенос на следующую строку той части, которая не вошла. Дублируем данную логику в отдельном методе с тем же подходом последовательного прохода, но в этом случае по символам. Что вместилось: полная строка, остальное — либо тоже полная строка, в случае очень длинного слова, либо просто новое слово на новой строке.
private func detectFullFillString(from word: String, width: CGFloat, font: UIFont) -> String { var string = "" for character in word { let checkingString = string.appending(String(character)) if checkingString.size(withFont: font).width > width { break } else { string.append(contentsOf: String(character)) } } return string }
В результате работы метода detectLines(from:width:font:) получаем массив строк, правильно поделённых по линиям. Далее из метода frames(for lines:width:font) получаем массив координат и размеров линий.
private func frames(for lines: [String], width: CGFloat, font: UIFont) -> [CGRect] { var rects: [CGRect] = [] let stringsSizes = lines.map { $0.size(withFont: font) } stringsSizes.forEach { let rect = CGRect(origin: CGPoint(x: (width - $0.width) / 2, y: $0.height * CGFloat(rects.count)), size: $0) rects.append(rect) } return rects }
Наводим красоту
Подзадача номер два: закрасить прямоугольники. Под понятием «красиво» предполагалось закрашивание выбранным цветом прямоугольника со скруглением углов. Решение: отрисовка дополнительного слоя по заданным координатам в контексте с использованием UIBezierPath. Чтобы стыки смотрелись лучше, не будем закруглять края прямоугольника, который меньше по ширине. Метод прост: проходим по координатам каждого прямоугольника и рисуем контур.
private func path(from rects: [CGRect], cornerRadius: CGFloat, horizontalInset: CGFloat) -> CGPath { let path = CGMutablePath() rects.enumerated().forEach { (index, rect) in let hasPrevious = index > 0 let isPreviousWider = hasPrevious ? rects[index - 1].width >= rect.width || abs(rects[index - 1].width - rect.width) < 5 : false let hasNext = index != rects.count - 1 let isNextWider = hasNext ? rects[index + 1].width >= rect.width || abs(rects[index + 1].width - rect.width) < 5 : false path.move(to: CGPoint(x: rect.minX - horizontalInset + (isPreviousWider ? 0 : cornerRadius), y: rect.minY)) // top path.addLine(to: CGPoint(x: rect.maxX + horizontalInset - (isPreviousWider ? 0 : cornerRadius), y: rect.minY)) if isPreviousWider == false { path.addQuadCurve(to: CGPoint(x: rect.maxX + horizontalInset, y: rect.minY + cornerRadius), control: CGPoint(x: rect.maxX + horizontalInset, y: rect.minY)) } // right path.addLine(to: CGPoint(x: rect.maxX + horizontalInset, y: rect.maxY - (isNextWider ? 0 : cornerRadius))) if isNextWider == false { path.addQuadCurve(to: CGPoint(x: rect.maxX + horizontalInset - cornerRadius, y: rect.maxY), control: CGPoint(x: rect.maxX + horizontalInset, y: rect.maxY)) } // bottom path.addLine(to: CGPoint(x: rect.minX - horizontalInset + (isNextWider ? 0 : cornerRadius), y: rect.maxY)) if isNextWider == false { path.addQuadCurve(to: CGPoint(x: rect.minX - horizontalInset, y: rect.maxY - cornerRadius), control: CGPoint(x: rect.minX - horizontalInset, y: rect.maxY)) } // left path.addLine(to: CGPoint(x: rect.minX - horizontalInset, y: rect.minY + (isPreviousWider ? 0 : cornerRadius))) if isPreviousWider == false { path.addQuadCurve(to: CGPoint(x: rect.minX - horizontalInset + cornerRadius, y: rect.minY), control: CGPoint(x: rect.minX - horizontalInset, y: rect.minY)) } path.closeSubpath() } return path }
Далее в методе draw(_:color:) заливаем контур.
private func draw(_ path: CGPath, color: UIColor) { color.set() if let ctx = UIGraphicsGetCurrentContext() { ctx.setAllowsAntialiasing(true) ctx.setShouldAntialias(true) ctx.addPath(path) ctx.drawPath(using: .fillStroke) } }
Полный код метода fillBackgroundRectArray(_:count:forCharacterRange:color:)
override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) { let cornerRadius: CGFloat = 8 let horizontalInset: CGFloat = 5 guard let font = (textStorage?.attributes(at: 0, effectiveRange: nil).first { $0.key == .font })?.value as? UIFont, let textContainerWidth = textContainers.first?.size.width else { return } /// Divide the text into paragraphs let lines = paragraphs(from: textStorage?.string ?? "") /// Divide the paragraphs into separate lines let strings = detectLines(from: lines, width: textContainerWidth, font: font) /// Get rects from the lines let rects = frames(for: strings, width: textContainerWidth, font: font) /// Get a contour by rects let rectsPath = path(from: rects, cornerRadius: cornerRadius, horizontalInset: horizontalInset) /// Fill it draw(rectsPath, color: color) }
Запускаем. Проверяем. Работает на отлично.

В качестве завершения: кастом — это иногда сложно и тошно, но как же красиво, когда работает как нужно. Творите кастом, это здорово.
Полный код можно посмотреть тут SelectionLayoutManager.
Ссылки
ссылка на оригинал статьи https://habr.com/ru/post/479992/
Добавить комментарий