In my app, I have a bunch of UIBezierPath
s that I import from SVG files using this thing, which all represent irregular shapes. I want to draw texts inside the shapes so as to "label" those shapes. Here's an example:
I want to draw the text "A" inside the shape. Basically, this'd be done in the draw(_:)
method of a custom UIView
, and I expect to call a method with a signature such as:
func drawText(_ text: String, in path: UIBezierPath, fontSize: CGFloat) {
// draws text, or do nothing if there is not enough space
}
After some looking online, I found this post, which seems to always draw the string in the centre of the image. This is not what I want. If I put the A
in the centre of the bounding box of the UIBezierPath
(which I know I can get with UIBezierPath.bounds
), it would be outside of the shape. This is probably because the shape is concave.
Note that the texts are quite short so I don't need to worry about line wrapping and stuff.
Of course, there are lots of places inside the shape I could put the "A" in, I just want a solution that chooses any one place that can show the text in a reasonable size. One way I have thought of is to find the largest rectangle that can be inscribed in the shape, and then draw the text in the centre of that rectangle. I've found this post that does this in Matlab, and it seems like a really computation-intensive to do... I'm not sure this is a good solution.
How should I implement drawText(_:in:fontSize:)
?
To avoid being an XY question, here's some background:
The shapes I'm handling are actually borders of administrative regions. In other words, I'm drawing a map. I don't want to use existing map APIs because I want my map to look very crude. It's only going to show the borders of administrative regions, and labels on them. So surely I could just use whatever algorithm the map APIs are using to draw the labels on their maps, right?
This question is not a duplicate of this, as I know I can do that with UIBezierPath.contains
. I'm trying to find a point. It's not a duplicate of this either, as my question is about drawing text inside a path, not on.
TextKit was built for tasks like this. You can create an array of paths outside of your bezier shape path and then set it as your textView's exclusionPaths:
textView.textContainer.exclusionPaths = [pathsAroundYourBezier];
Keep in mind that the exclusion paths are paths where text in the container will not be displayed. Apple documentation here: https://developer.apple.com/documentation/uikit/nstextcontainer/1444569-exclusionpaths
UPDATE DUE TO BUGS WITH EXCLUSION PATHS AT THE BEGINNING OF TEXTVIEW'S:
I've come up with a way to find where in a path text can fit.
Usage:
let pathVisible = rectPathSharpU(CGSize(width: 100, height: 125), origin: CGPoint(x: 0, y: 0))
let shapeLayer = CAShapeLayer()
shapeLayer.path = pathVisible.cgPath
shapeLayer.strokeColor = UIColor.green.cgColor
shapeLayer.backgroundColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 3
self.view.layer.addSublayer(shapeLayer)
let path = rectPathSharpU(CGSize(width: 100, height: 125), origin: CGPoint(x: 0, y: 0))
let fittingRect = findFirstRect(path: path, thatFits: "A".size())!
print("fittingRect: \(fittingRect)")
let label = UILabel.init(frame: fittingRect)
label.text = "A"
self.view.addSubview(label)
Output:
There may be cases with curved paths that will need to be taken into account, perhaps by iterating through every y point in a path bounds until a sizable space is found.
The function to find the first fitting rect:
func findFirstRect(path: UIBezierPath, thatFits: CGSize) -> CGRect? {
let points = path.cgPath.points
allPoints: for point in points {
var checkpoint = point
var size = CGSize(width: 0, height: 0)
thisPoint: while size.width <= path.bounds.width {
if path.contains(checkpoint) && path.contains(CGPoint.init(x: checkpoint.x + thatFits.width, y: checkpoint.y + thatFits.height)) {
return CGRect(x: checkpoint.x, y: checkpoint.y, width: thatFits.width, height: thatFits.height)
} else {
checkpoint.x += 1
size.width += 1
continue thisPoint
}
}
}
return nil
}
Extension for finding string size:
extension String {
func size(width:CGFloat = 220.0, font: UIFont = UIFont.systemFont(ofSize: 17.0, weight: .regular)) -> CGSize {
let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
label.numberOfLines = 0
label.lineBreakMode = NSLineBreakMode.byWordWrapping
label.font = font
label.text = self
label.sizeToFit()
return CGSize(width: label.frame.width, height: label.frame.height)
}
}
Creating the test path:
func rectPathSharpU(_ size: CGSize, origin: CGPoint) -> UIBezierPath {
// Initialize the path.
let path = UIBezierPath()
// Specify the point that the path should start get drawn.
path.move(to: CGPoint(x: origin.x, y: origin.y))
// add lines to path
path.addLine(to: CGPoint(x: (size.width / 3) + origin.x, y: (size.height / 3 * 2) + origin.y))
path.addLine(to: CGPoint(x: (size.width / 3 * 2) + origin.x, y: (size.height / 3 * 2) + origin.y))
path.addLine(to: CGPoint(x: size.width + origin.x, y: origin.y))
path.addLine(to: CGPoint(x: (size.width) + origin.x, y: size.height + origin.y))
path.addLine(to: CGPoint(x: origin.x, y: size.height + origin.y))
// Close the path. This will create the last line automatically.
path.close()
return path
}
If this doesn't work for paths with a lot of arcs like your picture example, please post the actual path data so I can test with that.
Bonus: I also created a function to find the widest section of a symmetric path, though height isn't taken into account. Though it may be useful:
func findWidestY(path: UIBezierPath) -> CGRect {
var widestSection = CGRect(x: 0, y: 0, width: 0, height: 0)
let points = path.cgPath.points
allPoints: for point in points {
var checkpoint = point
var size = CGSize(width: 0, height: 0)
thisPoint: while size.width <= path.bounds.width {
if path.contains(checkpoint) {
checkpoint.x += 1
size.width += 1
continue thisPoint
} else {
if size.width > widestSection.width {
widestSection = CGRect(x: point.x, y: point.y, width: size.width, height: 1)
}
break thisPoint
}
}
}
return widestSection
}