Skip to content

[rive_native] animations are not played and data binding updates values with one step delay #543

@orestesgaolin

Description

@orestesgaolin

Steps To Reproduce

  1. Run the example below with the rev/riv file provided to [email protected] using rive_native: ^0.0.11
    1. RivePlayer is taken from rive_native example
  2. Observe that animation does not work as expected (see video for reference)
  3. Change value e.g. Points
  4. Observe that changing properties via data binding is lagged by one step e.g. if I change Points from 10 to 20 the value in the animation stays at 10, only after I change it to 30 its value changes to 20 in the animation, and so on; this applies to all properties
  5. Additionally the values are not interpolated between same way as they are in the Rive editor
Source Code
// ignore_for_file: avoid_print, unused_field

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:rive_native/rive_native.dart' as rive;

class PacingArcPage extends StatefulWidget {
  const PacingArcPage({super.key});

  static Route<void> get route {
    return CupertinoPageRoute<void>(
      builder: (_) => const PacingArcPage(),
    );
  }

  @override
  State<PacingArcPage> createState() => _PacingArcPageState();
}

class _PacingArcPageState extends State<PacingArcPage> {
  bool showGuideLines = false;

  /// Total budget in pacepoints
  int totalBudget = 15;

  /// Current pacepoints
  double currentPacePoints = 6.4;

  /// Position of the PaceSetter in % of day complete
  int paceSetterPosition = 60;

  /// Show/hide the start and end times
  bool showTimes = true;

  /// Show/hide the PaceSetter
  bool showPaceSetter = true;

  /// Show/hide the budget
  bool showBudget = true;

  /// Show/hide the current pacepoints and pacepoints arc
  bool showPacePoints = true;

  final List<String> properties = [];

  @override
  Widget build(BuildContext context) {
    const amendedWidth = 273.0;
    const amendedHeight = amendedWidth * 0.606;

    return  Scaffold(

      body: Column(
        children: [

          Center(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: SizedBox(
                width: amendedWidth,
                height: amendedHeight,
                child: Stack(
                  alignment: Alignment.bottomCenter,
                  children: [
                    if (showGuideLines)
                      Container(
                        height: 24,
                        width: 254,
                        decoration: BoxDecoration(
                          border: Border.all(color: VisibleColors.surfaceSuccess, width: 1),
                        ),
                        child: Center(child: Text('<-  254px. ->', style: VisibleTextStyle.body5.onDark)),
                      ),
                    PacePointsDial(
                      pacePoints: showPacePoints ? currentPacePoints : null,
                      pacingBudget: showBudget ? totalBudget : null,
                      paceSetterFraction: showPaceSetter ? paceSetterPosition / 100 : null,
                      state: PacePointsDialState.currentDay,
                      paceSetterStartTime: showTimes ? '07:00' : null,
                      paceSetterEndTime: showTimes ? '21:00' : null,
                    ),
  
                  ],
                ),
              ),
            ),
          ),
          Expanded(
            child: ListView(
              children: [
                Row(
                  children: [
                    ShowingIndicator(
                      label: 'PP',
                      isShowing: showPacePoints,
                      onPressed: () {
                        setState(() {
                          showPacePoints = !showPacePoints;
                        });

                      },
                    ),
                    Expanded(
                      child: SliderWithNumberInside(
                        label: 'Points',
                        number: currentPacePoints,
                        max: 150,
                        onChanged: (v) {
                          setState(() {
                            currentPacePoints = v;
                          });
                          WidgetsBinding.instance.addPostFrameCallback((_) {
                            setState(() {});
                          });
                        },
                      ),
                    ),
                  ],
                ),
                Row(
                  children: [
                    ShowingIndicator(
                      label: 'PS',
                      isShowing: showPaceSetter,
                      onPressed: () {
                        // showPaceSetterBool?.value = !showPaceSetterBool!.value;
                        // paceSetterPositionProp?.value = paceSetterPosition.toDouble();
                        setState(() {
                          showPaceSetter = !showPaceSetter;
                        });
                        setState(() {});
                      },
                    ),
                    Expanded(
                      child: SliderWithNumberInside(
                        label: 'PaceSetter',
                        number: paceSetterPosition.toDouble(),
                        max: 100,
                        onChanged: (v) {
                          setState(() {
                            paceSetterPosition = v.toInt();
                          });
                          setState(() {});
                        },
                      ),
                    ),
                  ],
                ),
                Row(
                  children: [
                    ShowingIndicator(
                      label: 'BG',
                      isShowing: showBudget,
                      onPressed: () {
                        // budgetBool?.value = !budgetBool!.value;
                        setState(() {
                          showBudget = !showBudget;
                        });
                      },
                    ),
                    Expanded(
                      child: SliderWithNumberInside(
                        label: 'Budget',
                        number: totalBudget.toDouble(),
                        max: 100,
                        onChanged: (v) {
                          setState(() {
                            totalBudget = v.toInt();
                          });
                          WidgetsBinding.instance.addPostFrameCallback((_) {
                            setState(() {});
                          });
                        },
                      ),
                    ),
                  ],
                ),

                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Wrap(
                        spacing: 8,
                        runSpacing: 8,
                        children: [
          
                          ShowingIndicator(
                            label: 'Times',
                            isShowing: showTimes,
                            onPressed: () {
                              showTimes = !showTimes;
                      
                              setState(() {});
                            },
                          ),
                        ],
                      ),
                    ],
                  ),
                ),

      
              ],
            ),
          ),
        ],
      ),
    );
  }
}

