How to make text stroke in SwiftUI?
Asked Answered
R

9

34

I'm trying to make text-stroke in SwiftUI or add a border on my text, in the letters not the Text() item.

Is it possible?

I want to make this effect with the border: effect
(source: noelshack.com)

Rapp answered 2/8, 2019 at 22:37 Comment(2)
Have you tried using a shadow? Not really what you asked but the appearance might be similar.Goodness
Hi, I tried with 4 shadow with x position 1 and -1 and the same for y position ( radius to 0), that works fineRapp
T
14

I don't think there's a way for doing that "out of the box".
So far (beta 5) we can apply strokes to Shapes only.

For example:

struct SomeView: View {
    var body: some View {
        Circle().stroke(Color.red)
    }
}

But again that isn’t available for Text.

UIViewRepresentable

Another approach would be to use the good ol' UIKit \ NSAttributedString with SwiftUI via UIViewRepresentable.

Like so:

import SwiftUI
import UIKit

struct SomeView: View {
    var body: some View {
        StrokeTextLabel()
    }
}

struct StrokeTextLabel: UIViewRepresentable {
    func makeUIView(context: Context) -> UILabel {
        let attributedStringParagraphStyle = NSMutableParagraphStyle()
        attributedStringParagraphStyle.alignment = NSTextAlignment.center
        let attributedString = NSAttributedString(
            string: "Classic",
            attributes:[
                NSAttributedString.Key.paragraphStyle: attributedStringParagraphStyle,
                NSAttributedString.Key.strokeWidth: 3.0,
                NSAttributedString.Key.foregroundColor: UIColor.black,
                NSAttributedString.Key.strokeColor: UIColor.black,
                NSAttributedString.Key.font: UIFont(name:"Helvetica", size:30.0)!
            ]
        )

        let strokeLabel = UILabel(frame: CGRect.zero)
        strokeLabel.attributedText = attributedString
        strokeLabel.backgroundColor = UIColor.clear
        strokeLabel.sizeToFit()
        strokeLabel.center = CGPoint.init(x: 0.0, y: 0.0)
        return strokeLabel
    }

    func updateUIView(_ uiView: UILabel, context: Context) {}
}

#if DEBUG
struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        SomeView()
    }
}
#endif

Result

result

Of course you have to tweak the attributes (size, font, color, etc) of the NSAttributedString to generate the desired output. For that I would recommend the Visual Attributed String macOS app.

Tomkins answered 2/8, 2019 at 23:55 Comment(2)
thanks very much ! indeed since yesterday i search on the web and the solution is to use UIKit but my level is bad in swift. I hope that Apple make a easy way to make text complex effects in the futur in swiftUI.Rapp
When I change this to a UITextField and press return on the keyboard, this causes the app to freeze up. Would I have to change something in order for it to work?Madonna
M
29

Here is a 100% SwiftUI solution. Not perfect, but it works and it gives you full SwiftUI control of the resulting view.

enter image description here

import SwiftUI

struct SomeView: View {
    var body: some View {
        StrokeText(text: "Sample Text", width: 0.5, color: .red)
            .foregroundColor(.black)
            .font(.system(size: 12, weight: .bold))

    }
}

struct StrokeText: View {
    let text: String
    let width: CGFloat
    let color: Color

    var body: some View {
        ZStack{
            ZStack{
                Text(text).offset(x:  width, y:  width)
                Text(text).offset(x: -width, y: -width)
                Text(text).offset(x: -width, y:  width)
                Text(text).offset(x:  width, y: -width)
            }
            .foregroundColor(color)
            Text(text)
        }
    }
}

I suggest using bold weight. It works better with reasonably sized fonts and stroke widths. For larger sizes, you may have to add Text offsets in more angles to cover the area.

Marcelline answered 5/12, 2019 at 23:34 Comment(2)
I suggest to draw the outline in all 8 directions for a smooter result. I will add this as another answer, but I would also be happy if you modified yours.Autocade
Surely this breaks down with stroke widths greater than a third of the font widthCosine
S
24

I find another trick to create stroke, but it only works if your desired stroke width is not more than 1

Text("Hello World")
   .shadow(color: .black, radius: 1)

I used shadow, but make sure the radius is just 1, to get the same efffect

Serve answered 28/6, 2020 at 14:2 Comment(4)
This was really helpful to get some text to "pop" when overplayed on an imageRubbish
You can also stack more .shadow()s onto it to make the effect even stronger. Try 10 of them with radius of 0.4 eachSupersensible
By far the best solution here, especially the tip from @Supersensible to stack the shadows on top of each otherStrip
I'm glad I came back to this one. This plus @Supersensible tip works great at multiple scalesMarcelline
I
20

