Animating Strikethroughs in SwiftUI
Solving problems the right way.
While working on one of our internal tools, the design called for completed tasks in todo lists to be “struck through,” adding a bit of playfulness and physicality to the app. SwiftUI does provide a strikethrough modifier, but it is not animatable. For our tool, we wanted it to feel like it was being physically drawn, stroked from one end of the text to the other. What seemed like a simple feature turned out to be deceptively difficult if I wanted to implement it the right way.
Default implementation of strikethroughs in Swift vs. the intended effect.
Defining constraints to identify a solution
Before immediately researching a solution, I defined three constraints my approach would need to satisfy:
- The stroke needed to span the entire view if the text grew or shrank with Dynamic Type.
- The stroke had to span the entire text view, even when text wrapped to multiple lines.
- Existing VoiceOver behavior had to be preserved.
First round of experiments
My first attempt used a Path as an overlay on the Text view. The drawback was that it didn’t account for multiline text or Dynamic Type, because I had no access to precise width information about the rendered text.
Path as an overlay on the Text view’s issues with multiline text.
Next, I tried a Canvas view overlay. This felt promising — a graphics context meant I could draw freely — but I still lacked precise information about the rendered text, such as line height, padding, and font size. It worked for single-line text but broke entirely when the text wrapped to multiple lines.
Canvas view overlay presented problems with animation and, still, multi-line text.
My search led me to Text.Layout, which provided access to the internal structure of a rendered Text view, broken down into lines, runs, and individual glyphs, as well as iOS 17’s TextRenderer protocol, which surfaces both the layout and a graphics context through a single required method:
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) The implementation iterates over each line, calculates how much of the strikethrough stroke should be visible based on a _progress value between 0 and 1, and draws accordingly. Progress is tracked cumulatively across lines so the stroke draws continuously from one line into the next rather than animating each line independently:
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
let totalLength = layout.reduce(0) { $0 + $1.typographicBounds.width }
var accumulated: CGFloat = 0
for line in layout {
let bounds = line.typographicBounds
let strikeWidth = min(max(totalLength * _progress - accumulated, 0), bounds.width)
ctx.draw(line)
if strikeWidth > 0 {
let midY = bounds.rect.midY
let startX = bounds.rect.minX
ctx.stroke(
Path { path in
path.move(to: CGPoint(x: startX, y: midY))
path.addLine(to: CGPoint(x: startX + strikeWidth, y: midY))
},
with: .color(color),
lineWidth: lineWidth
)
}
accumulated += bounds.width
}
} Conforming to Animatable is what makes SwiftUI interpolate the renderer between states rather than jumping straight to the final value:
var animatableData: CGFloat {
get { _progress }
set { _progress = newValue }
}
Once applied via the textRenderer modifier, the strikethrough animated perfectly on Text views. Then I tried it on a TextField, and nothing rendered. Setting a breakpoint confirmed draw wasn’t even being called. TextField doesn’t render its content as Text views internally.
Disappointing, but not disqualifying
The TextRenderer approach still satisfied all three constraints for Text views. I just needed to get creative about how to apply it to a TextField.
What if I overlaid a Text view on top of the TextField? The Text view would display the animated strikethrough, and I could set its foreground color to clear, keeping the TextField content visible underneath while the animation played on top.
This worked well in initial testing, but when I integrated it into the project, a subtle misalignment appeared: TextField tends to fit more content on a single line, while Text wraps earlier. This meant the first line of the TextField had no strikethrough, while the second line did.
To fix the alignment, I created a custom Layout that forces both views to use the same width. This ensures both the TextField and the Text view wrap at the same point, keeping the strikethrough in sync with the visible content. The tradeoff is a slightly narrower TextField, but that was acceptable for our use case.
struct TextFieldOverlayLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let primary = subviews.first else { return .zero }
if primary.sizeThatFits(proposal).width.isZero {
let secondIndex = subviews.index(after: 0)
return subviews[secondIndex].sizeThatFits(proposal)
} else {
return primary.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
}
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count >= 2 else { return }
for subview in subviews {
subview.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
}
}
} In the view, the layout looked like this:
TextFieldOverlayLayout {
Text(store.description)
.foregroundStyle(Color.clear)
.font(.system(.body, design: .rounded, weight: .medium))
.lineSpacing(-1)
.allowsHitTesting(false)
.accessibilityHidden(true)
.textRenderer(StrikethroughRenderer(store.isComplete, color: .primary))
Textfield()
.foregroundStyle(Color.primary)
.font(.system(.body, design: .rounded, weight: .medium))
.lineSpacing(-1)
} The end result
My final draft required research, a few dead ends, and ultimately combining TextRenderer, a clear Text overlay, and a custom Layout to produce something that satisfied my aforementioned criteria. It may seem like a lot of effort for a small detail, but I believe these are exactly the kind of details worth putting extra time into! The way it feels to use an app is just as important as the functionality of the app itself.
The final working result.