iOS 小组件 - 文本容器(展开/收起)之技术设计与实现详解

757 阅读8分钟

2025.01.28 工作变动原因,故将一些工作期间Tapd内部写的Wiki文档转移到个人博客。

一个 ( 展开 / 收起 ) 式通用文本容器,这只是一个实现基本功能的 Demo 例子。

实际应用中还可以去做更多的性能优化~,比如:预判断截取、预判断逆向遍历还是正向遍历、优化遍历次数 等等。

初版源码的 setupExpandableText() 中使用了 boundingRect() 去计算换行方式引起了精度不足的问题。

因此,换行计算方式在文中新更新 ————————— 2024.01.07 更新 ————————— 有优化。

一、需求

tapd_44062861_1700211523_441.png

如效果图所示,需要完成一个文本容器(如果文本超过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
    }
}

具体的关键逻辑和可自定义配置的字段都添加了注释,详细解释看代码就可以了。

四、具体使用

tapd_44062861_1700213174_987.png

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高度增加超过一行文本高度的一半,可以认为是换行了。 (如果有更好更优雅的写法,请各位工友告诉我一下谢谢!!!)

八、最终效果

  • 优化前(计算精度不足,与实际渲染效果有出入)

tapd_44062861_1709198342_942.png

  • 优化后(计算精确,与实际渲染效果一样)

tapd_44062861_1709198459_247.jpg

最后,完美解决。

最最最后,完结撒花

告辞.jpeg