skip to Main Content

I am trying to animate movement between LazyVGrid views. I have two arrays of small Circle shapes (i.e. "dots") arranged in two different LazyVGrid Views (one on the left, one on the right). When the user taps a button, I want to animate each of those dots sliding from their original spot into their spot in a third LazyVGrid with all of them combined.

As if you had autonomous, but obedient, marbles arranged in grids in two places on a table, and when you command them to do so, they roll together to form a new grid in the center of the table (but simpler — no collisions, etc).

After many attempts, the following code is essentially my best attempt so far (though it looks dumb-ish out of context). The "Fill" button populates the original but it still just animates like the original grids of dots fading out and new dots fading in, and then more new dots fading in as the first set of new dots slides up.

Any tips on implementing this type of behavior?

import SwiftUI

struct CT2: View {
    @StateObject private var viewModel = DotManager()

    var body: some View {
        VStack {
            ZStack {
                HStack {

                    LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 20), count: 5), content: {
                        ForEach(viewModel.blueDots) { dot in
                            dot.dot
                                .frame(width: 20, height: 20)
                                .foregroundColor(.blue)
                        }
                    })

                    LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 20), count: 5), content: {
                        ForEach(viewModel.redDots) { dot in
                            dot.dot
                                .frame(width: 20, height: 20)
                                .foregroundColor(.red)
                        }
                    })
                }

                LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 20), count: 5), content: {
                    ForEach(viewModel.combinedDots) { dot in
                        dot.dot
                            .frame(width: 20, height: 20)
                            .foregroundColor(.purple)
                    }
                })
            }
        }

        HStack {
            Button(action: {
                viewModel.addDots()
            }, label: {
                Text("Fill")
            })
            .padding()
            .foregroundColor(.primary)
            .background(Color(UIColor.systemFill))

            Button(action: {
                viewModel.combineDots()
            }, label: {
                Text("Combine dots")
            })
            .padding()
            .foregroundColor(.primary)
            .background(Color(UIColor.systemFill))
        }
    }
}

enum DotType {
    case blue, red, combined
}

class Dot: Identifiable {
    let id = UUID()
    let dot = Circle()
}

class DotManager: ObservableObject {
    @Published var blueDots: [Dot] = []
    @Published var redDots: [Dot] = []
    @Published var combinedDots: [Dot] = []

    func addDots() {
        for _ in 0..<17 {
            let newDot = Dot()
            blueDots.append(newDot)
        }

        for _ in 17..<28 {
            let newDot = Dot()
            redDots.append(newDot)
        }
    }

    func combineDots() {
        for _ in 0..<blueDots.count {
            withAnimation(.easeInOut) {
                if let movedDot = blueDots.first {
                    blueDots.removeFirst()
                    combinedDots.append(movedDot)
                }
            }
        }
        for _ in 0..<redDots.count {
            withAnimation(.easeInOut) {
                if let movedDot = redDots.first {
                    redDots.removeFirst()
                    combinedDots.append(movedDot)
                }
            }
        }
    }
}

2

Answers


  1. To achieve the desired effect with your views, you can make use of @Namespace and .matchedGeometryEffect

    The modified code is as below.

    struct CT2: View {
        @StateObject private var viewModel = DotManager()
        @Namespace private var namespace
        ... same ...
    }
    

    and inside each ForEach loops (you have three ForEach here), add .matchedGeometryEffect.

    ForEach(viewModel.blueDots) { dot in
        dot.dot
            .frame(width: 20, height: 20)
            .foregroundColor(.blue)
            .matchedGeometryEffect(id: dot.id, in: namespace)
    }
    
    Login or Signup to reply.
  2. As I was suggesting in a comment, .matchedGeometryEffect can be used for this.

    In order for the dots to appear as if they are moving smoothly from one position to another, it helps if both the "before" and "after" versions can exist at the same time. When this is not the case, I found that the dots would move, but the before and after positions were not properly synchronized. This means, you can see how one set of dots fades out as they move off while the other set fades in, so the effect is a bit lost.

    In the updated example below, the dots are added to the combined array at the same time as they are added to the blue and red arrays. All dots are also displayed at all times, but the visibility is controlled using a boolean state variable that determines the opacity of the dots. The same variable is also used to determine the isSource flag for the .matchedGeometryEffect.

    struct CT2: View {
        @StateObject private var viewModel = DotManager()
        @Namespace private var nsDots // ADDED
        @State private var showingCombined = false // ADDED
    
        private func gridOfDots(dots: [Dot], color: Color, showWhenCombined: Bool) -> some View {
            LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 20), count: 5)) {
                ForEach(dots) { dot in
                    dot.dot
                        .frame(width: 20, height: 20)
                        .foregroundStyle(color)
                        .opacity(showingCombined == showWhenCombined ? 1 : 0)
                        .matchedGeometryEffect(
                            id: dot.id,
                            in: nsDots,
                            isSource: showWhenCombined == showingCombined
                        )
                }
            }
        }
    
        var body: some View {
            VStack {
                ZStack {
                    HStack {
                        gridOfDots(dots: viewModel.blueDots, color: .blue, showWhenCombined: false)
                        gridOfDots(dots: viewModel.redDots, color: .red, showWhenCombined: false)
                    }
                    gridOfDots(dots: viewModel.combinedDots, color: .purple, showWhenCombined: true)
                }
                .animation(.easeIn(duration: 1), value: showingCombined)
    
                HStack(spacing: 30) {
                    Button("Fill") {
                        withAnimation { viewModel.addDots() }
                    }
    
                    Button {
                        showingCombined.toggle()
                    } label: {
                        ZStack {
                            Text("Split").opacity(showingCombined ? 1 : 0)
                            Text("Combine").opacity(showingCombined ? 0 : 1)
                        }
                    }
    
                    Button("Clear") {
                        withAnimation { viewModel.reset() }
                    }
                }
                .padding()
                .foregroundStyle(.primary)
                .background(.background)
            }
            .padding()
        }
    }
    
    class DotManager: ObservableObject {
        @Published var blueDots: [Dot] = []
        @Published var redDots: [Dot] = []
        @Published var combinedDots: [Dot] = []
    
        func addDots() {
            for _ in 0..<17 {
                let newDot = Dot()
                blueDots.append(newDot)
                combinedDots.append(newDot)
            }
    
            for _ in 17..<28 {
                let newDot = Dot()
                redDots.append(newDot)
                combinedDots.append(newDot)
            }
        }
    
        func reset() {
            blueDots.removeAll()
            redDots.removeAll()
            combinedDots.removeAll()
        }
    }
    
    // other code unchanged
    

    Animation

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search