My goal is to achieve UI as on the screenshot. In simple words, I have a text view that can be more than 1 line, I need to add something similar to underline, which is half of a single text line thick and with some vertical offset.
The first idea that comes into mind is using .underline(), however it doesn't give you ability to customize line height and position. The next thought is to use .overlay, but in this case background is applied to the whole view(which can be more than 1 line), while I need it to be applied to each line separately.
P.s. - Splitting the text into two or more views is not an option, because the string is dynamic.
I don't believe this can be done directly in SwiftUI. Text
is still extremely limited. But it can be done with a UIViewRepresentable
. It's difficult to get this to exactly match Text
, and you have to be careful to only use attributes that UIKit understands, but it does work.
I'm assuming here that you really just want to put this extra background overlay over the entire string, rather than actually trying to implement a special kind of underline. (It should be possible to build that too, if you were to need this just for certain text.)
First, start with the basic boilerplate structure of a TextKitView
:
struct TextKitView: UIViewRepresentable {
var text: AttributedString
class CustomTextView: UIView {
private let textStorage = NSTextStorage()
private let layoutManager = NSLayoutManager()
private let textContainer: NSTextContainer
init(text: AttributedString, frame: CGRect) {
self.textContainer = NSTextContainer(size: frame.size)
self.textContainer.lineFragmentPadding = 0 // Better match Text
super.init(frame: frame)
// Setup TextKit components
textStorage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(textContainer)
// Set the text
textStorage.setAttributedString(NSAttributedString(text))
backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
let range = layoutManager.glyphRange(for: textContainer)
layoutManager.drawBackground(forGlyphRange: range, at: CGPoint.zero)
layoutManager.drawGlyphs(forGlyphRange: range, at: CGPoint.zero)
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
textContainer.size = size
layoutManager.ensureLayout(for: textContainer)
// Get the size of the content that fits the text container
let usedRect = layoutManager.usedRect(for: textContainer)
return CGSize(width: usedRect.width, height: usedRect.height)
}
}
func makeUIView(context: Context) -> CustomTextView {
CustomTextView(text: text, frame: .zero)
}
func updateUIView(_ uiView: CustomTextView, context: Context) {
uiView.setNeedsDisplay()
}
func sizeThatFits(_ proposal: ProposedViewSize,
uiView: CustomTextView,
context: Context) -> CGSize? {
uiView.sizeThatFits(proposal.replacingUnspecifiedDimensions())
}
}
Yes, it's a lot of boilerplate, but it's a good starting point for building custom text views.
With this, you need two changes to get what you're describing. Add to CustomTextView
a method that draws what you want as an "underline" to each enclosing rect:
private func drawUnderline(range: NSRange) {
let context = UIGraphicsGetCurrentContext()!
context.saveGState()
let color = CGColor(red: 233.0/255,
green: 217.0/255,
blue: 190.0/255,
alpha: 1)
context.setFillColor(color)
layoutManager.enumerateEnclosingRects(forGlyphRange: range,
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),
in: textContainer) { rect, _ in
let halfHeight = rect.size.height / 2
let halfOrigin = CGPoint(x: rect.origin.x,
y: rect.origin.y + halfHeight)
let halfSize = CGSize(width: rect.size.width,
height: halfHeight)
let halfRect = CGRect(origin: halfOrigin,
size: halfSize)
context.fill([halfRect])
}
context.restoreGState()
}
And then add that to draw(_:)
:
layoutManager.drawBackground(forGlyphRange: range, at: CGPoint.zero)
drawUnderline(range: range) // Add a call to your new method
layoutManager.drawGlyphs(forGlyphRange: range, at: CGPoint.zero)
With that I get this.
You can tweak to your precise goals.
Below is a complete example with a Preview:
import SwiftUI
import UIKit
struct ContentView: View {
let string: AttributedString
init() {
let attributes = AttributeContainer()
.font(.preferredFont(forTextStyle: .body))
string = AttributedString("Your Guide to Building a Balanced and Fulfilling Lifestyle", attributes: attributes)
}
var body: some View {
VStack {
TextKitView(text: string)
.padding()
Text(string)
}
.padding()
}
}
#Preview {
ContentView()
}
struct TextKitView: UIViewRepresentable {
var text: AttributedString
class CustomTextView: UIView {
private let textStorage = NSTextStorage()
private let layoutManager = NSLayoutManager()
private let textContainer: NSTextContainer
init(text: AttributedString, frame: CGRect) {
self.textContainer = NSTextContainer(size: frame.size)
self.textContainer.lineFragmentPadding = 0 // Better match Text
super.init(frame: frame)
// Setup TextKit components
textStorage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(textContainer)
// Set the text
textStorage.setAttributedString(NSAttributedString(text))
backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func drawUnderline(range: NSRange) {
let context = UIGraphicsGetCurrentContext()!
context.saveGState()
let color = CGColor(red: 233.0/255,
green: 217.0/255,
blue: 190.0/255,
alpha: 1)
context.setFillColor(color)
layoutManager.enumerateEnclosingRects(forGlyphRange: range,
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),
in: textContainer) { rect, _ in
let halfHeight = rect.size.height / 2
let halfOrigin = CGPoint(x: rect.origin.x,
y: rect.origin.y + halfHeight)
let halfSize = CGSize(width: rect.size.width,
height: halfHeight)
let halfRect = CGRect(origin: halfOrigin,
size: halfSize)
context.fill([halfRect])
}
context.restoreGState()
}
override func draw(_ rect: CGRect) {
let range = layoutManager.glyphRange(for: textContainer)
layoutManager.drawBackground(forGlyphRange: range, at: CGPoint.zero)
drawUnderline(range: range)
layoutManager.drawGlyphs(forGlyphRange: range, at: CGPoint.zero)
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
textContainer.size = size
layoutManager.ensureLayout(for: textContainer)
// Get the size of the content that fits the text container
let usedRect = layoutManager.usedRect(for: textContainer)
return CGSize(width: usedRect.width, height: usedRect.height)
}
}
func makeUIView(context: Context) -> CustomTextView {
CustomTextView(text: text, frame: .zero)
}
func updateUIView(_ uiView: CustomTextView, context: Context) {
uiView.setNeedsDisplay()
}
func sizeThatFits(_ proposal: ProposedViewSize,
uiView: CustomTextView,
context: Context) -> CGSize? {
uiView.sizeThatFits(proposal.replacingUnspecifiedDimensions())
}
}