Search code examples
iosswiftuilabelpaddingnsattributedstring

NSAttributedString with padding before and after the text and a background


I want to style a text using NSAttributedString. The text should have a background and a custom padding, so that the text has a little bit of space to the background's edge.

This is what I want to achieve:

what I want to achieve

This is what I don't want to achieve (background in the second line is not word / character-specific):

what I don't want to achieve

And this is the code that I tried in a playground:

let quote = "some text with a lot of other text and \nsome other text."
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left
let attributes: [NSAttributedString.Key: Any] = [
    NSAttributedString.Key.paragraphStyle: paragraphStyle,
    NSAttributedString.Key.backgroundColor: UIColor.red,
    NSAttributedString.Key.foregroundColor: UIColor.white
]
let attributedQuote = NSAttributedString(string: quote, attributes: attributes)

And this is what the playground preview renders:

[![playground preview][3]][3]

The text is very close to the edge of the background. Is there any way to get the background of the text to have some space to the text? I need some padding. I tried using headIndent but that would move the text with it's background to the right, not just the text. Therefor it is not useful for padding.


Solution

  • The text should have a background and a custom padding, so that the text has a little bit of space to the background's edge.

    The best way I found is using TextKit, it's a little bit cumbersome but it's completely modular and is made for this purpose.
    In my view, it isn't up to the TextView itself to draw the rectangles in its draw method, that's the LayoutManager's work.

    The entire classes used in the project are provided hereafter in order to ease the work with copy-paste (Swift 5.1 - iOS 13).

    AppDelegate.swift stores the property to get the text wherever you are in the app.

    class AppDelegate: UIResponder, UIApplicationDelegate {
    
        lazy var TextToBeRead: NSAttributedString = {
    
            var text: String
            if let filepath = Bundle.main.path(forResource: "TextToBeRead", ofType: "txt") {
                do { text = try String(contentsOfFile: filepath) }
                catch { text = "E.R.R.O.R." }
            } else { text = "N.O.T.H.I.N.G." }
    
            return NSAttributedString(string: text)
        }()
    }
    

    ViewController.swift ⟹ only one single text view at full screen.

    class ViewController: UIViewController, NSLayoutManagerDelegate {
    
        @IBOutlet weak var myTextView: UITextView!
        let textStorage = MyTextStorage()
        let layoutManager = MyLayoutManager()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            self.layoutManager.delegate = self
    
            self.textStorage.addLayoutManager(self.layoutManager)
            self.layoutManager.addTextContainer(myTextView.textContainer)
    
            let appDelegate = UIApplication.shared.delegate as? AppDelegate
            self.textStorage.replaceCharacters(in: NSRange(location: 0, length: 0),
                                               with: (appDelegate?.TextToBeRead.string)!)
        }
    
        func layoutManager(_ layoutManager: NSLayoutManager,
                           lineSpacingAfterGlyphAt glyphIndex: Int,
                           withProposedLineFragmentRect rect: CGRect) -> CGFloat { return 20.0 }
    
        func layoutManager(_ layoutManager: NSLayoutManager,
                           paragraphSpacingAfterGlyphAt glyphIndex: Int,
                           withProposedLineFragmentRect rect: CGRect) -> CGFloat { return 30.0 }
    }
    

    MyTextStorage.swift

    class MyTextStorage: NSTextStorage {
    
        var backingStorage: NSMutableAttributedString
    
        override init() {
    
            backingStorage = NSMutableAttributedString()
            super.init()
        }
    
        required init?(coder: NSCoder) {
    
            backingStorage = NSMutableAttributedString()
            super.init(coder: coder)
        }
    
    //    Overriden GETTERS
        override var string: String {
            get { return self.backingStorage.string }
        }
    
        override func attributes(at location: Int,
                                 effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] {
    
            return backingStorage.attributes(at: location, effectiveRange: range)
        }
    
    //    Overriden SETTERS
        override func replaceCharacters(in range: NSRange, with str: String) {
    
            backingStorage.replaceCharacters(in: range, with: str)
            self.edited(.editedCharacters,
                        range: range,
                        changeInLength: str.count - range.length)
        }
    
        override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) {
    
            backingStorage.setAttributes(attrs, range: range)
            self.edited(.editedAttributes,
                        range: range,
                        changeInLength: 0)
        }
    }
    

    MyLayoutManager.swift

    import CoreGraphics //Important to draw the rectangles
    
    class MyLayoutManager: NSLayoutManager {
    
        override init() { super.init() }
    
        required init?(coder: NSCoder) { super.init(coder: coder) }
    
        override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {   
            super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
    
            self.enumerateLineFragments(forGlyphRange: glyphsToShow) { (rect, usedRect, textContainer, glyphRange, stop) in
    
                var lineRect = usedRect
                lineRect.size.height = 30.0
    
                let currentContext = UIGraphicsGetCurrentContext()
                currentContext?.saveGState()
    
                currentContext?.setStrokeColor(UIColor.red.cgColor)
                currentContext?.setLineWidth(1.0)
                currentContext?.stroke(lineRect)
    
                currentContext?.restoreGState()
            }
        }
    }
    

    ... and here's what it looks like in the end: enter image description here

    There's only to customize the colors and adjust few parameters to stick to your project but this is the rationale to display a NSAttributedString with padding before and after the text and a background... for much more than 2 lines if need be.