swiftuitextviewnslayoutmanager

Is this a UITextView transparency bug?


This issue came up in relation to a problem I had yesterday for which I should be able to create a workaround. As I investigated further, I found that it occurs more broadly than I originally thought. I had previously only noticed it in displayed text that included at least one newline character, but that's not the case below.

The problem seems to result from using the NSLayoutManager's boundingRect method to obtain (among other things) individual character widths and then using those widths to set characters' UITextView frame width properties. Doing so apparently causes the setting of the text view's backgroundColor to UIColor.clear to be ignored (i.e., the background becomes opaque). The Playground code below reproduces the problem, shown in red text, and shows the workaround of using a constant for widths, in black. The tighter the kerning, the more pronounced the effect.

enter image description here

Is this a bug? Or is it a quirk due to something else?

//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {

    override func loadView() {

        let view = UIView()
        view.bounds = CGRect(x: -100, y: -100, width: 200, height: 200)
        view.backgroundColor = .white

        let str = "..T.V.W.Y.."

        let strStorage = NSTextStorage(string: str)
        let layoutManager = NSLayoutManager()
        strStorage.addLayoutManager(layoutManager)
        let textContainer = NSTextContainer(size: view.bounds.size)
        textContainer.lineFragmentPadding = 0.0
        layoutManager.addTextContainer(textContainer)

        let strArray = Array(str)

        struct CharInfo {
            var char: Character
            var origin: CGPoint?
            var size: CGSize?
        }

        var charInfoArray = [CharInfo]()

        for index in 0..<str.count {

            charInfoArray.append(CharInfo.init(char: strArray[index], origin: nil, size: nil))
            let charRange = NSMakeRange(index, 1)
            let charRect = layoutManager.boundingRect(forGlyphRange: charRange, in: textContainer)
            charInfoArray[index].origin = charRect.origin
            charInfoArray[index].size = charRect.size
        }

        for charInfo in charInfoArray {

            let textView0 = UITextView()
            textView0.backgroundColor = UIColor.clear // Ignored in this case!!
            textView0.text = String(charInfo.char)
            textView0.textContainerInset = UIEdgeInsets.zero
            let size0 = charInfo.size!
            textView0.frame = CGRect(origin: charInfo.origin!, size: size0)
            textView0.textContainer.lineFragmentPadding = CGFloat(0.0)
            textView0.textColor = UIColor.red
            view.addSubview(textView0)

            let textView1 = UITextView()
            textView1.backgroundColor = UIColor.clear // Required
            textView1.text = String(charInfo.char)
            textView1.textContainerInset = UIEdgeInsets.zero
            var size1 = charInfo.size!
            size1.width = 20 // But changing .height has no effect on opacity
            textView1.frame = CGRect(origin: charInfo.origin!, size: size1)
            textView1.frame = textView1.frame.offsetBy(dx: 0, dy: 20)
            textView1.textContainer.lineFragmentPadding = CGFloat(0.0)
            textView1.textColor = UIColor.black
            view.addSubview(textView1)
        }

        self.view = view
    }
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Solution

  • This does seem to be a bug, but it's with NSLayoutManager's instance method boundingRect(forGlyphRange:in:). It only looks like it could be a transparency change.

    According to Apple's documentation, boundingRect(forGlyphRange:in:) is supposed to "[return] a single bounding rectangle (in container coordinates) enclosing all glyphs and other marks drawn in the given text container for the given glyph range, including glyphs that draw outside their line fragment rectangles and text attributes such as underlining." But that's not what it's doing.

    In this case, the width of each boundingRect gets reduced by the amount that the next glyph was shifted to the left, due to kerning. You can test this, for example, using str = "ToT" and adding print(size0.width) right after it is set. You'll get this:

    6.0         // "T"; should have been 7.330078125
    6.673828125 // "o"
    7.330078125 // "T"
    

    Until this bug is fixed, a workaround would be to calculate glyph size for each character in isolation.