2

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.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • 1
    `UIBezierPath` instruments looks are not accurate for this task. I would use, at least, `CGPath.boundingBoxOfPath` and `CGPath.contains` plus some cross-ray heuristic algorithm (starting from center) to find a place where four corners of label's rect are in path. Taking into account that labels are small, by definition, that would be enough with high probability. Otherwise the computational algorithm will be very heavy. – Asperi May 01 '20 at 06:23
  • “Cross ray heuristic algorithm” is the word! I didn’t know what to search for before. Thanks! @Asperi – Sweeper May 01 '20 at 06:55

1 Answers1

0

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
}
elliott-io
  • 1,394
  • 6
  • 9
  • I knew there's gotta be an API made for this! Now I just need to figure out how to create a text container... As I said in the question, I'm actually making a map view kind of things there will be a few dozen of these regions next to each other. Should I use one text container for each region then? – Sweeper May 06 '20 at 08:12
  • Yes, I would recommend one for each. There is a createTextView method in this tutorial, but the whole code is quite long to post as there are many parts :-) https://www.raywenderlich.com/5960-text-kit-tutorial-getting-started – elliott-io May 06 '20 at 08:25
  • This doesn't seem like it will work for some paths, apparently because of [this bug](https://stackoverflow.com/a/33898013/5133585). If the path is shaped in a way that the first lines of the text view does not have enough space to display the text (meaning the text starts from the second or third line), the exclusion paths will be ignored completely... – Sweeper May 08 '20 at 07:24
  • @Sweeper, wow, that bug from 5 years ago is still a problem today? – elliott-io May 08 '20 at 18:03
  • Try copying and pasting this into a playground and quick look the last line: `let textView1 = UITextView(frame: CGRect(x: 0, y: 0, width: 100, height: 250));textView1.textAlignment = .center;textView1.text = "Hello World Hello World Hello World Hello World";textView1.textContainer.exclusionPaths = [UIBezierPath(rect: CGRect(x: 0, y: 0, width: 100, height: 125))];textView1`. The text is rendered at the top of the text view, when it should start from the middle. That _is_ due the old bug, and not some new bug, right? – Sweeper May 08 '20 at 18:07
  • @Sweeper, wow! That is odd. Perhaps you will need to do a little more calculation to not place exclusion paths at the start of the textView (and to place your textView frame in the center. Your goal is to display a short label, right? – elliott-io May 08 '20 at 20:21
  • Umm... aren't we back to the original problem again? _How do we know where the "center" of the path is?_ Yes, I only have a short label. – Sweeper May 08 '20 at 20:23
  • @Sweeper, well, you could check if a `path.contains(point)` or possibly override `lineFragmentRect`. To clarify your use-case, do you want to determine the widest part of your path and put a label there? – elliott-io May 08 '20 at 21:00
  • yes, ideally that. Or, _any_ place that can contain the label is fine. – Sweeper May 08 '20 at 21:03
  • Based on this, I've come up with an updated answer. It worked with my test path, which I'll post as well. – elliott-io May 08 '20 at 22:46
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/213474/discussion-between-sweeper-and-elliott-io). – Sweeper May 09 '20 at 09:53