Как научить UITextView красиво выделяться

от автора

Постановка задачи

Дано: многострочный текст.
Найти: красиво оформленный фон.

«Да это же на часок», — подумал я. — Нужно всего лишь поставить 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.

Ссылки

  1. NSLayoutManager
  2. NSTextContainer
  3. NSTextView
  4. Using Text Kit to Draw and Manage Text


ссылка на оригинал статьи https://habr.com/ru/post/479992/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *