1

I have a heartbeat animation in a WPF storyboard, and when I receive heartrate sensor data I want to adjust the speed of the animation to match the heartrate. I'm calling storyboard.Begin(,true) at the start of the app. A little later I call storyboard.GetCurrentTime() which throws

Cannot perform action because the specified Storyboard was not applied to this object for interactive control

What am I doing wrong?


Here's a minimal stripped down example. It uses an embedded resource for image data.

Steps to reproduce.

  1. Create a new dotnet core 3.1 WPF app and name it "AnimationWarning6"
  2. Create a folder, "img", right click, create new bitmap, scribble something in mspaint, and save to "asdf.bmp"
  3. In your solution, change the "Build Action" on the image to "Embedded Resource".
  4. Since this is dotnet core, add a nuget package reference to System.Drawing.Common (4.7.0).
  5. Open MainWindow.xaml and add <Image x:Name="ImageHeart"></Image> so it looks like
<Window x:Class="AnimationWarning6.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:AnimationWarning6"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Image x:Name="ImageHeart"></Image>
    </Grid>
</Window>
  1. Open MainWindow.xaml.cs and change it so that it looks like
using System;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;

namespace AnimationWarning6
{
    public partial class MainWindow : Window
    {
        private static readonly TimeSpan HeartGifNaturalDuration = new TimeSpan(0, 0, 1);
        private Storyboard _heartStoryboard;

        public MainWindow()
        {
            InitializeComponent();

            // load embedded resource image into memory
            var bm = new BitmapImage();

            using (var img = System.Drawing.Image.FromStream(Assembly.GetExecutingAssembly().GetManifestResourceStream("AnimationWarning6.img.asdf.bmp")))
            {
                using (var ms = new System.IO.MemoryStream())
                {
                    img.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
                    ms.Position = 0;

                    bm.BeginInit();
                    bm.CacheOption = BitmapCacheOption.OnLoad;
                    bm.UriSource = null;
                    bm.StreamSource = ms;
                    bm.EndInit();
                }
            }

            // create storyboard.
            _heartStoryboard = new Storyboard();
            _heartStoryboard.BeginTime = TimeSpan.Zero;
            var oaukf = new ObjectAnimationUsingKeyFrames();
            oaukf.BeginTime = TimeSpan.Zero;
            TimeSpan current = TimeSpan.Zero;

            // Add 10 DiscreteObjectKeyFrame to the ObjectAnimationUsingKeyFrames
            for (int i=0; i<10; i++)
            {
                var kf = new DiscreteObjectKeyFrame(bm, current);
                oaukf.KeyFrames.Add(kf);
                current += new TimeSpan(0, 0, 0, 0, 500);
            }
            oaukf.Duration = current;

            // Associate animation with the WPF image.Source
            Storyboard.SetTarget(oaukf, ImageHeart);
            Storyboard.SetTargetProperty(oaukf, new PropertyPath(Image.SourceProperty));

            // Setup storyboard to play
            _heartStoryboard.Children.Add(oaukf);
            _heartStoryboard.RepeatBehavior = RepeatBehavior.Forever;
            _heartStoryboard.Duration = HeartGifNaturalDuration;
            _heartStoryboard.Name = "HeartStoryboard";
            _heartStoryboard.Begin(ImageHeart, true);

            // In my real app, receiving data from the sensor triggers an event and if
            // enough time has elapsed the animation duration will be adjusted. I'm
            // just mocking a 5 second timer for this example.
            var t = new System.Timers.Timer(5000)
            {
                AutoReset = true,
            };

            t.Elapsed += (s, e) =>
            {
                int heartRate = 70; // <- dummy data for this example.

                double scaleFactor = HeartGifNaturalDuration.TotalSeconds * (double)heartRate / 60.0;

                var currentTime = _heartStoryboard.GetCurrentTime(); // <- this line throws
                // Exception thrown: 'System.InvalidOperationException' in PresentationFramework.dll
                // An exception of type 'System.InvalidOperationException' occurred in PresentationFramework.dll but was not handled in user code
                // Cannot perform action because the specified Storyboard was not applied to this object for interactive control.

                _heartStoryboard.RepeatBehavior = RepeatBehavior.Forever;
                _heartStoryboard.Stop();
                _heartStoryboard.SpeedRatio = scaleFactor;
                _heartStoryboard.Begin(ImageHeart, true); // reset animation to apply new SpeedRatio 
                _heartStoryboard.Seek(currentTime);
            };

            // start timer
            t.Start();
        }
    }
}

