8

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.

WalterBeiter
  • 2,021
  • 3
  • 23
  • 48
  • paragraphStyle.lineSpacing = desiredLineSpacing, start and end padding you can add with whitespaces or do something similar to https://stackoverflow.com/questions/27459746/adding-space-padding-to-a-uilabel – Alexandr Kolesnik Aug 01 '19 at 09:21
  • whitespaces work at the start and end but you need one when the text wraps. And because I don't know when the text wraps (auto layout, length of text), I cannot set a whitespace. The approach of the other question does not quite what I want. I want the background to just be as long as a single text line has characters. A background on a UILabel has the width of the label and the width of the words or characters of a single line. – WalterBeiter Aug 01 '19 at 09:35
  • You don't need the NSAttributedString for this simple task. Use a UILabel with constraints and 0 lines, and in code when you assign a string to it, just do like this: ```label.text = " \(myTextString) "```, and assign a background color for the label. – Starsky Aug 01 '19 at 09:41
  • @Starsky The problem with a label is, that the background is always as wide as the width of the label. But I want the background to be only visible behind a character. So that each line has a background that only goes as far as the characters go. (see updated question for an image) – WalterBeiter Aug 01 '19 at 09:47
  • and what if add \n after each line and make it white? – Alexandr Kolesnik Aug 01 '19 at 11:54
  • I don't know when to wrap that text because that the text is always different and can have more than two lines or even just one, depending on the device. – WalterBeiter Aug 01 '19 at 12:27
  • @WalterBeiter You could try also manipulate the label's frame in that case. – Starsky Aug 02 '19 at 08:06
  • @WalterBeiter I got your point now. When there is a new line break, you need kind of "a new label" with its independent frame and colored background. You might need to split the string into components separated by, let's say "\n", and then on each component create a label and assign the string to it. But you said you don't know when the line will break. This makes it harder to accomplish. – Starsky Aug 02 '19 at 08:09
  • @WalterBeiter Maybe you should look into highlighted text inside a TextView. This should be closer to a solution for you ;) Check here: [link](https://stackoverflow.com/questions/49313188/how-to-highlight-a-uitextviews-text-line-by-line-in-swift?rq=1) – Starsky Aug 02 '19 at 08:12
  • Did you think about using a UIStackView, this will allow for you to give each UILabels its own styling and use the stackviews spacing property for the spacing. Just an idea? – David Thorn Nov 04 '19 at 06:21
  • well this is an interesting idea, however, then I have to handle the line break myself. – WalterBeiter Nov 04 '19 at 13:47
  • 1
    @WalterBeiter: have taken a look at https://stackoverflow.com/a/28042708/3825084 that may be a good solution for your problem? – XLE_22 Nov 28 '19 at 12:43

1 Answers1

7

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.

XLE_22
  • 5,124
  • 3
  • 21
  • 72