Comment enseigner à UITextView à se démarquer magnifiquement

Énoncé du problème


Éléments fournis: texte sur plusieurs lignes.
Trouver: arrière-plan magnifiquement conçu.

Oui, c'est une heure, pensai-je. "Vous avez juste besoin de mettre backgroundColor dans AttribedText." Mais cela ne suffisait pas. Le fait est que la sélection standard est un rectangle plein. Laid. La solution - vous avez besoin d'une personnalisation! Le chemin épineux de sa création m'a incité à décrire le processus afin que les générations futures n'aient pas à souffrir autant. Intéressé, je demande chat.

Acceptation


La première action a été un appel Internet classique avec la question d'une bonne mise en œuvre. En réponse, il y a eu peu de suggestions. Dans le plus adéquat, tout est venu à un remplacement de la méthode fillBackgroundRectArray. Dans la vie normale, il est responsable de la coloration du texte en utilisant la propriété attributeText mentionnée ci-dessus. Pour voir de quel animal il s'agit, j'ai essayé une solution toute faite dans l'espoir que la tâche serait encore de quelques heures. Ça n'a pas marché. La décision a horriblement fonctionné.

La documentation est tout.


Décidant de ne pas recommencer, je me suis tourné vers la documentation. De cela, il est devenu clair que UITextView est un simple descendant de UIScrollView pour le texte piloté par trois compagnons:

  • NSTextStorage - essentiellement un wrapper sur NSMutableAtributedString, nĂ©cessaire pour stocker le texte et ses attributs;
  • NSTextContainer - NSObject, responsable de la forme gĂ©omĂ©trique dans laquelle le texte est prĂ©sentĂ©. Par dĂ©faut, c'est un rectangle, vous pouvez personnaliser n'importe quoi;
  • NSLayoutManager - gère les deux premiers: prend le texte, met en retrait, se divise en paragraphes et est Ă©galement responsable du remplissage dont nous avons besoin.

Les algorithmes sont cool


Par conséquent, la tâche se résume à créer un NSLayoutManadger personnalisé et à remplacer la méthode souhaitée.

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

Dans l'implémentation principale, fillBackgroundRectArray récupère les rectangles de mots et les peint. Étant donné que les chiffres fournis, en règle générale, sont des parties distinctes d'une ligne avec une jonction n'importe où, ils ont dû être abandonnés. D'où la sous-tâche des temps: déterminer les rectangles corrects qui commencent au début de la ligne et sont inextricables jusqu'à sa fin.

La méthode qui résout ce problème est l'algorithme suivant: il va dans une boucle selon le paragraphe et vérifie si la ligne tiendra sur une ligne si le mot suivant lui est ajouté? Sinon, il s'agit d'une ligne complète distincte, passez à la suivante. Si c'est le cas, prenez le mot suivant et vérifiez à nouveau la condition. Oui, l'implémentation est extrêmement simple, il y avait une idée de faire une méthode récursive, mais les mains n'ont pas encore atteint. Dans le commentaire, je vous demande vos options optimisées.

 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 } 

Il convient de noter un cas particulier de mots qui, à eux seuls, ne correspondent pas à une ligne. UITextView fonctionne avec eux très simplement - en transférant à la ligne suivante de la pièce qui n'était pas incluse. Nous dupliquons cette logique dans une méthode séparée avec la même approche de passage séquentiel, mais dans ce cas par des symboles. Que contenait-il: une ligne complète, le reste - soit une ligne complète, dans le cas d'un mot très long, ou juste un nouveau mot sur une nouvelle ligne.

 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 } 

Grâce à la méthode detectLines (from: width: font :), nous obtenons un tableau de chaînes qui sont correctement divisées le long des lignes. Ensuite, à partir de la méthode des cadres (pour les lignes: largeur: police), nous obtenons un tableau de coordonnées et de tailles de ligne.

 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 } 

Apporter la beauté


Sous-tâche numéro deux: peignez les rectangles. Sous le concept de "beau" était censé remplir la couleur sélectionnée du rectangle avec des coins arrondis. Solution: dessinez un calque supplémentaire aux coordonnées spécifiées dans le contexte à l'aide de UIBezierPath. Pour rendre les joints plus beaux, nous n'allons pas arrondir les bords du rectangle, qui est plus petit en largeur. La méthode est simple: nous suivons les coordonnées de chaque rectangle et dessinons un contour.

 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 } 

Ensuite, dans la méthode draw (_: color :), remplissez le chemin.

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

Code complet de la méthode 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) } 

Nous commençons. Nous vérifions. Fonctionne très bien.


En conclusion: la coutume est parfois difficile et Ă©coeurante, mais comme elle est belle quand elle fonctionne comme il se doit. Cast, c'est super.

Le code complet peut être trouvé ici SelectionLayoutManager .

Les références


  1. NSLayoutManager
  2. NSTextContainer
  3. NSTextView
  4. Utilisation de Text Kit pour dessiner et gérer du texte

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


All Articles