Search code examples
iosswiftswiftui

Is there a way to create custom underline in SwiftUI?


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. Desired UI Screenshot

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.


Solution

  • 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.

    Image of text with described "underline."

    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())
        }
    }