enum PacePointsDialState {
  currentDay,
  pendingScore,
  previousDay,
}

class PacePointsDial extends StatefulWidget {
  const PacePointsDial({
    super.key,
    required this.pacePoints,
    required this.state,
    this.pacingBudget,
    this.paceSetterFraction,
    this.paceSetterStartTime,
    this.paceSetterEndTime,
  });

  /// Passing null shows two dashes - - instead of PacePoints
  final double? pacePoints;

  /// Passing this value shows the budget
  final int? pacingBudget;

  /// Passing this value shows the pace setter
  final double? paceSetterFraction;

  final String? paceSetterStartTime;

  final String? paceSetterEndTime;

  final PacePointsDialState state;

  @override
  State<PacePointsDial> createState() => _PacePointsDialState();
}

class _PacePointsDialState extends State<PacePointsDial> {
  late rive.ViewModelInstance vm;

  // Public properties
  late rive.ViewModelInstanceBoolean? arcBool;
  late rive.ViewModelInstanceTrigger? showTrigger;
  late rive.ViewModelInstanceTrigger? hideTrigger;
  late rive.ViewModelInstanceTrigger? pendingStateTrigger;

  late rive.ViewModelInstanceNumber? paceSetterPositionProp;
  late rive.ViewModelInstanceTrigger? animationPendingScoreTrigger;
  late rive.ViewModelInstanceTrigger? animationPendingToLiveTrigger;
  late rive.ViewModelInstanceTrigger? animationIntroTrigger;
  late rive.ViewModelInstanceTrigger? animationPreviousDayOutTransition;
  late rive.ViewModelInstanceTrigger? animationPreviousDayInTransition;

  late rive.ViewModelInstanceBoolean? budgetBool;
  late rive.ViewModelInstanceNumber? totalBudgetProp;
  late rive.ViewModelInstanceBoolean? showPacepointsBool;
  late rive.ViewModelInstanceNumber? pacePointsNumberProp;

  late rive.ViewModelInstanceBoolean? showStartEndTimesBool;
  late rive.ViewModelInstanceBoolean? showPaceSetterBool;
  late rive.ViewModelInstanceString? paceSetterStartTimeProp;
  late rive.ViewModelInstanceString? paceSetterEndTimeProp;

  late rive.ViewModelInstanceColor? gradientStartColor;
  late rive.ViewModelInstanceColor? gradientStopColor;

  // Private properties (not meant to be accessed directly)
  late rive.ViewModelInstanceNumber? _pacePointsProgressProp;
  late rive.ViewModelInstanceTrigger? _pastRedTrigger;
  late rive.ViewModelInstanceTrigger? _colorRedTrigger;
  late rive.ViewModelInstanceTrigger? _colorGreenTrigger;
  late rive.ViewModelInstanceTrigger? _pastGreenTrigger;
  late rive.ViewModelInstanceNumber? _endXProp;
  late rive.ViewModelInstanceNumber? _startXProp;
  late rive.ViewModelInstanceNumber? _arcDelay;
  late rive.ViewModelInstanceNumber? _pacerDelay;

