I looked through the code. There are a few things to note, so I'm gonna go through each of them here.
self.animationFlag = true
DispatchQueue.main.asyncAfter(deadline: .now() + Self.animationDuration) {
self.animationFlag = false
}
Avoid doing this at all cost. It may look like it's working, but there are multiple problems that come with it.
- If the animation is cancelled, the latter part is not cancelled with it. Each part of the animation needs to be managed separately, preventing smooth redirection between different animations.
- If the user repeatedly trigger the animation, it'd easily lead to scenario where you need to be aware of the asynchrony. You need to handle the call properly whether the sequence is
TFTFTF, TTTFFF or some other sequence.
Just the latter reason is already enough to avoid doing it in SwiftUI that boast the elimination of such synchronisation problem.
Instead, combine all the animation fragments into one big animation. In this case, you can do:
struct FooView: View {
static var animationDuration = 1.0
@State var animationFlag = false
func doFoo() {
animationFlag.toggle()
}
var body: some View {
...
Text(verbatim: "🤔").font(.system(size: 150))
.modifier(Effect(animationFlag))
.animation(Animation.linear(duration: Self.animationDuration))
...
}
}
private struct Effect: GeometryEffect {
init(_ value: Bool) {
animatableData = value ? 0 : 1
}
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
let maxScale: CGFloat = 4, minScale: CGFloat = 1
let scale = maxScale - (maxScale - minScale) * 2 * abs(0.5 - animatableData)
let transform = CGAffineTransform.identity
.translatedBy(x: -size.width * (scale - 1) / 2, y: -size.height * (scale - 1) / 2)
.scaledBy(x: scale, y: scale)
return ProjectionTransform(transform)
}
}
When you doFoo, you ends up creating new Effect with different animatableData value in the body call. SwiftUI will
- Detect that the
Effect.animatableData has changed
- Interpolate the value of
animatableData, and
- Repeatedly call
effectValue and apply the result to the View (in this case, Text).
doFoo toggles animatableData between 0 and 1 depending on the animationFlag and so we can make custom transformation that scales the view at 1.0 when animatableData is 0 or 1, and at 1.2 when animatableData is 0.5.
Now you can work with the animation as one cohesive group, and if the animation is interrupted, SwiftUI can simply interpolate the current location to the new destination.
Note that GeometryEffect conforms to Animatable which allows for the interpolation mentioned above. Animatable requires animatableData to be VectorArithmatic. Things like Float and Double already conform to that out of the box, and AnimatablePair helps you quickly compose more complex data from simpler ones.
Should you need more complex animation, you can draw-it-yourself using Shape, or use AnimatableModifier. I find this SwiftUI Lab series on Advance Animation to be useful.
Also, we don't need withAnimation because Text already has .animation modifier.
In the end, when you design the animation, try to think what is the destination you want to reach from the event, and create single animation that reaches (from the current state) all the way to animation. Splitting the event transition into multiple chunks should be done with great care.
struct Foo: View {
@ObservedObject private var notifier = Notifier()
}
Nothing wrong with this, but since notifier will never mutate, I find Environment wrapper to be more suitable.
struct BarView: View {
let fooTrigger = ObservableObjectPublisher()
var body: some View {
VStack {
FooView()
.environment(\.fooTrigger, fooTrigger)
Spacer()
Button("Action!...") {
self.fooTrigger.send()
}
}
}
}
struct FooView: some View {
...
// Type inferred from the type of `EnvironmentValues.fooTrigger`
@Environment(\.fooTrigger) private var trigger
var body: some View {
VStack {
...
}
.onReceive(trigger, perform: self.doFoo)
}
}
extension EnvironmentValues {
private struct FooTrigger: EnvironmentKey {
static var defaultValue: ObservableObjectPublisher { .init() }
}
var fooTrigger: ObservableObjectPublisher {
get { self[FooTrigger.self] }
set { self[FooTrigger.self] = newValue }
}
}
This way, even if the superview don’t specify the fooTrigger it’ll still fetch a default value, which seems to be a reasonable behavior.
You’ll need to specify fooTrigger (via .environment) if you want to make sure they have the same object. SwiftUI seems to call defaultValue separately on each view, resulting in a different Publisher.
struct BarView: View {
let foo = Foo() // seems okay to do this way?
}
You're holding it the wrong way
.
You should NOT treat View as having identity of any kind. The only identity each view has is its location in the view hierarchy (BooView is the parent of Foo because it's accessed inside body). In fact, this
var body: some View {
let x = ...
return VStack {
x
x
}
}
already won't do what you think.
It is not that foo hold any significance more that where it appears in the body method. So any logic relying on the identity of view is incorrect almost immediately.
You'll see that I avoid referencing Foo in multiple places and instead pass in fooTrigger via Environment. This way, even if new FooView is created, and replace the old one (this should be a good mental model when using SwiftUIView), fooTrigger will know where to send message to.
I see that you nest Foo inside BarView. I find it easier to maintain if I do Foo as a private struct in the same file as BarView. It'd achieve the similar level of access control strictness, but much easier to refactor and move things around.
The code
struct BarView: View {
let fooTrigger = ObservableObjectPublisher()
var body: some View {
VStack {
FooView()
.environment(\.fooTrigger, fooTrigger)
Spacer()
Button("Action!...") {
self.fooTrigger.send()
}
}
}
}
struct FooView: View {
static var animationDuration = 1.0
@Environment(\.fooTrigger) private var trigger
@State var animationFlag = false
func doFoo() {
animationFlag.toggle()
}
var body: some View {
VStack {
ZStack {
Text(verbatim: "🤔").font(.system(size: 150))
.modifier(Effect(animationFlag))
.animation(Animation.linear(duration: Self.animationDuration))
}
.frame(width: 200, height: 200)
Text(verbatim: "self.flag = \(self.animationFlag)")
}
.onReceive(trigger, perform: self.doFoo)
}
}
private struct Effect: GeometryEffect {
init(_ value: Bool) {
animatableData = value ? 0 : 1
}
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
let maxScale: CGFloat = 1.2, minScale: CGFloat = 1
let scale = maxScale - (maxScale - minScale) * 2 * abs(0.5 - animatableData)
let transform = CGAffineTransform.identity
.translatedBy(x: -size.width * (scale - 1) / 2, y: -size.height * (scale - 1) / 2)
.scaledBy(x: scale, y: scale)
return ProjectionTransform(transform)
}
}
extension EnvironmentValues {
private struct FooTrigger: EnvironmentKey {
static var defaultValue: ObservableObjectPublisher { .init() }
}
var fooTrigger: ObservableObjectPublisher {
get { self[FooTrigger.self] }
set { self[FooTrigger.self] = newValue }
}
}