源碼
https://github.com/BackWorld/VerticalLabel
前言
一般來說,UIKit自帶的UILabel只支持水平方向的文本展示(可以RTL),但無法實現(xiàn)垂直方向文本的顯示,要想實現(xiàn)豎排文本的展示,則只能手動實現(xiàn)計算、渲染邏輯。
效果
參考思路
- 可直接通過CoreTextKit去計算frame、繪制;
- 可計算每個字符的frame,用CoreGraphics繪制(此處采用);
- 可計算每個字符的frame,添加多個UILabel顯示(subviews太多性能太差,不推薦);
實現(xiàn)
關(guān)于上述CoreTextKit繪制的方式,網(wǎng)上已有現(xiàn)成的可以作參考,但個人覺得邏輯過于復(fù)雜,不便理解和靈活修改。
字符size計算
將一段String文本計算每個字符的size
,然后通過total width
、total height
來確定要繪制文本的區(qū)域大小。
- 計算單個字符的size:
for char in string {
let size = labelFittedSize(with: .init(char))
}
Character
的擴展方法,通過UILabel
的sizeThatFits(:_)
來計算,這樣的好處是可以動態(tài)設(shè)置label的各種屬性,然后獲取label的attributtedString
,用于存儲渲染:
定義一個全局drawLabel
(工具對象)
private lazy var tmpLabel: UILabel = {
let lb = UILabel()
lb.font = font
lb.text = text
lb.textAlignment = .center
lb.numberOfLines = 0
return lb
}()
// 每次調(diào)用,都設(shè)置一下font,color
private var drawLabel: UILabel {
tmpLabel.font = font
tmpLabel.textColor = textColor
return tmpLabel
}
重新設(shè)置段落高度屬性
func setLabelAttrText(_ text: String) {
drawLabel.text = text
guard let attrText = drawLabel.attributedText else {
return
}
var range = NSMakeRange(0, text.count)
var attrs = attrText.attributes(at: 0, effectiveRange: &range)
if let pg = attrs[.paragraphStyle] as? NSParagraphStyle,
let mpg = pg.mutableCopy() as? NSMutableParagraphStyle {
mpg.lineHeightMultiple = wordSpacing
attrs[.paragraphStyle] = mpg
}
drawLabel.attributedText = NSAttributedString(string: text, attributes: attrs)
}
計算size,drawLabel為全局屬性
func labelFittedSize(with text: String) -> CGSize {
setLabelAttrText(text)
let flexibleSize = CGSize(width: .zero, height: .max)
return drawLabel.sizeThatFits(flexibleSize)
}
- 計算指定
contentSize
內(nèi),一豎行(列)的字符
定義幾個數(shù)據(jù)模型:
class Texter {
var lines: [Line] = []
class Line: CustomStringConvertible {
var words: [Word]
var maxWidth: CGFloat
var height: CGFloat {
return words.reduce(0){ $0 + $1.size.height }
}
init(words: [Word], maxWidth: CGFloat) {
self.words = words
self.maxWidth = maxWidth
}
var description: String {
return "{words: \(words)}, {maxWidth: \(maxWidth)}"
}
}
class Word: CustomStringConvertible {
var text: NSAttributedString
var size: CGSize
init(text: NSAttributedString, size: CGSize) {
self.text = text
self.size = size
}
var description: String {
return "{text: \(text.string)}, {size: \(size)}"
}
}
}
// 渲染字符用
class Character: CustomStringConvertible {
var text: NSAttributedString
var frame: CGRect
init(text: NSAttributedString, frame: CGRect) {
self.text = text
self.frame = frame
}
var description: String {
return "{text: \(text)}, frame: {\(frame)}"
}
}
核心計算方法:
func calculating() {
guard let text = text else {
return
}
texter = .init()
var y = CGFloat.zero
var x = CGFloat.zero
var maxW = CGFloat.zero
var words: [Texter.Word] = []
var isChangedLine = false
func resetValues() {
y = 0
maxW = 0
words = []
isChangedLine = true
}
func addNewLineIfNeeded() -> Bool {
x += (maxW + lineSpacing)
if x > contentSize.width {
if breaking == .truncate,
let words = texter.lines.last?.words,
words.count >= 3
{
let size = labelFittedSize(with: ".")
let text = drawLabel.attributedText!
words[words.count-3..<words.count].forEach{
$0.text = text
$0.size = size
}
texter.lines.last?.words = words
}
return false
}
texter.lines.append(.init(words: words, maxWidth: maxW))
if limitedLines > 0, texter.lines.count == limitedLines {
return false
}
return true
}
func addWord(size: CGSize){
words.append(.init(text: drawLabel.attributedText!, size: size))
}
for (i,char) in text.enumerated()
{
isChangedLine = false
if char.isNewline {
if !addNewLineIfNeeded() {
break
}
resetValues()
continue
}
let str = String(char)
let size = labelFittedSize(with: str)
if maxW < size.width {
maxW = size.width
}
y += size.height
if y > contentSize.height {
if !addNewLineIfNeeded() {
break
}
resetValues()
addWord(size: size)
}
else {
y -= size.height
addWord(size: size)
}
if !isChangedLine, i == text.count-1 {
if !addNewLineIfNeeded() {
break
}
}
y += size.height
}
}
上述邏輯較為雜糅,簡單來說就是循環(huán)計算每個字符的size,然后累加size.height,如果>contentSize.height
,則創(chuàng)建一個Line(words:[])
對象,并加到texter.lines
里,否則用words
臨時變量存儲一個Word
對象,直到i == text.count-1
。
上述同時對指定行數(shù)的算法、截斷的需求做了處理:
enum BreakingMode: Int {
case truncate
case wordWrap
}
核心計算
func addNewLineIfNeeded() -> Bool {
x += (maxW + lineSpacing)
// 自動截斷處理:
if x > contentSize.width {
if breaking == .truncate,
let words = texter.lines.last?.words,
words.count >= 3
{
let size = labelFittedSize(with: ".")
let text = drawLabel.attributedText!
words[words.count-3..<words.count].forEach{
$0.text = text
$0.size = size
}
texter.lines.last?.words = words
}
return false
}
texter.lines.append(.init(words: words, maxWidth: maxW))
// 行數(shù)限制處理:
if limitedLines > 0, texter.lines.count == limitedLines {
return false
}
return true
}
- 計算
layoutArea
:
對上述計算得到的texter
里的lines
.words
的size
進(jìn)行計算,得到一個可以容納下所有符合要求的字符的渲染區(qū)域(CGRect):
var textsArea: CGRect {
let lines = texter.lines
let w = lines.reduce(0){ $0 + $1.maxWidth + lineSpacing } - lineSpacing
let heights = lines.map{ $0.height }
guard
let h = heights.max(by: { $0 <= $1 }) else {
return .zero
}
return .init(origin: .zero, size: .init(width: w, height: h))
}
- 渲染文本
這里采用了一個TextsView
的單獨類來承擔(dān)字符的渲染,目的是為了方便布局對齊。
這里擴展了一個characters
數(shù)組計算屬性,將上述的texter中的數(shù)據(jù)轉(zhuǎn)換成直接可以渲染的text、frame
對象。該計算也參考了用戶設(shè)置的行對齊的屬性:
enum LineAlignment: Int {
case top
case center
case bottom
}
核心計算邏輯
var characters: [Character] {
guard let firstLine = texter.lines.first else {
return []
}
var x: CGFloat = isLTR ? 0 : (textsArea.maxX - firstLine.maxWidth)
var yBase: CGFloat = 0
var y: CGFloat = 0
let area = textsArea
var list: [Character] = []
for line in texter.lines {
// 根據(jù)垂直行對齊的方式,設(shè)置y的base參考線值
switch lineAlignment {
case .top: yBase = 0
case .center: yBase = (area.height - line.height) / 2
case .bottom: yBase = area.height - line.height
}
y = yBase
for word in line.words {
list.append(.init(text: word.text, frame: .init(origin: .init(x: x, y: y), size: word.size)))
y += word.size.height
}
if isLTR {
x += (line.maxWidth + lineSpacing)
}
else {
x -= (line.maxWidth + lineSpacing)
}
}
return list
}
字符渲染:
class TextsView: UIView {
var characters: [Character] = [] {
didSet{
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
super.draw(rect)
for c in characters {
c.text.draw(in: c.frame)
}
}
}
// 存儲屬性
private lazy var textsView: TextsView = {
let view = TextsView()
addSubview(view)
return view
}()
// 賦值,觸發(fā)渲染
textsView.characters = characters
- 計算
TextsView
的frame
:
var area = textsArea
switch (xPosition, yPosition) {
case (.left, .top):
area.origin = .zero
case (.left, .center):
area.origin.y = (contentSize.height - area.size.height)/2
case (.left, .bottom):
area.origin.y = contentSize.height - area.size.height
case (.right, .top):
area.origin.x = contentSize.width - area.size.width
case (.right, .center):
area.origin.x = contentSize.width - area.size.width
area.origin.y = (contentSize.height - area.size.height)/2
case (.right, .bottom):
area.origin.x = contentSize.width - area.size.width
area.origin.y = contentSize.height - area.size.height
case (.center, .top):
area.origin.x = (contentSize.width - area.size.width) / 2
case (.center, .center):
area.origin.x = (contentSize.width - area.size.width) / 2
area.origin.y = (contentSize.height - area.size.height)/2
case (.center, .bottom):
area.origin.x = (contentSize.width - area.size.width) / 2
area.origin.y = contentSize.height - area.size.height
}
textsView.backgroundColor = .clear
textsView.frame = area
上述frame
計算依賴于用戶設(shè)置的水平、垂直方式的對齊方式:
enum XPosition: Int {
case left
case center
case right
}
enum YPosition: Int {
case top
case center
case bottom
}
- 外部方法:
func setNeedsUpdate() {
// 計算
calculating()
// 渲染
drawingTexts()
}
}
6. 外部使用:
```swift
@IBOutlet weak var label: VerticalLabel!
@IBAction func xAlignChanged(_ sender: UISegmentedControl) {
label.horizontal = sender.selectedSegmentIndex
}
@IBAction func yAlignChanged(_ sender: UISegmentedControl) {
label.vertical = sender.selectedSegmentIndex
}
@IBAction func directionChanged(_ sender: UISegmentedControl) {
label.direction = sender.selectedSegmentIndex
}
@IBAction func lineAlignmentChanged(_ sender: UISegmentedControl) {
label.lineAlign = sender.selectedSegmentIndex
}
override func viewDidLoad() {
super.viewDidLoad()
label.font = .boldSystemFont(ofSize: 24)
label.text = "東風(fēng)夜放花千樹,\n更吹落,星如雨。\n寶馬雕車香滿路,\n鳳簫聲動,玉壺光轉(zhuǎn),\n一夜魚龍舞。\n\n\n\n\n蛾兒雪柳黃金縷,\n笑語盈盈暗香去。\n眾里尋他千百度,\n驀然回首,\n那人卻在,燈火闌珊處。這是超出的文本這是超出的文本這是超出的文本這是超出的文本"
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
label.setNeedsUpdate()
}