Skip to content
charmotion

Rive in UIKit: RiveUIView and Legacy RiveView

By the Charmotion team · Updated

Cover illustration for the iOS & Swift guide: Rive in UIKit: RiveUIView and Legacy RiveView, with Charmotion demo characters

In UIKit, the new Rive runtime renders through RiveUIView: build a Rive object from a Worker, File, artboard and bound state machine, then assign it to riveView.rive and add the view with Auto Layout. The legacy runtime uses RiveViewModel(fileName:stateMachineName:) and createRiveView(), driven with setInput and triggerInput.

Key takeaways

  • RiveUIView (new runtime) is a Metal-backed UIView; its rive property can be set after the view is on screen.
  • RiveView (legacy) comes from RiveViewModel.createRiveView(); keep the view model as a property or the view stops responding.
  • Pause with isPaused and cap frames with frameRate = .fps(30) in the new runtime; legacy uses pause() and setPreferredFramesPerSecond.
  • Moods are a NumberProperty on a retained view model instance in the new runtime, and a number input in the legacy one.

How do I add a Rive animation to a UIViewController?

With the new runtime, create a RiveUIView up front with rive: nil, lay it out, and assign the Rive object when loading finishes. Setting rive again later swaps the graphic.

import UIKit
import RiveRuntime

final class WelcomeViewController: UIViewController {
    private let riveView = RiveUIView(rive: nil)
    private var character: ViewModelInstance?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        riveView.translatesAutoresizingMaskIntoConstraints = false
        riveView.isAccessibilityElement = true
        riveView.accessibilityLabel = "Animated mascot"
        view.addSubview(riveView)
        NSLayoutConstraint.activate([
            riveView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            riveView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            riveView.widthAnchor.constraint(equalToConstant: 240),
            riveView.heightAnchor.constraint(equalTo: riveView.widthAnchor),
        ])

        Task { await loadCharacter() }
    }

    private func loadCharacter() async {
        do {
            let worker = try await Worker()
            let file = try await File(source: .local("character", Bundle.main), worker: worker)
            let artboard = try await file.createArtboard()
            let instance = try await file.createViewModelInstance(
                .viewModelDefault(from: .artboardDefault(artboard))
            )
            let machine = try await artboard.createStateMachine(
                "CharacterViewModelMachine",
                binding: instance
            )
            character = instance
            riveView.rive = try await Rive(file: file, artboard: artboard, stateMachine: machine)
        } catch {
            print("Rive failed to load: \(error)")
        }
    }
}

A Task created in a UIViewController method inherits the main actor, which is what Rive's @MainActor APIs require. If you do not need the instance (a purely decorative loop), RiveUIView({ … }) also accepts an async closure that returns a Rive; Rive's docs show it with try await Rive(file: file), which binds the file's default instance automatically.

What is the difference between RiveUIView and the legacy RiveView?

Both are UIView subclasses in the same RiveRuntime module, but they belong to different runtimes and cannot be mixed for one graphic.

RiveUIView (new)RiveView (legacy)
Created withRiveUIView(rive:) or RiveUIView({ async closure })riveViewModel.createRiveView()
ControllerA Rive object plus your retained ViewModelInstanceThe RiveViewModel that created it
Changing stateData binding properties and triggerssetInput, triggerInput (and legacy data binding)
ThreadingWorker background thread, API on main actorMain thread
PauseisPaused = trueviewModel.pause()
Frame rateframeRate = .fps(30)setPreferredFramesPerSecond(preferredFramesPerSecond: 30)
Events, linear animationsNot supportedSupported

Pick by file: an input-only .riv needs the legacy view; a file with view models works with either, and Rive steers new work to RiveUIView.

How do I create a RiveView with RiveViewModel.createRiveView()?

In the legacy runtime, the view model owns the file and state machine, and createRiveView() returns a RiveView wired to it. Store the view model as a property: it is the object you send inputs to.

import UIKit
import RiveRuntime

final class InboxEmptyViewController: UIViewController {
    private let mascot = RiveViewModel(
        fileName: "character",
        stateMachineName: "CharacterStateMachine"
    )

    override func viewDidLoad() {
        super.viewDidLoad()
        let riveView = mascot.createRiveView()
        riveView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(riveView)
        NSLayoutConstraint.activate([
            riveView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            riveView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 48),
            riveView.widthAnchor.constraint(equalToConstant: 200),
            riveView.heightAnchor.constraint(equalToConstant: 200),
        ])
        mascot.setInput("mood", value: Float(8))   // sleeping: calm empty state
    }
}

fileName is the bundle resource name without .riv. The initializer also takes fit (default .contain), alignment, autoPlay and artboardName. A RiveViewModel drives one view; create one per visible copy of the character.

In a table or collection view header, create the view model with the cell or header object, not in cellForRowAt, so scrolling does not reload the file each time.

How do I drive moods with setInput on the legacy runtime?

RiveViewModel exposes typed overloads: setInput(_:value:) for Bool, Float and Double, and triggerInput(_:) for triggers. Wire them to your view controller's state changes:

