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
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
How to Create a Hotspot Network in iOS App Using Swift
How to Trigger Xcode's 'Update to Latest Package Versions' from Command Line
Xcode 11 - Disable Resize Mode in Catalyst Swift
Ambiguous Use of Operator '-' in Swift with 'Abs()'
Delegate Property with Different Type in Swift
Swift 4.1 Deinitialize and Deallocate(Capacity:) Deprecated
Generic and (Early) Binding in Swift 1.2
Cast Cgfloat to Int in Extension Binaryfloatingpoint
Swiftui - Why Does the Keyboard Pushes My View
How to Load Nsview from Xib with Swift 3
Swift:Pause and Resume Nstimer
Unresponsive Uibutton in Subview Added to Uistackview
Swift Any Difference Between Closures and First-Class Functions
How to Unwrap Arbitrarily Deeply Nested Optionals in Swift
Swift - Associated Types in Protocol with Where Clause
How to Switch an Xcode Project to Use Swift Version 1.2 in the Xcode 7 Beta
Swift Combine: What Are Those Multicast Functions for and How to Use Them