Here's another approach, without overlaying copies of the Text object. Works with any Shape or View.:

extension View {
    func stroke(color: Color, width: CGFloat = 1) -> some View {
        modifier(StrokeModifier(strokeSize: width, strokeColor: color))
    }
}

struct StrokeModifier: ViewModifier {
    private let id = UUID()
    var strokeSize: CGFloat = 1
    var strokeColor: Color = .blue

    func body(content: Content) -> some View {
        if strokeSize > 0 {
            appliedStrokeBackground(content: content)
        } else {
            content
        }
    }

    private func appliedStrokeBackground(content: Content) -> some View {
        content
            .padding(strokeSize*2)
            .background(
                Rectangle()
                    .foregroundColor(strokeColor)
                    .mask(alignment: .center) {
                        mask(content: content)
                    }
            )
    }

    func mask(content: Content) -> some View {
        Canvas { context, size in
            context.addFilter(.alphaThreshold(min: 0.01))
            if let resolvedView = context.resolveSymbol(id: id) {
                context.draw(resolvedView, at: .init(x: size.width/2, y: size.height/2))
            }
        } symbols: {
            content
                .tag(id)
                .blur(radius: strokeSize)
        }
    }
}
Immensity answered 19/4, 2023 at 12:46 Comment(6)
This looks like a pretty decent solution, gives the desired effect on text at least (not tried other shapes yet) and doesn't look like there are any rough edges you're likely to get with the duplicate-overlay solutions. Something I did notice is that if you've got a strokeSize of zero you still get a very small amount of stroke, so I added a strokeSize > 0 check to the start of the if let resolvedView = statement, which removed all stroking.Knp
@Knp Thx! Great suggestion, I edited the answer to accommodate the case where the stroke size is 0. Also refactored a bit to make it clearer, how it's built up.Immensity
Yeah that looks a bit more sensible than my quick hack!Knp
This sloution work great with Text and PNG image without background! It's what I was looking for, Thank you very much. BTW I notice if I use it with Rectangle Image, it had a little bit of cornerRadius so the border isn't 100% Rectangle shapeSchnurr
Very cool implementation ! A little question, why drawing creating a new layer instead of drawing directly into the first context ? I tested with same resultsAcaroid
@Acaroid You're absolutely right! Thanks for the hint. Edited the solutionImmensity
T
14

I don't think there's a way for doing that "out of the box".
So far (beta 5) we can apply strokes to Shapes only.

For example:

struct SomeView: View {
    var body: some View {
        Circle().stroke(Color.red)
    }
}

But again that isn’t available for Text.

UIViewRepresentable

Another approach would be to use the good ol' UIKit \ NSAttributedString with SwiftUI via UIViewRepresentable.

Like so:

import SwiftUI
import UIKit

struct SomeView: View {
    var body: some View {
        StrokeTextLabel()
    }
}

struct StrokeTextLabel: UIViewRepresentable {
    func makeUIView(context: Context) -> UILabel {
        let attributedStringParagraphStyle = NSMutableParagraphStyle()
        attributedStringParagraphStyle.alignment = NSTextAlignment.center
        let attributedString = NSAttributedString(
            string: "Classic",
            attributes:[
                NSAttributedString.Key.paragraphStyle: attributedStringParagraphStyle,
                NSAttributedString.Key.strokeWidth: 3.0,
                NSAttributedString.Key.foregroundColor: UIColor.black,
                NSAttributedString.Key.strokeColor: UIColor.black,
                NSAttributedString.Key.font: UIFont(name:"Helvetica", size:30.0)!
            ]
        )

        let strokeLabel = UILabel(frame: CGRect.zero)
        strokeLabel.attributedText = attributedString
        strokeLabel.backgroundColor = UIColor.clear
        strokeLabel.sizeToFit()
        strokeLabel.center = CGPoint.init(x: 0.0, y: 0.0)
        return strokeLabel
    }

    func updateUIView(_ uiView: UILabel, context: Context) {}
}

#if DEBUG
struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        SomeView()
    }
}
#endif

Result

result

Of course you have to tweak the attributes (size, font, color, etc) of the NSAttributedString to generate the desired output. For that I would recommend the Visual Attributed String macOS app.

