Skip to content

Animating Strikethroughs in SwiftUI

Solving problems the right way.

by Ashli Rankin
Animating strikethroughs in SwiftUI.

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. Animated and non animated examples.

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:

  1. The stroke needed to span the entire view if the text grew or shrank with Dynamic Type.
  2. The stroke had to span the entire text view, even when text wrapped to multiple lines.
  3. 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.

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.

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 Text​Renderer protocol, which surfaces both the layout and a graphics context through a single required method:

Swift
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:

Swift
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:

Swift
var animatableData: CGFloat {
    get { _progress }
    set { _progress = newValue }
}

Once applied via the text​Renderer modifier, the strikethrough animated perfectly on Text views. Then I tried it on a Text​Field, and nothing rendered. Setting a breakpoint confirmed draw wasn’t even being called. Text​Field doesn’t render its content as Text views internally.

Disappointing, but not disqualifying

The Text​Renderer approach still satisfied all three constraints for Text views. I just needed to get creative about how to apply it to a Text​Field.

What if I overlaid a Text view on top of the Text​Field? The Text view would display the animated strikethrough, and I could set its foreground color to clear, keeping the Text​Field 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: Text​Field tends to fit more content on a single line, while Text wraps earlier. This meant the first line of the Text​Field 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 Text​Field and the Text view wrap at the same point, keeping the strikethrough in sync with the visible content. The tradeoff is a slightly narrower Text​Field, but that was acceptable for our use case.

Swift
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:

Swift
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 Text​Renderer, 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.

Animated examples of the functional strikethrough renderer. Single line and Multi line examples.

The final working result.

Ashli Rankin

Ashli Rankin

Ashli is an iOS Engineer at Lickability. She enjoys listening to music 🎧 and dancing to the beat of her own rhythm.

You might also like…

<- Back to blog