I was messing around with a fun animation in SwiftUI when I ran into a weird problem involving animating changes to SwiftUI's SF symbols. Basically, I want to animate a set of expanding circles that lose opacity as they get farther out. This works fine when I animate the circles using the Circle()
shape, but throws a weird error when I use Image(systemName: "circle")
. Namely, it throws No symbol named 'circle' found in system symbol set
and I get the dreaded "purple" error in Xcode. Why does my animation work with shapes but not with SF symbols?
Animation Code with Shapes:
struct ContentView: View {
let timer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
@State var firstIndex: Int = 0
@State var secondIndex: Int = 10
@State var thirdIndex: Int = 20
@State var fourthIndex: Int = 30
private func changeIndex(index: Int) -> Int {
if index == 40 {
return 0
} else {
return index 1
}
}
var body: some View {
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircle(index: firstIndex)
ExpandingCircle(index: secondIndex)
ExpandingCircle(index: thirdIndex)
ExpandingCircle(index: fourthIndex)
}
.onReceive(timer) { time in
withAnimation(.linear(duration: 0.25)) {
self.firstIndex = changeIndex(index: firstIndex)
self.secondIndex = changeIndex(index: secondIndex)
self.thirdIndex = changeIndex(index: thirdIndex)
self.fourthIndex = changeIndex(index: fourthIndex)
}
}
}
}
Where ExpandingCircle
is defined as:
struct ExpandingCircle: View {
let index: Int
private func getSize() -> CGFloat {
return CGFloat(index * 2)
}
private func getOpacity() -> CGFloat {
if index == 0 || index == 40 {
return 0
} else {
return CGFloat(1 - (Double(index) * 0.025))
}
}
var body: some View {
Circle()
.strokeBorder(Color.red, lineWidth: 4)
.frame(width: getSize(), height: getSize())
.opacity(getOpacity())
}
}
To replicate the error, swap out ExpandingCircle
in ContentView
for ExpandingCircleImage
:
struct ExpandingCircleImage: View {
let index: Int
private func getSize() -> CGFloat {
return CGFloat(index * 2)
}
private func getOpacity() -> CGFloat {
if index == 0 || index == 40 {
return 0
} else {
return CGFloat(1 - (Double(index) * 0.025))
}
}
var body: some View {
Image(systemName: "circle")
.foregroundColor(.red)
.font(.system(size: getSize()))
.opacity(getOpacity())
}
}
CodePudding user response:
Your ExpandingCircleImage
is choking because you can't have a system font of size 0, and you keep trying to feed 0 to your ExpandingCircleImage
view. However, in addition to that, you don't need to use a timer to drive the animation. In fact, it makes the animation look weird because a timer is not exact. Next, your ExpandingCircle
or ExpandingCircleImage
should animate itself and be the complete effect.
The next issue you will encounter when you fix the font size = 0 issue, is that .font(.system(size:))
is not animatable as it is. You need to write an AnimatableModifier
for it. That looks like this:
struct AnimatableSfSymbolFontModifier: AnimatableModifier {
var size: CGFloat
var animatableData: CGFloat {
get { size }
set { size = newValue }
}
func body(content: Content) -> some View {
content
.font(.system(size: size))
}
}
extension View {
func animateSfSymbol(size: CGFloat) -> some View {
self.modifier(AnimatableSfSymbolFontModifier(size: size))
}
}
The animatableData
variable is the key. It teaches SwiftUI what to change to render the animation. In this case, we are animating the size of the font. The view extension is just a convenience so we can use .
notation.
Another trick to animating a view like this is to have multiple animations that only go part of the way of the whole. In other words, if you use four circles, the first goes to 25%, the next from 25% to 50%, then 50% to 75%, lastly 75% to 100%. You also appear to have wanted the rings to fade as the expand, so I wrote that in as well. The code below will have two animating views, one made with a shape, and one with an SF Symbol.
struct ContentView: View {
var body: some View {
VStack {
Spacer()
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircle(maxSize: 100)
}
.frame(height: 100)
Spacer()
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircleImage(maxSize: 100)
}
.frame(height: 100)
Spacer()
}
}
}
struct ExpandingCircle: View {
let maxSize: CGFloat
@State private var animate = false
var body: some View {
ZStack {
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.75 : 1)
.scaleEffect(animate ? 0.25 : 0)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.5 : 0.75)
.scaleEffect(animate ? 0.5 : 0.25)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.25 : 0.5)
.scaleEffect(animate ? 0.75 : 0.5)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0 : 0.25)
.scaleEffect(animate ? 1 : 0.75)
}
.frame(width: maxSize, height: maxSize)
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
struct ExpandingCircleImage: View {
let maxSize: CGFloat
@State private var animate = false
var body: some View {
ZStack {
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.25) : 1)
.opacity(animate ? 0.75 : 1)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.5) : (maxSize * 0.25))
.opacity(animate ? 0.5 : 0.75)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.75) : (maxSize * 0.5))
.opacity(animate ? 0.25 : 0.5)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize) : (maxSize * 0.75))
.opacity(animate ? 0 : 0.25)
}
.foregroundColor(.red)
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
Remember to include the AnimatableModifier
in your code.