  final List<String> properties = [];
  void _initialState() async {
    hideTrigger?.trigger();

    // gradientStartColor?.value = VisibleColors.surfaceOnDarkDefault.withSafeOpacity(0);
    // gradientStopColor?.value = VisibleColors.surfaceOnDarkDefault.withSafeOpacity(1);
    await Future.delayed(1500.ms);
    animationIntroTrigger?.trigger();
    _updateStartEndTimes();
    _updatePacePoints();
    _updatePacingBudget();
    _updatePaceSetterFraction();
  }

  void _updatePaceSetterFraction() {
    if (widget.paceSetterFraction != null) {
      showPaceSetterBool?.value = true;
      paceSetterPositionProp?.value = (widget.paceSetterFraction! * 100).clamp(0.0, 100.0);
    } else {
      showPaceSetterBool?.value = false;
      paceSetterPositionProp?.value = 0;
    }
  }

  void _updatePacingBudget() {
    if (widget.pacingBudget != null) {
      budgetBool?.value = true;
      totalBudgetProp?.value = widget.pacingBudget!.toDouble();
    } else {
      budgetBool?.value = false;
      totalBudgetProp?.value = 0;
    }
  }

  void _updatePacePoints() {
    if (widget.pacePoints != null) {
      showPacepointsBool?.value = true;
      pacePointsNumberProp?.value = widget.pacePoints!;
      arcBool?.value = true;
    } else {
      showPacepointsBool?.value = false;
      pacePointsNumberProp?.value = 0;
      arcBool?.value = false;
    }
  }

  void _updateStartEndTimes() {
    if (widget.paceSetterStartTime != null && widget.paceSetterEndTime != null) {
      showStartEndTimesBool?.value = true;
      paceSetterStartTimeProp?.value = widget.paceSetterStartTime!;
      paceSetterEndTimeProp?.value = widget.paceSetterEndTime!;
    } else {
      showStartEndTimesBool?.value = false;
      paceSetterStartTimeProp?.value = '';
      paceSetterEndTimeProp?.value = '';
    }
  }

  @override
  void didUpdateWidget(covariant PacePointsDial oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.pacePoints != oldWidget.pacePoints) {
      _updatePacePoints();
    }
    if (widget.paceSetterStartTime != oldWidget.paceSetterStartTime ||
        widget.paceSetterEndTime != oldWidget.paceSetterEndTime) {
      _updateStartEndTimes();
    }
    if (widget.pacingBudget != oldWidget.pacingBudget) {
      _updatePacingBudget();
    }
    if (widget.paceSetterFraction != oldWidget.paceSetterFraction) {
      _updatePaceSetterFraction();
    }
  }

  @override
  Widget build(BuildContext context) {
    return RivePlayer(
      asset: Assets.animations.pacingArc,
      autoBind: true,
      artboardName: 'main',
      fit: rive.Fit.fitWidth,
      withViewModelInstance: (viewModelInstance) {
        vm = viewModelInstance;
        for (final property in viewModelInstance.properties) {
          properties.add('${property.name} | ${property.type}');
        }

        // Public properties
        arcBool = vm.boolean('arc');
        arcBool?.value = true;
        showTrigger = vm.trigger('show');
        hideTrigger = vm.trigger('hide');
        hideTrigger?.trigger();

        pendingStateTrigger = vm.trigger('pendingState');
        animationPreviousDayOutTransition = vm.trigger('animationPreviousDayOutTransition');
        animationPreviousDayInTransition = vm.trigger('animationPreviousDayInTransition');

        animationPendingScoreTrigger = vm.trigger('animationPendingScore');
        animationPendingToLiveTrigger = vm.trigger('animationPendingToLive');
        animationIntroTrigger = vm.trigger('animationIntro');

        paceSetterPositionProp = vm.number('paceSetterPosition');
        paceSetterPositionProp?.value = 0;
        budgetBool = vm.boolean('budget');
        budgetBool?.value = false;
        pacePointsNumberProp = vm.number('pacePoints');
        pacePointsNumberProp?.value = 0;
        showPacepointsBool = vm.boolean('showPacePoints');
        showPacepointsBool?.value = false;
        totalBudgetProp = vm.number('totalBudget');
        totalBudgetProp?.value = 0;

        showStartEndTimesBool = vm.boolean('showStartEndTimes');
        showPaceSetterBool = vm.boolean('showPaceSetter');
        paceSetterEndTimeProp = vm.string('paceSetterEndTime');
        paceSetterStartTimeProp = vm.string('paceSetterStartTime');

        gradientStartColor = vm.color('gradientStart');
        gradientStopColor = vm.color('gradientStop');
        // gradientStartColor?.value = VisibleColors.surfaceOnDarkDefault.withSafeOpacity(0);
        // gradientStopColor?.value = VisibleColors.surfaceOnDarkDefault.withSafeOpacity(1);

        // Private properties
        _pacePointsProgressProp = vm.number('_pacePointsProgress');
        _pastRedTrigger = vm.trigger('_pastRed');
        _colorRedTrigger = vm.trigger('_colorRed');
        _colorGreenTrigger = vm.trigger('_colorGreen');
        _pastGreenTrigger = vm.trigger('_pastGreen');
        _endXProp = vm.number('_endX');
        _startXProp = vm.number('_startX');
        _arcDelay = vm.number('_arcDelay');
        _pacerDelay = vm.number('_pacerDelay');

        _initialState();

        // Set some defaults
        // WidgetsBinding.instance.addPostFrameCallback((_) {
        //   // _initialState();
        // });
      },
    );
  }
}

