Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(animation): not defaulting starting value from animated value #11859

Merged
merged 2 commits into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/Uno.UI.RuntimeTests/Extensions/StoryboardExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Media.Animation;

namespace Uno.UI.RuntimeTests.Extensions;

internal static class StoryboardExtensions
{
public static void Begin(this Timeline timeline)
{
var storyboard = new Storyboard { Children = { timeline } };

storyboard.Begin();
}

public static async Task RunAsync(this Timeline timeline, TimeSpan? timeout, bool throwsException = false)
{
var storyboard = new Storyboard { Children = { timeline } };

await storyboard.RunAsync(timeout, throwsException);
}

public static async Task RunAsync(this Storyboard storyboard, TimeSpan? timeout = null, bool throwsException = false)
{
var tcs = new TaskCompletionSource<bool>();
void OnCompleted(object sender, object e)
{
tcs.SetResult(true);
storyboard.Completed -= OnCompleted;
}

storyboard.Completed += OnCompleted;
storyboard.Begin();

if (timeout is { })
{
if (await Task.WhenAny(tcs.Task, Task.Delay(timeout.Value)) != tcs.Task)
{
if (throwsException)
{
throw new TimeoutException($"Timeout waiting for the storyboard to complete: {timeout}ms");
}
}
}
else
{
await tcs.Task;
}
}

public static TTimeline BindTo<TTimeline>(this TTimeline timeline, DependencyObject target, string property)
where TTimeline : Timeline
{
Storyboard.SetTarget(timeline, target);
Storyboard.SetTargetProperty(timeline, property);

return timeline;
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Uno.UI.RuntimeTests.Extensions;
using Windows.UI;
using Windows.UI.Composition;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Animation;
Expand Down Expand Up @@ -116,37 +119,44 @@ async Task WaitAndSnapshotValue(int millisecondsDelay)

[TestMethod]
[RunsOnUIThread]
public async void When_RepeatForever_ShouldLoop_AsdAsd()
public async Task When_RepeatForever_ShouldLoop()
{
// On CI, the measurement at 100ms seem to be too unreliable on Android & MacOS.
// Stretch the test by 5x greatly improve the stability. When testing locally, we can used 1x to save time (5s vs 25s).
const int TimeResolutionScaling =
#if !DEBUG && (__ANDROID__ || __MACOS__)
5;
#else
1;
#endif

var target = new Windows.UI.Xaml.Shapes.Rectangle
{
Stretch = Stretch.Fill,
Fill = new SolidColorBrush(Colors.SkyBlue),
Width = 50,
Height = 50,
};
WindowHelper.WindowContent = target;
await WindowHelper.WaitForIdle();
await WindowHelper.WaitForLoaded(target);
await WindowHelper.WaitForIdle();

var animation = new DoubleAnimation
{
EnableDependentAnimation = true,
From = 0,
To = 50,
RepeatBehavior = RepeatBehavior.Forever,
Duration = TimeSpan.FromMilliseconds(500),
};
Storyboard.SetTarget(animation, target);
Storyboard.SetTargetProperty(animation, nameof(Rectangle.Width));

var storyboard = new Storyboard { Children = { animation } };
storyboard.Begin();
Duration = TimeSpan.FromMilliseconds(500 * TimeResolutionScaling),
}.BindTo(target, nameof(Rectangle.Width));
animation.Begin();

// In an ideal world, the measurements would be [0 or 50,10,20,30,40] repeated 10 times.
var list = new List<double>();
for (int i = 0; i < 50; i++)
{
list.Add(target.Width);
await Task.Delay(100);
list.Add(NanToZero(target.Width));
await Task.Delay(100 * TimeResolutionScaling);
}

var delta = list.Zip(list.Skip(1), (a, b) => b - a).ToArray();
Expand All @@ -157,10 +167,77 @@ public async void When_RepeatForever_ShouldLoop_AsdAsd()
.ToArray();
var incrementSizes = drops.Zip(drops.Skip(1), (a, b) => b - a - 1).ToArray(); // -1 to exclude the drop itself

var context = new StringBuilder()
.AppendLine("list: " + string.Join(", ", list.Select(x => x.ToString("0.#"))))
.AppendLine("delta: " + string.Join(", ", delta.Select(x => x.ToString("+0.#;-0.#;0"))))
.AppendLine("averageIncrement: " + averageIncrement)
.AppendLine("drops: " + string.Join(", ", drops.Select(x => x.ToString("0.#"))))
.AppendLine("incrementSizes: " + string.Join(", ", incrementSizes.Select(x => x.ToString("0.#"))))
.ToString();

// This 500ms animation is expected to climb from 0 to 50, reset to 0 instantly, and repeat forever.
// Given that we are taking 5measurements per cycle, we can expect the followings:
Assert.AreEqual(10d, averageIncrement, 1.5, "an rough average of increment (exluding the drop) of 10 (+-15% error margin)");
Assert.IsTrue(incrementSizes.Count(x => x > 3) > 8, $"at least 10 (-2 error margin: might miss first and/or last) sets of continuous increments that size of 4 (+-1 error margin: sliding slot): {string.Join(",", incrementSizes)}");
Assert.AreEqual(10d, averageIncrement, 2.5, $"Expected an rough average of increment (excluding the drop) of 10 (+-25% error margin).\n" + context);
Assert.IsTrue(incrementSizes.Count(x => x >= 3) >= 8, $"Expected at least 10sets (-2 error margin: might miss first and/or last) of continuous increments in size of 4 (+-1 error margin: sliding slot).\n" + context);

double NanToZero(double value) => double.IsNaN(value) ? 0 : value;
}

[TestMethod]
[RunsOnUIThread]
public async Task When_StartingFrom_AnimatedValue()
{
var translate = new TranslateTransform();
var border = new Border()
{
Background = new SolidColorBrush(Colors.Pink),
Margin = new Thickness(0, 50, 0, 0),
Width = 50,
Height = 50,
RenderTransform = translate,
};
WindowHelper.WindowContent = border;
await WindowHelper.WaitForLoaded(border);
await WindowHelper.WaitForIdle();

// Start an animation. Its final value will serve as
// the inferred starting value for the next animation.
var animation0 = new DoubleAnimation
{
// From = should be 0
To = 50,
Duration = new Duration(TimeSpan.FromSeconds(2)),
}.BindTo(translate, nameof(translate.Y));
await animation0.RunAsync(timeout: animation0.Duration.TimeSpan + TimeSpan.FromSeconds(1));
await Task.Delay(1000);

// Start an second animation which should pick up from current animated value.
var animation1 = new DoubleAnimation
{
// From = should be 50 from animation #0
To = -50,
Duration = new Duration(TimeSpan.FromSeconds(5)),
}.BindTo(translate, nameof(translate.Y));
animation1.Begin();
await Task.Delay(125);

// ~125ms into a 5s animation where the value is animating from 50 to -50,
// the value should be still positive.
// note: On android, the value will be scaled by ViewHelper.Scale, but the assertion will still hold true.
var y = GetTranslateY(translate, isStillAnimating: true);
Assert.IsTrue(y > 0, $"Expecting Translate.Y to be still positive: {y}");
}

private double GetTranslateY(TranslateTransform translate, bool isStillAnimating = false) =>
#if !__ANDROID__
translate.Y;
#else
isStillAnimating
// On android, animation may target a native property implementing the behavior instead of the specified dependency property.
// We need to retrieve the value of that native property, as reading the dp value will just give the final value.
? (double)translate.View.TranslationY
// And, when the animation is completed, this native value is reset even for HoldEnd animation.
: translate.Y;
#endif
}
}
10 changes: 10 additions & 0 deletions src/Uno.UI/DataBinding/BindingPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ public IEnumerable<IBindingItem> GetPathItems()
return _chain?.Flatten(i => i.Next!) ?? Array.Empty<BindingItem>();
}

