How to add tag as part of multi-line text view in SwiftUI

Eugene Mokeiev
Just Eat Takeaway-tech
5 min readMay 16, 2024

--

Sometimes we need to append a tag at the end of multi-line text view. For example, it can be an age restriction label or allergy tag at the end of the product description. What makes it more difficult is a strict line limit on said text, and a requirement that the appended tag should not be truncated.

How to achieve all this?

The answer is a custom component. Our plan of implementation is the following:

  1. Split the text into array of words
  2. Fill lines of text programmatically word after word, switching to the next line if the previous one is filled
  3. Set the highest layout priority for our tag so it’s never truncated

Let’s follow it step-by-step!

Creating TextAndTagViewModel

First, we introduce our view model. We introduce a typealias for our lines (string arrays). Next, we add the necessary view parameters: word spacing, line spacing and font. We also add two internal properties: line limit and view width along with accessibility information to utilise later.

class TextAndTagViewModel: ObservableObject {
typealias TextLine = [String]

let wordSpacing: CGFloat = 3
let lineSpacing: CGFloat = 2
let font = UIFont.systemFont(ofSize: 18)

@Published var textLines: [TextLine] = [TextLine]()
var accessibilityLabel: String

private let lineLimit: Int
private let viewWidth: Double

init(text: String,
tagText: String,
lineLimit: Int,
viewWidth: Double) {
self.viewWidth = viewWidth
self.lineLimit = lineLimit
accessibilityLabel = [text, tagText].joined(separator: " ")
textLines = createTextLines(from: text, tagText: tagText)
}
}

Helper function

Then, we introduce a simple text-measuring function using UILabel. It’s pretty straightforward and provides us with accurate measurements:

private func textWidth(_ text: String) -> CGFloat {
let label = UILabel()
label.font = font
label.text = text
label.sizeToFit()
return label.frame.size.width
}

Line filling algorithm

Next, we add a more advanced helper function. It constructs a line of text from the provided words, and takes into account the fact that the line is the last one.

How it works:

  • for each word, we calculate its width and add it to the total width along with word spacing
  • if it fits the line width, we add the word to the line
  • if not, we check if it’s the last line
  • if it’s the last line, we add the tag view and clear the remaining words

We return the line along with the remaining words.

private func buildTextLine(
from words: [String],
isLastLine: Bool) -> (line: TextLine, remainingWords: [String]) {
var remainingWords = words
var line = TextLine()
var totalWidth: CGFloat = 0

for word in words {
let wordWidth = textWidth(word)
if (totalWidth + wordSpacing + wordWidth) < viewWidth {
totalWidth += wordWidth + wordSpacing
line.append(word)
remainingWords.removeFirst()
} else if isLastLine, let tag = remainingWords.last {
line.append(tag)
remainingWords.removeAll()
break
} else {
break
}
return (line, remainingWords)
}

Building the text lines

This function concludes our view model implementation.

We split our text into array of words. After that, we initiate the while loop and go through the array until all of out text lines are filled.

private func createTextLines(
from text: String,
tagText: String) -> [TextLine] {
guard viewWidth > 0 else {
return []
}
var words = text.split(separator: " ").compactMap { String($0) }
words.append(tagText)
var result: [TextLine] = [TextLine]()

while !words.isEmpty {
let isLastLine = (result.count == lineLimit - 1)
let (textLine, remainingWords) = buildTextLine(from: words, isLastLine: isLastLine)
result.append(textLine)
words = remainingWords
}
return result
}

Creating TextAndTagView

Now we are ready to proceed with view implementation.

struct TextAndTagView: View {
@ObservedObject private var viewModel: TextAndTagViewModel

init(viewModel: TextAndTagViewModel) {
self.viewModel = viewModel
}
}

First, we add helper functions which construct text and tag views. Each of them has one line of text and utilises our font. Tags also have fixed size to avoid truncation.

private func textView(text: String) -> some View {
Text(text)
.font(Font(viewModel.font))
.lineLimit(1)
}
private func tagView(text: String) -> some View {
Text(text)
.fixedSize()
.font(Font(viewModel.font))
.background(Color.yellow)
.lineLimit(1)
}

Next, we create a helper function for line view, which is a horizontal stack of text and tag views. If it’s the last word in the line, and the line is the last one, we populate the stack with a tag. Otherwise, we add a text view.

Notice how we prioritise the first word in the line for the layout engine. This allows the view to execute truncation properly: truncate the word near the tag view instead of truncating all of the words equally. Our algorithm which we implemented in the view model ensures that there is only one word to truncate.

private func lineView(words: [String], lineIndex: Int) -> some View {
HStack(spacing: viewModel.wordSpacing) {
ForEach(Array(zip(words.indices, words)), id: \.0) { wordIndex, word in
if isLastLine, wordIndex == words.indices.count - 1 {
tagView(text: word)
} else {
textView(text: word)
.layoutPriority(wordIndex == 0 ? 1 : 0)
}
}
}
}

Now we are ready to add our implementation to the body function. This is a simple vertical stack of text lines from view model.

var body: some View {
VStack(alignment: .center, spacing: viewModel.lineSpacing) {
ForEach(Array(zip(viewModel.textLines.indices, viewModel.textLines)), id: \.0) { lineIndex, words in
let isLastLine = lineIndex == viewModel.textLines.indices.count - 1
lineView(words: words, isLastLine: isLastLine)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(viewModel.accessibilityLabel)
}

How it looks like in practice

Two-line view, enough space:

Two-line view, not enough space:

Conclusion

This simple TextAndTagView allows us to solve the problem of tag truncation. We can use it to solve the issues of properly displaying age restriction labels, food / allergy tags, offers etc. Hope it will be useful to you, too!

Want to come work with us at Just Eat Takeaway.com? Check out our open roles.

--

--