5

The app has the following setup:

My main view creates a tag cloud using a SwiftUI ForEach loop. The ForEach gets its data from the @Published array of an ObservableObject called TagModel. Using a Timer, every three seconds the ObservableObject adds a new tag to the array. By adding a tag the ForEach gets triggered again and creates another TagView. Once more than three tags have been added to the array, the ObservableObject removes the first (oldest) tag from the array and the ForEach removes that particular TagView.

With the following problem:

The creation of the TagViews works perfect. It also animates the way it's supposed to with the animations and .onAppear modifiers of the TagView. However when the oldest tag is removed it does not animate its removal. The code in .onDisappear executes but the TagView is removed immediately.

I tried the following to solve the issue:

I tried to have the whole appearing and disappearing animations of TagView run inside the .onAppear by using animations that repeat and then autoreverse. It sort of works but this way there are two issues. First, if the animation is timed too short, once the animation finishes and the TagView is removed, will show up for a short moment without any modifiers applied, then it will be removed. Second, if I set the animation duration longer the TagView will be removed before the animation has finished. In order for this to work I'd need to time the removal and the duration of the animation very precisely, which would make the TagView very dependent on the Timer and this doesn't seem to be a good solution.

Another solution I tried was finding something similar to self.presentationMode.wrappedValue.dismiss() using the @Environment(\.presentationMode) variable and somehow have the TagView remove itself after the .onAppear animation has finished. But this only works if the view has been created in an navigation stack and I couldn't find any other way to have a view destroy itself. Also I assume that would again cause issue as soon as TagModel updates its array.

I read several other S.O. solution that pointed towards the enumeration of the data in the ForEach loop. But I'm creating each TagView as its own object, I'd assume this should not be the issue and I'm not sure how I'd have to implement this if this is part of the issue.

Here is the simplified code of my app that can be run in an iOS single view SwiftUI project.

import SwiftUI

struct ContentView: View {
    private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
    @ObservedObject var tagModel = TagModel()
    
    var body: some View {
        ZStack {
            ForEach(tagModel.tags, id: \.self) { label in
                TagView(label: label)
            }
            .onReceive(timer) { _ in
                self.tagModel.addNextTag()
                if tagModel.tags.count > 3 {
                    self.tagModel.removeOldestTag()
                }
            }
        }
    }
}

class TagModel: ObservableObject {
    @Published var tags = [String]()
    
    func addNextTag() {
        tags.append(String( Date().timeIntervalSince1970 ))
    }
    func removeOldestTag() {
        tags.remove(at: 0)
    }
}

struct TagView: View {
    @State private var show: Bool = false
    @State private var position: CGPoint = CGPoint(x: Int.random(in: 50..<250), y: Int.random(in: 10..<25))
    @State private var offsetY: CGFloat = .zero
    
    let label: String
    
    var body: some View {
        let text = Text(label)
            .opacity(show ? 1.0 : 0.0)
            .scaleEffect(show ? 1.0 : 0.0)
            .animation(Animation.easeInOut(duration: 6))
            .position(position)
            .offset(y: offsetY)
            .animation(Animation.easeInOut(duration: 6))
            .onAppear() {
                show = true
                offsetY = 100
            }
            .onDisappear() {
                show = false
                offsetY = 0
            }
        return text
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Marco Boerner
  • 1,243
  • 1
  • 11
  • 34

1 Answers1

7

It is not clear which effect do you try to achieve, but on remove you should animate not view internals, but view itself, ie. in parent, because view remove there and as-a-whole.

Something like (just direction where to experiment):

var body: some View {
    ZStack {
        ForEach(tagModel.tags, id: \.self) { label in
            TagView(label: label)
                .transition(.move(edge: .leading))    // << here !! (maybe asymmetric needed)
        }
        .onReceive(timer) { _ in
            self.tagModel.addNextTag()
            if tagModel.tags.count > 3 {
                self.tagModel.removeOldestTag()
            }
        }
    }
    .animation(Animation.easeInOut(duration: 1))    // << here !! (parent animates subview removing)
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 2
    Your code indeed helped me to get to the final result. I moved the `.transition` modifier inside of TagView as this didn't make a difference. Adding `.animation()` to the `ZStack` was what I needed! Thank you! : ) I'm still trying to wrap my head around some of the SwiftUI concepts as to where a modifier belongs exactly. – Marco Boerner Jan 18 '21 at 20:08