I have an HStack and a VStack, both containing the same views. By clicking a button, I want to create a rotational matchedGeometryEffect, where the gray background of a ZStack rotates in the direction of the HStack, and vice versa — the HStack rotates to align with the VStack(both in the shortest distance). I started working on the rotationEffect, but since I have text, I can't use it directly. I want the text to stay upright during the rotation, but I also want it to move along an arc during the transition. The gray background should rotate normally, while I want the red circle to be the center of the rotation, not the center of the view itself. I wrote some code, but there's an issue: during the transition, the background color fades in and out. I’d like the color to remain consistent throughout the transition. Can we make this rotational matchedGeometryEffect?
struct MyView: View {
@Namespace private var animationNamespace
@State private var isVertical: Bool = false
var body: some View {
VStack {
Spacer()
ZStack {
if isVertical {
VStack {
ForEach(0...10, id: \.self) { index in
Rectangle()
.frame(width: 20.0, height: 20.0)
.foregroundColor((index == 0) ? Color.red : Color.black)
.overlay(Text(String(describing: index)).foregroundColor(.white))
.matchedGeometryEffect(id: "\(index)", in: animationNamespace)
.transition(.scale(scale: 1))
}
}
} else {
HStack {
ForEach(0...10, id: \.self) { index in
Rectangle()
.frame(width: 20.0, height: 20.0)
.foregroundColor((index == 0) ? Color.red : Color.black)
.overlay(Text(String(describing: index)).foregroundColor(.white))
.matchedGeometryEffect(id: "\(index)", in: animationNamespace)
.transition(.scale(scale: 1))
}
}
}
}
.bold()
.padding()
.background(Color.gray)
.cornerRadius(10)
.animation(.easeInOut(duration: 2.0), value: isVertical)
Spacer()
Button("Toggle Layout") {
isVertical.toggle()
}
}
.padding()
}
}
matchedGeometryEffect
cannot match the orientation of views. The optional parameter of matchedGeometryEffect
takes a MatchedGeometryProperties
, which only has position
, size
, and both (frame
).
Here is an implementation using a similar approach to Benzy Neez's answer, i.e. rotating the text in the opposite direction to keep the text upright.
struct UnitPointInStackKey: PreferenceKey {
static let defaultValue: UnitPoint? = nil
static func reduce(value: inout UnitPoint?, nextValue: () -> UnitPoint?) {
if let next = nextValue() {
value = next
}
}
}
struct MyView: View {
@State private var isVertical: Bool = false
@State private var rotationAnchor = UnitPoint.center
var body: some View {
VStack {
Spacer()
ZStack {
HStack {
numbers
}
.padding()
.background {
Color.gray
.clipShape(.rect(cornerRadius: 10))
}
.coordinateSpace(name: "stack")
.onPreferenceChange(UnitPointInStackKey.self) {
if let value = $0 {
rotationAnchor = value
}
}
.rotationEffect(.degrees(isVertical ? 90 : 0), anchor: rotationAnchor)
}
.bold()
.animation(.easeInOut(duration: 2.0), value: isVertical)
Spacer()
Button("Toggle Layout") {
isVertical.toggle()
}
}
.padding()
}
var numbers: some View {
ForEach(0...10, id: \.self) { index in
Rectangle()
.frame(width: 20.0, height: 20.0)
.foregroundColor((index == 0) ? Color.red : Color.black)
.overlay {
Text(String(describing: index)).foregroundColor(.white)
.rotationEffect(.degrees(isVertical ? -90 : 0))
}
.overlay {
GeometryReader { geo in
if index == 0, let stackFrame = geo.bounds(of: .named("stack")) {
let textFrame = geo.frame(in: .named("stack"))
let unitPoint = UnitPoint(
x: textFrame.midX / stackFrame.width,
y: textFrame.midY / stackFrame.height
)
Color.clear
.preference(key: UnitPointInStackKey.self, value: unitPoint)
}
}
}
}
}
}
The key differences are:
UnitPoint
representing the center of rotation, so it works even if you don't know how long the view is beforehand.