extension InboxEmptyViewController {
    func refreshStarted() {
        mascot.setInput("mood", value: Float(5))     // loading
    }

    func refreshFinished(newItems: Int) {
        if newItems > 0 {
            mascot.triggerInput("success")
            mascot.setInput("mood", value: Float(1)) // happy
        } else {
            mascot.setInput("mood", value: Float(8)) // sleeping
        }
    }

    func refreshFailed() {
        mascot.triggerInput("error")
        mascot.setInput("mood", value: Float(4))     // sad
    }
}

Input names are case-sensitive and must match the state machine. Rive documents inputs as a legacy feature: they keep working in this runtime, but new files should expose view models instead. Charmotion files carry both, so the same .riv works here through CharacterStateMachine and in the new runtime through CharacterViewModelMachine; the generated {Name}View.swift and {Name}NewRuntimeView.swift files show each.

How do I set the mood from UIKit on the new runtime?

Keep the view model instance you bound during setup (the character property in the first example) and call its typed setters. They queue the change and return immediately; no play() call is needed.

extension WelcomeViewController {
    private static let mood = NumberProperty(path: "mood")

    func showListening() {
        character?.setValue(of: Self.mood, to: Float(6))
    }

    func celebrate() {
        character?.fire(trigger: TriggerProperty(path: "success"))
    }

    func observeTaps() {
        guard let character else { return }
        Task {
            for try await _ in character.stream(of: TriggerProperty(path: "yes")) {
                print("The user tapped yes inside the artboard")
            }
        }
    }
}

stream(of:) emits each time a trigger fires, including when the file fires it from a listener. Cancel the task when the controller goes away, for example by storing it and calling cancel() in viewDidDisappear.

How do I pause Rive or cap its frame rate in UIKit?

Pause off-screen or decorative animations to save battery, and when Reduce Motion is on.

// New runtime
riveView.isPaused = true
riveView.frameRate = .fps(30)
riveView.frameRate = .range(minimum: 30, maximum: 60, preferred: nil)
riveView.frameRate = .default

// Legacy runtime
mascot.pause()
mascot.play()
mascot.setPreferredFramesPerSecond(preferredFramesPerSecond: 30)

Rive notes that on versions earlier than iOS 15 a frame-rate range behaves like a fixed .fps value. A good default for a small mascot is to pause in viewDidDisappear and resume in viewWillAppear. To react to Reduce Motion changes while the screen is open, see Reduce Motion in SwiftUI and UIKit.

How do I migrate a UIKit screen from RiveViewModel to the new runtime?

Rive's migration guide maps each legacy call to a new one:

LegacyNew runtime
RiveViewModel(fileName:stateMachineName:)Worker() → File(source:worker:) → createArtboard() → createStateMachine(_:binding:) → Rive(file:artboard:stateMachine:)
createRiveView()RiveUIView(rive:)
viewModel.view() (SwiftUI)RiveUIViewRepresentable(rive:)
setInput("mood", value:)setValue(of: NumberProperty(path: "mood"), to:)
triggerInput("success")fire(trigger: TriggerProperty(path: "success"))
pause() / play()isPaused = true / false
setPreferredFramesPerSecond(preferredFramesPerSecond: 30)frameRate = .fps(30)
State-change delegateObserve a property with valueStream(of:)

Migrate one screen at a time: the two runtimes coexist in the same app and package. First make sure the file has view models (the editor's Convert Inputs to ViewModels helps), then swap the view, then the calls. The SwiftUI side of the same move is covered in Rive data binding in SwiftUI.

Show a Rive character in a UIKit view controller (new runtime)

  1. Add the view. Create RiveUIView(rive: nil), add it as a subview and constrain its size with Auto Layout.
  2. Load in a Task. From viewDidLoad, start Task { await loadCharacter() } so the async setup runs on the main actor.
  3. Bind a view model instance. Create the instance with file.createViewModelInstance, store it, and pass it to artboard.createStateMachine(_:binding:).
  4. Assign the Rive object. Set riveView.rive = try await Rive(file:artboard:stateMachine:).
  5. Drive and pause. Call setValue(of:to:) and fire(trigger:) on the stored instance; set isPaused when the screen is hidden.

Frequently asked questions

Why does my legacy RiveView stop responding to setInput?

Usually the RiveViewModel was a local variable and was released after viewDidLoad, or a second view model was created for the same view. Store the view model as a property of the view controller, create the view from it once, and send every input to that same object.

Can I use RiveUIView in a UITableViewCell?

Yes, but avoid loading the file in cellForRowAt. Load the File once, keep it in a shared model, and create a Rive object per visible cell, or show a static image in the list and a live RiveUIView only in the detail screen. Pause views that scroll off screen.

Does RiveUIView work on macOS and visionOS?

RiveUIView is an NSView on macOS and a UIView elsewhere, and the package declares macOS 13.1, Mac Catalyst 14, tvOS 16 and visionOS 1 alongside iOS 14. There is no watchOS platform in the package manifest, so Apple Watch apps need another approach.

Quick answers from this guide

Sources

Related guides