源碼
https://github.com/BackWorld/VerticalLabel
前言
一般來(lái)說(shuō),UIKit自帶的UILabel只支持水平方向的文本展示(可以RTL),但無(wú)法實(shí)現(xiàn)垂直方向文本的顯示,要想實(shí)現(xiàn)豎排文本的展示,則只能手動(dòng)實(shí)現(xiàn)計(jì)算、渲染邏輯。
效果


參考思路
- 可直接通過(guò)CoreTextKit去計(jì)算frame、繪制;
- 可計(jì)算每個(gè)字符的frame,用CoreGraphics繪制(此處采用);
- 可計(jì)算每個(gè)字符的frame,添加多個(gè)UILabel顯示(subviews太多性能太差,不推薦);
實(shí)現(xiàn)
關(guān)于上述CoreTextKit繪制的方式,網(wǎng)上已有現(xiàn)成的可以作參考,但個(gè)人覺(jué)得邏輯過(guò)于復(fù)雜,不便理解和靈活修改。
字符size計(jì)算
將一段String文本計(jì)算每個(gè)字符的size,然后通過(guò)total width、total height來(lái)確定要繪制文本的區(qū)域大小。
- 計(jì)算單個(gè)字符的size:
for char in string {
let size = labelFittedSize(with: .init(char))
}
Character的擴(kuò)展方法,通過(guò)UILabel的sizeThatFits(:_)來(lái)計(jì)算,這樣的好處是可以動(dòng)態(tài)設(shè)置label的各種屬性,然后獲取label的attributtedString,用于存儲(chǔ)渲染:
定義一個(gè)全局drawLabel(工具對(duì)象)
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)
}
計(jì)算size,drawLabel為全局屬性
func labelFittedSize(with text: String) -> CGSize {
setLabelAttrText(text)
let flexibleSize = CGSize(width: .zero, height: .max)
return drawLabel.sizeThatFits(flexibleSize)
}
- 計(jì)算指定
contentSize內(nèi),一豎行(列)的字符
定義幾個(gè)數(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)}"
}
}
核心計(jì)算方法:
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
}
}
上述邏輯較為雜糅,簡(jiǎn)單來(lái)說(shuō)就是循環(huán)計(jì)算每個(gè)字符的size,然后累加size.height,如果>contentSize.height,則創(chuàng)建一個(gè)Line(words:[])對(duì)象,并加到texter.lines里,否則用words臨時(shí)變量存儲(chǔ)一個(gè)Word對(duì)象,直到i == text.count-1。
上述同時(shí)對(duì)指定行數(shù)的算法、截?cái)嗟男枨笞隽颂幚恚?/p>
enum BreakingMode: Int {
case truncate
case wordWrap
}
核心計(jì)算
func addNewLineIfNeeded() -> Bool {
x += (maxW + lineSpacing)
// 自動(dòng)截?cái)嗵幚恚? 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
}
- 計(jì)算
layoutArea:
對(duì)上述計(jì)算得到的texter里的lines.words的size進(jìn)行計(jì)算,得到一個(gè)可以容納下所有符合要求的字符的渲染區(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))
}
- 渲染文本
這里采用了一個(gè)TextsView的單獨(dú)類(lèi)來(lái)承擔(dān)字符的渲染,目的是為了方便布局對(duì)齊。
這里擴(kuò)展了一個(gè)characters數(shù)組計(jì)算屬性,將上述的texter中的數(shù)據(jù)轉(zhuǎn)換成直接可以渲染的text、frame對(duì)象。該計(jì)算也參考了用戶設(shè)置的行對(duì)齊的屬性:
enum LineAlignment: Int {
case top
case center
case bottom
}
核心計(jì)算邏輯
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ù)垂直行對(duì)齊的方式,設(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)
}
}
}
// 存儲(chǔ)屬性
private lazy var textsView: TextsView = {
let view = TextsView()
addSubview(view)
return view
}()
// 賦值,觸發(fā)渲染
textsView.characters = characters
- 計(jì)算
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計(jì)算依賴于用戶設(shè)置的水平、垂直方式的對(duì)齊方式:
enum XPosition: Int {
case left
case center
case right
}
enum YPosition: Int {
case top
case center
case bottom
}
- 外部方法:
func setNeedsUpdate() {
// 計(jì)算
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)夜放花千樹(shù),\n更吹落,星如雨。\n寶馬雕車(chē)香滿路,\n鳳簫聲動(dòng),玉壺光轉(zhuǎn),\n一夜魚(yú)龍舞。\n\n\n\n\n蛾兒雪柳黃金縷,\n笑語(yǔ)盈盈暗香去。\n眾里尋他千百度,\n驀然回首,\n那人卻在,燈火闌珊處。這是超出的文本這是超出的文本這是超出的文本這是超出的文本"
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
label.setNeedsUpdate()
}
Xib設(shè)置