Tomkins answered 2/8, 2019 at 23:55 Comment(2)
thanks very much ! indeed since yesterday i search on the web and the solution is to use UIKit but my level is bad in swift. I hope that Apple make a easy way to make text complex effects in the futur in swiftUI.Rapp
When I change this to a UITextField and press return on the keyboard, this causes the app to freeze up. Would I have to change something in order for it to work?Madonna
H
6

You can do this with SwiftFX

import SwiftUI
import SwiftFX

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .fxEdge()
    }
}

Here's the Swift Package:

.package(url: "https://github.com/hexagons/SwiftFX.git", from: "0.1.0")

Setup instructions here.

Holmann answered 5/3, 2020 at 14:42 Comment(7)
I tried your solution first because it works with Metal, so I thought it was better than Jose Santos' solution using 4 Texts. I'm a bit disappointed… because it works the same way :/ I tried with .fxEdge(strength: .constant(1), distance: .constant(100)) (voluntarily extreme) and got 5 text labels :/ I'm sorry but because of the Metal libs setup requirement, I won't use your solution.Hardan
Yeah the setup is a bit annoying. I'll try to fix this now as Swift Package manager has resource support. Tho to be honest I would personally go with the view representable approach by backslash-f. I hope SwiftUI adds support for the stroke modifier to Text one day.Holmann
I worked on a solution for a few hours yesterday, and I finished it this morning. It was 6 lines of pure SwiftUI, working perfectly! I could do everything (really large stroke, non-opaque color…), and as I was writing my long answer here, I cleaned XCode’s cache and it stopped working 😭 I’m so frustrated because a tiny thing doesn’t work as I’d like it to. (I think it uses Core Graphics which adds a default black background 😕)Hardan
Edit: it works perfectly with monochrome IIRC, and shows artefacts around the stroke with colored strokes.Hardan
And the problem of backslash-f’s solution is that it used UIKit, and I would like my solution to be cross-platform. NSAttributedString is what I used first, but I really wasn’t happy with it (not cross-platform and bad outline).Hardan
I managed to have my solution working again, and just shared it: https://mcmap.net/q/425984/-how-to-make-text-stroke-in-swiftui. I'll add pictures soon.Hardan
This seems like overkill. This re-checks its inputs and re-renders its output every frameCosine
F
3

I used the 'offset' text solution quite a bit before changing to using this instead and have found that it works a ton better. AND it has the added benefit of allowing outline text with a hollow inside WITHOUT needing to download a package just to get a simple effect.

It works by stacking .shadow and keeping the radius low to create a solid line around the object. if you want a thicker border, you will need to add more .shadow modifiers to the extension, but for all my text needs, this has done really well. Plus, it works on pictures as well.

It's not perfect, but I am a fan of simple solutions that stay in the realm of SwifUI and can be easily implemented.

Finally, the outline Bool parameter applies an inverted mask(something else SwiftUI lacks) and I have provided that extension as well.

extension View {
@ViewBuilder
func viewBorder(color: Color = .black, radius: CGFloat = 0.4, outline: Bool = false) -> some View {
    if outline {
        self
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .invertedMask(
                self
            )
    } else {
        self
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
            .shadow(color: color, radius: radius)
    }

}

}

extension View {
func invertedMask<Content : View>(_ content: Content) -> some View {
    self
        .mask(
            ZStack {
                self
                    .brightness(1)
                content
                    .brightness(-1)
            }.compositingGroup()
            .luminanceToAlpha()
        )
}

}

Again, not a 'perfect' solution, but it is a simple and effective one.

Fishwife answered 9/1, 2021 at 10:24 Comment(2)
I like it. But I think you can optimise it by just doing 4x . shadow(..., x: [0,0,1,-1], y:[1,-1,0,0]) with the same result and better performance(?)Funeral
Best suggestion is this response. It avoids the ZStack (if not outlined), and is fast enough.Billiards
M
2

⚠️ Edit: After cleaning Xcode cache… it doesn't work anymore 😕 I couldn't find a way to fix it.

