rive-flutter icon indicating copy to clipboard operation
rive-flutter copied to clipboard

Some observations and requests

Open tantzygames opened this issue 4 years ago • 3 comments

Thank you for making this amazing tool. I’ve had a chance to use it a few times since late last year, and thought I’d explain how I’ve used it and make some feature requests.

rive_trails

First I created Christmas lights, then Christmas presents and then Easter eggs using v6. For each of these I needed to adjust various colors, set rotations, and turn components on and off. I needed to import from the src dir to get to these, so thank you for exposing the components so we no longer have to. However it still involves a fair bit of searching and experimentation to figure out how to get the right components and then how to achieve the desired results.

I create many icons/images white and set their color in Flutter. I want to use Rive in a similar way, and every time I’ve used Rive I’ve needed to change colors. I’d love to have a simple way to find an element by name, and set it’s fill or stroke color (internally searching it’s children for fills or strokes). Something like:

Artboard.getComponentByName(‘Ball’).setFillColor(Colors.red);
Artboard.getComponentByName(‘Ball’).y = 40.5;
Here is what I am doing for the lights:

rive.artboard.forEachComponent((child){
        if (child.name == 'Lid') {
          riveNode.Node node = child;
          node.rotation = vMath.radians((num * 60).toDouble());
        }
        else if (child.name == 'Fill') {
          Fill fill = child;
          fill.paint.color = fillColor;
        }
        else if (child.name == 'Stroke') {
          Stroke stroke = child;
          stroke.paint.color = strokeColor;
        }
        else if (child.name == 'Glow') {
          if (!inTimes) {
            try {
              riveNode.Node node = child;
              node.opacity = 0;
            } catch (e) {}

          }
        }
      });

For each RiveFile I stored the bytes so I could create unique instances. Another request is for easy instances, which I see you’ve been working on.

I’ve used Rive recently to create a metronome and animated play button for a midi player.

https://user-images.githubusercontent.com/22208505/125040374-b937d800-e0d6-11eb-9e16-32cb9f46dfa2.mp4

The metronome took some figuring out. I first created a simple looping animation with a custom controller to adjust the speed, but it wasn’t totally accurate compared to the playback. Then I tried ping-pong, but that had the same problem. I needed a way to sync the metronome for each tick. I ended up with 2 oneShot animations, but I needed them to stay in their final position instead of resetting, so I removed the auto-reset. Most of my other oneShot uses require holding the final frame, but resetting before playing again. So another request is to include options to reset before playing or reset after playing.

I created an animation for the metronome weight from slowest to fastest. I wanted to set the time for this animation based on the speed of the music. I couldn’t find a good way to do this so I ended up setting the Y position. I think it would be really useful for a variety of use cases (sliders, scroll bars and more) to be able to set an animation position, and a time to get there from the current position. For example, to move from the current time/position to 0.25 position over half a second: AnimController.setTime(0.25, 0.5);

Since then I’ve been able to set the hand animation position which would probably work for the weight as well, but relies on the speed variable:

  metronomeTick.speed = 0;
  metronomeTick.play();
  metronomeTick.time = 0.25;

For the play button, play and pause are oneShot animations with resets at the beginning instead of the end. For the loading I created an animation that included the entry, a looping section, and an exit. Now, I could split that into 3 separate animations but that would make it difficult to edit because I’d need to split it every time I made a change. Instead I created overrides for the start time, end time and loop type, and import the same animation 3 times with different values. To do this I had to create a custom LinearAnimation and controller, but it works really well, and I think it would be a useful feature to include as a default. To play the loading animations, I created an onLoop callback which is triggered when _didLoop = true; to chain them together, but gosh it was tricky. There are so many moving parts, trying to figure out how to get them to behave, and what part was going wrong was a nightmare. Added to that was a real problem that sometimes animations don’t quite get to 100% (which you can see at the end of the video) and so the callbacks are never called. I think there should be a way for this to work, but I haven’t been able to get it to be rock solid. For now what works every time is waiting on a Future.delayed between each part of the animation.

The play buttons on the first screen need color changes, but in this case changing the fill color didn’t work because it un-hid all my hidden layers (I guess you’re using color alpha to fade layers). I had to use this instead which retained my animated layer alphas:

