Pavel
Pavel

Reputation: 4420

Animate view on property change SwiftUI

I have a view

struct CellView: View {
    @Binding var color: Int
    @State var padding : Length = 10
    let colors = [Color.yellow, Color.red, Color.blue, Color.green]

    var body: some View {
        colors[color]
            .cornerRadius(20)
            .padding(padding)
            .animation(.spring())
    }
}

And I want it to have padding animation when property color changes. I want to animate padding from 10 to 0.

I've tried to use onAppear

    ...onAppear {
      self.padding = 0
    }

But it work only once when view appears(as intended), and I want to do this each time when property color changes. Basically, each time color property changes, I want to animate padding from 10 to 0. Could you please tell if there is a way to do this?

Upvotes: 2

Views: 7239

Answers (4)

Neil Smith
Neil Smith

Reputation: 1061

As of Xcode 12, Swift 5

One way to achieve the desired outcome could be to move the currently selected index into an ObservableObject.

final class CellViewModel: ObservableObject {
    @Published var index: Int
    init(index: Int = 0) {
        self.index = index
    }
}

Your CellView can then react to this change in index using the .onReceive(_:) modifier; accessing the Publisher provided by the @Published property wrapper using the $ prefix.

You can then use the closure provided by this modifier to update the padding and animate the change.

struct CellView: View {
    @ObservedObject var viewModel: CellViewModel
    @State private var padding : CGFloat = 10
    let colors: [Color] = [.yellow, .red, .blue, .green]
    
    var body: some View {
        colors[viewModel.index]
            .cornerRadius(20)
            .padding(padding)
            .onReceive(viewModel.$index) { _ in
                padding = 10
                withAnimation(.spring()) {
                    padding = 0
                }
            }
    }
}

And here's an example parent view for demonstration:

struct ParentView: View {
    let viewModel: CellViewModel
    
    var body: some View {
        VStack {
            CellView(viewModel: viewModel)
                .frame(width: 200, height: 200)
            HStack {
                ForEach(0..<4) { i in
                    Button(action: { viewModel.index = i }) {
                        Text("\(i)")
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color(.secondarySystemFill))
                    }
                }
            }
        }
    }
}

Note that the Parent does not need its viewModel property to be @ObservedObject here.

Upvotes: 2

arthas
arthas

Reputation: 818

Whenever there is a single incremental change in the color property this will toggle the padding between 10 and 0

padding =  color % 2 == 0 ? 10 : 0

Upvotes: 0

arsenius
arsenius

Reputation: 13276

As you noticed in the other answer, you cannot update state from within body. You also cannot use didSet on a @Binding (at least as of Beta 4) the way you can with @State.

The best solution I could come up with was to use a BindableObject and sink/onReceive in order to update padding on each color change. I also needed to add a delay in order for the padding animation to finish.

class IndexBinding: BindableObject {
    let willChange = PassthroughSubject<Void, Never>()
    var index: Int = 0 {
        didSet {
            self.willChange.send()
        }
    }
}

struct ParentView: View {
    @State var index = IndexBinding()
    var body: some View {
        CellView(index: self.index)
            .gesture(TapGesture().onEnded { _ in
                self.index.index += 1
            })
    }
}

struct CellView: View {

    @ObjectBinding var index: IndexBinding
    @State private var padding: CGFloat = 0.0

    var body: some View {
            Color.red
                .cornerRadius(20.0)
                .padding(self.padding + 20.0)
                .animation(.spring())
                .onReceive(self.index.willChange) {
                    self.padding = 10.0
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
                        self.padding = 0.0
                    }
                }
    }
}


This example doesn't animate in the Xcode canvas on Beta 4. Run it on the simulator or a device.

Upvotes: 2

Marc T.
Marc T.

Reputation: 5340

You could use Computed Properties to get this working. The code below is an example how it could be done.

import SwiftUI

struct ColorChanges: View {
    @State var color: Float = 0

    var body: some View {
        VStack {
            Slider(value: $color, from: 0, through: 3, by: 1)
            CellView(color: Int(color))
        }
    }
}

struct CellView: View {
    var color: Int
    @State var colorOld: Int = 0

    var padding: CGFloat {
        if color != colorOld {
            colorOld = color
            return 40
        } else {
            return 0
        }
    }

    let colors = [Color.yellow, Color.red, Color.blue, Color.green]

    var body: some View {
        colors[color]
            .cornerRadius(20)
            .padding(padding)
            .animation(.spring())
    }
}

Upvotes: 0

Related Questions