rive-app / rive-ios

iOS runtime for Rive
MIT License
467 stars 53 forks source link

SwiftUI - setInput and triggerInput don't work in SubView when parent updated. Example Code Inc. #277

Open SleepiestAdam opened 10 months ago

SleepiestAdam commented 10 months ago

Description

In SwiftUI if a Rive view is hosted within a parent view that has been updated when a @Published value has changed, then it appears the RiveRuntime no longer responds to either triggerInput or setInput work.

By the looks of it's it's duplicating the view the RiveViewModel is using / there's a lifecycle issue. I've produced a very basic demo showing a version that works when the Confetti isn't within a subview (red background), right alongside one within a subview (green background) that doesn't work.

Both print a statement out when tapped, but only the one directly embedded in the SplashScreen works, any RiveViewModel.view()'s within subviews fail to respond to any triggerInput or setInput.

Minimal code to reproduce the issue is attached and is based on the rive-ios demo project with minimal changes.

This is likely causing issues for anyone using Rive in a serious way in a SwiftUI app, as very few apps are likely to host a RiveViewModel where the parent view has never been updated by an ObservableObject.

Minimal Repro

This is based on the SwiftUI Demo app. Just replace the SplashScreen with the code below. Minimal Repo also attached:

import SwiftUI
import RiveRuntime

// This is a view model that has a published property just for the purpose of forcing an update of the core view.
class ViewModel : ObservableObject {
    @Published var updateViewFlag : Int = 0

    init() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
            self.updateViewFlag += 1
        })
    }
}

// This is a subview that we will embed. Notice that after the ViewModel updateViewFlag has been updated this confetti no longer responds to "triggerInput"
struct ConfettiSubview: View {
    var confetti = RiveViewModel(fileName: "confetti", stateMachineName: "State Machine 1")

    var body: some View {
        confetti.view()
            .frame(width: 350, height: 200)
            .background(Color.green)
            .onTapGesture {
                print("Subview Tapped - Doesn't Work")
                confetti.triggerInput("Trigger explosion")
            }
    }
}

// Our main splash screen, one with a Subview in and one with the confetti directly in the view.
struct SplashScreen: View {
    @ObservedObject var viewModel = ViewModel()

    /// This file has a StateMachine that will react when we trigger an input called "Trigger explosion"
    var confetti = RiveViewModel(fileName: "confetti", stateMachineName: "State Machine 1")

    var body: some View {

        VStack(spacing: 10) {

            // This one always works
            confetti.view()
                .frame(width: 350, height: 200)
                .background(Color.red)
                .onTapGesture {
                    print("Primary View Tapped - Works")
                    confetti.triggerInput("Trigger explosion")
                }

            // This one doesn't work if the viewModel has ever historically triggered a view update
            ConfettiSubview()
        }

    }

}

extension Color {
    static var lightPurple = Color(hue: 0.786, saturation: 0.528, brightness: 0.463)
    static var darkPurple = Color(hue: 0.748, saturation: 0.917, brightness: 0.189)
}

struct SplashScreen_Previews: PreviewProvider {
    static var previews: some View {
        SplashScreen()
            .previewDevice("iPhone 13")
    }
}

Demo-App.zip

SleepiestAdam commented 10 months ago

Any update on this / you guys been able to reproduce the issue internally?

zplata commented 10 months ago

Hi @SleepiestAdam - sorry for the delayed response. We'll take a look at this next chance we get, and definitely appreciate the reproduction project you provided!

SleepiestAdam commented 10 months ago

No worries, not sure if this is related (I suspect it is), but RiveViewModel.riveView also isn't available for some reason after initialisation.

import SwiftUI
import RiveRuntime

struct Onboarding: View {

    @ObservedObject var viewModel = OnboardingViewModel.shared
    var splash : RiveViewModel

    init() {
        self.splash = RiveViewModel(fileName: "Splash", fit: .fitHeight, autoPlay: true)
        self.splash.riveView!.playerDelegate = viewModel // Thiss crashes
        // Trying to tap into player delegate to hide the splash.view() after it's completed playback.
    }

    // Rest of code inc body.

}

In RiveViewModel it looks like one of your devs might have also suspected an issue given the TODO in the RiveViewModel class... Seems like it's (maybe?) getting deallocated somehow; but I have absolutely no idea why, as as far as I can tell it is not a weak ref and if I remove the playerDelegate assignation line then it does play the Rive animation perfectly fine, so it is loading my Splash.riv without any issues, and isn't crashing at "riveModel = try! RiveModel(fileName: fileName, extension: extension, in: bundle)" of your internal code. Bit of an odd one...

    // TODO: could be a weak ref, need to look at this in more detail. 
    open private(set) var riveView: RiveView?
    private var defaultModel: RiveModelBuffer!

    public init(
        fileName: String,
        extension: String = ".riv",
        in bundle: Bundle = .main,
        stateMachineName: String?,
        fit: RiveFit = .contain,
        alignment: RiveAlignment = .center,
        autoPlay: Bool = true,
        artboardName: String? = nil
    ) {
        self.fit = fit
        self.alignment = alignment
        self.autoPlay = autoPlay
        super.init()
        riveModel = try! RiveModel(fileName: fileName, extension: `extension`, in: bundle)
        sharedInit(artboardName: artboardName, stateMachineName: stateMachineName, animationName: nil)
    }
zplata commented 9 months ago

For the first issue, yes, I think this is due to var confetti = RiveViewModel(fileName: "confetti", stateMachineName: "State Machine 1") being reinitialized on the parent re-render. RiveViewModel holds a reference to its view (which I think you caught in your second comment) and when calling confetti.view() in the body, creates a new RiveView view and a reference on the newly initialized view model. So an overview of what I think is happening is:

A quick and dirty hack that's probably not viable as a long-term solution is to wrap confetti with a @StateObject wrapper, so that it does not get reinitialized, and the view persists. But ultimately, I think we have to re-examine how our RiveViewModel pattern should work in a SwiftUI paradigm. It's a relatively new implementation for Rive so there's definitely some room to learn from this and would love any suggestions here - a similar issue was reported: https://github.com/rive-app/rive-ios/issues/244

As for the second issue, riveView is not set yet as a member variable on rive view model until you call .view() or set the view manually on the view model, so you can't yet set the playerDelegate upon just creating the view model. We've usually seen folks wrap the RiveViewModel in another class and implement the delegates there (small example here: https://github.com/rive-app/rive-ios/blob/main/Example-iOS/Source/Examples/SwiftUI/SwiftEvents.swift#L42), but I could see why you want it separated.

SleepiestAdam commented 6 months ago

@zplata Any update on improvement of the SwiftUI implementation to negate some of these issues?

We're running into issues in instances where StateObject isn't an option (e.g. when trying to load a rive assets via a dynamic web url where the object needs creating as part of an initializer where StateObject isn't available).

As we approach our internal project completion in the coming 1-2 months going to soon be blocking us producing a production build.