if (c.coreType == 18) {
  SolidColorBase solidColorBase = c;
  solidColorBase.colorValue = color.value;
}

To recap, I’d really to easily set the color of a component without affecting the layer alpha, and a standard animation controller which can:

  • Set the speed
  • An OnLoop callback guaranteed to be triggered
  • Option to reset before and/or after playing
  • Custom start time, end time and loop type to manipulate different sections of an animation independently

The reason I ask instead of just doing it myself, is because I figure it will be much easier for you to do it since you know how it all works, and it makes sense to do it once rather than every user experimenting and figuring it out for themselves, and I think it will generally make working with Rive animations much more flexible and user friendly.

Here is the code for my controller and animation in case it’s useful:

class AnimController extends rive.RiveAnimationController<rive.RuntimeArtboard> {

  AnimInstance _instance;

  double _speedMultiplier;

  /// Animation name
  final String animationName;

  /// Pauses the animation when it's created
  final bool autoplay;

  /// Mix value for the animation, value between 0 and 1
  double _mix;

  /// Fires when the animation stops being active
  final VoidCallback onStop;

  /// Fires when the animation starts being active
  final VoidCallback onStart;

  /// Fires when a looping animation loops
  final VoidCallback onLoop;

  /// Overrides for animation start time, end time and loop type
  double startOverride = -1;
  double endOverride = -1;
  Loop loopOverride;

  /// reset after playing OneShot?
  final bool oneShotReset;

  // Controls the level of mix for the animation, clamped between 0 and 1
  AnimController(this.animationName, {
    double mix = 1,
    double speed = 1,
    double start = -1,
    double end = -1,
    Loop loop,
    this.autoplay = true,
    this.oneShotReset = true,
    this.onStop,
    this.onStart,
    this.onLoop,
  }) {
    _mix = mix.clamp(0, 1).toDouble();
    _speedMultiplier = speed;
    startOverride = start;
    endOverride = end;
    loopOverride = loop;
    isActive = autoplay;
    isActiveChanged.addListener(onActiveChanged);
  }

  AnimInstance get instance => _instance;
  double get mix => _mix;
  set mix(double value) => _mix = value.clamp(0, 1).toDouble();
  set speed(double value) => _speedMultiplier = value;
  set time(double value) => instance.time = value;
  bool get didLoop => instance.didLoop;

  @override
  bool init(rive.RuntimeArtboard artboard) {
    //_instance = artboard.animationByName(animationName);
    var animation = artboard.animations.firstWhere(
          (animation) => animation is LinearAnimation && animation.name == animationName,
      orElse: () => null,
    );
    if (animation != null) {
      _instance = AnimInstance(animation as LinearAnimation);
      if (startOverride > -1)
        _instance.setStartOverride(startOverride);
      if (endOverride > -1)
        _instance.setEndOverride(endOverride);
      _instance.loopOverride = loopOverride;
      if (onLoop != null)
        _instance.onLoop = onLoop;
    }
    isActive = autoplay;
    return _instance != null;
  }

  /// Dispose of any callback listeners
  @override
  void dispose() {
    super.dispose();
    isActiveChanged.removeListener(onActiveChanged);
  }

  @override
  void apply(rive.RuntimeArtboard artboard, double elapsedSeconds) {
    if (_instance == null) {
      isActive = false;
    }
    if (!_instance.keepGoing && oneShotReset) {
      isActive = false;
    }

    _instance
      ..animation.apply(_instance.time, coreContext: artboard, mix: mix)
      ..advance(elapsedSeconds * _speedMultiplier);

  }

  /// Resets the animation back to it's starting time position
  void reset() => _instance?.reset();

  /// Perform tasks when the animation's active state changes
  void onActiveChanged() {
    // If the animation stops and it is at the end of the one-shot, reset the
    // animation back to the starting time
    if (!isActive && oneShotReset) {
      reset();
    }
    // Fire any callbacks
    isActive
        ? onStart?.call()
    // onStop can fire while widgets are still drawing
        : WidgetsBinding.instance?.addPostFrameCallback((_) => onStop?.call());
  }

  void play()
  {
    isActive = true;
  }
  void stop()
  {
    isActive = false;
  }
  void reverse()
  {
    instance.direction = -instance.direction;
    reset();
  }
}

