Skip to content

Instantly share code, notes, and snippets.

@BigZaphod
Last active January 22, 2023 23:39
Show Gist options
  • Save BigZaphod/ef7082e7d85fd36891f72b38d3ce1196 to your computer and use it in GitHub Desktop.
Save BigZaphod/ef7082e7d85fd36891f72b38d3ce1196 to your computer and use it in GitHub Desktop.
Moving views between stacks with SwiftUI while preserving view identity
// This is an experiment for moving a view between different stacks and having SwiftUI animate it properly.
// By "drawing" all of the cards in one place and moving their geometry, it preserves the card view's
// identifity from SwiftUI's POV. This means when things change, SwiftUI can understand how they changed
// and animate it properly. Is there a better way to do this?
class Card : Identifiable, ObservableObject, Equatable {
@Published var name: String
@Published var tagged = false
init(_ name: String) {
self.name = name
}
static func == (lhs: Card, rhs: Card) -> Bool {
return lhs === rhs
}
}
struct CardView: View {
@ObservedObject var card: Card
var body: some View {
ZStack {
// Background
Color.cardBackground
.cornerRadius(20)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.black, lineWidth: 1))
.padding(0.5)
Text(card.name)
.fontWeight(card.tagged ? .bold : .regular)
.foregroundColor(card.tagged ? .red : .white)
.shadow(color: .black, radius: 1)
}
}
}
struct ContentView: View {
@State var topGroup = [Card("A")]
@State var middleGroup = [Card("B"), Card("C")]
@State var bottomGroup = [Card("D")]
@State var topCard: Card?
@Namespace var ns
var body: some View {
ZStack {
VStack(spacing: 20) {
// Each of the following HStacks have a view that acts as a kind of placeholder
// for the actual card. When a card is added to the particular array, it causes
// a placeholder to be created and then the source card's geometry is remapped
// onto the placeholder. This preserves the identity of the card's view so that
// SwiftUI can animate them around the screen properly while actually drawing
// the card where we wanted it and having each HStack handle layout like normal.
// (Note that each stack's placeholders have a slightly different size. Fun.)
HStack {
ForEach(topGroup) { c in
Color.clear
.frame(width: 80 * 1.1, height: 120 * 1.1)
.matchedGeometryEffect(id: c.id, in: ns, isSource: true)
}
}
.padding(10)
.frame(maxWidth: .infinity, minHeight: 100)
.background(Color.mint)
HStack {
ForEach(middleGroup) { c in
Color.clear
.frame(width: 80 * 0.75, height: 120 * 0.75)
.matchedGeometryEffect(id: c.id, in: ns, isSource: true)
}
}
.padding(10)
.frame(maxWidth: .infinity, minHeight: 100)
.background(Color.gray)
HStack {
ForEach(bottomGroup) { c in
Color.clear
.frame(width: 80, height: 120)
.matchedGeometryEffect(id: c.id, in: ns, isSource: true)
}
}
.padding(10)
.frame(maxWidth: .infinity, minHeight: 100)
.background(Color.brown)
}
// All of the cards actually "live" here, only their geometry is moved when necessary.
// This ensures every card always has the same structural identity from SwiftUI's POV.
ForEach(topGroup + middleGroup + bottomGroup) { c in
CardView(card: c)
.matchedGeometryEffect(id: c.id, in: ns, isSource: false)
.zIndex(topCard == c ? 1 : 0)
.onTapGesture {
withAnimation {
c.tagged.toggle()
topCard = c
if let idx = topGroup.firstIndex(of: c) {
topGroup.remove(at: idx)
bottomGroup.append(c)
} else if let idx = middleGroup.firstIndex(of: c) {
middleGroup.remove(at: idx)
topGroup.append(c)
} else if let idx = bottomGroup.firstIndex(of: c) {
bottomGroup.remove(at: idx)
middleGroup.append(c)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@BigZaphod
Copy link
Author

Screen.Recording.2021-06-09.at.5.31.06.PM.mov

@marcpalmer
Copy link

marcpalmer commented Jun 10, 2021

Here's a version using explicit view ids, but not the other drag and drop stuff we mentioned (will follow up separately).

import SwiftUI
import PlaygroundSupport

// This is an experiment for moving a view between different stacks and having SwiftUI animate it properly.
// By "drawing" all of the cards in one place and moving their geometry, it preserves the card view's
// identifity from SwiftUI's POV. This means when things change, SwiftUI can understand how they changed
// and animate it properly. Is there a better way to do this?

class Card : Identifiable, ObservableObject, Equatable {
    @Published var name: String
    @Published var tagged = false
    
    init(_ name: String) {
        self.name = name
    }
    
    static func == (lhs: Card, rhs: Card) -> Bool {
        return lhs === rhs
    }
}

extension Color {
    static var cardBackground: Color { Color.yellow }
}

struct CardView: View {
    @ObservedObject var card: Card
    
    var body: some View {
        ZStack {
            // Background
            Color.cardBackground
                .cornerRadius(20)
                .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.black, lineWidth: 1))
                .padding(0.5)
            
            Text(card.name)
                .fontWeight(card.tagged ? .bold : .regular)
                .foregroundColor(card.tagged ? .red : .white)
                .shadow(color: .black, radius: 1)
        }
    }
}

struct ContentView: View {
    @State var topGroup = [Card("A")]
    @State var middleGroup = [Card("B"), Card("C")]
    @State var bottomGroup = [Card("D")]
    
    @State var topCard: Card?
    
    @Namespace var ns
    
    @ViewBuilder
    func cardView(for c: Card) -> some View {
        CardView(card: c)
            .frame(width: 80 * 1.1, height: 120 * 1.1)
            .zIndex(topCard == c ? 1 : 0)
            .onTapGesture {
                withAnimation {
                    c.tagged.toggle()
                    
                    topCard = c
                    
                    if let idx = topGroup.firstIndex(of: c) {
                        topGroup.remove(at: idx)
                        bottomGroup.append(c)
                    } else if let idx = middleGroup.firstIndex(of: c) {
                        middleGroup.remove(at: idx)
                        topGroup.append(c)
                    } else if let idx = bottomGroup.firstIndex(of: c) {
                        bottomGroup.remove(at: idx)
                        middleGroup.append(c)
                    }
                }
            }
            .matchedGeometryEffect(id: c.id, in: ns)
            .id(c.id)
    }
    
    var body: some View {
        ZStack {
            VStack(spacing: 20) {
                HStack {
                    ForEach(topGroup) { c in
                        cardView(for: c)
                    }
                }
                .padding(10)
                .frame(maxWidth: .infinity, minHeight: 150)
                .background(Color.mint)
                
                HStack {
                    ForEach(middleGroup) { c in
                        cardView(for: c)
                    }
                }
                .padding(10)
                .frame(maxWidth: .infinity, minHeight: 150)
                .background(Color.gray)
                
                HStack {
                    ForEach(bottomGroup) { c in
                        cardView(for: c)
                    }
                }
                .padding(10)
                .frame(maxWidth: .infinity, minHeight: 150)
                .background(Color.brown)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())
PlaygroundPage.current.needsIndefiniteExecution = true

@marcpalmer
Copy link

marcpalmer commented Jun 10, 2021

Formatting is horrific in that, Github and Xcode disagreeing, fixed

@BigZaphod
Copy link
Author

My indentation is showing up horrible too but nothing seems to fix it. No idea why.

@BigZaphod
Copy link
Author

Your code is much simpler and I like the structure of it a lot better, but I don't think it's quite doing the same thing mine was doing. I'm seeing a slight fade as the cards move between stacks which I think it means the underlying layer (whatever they call it) is actually disappearing and reappearing during the move process because SwiftUI has decided they have two separate identities. I suspect attaching an .onAppear() and .onDisappear() would indicate that's the case.

Obviously for a lot of situations this is probably fine, but I'm not sure what would happen if the card had some kind of slow and obvious continuous animation on it, for example. I think the animation would restart or get interrupted during the movement.

I tested removing the .id() modifier and I can't see any difference in behavior - there's still a fade during the transition, so I think that implies that SwiftUI really does primarily use the static structural identity and maybe only uses an explicit id whenever there's ambiguity between views in the tree/graph that are all at the same level (like the child views for a ForEach).

@marcpalmer
Copy link

The fade is probably easy to solve by changing the transition, default is opacity.

Re: animations that kind of continuous animation will be tricky in SwiftUI anyway, but again I believe it is totally doable as long as the state does not get lost e.g. the content view could track some animatable state for each Card instance, do whichever view "is" that card at any time can pull that state even if its animation is in flight (the state animates).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment