How to make a background with rounded corners for text view in UIKit — Telegram-Drawing-Text-Editing, ep. 4

Ivan Pryhara
14 min readJan 21, 2023

--

First part

All right, the person who found this article already know something about TextKit, so I won’t annoy you with this principles and basic stuff which you can google in a couple of minutes, meanwhile I’ll put you right into the seeking solution.

Let’s start from the NSLayoutManager custom implementation:

class IPNSLayoutManager: NSLayoutManager {

var strokePoints: [Int: [String: CGPoint]] = [:]

var strokePath: UIBezierPath = UIBezierPath()

var cornerRadius: CGFloat = 7

override init() {
super.init()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
// Get a text storage from NSLayoutManger's property
guard let textStorage = textStorage else { return }
// Get attributes from the textStorage
let attributes = textStorage.attributes(at: 0, effectiveRange: nil)

guard let backgroundColor = attributes[.backgroundColor] as? UIColor else { return }
// Kind of a hack to disable default background with sharp corners, which we want to avoid
let restoredAlphaColor = backgroundColor.withAlphaComponent(1)
// Set the color as fill color
restoredAlphaColor.setFill()

let range = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)

let glyphRange = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil)

let context = UIGraphicsGetCurrentContext()
context?.saveGState()
context?.translateBy(x: origin.x, y: origin.y)
// Keep in mind that drawBackground method will always be invoked after you change your text, so we
// Erase all data in strokePoints to avoid duplication of already drawn paths.
strokePoints = [:]
strokePath = UIBezierPath()

self.enumerateLineFragments(forGlyphRange: glyphRange) { [weak self] rect, usedRect, textContainer, glyphRange, stop in
// Get points from usedRect
self?.getPoints(from: usedRect)
context?.addPath(self?.strokePath.cgPath ?? UIBezierPath().cgPath)
context?.setFillColor(restoredAlphaColor.cgColor)
context?.setStrokeColor(restoredAlphaColor.cgColor)
context?.fillPath(using: .evenOdd)

context?.strokePath()

}
context?.restoreGState()
}
}

Alright here a quick stop to show you my way of dealing with a problem. Default implementation of some articles you’ve already seen will give something like this.

Good looking, but unexpected result.

Well, we could take this approach and improve it significantly. As you can see each line is a separate rounded rectangle which has a frame and its frame has minX, minY, maxX, maxY values which are used to define four corners of the rectangle. With this knowledge we could draw a stroke that will start from the top of our text and move (in my example, you can choose a different direction) clockwise until we met our starting point.

As you can see in getPoints(from:) implementation I define four points and give them corresponding names just for sake of simplicity. And they are really logical, consider them as:

rectangleFrame — a rectangle that represents a glyph range

ltc - left top corner (x: rectangleFrame.minX, y: rectangleFrame.minY)

lbc - left bottom corner (x: rectangleFrame.minX, y: rectangleFrame.maxY)

rtc - right top corner (x: rectangleFrame.maxX, y: rectangleFrame.minY)

rbc - right bottom corner (x: rectangleFrame.maxX, y: rectangleFrame.maxY)

Drawing concept

All right the next screenshot shows what kind of result we want to get and what direction we move and a couple of other concepts:

Yellow stroke represents the way we move. We do draw each line separately, and remove previous path. Consider this example: We have three lines, so the buildPath method will be called three times. One time per line. When we are on the first line we draw first rectangle, then we erase points and path. Then we draw first line again, and after that we move to the second line with applied rules-of-proper-arc and so on until we drew all rectangles. If you remove fill color you’ll see what I’m trying to describe here.

1 — Red group

The first group is the first line which we start to draw from the ltc and move to rtc and finally to rbc

2 — Green group

The second group is the middle lines, so potentially is the biggest group of our text. All four points of each line will differ depend on previous and next line from current line pov.

3 — Magenta group

The last for the text right side of the text is the magenta or third group. The last line. It’s easier to calculate than middle lines, cause the rbc and lbc will always be the same. Likewise for ltc, rtc for the first line(Red group).

