2025.01.28 工作变动原因,故将一些工作期间Tapd内部写的Wiki文档转移到个人博客。
一个 ( 展开 / 收起 ) 式通用文本容器,这只是一个实现基本功能的 Demo 例子。
实际应用中还可以去做更多的性能优化~,比如:预判断截取、预判断逆向遍历还是正向遍历、优化遍历次数 等等。
初版源码的
setupExpandableText()中使用了boundingRect()去计算换行方式引起了精度不足的问题。因此,换行计算方式在文中新更新 ————————— 2024.01.07 更新 ————————— 有优化。
一、需求
如效果图所示,需要完成一个文本容器(如果文本超过3行,后缀文本改为...展开)。 回顾项目里以往的代码,并没有一个开箱即用的通用小组件,这里就决定完成一个通用小组件 (一个带 展开/收起 功能的裁剪文本容器)。
二、技术设计思路
-
首先,可以知道需要引用一个动态布局的通用小组件,我们需要让持有者知道小组件的高度。
-
第二,可以支持灵活自定义比较重要的字段(裁剪后缀文本、字体、段落样式、要裁剪的行数)
-
第三,超链接文本展开和收起的动作回调(方便业务有特殊处理)
三、小组件源码(初版,在后面对setupExpandableText()中的计算换行方法有优化)
// Created by Yim on 2023/10/20.
// 展开/收起 式通用文本容器
import UIKit
class YAYExpandableTextView: UITextView {
/// 输入文本
var originalText = ""
/// 收起后的文本
var collapseText = ""
/// 自定义的(展开)拼接文本
var suffixStr = ""
/// 自定义的(收起)拼接文本
var closeStr = ""
/// 文本宽度
var textWidth: CGFloat = 0
/// 字体
var textFont = UIFont()
/// 字体颜色
var expandableTextColor: UIColor = .white
/// 展开/收起 高度回调,点击回调
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: - 初始化
/// 将文本按长度度截取并加上指定后缀
/// @param str 文本
/// @param suffixStr 指定后缀
/// @param font 文本字体
/// @param textWidth 文本长度
/// @param num 多少行
func setupExpandableText(_ str: String, suffixStr: String = "...展开", 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
// 自定义的(展开)拼接文本
self.suffixStr = suffixStr
// 自定义的(收起)拼接文本
self.closeStr = closeStr
// 记录高度变化次数(相当于行数)
var wrapTimes: Int = 0
var wrapHeight: CGFloat = 0
for i in 0..<str.count {
// 截取下标
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
// 记录换行次数
if wrapHeight != size.height {
wrapTimes = wrapTimes + 1
wrapHeight = size.height
// 换行超出指定行数,进行裁剪
if wrapTimes == row + 1 {
// 因为已经是刚好超出的第一个字符,所以是 截取文本长度 - 1 - 要拼接的文本长度(再拼 - 1,优化效果,顺便防止特殊字符长度比较长)
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
}
}
}
// 不需要额外处理,基础样式文本
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
/// 默认收起的配置
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)
// 添加基础段落样式
attributedText.addAttributes([
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle,
], range: NSRange(location: 0, length: attributedText.string.count))
// 给富文本后面增加可操作的点击链接通过代理来实现
attributedText.addAttribute(NSAttributedString.Key.link, value: "didDownClose://", range: NSRange(location: attributedText.string.count - closeStr.count, length: closeStr.count))
self.attributedText = attributedText
return attributedText
}
/// 默认打开的配置
func configDidOpenClose() -> NSMutableAttributedString {
let attributedText = NSMutableAttributedString(string: collapseText)
// 添加自定义文本样式
attributedText.addAttributes([
NSAttributedString.Key.foregroundColor: expandableTextColor
], range: NSRange(location: 0, length: collapseText.count - suffixStr.count))
// 添加(展开)后缀文本样式
attributedText.addAttributes([
NSAttributedString.Key.foregroundColor: UIColor.hexColor(hex: "#39639E")
], range: NSRange(location: collapseText.count - suffixStr.count, length: suffixStr.count))
// 添加基础段落样式
attributedText.addAttributes([
NSAttributedString.Key.font: textFont,
NSAttributedString.Key.paragraphStyle: paraStyle,
], range: NSRange(location: 0, length: collapseText.count))
// 给富文本后面增加可操作的点击链接通过代理来实现
attributedText.addAttribute(NSAttributedString.Key.link, value: "didOpenClose://", range: NSRange(location: collapseText.count - suffixStr.count, length: suffixStr.count))
self.attributedText = attributedText
return attributedText
}
/// 计算文本高度
func getTextViewSize(_ text: String, with font: UIFont, width: CGFloat) -> CGSize {
// 系统计算最佳适配size,可能会调用渲染,消耗性能,所以放在最后计算显示用
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 {
// 点击 展开
if URL.scheme == "didOpenClose" {
// 变成 收起 的配置
let viewHeight = getTextViewSize(self.configDidDownClose().string, with: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didOpenClose")
return false
}
// 点击 收起
if URL.scheme == "didDownClose" {
// 变成 展开 的配置
let viewHeight = getTextViewSize(self.configDidOpenClose().string, with: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didDownClose")
return false
}
return true
}
}
具体的关键逻辑和可自定义配置的字段都添加了注释,详细解释看代码就可以了。
四、具体使用
用 lazy var introductionTextView: YAYExpandableTextView 初始化了小组件,调用 setupExpandableText()方法进行文本初始化。在 viewHieghtClosure 中获取了小组件的高度,回调对整个页面布局进行刷新。
最后,完成了一个开箱即用的通用小组件,以后类似的场景就可以直接引用该文本容器了。
————————— 2024.01.07 更新 —————————
书接上文,在上文中实现的小组件,经过和同事的沟通(
单方面被要求)后,需要精进换行文本最后一位的准度。经过长时间(一下午)研究与调试过后,达到了既定目标,在此分享一下自己摸索出来的实现写法。
五、问题
iOS提供的 boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], attributes: [NSAttributedString.Key : Any]? = nil, context: NSStringDrawingContext?) -> CGRect 方法,用来计算 String 文本宽度去预估换行。
但是最终渲染结果和 boundingRect 计算出来的换行会有一些出入,所以需要更换成 sizeThatFits(_ size: CGSize) -> CGSize (调用渲染匹配文本的最佳size) 去计算布局。
六、难点
使用 sizeThatFits(_ size: CGSize) -> CGSize 计算布局,得出来的 CGSize 是会动态变化的,导致原来的计算换行方式不能用。
经过调试后使用了一种能解决绝大多数情况的取巧解决办法,如果有更好更优雅的写法的话请各位工友指点迷津~~~
七、重要逻辑优化
/// 将文本按长度度截取并加上指定后缀
/// @param str 文本
/// @param suffixStr 指定后缀
/// @param font 文本字体
/// @param textWidth 文本长度
/// @param num 多少行
/// @param needCrop 是否需要裁剪文本
func setupExpandableText(_ str: String, suffixStr: String = "...展开", 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
// 自定义的(展开)拼接文本
self.suffixStr = suffixStr
// 自定义的(收起)拼接文本
self.closeStr = closeStr
if needCrop {
var str = self.originalText
// 记录高度变化次数(相当于行数)
var wrapTimes: Int = 0
// 记录第一行文本的所需高度
var firstRowMaxHeight: CGFloat = 0
// 记录上一次的size
var oldSize: CGSize = CGSize(width: 0, height: 0)
// 遍历字符串,看是否需要进行裁剪拼接
for i in 0..<str.count {
// 截取下标
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去计算换行不准确,还是要用sizeThatFits去计算渲染高度
let size = self.getTextViewSize(font: textFont, width: textWidth)
// 等系统计算size后,初始化参数
if wrapTimes == 0 {
firstRowMaxHeight = size.height
wrapTimes = wrapTimes + 1
}
// 如果突然size高度增加超过一行文本高度的一半,可以认为是换行了。
else if (size.height - oldSize.height) > firstRowMaxHeight/2.0 {
wrapTimes = wrapTimes + 1
}
// 记录第一行文本的最大高度(因为换行的时候会先走上面一个else if,只有是第一行的时候才会走这里面)
// 主要排除:首特殊字符、一般符号、中英文、字母高度不一致等干扰因素
else if wrapTimes == 1 {
firstRowMaxHeight = size.height
}
// 记录旧size
oldSize = size
// 换行超出指定行数,进行裁剪
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
// 截取文本下标
var cutIndex = tempStr.index(tempStr.startIndex, offsetBy: 1)
// 偏移量
var indexOffset = tempStr.count - 1
// 如果截取文本宽度足够,进行替换
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])
// 去除换行超出的那个字符
colText.removeLast()
// 裁剪后的文本
collapseText = "\(colText)\(suffixStr)"
// 文本、超连接润色,点击处理
self.configDidOpenClose()
// 返回高度
let viewHeight = getTextViewSize(font: textFont, width: textWidth).height
viewHieghtClosure?(viewHeight, "didOpenClose")
return
}
}
}
// 不需要额外处理,基础样式文本
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, "")
// 添加手势识别(添加了长按复制功能的回调等等,这里不展开篇幅写了)
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)
}
/// 计算文本frame
func getTextViewSize(font: UIFont, width: CGFloat) -> CGSize {
// 系统计算最佳适配size,可能会调用渲染,消耗性能,所以放在最后计算显示用
return self.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
}
面对 sizeThatFits 计算出来的size,总是会动态变化,以前根据文本高度变化去记录换行的方式不适用了。
最后取巧的使用一种办法去解决怎么记录换行的问题,这边认为如果突然size高度增加超过一行文本高度的一半,可以认为是换行了。 (如果有更好更优雅的写法,请各位工友告诉我一下谢谢!!!)
八、最终效果
- 优化前(计算精度不足,与实际渲染效果有出入)
- 优化后(计算精确,与实际渲染效果一样)
最后,完美解决。