6

Let's imagine that I have some text: "Some attributed text in trapezoid."

I have NSAttributedString extension, which returns me UIImage with attributed text:

extension NSAttributedString {
    func asImage() -> UIImage? {
        defer {
            UIGraphicsEndImageContext()
        }
        let size = boundingRect(with: CGSize.zero, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil).size
        UIGraphicsBeginImageContext(size)
        draw(at: CGPoint.zero)
        return UIGraphicsGetImageFromCurrentImageContext()
    }
}

But this function returns me text in one line, because of using boundingRect:

------------------------------------
|Some attributed text in trapezoid.|
------------------------------------

If I would use custom rect for drawing text it won't help much...

UIGraphicsBeginImageContext(CGRect(x: 0, y: 0, width: 100, height: 30))
draw(at: CGPoint.zero)

...because of text will be in rectangle:

--------------
|Some attribu|
|ted text in |
|trapezoid.  |
--------------

What i need, is to draw text in a trapezoid with known corner positions (or in a circle with known radius). So each new line of text should start with a little offset, see example: enter image description here

So I want to see something like that:

---------------
\Some attribut/
 \ed text in /
  \trapezoid/
   ---------

How can I achieve this result?

Vasilii Muravev
  • 3,063
  • 17
  • 45
  • 1
    This article provides a good start. https://developer.apple.com/library/content/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/CustomTextProcessing/CustomTextProcessing.html#//apple_ref/doc/uid/TP40009542-CH4-SW1 – BallpointBen Jul 27 '17 at 02:19
  • 1
    For circular based shapes, I think here's your solution: [How to fit text in a circle in UILabel](https://stackoverflow.com/a/22179104/2124535) And using it for a trapezoid *shouldn't* (haven't tested) be so far out since it would mean defining the excluded areas as 2 right triangles – nathan Jul 27 '17 at 05:21
  • 1
    Adding to BallPointBen, maybe some ideas: https://stackoverflow.com/questions/26632474/uitextview-textcontainer-exclusion-path-fails-if-full-width-and-positioned-at-to – Larme Jul 27 '17 at 11:48

2 Answers2

2

You'll have to drop down to CoreText levels here. The good new is, you will be able to draw text in just about any shape you wish!

extension NSAttributedString {
    public func draw(in path: CGPath) {
        let context = UIGraphicsGetCurrentContext()!

        let transform = CGAffineTransform(scaleX: +1, y: -1)

        let flippedPath = CGMutablePath()
        flippedPath.addPath(path, transform: transform)

        let range = CFRange(location: 0, length: 0)
        let framesetter = CTFramesetterCreateWithAttributedString(self)
        let frame = CTFramesetterCreateFrame(framesetter, range, flippedPath, nil)

        context.saveGState()

        // Debug: fill path.
        context.setFillColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 0.5)
        context.beginPath()
        context.addPath(path)
        context.fillPath()

        context.concatenate(transform)

        CTFrameDraw(frame, context)

        context.restoreGState()
    }
}

And you can use it like so:

let string = NSAttributedString(string: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.")

let bounds = CGRect(x: 0, y: 0, width: 120, height: 120)

let path = CGMutablePath()
path.move(to: CGPoint(x: 10, y: 10))
path.addLine(to: CGPoint(x: 110, y: 10))
path.addLine(to: CGPoint(x: 90, y: 110))
path.addLine(to: CGPoint(x: 30, y: 110))
path.closeSubpath()

UIGraphicsBeginImageContextWithOptions(bounds.integral.size, true, 0)
defer { UIGraphicsEndImageContext() }

let context = UIGraphicsGetCurrentContext()!
context.setFillColor(UIColor.white.cgColor)
context.fill(.infinite)

string.draw(in: path)

let image = UIGraphicsGetImageFromCurrentImageContext()!

The bad news is, this solution does not give you an ellipsis at the end. If you really want to have that, you may need to make some adjustments to the last line the framesetter gives you.

Christian Schnorr
  • 10,768
  • 8
  • 48
  • 83
  • Cool, that's what I need. `The bad news is, this solution does not give you an ellipsis at the end` - it works with ellipse also, if path is ellipse. Do you know how to set horizontal text alignment for center? – Vasilii Muravev Jul 31 '17 at 18:16
  • @VasiliiMuravev You can try playing around with the paragraph style of the attributed string. If that doesn't work, you can horizontally center the lines yourself by asking the frame for the lines and drawing them individually. – Christian Schnorr Aug 03 '17 at 14:04
1

With Christian Schnorr answer made it for my purposes:

import PlaygroundSupport
import UIKit

extension NSAttributedString {

    /// Draws attributed string in selected CGPath.
    func draw(in path: CGPath) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        let transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
        let flippedPath = CGMutablePath()
        flippedPath.addPath(path, transform: transform)
        let range = CFRange(location: 0, length: 0)
        let framesetter = CTFramesetterCreateWithAttributedString(self)
        let frame = CTFramesetterCreateFrame(framesetter, range, flippedPath, nil)
        context.saveGState()
        context.concatenate(transform)
        CTFrameDraw(frame, context)
        context.restoreGState()
    }

    /// Renders attributed string.
    ///
    /// - Parameters:
    ///   - size: A 'CGSize' for rendering string in trapezoid.
    ///   - degree: A `CGFloat`, representing trapezoid angles in degrees.
    /// - Returns: An optional `UIImage` with rendered string.
    func asTrapezoidImage(size: CGSize, degree: CGFloat) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(size, false, 0)
        defer { UIGraphicsEndImageContext() }
        draw(in: size.trapezoidPath(degree))
        return UIGraphicsGetImageFromCurrentImageContext()
    }
}

extension CGSize {

    /// Converts CGSize into trapezoid CGPath.
    ///
    /// - Parameter degree: A `CGFloat`, representing trapezoid angles in degrees.
    /// - Returns: A `CGPath` with trapezoid.
    func trapezoidPath(_ degree: CGFloat) -> CGPath {
        var offset = height * tan(CGFloat.pi * degree / 180.0)
        offset = max(0, min(width / 2.0, offset))
        let path = CGMutablePath()
        path.move(to: CGPoint.zero)
        path.addLine(to: CGPoint(x: width, y: 0.0))
        path.addLine(to: CGPoint(x: width - offset, y: height))
        path.addLine(to: CGPoint(x: offset, y: height))
        path.closeSubpath()
        return path
    }
}

Using:

let string = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit."
let attributes: [NSAttributedStringKey: Any] = [
    .foregroundColor: UIColor.blue,
    .backgroundColor: UIColor.white,
    .font: UIFont.systemFont(ofSize: 24.0)
]
let attrString = NSAttributedString(string: string, attributes: attributes)
let size = CGSize(width: 400.0, height: 120.0)
let image = attrString.asTrapezoidImage(size: size, degree: 12.0)
let imageView = UIImageView(image: image)
imageView.frame = CGRect(origin: CGPoint.zero, size: size)
PlaygroundPage.current.needsIndefiniteExecution = false
PlaygroundPage.current.liveView = imageView

Note Degrees allowed from 0° to 90°

Vasilii Muravev
  • 3,063
  • 17
  • 45