After we’ve done with the right side of the figure we invoke a method that is responsible for building left side of the text background. Which has pretty much the same logic of group but with a little adjustments which I’ll describe later.

Arcs’s states

There are [2] arc states we’ll encounter. When center of the arc that rectangle possess

  1. is inside
  2. is outside

This two state are used differently depends on which line and which point of the rectangle we’re considering.

First line(Red group)

The ltc and rtc will always have first arc state. However rbc will have different states depend on a simple condition: if nextLine is bigger than current use second state, otherwise the first . The same concept is applied for lbc with only difference that in this case we’ll compare current line with the previous one.

Each middle line(Green group)

In that case there’s no corners that always have the same state. For rtc is the next conditional if previous line is bigger use second state, otherwise the first for rbc - if nextLine is bigger than current use second state, otherwise the first . For the left side the same algorithm is applied with small adjustments, ltc check next line, meanwhile rbc previous one.

The last line(Magenta group)

Here only ltc and rtc will have changeable states which are pretty the same as for this corners described above for middle lines(green group). lbc and rbc always have first state for the arcs.

Reusable building arcs/lines methods

For the sake of simplicity and reusability I create a couple of methods that are used to build proper arcs and lines depend on different states. Here they’re:

Yeah, there maybe a way to avoid duplication by creating an enum with four cases that represent different corners, but I don’t do that, cause it makes a function to be really big.

Here’s a little logical explanation about what you’ll see. If you don’t care you can move on to methods implementation by this link or you could consider the NSLayoutManager implementation directly on my Github.

When we move our cursor to a provided point the next element(line or arc) will be drawn from that point, obviously. So for cases when our next element is an arc we need to keep in mind that we don’t want to start drawing from either of these four corners(ltc, rtc, rbc, lbc), but what we want to do is to make an offset from the point to make sure our rectangle has right dimensions. Observe the underlying image to get the idea better.

As you can see we just can’t use rtc’s X and Y, because it will cause that our rectangle increases in itself size. So in case of rtc previous point ltc and we move our line to a point that has next parameters (rtc.x — cornerRadius, y: rtc.y) . For different points adjustments are distinct. Talking about left side of the figure in some cases we need to add cornerRadius to X, Y because we changed direction

Enhanced default drawing methods

Consider methods down bellow. Each of them has two states for line methods is with or without an offset. When we draw a line with an offset it means that the corner has an arc, otherwise it doesn’t. The latter is when lines have the same length. In this version of the article I don’t cover this, because I’m still in search of a solution for that.

Methods that responsible for arcs drawing have two states too. One when previous line was bigger, and another is when current line is bigger.

RTC methods

func addLine(rtc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: rtc)
} else {
strokePath.addLine(to: CGPoint(x: rtc.x - cornerRadius, y: rtc.y))
}
}

func addArc(rtc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: rtc.x - cornerRadius,
y: rtc.y + cornerRadius), radius: cornerRadius, startAngle: CGFloat(3 * Double.pi / 2), endAngle: 0, clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: rtc.x + cornerRadius,
y: rtc.y + cornerRadius), radius: cornerRadius, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi), clockwise: false)
}
}

RBC methods

func addLine(rbc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: rbc)
} else {
strokePath.addLine(to: CGPoint(x: rbc.x, y: rbc.y - cornerRadius))
}
}

func addArc(rbc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: rbc.x - cornerRadius,
y: rbc.y - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: CGFloat(Double.pi / 2), clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: rbc.x + cornerRadius,
y: rbc.y - cornerRadius), radius: cornerRadius, startAngle: CGFloat(Double.pi), endAngle: CGFloat(2 * Double.pi / 4) , clockwise: false)
}
}

LBC methods

func addLine(lbc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: lbc)
} else {
strokePath.addLine(to: CGPoint(x: lbc.x + cornerRadius, y: lbc.y))
}
}

