源码
https://github.com/BackWorld/VerticalLabel
前言
一般来说,UIKit自带的UILabel只支持水平方向的文本展示(可以RTL),但无法实现垂直方向文本的显示,要想实现竖排文本的展示,则只能手动实现计算、渲染逻辑。
效果
参考思路
- 可直接通过CoreTextKit去计算frame、绘制;
- 可计算每个字符的frame,用CoreGraphics绘制(此处采用);
- 可计算每个字符的frame,添加多个UILabel显示(subviews太多性能太差,不推荐);
实现
关于上述CoreTextKit绘制的方式,网上已有现成的可以作参考,但个人觉得逻辑过于复杂,不便理解和灵活修改。
字符size计算
将一段String文本计算每个字符的size
,然后通过total width
、total height
来确定要绘制文本的区域大小。
- 计算单个字符的size:
for char in string {
let size = labelFittedSize(with: .init(char))
}
Character
的扩展方法,通过UILabel
的sizeThatFits(:_)
来计算,这样的好处是可以动态设置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
}()
// 每次调用,都设置一下font,color
private var drawLabel: UILabel {
tmpLabel.font = font
tmpLabel.textColor = textColor
return tmpLabel
}
重新设置段落高度属性
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
内,一竖行(列)的字符
定义几个数据模型:
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
}
}
上述逻辑较为杂糅,简单来说就是循环计算每个字符的size,然后累加size.height,如果>contentSize.height
,则创建一个Line(words:[])
对象,并加到texter.lines
里,否则用words
临时变量存储一个Word
对象,直到i == text.count-1
。
上述同时对指定行数的算法、截断的需求做了处理:
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))
// 行数限制处理:
if limitedLines > 0, texter.lines.count == limitedLines {
return false
}
return true
}
- 计算
layoutArea
:
对上述计算得到的texter
里的lines
.words
的size
进行计算,得到一个可以容纳下所有符合要求的字符的渲染区域(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
的单独类来承担字符的渲染,目的是为了方便布局对齐。
这里扩展了一个characters
数组计算属性,将上述的texter中的数据转换成直接可以渲染的text、frame
对象。该计算也参考了用户设置的行对齐的属性:
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 {
// 根据垂直行对齐的方式,设置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
}()
// 赋值,触发渲染
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
计算依赖于用户设置的水平、垂直方式的对齐方式:
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 = "东风夜放花千树,\n更吹落,星如雨。\n宝马雕车香满路,\n凤箫声动,玉壶光转,\n一夜鱼龙舞。\n\n\n\n\n蛾儿雪柳黄金缕,\n笑语盈盈暗香去。\n众里寻他千百度,\n蓦然回首,\n那人却在,灯火阑珊处。这是超出的文本这是超出的文本这是超出的文本这是超出的文本"
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
label.setNeedsUpdate()
}