Jun 27, 2020

|

安全且优雅地使用 NSAttributedString - Part.2

在上一篇中,我们讨论了如何安全且优雅地定义一个属性字符串。不过在实际的运用场景中,情况往往会更复杂一些。

例如,当我们需要一个由很多属性不同的部分构成的完整字符串,或者是想要实现图文混排。这个时候,不可避免的需要将所有部分组装在一起,最基础的方法是创建 NSMutableAttributedString ,然后通过 append(_:) 方法来完成字符串的构建。简单易懂是它的有点,然而不知不觉中可能就会写出类似下面这样的代码:

let mutableAttributedString = NSMutableAttributedString(attributedString: str1)
mutableAttributedString.append(str2)
mutableAttributedString.append(str3)
mutableAttributedString.append(str4)
...

这段代码多少有一些繁重感,也不够直观,有很大的优化余地。条条大路通罗马,也许一千个开发者就有一千种改善的思路,接下来想要讨论两种更优雅的方案,分别借力于枚举 (Enum) 和 字符串插值 (StringInterpolation)。

枚举

和前文类似,我们完全可以再次定义一个枚举来优化代码的可读性。根据自己的需求,定义一些常用的 case,只定义字符串实在是有一些孤独,我们可以再追加一个用于插图的 case:

enum AttributedString {
    case string(String, [StringAttribute]?)
    case image(UIImage, offsetX: CGFloat = 0.0, offsetY: CGFloat = 0.0, scale: CGFloat = 1.0)
    
    static func assemble(_ attrStrings: [AttributedString]) -> NSAttributedString {
        let mutableAttributedString = NSMutableAttributedString(string: "")
        attrStrings.forEach { (attrString: AttributedString) in
            switch attrString {
            case .string(let string, let attributes):
                mutableAttributedString.append(.init(string: string, attributes: attributes?.attributesDictionary))
            case .image(let image, let offsetX, let offsetY, let scale):
                mutableAttributedString.append(.init(image: image, offsetX: offsetX, offsetY: offsetY, scale: scale))
            }
        }
        return mutableAttributedString
    }
}

extension NSAttributedString {
    convenience init(image: UIImage, offsetX: CGFloat, offsetY: CGFloat, scale: CGFloat = 1.0) {
        let attachment = NSTextAttachment()
        
        let size: CGSize = {
            guard scale != 1.0 else { return image.size }
            return image.size.applying(CGAffineTransform(scaleX: scale, y: scale))
        }()
        
        attachment.bounds = .init(origin: .init(x: offsetX, y: offsetY), size: size)
        attachment.image = image
        
        self.init(attachment: attachment)
    }
}

这样一来,即简单明了,还可以避免很多晦涩且重复的代码。

xxxxxx.attributedText = AttributedString.assemble([
    .image(UIImage(named: "stop") ?? UIImage(), offsetX: 0.0, offsetY: -8.0, scale: 0.5),
    .string("踟蹰不如", [ .foregroundColor(.gray) ]),
    .string("停止抱歉", [ .foregroundColor(.orange) ])
])

字符串插值 (StringInterpolation)

相较于枚举,借助字符串插值的方法不会那么直观。这是一个 Swift 5 的新特性,初见之时我其实并没有彻底感受到字符串插值的强大,私以为只是能够将一些字符串的共通处理写的更优雅,尽管这也足以令人欣喜。直到很久之后读了 ExpressibleByStringInterpolation,才真是理解了一些字符串插值为我们带来的便利。文中关于字符串插值初级使用所举的例子十分巧妙,很优雅的避开了 DateFormatter 格式定义的坑。但字符串插值的能力更多体现在自定义字符串插值类型中。

由于我们期待的插值类型是属性字符串,所以我们可以从定义结构体开始着手,首先为该结构体定义一个 NSAttributedString 类型的属性:

struct AttributedString {
    var attributedString: NSAttributedString
}

此时需要了解两个协议:ExpressibleByStringLiteralExpressibleByStringInterpolation,后者继承于前者,二者分别有一个必须实现的初始化方法:

extension AttributedString: ExpressibleByStringLiteral {
    init(stringLiteral: String) {
        self.attributedString = NSAttributedString(string: stringLiteral)
    }
}