func addArc(lbc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: lbc.x + cornerRadius,
y: lbc.y - cornerRadius), radius: cornerRadius, startAngle: CGFloat(Double.pi / 2), endAngle: CGFloat(Double.pi), clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: lbc.x - cornerRadius,
y: lbc.y - cornerRadius), radius: cornerRadius, startAngle: CGFloat(2 * Double.pi / 4), endAngle: 0, clockwise: false)
}
}

LTC methods

func addLine(ltc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: ltc)
} else {
strokePath.addLine(to: CGPoint(x: ltc.x, y: ltc.y + cornerRadius))
}
}

func addArc(ltc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: ltc.x + cornerRadius,
y: ltc.y + cornerRadius), radius: cornerRadius, startAngle: CGFloat(Double.pi), endAngle: CGFloat(3 * Double.pi / 2), clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: ltc.x - cornerRadius,
y: ltc.y + cornerRadius), radius: cornerRadius , startAngle: 0, endAngle: (3 * Double.pi) / 2, clockwise: false)
}
}

Build a path

Let’s begin drawing already and have a look at buildPath method once again!

override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
// Get a text storage from NSLayoutManger's property
guard let textStorage = textStorage else { return }
// Get attributes from the textStorage
let attributes = textStorage.attributes(at: 0, effectiveRange: nil)

guard let backgroundColor = attributes[.backgroundColor] as? UIColor else { return }
// Kind of a hack to disable default background with sharp corners, which we want to avoid
let restoredAlphaColor = backgroundColor.withAlphaComponent(1)
// Set the color as fill color
restoredAlphaColor.setFill()

let range = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)

let glyphRange = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil)

let context = UIGraphicsGetCurrentContext()
context?.saveGState()
context?.translateBy(x: origin.x, y: origin.y)

strokePoints = [:]
strokePath = UIBezierPath()

self.enumerateLineFragments(forGlyphRange: glyphRange) { [weak self] rect, usedRect, textContainer, glyphRange, stop in
// Get points from usedRect
self?.getPoints(from: usedRect)
context?.addPath(self?.strokePath.cgPath ?? UIBezierPath().cgPath)
context?.setFillColor(restoredAlphaColor.cgColor)
context?.setStrokeColor(restoredAlphaColor.cgColor)
context?.fillPath(using: .evenOdd)

context?.strokePath()

}
context?.restoreGState()
}

As you can saw before I’ve added strokePoints that has the next structure:

• We have a line index which is outermost key, the corresponding value is another dictionary. Inner dictionary holds a CGPoint instance that’s stored behind the key — string representation of the corner(rtc, ltc …)

And strokePath that is UIBezierPath that will be expanded by points from strokePoints during drawing.

// The structure is next [0 : [ltc: (x:0,y:0), rtc: (x:20,y:0), ...], ...]
var strokePoints: [Int: [String: CGPoint]] = [:]
// The path that will be used as a stroke around text's lines(rectangles)
var strokePath: UIBezierPath = UIBezierPath()

Since the method drawBackground is called whenever you add a new symbol and have enabled background drawing at the moment we need to ensure that previous values of strokePoints will be eradicated before we start new render. The same thing should be made with strokePath . I do this right before calling enumerateLineFragments

The very first call in the enumerateLineFragments is getPoints(from: usedRect) that is responsible for taking points of provided rect and put them to strokePoints with a help of method appendPointsToStrokePoints(ltc: rtc: rbc: lbc: )

func getPoints(from rect: CGRect) {

let ltc: CGPoint = rect.origin
let rtc: CGPoint = CGPoint(x: rect.maxX, y: rect.minY)
let rbc: CGPoint = CGPoint(x: rect.maxX, y: rect.maxY)
let lbc: CGPoint = CGPoint(x: rect.minX, y: rect.maxY)

appendPointsToStrokePoints(ltc: ltc, rtc: rtc, rbc: rbc, lbc: lbc)

buildPath()

}

func appendPointsToStrokePoints(ltc: CGPoint, rtc: CGPoint, rbc: CGPoint, lbc: CGPoint) {
let currentIndexOfStrokePoints: Int = strokePoints.count

strokePoints[currentIndexOfStrokePoints] = ["ltc": ltc, "rtc": rtc, "rbc": rbc, "lbc": lbc]
}

