Home > Enterprise >  SwiftUI odd animation behavior with systemImage
SwiftUI odd animation behavior with systemImage

Time:11-30

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.

  • Related