Declaración del problema.
Dado: texto de varias líneas.
Buscar: fondo bellamente diseñado.
Sí, es una hora, pensé. "Solo necesita poner backgroundColor en atributos de texto". Pero eso no fue suficiente. El hecho es que la selección estándar es un rectángulo relleno. Feo La solución: ¡necesitas una costumbre! El camino espinoso de su creación me llevó a describir el proceso para que las generaciones futuras no tuvieran que sufrir tanto. Interesado, pido gato.
Aceptación
La primera acción fue una llamada clásica de Internet con la cuestión de la implementación adecuada. En respuesta, hubo pocas sugerencias. En el más adecuado, todo llegó a una anulación del método fillBackgroundRectArray. En la vida normal, es responsable de colorear el texto utilizando la propiedad atribuidaTexto mencionada anteriormente. Para ver qué tipo de animal es, probé una solución preparada con la esperanza de que la tarea aún fuera por un par de horas. No funcionó. La decisión funcionó horriblemente.
La documentación lo es todo.
Decidiendo no volver a hacer esto, recurrí a la documentación. De esto quedó claro que UITextView es un simple descendiente de UIScrollView para texto conducido por tres compañeros:
- NSTextStorage: esencialmente un contenedor sobre NSMutableAtributedString, necesario para almacenar texto y sus atributos;
- NSTextContainer - NSObject, responsable de la forma geométrica en la que se presenta el texto. Por defecto es un rectángulo, puede personalizar cualquier cosa;
- NSLayoutManager: administra los dos primeros: toma texto, sangra, divide en párrafos y también es responsable del relleno que necesitamos.
Los algoritmos son geniales
Como resultado, la tarea se reduce a crear un NSLayoutManadger personalizado y anular el método deseado en él.
class SelectionLayoutManager: NSLayoutManager { override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) { }
En la implementación principal, fillBackgroundRectArray obtiene los rectángulos de las palabras y las pinta. Como las figuras proporcionadas, como regla, son partes separadas de una línea con una unión en cualquier lugar, tuvieron que ser abandonadas. De ahí la subtarea de tiempos: determine los rectángulos correctos que comienzan al principio de la línea y son inextricables hasta su final.
El método que resuelve este problema es el siguiente algoritmo: va en un bucle de acuerdo con el párrafo y comprueba si la línea encajará en una línea si se le agrega la siguiente palabra. Si no, entonces esta es una línea completa separada, vaya a la siguiente. Si es así, tome la siguiente palabra y verifique la condición nuevamente. Sí, la implementación es extremadamente simple, hubo una idea para hacer un método recursivo, pero las manos aún no han llegado. En el comentario, le pido sus opciones optimizadas.
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 la pena señalar un caso especial de palabras que por sí solas no caben en una línea. UITextView trabaja con ellos de manera muy simple, transfiriendo a la siguiente línea de la parte que no estaba incluida. Duplicamos esta lógica en un método separado con el mismo enfoque de paso secuencial, pero en este caso por símbolos. Qué contenía: una línea completa, el resto, ya sea una línea completa, en el caso de una palabra muy larga, o simplemente una nueva palabra en una nueva línea.
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 del método detectLines (from: width: font :), obtenemos una serie de cadenas que se dividen correctamente a lo largo de las líneas. A continuación, del método de marcos (para líneas: ancho: fuente), obtenemos una matriz de coordenadas y tamaños de línea.
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 }
Trayendo belleza
Subtarea número dos: pintar sobre los rectángulos. Bajo el concepto de "hermoso" se suponía que debía llenar el color seleccionado del rectángulo con esquinas redondeadas. Solución: dibuje una capa adicional en las coordenadas especificadas en el contexto utilizando UIBezierPath. Para que las juntas se vean mejor, no redondearemos los bordes del rectángulo, que es más pequeño en ancho. El método es simple: seguimos las coordenadas de cada rectángulo y dibujamos un 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))
A continuación, en el método de dibujar (_: color :), rellene el camino.
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 del 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 }
Empezamos Lo comprobamos Funciona muy bien
En conclusión: la costumbre a veces es difícil y repugnante, pero qué hermosa es cuando funciona como debería. Elenco, es genial.
El código completo se puede encontrar aquí
SelectionLayoutManager .
Referencias
- NSLayoutManager
- NSTextContainer
- NSTextView
- Uso del kit de texto para dibujar y administrar texto