Other answers are good, but they have downsides (for the ones I've tried):

  • Either they create many layers, needing complex omputations (stacked shadows).
  • Either they use an overlay technique with four versions of the text under (in + or x shape for better look). When I tried having a larger stroke, the labels became visible, and it was looking very bad.

In most cases, accessibility is not handled properly either.

My vanilla SwiftUI solution

That's why I tried to come up with a vanilla SwiftUI, really simple but effective solution.

My main idea was to use .blur(radius: radius, opaque: true) to have a perfect stroke around.

After hours of playing with all the modifiers, I found a 8-line solution, and I'm sure you'll love it. As the blur is opaque, it's also pixelated, I couldn't find a way to avoid this. Also, the second drawingGroup adds a strange rounded square shape, I don't know why.

Features

Feature Working?
Vanilla SwiftUI
Custom size stroke
Pixel size stroke ❌ (I don't understand the unit)
Colored stroke
Non-opaque stoke color
Rounded stroke
No stroke clipping
Perfect padding
Original text color conservation
Accessibility
No pixelation
Works with any View
Readable, commented…

Code

extension View {

    /// Adds a stroke around the text. This method uses an opaque blur, hence the `radius` parameter.
    ///
    /// - Parameters:
    ///   - color: The stroke color. Can be non-opaque.
    ///   - radius: The blur radius. The value is not in pixels or points.
    ///             You need to try values by hand.
    /// - Warning:
    ///   - The opaque blur is pixelated, I couldn't find a way to avoid this.
    ///   - The second `drawingGroup` allows stroke opacity, but adds a
    ///     strange rounded square shape.
    ///
    /// # Example
    ///
    /// ```
    /// Text("Lorem ipsum")
    ///     .foregroundColor(.red)
    ///     .font(.system(size: 20, weight: .bold, design: .rounded))
    ///     .stroked(color: Color.blue.opacity(0.5), radius: 0.5)
    /// ```
    ///
    /// # Copyright
    ///
    /// CC BY-SA 4.0 [Rémi BARDON](https://github.com/RemiBardon)
    /// (posted on [Stack Overflow](https://mcmap.net/q/425984/-how-to-make-text-stroke-in-swiftui))
    @ViewBuilder
    public func stroked(color: Color, radius: CGFloat) -> some View {
        ZStack {
            self
                // Add padding to avoid clipping
                // (3 is a a number I found when trying values… it comes from nowhere)
                .padding(3*radius)
                // Apply padding
                .drawingGroup()
                // Remove any color from the text
                .colorMultiply(.black)
                // Add an opaque blur around the text
                .blur(radius: radius, opaque: true)
                // Remove black background and allow color with opacity
                .drawingGroup()
                // Invert the black blur to get a white blur
                .colorInvert()
                // Multiply white by whatever color
                .colorMultiply(color)
                // Disable accessibility for background text
                .accessibility(hidden: true)
            self
        }
    }

}

Screenshots

When it was still working, stroke looked like this:

Working stroke

Now it's broken, the stroke has a black background:

enter image description here

Mackler answered 1/5, 2021 at 16:50 Comment(0)
A
2

I suggest to draw the outlined Text in all 8 directions:

struct OutlinedText: View {
    var text: String
    var width: CGFloat
    var color: Color
    
    var body: some View {
        let diagonal: CGFloat = 1/sqrt(2) * width
        ZStack{
            ZStack{
                // bottom right
                Text(text).offset(x:  diagonal, y:  diagonal)
                // top left
                Text(text).offset(x: -diagonal, y: -diagonal)
                // bottom left
                Text(text).offset(x: -diagonal, y:  diagonal)
                // top right
                Text(text).offset(x:  diagonal, y: -diagonal)
                // left
                Text(text).offset(x:  -width, y: 0)
                // right
                Text(text).offset(x:  width, y: 0)
                // top
                Text(text).offset(x:  0, y: -width)
                // bottom
                Text(text).offset(x:  0, y: width)
            }
            .foregroundColor(color)
            Text(text)
        }.padding()
    }
}
Autocade answered 27/9, 2022 at 9:6 Comment(0)
C
0

The .shadow() modifier called iterately can create the effect of the stroke. Just create this modifier and add it to your view.

import SwiftUI

struct StrokeStyle: ViewModifier {

    var color: Color
    var lineWidth: Int

    func body(content: Content) -> some View {
        applyShadow(
            content: AnyView(content),
            lineWidth: lineWidth
        )
    }

    func applyShadow(content: AnyView, lineWidth: Int) -> AnyView {
        if lineWidth == 0 {
            return content
        } else {
            return applyShadow(
                content: AnyView(
                    content.shadow(
                        color: color,
                        radius: 1
                    )
                ),
                lineWidth: lineWidth - 1
            )
        }
    }
}

extension View {
    func strokeBorder(color: Color, lineWidth: Int) -> some View {
        self.modifier(LHSStrokeStyle(color: color, lineWidth: lineWidth))
    }
}
Cornia answered 18/4, 2023 at 10:11 Comment(1)
As it’s currently written, your answer is unclear. Please edit to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers in the help center.Hydrolysis

© 2022 - 2024 — McMap. All rights reserved.