Pull to refresh

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

Reading time6 min
Views5K

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


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

«Да это же на часок», — подумал я. — Нужно всего лишь поставить 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
Tags:
Hubs:
Total votes 14: ↑14 and ↓0+14
Comments1

Articles