Skip to content
charmotion

Rive Data Binding in SwiftUI (New Apple Runtime)

By the Charmotion team · Updated

Cover illustration for the iOS & Swift guide: Rive Data Binding in SwiftUI (New Apple Runtime), with Charmotion demo characters

To use Rive data binding in SwiftUI, create a Worker and File, make a view model instance with file.createViewModelInstance, bind it with artboard.createStateMachine(name, binding: instance), and render Rive(file:artboard:stateMachine:) through RiveUIViewRepresentable(rive:). Keep the instance, then call setValue(of:to:) and fire(trigger:) on it.

Key takeaways

  • The new Apple runtime arrived in rive-ios 6.24.0 and uses Swift Concurrency; every call is async and runs on the main actor.
  • It has no state machine inputs. Numbers, booleans and triggers live in a view model you bind explicitly.
  • Retain the ViewModelInstance yourself: the state machine has no getter for bound instances.
  • NumberProperty.Value is Float, so convert an Int mood with Float(mood).
  • Do not use the deprecated Rive(file:…dataBind:) initializer for new code.

What changed in the new Rive Apple runtime?

Rive's Apple package (RiveRuntime, from rive-app/rive-ios) now contains two APIs. The legacy runtime is built around RiveViewModel and is, in Rive's words, "currently single-threaded on the main thread." The new runtime, introduced in version 6.24.0, is "a Swift-first API leveraging Swift Concurrency, with improved multi-threading support." At the time of writing the latest release is 6.28.1 (October 8, 2026); 6.28.0 added global view models and blob data binding.

Three differences matter in practice:

  • Everything is asynchronous. Creating a worker, a file, an artboard, a state machine and the Rive object all use try await. Rive still requires the calls to be made on the main actor; the heavy work happens on the worker's background thread.
  • No inputs, events or linear animations. The migration guide lists state machine inputs and event listeners as legacy-only, with "no current plans to reintroduce" them. Linear animations must be wrapped in a state machine.
  • Data binding is the contract. Your code talks to a view model instance through typed properties (NumberProperty, TriggerProperty and so on) addressed by path.

If your .riv only exposes inputs, either stay on the legacy runtime (see Rive in SwiftUI) or use the editor's Menu → Convert Inputs to ViewModels first.

How do Worker, File, Artboard and Rive fit together?

The new runtime splits what RiveViewModel used to do into five objects. Each one is created from the previous one:

ObjectCreated withJob
Workertry await Worker()Owns the background thread and shared out-of-band assets (images, fonts, audio).
Filetry await File(source:worker:)The parsed .riv. Sources are .local(name, bundle), .url(URL) or .data(Data).
Artboardtry await file.createArtboard()The canvas to draw; pass a name for a non-default artboard.
StateMachinetry await artboard.createStateMachine(name, binding: instance)Runs the logic and reads the bound view model instance.
Rivetry await Rive(file:artboard:stateMachine:)The view configuration (fit, background) handed to the view.

Rive's docs say a Worker "must be alive for the duration of Rive usage" and that a File keeps a strong reference to its worker. One worker roughly equals one background thread; for several heavy graphics you can give each file its own worker, or share one worker so files share registered assets.

Note the initializer: the source code exposes Worker() and Worker(configuration:). Some migration snippets show Worker.shared(), which is an app-level helper in Rive's example, not part of RiveRuntime.

How do I create a view model instance and bind it to a state machine?

Create the instance from the file, then pass it to createStateMachine(_:binding:). .viewModelDefault(from: .artboardDefault(artboard)) means "the default instance of the view model assigned to this artboard." Other sources are .blank(from:) and .name(_:from:) for a named instance, and ViewModelSource.name("Character") selects a view model by name.

Put the setup in an observable model so the instance outlives the view body:

import SwiftUI
import RiveRuntime

@MainActor
final class CharacterModel: ObservableObject {
    @Published private(set) var rive: Rive?
    private var instance: ViewModelInstance?

    private let mood = NumberProperty(path: "mood")

    func load() async {
        guard rive == nil else { return }
        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 stateMachine = try await artboard.createStateMachine(
                "CharacterViewModelMachine",
                binding: instance
            )
            self.instance = instance
            rive = try await Rive(file: file, artboard: artboard, stateMachine: stateMachine)
        } catch {
            print("Rive setup failed: \(error)")
        }
    }

    func setMood(_ value: Int) {
        instance?.setValue(of: mood, to: Float(value))
    }

    func fire(_ trigger: String) {
        instance?.fire(trigger: TriggerProperty(path: trigger))
    }
}

