iOS 小組件 - 文本容器(展開(kāi)/收起)之技術(shù)設(shè)計(jì)與實(shí)現(xiàn)詳解

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)化。

一、需求

tapd_44062861_1700211523_441.png

如效果圖所示,需要完成一個(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ì)解釋看代碼就可以了。

四、具體使用

tapd_44062861_1700213174_987.png

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í)際渲染效果有出入)
tapd_44062861_1709198342_942.png
  • 優(yōu)化后(計(jì)算精確,與實(shí)際渲染效果一樣)
tapd_44062861_1709198459_247.jpg

最后,完美解決。

最最最后,完結(jié)撒花

告辭.jpeg
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容