1
votes

I've recently encountered an issue in a container View that has a nested list of View items that use a repeatForever animation, that works fine when firstly drew and jumpy after a sibling item is added dynamically.

The list of View is dynamically generated from an ObservableObject property, and its represented here as Loop. It's generated after a computation that takes place in a background thread (AVAudioPlayerNodeImpl.CompletionHandlerQueue).

The Loop View animation has a duration that equals a dynamic duration property value of its passed parameter player. Each Loop has its own values, that may or not be the same in each sibling.

When the first Loop View is created the animation works flawlessly but becomes jumpy after a new item is included in the list. Which means, that the animation works correctly for the tail item (the last item in the list, or the newest member) and the previous wrongly.

From my perspective, it seems related to how SwiftUI is redrawing and there's a gap in my knowledge, that lead to an implementation that causes the animation states to scatter. The question is what is causing this or how to prevent this from happening in the future?

I've minimised the implementation, to improve clarity and focus on the subject.

Let's take a look into the Container View:

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var engine: Engine

    fileprivate func Loop(duration: Double, play: Bool) -> some View {
            ZStack {
                Circle()
                    .stroke(style: StrokeStyle(lineWidth: 10.0))
                    .foregroundColor(Color.purple)
                    .opacity(0.3)
                    .overlay(
                        Circle()
                            .trim(
                                from: 0,
                                to: play ? 1.0 : 0.0
                        )
                            .stroke(
                                style: StrokeStyle(lineWidth: 10.0,
                                                   lineCap: .round,
                                                   lineJoin: .round)
                        )
                            .animation(
                                self.audioEngine.isPlaying ?
                                    Animation
                                        .linear(duration: duration)
                                        .repeatForever(autoreverses: false) :
                                    .none
                        )
                            .rotationEffect(Angle(degrees: -90))
                            .foregroundColor(Color.purple)
                )
            }
            .frame(width: 100, height: 100)
            .padding()
    }

    var body: some View {
        VStack {
            ForEach (0 ..< self.audioEngine.players.count, id: \.self) { index in
                HStack {
                    self.Loop(duration: self.engine.players[index].duration, play: self.engine.players[index].isPlaying)
                }
            }
        }
    }
}

In the Body you'll find a ForEach that watches a list of Players, a @Published property from Engine.

Have a look onto the Engine class:

Class Engine: ObservableObject {
  @Published var players = []

  func record() {
    ...
  }

  func stop() {
    ...
    self.recorderCompletionHandler()
  }

  func recorderCompletionHandler() {
    ...

    let player = self.createPlayer(...)
    player.play()

    DispatchQueue.main.async {
      self.players.append(player)
    }
  }

  func createPlayer() {
    ...
  }
}

Finally, a small video demo to showcase the issue that is worth more than words:

enter image description here

For this particular example, the last item has a duration that is double the duration of the previous two, that have the same duration each. Although the issue happens regardless of this exemplified state.

Would like to mention that the start time or trigger time is the same for all, the .play a method called in sync!

Edited

Another test after following good practices provided by @Ralf Ebert, with a slight change given my requirements, toggle the play state, which unfortunately causes the same issue, so thus far this does seem to be related with some principle in SwiftUI that is worth learning.

A modified version for the version kindly provided by @Ralf Ebert:

// SwiftUIPlayground
import SwiftUI

struct PlayerLoopView: View {
    @ObservedObject var player: MyPlayer

    var body: some View {
        ZStack {
            Circle()
                .stroke(style: StrokeStyle(lineWidth: 10.0))
                .foregroundColor(Color.purple)
                .opacity(0.3)
                .overlay(
                    Circle()
                        .trim(
                            from: 0,
                            to: player.isPlaying ? 1.0 : 0.0
                        )
                        .stroke(
                            style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
                        )
                        .animation(
                            player.isPlaying ?
                                Animation
                                .linear(duration: player.duration)
                                .repeatForever(autoreverses: false) :
                                .none
                        )
                        .rotationEffect(Angle(degrees: -90))
                        .foregroundColor(Color.purple)
                )
        }
        .frame(width: 100, height: 100)
        .padding()
    }
}