As you can see we call buildPathinside this method. Which can cause a pretty reasonable question, why the hell we need to call this method on each line, actually because I haven’t found a better way to do that, and this method helps to ensure that we always have relevant figure. Because when iteration comes to last line the property of strokePoints will be full of lines that we’ve written so far and because we’ll invalidate previous paths we get correct form that embeds our text properly. Currently this is the only way I can make it work, so in the future updates of the article, I probably change the way the algorithm behaves.

buildPath()

buildPath method is really big, so instead of just put it here I will cut it to a few code snippets which helps to better understand what’s going on. Keep in mind everything that goes next are placed in buildPath, except a few things I write about.

let numberOfLines = strokePoints.count

for i: Int in 0..<numberOfLines{


}

strokePath.close()

So literally buildPath possesses for loop that iterates through a range of number of lines.

Everything bellow is placed in for loop:

Simplified structure of the method. Is not actual code!

buildPath {

for {
code to build lines and arcs
}

closePath
}

Preparation stage

// 1
var currentLine: [String : CGPoint]? = strokePoints[i]
// 2
guard var ltc: CGPoint = currentLine?["ltc"],
var rtc: CGPoint = currentLine?["rtc"],
var rbc: CGPoint = currentLine?["rbc"],
var lbc: CGPoint = currentLine?["lbc"] else { return }

var previousLine: [String : CGPoint]? = nil
// 3
if i > 0 {
previousLine = strokePoints[i - 1]
}

var nextLine: [String : CGPoint]? = nil
// 3
if i < numberOfLines - 1 {
nextLine = strokePoints[i + 1]
}
  1. Initialize current line which has a type of [String: CGPoint]? which we get from strokePoints with a value that is current index i

2. Perform optional binding to get corners from current line

3. Try to get previous and next lines if it is possible

Draw a background if numbers of lines is equal to 1

The code we put after we’ve made a preparation stage is build a rounded corners rectangle for the first, in that case one line, cause we don’t have any:

if numberOfLines == 1 {
strokePath.move(to: ltc)

addLine(rtc: rtc)
addArc(rtc: rtc)

addLine(rbc: rbc)
addArc(rbc: rbc)

addLine(lbc: lbc)
addArc(lbc: lbc)

addLine(ltc: ltc)
addArc(ltc: ltc)
}

Draw a background if numbers of lines is greater than one

The first line rectangle code:

if i == 0 {
guard let nextLine: [String : CGPoint] = nextLine else { return }

addLine(rtc: rtc)
addArc(rtc: rtc)

addLine(rbc: rbc)

if nextLine["rtc"]!.x < rtc.x {
// From top the left (corner inside)
addArc(rbc: rbc)

} else if nextLine["rtc"]!.x > rtc.x {
// From top to the right (corner outside)
addArc(rbc: rbc, currentGreater: false)
}

}

In this case we need to unwrap nextLine to ensure that this line exists. After that we start drawing our figure. Depends on whether nextLine’s x is bigger or less than currentLine’s x we draw an arc with correct direction of the end point.

Each middle line will use the next algorithm:

else {
// MARK: The right side of some middle line
guard let previousLine: [String : CGPoint] = previousLine,
let nextLine: [String : CGPoint] = nextLine else { return }

if previousLine["rtc"]!.x > rtc.x {
// From top to the left( corner outside )
addLine(rtc: rtc)
addArc(rtc: rtc, currentGreater: false)
} else {
// From top to the right ( corner inside )
addLine(rtc: rtc)
addArc(rtc: rtc)
}

if nextLine["rtc"]!.x > rbc.x {
// From bottom to the right ( corner inside )
addLine(rbc: rbc)
addArc(rbc: rbc, currentGreater: false)
} else {
// From bottom to the left ( corner outside )
addLine(rbc: rbc)
addArc(rbc: rbc)
}
}

