NSTextViewで折りたたみ

NSTextViewで折りたたみ(フォールディング)は、折りたたみたい文字のグリフをNullGlyphに置き換えることで実現できます。

下記に手順を紹介します。

折りたたむ文字列に目印をつける

折りたたみたい文字に目印となるカスタム属性を設定します。 ここではカスタム属性のキーとして下記の.foldingを定義しているものとします。

// カスタムAttribute
extension NSAttributedString.Key {
    static let folding = NSAttributedString.Key(rawValue: “NSAttributedString.Key.folding”)
}

例えば”ABCD”という文字列があったとして、”BC”を折りたたみたい場合は”BC”の範囲にカスタム属性.foldingを設定します。

textView.string = “ABCD”
textView.storage?.addAttributes([.folding: true], range: NSRange(location: 1, length: 2))

カスタム属性を設定しただけでは何も起こりません。 次にカスタム属性を目印にしてグリフをNullGlyphに置き換えるようにします。

グリフをNullGlyphに置き換える

カスタム属性を設定した箇所のグリフを変更します。 グリフはNSLayoutManagerで設定するのでサブクラスを作ってグリフを設定するメソッドをオーバーライドします。

class LayoutManager: NSLayoutManager {
    override func setGlyphs(_ glyphs: UnsafePointer<CGGlyph>,
                            properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
                            characterIndexes charIndexes: UnsafePointer<Int>,
                            font aFont: NSFont,
                            forGlyphRange glyphRange: NSRange) {
        // Glyphプロパティで.nullを指定すると該当箇所のGlyphがNULLGlyphとして扱われる
        var properties = [NSLayoutManager.GlyphProperty](repeating: .null, count: glyphRange.length)

        for i in 0..<glyphRange.length {
            let index = glyphRange.location + i
            if let attrs = textStorage?.attributes(at: index, effectiveRange: nil),
                attrs[.folding] == nil {
                properties[i] = props.advanced(by: i).pointee
            }
        }

        super.setGlyphs(glyphs, properties: &properties, characterIndexes: charIndexes, font: aFont, forGlyphRange: glyphRange)
    }
}

次にここで作ったカスタムLayoutManagerをNSTextViewが利用するように設定します。

カスタムLayoutManagerを使用する

NSTextViewのインスタンスを作る時に設定します。

let textStorage   = NSTextStorage()
let layoutManager = LayoutManager()

let textContainer = NSTextContainer()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
NSTextView(frame: frame, textContainer: textContainer)

折りたたみを解除するには?

カスタム属性を消去すれば折りたたみは解除されます。

textView.textStorage?.removeAttribute(.folding, range: NSRange(location: 1, length: 2))

まとめ

基本的にiOS(UITextView)でも同じ考え方で折りたたみを実装できます。(メソッドや引数が異なる程度の差です。) また、この方法の応用でIDEによくあるラインフォールディングも実装できます。 折りたたみの範囲を行単位にするだけです。