struct PlayersProgressView: View {
    @ObservedObject var engine = Engine()

    var body: some View {
        NavigationView {
            VStack {
                ForEach(self.engine.players) { player in
                    HStack {
                        Text("Player")
                        PlayerLoopView(player: player)
                    }
                }
            }
            .navigationBarItems(trailing:
                VStack {
                    Button("Add Player") {
                        self.engine.addPlayer()
                    }
                    Button("Play All") {
                        self.engine.playAll()
                    }
                    Button("Stop All") {
                        self.engine.stopAll()
                    }
                }.padding()
            )
        }
    }
}

class MyPlayer: ObservableObject, Identifiable {
    var id = UUID()
    @Published var isPlaying: Bool = false
    var duration: Double = 1
    func play() {
        self.isPlaying = true
    }
    func stop() {
        self.isPlaying = false
    }
}

class Engine: ObservableObject {
    @Published var players = [MyPlayer]()

    func addPlayer() {
        let player = MyPlayer()
        players.append(player)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
            player.isPlaying = true
        }
    }

    func stopAll() {
        self.players.forEach { $0.stop() }
    }

    func playAll() {
        self.players.forEach { $0.play() }
    }
}

struct PlayersProgressView_Previews: PreviewProvider {
    static var previews: some View {
        PlayersProgressView()
    }
}

The following demo was created by following the steps (the demo only shows after the stop all to keep it under 2mb maximum image upload in Stack Overflow):

- Add player
- Add player
- Add player
- Stop All (*the animations played well this far)
- Play All (*same issue as previously documented)
- Add player (*the tail player animation works fine)

enter image description here

Found an article reporting a similar issue: https://horberg.nu/2019/10/15/a-story-about-unstoppable-animations-in-swiftui/

I'll have to find a different approach instead of using .repeatForever

2
It looks here is needed animatable value-based progress instead of just end-less animation. My solution in Rectangle progress bar swiftUI might be helpful. In provided code the animations just reset on every body rebuild w/o stored current state - that's the effect. - Asperi
@Asperi, that is great, thank you! The current implementation of player unfortunately doesn't have a progress event handler at the moment but will look into that in the future, or any other use case that progress can be computed incrementally ;) Appreciate your time ;) - punkbit

2 Answers

2
votes

You need to make sure that no view update (triggered f.e. by a change like adding a new player) causes 'Loop' to be re-evaluated again because this could reset the animation.

In this example, I would:

  • make the player Identifiable so SwiftUI can keep track of the objects (var id = UUID() suffices), then you can use ForEach(self.engine.players) and SwiftUI can keep track of the Player -> View association.
  • make the player itself an ObservableObject and create a PlayerLoopView instead of the Loop function in your example:
struct PlayerLoopView: View {
    @ObservedObject var player: Player

    var body: some View {
        ZStack {
            Circle()
            // ...
        }
    }

That's imho the most reliable way to prevent state updates to mess with your animation.

See here for a runnable example: https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/PlayersProgressView.swift

0
votes

This problem seems to be generated with the original implementation, where the .animation method takes a conditional and that's what causes the jumpiness.

If we decide not and instead keep the desired Animation declaration and only toggle the animation duration it works fine!

As follows:

ZStack {
    Circle()
        .stroke(style: StrokeStyle(lineWidth: 10.0))
        .foregroundColor(Color.purple)
        .opacity(0.3)
    Circle()
        .trim(
            from: 0,
            to: player.isPlaying ? 1.0 : 0.0
        )
        .stroke(
            style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
        )
        .animation(
            Animation
                .linear(duration: player.isPlaying ? player.duration : 0.0)
                .repeatForever(autoreverses: false)
        )
        .rotationEffect(Angle(degrees: -90))
        .foregroundColor(Color.purple)
}
.frame(width: 100, height: 100)
.padding()

Obs: The third element duration is 4x longer, just for testing

The result as desired:

enter image description here