Because middle lines are, well somewhere in between of other lines they have previous and next lines neighbours, so check whether they aren’t nil. Depends on previous line’s X we draw rtc , hence rbc will differ if next line’s X is greater or less than current.

The next code should be put before else block but it’s responsible for the last line drawing:

else if i == numberOfLines - 1 {
// MARK: The last line/rectangle
guard let previousLine: [String : CGPoint] = previousLine else { return }


addLine(rtc: rtc)
if previousLine["rtc"]!.x > rtc.x {
// From top to the left (corner outside)
addArc(rtc: rtc, currentGreater: false)
} else {
// From top to the rigth (corner outside)
addArc(rtc: rtc)
}

addLine(rbc: rbc)
addArc(rbc: rbc)

drawCornersForPreviousPoints(strokePoints, index: i)

}

Check for previous line and compare Xs, like before. Move one until this line drawCornersForPreviousPoints(strokePoints, index: i) This is the function which will draw left side of our figure, so we move backwards to the very first line. We define this function outside the scope

Well the algorithm of this method is generally the same as for right side instead of a few exceptions:

  1. If a line A is bigger than line B it means that A has a lower X on this left side, because nearer we get to the left side of the screen our X decreases. Which we need to take into account when compare values.
  2. Indexes of the next and previous line are changed in compare to right side.
  3. Counter will be decreased until we get to 0

drawCornersForPreviousPoints()

func drawCornersForPreviousPoints(_ strokePoints: [Int : [String : CGPoint]], index i: Int) {
// i is stands for the last index of our array of points, or simply (strokePoints.count - 1)
var c = i
// Here we move backwards to the top
// Start with left bottom corner:
while c >= 0 {
let currentLine: [String : CGPoint]? = strokePoints[c]
// MARK: The left side of some middle line
guard let ltc: CGPoint = currentLine?["ltc"],
let lbc: CGPoint = currentLine?["lbc"] else { return }

var nextLine: [String : CGPoint]? = nil
// index of next line
let iN = c - 1

if iN >= 0 {
nextLine = strokePoints[iN]
}

var previousLine: [String : CGPoint]? = nil
// index of previous line
let iP = c + 1

if iP <= i {
previousLine = strokePoints[iP]
}

if c == i {
// Keep in mind that greater line will have lower X-Axis!!!!
addLine(lbc: lbc)
addArc(lbc: lbc)

guard let nextLine: [String : CGPoint] = nextLine else { return }
addLine(ltc: ltc)

if nextLine["ltc"]!.x < ltc.x {
// Next line is greater than current
addArc(ltc: ltc, currentGreater: false)
} else {
// Next line is less than current
addArc(ltc: ltc)
}

} else if c == 0 {
// The first line/rectangle from the top.
guard let previousLine: [String : CGPoint] = previousLine else { return }

addLine(lbc: lbc)

if previousLine["ltc"]!.x < lbc.x {
// Previous is bigger
addArc(lbc: lbc, currentGreater: false)
} else {
// Previous is less than current
addArc(lbc: lbc)
}

addLine(ltc: ltc)
addArc(ltc: ltc)

} else {

guard let previousLine: [String : CGPoint] = previousLine else { return }

addLine(lbc: lbc)

if previousLine["ltc"]!.x < lbc.x {
// Previous line bigger than current

addArc(lbc: lbc, currentGreater: false)

} else {
// Previous line is less than current
addArc(lbc: lbc)
}

guard let nextLine = nextLine else { return }

addLine(ltc: ltc)

if nextLine["ltc"]!.x < ltc.x {
// Next line is greater than current
addArc(ltc: ltc, currentGreater: false)

} else {
// Next luine is less than current
addArc(ltc: ltc)
}
}
c -= 1
}
}

And that’s pretty all you need to build a background with rounded corners for text view.

My GitHub

Second part

This part is kind of optional, because it goes beyond the initial topic, but which, I believe, helps people to see the result.

Comming soon…

--

--

Ivan Pryhara
Ivan Pryhara

No responses yet