Como ensinar o UITextView a se destacar maravilhosamente

Declaração do problema


Fornecido: texto com várias linhas.
Encontre: fundo lindamente projetado.

Sim, é uma hora, pensei. "Você só precisa colocar backgroundColor em attributeText." Mas isso não foi suficiente. O fato é que a seleção padrão é um retângulo preenchido. Feio. A solução - você precisa de um costume! O caminho espinhoso de sua criação me levou a descrever o processo para que as gerações futuras não tivessem que sofrer tanto. Interessado, peço gato.

Aceitação


A primeira ação foi uma chamada clássica da Internet com a questão da implementação adequada. Em resposta, houve poucas sugestões. No mais adequado, tudo veio a uma substituição do método fillBackgroundRectArray. Na vida normal, ele é responsável por colorir o texto usando a propriedade attributeText mencionada acima. Para ver que tipo de animal é, tentei uma solução pronta, na esperança de que a tarefa ainda durasse algumas horas. Não deu certo. A decisão funcionou horrivelmente.

Documentação é tudo.


Decidindo não fazer isso de novo, virei-me para a documentação. A partir disso, ficou claro que o UITextView é um descendente simples do UIScrollView para texto direcionado por três companheiros:

  • NSTextStorage - essencialmente um invólucro sobre NSMutableAtributedString, necessário para armazenar texto e seus atributos;
  • NSTextContainer - NSObject, responsável pela forma geométrica na qual o texto é apresentado. Por padrão, é um retângulo, você pode personalizar qualquer coisa;
  • NSLayoutManager - gerencia os dois primeiros: pega texto, recua, divide em parágrafos e também é responsável pelo preenchimento necessário.

Algoritmos são legais


Como resultado, a tarefa se resume a criar um NSLayoutManadger personalizado e substituir o método desejado.

class SelectionLayoutManager: NSLayoutManager { override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) { } 

Na implementação principal, fillBackgroundRectArray obtém os retângulos de palavras e os pinta. Como os números fornecidos, em regra, são partes separadas de uma linha com uma junção em qualquer lugar, eles tiveram que ser abandonados. Daí a subtarefa dos tempos: determine os retângulos corretos que começam no início da linha e são inextricáveis ​​até o fim.

O método que resolve esse problema é o seguinte algoritmo: ele segue um loop de acordo com o parágrafo e verifica se a linha se encaixará em uma linha se a próxima palavra for adicionada a ela? Caso contrário, esta é uma linha completa separada, vá para a próxima. Nesse caso, pegue a próxima palavra e verifique a condição novamente. Sim, a implementação é extremamente simples, houve uma ideia de criar um método recursivo, mas as mãos ainda não chegaram. No comentário, peço suas opções otimizadas.

 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 } 

Vale a pena notar um caso especial de palavras que, por si só, não se encaixam em uma linha. O UITextView trabalha com eles de maneira muito simples - transferindo para a próxima linha da peça que não foi incluída. Duplicamos essa lógica em um método separado, com a mesma abordagem de passagem seqüencial, mas neste caso por símbolos. O que ele continha: uma linha completa, o restante - ou uma linha completa, no caso de uma palavra muito longa, ou apenas uma nova palavra em uma nova linha.

 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 } 

Como resultado do método detectLines (from: width: font :), obtemos uma matriz de seqüências de caracteres que são divididas corretamente ao longo das linhas. Em seguida, no método de quadros (para linhas: largura: fonte), obtemos uma matriz de coordenadas e tamanhos de linha.

 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 } 

Trazendo beleza


Subtarefa número dois: pinte os retângulos. Sob o conceito de "bonito" deveria preencher a cor selecionada do retângulo com cantos arredondados. Solução: desenhe uma camada adicional nas coordenadas especificadas no contexto usando UIBezierPath. Para melhorar a aparência das juntas, não arredondaremos as bordas do retângulo, que é menor em largura. O método é simples: seguimos as coordenadas de cada retângulo e desenhamos um contorno.

 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 } 

Em seguida, no método draw (_: color :), preencha o caminho.

 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) } } 

Código completo do método 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) } 

Começamos. Nós verificamos. Works great.


Concluindo: o costume às vezes é difícil e doentio, mas como é bonito quando funciona como deveria. Elenco, é ótimo.

O código completo pode ser encontrado aqui SelectionLayoutManager .

Referências


  1. NSLayoutManager
  2. NSTextContainer
  3. NSTextView
  4. Usando o Kit de Texto para Desenhar e Gerenciar Texto

Source: https://habr.com/ru/post/pt479992/


All Articles