2
votes

Consider this simple example:

struct TestView: View {
    @State private var enabled = false
    
    var body: some View {
        Circle()
            .foregroundColor(.red)
            .overlay(
                Circle()
                    .foregroundColor(.blue)
                    .frame(width: 50, height: 50)
                    .animation(.spring())
                
            )
            .frame(width: 100, height: 100)
            .offset(x: 0, y: enabled ? -50 : 50)
            .animation(.easeIn(duration: 1.0))
            .onTapGesture{
                enabled.toggle()
            }
            
    }
}

This yields the following animation when tapping on the resulting circle:

enter image description here

The nested circle animates to its new global position with its own timing function (spring), while the outer circle / parent view animates to the new global position with its easeInOut timing function. Ideally, all children would animate with the parents timing function if a modifier works at the parent views level of abstraction (in this case, offset, but also things like position).

Presumably this happens because the SwiftUI rendering engine computes new global properties for all affected children in the view hierarchy in a layout pass, and animates changes to each property based on the most specific animation modifier attached (even though, in this case, the relative position of the child within the parent does not change). And this makes it incredibly hard to do something as simple as translate a view properly when subviews of that view might have their own complex animations running (which the parent view doesn't and really shouldn't know of).

One other quirk I noticed is that, in this particular example, adding an animation(nil) modifier immediately before the offset modifier breaks the animations on the outer circle, despite the .easeInOut remaining attached directly to the offset modifier. This violates my understanding of how these modifiers are chained, where (according to this source) an animation modifier applies to all views it entails until the next nested animation modifier.

2
For the truth it is not clear for me what do you try to achieve.Asperi
@Asperi Animations that affect the property of a parent view (offset, position) shouldn't trigger animations within a child view. In the above example, the entire circle (outer + inner) should move together, even if the child views (i.e. the inner circle) are performing other animations.roozbubu
Animations are triggered by modified properties, but not vice versa. So if you change animatable property current context animation is activated. If you want independent animations join them to specific different values.Asperi
@Asperi Binding the animation modifiers to different values in this case still doesn't solve the problem - I've tried. The problem is that animation modifiers bound to a value which changes one property will still apply to all other properties of the view they modify to, even when a change in that property is implicit (i.e. it's global position on screen, due to the parent changing it's own offset, even when the local position of the child doesn't change at all).roozbubu
@roozbubu, I found the same kind of problem, see stackoverflow.com/questions/65582121/….foolbear

2 Answers

2
votes

This seems like a bug to me, might be worth reporting to Apple. I did find a weird workaround, by adding another modifier before the .offset modifier:

struct TestView: View {
    @State private var enabled = false
    
    var body: some View {
        Circle()
            .foregroundColor(.red)
            .overlay(
                Circle()
                    .foregroundColor(.blue)
                    .frame(width: 50, height: 50)
                    .animation(.spring())
                
            )
            .frame(width: 100, height: 100)
            .scaleEffect(1) // <------- this doesn't do anything but somehow overrides the animation
            .offset(x: 0, y: enabled ? -50 : 50)
            .animation(.easeIn(duration: 1.0))
            .onTapGesture{
                enabled.toggle()
            }
            
    }
}

I've tried it with scaleEffect, rotationEffect, and rotation3DEffect, and they all work. Not all modifiers work. I'm really curious why these specific ones do.

The .animation(nil) thing also seems like a bug to me.

2
votes

I'll try to clarify some points you mentioned in your question:

Ideally, all children would animate with the parents timing function if a modifier works at the parent views level of abstraction

Consider the following example:

Circle()
    .overlay(
        Circle()
            .frame(width: 50, height: 50)
            .foregroundColor(.blue)
    )
    .frame(width: 100, height: 100)
    .foregroundColor(.red)

If what you said would be true, then the nested Circle should be red. In SwiftUI parent modifiers don't override child modifiers.


adding an animation(nil) modifier immediately before the offset modifier breaks the animations on the outer circle

This happens for exactly the same as in the example above.

By calling:

Circle()
    .foregroundColor(.red)
    .overlay(
        Circle()
            .foregroundColor(.blue)
            .frame(width: 50, height: 50)
            .animation(.spring())
    )
    .frame(width: 100, height: 100)
    .animation(nil) // with `nil` animation
    .offset(x: 0, y: enabled ? -50 : 50)
    .animation(.easeIn(duration: 1.0))
    .onTapGesture {
        enabled.toggle()
    }

you're setting the animation to nil to the parent only. The child view has its own animation modifier.

It would work if the inner Circle had no animation modifiers and therefore the parent modifier would apply to this particular child view as well:

Circle()
    .foregroundColor(.red)
    .overlay(
        Circle()
            .foregroundColor(.blue)
            .frame(width: 50, height: 50)
            // .animation(.spring())
    )
    .frame(width: 100, height: 100)
    .animation(nil) // now this will work for both parent and child view
    .offset(x: 0, y: enabled ? -50 : 50)
    .animation(.easeIn(duration: 1.0))
    .onTapGesture {
        enabled.toggle()
    }

This violates my understanding of how these modifiers are chained, where [...] an animation modifier applies to all views it entails until the next nested animation modifier.

until the next nested animation modifier is the key here. Setting animation(nil) works until it finds another animation modifier.


how to override nested offset/position animations?

The easiest option is simply to remove the nested .animation(.spring()) modifier.

If this is not possible you can create one view from both circles, e.g. using scaleEffect as @bze12 suggested in their answer.

From the documentation of scaleEffect:

Scales this view's rendered output by the given vertical and horizontal size amounts, relative to an anchor point.

This means that the below code:

Circle()
    .foregroundColor(.red)
    .overlay(
        Circle()
            .foregroundColor(.blue)
            .frame(width: 50, height: 50)
            .animation(.spring())
    )
    .frame(width: 100, height: 100)
    .scaleEffect()
    .offset(x: 0, y: enabled ? -50 : 50)
    .animation(.easeIn(duration: 1.0))
    .onTapGesture {
        enabled.toggle()
    }

can be also read as:

scaledView
    .offset(x: 0, y: enabled ? -50 : 50)
    .animation(.easeIn(duration: 1.0))
    .onTapGesture {
        enabled.toggle()
    }

As you can see there is only one animation modifier now and it applies to the resulting scaled view.