// ExpressibleByStringInterpolation inherits from ExpressibleByStringLiteral
extension AttributedString: ExpressibleByStringInterpolation {
    init(stringInterpolation: StringInterpolation) {
        self.attributedString = NSAttributedString(attributedString: stringInterpolation.attributedString)
    }
}

字面量部分只需要初始化方法即可,插值的情况下自然会复杂一些,从初始化方法接受的参数是一个关联类型就可以看出来。StringInterpolation 才真正在负责在处理插值。它的工作原理是将各个组成部分通过一系列 append 方法组装起来,再作为参数传给上面的初始化方法 init(stringInterpolation:)appendLiteral(_:) 的职责是处理普通的字符串字面量,名为 appendInterpolation 的方法们则是承担了处理类型五花八门的插值的任务。

在我们的例子中,就分别为字符串及其属性,图片定义了不同的 appendInterpolation 方法:

extension AttributedString: ExpressibleByStringInterpolation {
    // Custom String Interpolation Type
    struct StringInterpolation: StringInterpolationProtocol {
        var attributedString: NSMutableAttributedString
        
        init(literalCapacity: Int, interpolationCount: Int) {
            self.attributedString = NSMutableAttributedString()
        }

        mutating func appendLiteral(_ literal: String) {
            let attrString = NSAttributedString(string: literal)
            self.attributedString.append(attrString)
        }
        
        mutating func appendInterpolation(_ string: String, stringAttributes: [StringAttribute]) {
            let attrString = NSAttributedString(string: string,
                                                stringAttributes: stringAttributes)
            self.attributedString.append(attrString)
        }
        
        // for UIImage
        mutating func appendInterpolation(image: UIImage, offsetX: CGFloat, offsetY: CGFloat, scale: CGFloat = 1.0) {
            let attachment = NSTextAttachment()
            
            let size: CGSize = {
                guard scale != 1.0 else { return image.size }
                return image.size.applying(CGAffineTransform(scaleX: scale, y: scale))
            }()
            
            attachment.bounds = .init(origin: .init(x: offsetX, y: offsetY), size: size)
            attachment.image = image
        
            self.attributedString.append(NSAttributedString(attachment: attachment))
        }
    }
}

关于 appendLiteral(_:) 方法,有一点不得不说,如果想要在其中做除了 append 之外的事,务必要万分小心,除了显然可见的字面量之外,空格,换行,甚至是字符串前后的不可见部分都会受到其影响。

严格意义上我们已经完成了用字符串插值的方法来组装属性字符串,实在是有些令人激动!不如趁热打铁,顺手添加两个便利方法,一定程度上能够再提升一点点灵活度。

struct AttributedString {
    var attributedString: NSAttributedString
    
    mutating func append(_ newStr: AttributedString) {
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
        mutableAttributedString.append(newStr.attributedString)
        self.attributedString = mutableAttributedString
    }
    
    mutating func append(contentsOf newStrs: [AttributedString]) {
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
        newStrs.forEach { str in
            mutableAttributedString.append(str.attributedString)
        }
        self.attributedString = mutableAttributedString
    }
}

到这里,组装属性字符串的过程变得优雅且灵活了一些。

var attrString1: AttributedString = """
\("踟蹰不如", stringAttributes: [ .foregroundColor(.systemGray), .font(.systemFont(ofSize: 14.0)) ])
\("停止抱歉", stringAttributes: [ .foregroundColor(.systemGray2), .font(.systemFont(ofSize: 14.0)) ])
"""

let attrString2: AttributedString = """
\(image: UIImage(named: "stop") ?? UIImage(), offsetX: 0.0, offsetY: 0.0, scale: 0.5)
\("再过秋天", stringAttributes: [ .foregroundColor(.systemGray3), .font(.systemFont(ofSize: 14.0)) ])
\("烂了蜿蜒", stringAttributes: [ .foregroundColor(.systemGray4), .font(.systemFont(ofSize: 14.0)) ])
"""

attrString1.append(attrString2)

xxxxxlabel.attributedText = attrString1.attributedString

尾声

我想很多人对属性字符串的感情应该是爱恨交织的,希望这两篇文章能为它拉回几张的票...😂

Source Code: https://github.com/Ckitakishi/PlayWithAttributedString