struct CharacterView: View {
    @StateObject private var model = CharacterModel()

    var body: some View {
        RiveUIViewRepresentable(rive: model.rive)
            .frame(width: 240, height: 240)
            .accessibilityLabel("Animated character")
            .task { await model.load() }
    }
}

RiveUIViewRepresentable takes an optional Rive, so the view can appear before loading finishes. The initializer is labeled (rive:) in the source, even though some doc snippets omit the label. If you prefer to keep loading inside the view, AsyncRiveUIViewRepresentable { … } takes an async closure that returns a Rive; you then need another way to keep the instance.

Set initial values before the view is presented when you can. Rive's migration guide notes this avoids a frame showing the file's defaults.

How do I set the mood number and fire triggers from SwiftUI?

Setting a property queues the change and returns immediately; unlike the legacy runtime, you do not call play() afterwards. Triggers have no value, so you fire them:

struct UploadScreen: View {
    @StateObject private var character = CharacterModel()

    var body: some View {
        VStack(spacing: 24) {
            RiveUIViewRepresentable(rive: character.rive)
                .frame(width: 220, height: 220)
            Button("Upload") { Task { await upload() } }
        }
        .task { await character.load() }
    }

    private func upload() async {
        character.setMood(5)                       // loading loop
        let ok = await performUpload()
        character.fire(ok ? "success" : "error")   // one-shot reaction
        character.setMood(0)                       // back to idle
    }
}

Because the state machine owns transitions, your code only says what the app is doing. A well-built file lets a trigger's one-shot finish and then returns to whichever loop mood currently selects.

Charmotion files use exactly this contract: a Character view model on CharacterViewModelMachine, with these values. Each set also ships a ready-made {Name}NewRuntimeView.swift for this runtime and a {Name}View.swift for the legacy RiveViewModel.

moodLoopmoodLoop
0idle5loading
1happy6listening
2wave7talking
3thinking8sleeping
4sad9attention

Triggers: success, error, yes, no, enter, exit.

Ten looping states of one character, each selected by a mood number from 0 to 9
The ten looping states in one Charmotion Rive file, selected with the mood number input.

How do I observe a property change coming back from Rive?

Data binding is two-way: the state machine can change a property, for example when the user taps a hit area in the artboard. String, number, boolean, color and enum properties expose valueStream(of:); triggers expose stream(of:), which emits Void each time the trigger fires. Reading once uses try await instance.value(of:).

extension CharacterModel {
    func watchMood() async {
        guard let instance else { return }
        do {
            for try await value in instance.valueStream(of: NumberProperty(path: "mood")) {
                print("mood is now \(Int(value))")
            }
        } catch {
            print("Stream ended: \(error)")
        }
    }

    func waitForSuccess() async throws {
        guard let instance else { return }
        for try await _ in instance.stream(of: TriggerProperty(path: "success")) {
            break   // first fire is enough
        }
    }
}

Start these loops from .task so SwiftUI cancels them when the view disappears. Rive's docs say the thrown error is a ViewModelInstanceError. You do not need the deprecated dirtyStream() to trigger redraws; the controller redraws after changes to bound instances on its own.

The new runtime has no state-name callback like the legacy RiveStateDelegate. If your app needs to know "the intro finished," model it as a trigger or string property in the file and observe that.

Why is my binding not updating?

Most "nothing happens" reports come down to one of these:

  1. The instance was not retained. If the ViewModelInstance is a local variable in a function, it is gone once setup returns. Rive's docs: "Retain references to any supplied instances you need to read or modify after binding. Bound instances cannot be retrieved through the state machine."
  2. You bound one instance and set another. Creating a second instance with createViewModelInstance gives you an unbound copy. Setting values on it changes nothing on screen.
  3. You used Rive(file:) and then looked for the instance. The convenience initializers bind the authored default instance, which you cannot retrieve; the deprecated rive.viewModelInstance getter returns nil with the current initializers.
  4. Wrong path or type. Paths are case-sensitive and nested properties use slashes ("settings/volume"). A number in the file must be read with NumberProperty, not StringProperty.
  5. Wrong state machine name. createStateMachine throws ArtboardError.invalidStateMachine if the name does not exist. Do not swallow that error silently.
  6. Calls off the main actor. The APIs are @MainActor. Mark your model @MainActor, as above, and the compiler enforces it.