public (object DataContext, string PropertyName) GetTargetContextAndPropertyName()
{
var info = GetPathItems().Last();
var propertyName = info.PropertyName
.Split(new[] { '.' }).Last()
.Replace("(", "").Replace(")", "");

return (info.DataContext, propertyName);
}

/// <summary>
/// Checks the property path for members which may be shared resources (<see cref="Brush"/>es and <see cref="Transform"/>s) and creates a
/// copy of them if need be (ie if not already copied). Intended to be used prior to animating the targeted property.
Expand Down
18 changes: 5 additions & 13 deletions src/Uno.UI/Extensions/TimelineExtensions.iOSmacOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,12 @@ internal static void SetValueBypassPropagation(this Timeline timeline, object va
timeline.Log().DebugFormat("Setting [{0}] to [{1} / {2}] and bypassing native propagation", value, Storyboard.GetTargetName(timeline), Storyboard.GetTargetProperty(timeline));
}

var animatedItem = timeline.PropertyInfo.GetPathItems().Last();

// Get the property name from the last part of the
// specified name (for dotted paths, if they exist)
var propertyName = animatedItem.PropertyName.Split(new[] { '.' }).Last().Replace("(", "").Replace(")", "");

var dc = animatedItem.DataContext;
using (dc != null ?
DependencyObjectStore.BypassPropagation(
var (dc, propertyName) = timeline.PropertyInfo.GetTargetContextAndPropertyName();
using (dc != null
? DependencyObjectStore.BypassPropagation(
(DependencyObject)dc,
DependencyProperty.GetProperty(dc.GetType(), propertyName)
) :
// DC may have been collected since it's weakly held
null
DependencyProperty.GetProperty(dc.GetType(), propertyName))
: null // DC may have been collected since it's weakly held
)
{
timeline.PropertyInfo.Value = value;
Expand Down
9 changes: 9 additions & 0 deletions src/Uno.UI/FeatureConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -771,5 +771,14 @@ public static class Cursors
public static bool UseHandForInteraction { get; set; } = true;
#endif
}

public static class Timeline
{
/// <summary>
/// Determines if the default animation starting value
/// will be from the animated value or local value, when the From property is omitted.
/// </summary>
public static bool DefaultsStartingValueFromAnimatedValue { get; } = true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,9 @@ private static IValueAnimator GetGPUAnimator(this Timeline timeline, double star
// Performance : http://developer.android.com/guide/topics/graphics/hardware-accel.html#layers-anims
// Properties : http://developer.android.com/guide/topics/graphics/prop-animation.html#views

var info = timeline.PropertyInfo.GetPathItems().Last();
var target = info.DataContext;
var property = info.PropertyName.Split(new[] { '.' }).Last().Replace("(", "").Replace(")", "");
var (target, property) = timeline.PropertyInfo.GetTargetContextAndPropertyName();

// note: implementation below should be mirrored in TryGetNativeAnimatedValue
if (target is View view)
{
switch (property)
Expand Down Expand Up @@ -332,10 +331,60 @@ private static ValueAnimator GetRelativeAnimator(Java.Lang.Object target, string
}

/// <summary>
/// Ensures that scale value is without the android accepted values
/// Ensures that scale value is within the android accepted values
/// </summary>
private static double ToNativeScale(double value)
=> double.IsNaN(value) ? 1 : value;

/// <summary>
/// Get the underlying native property value which the target dependency property is associated with when animating.
/// </summary>
/// <param name="timeline"></param>
/// <param name="value">The associated native animated value <b>in logical unit</b>.</param>
/// <returns>Whether a native animation value is present and active.</returns>
/// <remarks>The <paramref name="value"/> will be <b>in logical unit</b>, as the consumer of this method will have no idea which property are scaled or not.</remarks>
internal static bool TryGetNativeAnimatedValue(Timeline timeline, out object value)
{
value = null;

if (timeline.GetIsDependantAnimation() || timeline.GetIsDurationZero())
{
return false;
}

var (target, property) = timeline.PropertyInfo.GetTargetContextAndPropertyName();
if (target is Transform { IsAnimating: false })
{
// While not animating, these native properties will be reset.
// In that case, the dp actual value should be read instead (by returning false here).
return false;
}

value = property switch
{
// note: Implementation here should be mirrored in GetGPUAnimator
nameof(FrameworkElement.Opacity) when target is View view => (double)view.Alpha,

nameof(TranslateTransform.X) when target is TranslateTransform translate => ViewHelper.PhysicalToLogicalPixels(translate.View.TranslationX),
nameof(TranslateTransform.Y) when target is TranslateTransform translate => ViewHelper.PhysicalToLogicalPixels(translate.View.TranslationY),
nameof(RotateTransform.Angle) when target is RotateTransform rotate => (double)rotate.View.Rotation,
nameof(ScaleTransform.ScaleX) when target is ScaleTransform scale => (double)scale.View.ScaleX,
nameof(ScaleTransform.ScaleY) when target is ScaleTransform scale => (double)scale.View.ScaleY,
//nameof(SkewTransform.AngleX) when target is SkewTransform skew => ViewHelper.PhysicalToLogicalPixels(skew.View.ScaleX), // copied as is from GetGPUAnimator
//nameof(SkewTransform.AngleY) when target is SkewTransform skew => ViewHelper.PhysicalToLogicalPixels(skew.View.ScaleY),

nameof(CompositeTransform.TranslateX) when target is CompositeTransform composite => ViewHelper.PhysicalToLogicalPixels(composite.View.TranslationX),
nameof(CompositeTransform.TranslateY) when target is CompositeTransform composite => ViewHelper.PhysicalToLogicalPixels(composite.View.TranslationY),
nameof(CompositeTransform.Rotation) when target is CompositeTransform composite => (double)composite.View.Rotation,
nameof(CompositeTransform.ScaleX) when target is CompositeTransform composite => (double)composite.View.ScaleX,
nameof(CompositeTransform.ScaleY) when target is CompositeTransform composite => (double)composite.View.ScaleY,
//nameof(CompositeTransform.SkewX) when target is CompositeTransform composite => ViewHelper.PhysicalToLogicalPixels(composite.View.ScaleX), // copied as is from GetGPUAnimator
//nameof(CompositeTransform.SkewY) when target is CompositeTransform composite => ViewHelper.PhysicalToLogicalPixels(composite.View.ScaleY),

_ => null,
};

return value != null;
}
}
}
Loading