First off, I'm calling .Begin(ImageHeart, true), which should mark this as "controllable." (right?)

I tried calling .Stop() and .Stop(ImageHeart) and .Pause() and .Pause(ImageHeart) immediately prior to _heartStoryboard.GetCurrentTime() but that still throws, as mentioned here and here.

I think my problem is the same as this unanswered question (the sole answer seems like a comment) here.

This answer says

My problem went away when I explicitly defined a starting value for each animated property at keyframe 0

but I'm already doing that as far as I can tell.

While these two (duplicate, and both unanswered) questions, here and here, are about determining if a storyboard has already begun, I think this is actually the same problem I'm having. My current code uses

var currentTime = _heartStoryboard.GetCurrentTime(ImageHeart) ?? TimeSpan.Zero;  

which throws the same warnings in the output console (System.Windows.Media.Animation Warning: 6) as mentioned in those two answers. This fixes the exception being thrown, but always sets currentTime to TimeSpan.Zero. This causes the animation to jerk when it resets instead of continuing at the same frame it was before the SpeedRatio change. So, back to the original question, why can't I call _heartStoryboard.GetCurrentTime()?


edit, source hunting

Exception stack trace gives

at System.Windows.Media.Animation.Storyboard.GetStoryboardClock(DependencyObject o, Boolean throwIfNull, InteractiveOperation operation)
at System.Windows.Media.Animation.Storyboard.GetCurrentTimeImpl(DependencyObject containingObject)
at System.Windows.Media.Animation.Storyboard.GetCurrentTime()
at AnimationWarning6.MainWindow.<.ctor>b__2_0(Object s, ElapsedEventArgs e) in C:\Users\tolos\code\csharp\AnimationWarning6\AnimationWarning6\MainWindow.xaml.cs:line 76
at System.Timers.Timer.MyTimerCallback(Object state)  

Looking at the dotnet wpf source for GetStoryboardClock on github I guess it throws from

private Clock GetStoryboardClock(DependencyObject o, bool throwIfNull, InteractiveOperation operation)
{
    Clock clock = null;
    WeakReference clockReference = null;

    HybridDictionary clocks = StoryboardClockTreesField.GetValue(o);

    if (clocks != null)
    {
        clockReference = clocks[this] as WeakReference;
    }

    if (clockReference == null)
    {
        if (throwIfNull)
        {
            // This exception indicates that the storyboard has never been applied.
            // We check the weak reference because the only way it can be null
            // is if it had never been put in the dictionary.
            throw new InvalidOperationException(SR.Get(SRID.Storyboard_NeverApplied));
        
        ...

If I break on the line before the exception, and execute in the immediate window

(Storyboard.StoryboardClockTreesField.GetValue((DependencyObject)ImageHeart)[_heartStoryboard] as WeakReference) == null  

the result is false, yet the code in my application throws, so the result must be == null? I don't know how it can be null, because I can cast to Clock like the github source code

((Storyboard.StoryboardClockTreesField.GetValue((DependencyObject)ImageHeart)[_heartStoryboard] as WeakReference).Target as Clock).CurrentTime  

gives TotalSeconds: 0.9651458

Community
  • 1
  • 1
BurnsBA
  • 4,347
  • 27
  • 39

1 Answers1

1

Storyboard was being called with the framework element as an argument with true to mark "controllable". i.e.

_heartStoryboard.Begin(ImageHeart, true);

I think the problem is that it's assigning a clock to the ImageHeart, but not to the storyboard. When GetCurrentTime is called without arguments, it looks up the clock associated with the storyboard (according to source file), which is null in this case, because it was never set. The end result is that calling

_heartStoryboard.GetCurrentTime();

throws an exception:

Cannot perform action because the specified Storyboard was not applied to this object for interactive control

It seems like there's three options.

(1) Call GetCurrentTime with the ImageHeart. Somehow this only sort of works, and still results in warning messages in the console. Animation jerks back to the first frame for some reason when trying to continue at the correct time.

(2) Leave all the storyboard calls with explicit parameters, but somehow start the storyboard clock. I'm not sure how to do this.

(3) Get rid of all parameters to storyboard calls, just call _heartStoryboard.Begin(). The comment for the method in source says

Begins all animations underneath this storyboard, clock tree starts in "shared clocks" mode.

This also defaults isControllable to true. This works for me, no more warnings, no more exceptions.

BurnsBA
  • 4,347
  • 27
  • 39