To inspect an unknown file, file.getViewModelNames(), file.getProperties(of:) and file.getDefaultViewModelInfo(for:) return what the file actually contains.

Which platforms does the new runtime support?

Both runtimes ship in the same package, so they share its minimum versions. The Package.swift manifest declares:

platforms: [.iOS("14.0"), .visionOS("1.0"), .tvOS("16.0"), .macOS("13.1"), .macCatalyst("14.0")]

There is no watchOS entry, so treat Apple Watch as unsupported even though one migration page summary has suggested otherwise. See character animation on watchOS and visionOS for alternatives.

The view type is RiveUIView, a Metal-backed UIView (an NSView on macOS). RiveUIViewRepresentable wraps it for SwiftUI and adds modifiers: .paused(_:), .frameRate(_:) (.default, .fps(30) or a .range) and .semantics(_:) for VoiceOver semantics authored in the editor, which only the new runtime supports.

Should I still use RiveViewModel and state machine inputs?

Use the legacy runtime when you have a file that only has inputs and you cannot change it, when you rely on Rive events, or when you target a deployment path you have already shipped and tested. It still ships, and setInput and triggerInput still work there.

For new work, Rive positions the new runtime as the direction. A practical migration path:

  1. Convert inputs to view models in the editor, keeping the same names (mood, success).
  2. Replace RiveViewModel(fileName:stateMachineName:) with the Worker → File → Artboard → StateMachine → Rive chain.
  3. Replace viewModel.view() with RiveUIViewRepresentable(rive:).
  4. Replace setInput("mood", value:) with setValue(of: NumberProperty(path: "mood"), to:) and triggerInput with fire(trigger:).
  5. Replace pause() with .paused(true) and setPreferredFramesPerSecond with .frameRate(.fps(30)).

UIKit screens follow the same mapping with RiveUIView; see Rive in UIKit.

Bind a Rive view model in SwiftUI

  1. Add the package. Add https://github.com/rive-app/rive-ios with Swift Package Manager (6.24.0 or later) and bundle your .riv in the app target.
  2. Load the file. In a @MainActor model, create try await Worker() and File(source: .local("character", Bundle.main), worker:).
  3. Create and retain an instance. Call file.createViewModelInstance(.viewModelDefault(from: .artboardDefault(artboard))) and store the result in a property.
  4. Bind it. Create the state machine with artboard.createStateMachine("CharacterViewModelMachine", binding: instance), then Rive(file:artboard:stateMachine:).
  5. Render. Place RiveUIViewRepresentable(rive: model.rive) with a frame and load it from .task.
  6. Drive it. Call setValue(of: NumberProperty(path: "mood"), to: Float(5)) for loops and fire(trigger: TriggerProperty(path: "success")) for one-shots.

Frequently asked questions

Does the new Rive Apple runtime support setInput?

No. State machine inputs exist only in the legacy runtime, and Rive says there are no plans to bring them back. Convert inputs to view model properties in the editor, keep the same names, and use NumberProperty, BoolProperty and TriggerProperty on a bound view model instance instead.

Why does my NumberProperty setValue not compile with an Int?

NumberProperty's value type is Float. Passing an Int variable fails type checking, so convert it: instance.setValue(of: NumberProperty(path: "mood"), to: Float(mood)). A literal such as 5 works because Swift infers it as Float. Reading the value back with value(of:) or valueStream(of:) also returns a Float, so convert with Int(value) when you map it to an enum.

Is Rive(file:artboard:stateMachine:dataBind:) still okay to use?

It compiles but is deprecated, and the source says it will be removed in a future release. Create and retain the view model instance yourself, bind it with artboard.createStateMachine(name, binding: instance), and then use the Rive initializer without dataBind. That also gives you the instance reference you need for setValue and fire.

Can I use one Worker for several Rive files?

Yes. Files created with the same Worker share its background thread and any out-of-band assets registered on it. Rive suggests separate workers when several heavy graphics should each get their own thread. Either way, keep the worker alive as long as its files are in use.

Quick answers from this guide

Sources

Related guides