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