animationswiftui

SwiftUI transition collision between appear and remove


I have a view with a VStack and an array of items. I use this transition for the items...

extension AnyTransition {
    static var moveAndFade: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .bottom).combined(with: .opacity),
            removal: .opacity
        )
    }
}

i.e. when the items are added they animate in from the bottom. When the are removed they just fade out.

This all works but it can collide when items are added at the same time as existing items are removed. The items being removed don't animate up with the rest and it creates a collision effect.

Here is the view...

public struct TickerView: View {
    var items: [TickerItem]

    public var body: some View {
        VStack(alignment: .trailing) {
            ForEach(items.sorted(by: \.timeStamp, <)) { item in
                Text(item.text)
                    .transition(.moveAndFade)
            }
            .frame(maxWidth: .infinity, alignment: .trailing)
        }
        .frame(maxWidth: .infinity, minHeight: 0, maxHeight: 200, alignment: .bottom)
        .clipped()
    }
}

You can see an animation of the issue here. If there are no items added when the fade happens it works but if there are items added the animations goes wrong.

I feel like there is a simple view modifier that I'm missing that will group these animations together so that they don't collide.

I just would like the fade to animate with the rest off the items if it happens to occur at the same time.

enter image description here

This is the preview code I used to create the Preview. It's just a way to push items into the array.

public struct TestTickerView: View {
    @State var items: [TickerItem] = []

    public init() {}

    public var body: some View {
        VStack {
            Button {
                let id = UUID()
                withAnimation {
                    items.append(.init(id: id, text: "Random Text \(Int.random(in: 1...100))", timeStamp: .now))
                }
                Task {
                    try await Task.sleep(for: .seconds(3))
                    await MainActor.run {
                        withAnimation(.easeIn(duration: 1)) {
                            items.removeAll { $0.id == id }
                        }
                    }
                }
            } label: {
                Text("Add item")
            }
            Spacer()
            TickerView(items: items)
        }
        .padding()
    }
}

Solution

  • What you can do is update the text's opacity and remove it from the array when it's completely invisible:

    1. Add an opacity property to TickerItem:

      var opacity = 1.0
      
    2. Add the opacity modifier in TickerView:

      Text(item.text)
          .transition(.moveAndFade)
          .opacity(item.opacity)
      
    3. Update the Button's action:

      Button {
          let id = UUID()
          withAnimation {
              items.append(.init(id: id, text: "Random Text \(Int.random(in: 1...100))", timeStamp: .now))
          }
          Task {
              try await Task.sleep(for: .seconds(3))
              //await MainActor.run { //not needed
                  //MARK: - for iOS 17 +
                  withAnimation(.easeIn(duration: 1)) {
                      guard let index = items.firstIndex(where: {$0.id == id}) else {return}
                      items[index].opacity = 0
                  } completion: {
                      items.removeAll { $0.id == id }
                  }
                  //MARK: - for older ios
      //            withAnimation(.easeIn(duration: 1)) {
      //                guard let index = items.firstIndex(where: {$0.id == id}) else {return}
      //                items[index].opacity = 0
      //            }
      //            try await Task.sleep(for: .seconds(1))
      //            items.removeAll { $0.id == id }
              //}
          }
      } label: {
          Text("Add item")
      }