Pernyataan masalah
Diberikan: teks multi-baris.
Temukan: latar belakang yang dirancang dengan indah.
Ya, satu jam, pikirku. "Anda hanya perlu meletakkan backgroundColor di attributedText." Tapi itu tidak cukup. Faktanya adalah bahwa pemilihan standar adalah persegi panjang yang diisi. Jelek. Solusinya - Anda perlu kustom! Jalan berduri penciptaannya mendorong saya untuk menggambarkan proses sehingga generasi masa depan tidak harus terlalu menderita. Tertarik, saya minta kucing.
Penerimaan
Tindakan pertama adalah panggilan Internet klasik dengan pertanyaan implementasi yang tepat. Sebagai tanggapan, ada beberapa saran. Yang paling memadai, semuanya sampai pada metode fillBackgroundRectArray. Dalam kehidupan normal, ia bertanggung jawab untuk mewarnai teks dengan menggunakan properti attributedText yang disebutkan di atas. Untuk melihat hewan seperti apa itu, saya mencoba solusi yang sudah jadi dengan harapan tugas itu masih akan berlangsung selama beberapa jam. Itu tidak berhasil. Keputusan itu bekerja dengan sangat buruk.
Dokumentasi adalah segalanya.
Memutuskan untuk tidak melakukan ini lagi, saya beralih ke dokumentasi. Dari sini menjadi jelas bahwa UITextView adalah turunan sederhana dari UIScrollView untuk teks yang digerakkan oleh tiga teman:
- NSTextStorage - pada dasarnya pembungkus NSMutableAtributedString, diperlukan untuk menyimpan teks dan atributnya;
- NSTextContainer - NSObject, bertanggung jawab atas bentuk geometris di mana teks disajikan. Secara default itu adalah persegi panjang, Anda dapat menyesuaikan apa pun;
- NSLayoutManager - mengelola dua yang pertama: mengambil teks, indentasi, dibagi menjadi paragraf, dan juga bertanggung jawab atas isi yang kita butuhkan.
Algoritma itu keren
Akibatnya, tugas bermuara pada membuat NSLayoutManadger kustom dan mengganti metode yang diinginkan di dalamnya.
class SelectionLayoutManager: NSLayoutManager { override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) { }
Dalam implementasi utama, fillBackgroundRectArray mendapatkan segi empat kata dan melukisnya. Karena angka-angka yang disediakan, sebagai suatu peraturan, adalah bagian yang terpisah dari satu garis dengan persimpangan di mana saja, mereka harus ditinggalkan. Oleh karena itu subtugas kali: tentukan persegi panjang yang benar yang dimulai pada awal baris dan tidak dapat dipisahkan sampai akhir.
Metode yang memecahkan masalah ini adalah algoritma berikut: ia berjalan dalam satu lingkaran sesuai dengan paragraf dan memeriksa apakah baris tersebut akan cocok dalam satu baris jika kata berikutnya ditambahkan ke dalamnya? Jika tidak, maka ini adalah baris penuh yang terpisah, pergi ke yang berikutnya. Jika demikian, ambil kata berikutnya dan periksa kembali kondisinya. Ya, implementasinya sangat sederhana, ada ide untuk membuat metode rekursif, tetapi tangan belum mencapai. Dalam komentar, saya meminta opsi yang dioptimalkan.
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 }
Perlu dicatat kasus kata-kata khusus yang dengan sendirinya tidak cocok dalam satu baris. UITextView bekerja dengan sangat sederhana - mentransfer ke baris berikutnya dari bagian yang tidak termasuk. Kami menduplikasi logika ini dalam metode terpisah dengan pendekatan yang sama dari bagian berurutan, tetapi dalam hal ini dengan simbol. Apa yang dikandungnya: baris penuh, sisanya - baik baris penuh, dalam kasus kata yang sangat panjang, atau hanya kata baru di baris baru.
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 }
Sebagai hasil dari metode detectLines (dari: width: font :), kami mendapatkan array string yang dibagi dengan benar di sepanjang baris. Selanjutnya, dari metode bingkai (untuk garis: lebar: font), kita mendapatkan larik koordinat dan ukuran garis.
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 }
Membawa kecantikan
Subtask nomor dua: cat di atas persegi panjang. Di bawah konsep "indah" seharusnya mengisi warna persegi panjang yang dipilih dengan sudut bulat. Solusi: menggambar lapisan tambahan pada koordinat yang ditentukan dalam konteks menggunakan UIBezierPath. Untuk membuat sambungan terlihat lebih baik, kami tidak akan membulatkan tepi persegi panjang, yang lebarnya lebih kecil. Metodenya sederhana: kita mengikuti koordinat masing-masing persegi panjang dan menggambar 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))
Selanjutnya, dalam metode draw (_: color :), isi path.
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) } }
Kode lengkap metode 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 }
Kita mulai. Kami periksa. Bagus sekali.
Kesimpulannya: adat kadang-kadang sulit dan memuakkan, tetapi betapa indahnya ketika itu berfungsi sebagaimana mestinya. Cast, ini bagus.
Kode lengkap dapat ditemukan di sini
SelectionLayoutManager .
Referensi
- NSLayoutManager
- NSTextContainer
- NSTextView
- Menggunakan Text Kit untuk Menggambar dan Mengelola Teks