Wie Sie UITextView beibringen, sich von anderen abzuheben

Erklärung des Problems


Gegeben: mehrzeiliger Text.
Suche: schön gestalteter Hintergrund.

Ja, es ist eine Stunde, dachte ich. "Sie müssen nur backgroundColor in attributedText einfügen." Aber das hat nicht gereicht. Tatsache ist, dass die Standardauswahl ein gefülltes Rechteck ist. Hässlich Die Lösung - Sie brauchen ein Custom! Der dornige Weg seiner Entstehung veranlasste mich, den Prozess zu beschreiben, damit zukünftige Generationen nicht so viel leiden müssen. Interessiert frage ich nach Katze.

Akzeptanz


Die erste Aktion war ein klassischer Internetanruf mit der Frage nach der ordnungsgemäßen Umsetzung. Als Antwort gab es nur wenige Vorschläge. Im besten Fall wurde die Methode fillBackgroundRectArray überschrieben. Im normalen Leben ist er dafür verantwortlich, den Text unter Verwendung der oben genannten attributedText-Eigenschaft einzufärben. Um zu sehen, um was für ein Tier es sich handelt, habe ich eine fertige Lösung ausprobiert, in der Hoffnung, dass die Aufgabe noch ein paar Stunden dauern würde. Hat nicht funktioniert. Die Entscheidung hat fürchterlich geklappt.

Dokumentation ist alles.


Da ich mich entschied, dies nicht noch einmal zu tun, wandte ich mich der Dokumentation zu. Daraus wurde deutlich, dass UITextView ein einfacher Abkömmling von UIScrollView für Text ist, der von drei Gefährten gesteuert wird:

  • NSTextStorage - im Wesentlichen ein Wrapper über NSMutableAtributedString, der zum Speichern von Text und seinen Attributen benötigt wird.
  • NSTextContainer - NSObject, verantwortlich für die geometrische Form, in der der Text dargestellt wird. Standardmäßig handelt es sich um ein Rechteck. Sie können alles anpassen.
  • NSLayoutManager - verwaltet die ersten beiden: Nimmt Text, Einrückungen, Unterteilung in Absätze und ist auch für die Füllung verantwortlich, die wir benötigen.

Algorithmen sind cool


Infolgedessen läuft die Aufgabe darauf hinaus, einen benutzerdefinierten NSLayoutManadger zu erstellen und die gewünschte Methode darin zu überschreiben.

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

In der Hauptimplementierung ruft fillBackgroundRectArray die Rechtecke von Wörtern ab und malt sie. Da die angegebenen Zahlen in der Regel separate Teile einer Linie mit einer Kreuzung sind, mussten sie aufgegeben werden. Daher die Teilaufgabe der Zeit: Bestimme die richtigen Rechtecke, die am Anfang der Linie beginnen und bis zu ihrem Ende untrennbar sind.

Die Methode, die dieses Problem löst, ist der folgende Algorithmus: Er durchläuft eine Schleife gemäß dem Absatz und prüft, ob die Zeile in eine Zeile passt, wenn das nächste Wort hinzugefügt wird. Wenn nicht, ist dies eine separate vollständige Zeile. Fahren Sie mit der nächsten fort. Wenn ja, nimm das nächste Wort und überprüfe den Zustand erneut. Ja, die Implementierung ist extrem einfach, es gab eine Idee, eine rekursive Methode zu erstellen, aber die Hände sind noch nicht erreicht. In dem Kommentar frage ich Sie nach Ihren optimierten Optionen.

 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 } 

Es lohnt sich, einen speziellen Fall von Wörtern zu erwähnen, die für sich genommen nicht in eine Zeile passen. UITextView arbeitet mit ihnen sehr einfach - es wird in die nächste Zeile des Teils übertragen, das nicht enthalten war. Wir duplizieren diese Logik in einer separaten Methode mit demselben Ansatz der sequentiellen Passage, in diesem Fall jedoch durch Symbole. Was enthielt es: eine vollständige Zeile, der Rest - entweder eine vollständige Zeile, wenn es sich um ein sehr langes Wort handelt, oder nur ein neues Wort in einer neuen Zeile.

 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 } 

Als Ergebnis der Methode detectLines (from: width: font :) erhalten wir ein Array von Zeichenfolgen, die entlang der Linien korrekt aufgeteilt sind. Als nächstes erhalten wir von der Rahmenmethode (für Linien: Breite: Schriftart) ein Array von Koordinaten und Liniengrößen.

 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 } 

Schönheit bringen


Unteraufgabe Nummer zwei: Übermalen Sie die Rechtecke. Unter dem Begriff "schön" sollte die gewählte Farbe des Rechtecks ​​mit abgerundeten Ecken ausgefüllt werden. Lösung: Zeichnen Sie mit UIBezierPath eine zusätzliche Ebene an den angegebenen Koordinaten im Kontext. Damit die Fugen besser aussehen, werden die Kanten des Rechtecks, dessen Breite kleiner ist, nicht abgerundet. Die Methode ist einfach: Wir folgen den Koordinaten jedes Rechtecks ​​und zeichnen eine Kontur.

 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 } 

Füllen Sie als Nächstes in der Methode draw (_: color :) den Pfad aus.

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

Vollständiger Code der fillBackgroundRectArray-Methode (_: 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) } 

Wir fangen an. Wir prüfen. Funktioniert super.


Fazit: Sitte ist manchmal schwierig und widerlich, aber wie schön ist es, wenn sie so funktioniert, wie es soll. Besetzung, es ist großartig.

Den vollständigen Code finden Sie hier SelectionLayoutManager .

Referenzen


  1. NSLayoutManager
  2. NSTextContainer
  3. NSTextView
  4. Verwenden von Text Kit zum Zeichnen und Verwalten von Text

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


All Articles