Published on

Uncovering and Solving a SwiftUI Text Animation Bug

Authors
  • avatar
    Name
    Omar Elsayed
    Twitter

Introduction

When working with SwiftUI, developers often encounter unexpected behaviors that can be challenging to debug. Recently, my team in klivvr faced an interesting issue with text view animations that led us down a path of discovery and ultimately revealed a long-standing framework bug.

The Problem

We encountered a situation where text animations weren’t working as expected in SwiftUI. Specifically, the offset animation on Text views wasn’t functioning properly. Here’s what we initially tried:

//To give you more info our app support iOS 15 and above

// Attempt 1: Using traditional animation modifier
Text("Hello, world!")
    .offset(y: animate ? 0 : 100) // Doesn't work
    .animation(.default, value: animate)

// Attempt 3: Using withAnimation
withAnimation {
    // Text animations still failed to work
}

My team memeber Eslam spent considerable time trying different approaches and in the end we discovered this wasn’t just a coding issue — it was actually a known bug in SwiftUI itself.

After searching about the issue we were facing, we found this forum: https://forums.developer.apple.com/forums/thread/748852 .

From it we discovered that this is a well known issue in swiftUI and it was confirmed by Apple engineer in the developer forum thread who acknowledged it by saying :

This is a known bug, thanks so much for filing a bug report!

In the thread title it said that this bug was found in iOS 17 but this isn’t true, this issue was in SwiftUI since 2022 this was proven by this StackOverFlow form: https://stackoverflow.com/questions/74927385/string-inside-text-view-doent-be-animated-i-dont-know-why-it-works-like-that and many more.

The Solution

In Apple Developer forum we found this solution that was developed by someone called kurtlee93. Here’s the working implementation:

public extension View {
    func projectionOffset(x: CGFloat = 0, y: CGFloat = 0) -> some View {
        self.projectionOffset(.init(x: x, y: y))
    }

    func projectionOffset(_ translation: CGPoint) -> some View {
        modifier(ProjectionOffsetEffect(translation: translation))
    }
}

private struct ProjectionOffsetEffect: GeometryEffect {
    var translation: CGPoint
    var animatableData: CGPoint.AnimatableData {
        get { translation.animatableData }
        set { translation = .init(x: newValue.first, y: newValue.second) }
    }

    public func effectValue(size: CGSize) -> ProjectionTransform {
        .init(CGAffineTransform(translationX: translation.x, y: translation.y))
    }
}

You may ask why this works with Text view while it seems like it’s doing the same thing like .offset(), Good question, let me explain it to you.

Dive Deep in to the Solution

Let me break this code down piece by piece:

1. let’s look at the View extension

public extension View {
    // This is a convenience method that allows using x and y coordinates separately
    func projectionOffset(x: CGFloat = 0, y: CGFloat = 0) -> some View {
        self.projectionOffset(.init(x: x, y: y))
    }

    // This is the main method that applies the custom geometry effect
    func projectionOffset(_ translation: CGPoint) -> some View {
        modifier(ProjectionOffsetEffect(translation: translation))
    }
}

This extension create a nice API where you can use it in different ways like:

  • view.projectionOffset(y: 20)
  • view.projectionOffset(x: 20)
  • view.projectionOffset(x: 20, y: 20)
  • view.projectionOffset(CGPoint(x: 10, y: 20))

2. Now, the interesting part ProjectionOffsetEffect

private struct ProjectionOffsetEffect: GeometryEffect {
    // Stores the current position
    var translation: CGPoint

    // This is the magic that enables animation
    var animatableData: CGPoint.AnimatableData {
        get { translation.animatableData }
        set { translation = .init(x: newValue.first, y: newValue.second) }
    }

    // This creates the actual transform that moves the view
    public func effectValue(size: CGSize) -> ProjectionTransform {
        .init(CGAffineTransform(translationX: translation.x, y: translation.y))
    }
}
  1. GeometryEffect, this protocol allows us to create custom geometric transformations that can be animated.
  2. animatableData, this varbile is what does the whole magic by:
  • Using CGPoint.AnimatableData which is essentially two numbers (x and y)
  • The getter converts our CGPoint to animatable data
  • The setter updates our position as the animation progresses
  1. effectValue, this creates the actual transform that moves the view by:
  • Taking the current size of the view
  • Returns a ProjectionTransform which is more powerful than a simple offset
  • Uses CGAffineTransform to create the movement

3. Why this works better than .offset()

  1. It explicitly defines how the animation should interpolate through animatableData
  2. It creates a proper geometric transformation instead of using .offset() which only moves the view without changing the frame position on the other hand projectionOffset moves the view with it’s frame.

Practical Takeaways

This experience taught us several valuable lessons:

  • When something feels off, it is worth searching and looking if anyone faced a similar thing, this can save a lot of time.
  • Solutions often lie in unexpected places (like using projection transforms instead of simple offsets)
  • The developer community is an invaluable resource for finding and validating issues

What’s Next?

While our solution works reliably across all iOS versions, it’s worth noting that this is still a workaround. SwiftUI is constantly evolving, and we hope to see this animation issue addressed in future updates.

Until then, feel free to use our custom modifier in your projects. It’s a robust solution that gets the job done.

Subscribe for more