2025.01.28 工作變動(dòng)原因,故將一些工作期間Tapd內(nèi)部寫(xiě)的Wiki文檔轉(zhuǎn)移到個(gè)人博客。
一個(gè) ( 展開(kāi) / 收起 ) 式通用文本容器,這只是一個(gè)實(shí)現(xiàn)基本功能的 Demo 例子。
實(shí)際應(yīng)用中還可以去做更多的性能優(yōu)化~,比如:預(yù)判斷截取、預(yù)判斷逆向遍歷還是正向遍歷、優(yōu)化遍歷次數(shù) 等等。
初版源碼的
setupExpandableText()中使用了boundingRect()去計(jì)算換行方式引起了精度不足的問(wèn)題。因此,換行計(jì)算方式在文中新更新 ————————— 2024.01.07 更新 ————————— 有優(yōu)化。
一、需求

如效果圖所示,需要完成一個(gè)文本容器(如果文本超過(guò)3行,后綴文本改為...展開(kāi))。
回顧項(xiàng)目里以往的代碼,并沒(méi)有一個(gè)開(kāi)箱即用的通用小組件,這里就決定完成一個(gè)通用小組件 (一個(gè)帶 展開(kāi)/收起 功能的裁剪文本容器)。
二、技術(shù)設(shè)計(jì)思路
首先,可以知道需要引用一個(gè)動(dòng)態(tài)布局的通用小組件,我們需要讓持有者知道小組件的高度。
第二,可以支持靈活自定義比較重要的字段(裁剪后綴文本、字體、段落樣式、要裁剪的行數(shù))
第三,超鏈接文本展開(kāi)和收起的動(dòng)作回調(diào)(方便業(yè)務(wù)有特殊處理)
三、小組件源碼(初版,在后面對(duì)setupExpandableText()中的計(jì)算換行方法有優(yōu)化)
// Created by Yim on 2023/10/20.
// 展開(kāi)/收起 式通用文本容器
import UIKit
class YAYExpandableTextView: UITextView {
/// 輸入文本
var originalText = ""
/// 收起后的文本
var collapseText = ""
/// 自定義的(展開(kāi))拼接文本
var suffixStr = ""
/// 自定義的(收起)拼接文本
var closeStr = ""
/// 文本寬度
var textWidth: CGFloat = 0
/// 字體
var textFont = UIFont()
/// 字體顏色
var expandableTextColor: UIColor = .white
/// 展開(kāi)/收起 高度回調(diào),點(diǎn)擊回調(diào)
var viewHieghtClosure: ((CGFloat, String) -> ())?
/// 段落樣式
lazy var paraStyle: NSMutableParagraphStyle = {
let paraStyle = NSMutableParagraphStyle()
paraStyle.lineBreakMode = NSLineBreakMode.byCharWrapping
paraStyle.alignment = NSTextAlignment.left
paraStyle.lineSpacing = 5
paraStyle.hyphenationFactor = 0.0
paraStyle.firstLineHeadIndent = 0.0
paraStyle.paragraphSpacingBefore = 0.0
paraStyle.headIndent = 0
paraStyle.tailIndent = 0
return paraStyle
}()
// MARK: - 初始化
/// 將文本按長(zhǎng)度度截取并加上指定后綴
/// @param str 文本
/// @param suffixStr 指定后綴
/// @param font 文本字體
/// @param textWidth 文本長(zhǎng)度
/// @param num 多少行
func setupExpandableText(_ str: String, suffixStr: String = "...展開(kāi)", closeStr: String = " 收起", textFont: UIFont, textColor: UIColor, textWidth: CGFloat, numberOfRows row: Int) {
self.delegate = self
self.isEditable = false
self.bounces = false
// 原文本
self.originalText = str
// 字體
self.textFont = textFont
// 字體顏色
self.expandableTextColor = textColor
// 文本寬度
self.textWidth = textWidth
// 自定義的(展開(kāi))拼接文本
self.suffixStr = suffixStr
// 自定義的(收起)拼接文本
self.closeStr = closeStr
// 記錄高度變化次數(shù)(相當(dāng)于行數(shù))
var wrapTimes: Int = 0
var wrapHeight: CGFloat = 0
for i in 0..<str.count {
// 截取下標(biāo)
let index = str.index(str.startIndex, offsetBy: i)
// 截取后的文本
let tempStr = String(str[..<index])
// 文本寬高size
let size = tempStr.boundingRect(with: CGSize(width: textWidth, height: CGFloat.greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine], attributes: [
.font: textFont,
.paragraphStyle: paraStyle,
], context: nil).size
// 記錄換行次數(shù)
if wrapHeight != size.height {
wrapTimes = wrapTimes + 1
wrapHeight = size.height
// 換行超出指定行數(shù),進(jìn)行裁剪
if wrapTimes == row + 1 {
// 因?yàn)橐呀?jīng)是剛好超出的第一個(gè)字符,所以是 截取文本長(zhǎng)度 - 1 - 要拼接的文本長(zhǎng)度(再拼 - 1,優(yōu)化效果,順便防止特殊字符長(zhǎng)度比較長(zhǎng))
let cutIndex = tempStr.index(tempStr.startIndex, offsetBy: tempStr.count - 1 - suffixStr.count - 1)
let colText = String(tempStr[..<cutIndex])
collapseText = "\(colText)\(suffixStr)"
// 返回高度
let viewHeight = getTextViewSize(self.configDidOpenClose().string, with: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didOpenClose")
return
}
}
}
// 不需要額外處理,基礎(chǔ)樣式文本
let attributedText = NSAttributedString(string: originalText, attributes: [
NSAttributedString.Key.foregroundColor: expandableTextColor,
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle
])
self.attributedText = attributedText
// 返回高度
let viewHeight = getTextViewSize(attributedText.string, with: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "")
}
// MARK: - private
/// 默認(rèn)收起的配置
func configDidDownClose() -> NSMutableAttributedString {
let attributedText = NSMutableAttributedString()
// 添加自定義文本樣式
let attStr1 = NSAttributedString(string: originalText, attributes: [
NSAttributedString.Key.foregroundColor: expandableTextColor
])
attributedText.append(attStr1)
// 添加(收起)后綴文本樣式
let attStr2 = NSAttributedString(string: closeStr, attributes: [
NSAttributedString.Key.foregroundColor: UIColor.hexColor(hex: "#39639E")
])
attributedText.append(attStr2)
// 添加基礎(chǔ)段落樣式
attributedText.addAttributes([
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle,
], range: NSRange(location: 0, length: attributedText.string.count))
// 給富文本后面增加可操作的點(diǎn)擊鏈接通過(guò)代理來(lái)實(shí)現(xiàn)
attributedText.addAttribute(NSAttributedString.Key.link, value: "didDownClose://", range: NSRange(location: attributedText.string.count - closeStr.count, length: closeStr.count))
self.attributedText = attributedText
return attributedText
}
/// 默認(rèn)打開(kāi)的配置
func configDidOpenClose() -> NSMutableAttributedString {
let attributedText = NSMutableAttributedString(string: collapseText)
// 添加自定義文本樣式
attributedText.addAttributes([
NSAttributedString.Key.foregroundColor: expandableTextColor
], range: NSRange(location: 0, length: collapseText.count - suffixStr.count))
// 添加(展開(kāi))后綴文本樣式
attributedText.addAttributes([
NSAttributedString.Key.foregroundColor: UIColor.hexColor(hex: "#39639E")
], range: NSRange(location: collapseText.count - suffixStr.count, length: suffixStr.count))
// 添加基礎(chǔ)段落樣式
attributedText.addAttributes([
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle,
], range: NSRange(location: 0, length: collapseText.count))
// 給富文本后面增加可操作的點(diǎn)擊鏈接通過(guò)代理來(lái)實(shí)現(xiàn)
attributedText.addAttribute(NSAttributedString.Key.link, value: "didOpenClose://", range: NSRange(location: collapseText.count - suffixStr.count, length: suffixStr.count))
self.attributedText = attributedText
return attributedText
}
/// 計(jì)算文本高度
func getTextViewSize(_ text: String, with font: UIFont, width: CGFloat) -> CGSize {
// 系統(tǒng)計(jì)算最佳適配size,可能會(huì)調(diào)用渲染,消耗性能,所以放在最后計(jì)算顯示用
return self.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
}
}
extension YAYExpandableTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
// 點(diǎn)擊 展開(kāi)
if URL.scheme == "didOpenClose" {
// 變成 收起 的配置
let viewHeight = getTextViewSize(self.configDidDownClose().string, with: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didOpenClose")
return false
}
// 點(diǎn)擊 收起
if URL.scheme == "didDownClose" {
// 變成 展開(kāi) 的配置
let viewHeight = getTextViewSize(self.configDidOpenClose().string, with: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didDownClose")
return false
}
return true
}
}
具體的關(guān)鍵邏輯和可自定義配置的字段都添加了注釋,詳細(xì)解釋看代碼就可以了。
四、具體使用

用 lazy var introductionTextView: YAYExpandableTextView 初始化了小組件,調(diào)用 setupExpandableText()方法進(jìn)行文本初始化。在 viewHieghtClosure 中獲取了小組件的高度,回調(diào)對(duì)整個(gè)頁(yè)面布局進(jìn)行刷新。
最后,完成了一個(gè)開(kāi)箱即用的通用小組件,以后類似的場(chǎng)景就可以直接引用該文本容器了。
————————— 2024.01.07 更新 —————————
書(shū)接上文,在上文中實(shí)現(xiàn)的小組件,經(jīng)過(guò)和同事的溝通(
單方面被要求)后,需要精進(jìn)換行文本最后一位的準(zhǔn)度。經(jīng)過(guò)長(zhǎng)時(shí)間(一下午)研究與調(diào)試過(guò)后,達(dá)到了既定目標(biāo),在此分享一下自己摸索出來(lái)的實(shí)現(xiàn)寫(xiě)法。
五、問(wèn)題
iOS提供的 boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], attributes: [NSAttributedString.Key : Any]? = nil, context: NSStringDrawingContext?) -> CGRect 方法,用來(lái)計(jì)算 String 文本寬度去預(yù)估換行。
但是最終渲染結(jié)果和 boundingRect 計(jì)算出來(lái)的換行會(huì)有一些出入,所以需要更換成 sizeThatFits(_ size: CGSize) -> CGSize (調(diào)用渲染匹配文本的最佳size)去計(jì)算布局。
六、難點(diǎn)
使用 sizeThatFits(_ size: CGSize) -> CGSize 計(jì)算布局,得出來(lái)的 CGSize 是會(huì)動(dòng)態(tài)變化的,導(dǎo)致原來(lái)的計(jì)算換行方式不能用。
經(jīng)過(guò)調(diào)試后使用了一種能解決絕大多數(shù)情況的取巧解決辦法,如果有更好更優(yōu)雅的寫(xiě)法的話請(qǐng)各位工友指點(diǎn)迷津~~~
七、重要邏輯優(yōu)化
/// 將文本按長(zhǎng)度度截取并加上指定后綴
/// @param str 文本
/// @param suffixStr 指定后綴
/// @param font 文本字體
/// @param textWidth 文本長(zhǎng)度
/// @param num 多少行
/// @param needCrop 是否需要裁剪文本
func setupExpandableText(_ str: String, suffixStr: String = "...展開(kāi)", closeStr: String = " 收起", textFont: UIFont, textColor: UIColor, textWidth: CGFloat, numberOfRows row: Int, needCrop: Bool = true) {
self.delegate = self
self.isEditable = true
self.bounces = false
// 原文本
self.originalText = str
// 字體
self.textFont = textFont
// 字體顏色
self.expandableTextColor = textColor
// 文本寬度
self.textWidth = textWidth
// 自定義的(展開(kāi))拼接文本
self.suffixStr = suffixStr
// 自定義的(收起)拼接文本
self.closeStr = closeStr
if needCrop {
var str = self.originalText
// 記錄高度變化次數(shù)(相當(dāng)于行數(shù))
var wrapTimes: Int = 0
// 記錄第一行文本的所需高度
var firstRowMaxHeight: CGFloat = 0
// 記錄上一次的size
var oldSize: CGSize = CGSize(width: 0, height: 0)
// 遍歷字符串,看是否需要進(jìn)行裁剪拼接
for i in 0..<str.count {
// 截取下標(biāo)
let index = str.index(str.startIndex, offsetBy: i)
// 截取后的文本
let tempStr = String(str[..<index])
// 文本賦值
self.attributedText = NSAttributedString(string: tempStr, attributes: [
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle,
])
// 文本寬高size,用boundingRect去計(jì)算換行不準(zhǔn)確,還是要用sizeThatFits去計(jì)算渲染高度
let size = self.getTextViewSize(font: textFont, width: textWidth)
// 等系統(tǒng)計(jì)算size后,初始化參數(shù)
if wrapTimes == 0 {
firstRowMaxHeight = size.height
wrapTimes = wrapTimes + 1
}
// 如果突然size高度增加超過(guò)一行文本高度的一半,可以認(rèn)為是換行了。
else if (size.height - oldSize.height) > firstRowMaxHeight/2.0 {
wrapTimes = wrapTimes + 1
}
// 記錄第一行文本的最大高度(因?yàn)閾Q行的時(shí)候會(huì)先走上面一個(gè)else if,只有是第一行的時(shí)候才會(huì)走這里面)
// 主要排除:首特殊字符、一般符號(hào)、中英文、字母高度不一致等干擾因素
else if wrapTimes == 1 {
firstRowMaxHeight = size.height
}
// 記錄舊size
oldSize = size
// 換行超出指定行數(shù),進(jìn)行裁剪
if wrapTimes > row {
// 拼接文本寬度
let suffixWidth = suffixStr.boundingRect(with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 0), options: [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine], attributes: [
.font: textFont,
.paragraphStyle: paraStyle,
], context: nil).size.width
// 往上遍歷截取文本寬度
var needCutTextWidth: CGFloat = 0
// 截取文本下標(biāo)
var cutIndex = tempStr.index(tempStr.startIndex, offsetBy: 1)
// 偏移量
var indexOffset = tempStr.count - 1
// 如果截取文本寬度足夠,進(jìn)行替換
while suffixWidth > needCutTextWidth {
cutIndex = tempStr.index(tempStr.startIndex, offsetBy: indexOffset)
let cutString = String(tempStr[cutIndex..<tempStr.index(tempStr.startIndex, offsetBy: tempStr.count)])
needCutTextWidth = cutString.boundingRect(with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 0), options: [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine], attributes: [
.font: textFont,
.paragraphStyle: paraStyle,
], context: nil).size.width
indexOffset = indexOffset - 1
}
var colText = String(tempStr[..<cutIndex])
// 去除換行超出的那個(gè)字符
colText.removeLast()
// 裁剪后的文本
collapseText = "\(colText)\(suffixStr)"
// 文本、超連接潤(rùn)色,點(diǎn)擊處理
self.configDidOpenClose()
// 返回高度
let viewHeight = getTextViewSize(font: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didOpenClose")
return
}
}
}
// 不需要額外處理,基礎(chǔ)樣式文本
let attributedText = NSAttributedString(string: originalText, attributes: [
NSAttributedString.Key.foregroundColor: expandableTextColor,
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle
])
self.attributedText = attributedText
// 返回高度
let viewHeight = getTextViewSize(font: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "")
// 添加手勢(shì)識(shí)別(添加了長(zhǎng)按復(fù)制功能的回調(diào)等等,這里不展開(kāi)篇幅寫(xiě)了)
let tap = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
tap.delegate = self
self.addGestureRecognizer(tap)
let longTap = UILongPressGestureRecognizer(target: self, action: #selector(longTap(_:)))
longTap.delegate = self
self.addGestureRecognizer(longTap)
}
/// 計(jì)算文本frame
func getTextViewSize(font: UIFont, width: CGFloat) -> CGSize {
// 系統(tǒng)計(jì)算最佳適配size,可能會(huì)調(diào)用渲染,消耗性能,所以放在最后計(jì)算顯示用
return self.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
}
面對(duì) sizeThatFits 計(jì)算出來(lái)的size,總是會(huì)動(dòng)態(tài)變化,以前根據(jù)文本高度變化去記錄換行的方式不適用了。
最后取巧的使用一種辦法去解決怎么記錄換行的問(wèn)題,這邊認(rèn)為如果突然size高度增加超過(guò)一行文本高度的一半,可以認(rèn)為是換行了。 (如果有更好更優(yōu)雅的寫(xiě)法,請(qǐng)各位工友告訴我一下謝謝?。。。?/strong>
八、最終效果
- 優(yōu)化前(計(jì)算精度不足,與實(shí)際渲染效果有出入)

- 優(yōu)化后(計(jì)算精確,與實(shí)際渲染效果一樣)

最后,完美解決。
最最最后,完結(jié)撒花
