SwiftUI - Responding to taps in parent View

2k views Asked by At

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

There are 1 answers

0
Asperi On BEST ANSWER

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

demo

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.