2

When animating a View between 2 states, how it can be achieved to animate not just from state1 to state2 or back, but set specific animation fraction like it's done with UIViewPropertyAnimator's fractionComplete in UIKit?

Here is the code where I can animate between 2 states on tap. But I need to follow slider value (or get value from socket, or scrollView offset, whatever is CGFloat from 0 to 1). Basically, need to pass an animation fraction so the view will represent it's state on the timeline statically. In short, need to SET the animation progress.

struct TransitionView: View {
    @Namespace var namespace
    @State var isExpanded: Bool = false
    
    @ViewBuilder
    var body: some View {
        VStack {
            switch isExpanded {
            case true:
                VStack {
                    Circle()
                        .strokeBorder(Color.green, lineWidth: 5)
                        .matchedGeometryEffect(id: "circle", in: namespace)
                        .frame(width: 70, height: 70)
                    Text(verbatim: "I am Circle")
                        .matchedGeometryEffect(id: "text", in: namespace)
                }
            case false:
                HStack {
                    Circle()
                        .strokeBorder(Color.red, lineWidth: 5)
                        .matchedGeometryEffect(id: "circle", in: namespace)
                        .frame(width: 30, height: 30)
                    Text(verbatim: "I am Circle")
                        .matchedGeometryEffect(id: "text", in: namespace)
                }
            }
        }
        .contentShape(Rectangle())
        .animation(.easeInOut, value: isExpanded)
        .onTapGesture {
            isExpanded.toggle()
        }
    }
}

enter image description here

bodich
  • 1,708
  • 12
  • 31
  • Are you trying to *set* the animation progress, or are you trying to *get* it? – Sweeper Jul 27 '23 at 08:24
  • I am trying to set it. – bodich Jul 27 '23 at 08:28
  • I don’t think you can with this setup. You can’t have a neutral Bool or a half a Bool. Pre iOS 17 I use GeometryEffect to control animations, I saw something in the WWDC videos about keyframing but haven’t had time to dive into it there is likely a better way now. – lorem ipsum Jul 27 '23 at 11:23
  • I understand I can't have a half Bool. That's why I've mentioned UIViewPropertyAnimator as an example. It is not making a half of Bool, it can present a half of animation between true and false using fractionComplete which is CGFloat from 0 to 1. – bodich Jul 28 '23 at 08:16

1 Answers1

0

What you are seeing in your example is actually a transition from a circle+label with horizontal layout to a circle+label with vertical layout. But the two groups of views are independent of each other, so changes to properties like color are not being animated.

Your use of matchedGeometryEffect helps to smooth the transition from one layout to the other, but this is more due to luck than anything. This is because you have not identified a source. According to the documentation, results are undefined if "the number of currently-inserted views in the group with isSource = true is not exactly one". In other words, you are not using matchedGeometryEffect correctly.

For better control of the animation, I would suggest the following changes:

1. Combine the two layouts into one
Change the view structure so that there is only one circle and only one label. This is made tricky because you are changing the layout from horizontal to vertical, but one way to do it is to apply the circle as an overlay with computed offset:

struct TransitionView: View {
    @State var isExpanded: Bool = false
    private let minCircleSize = CGFloat(30)
    private let maxCircleSize = CGFloat(70)
    private let spacing = CGFloat(10)

    var body: some View {
        Text(verbatim: "I am Circle")
            .padding(.top, isExpanded ? maxCircleSize + spacing : 0)
            .padding(.leading, isExpanded ? 0 : minCircleSize + spacing)
            .frame(minHeight: minCircleSize)
            .overlay {
                GeometryReader { proxy in
                    Circle()
                        .strokeBorder(isExpanded ? .green : .red, lineWidth: 5)
                        .frame(
                            width: isExpanded ? maxCircleSize : minCircleSize,
                            height: isExpanded ? maxCircleSize : minCircleSize
                        )
                        .offset(x: isExpanded ? proxy.size.width / 2 - (maxCircleSize / 2) : 0)
                }
            }
            .contentShape(Rectangle())
            .animation(.easeInOut, value: isExpanded)
            .onTapGesture {
                isExpanded.toggle()
            }
    }
}

2. Replace the boolean with a suitable variable
If you want the transition to be able to represent an intermediate state then you need to replace the boolean with a variable that can be used to hold this state, like CGFloat. If we stick to a simple color change when the progress reaches halfway then it can be implemented as follows:

struct TransitionView: View {

    @State private var fractionComplete = 0.0
    private let minCircleSize = CGFloat(30)
    private let maxCircleSize = CGFloat(70)
    private let spacing = CGFloat(10)

    var body: some View {
        ZStack {
            Text(verbatim: "I am Circle")
                .padding(.top, fractionComplete * (maxCircleSize + spacing))
                .padding(.leading, (1 - fractionComplete) * (minCircleSize + spacing))
                .frame(minHeight: minCircleSize)
                .overlay {
                    GeometryReader { proxy in
                        Circle()
                            .strokeBorder(fractionComplete > 0.5 ? .green : .red, lineWidth: 5)
                            .frame(
                                width: minCircleSize + (fractionComplete * (maxCircleSize - minCircleSize)),
                                height: minCircleSize + (fractionComplete * (maxCircleSize - minCircleSize))
                            )
                            .offset(x: fractionComplete * (proxy.size.width / 2 - (maxCircleSize / 2)))
                    }
                }
                .contentShape(Rectangle())
                .animation(.easeInOut, value: fractionComplete)
                .onTapGesture {
                    fractionComplete = fractionComplete > 0.5 ? 0 : 1
                }
            // A slider
            VStack {
                Spacer()
                Spacer()
                Slider(
                    value: $fractionComplete,
                    in: 0...1
                )
                .padding()
                Spacer()
            }
        }
    }
}

3. Use an Animatable view modifier for complex animations
Animation works by determining the start position and the end position and interpolating between them. This works fine when the change is linear, like it is for the circle size and offsets here. However, if the intermediate state involves a more complex function, such as following a non-linear path, then you may need to implement your own Animatable view modifier.

I don't think this applies here, because you are more interested in representing a frozen intermediate state than in performing an irregular animation. But for more on animatable view modifiers, see these other answers:
Change Reverse Animation Speed SwiftUI
swiftui Alignment Guide for two view

Benzy Neez
  • 1,546
  • 2
  • 3
  • 10
  • Thank you Benzy for your time. But your solution is too specific and absolutely not reusable for another layouts requiring custom logic for every new case. I've solved this by creating a custom fully reusable component like Rotating Stack. It does not care of the internal views sizes which can be dynamic whatever. – bodich Jul 31 '23 at 07:25
  • I don't say your solution does not work, it works perfectly, but I was looking for the reusable solution, not the example in the question solved. – bodich Jul 31 '23 at 07:27
  • I was trying to provide you with steps to reach a solution with code examples to illustrate it working, but the code was specific to your original example and not intended for generic re-use. So yes, if the switch of layout orientation was not just a convoluted example but actually the core of your requirement then a generic Rotating Stack sounds like the right approach. It presumably uses a fractional progress state and not a boolean, which was point 2 of my answer. – Benzy Neez Jul 31 '23 at 08:13