Search code examples
iosswiftcore-graphicsuibezierpath

How to find where to draw text inside an irregular shape represented by a UIBezierPath?


In my app, I have a bunch of UIBezierPaths 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:

enter image description here

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.


Solution

  • 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
    }