Skip to content

RiveView shows stale layer content after scrolling off-screen and back when animation has settled #431

@mfazekas

Description

@mfazekas

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:

  1. The last setNeedsDisplay() is consumed by a draw() call that returns early because isOnscreen() == false
  2. isPlaying == false → timer stopped → no further setNeedsDisplay() calls
  3. Scrolling in UIScrollView changes bounds.origin only — this does not trigger layout or setNeedsDisplay() on subviews

Evidence from logs:

  • Instrumented draw() confirmed zero calls during the stuck period
  • Instrumented redrawIfNecessary() with Thread.callStackSymbols showed the only setNeedsDisplay() calls during the stuck period came from viewDidLayoutSubviewsframe.didSetredrawIfNecessary(), triggered by a UILabel.text change in the host app's navigation UI — not by anything in RiveView
  • Overriding setNeedsLayout() on the root view confirmed this: blocking the label text change eliminated all setNeedsDisplay() calls on the offscreen RiveView, and the view remained permanently stuck

Provide a Repro

Steps:

  1. Tap "Tap to focus" on Item 1 → animation plays and settles
  2. Press "Skip +2" → Item 1 is now 2 pages off-screen
  3. Wait for the off-screen animations to settle
  4. 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 RiveView uses offscreenBehavior = .playAndNoDraw (the default)
  • The animation settles (isPlaying becomes false) 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions