Постановка задачи
Дано: многострочный текст.
Найти: красиво оформленный фон.
«Да это же на часок», — подумал я. — Нужно всего лишь поставить 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.
6eromKYcIIexy
Спасибо за реализацию!
В свое время мне нужно было такое же решение и я взял его в либине: YYText