Master AgentX
Master AgentX

Reputation: 395

SwiftUI: Unable to animate images

I am trying to make similar Speaker Animation in SwiftUI as that of Apple's Control Center Speaker or Brightness HUD. I'm using 4 images from SFSymbols provided by Apple:

  1. "speaker.slash.fill" // speakerEmpty
  2. "speaker.1.fill" // speaker1
  3. "speaker.2.fill" // speaker2
  4. "speaker.3.fill" // speaker3

So, I have a Switch (Toggle in SwiftUI) to toggle my Sound option with an animation of four images. This is how I want images to be animated: When the toggle is on:

  1. speakerEmpty should animate to -> speaker1 then
  2. speaker1 should animate to -> speaker2 then
  3. speaker2 should animate to -> speaker3

And reverse for when the toggle is off.

Here is the code I tried:

SpeakerSymbol Enum

import SwiftUI

enum SpeakerSymbol:CaseIterable {
case speakerEmpty, speaker1, speaker2, speaker3

var image:Image {
    switch self {
        case .speakerEmpty: return Image(systemName: "speaker.slash.fill")
        case .speaker1: return Image(systemName: "speaker.1.fill")
        case .speaker2: return Image(systemName: "speaker.2.fill")
        case .speaker3: return Image(systemName: "speaker.3.fill")
    }
}
}

Speaker Selection Observable Object

final class SpeakerSelection: ObservableObject {
  @Published var selectedSymbol:SpeakerSymbol = .speaker3
  @Published var isSoundEnabled = true {
    didSet {
        if isSoundEnabled {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.selectedSymbol = .speakerEmpty
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.selectedSymbol = .speaker1
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.selectedSymbol = .speaker2
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.selectedSymbol = .speaker3
            }
        } else {
            selectedSymbol = .speaker3
            selectedSymbol = .speaker2
            selectedSymbol = .speaker1
            selectedSymbol = .speakerEmpty

        }
    }
}
}

Profile View

struct ProfileView: View {
@ObservedObject var selection = SpeakerSelection()

var body: some View {

    NavigationView {
        VStack {
            Spacer()
            Text("Speaker Toggle").font(.largeTitle)
            Spacer()

            Toggle(isOn: withAnimation {
                $selection.isSoundEnabled
                }, label: {
                    selection.selectedSymbol.image
                        .frame(width: 50)
                        .animation(Animation.default.delay(0.2))
                        .transition(.asymmetric(insertion: .opacity, removal: .opacity))
                    //                            speakerSymbol()
                    Text("Sound")
            }).padding().animation(.default).font(.largeTitle)
            Spacer()
            Spacer()
        }
    }
}
func speakerSymbol() -> AnyView {
    switch selection.selectedSymbol {
        case .speakerEmpty: return AnyView(SpeakerSymbol.speakerEmpty.image
            .animation(Animation.default.delay(0.2))
            .transition(.asymmetric(insertion: .opacity, removal: .opacity)))
        case .speaker1: return AnyView(SpeakerSymbol.speaker1.image
            .animation(Animation.default.delay(0.2))
            .transition(.asymmetric(insertion: .opacity, removal: .opacity)))
        case .speaker2: return AnyView(SpeakerSymbol.speaker2.image
            .animation(Animation.default.delay(0.2))
            .transition(.asymmetric(insertion: .opacity, removal: .opacity)))
        case .speaker3: return AnyView(SpeakerSymbol.speaker3.image
            .animation(Animation.default.delay(0.2))
            .transition(.asymmetric(insertion: .opacity, removal: .opacity)))
    }
  }
}

I guess my code logic is fine. Images are replacing fine as it should. But there is no animation effect working. It's frustrating I've tried a bunch of possible ways but none is working.

Upvotes: 2

Views: 1249

Answers (1)

Asperi
Asperi

Reputation: 257613

Here is possible approach. I simplified it for a demo purpose and less posting code, but idea should be clear and easy transferrable to your real code

Result demo (really it is much fluent than on gif):

demo

Modified model

enum SpeakerSymbol: Int, CaseIterable { // Inherited from Int for convenient below

    case speakerEmpty, speaker1, speaker2, speaker3
    
    var image: some View {
        var name: String
        switch self {
        case .speakerEmpty: name = "speaker.slash.fill"
        case .speaker1: name = "speaker.1.fill"
        case .speaker2: name = "speaker.2.fill"
        case .speaker3: name = "speaker.3.fill"
        }
        return Image(systemName: name).font(.largeTitle)
    }
}

Animatable modifier for SpeakerSymbol, required to let SwiftUI animation know that changed SpeakerSymbol value is able to animate

struct SpeakerModifier: AnimatableModifier {
    var symbol: SpeakerSymbol
    
    init(symbol: SpeakerSymbol) {
        self.symbol = symbol
        self.animating = Double(symbol.rawValue) // enum to Double
    }
    
    private var animating: Double // Double supports Animatable
    var animatableData: Double { // required part of Animatable protocol
        get { animating }
        set { animating = newValue }
    }
    
    func body(content: Content) -> some View {
        return SpeakerSymbol(rawValue: Int(animating))!.image // Double -> enum
    }
}

Demo of usage

struct TestSpeakerModifier: View {
    @State private var speaker: SpeakerSymbol = .speakerEmpty

    var body: some View {
        VStack {
            Color.clear // << just holder area
                .modifier(SpeakerModifier(symbol: speaker))
                .frame(width: 60, height: 60, alignment: .leading)
            Divider()
            Button("Toggle") {
                withAnimation { // animates between enum states
                    self.speaker = 
                       (self.speaker == .speakerEmpty ? .speaker3 : .speakerEmpty)
                }
            }
        }
    }
}

Upvotes: 2

Related Questions