class AnimInstance {
  final LinearAnimation animation;
  double _time = 0;
  double _totalTime = 0;
  double _lastTotalTime = 0;
  int _direction = 1;
  bool _didLoop = false;
  bool get didLoop => _didLoop;
  double _spilledTime = 0;
  double get spilledTime => _spilledTime;

  double get totalTime => _totalTime;
  double get lastTotalTime => _lastTotalTime;

  /// Overrides
  Loop loopOverride;
  int startOverride = -1;
  int endOverride = -1;

  /// Fires when a looping animation loops
  VoidCallback onLoop;

  AnimInstance(this.animation)
      : _time =
      (animation.enableWorkArea ? animation.workStart : 0).toDouble() /
          animation.fps;

  /// Note that when time is set, the direction will be changed to 1
  set time(double value) {
    if (_time == value) {
      return;
    }
    // Make sure to keep last and total in relative lockstep so state machines
    // can track change even when setting time.
    var diff = _totalTime - _lastTotalTime;
    _time = _totalTime = value;
    _lastTotalTime = _totalTime - diff;
    _direction = 1;
  }

  /// Returns the current time position of the animation in seconds
  double get time => _time;

  /// Direction should only be +1 or -1
  set direction(int value) => _direction = value == -1 ? -1 : 1;

  /// Returns the animation's play direction: 1 for forwards, -1 for backwards
  int get direction => _direction;

  /// Returns the end time of the animation in seconds
  double get endTime {
      if (endOverride > -1)
        return endOverride / animation.fps;
      return (animation.enableWorkArea ? animation.workEnd : animation.duration).toDouble() / animation.fps;
  }

  /// Returns the start time of the animation in seconds
  double get startTime {
    if (startOverride > -1)
      return startOverride / animation.fps;
    return (animation.enableWorkArea ? animation.workStart : 0).toDouble() / animation.fps;
  }

  /// Set start and end time overrides
  void setStartOverride(double seconds) {
    startOverride = (seconds * animation.fps).toInt();
  }
  void setEndOverride(double seconds) {
    endOverride = (seconds * animation.fps).toInt();
  }

  double get progress => (_time - startTime) / (endTime - startTime);

  /// Resets the animation to the starting frame
  void reset() {
    _time = startTime;
    //_didLoop = false;
  }

  /// Whether the controller driving this animation should keep requesting
  /// frames be drawn.
  bool get keepGoing => animation.loop != Loop.oneShot || !_didLoop;

  bool advance(double elapsedSeconds) {
    var deltaSeconds = elapsedSeconds * animation.speed * _direction;
    _lastTotalTime = _totalTime;
    _totalTime += deltaSeconds;
    _time += deltaSeconds;

    double frames = _time * animation.fps;

    var start = animation.enableWorkArea ? animation.workStart : 0;
    var end = animation.enableWorkArea ? animation.workEnd : animation.duration;
    Loop loop = animation.loop;

    /// Check overrides
    if (startOverride > -1)
      start = startOverride;
    if (endOverride > -1)
      end = endOverride;
    if (loopOverride != null)
      loop = loopOverride;

    var range = end - start;

    bool keepGoing = true;
    _didLoop = false;
    _spilledTime = 0;

    switch (loop) {
      case Loop.oneShot:
        if (frames > end) {
          keepGoing = false;
          _spilledTime = (frames - end) / animation.fps;
          frames = end.toDouble();
          _time = frames / animation.fps;
          _didLoop = true;
          onLoop?.call();
        }
        break;
      case Loop.loop:
        if (frames >= end) {
          _spilledTime = (frames - end) / animation.fps;
          frames = _time * animation.fps;
          frames = start + (frames - start) % range;
          _time = frames / animation.fps;
          _didLoop = true;
          onLoop?.call();
        }
        break;
      case Loop.pingPong:
      // ignore: literal_only_boolean_expressions
        while (true) {
          if (_direction == 1 && frames >= end) {
            _spilledTime = (frames - end) / animation.fps;
            _direction = -1;
            frames = end + (end - frames);
            _time = frames / animation.fps;
            _didLoop = true;
            onLoop?.call();
          } else if (_direction == -1 && frames < start) {
            _spilledTime = (start - frames) / animation.fps;
            _direction = 1;
            frames = start + (start - frames);
            _time = frames / animation.fps;
            _didLoop = true;
            onLoop?.call();
          } else {
            // we're within the range, we can stop fixing. We do this in a
            // loop to fix conditions when time has advanced so far that we've
            // ping-ponged back and forth a few times in a single frame. We
            // want to accomodate for this in cases where animations are not
            // advanced on regular intervals.
            break;
          }
        }
        break;
    }
    return keepGoing;
  }
}

