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.
- Create a new dotnet core 3.1 WPF app and name it "AnimationWarning6"
- Create a folder, "img", right click, create new bitmap, scribble something in mspaint, and save to "asdf.bmp"
- In your solution, change the "Build Action" on the image to "Embedded Resource".
- Since this is dotnet core, add a nuget package reference to System.Drawing.Common (4.7.0).
- 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>
- 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