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 }
}
}