tantzygames avatar Jul 09 '21 07:07 tantzygames

Thanks for the thoughtful feedback. The app looks great!

Instancing

Artboard instancing is available, there's a new method Artboard.instance: https://github.com/rive-app/rive-flutter/blob/61e83f31848aeea7abdc484a095b3fbe746f5b55/lib/src/runtime_artboard.dart#L122

This will clone the artboard efficiently keeping animations, state machines, etc shared.

Three part animation

This is exactly what our StateMachines are built for! Yes, you would currently need to split the animation into three parts. We could add a feature that gives you the ability to segment/splice the start/end of the animation in the StateMachine @neurowave. So you could re-use the same animation for different states, but effectively have a different work area (start/end) per state. @JcToon would this be valuable? @alxgibsn there might be a cool way to do show this custom start/end in the inspector for the state or potentially add multiple work areas to each animation when viewing their timeline (state inspector could let you choose which work area to use). But maybe this is all overkill?

Changing Colors

We want to provide an easier API here, but there are a few different considerations we need to make with regards to Gradients and Solid Colors and how this impacts our other APIs. Right now what you're doing is fine, even though tedious.

You should also be able to do SolidColorBase.typeKey to not hardcode "18" for checking the concrete type: https://github.com/rive-app/rive-flutter/blob/61e83f31848aeea7abdc484a095b3fbe746f5b55/lib/src/generated/shapes/paint/solid_color_base.dart#L9

You can also just cast to SolidColor (all SolidColorBase types will be SolidColor) and then do solidColor.color = color;. Layer alpha comes from the parent alpha values, changing the color shouldn't affect those. I'm not sure where things are going wrong there. Any chance you can show what you're doing in a small isolated code example?

Metronome Controller

Interpolating the time value is cool (animate to position/time value), but you're the first to request it so I'm not sure if many others would find value in it if it were a core part of Rive. It's definitely easy to write into a custom controller which we could provide an example/gist of. Another way I'd consider doing a metronome is by simply syncing the time value of the animation each frame to the detected beat/time in measure/beat of the song. As long as your beat/time calculator is frame coherent, it should look smooth (you could always apply a tiny bit of interpolation if necessary). Or sync it to a midi clock if you have that available, that'd be the easiest! I would do this all in a custom controller.

luigi-rosso avatar Jul 09 '21 18:07 luigi-rosso

Thanks for your reply Luigi.

Artboard.instance() works great! Thanks

Yes, I would have loved to use a state machine for the play button, but couldn't without splitting it. Having a slicer (custom in/out) as a part of each animation state would be great.

I'll put something together for you to show the fill problem. I have 5 objects in the Rive file: play button, each pause line, rotating drop, and a droplet. Object layer alpha is different for each animation.

I tried finding fill layers by name (Fill 1) before realizing that the name is null unless explicitly set. Then I tried:

if (c.coreType == 20) { // FillBase
        Fill fill = c;
        fill.paint.color = color;
      }

This turns on all the layers all the time. Perhaps FillBase is too broad?

Syncing the animation frames to the midi clock was going to be my next attempt, in which case I could do away with animation and just set the rotation of the component, but I'm trying to minimize per frame actions until I can move the timing/sequencer to native code. Currently I only need to synchronize once per tick.

tantzygames avatar Jul 10 '21 01:07 tantzygames

Oh, another reason I think speed should be a default variable:

When I created the play/pause transitions I wasn't sure how fast I wanted them. I needed to see them in context, in the app. So I created them at 30f/0.5sec and then tried various speeds in the app until I was happy - quick and easy.

Without speed control I would have had to go back and forth between the Rive editor, export, import, test which would have been much more difficult and time consuming.

tantzygames avatar Jul 10 '21 03:07 tantzygames

Going to close this owing to the age of the Github issue. A lot more features and API have been introduced in both the editor and runtime.

Please feel free to reopen, or create a separate issue

HayesGordon avatar Aug 10 '23 12:08 HayesGordon