Nano Notes avec titres formatés
Nano Notes est une application très simple. Un écran avec la liste des notes et un écran pour modifier une note. Côté code, ça donne une UITableView
et une UITextView
.
Pour chaque note, la première ligne constitue son titre. Sur la liste des notes, ces titres sont affichés en gras. Pour l’écran d’édition, il serait donc logique d’afficher la première ligne en gras. Hélas, hélas, je n’ai jamais trouvé comment implémenter ça en gardant le texte modifiable. Je me doutais que la solution était du côté de Core Text, et demanderait un niveau de complexité bien trop élevé pour une application si modeste.
Et puis, un beau jour (le mois dernier), j’ai trouvé une solution !
L’astuce consiste à exploiter la propriété attributedText
de UITextView
. Plus précisément, avec textStorage
, il est possible d’ajouter et de retirer des attributs sans affecter la saisie en cours. Voici donc le résultat :

Vous ne pouvez pas savoir comme ce changement me fait plaisir. L’application a enfin l’interface qui convient !
Pour les plus curieux, voici le code en question. Ce n’est pas très élégant, mais ça fonctionne. Quand le texte change, il faut regarder si la première ligne se termine par un texte non-gras (on applique alors l’attribut), ou si la deuxième ligne commence par du gras (on retire alors l’attribut). Les va-et-vient entre NSString
/NSRange
et String
/Range
sont assez pénibles, mais difficiles à éviter pour la configuration actuelle du projet.
func configureView() {
// ...
let attributedText = NSMutableAttributedString(string: text, attributes: [.font: noteFont(), .foregroundColor: UIColor.label])
let range = NSString(string: text).range(of: text.firstLineAsTitle())
if range.location != NSNotFound {
attributedText.setAttributes([.font: titleFont(), .foregroundColor: UIColor.label], range: range)
}
textView?.attributedText = attributedText
textView?.isEditable = true
}
// ...
func textViewDidChange(_ textView: UITextView) {
let firstLineBreakRange = NSString(string: textView.text).range(of: "\n")
guard firstLineBreakRange.location != NSNotFound else {
// Always set first line as bold
let range = NSRange(location: 0, length: textView.attributedText.length)
textView.textStorage.removeAttribute(.font, range: range)
textView.textStorage.addAttribute(.font, value: titleFont(), range: range)
return
}
var effectiveRange = NSRange()
let attributesAtFirstLineBreak = textView.attributedText.attributes(at: firstLineBreakRange.lowerBound, effectiveRange: &effectiveRange)
if let fontAtFirstLineBreak = attributesAtFirstLineBreak[.font] as? UIFont, fontAtFirstLineBreak.fontDescriptor.symbolicTraits.contains(.traitBold) {
// If bold after the first line break, then remove the attribute for the range after the break
let rangeToClean = NSRange(location: firstLineBreakRange.location, length: effectiveRange.upperBound - firstLineBreakRange.location )
textView.textStorage.removeAttribute(.font, range: rangeToClean)
textView.textStorage.addAttribute(.font, value: noteFont(), range: rangeToClean)
textView.typingAttributes = [.font: noteFont(), .foregroundColor: UIColor.label]
}
if firstLineBreakRange.lowerBound > 0 {
let attributesBeforeFirstLineBreak = textView.attributedText.attributes(at: firstLineBreakRange.lowerBound - 1, effectiveRange: &effectiveRange)
if let fontBeforeFirstLineBreak = attributesBeforeFirstLineBreak[.font] as? UIFont, !fontBeforeFirstLineBreak.fontDescriptor.symbolicTraits.contains(.traitBold) {
// If not bold before the first line break, then remove the attribute for the range after the break
let range = NSRange(location: 0, length: firstLineBreakRange.lowerBound)
textView.textStorage.removeAttribute(.font, range: range)
textView.textStorage.addAttribute(.font, value: titleFont(), range: range)
}
}
}