TL;DR:
I want to trigger an action when (parent) state changes. This seems difficult in a declarative context.
Remarks
The challenge here is that I am not trying to make a property of one view dependent on another. That's well-covered territory. I could (and have) read all day about sharing state changes. But this is an event.
The best I have come across is by arsenius. His approach does work; I am wondering if there is a more Reactive way to do it. Maybe a one-shot Publisher
? Seems sketchy.
Code
"Event" is not always a dirty word in FRP. I can start a View
's animation by handling an event in the same View
:
import SwiftUI
struct MyReusableSubview : View {
@State private var offs = CGFloat.zero // Animate this.
var body: some View {
Rectangle().foregroundColor(.green).offset(y: self.offs)
// A local event triggers the action...
.onTapGesture { self.simplifiedAnimation() }
// ...but we want to animate when parent view says so.
}
private func simplifiedAnimation() {
self.offs = 200
withAnimation { self.offs = 0 }
}
}
But I want this View
to be composable and reusable. It seems reasonable to want to plug this into a larger hierarchy, which will have its own idea of when to run the animation. All my "solutions" either change state during View
update, or won't even compile.
struct ContentView: View {
var body: some View {
VStack {
Button(action: {
// Want this to trigger subview's animation.
}) {
Text("Tap me")
}
MyReusableSubview()
}.background(Color.gray)
}
}
Surely SwiftUI is not going to force me not to decompose my hierarchy?
Solution
Here is arsenius' suggestion. Is there a more Swifty-UI way?
struct MyReusableSubview : View {
@Binding var doIt : Bool // Bound to parent
// ... as before...
var body: some View {
Group {
if self.doIt {
ZStack { EmptyView() }
.onAppear { self.simplifiedAnimation() }
// And call DispatchQueue to clear the doIt flag.
}
Rectangle()
.foregroundColor(.green)
.offset(y: self.offs)
}
}
}
Here is possible approach with optional external event generator, so if one provided reusable view reacts on external provider as well as on local (if local is not needed, then it can be just removed).
Tested with Xcode 11.4 / iOS 13.4
Full module code:
import SwiftUI
import Combine
struct MyReusableSubview : View {
private let publisher: AnyPublisher<Bool, Never>
init(_ publisher: AnyPublisher<Bool, Never> =
Just(false).dropFirst().eraseToAnyPublisher()) {
self.publisher = publisher
}
@State private var offs = CGFloat.zero // Animate this.
var body: some View {
Rectangle().foregroundColor(.green).offset(y: self.offs)
// A local event triggers the action...
.onTapGesture { self.simplifiedAnimation() }
.onReceive(publisher) { _ in self.simplifiedAnimation() }
// ...but we want to animate when parent view says so.
}
private func simplifiedAnimation() {
self.offs = 200
withAnimation { self.offs = 0 }
}
}
struct TestParentToChildEvent: View {
let generator = PassthroughSubject<Bool, Never>()
var body: some View {
VStack {
Button("Tap") { self.generator.send(true) }
Divider()
MyReusableSubview(generator.eraseToAnyPublisher())
.frame(width: 300, height: 200)
}
}
}
struct TestParentToChildEvent_Previews: PreviewProvider {
static var previews: some View {
TestParentToChildEvent()
}
}
This demo seems simplest for me, also it is possible indirect dependency injection of external generator via Environment instead of constructor.