1

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

halfer
  • 19,824
  • 17
  • 99
  • 186
punkbit
  • 7,347
  • 10
  • 55
  • 89
  • It looks here is needed animatable value-based progress instead of just end-less animation. My solution in [Rectangle progress bar swiftUI](https://stackoverflow.com/a/61760123/12299030) might be helpful. In provided code the animations just reset on every `body` rebuild w/o stored current state - that's the effect. – Asperi May 16 '20 at 10:48
  • @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 May 16 '20 at 14:05

2 Answers2

2

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

Ralf Ebert
  • 3,556
  • 3
  • 29
  • 43
  • 1
    thank you so much for taking the time and effort to look into this issue! Another big + for the good practices shared in the example provided! I made a test where the state changes and unfortunately it has the same flaw; updated the post accordingly; I'll keep researching to figure it out and hope to share my findings soon! Thank you – punkbit May 16 '20 at 14:02
  • I could reproduce the issue with the updated code. It helps to set the duration to something like 10seconds to make it more visible. While caused by changing the state, the body is not re-evaluated during the flickering. The animation code looks good to me. I'd guess it is a SwiftUI bug. It might be worthwile to report it in the Apple Feedback Assistant together with the example code and steps to reproduce. – Ralf Ebert May 16 '20 at 14:54
  • 1
    Yeh definitely, it's a pity that my original post is so lengthy though; but I've communicated the issue to the SwiftUI team. Thanks for your support! – punkbit May 16 '20 at 14:57
  • found a solution, check it out! – punkbit May 16 '20 at 17:39
1

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

punkbit
  • 7,347
  • 10
  • 55
  • 89
  • Ohh man thanks a lot. I could not understand what caused the jumpiness. After reading your answer everything got cleared. – Fahim Rahman Aug 14 '22 at 16:25