class SliderWithNumberInside extends StatelessWidget {
  const SliderWithNumberInside({
    super.key,
    required this.label,
    required this.number,
    required this.max,
    this.onChanged,
  });

  final String label;
  final double number;
  final double max;
  final void Function(double)? onChanged;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: LayoutBuilder(
        builder: (context, constraints) {
          return GestureDetector(
            onTapDown: (details) {
              // final deta = details.localPosition.dx / constraints.maxWidth;
              // final newValue = (deta * max).clamp(0.0, max);

              // onChanged?.call(newValue);
              HapticFeedback.lightImpact();
            },
            onHorizontalDragUpdate: (details) {
              // HapticFeedback.lightImpact();
              // final deta = details.localPosition.dx / constraints.maxWidth;
              // final newValue = (deta * max).clamp(0.0, max);

              // onChanged?.call(newValue);
            },

            onHorizontalDragEnd: (details) {
              final deta = details.localPosition.dx / constraints.maxWidth;
              final newValue = (deta * max).clamp(0.0, max);

              onChanged?.call(newValue);
              print('Drag end: $newValue');
              HapticFeedback.lightImpact();
            },
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 8),
              child: Container(
                width: double.infinity,
                height: 24,
                decoration: ShapeDecoration(
                  color: Colors.grey[300],
                  shape: RoundedSuperellipseBorder(
                    borderRadius: BorderRadius.circular(20),
                  ),
                ),
                child: Stack(
                  children: [
                    Positioned.fill(
                      child: FractionallySizedBox(
                        alignment: Alignment.centerLeft,
                        widthFactor: (number / max).clamp(0.0, 1.0),
                        child: Container(
                          decoration: BoxDecoration(
                            color: Colors.blue,
                            borderRadius: BorderRadius.circular(20),
                          ),
                        ),
                      ),
                    ),
                    Center(
                      child: Text(
                        '$label ${number.toStringAsFixed(1)}',
                        style: const TextStyle(
                          color: Colors.white,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

class ShowingIndicator extends StatelessWidget {
  const ShowingIndicator({
    super.key,
    required this.label,
    required this.isShowing,
    this.onPressed,
  });

  final String label;
  final bool isShowing;
  final void Function()? onPressed;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onPressed,
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        decoration: BoxDecoration(
          color: isShowing ? Colors.green : Colors.red,
          borderRadius: BorderRadius.circular(4),
        ),
        child: Text(
          '$label: ${isShowing ? '✅' : '❌'}',
          style: const TextStyle(color: Colors.white),
        ),
      ),
    );
  }
}

/// Visible note: this is a direct copy of the example RivePlayer provided by the package example.
/// https://pub.dev/packages/rive_native#getting-started see example in unpackaged source code.
class RivePlayer extends StatefulWidget {
  const RivePlayer({
    super.key,
    required this.asset,
    this.stateMachineName,
    this.artboardName,
    this.hitTestBehavior = rive.RiveHitTestBehavior.opaque,
    this.cursor = MouseCursor.defer,
    this.fit = rive.Fit.contain,
    this.alignment = Alignment.center,
    this.layoutScaleFactor = 1.0,
    this.withArtboard,
    this.withStateMachine,
    this.withViewModelInstance,
    this.assetLoader,
    this.autoBind = true,
  });
  final String asset;
  final String? stateMachineName;
  final String? artboardName;
  final rive.RiveHitTestBehavior hitTestBehavior;
  final MouseCursor cursor;
  final rive.Fit fit;
  final Alignment alignment;
  final double layoutScaleFactor;
  final rive.AssetLoaderCallback? assetLoader;
  final bool autoBind;

  final void Function(rive.StateMachine stateMachine)? withStateMachine;
  final void Function(rive.Artboard artboard)? withArtboard;
  final void Function(rive.ViewModelInstance viewModelInstance)? withViewModelInstance;

  @override
  State<RivePlayer> createState() => _RivePlayerState();
}

class _RivePlayerState extends State<RivePlayer> {
  rive.File? riveFile;

  late rive.Artboard artboard;
  late rive.StateMachinePainter stateMachinePainter;

  @override
  void initState() {
    super.initState();
    init();
  }

  Future<void> init() async {
    riveFile = await _loadFile();
    if (riveFile == null) return;

    if (widget.artboardName != null) {
      artboard = riveFile!.artboard(widget.artboardName!)!;
    } else {
      artboard = riveFile!.artboardAt(0)!;
    }
    widget.withArtboard?.call(artboard);

    stateMachinePainter =
        rive.RivePainter.stateMachine(
            stateMachineName: widget.stateMachineName,
            withStateMachine: (stateMachine) {
              widget.withStateMachine?.call(stateMachine);
              if (!widget.autoBind) return;
              final vm = riveFile!.defaultArtboardViewModel(artboard);
              if (vm == null) {
                return;
              }
              final vmi = vm.createDefaultInstance();
              if (vmi == null) {
                return;
              }
              stateMachine.bindViewModelInstance(vmi);
              widget.withViewModelInstance?.call(vmi);
            },
          )
          ..hitTestBehavior = widget.hitTestBehavior
          ..cursor = widget.cursor
          ..fit = widget.fit
          ..alignment = widget.alignment
          ..layoutScaleFactor = widget.layoutScaleFactor;

    setState(() {});
  }

  Future<rive.File?> _loadFile() async {
    final bytes = await rootBundle.load(widget.asset);
    return rive.File.decode(
      bytes.buffer.asUint8List(),
      riveFactory: rive.Factory.rive,
      assetLoader: widget.assetLoader,
    );
  }

  @override
  void didUpdateWidget(RivePlayer oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.hitTestBehavior != oldWidget.hitTestBehavior) {
      stateMachinePainter.hitTestBehavior = widget.hitTestBehavior;
    }
    if (widget.cursor != oldWidget.cursor) {
      stateMachinePainter.cursor = widget.cursor;
    }
    if (widget.fit != oldWidget.fit) {
      stateMachinePainter.fit = widget.fit;
    }
    if (widget.alignment != oldWidget.alignment) {
      stateMachinePainter.alignment = widget.alignment;
    }
    if (widget.layoutScaleFactor != oldWidget.layoutScaleFactor) {
      stateMachinePainter.layoutScaleFactor = widget.layoutScaleFactor;
    }
    stateMachinePainter.scheduleRepaint();
  }

  @override
  Widget build(BuildContext context) {
    return riveFile != null
        ? rive.RiveArtboardWidget(
            artboard: artboard,
            painter: stateMachinePainter,
          )
        : const SizedBox();
  }

  @override
  void dispose() {
    artboard.dispose();
    stateMachinePainter.dispose();
    riveFile?.dispose();
    super.dispose();
  }
}

Source .riv/.rev file

sent to [email protected]

Expected behavior

screenshot_20250919_140100.mp4

Screenshots

screenshot_20250919_140156.mp4

Device & Versions (please complete the following information)

  • Device: Android Samsung S23 Android 15, iOS simulator iOS 26
  • OS: Android/iOS
  • Flutter Version: 3.35.4

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions