Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make text stroke in SwiftUI?

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)

like image 808
Nicolas M Avatar asked Aug 02 '19 22:08

Nicolas M


People also ask

How do I add a border in SwiftUI?

Tip: Use stroke() or strokeBorder() with shapes, and border() with other view types.

How do I change the font in SwiftUI?

Hold the command key and click the text to bring up a pop-over menu. Choose Show SwiftUI Inspector and then you can edit the text/font properties.


6 Answers

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.

like image 110
Jose Santos Avatar answered Oct 03 '22 12:10

Jose Santos


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.

like image 27
backslash-f Avatar answered Oct 03 '22 13:10

backslash-f


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

like image 20
Wendy Liga Avatar answered Oct 03 '22 13:10

Wendy Liga


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.

like image 39
Heestand XYZ Avatar answered Oct 03 '22 13:10

Heestand XYZ


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.

like image 32
The Fox Avatar answered Oct 03 '22 13:10

The Fox


⚠️ 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://stackoverflow.com/a/67348676/10967642))
    @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

like image 37
Rémi B. Avatar answered Oct 03 '22 11:10

Rémi B.