How can I animate the circle image with scale from 0 to 1 while animating with starting position at 7 o'clock & scaling end position at 11 o'clock along the circumference of the circle and then animate with opacity from 1 to 0 while 11 o'clock to 3 o'clock. Then reposition the image back to 7 o'clock & replay the animation in a loop. With the following code, I'm able to scale and rotate at the same time but it does not follow along the circumference. I not sure how to offset the animation during the scale & rotate such that it stays along the circle.
struct ContentView: View {
@State private var startAngle: Double = -45
@State private var endAngle: Double = 120
@State private var animationValue: Double = 0.0
var body: some View {
ZStack {
let width: CGFloat = 60
Circle()
.stroke(.black, lineWidth: 1.0)
.frame(width: width)
.overlay {
Image(systemName: "circle.fill")
.offset(x: -width/2)
.modifier(ScaleAndRotateModifier(value: animationValue,
startAngle: startAngle,
endAngle: endAngle))
.onAppear {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
animationValue = 1.0
}
}
}
}
.foregroundStyle(.black)
.padding()
}
}
struct ScaleAndRotateModifier: AnimatableModifier {
var value: Double
var startAngle: Double
var endAngle: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
func body(content: Content) -> some View {
content
.rotationEffect(Angle(degrees: startAngle + value * endAngle), anchor: .center)
.scaleEffect(value * 0.5)
}
}
Since this animation involves multiple stages, it would be convenient to use a keyframeAnimator
here.
Write a struct with all the properties you want to animate:
struct AnimatedProperties {
var angle: Angle = .degrees(120)
var scale: CGFloat = 0
var opacity: CGFloat = 1
}
Then you can do
let width: CGFloat = 60
Circle()
.stroke(.black, lineWidth: 1.0)
.frame(width: width)
.overlay {
Image(systemName: "circle.fill")
.keyframeAnimator(initialValue: AnimatedProperties()) { content, properties in
content
.scaleEffect(properties.scale)
.opacity(properties.opacity)
.offset(x: width / 2 * cos(properties.angle.radians), y: width / 2 * sin(properties.angle.radians))
} keyframes: { properties in
KeyframeTrack(\.angle) {
// angle goes from 120 to 480 over the course of 3 seconds
LinearKeyframe(.degrees(480), duration: 3)
}
KeyframeTrack(\.scale) {
// scale goes to 1 during the first second
LinearKeyframe(1, duration: 1)
// then stays there for 1 second
LinearKeyframe(1, duration: 1)
// then goes back to 0 in the last second
LinearKeyframe(0, duration: 1)
}
KeyframeTrack(\.opacity) {
// opacity stays at 1 for the first second
LinearKeyframe(1, duration: 1)
// then goes to 0 in the next second
LinearKeyframe(0, duration: 1)
// then goes back to 1 again
LinearKeyframe(1, duration: 1)
}
}
}
Notice the line:
.offset(x: width / 2 * cos(properties.angle.radians), y: width / 2 * sin(properties.angle.radians))
This is how you compute the required offset for a given angle.
In my opinion, this animation looks a bit funny. At around 2 seconds, the circle "flashes", because its opacity goes from non-zero to zero, then back to non-zero again, very quickly. To me this doesn't feel coherent. I think this set of key frames look much better, where the animation is only two seconds long, and the circle instantaneously moves from 3 o'clock to 7 o'clock to start a new cycle.
} keyframes: { properties in
KeyframeTrack(\.angle) {
// angle goes from 120 to 360 over the course of 2 seconds
LinearKeyframe(.degrees(360), duration: 2)
}
KeyframeTrack(\.scale) {
// scale goes to 1 during the first second
LinearKeyframe(1, duration: 1)
// then stays there for another second
LinearKeyframe(1, duration: 1)
}
KeyframeTrack(\.opacity) {
// opacity stays at 1 for the first second
LinearKeyframe(1, duration: 1)
// then goes to 0 in the next second
LinearKeyframe(0, duration: 1)
}
}
If keyframeAnimator
is not available for your target iOS version, you can still use your view modifier approach, but you'd need to calculate the scale and opacity manually.
struct ContentView: View {
let startAngle: Angle = .degrees(120)
let endAngle: Angle = .degrees(480)
@State private var animationValue: Double = 0.0
var body: some View {
ZStack {
let width: CGFloat = 60
Circle()
.stroke(.black, lineWidth: 1.0)
.frame(width: width)
.overlay {
Image(systemName: "circle.fill")
.modifier(ScaleAndRotateModifier(value: animationValue,
startAngle: startAngle.radians,
endAngle: endAngle.radians,
radius: width / 2))
.onAppear {
withAnimation(.linear(duration: 3.0).repeatForever(autoreverses: false)) {
animationValue = 1.0
}
}
}
}
.foregroundStyle(.black)
.padding()
}
}
struct ScaleAndRotateModifier: ViewModifier, Animatable, _RemoveGlobalActorIsolation {
var value: Double
let startAngle: Double
let endAngle: Double
let radius: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
var scale: CGFloat {
if value * 3 < 1 {
value * 3
} else if value * 3 < 2 {
1
} else {
3 - value * 3
}
}
var opacity: CGFloat {
if value * 3 < 1 {
1
} else if value * 3 < 2 {
(2 - value * 3)
} else {
1 - (3 - value * 3)
}
}
func body(content: Content) -> some View {
let angle = startAngle + (endAngle - startAngle) * value
content
.scaleEffect(scale)
.opacity(opacity)
.offset(x: radius * cos(angle), y: radius * sin(angle))
}
}