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:
(source: noelshack.com)
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:
(source: noelshack.com)
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
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.
Here is a 100% SwiftUI solution. Not perfect, but it works and it gives you full SwiftUI control of the resulting view.
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.
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
.shadow()
s onto it to make the effect even stronger. Try 10 of them with radius of 0.4
each –
Supersensible 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)
}
}
}
strokeSize > 0
check to the start of the if let resolvedView =
statement, which removed all stroking. –
Knp 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
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.
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.
Text
s. 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 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.
. shadow(..., x: [0,0,1,-1], y:[1,-1,0,0])
with the same result and better performance(?) –
Funeral ⚠️ Edit: After cleaning Xcode cache… it doesn't work anymore 😕 I couldn't find a way to fix it.
+
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.
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.
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… | ✅ |
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
}
}
}
When it was still working, stroke looked like this:
Now it's broken, the stroke has a black background:
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()
}
}
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))
}
}
© 2022 - 2024 — McMap. All rights reserved.