-
Notifications
You must be signed in to change notification settings - Fork 98
Description
Description
When a RiveView is inside a UIScrollView and its animation settles (state machine stops advancing) while the view is off-screen, the view permanently displays stale cached layer content after scrolling back into view.
Root cause (confirmed via instrumented logs):
When isPlaying becomes false, stopTimer() is called and the CADisplayLink tick loop stops. From that point, nobody calls setNeedsDisplay() on the view. When the view scrolls back into the visible area, Core Animation composites the layer's stale backing store onto screen without calling draw(), because the layer was never marked dirty again.
This was confirmed by instrumenting draw() with timestamps: there is a gap of ~10 seconds with zero draw() calls on the affected view while it is displayed on screen showing incorrect content.
Why draw() is never called:
draw() is only called when setNeedsDisplay() has been invoked since the last draw. After the animation settles off-screen:
- The last
setNeedsDisplay()is consumed by adraw()call that returns early becauseisOnscreen() == false isPlaying == false→ timer stopped → no furthersetNeedsDisplay()calls- Scrolling in
UIScrollViewchangesbounds.originonly — this does not trigger layout orsetNeedsDisplay()on subviews
Evidence from logs:
- Instrumented
draw()confirmed zero calls during the stuck period - Instrumented
redrawIfNecessary()withThread.callStackSymbolsshowed the onlysetNeedsDisplay()calls during the stuck period came fromviewDidLayoutSubviews→frame.didSet→redrawIfNecessary(), triggered by aUILabel.textchange in the host app's navigation UI — not by anything inRiveView - Overriding
setNeedsLayout()on the root view confirmed this: blocking the label text change eliminated allsetNeedsDisplay()calls on the offscreenRiveView, and the view remained permanently stuck
Provide a Repro
Steps:
- Tap "Tap to focus" on Item 1 → animation plays and settles
- Press "Skip +2" → Item 1 is now 2 pages off-screen
- Wait for the off-screen animations to settle
- Press "Skip -2" → Item 1 scrolls back into view
Expected: Item 1 shows the focused (orange) state
Actual: Item 1 shows the unfocused (default/gray) state
Minimal reproduction (Swift / UIKit)
import UIKit
import RiveRuntime
// MARK: - Per-item ViewModel
private class ItemViewModel: RiveViewModel {
private(set) var instance: RiveDataBindingViewModel.Instance?
private(set) var isFocused: Bool = false
private let label: String
init(label: String) {
self.label = label
super.init(fileName: "focused_change_anim")
riveModel?.enableAutoBind { [weak self] inst in
guard let self else { return }
self.instance = inst
inst.stringProperty(fromPath: "label")?.value = self.label
inst.booleanProperty(fromPath: "focused")?.value = self.isFocused
}
}
func setFocused(_ focused: Bool) {
isFocused = focused
instance?.booleanProperty(fromPath: "focused")?.value = focused
play()
}
}
// MARK: - View Controller
class ScrollMinimalViewController: UIViewController {
private let itemCount = 6
private let skipDistance = 2
private var viewModels: [ItemViewModel] = []
private var riveViews: [RiveView] = []
private var containers: [UIView] = []
private var focusedIndex: Int? = nil
private let scrollView: UIScrollView = {
let sv = UIScrollView()
sv.isPagingEnabled = true
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
private let pageLabel: UILabel = {
let l = UILabel()
l.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
l.textAlignment = .center
return l
}()
private let backButton: UIButton = {
let b = UIButton(type: .system)
b.setTitle("← Skip -2", for: .normal)
return b
}()
private let forwardButton: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Skip +2 →", for: .normal)
return b
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
buildUI()
buildItems()
updatePageLabel()
}
private func buildUI() {
backButton.addTarget(self, action: #selector(skipBack), for: .touchUpInside)
forwardButton.addTarget(self, action: #selector(skipForward), for: .touchUpInside)
let navRow = UIStackView(arrangedSubviews: [backButton, pageLabel, forwardButton])
navRow.axis = .horizontal
navRow.distribution = .equalSpacing
navRow.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(navRow)
view.addSubview(scrollView)
scrollView.delegate = self
NSLayoutConstraint.activate([
navRow.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
navRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
navRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
scrollView.topAnchor.constraint(equalTo: navRow.bottomAnchor, constant: 6),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
private func buildItems() {
for i in 0..<itemCount {
let vm = ItemViewModel(label: "Item \(i + 1)")
viewModels.append(vm)
let rv = vm.createRiveView()
rv.offscreenBehavior = .playAndNoDraw // default
riveViews.append(rv)
let container = UIView()
container.clipsToBounds = true
container.addSubview(rv)
let btn = UIButton(type: .system)
btn.tag = 200 + i
btn.setTitle("Tap to focus", for: .normal)
btn.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.75)
btn.layer.cornerRadius = 12
btn.addTarget(self, action: #selector(focusTapped(_:)), for: .touchUpInside)
container.addSubview(btn)
scrollView.addSubview(container)
containers.append(container)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let w = scrollView.bounds.width
let h = scrollView.bounds.height
guard w > 0, h > 0 else { return }
for (i, container) in containers.enumerated() {
container.frame = CGRect(x: CGFloat(i) * w, y: 0, width: w, height: h)
riveViews[i].frame = container.bounds
containers[i].viewWithTag(200 + i)?.frame = CGRect(x: 24, y: h - 72, width: w - 48, height: 52)
}
scrollView.contentSize = CGSize(width: w * CGFloat(itemCount), height: h)
}
@objc private func skipForward() {
let clamped = min(itemCount - 1, currentPage() + skipDistance)
scrollView.setContentOffset(CGPoint(x: CGFloat(clamped) * scrollView.bounds.width, y: 0), animated: true)
updatePageLabel(page: clamped)
}
@objc private func skipBack() {
let clamped = max(0, currentPage() - skipDistance)
scrollView.setContentOffset(CGPoint(x: CGFloat(clamped) * scrollView.bounds.width, y: 0), animated: true)
updatePageLabel(page: clamped)
}
private func currentPage() -> Int {
let w = scrollView.bounds.width
guard w > 0 else { return 0 }
return max(0, min(itemCount - 1, Int(round(scrollView.contentOffset.x / w))))
}
private func updatePageLabel(page: Int? = nil) {
let p = page ?? currentPage()
pageLabel.text = "Page \(p + 1) / \(itemCount)"
backButton.isEnabled = p >= skipDistance
forwardButton.isEnabled = p + skipDistance < itemCount
}
@objc private func focusTapped(_ sender: UIButton) {
let i = sender.tag - 200
if let prev = focusedIndex, prev != i {
viewModels[prev].setFocused(false)
}
let newFocused = focusedIndex != i
viewModels[i].setFocused(newFocused)
focusedIndex = newFocused ? i : nil
}
}
extension ScrollMinimalViewController: UIScrollViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { updatePageLabel() }
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { updatePageLabel() }
}Source .riv file
https://rive.app/community/files/26737-50222-toggle-state
Expected behavior
When a RiveView scrolls back into the visible area after having been off-screen while isPlaying == false, it should display the current settled state of the artboard, not the last content that was rendered while it was on-screen.
Device & Versions
- Device: iPhone 16 Pro Simulator
- iOS version: iOS 18
Additional context
The issue affects any use case where:
- A
RiveViewusesoffscreenBehavior = .playAndNoDraw(the default) - The animation settles (
isPlayingbecomesfalse) while the view is off-screen - The view later becomes visible again without any intervening layout pass on the
RiveView
The issue is also reproducible when a data-binding property is changed on an off-screen view (e.g. unfocusing item 1 while viewing item 3) — the animation runs entirely off-screen, settles, and the stale content remains visible on return.