SwiftUI - setInput and triggerInput don't work in SubView when parent updated. Example Code Inc.
Opened this issue · 5 comments
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")
}
}
Any update on this / you guys been able to reproduce the issue internally?
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!
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)
}
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:
- You create
confetti
, theRiveViewModel
- You render the
confetti.view()
, and thus a new view is created andconfetti
holds a reference to this view (for controlling API purposes) - Rerender is triggered up the parent tree
- Subview recreates
confetti
(let's call itconfetti2
) - You render a new
confetti2.view()
, and thus a new view is created andconfetti2
holds a reference to this new view - The original
confetti.view
notices an update, so its UIViewRepresentable implementation ofupdateUIView
is called. Right now, we're not setting the newconfetti2.view
toconfetti.view
because that would essentially restart the animation/state machine - However, the original
confetti.view
remains face up, and any new use ofconfetti2.triggerInput()
or any API for that matter, would not affect the view on screen (the original view)
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: #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.
@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.