I finally achieved this by using CTLine and CTRun to manually measure the text.
let layer = CATextLayer()
layer.string = self.text
layer.fontSize = CGFloat(self.fontSize)
layer.font = CTFontCreateWithName("Helvetica" as CFString, CGFloat(self.fontSize), nil)
layer.truncationMode = .end
layer.allowsFontSubpixelQuantization = false
layer.contentsScale = UIScreen.main.scale
layer.frame = CGRect(origin: CGPoint(x: 0, y: 0), size: layer.preferredFrameSize())
let attrs = [ NSAttributedString.Key.font: UIFont(name: "Helvetica", size: 20.0)! ]
let nsText = NSAttributedString(string: self.text, attributes: attrs)
let nsLine = CTLineCreateWithAttributedString(nsText)
let runs = CTLineGetGlyphRuns(nsLine) as? Array<CTRun>
// measure text
var widths = Array<CGFloat>()
for run in runs! {
let glyphCount = CTRunGetGlyphCount(run)
var cgSizes = Array(repeating: CGSize(), count: glyphCount)
let _ = CTRunGetAdvances(run, CFRangeMake(0, glyphCount), &cgSizes)
for cgSize in cgSizes {
widths.append(cgSize.width)
}
}
// draw bounding box
let height = layer.frame.height
print("advance widths: \(widths), height: \(height)")
var xOffset = CGFloat()
for width in widths {
let path = UIBezierPath()
path.move(to: CGPoint(x: xOffset, y: 0.0))
path.addLine(to: CGPoint(x: xOffset + width, y: 0.0 ))
path.addLine(to: CGPoint(x: xOffset + width, y: height))
path.addLine(to: CGPoint(x: xOffset, y: height))
path.close()
let boundsLayer = CAShapeLayer()
boundsLayer.path = path.cgPath
boundsLayer.lineWidth = 1
boundsLayer.strokeColor = UIColor.blue.cgColor
boundsLayer.fillColor = CGColor(gray: 0, alpha: 0)
layer.addSublayer(boundsLayer)
xOffset += width
}
layer.borderWidth = 1
layer.borderColor = UIColor.red.cgColor