Switching a @State Property to a @Binding Property Interferes with Animation

Switching a @State property to a @Binding property interferes with animation

Actually I see here some misunderstanding that must be clarified and, probably, then initial solution should be rethink. Binding is not state shared to parent, it is link to parent's state holding source of truth, so your view becomes dependent on parent's capability to refresh it on state change, which is not always reliable (or stable, or persistent, etc.), especially in different view hierarchies (like, sheets, UIKit backend, etc.). Changing binding you do not refresh your view directly (as opposite to changes own state) even if your view depends on value in binding, but change parent state, which might do or might do not update your view back. Finalizing - what you've implied is not reliable approach by nature, and you actually observe this.

Alternate solution: use ObsevableObject/ObservedObject view model pattern.

Tested with Xcode 12.4 / iOS 14.4

demo

import MapKit

class ReleaseGestureVM: ObservableObject {
@Published var opened: Bool = false
}

struct ReleaseGesture<Header: View, Content: View>: View {

// MARK: Init properties
@ObservedObject var vm: ReleaseGestureVM

// Height of the provided header view
let headerHeight: CGFloat

// Height of the provided content view
let contentHeight: CGFloat

// The spacing between the header and the content
let separation: CGFloat

let header: () -> Header
let content: () -> Content

// MARK: State
@GestureState private var translation: CGFloat = 0

// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5

// MARK: Computed properties

// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.vm.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}

// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.vm.opened.toggle()
}
}

// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}

// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}

var body: some View {

GeometryReader { reader in

VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)

}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)

}
.clipped()
}

// MARK: Initializer
init(
vm: ReleaseGestureVM,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
@ViewBuilder header: @escaping () -> Header,
@ViewBuilder content: @escaping () -> Content
) {
self.vm = vm
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}

struct ReleaseGesture_Previews: PreviewProvider {

struct WrapperView: View {

@State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))

@StateObject private var vm = ReleaseGestureVM()

var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
vm: self.vm,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)

}
)
}
.ignoresSafeArea()

}
}

static var previews: some View {
WrapperView()
}
}

SwiftUI animation problem with a binding to a StateObject inside a NavigationView

To be honest with you, I am not sure why, but utilizing the animation modifier on the RectView allows the animation to occur without any issues.

struct RectView: View {
@Binding var isRed: Bool

var body: some View {
Rectangle()
.fill(isRed ? Color.red : Color.gray)
.frame(width: 75, height: 75, alignment: .center)
.animation(.easeOut(duration: 1), value: isRed)
.onTapGesture { isRed.toggle() }
}
}

screen recording example

SwiftUI Animation from @Published property changing from outside the View

The easiest option is to add a withAnimation block inside your timer closure:

withAnimation(.easeIn(duration: 0.5)) {
isOn.toggle()
}

If you don't have the ability to change the @ObservableObject closure, you could add a local variable to mirror the changes:

struct ContentView: View {
@ObservedObject var model: Model
@State var localIsOn = false
var body: some View {
VStack {
if localIsOn {
MyImage(color: .blue)
} else {
MyImage(color: .clear)
}
Spacer()
Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
Spacer()
}.onChange(of: model.isOn) { (on) in
withAnimation {
localIsOn = on
}
}
}
}

You could also do a similar trick with a mirrored variable inside your ObservableObject:


struct ContentView: View {
@ObservedObject var model: Model
var body: some View {
VStack {
if model.animatedOn {
MyImage(color: .blue)
} else {
MyImage(color: .clear)
}
Spacer()
Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
Spacer()
}
}
}

class Model: ObservableObject {
@Published var isOn: Bool = false
@Published var animatedOn : Bool = false

var cancellable : AnyCancellable?

var timer = Timer()
init() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [unowned self] _ in
isOn.toggle()
})
cancellable = $isOn.sink(receiveValue: { (on) in
withAnimation {
self.animatedOn = on
}
})
}
}

Why doesn't custom SwiftUI view animate on ViewModel state change?

AnyView hides all implementation details from the compiler. That means that SiwftUI can't animate, because it tries to infer all animations during compilation.

That's why you should try to avoid AnyView whenever possible.

If you want to seperate some logic of your body into a computed property (or function), use the @ViewBuilder annotation.

@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle:
mainView
case let .error(error):
ZStack {
mainView
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}.animation(.default)
default:
EmptyView()
}
}

Notice that I removed return in the property. The @ViewBuilder behaves much like the default body property by wrapping everything in a Group.


The second problem are the states between you animate.
By using the switch you are creating an SwiftUI._ConditionalContent<MainView,SwiftUI.ZStack<SwiftUI.TupleView<(MainView, ErrorView)>>

That is a little hard to parse, but you creating a ConditionalContent that switches between just the MainView and an ZStack consisting of your MainView and the ErrorView, which is not what we want.

We should try to create a ZStack with out MainView and an optional ErrorView.

What I like to to is to extend by view models with a computed error: Error? property.

extension ViewModel {
var error: Error? {
guard case let .error(error) = state else {
return nil
}
return error
}
}

Now, you can create an optional view, by using the map function of the Optional type

ZStack {
mainView
viewModel.error.map { unwrapped in
ErrorView(
errorDescription: unwrapped.localizedDescription,
state: $viewModel.state
)
}
}

That should work as expected.

Cannot change value of @Binding, also how to animate a view from only one toggle state

Question 1

You need an animation that changes depending on isOn's state.

// on the view
.animation(animation, value: isOn)

// define the variable animation
var animation: Animation? {
isOn ? Animation.easeIn : nil
}

Question 2

You're already smart to use the value restriction version of .animation. That should restrict any changes in the hierarchy to just changes in that Binding value.



Related Topics



Leave a reply



Submit