Offset modifier not working when used inside a scoped animation (iOS 17) in SwiftUI

The target view has .opacity() and .offset() modifiers in a scoped animation (iOS 17):

Text("Hello, world!")
    .animation(.default) {
        $0
            .opacity(animate ? 1 : 0.2)
            .offset(y: animate ? 0 : 100) // <-- DOESN'T WORK
    }

But only the .opacity() works when the state is changed directly or withAnimation{}. The .offset() only works when using withAnimation{}, even though it should animate in both cases, like opacity.

Is this a SwiftUI bug? Did anyone encounter this?

import SwiftUI

struct ContentView: View {
    
    @State private var animate = false
    
    var body: some View {
        VStack(spacing: 20) {
            Button("Toggle Scoped Animation") {
                animate.toggle()
            }
            Button("Toggle withAnimation{}") {
                withAnimation {
                    animate.toggle()
                }
            }
            Text("Hello, world!")
                .animation(.default) {
                    $0
                        .opacity(animate ? 1 : 0.2)
                        .offset(y: animate ? 0 : 100) // <-- DOESN'T WORK
                }
        }
    }
}

#Preview {
    ContentView()
}

@main
struct ScopedAnimationOffsetBugApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Tested on Xcode 15.3 (15E204a), iOS 17.4 Simulator and iPhone Device.

Bug report FB13693703 filed with Apple.

Accepted Reply

Hi @calin , this is a known bug, thanks so much for filing a bug report! The workaround here is to use:

 Text("Hello, world!")
                .offset(y: animate ? 0 : 100)
                .animation(.default, value: self.animate)

or withAnimation like you have above

Replies

Hi @calin , this is a known bug, thanks so much for filing a bug report! The workaround here is to use:

 Text("Hello, world!")
                .offset(y: animate ? 0 : 100)
                .animation(.default, value: self.animate)

or withAnimation like you have above

I noted that animation modifier on Text is useless.

This code works the same:

struct ContentView: View {
    
    @State private var animate = false
    
    var body: some View {
        VStack(spacing: 20) {
            Button("Toggle Scoped Animation") {
                animate.toggle()
            }
            Button("Toggle withAnimation{}") {
                withAnimation {
                    animate.toggle()
                }
            }
            Text("Hello, world!")
//                .animation(.default) {
//                    $0
                        .opacity(animate ? 1 : 0.2)
                        .offset(y: animate ? 0 : 100) // <-- DOESN'T WORK
//                }
        }
    }
}

For people who've been experiencing this issue: There is a really stupid but 100% working fix.

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))
    }
}

This will works perfectly with scoped animation.

Text("Hello, world!")
    .animation(.default) {
        $0
            .opacity(animate ? 1 : 0.2)
            // .offset(y: animate ? 0 : 100) // <-- DOESN'T WORKS!!!
            .projectionOffset(y: animate ? 0 : 100) // <-- WORKS!!!
    }

I've spent the last 3 days trying to figure out why every other animatable modifier works fine with scoped animation but not offset(). turns out it can. it's just a bug in the implementation, which I don't understand the how, considering how simple the implementation is, but whatever. hope this helps! @calin