mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-29 07:16:33 +08:00
363 lines
16 KiB
C#
363 lines
16 KiB
C#
using System.Reflection;
|
|
using AtomUI.MotionScene;
|
|
using AtomUI.Controls.Primitives;
|
|
using Avalonia;
|
|
using Avalonia.Animation;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AtomCollapse = AtomUI.Desktop.Controls.Collapse;
|
|
using AtomExpander = AtomUI.Desktop.Controls.Expander;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.Motion;
|
|
|
|
public class ContentExpansionMotionTests
|
|
{
|
|
static ContentExpansionMotionTests() => AvaloniaTestApp.EnsureInitialized();
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Equal_Height_Collapse_Accordion_Exchanges_Space_Without_Midpoint_Expansion(double scaling)
|
|
{
|
|
using var scene = new Scene(false, scaling);
|
|
scene.SetExpanded(true);
|
|
var stableTop = scene.OccupiedHeight;
|
|
var fullHeight = scene.Actor.Bounds.Height;
|
|
scene.EnableMotion(true);
|
|
scene.Click(1);
|
|
scene.Step(0);
|
|
scene.Step(500);
|
|
|
|
scene.IsExpanded().ShouldBeFalse();
|
|
scene.IsExpanded(1).ShouldBeTrue();
|
|
foreach (var actor in new[] { scene.Actor, scene.ContentActor(1) })
|
|
{
|
|
actor.IsVisible.ShouldBeTrue();
|
|
actor.Bounds.Height.ShouldBeGreaterThan(0);
|
|
actor.Bounds.Height.ShouldBeLessThan(fullHeight);
|
|
actor.Opacity.ShouldBeGreaterThan(0);
|
|
actor.Opacity.ShouldBeLessThan(1);
|
|
}
|
|
scene.OccupiedHeight.ShouldBe(stableTop, 1 / scaling,
|
|
"Equal content heights must exchange space on the same curve and clock frame.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void Reversal_Preserves_Rendered_Size_Before_The_Next_Tick(bool expander)
|
|
{
|
|
using var scene = new Scene(expander);
|
|
scene.SetExpanded(true);
|
|
var fullHeight = scene.Actor.Bounds.Height;
|
|
var expandedOccupiedHeight = scene.OccupiedHeight;
|
|
scene.EnableMotion(true);
|
|
scene.Click();
|
|
scene.Step(0);
|
|
scene.Step(400);
|
|
var size = scene.Actor.Bounds.Size;
|
|
scene.Click();
|
|
|
|
scene.Actor.Bounds.Height.ShouldBe(size.Height, 1,
|
|
"Reversal must capture the visible size before cancelling the previous animation.");
|
|
scene.Step(400);
|
|
scene.Actor.Bounds.Height.ShouldBe(size.Height, 1);
|
|
|
|
// The cancelled close would finish at 1000 ms; the reversed open owns 1400 ms.
|
|
scene.Step(1001);
|
|
scene.IsExpanded().ShouldBeTrue();
|
|
scene.Actor.IsVisible.ShouldBeTrue();
|
|
scene.Actor.Bounds.Height.ShouldBeGreaterThan(size.Height);
|
|
scene.Actor.Bounds.Height.ShouldBeLessThan(fullHeight);
|
|
scene.Step(1401);
|
|
scene.Actor.IsVisible.ShouldBeTrue();
|
|
scene.Actor.Bounds.Height.ShouldBe(fullHeight, 1);
|
|
scene.OccupiedHeight.ShouldBe(expandedOccupiedHeight, 1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void Reversal_Preserves_Opacity_Before_The_Next_Tick(bool expander)
|
|
{
|
|
using var scene = new Scene(expander);
|
|
scene.SetExpanded(true);
|
|
scene.EnableMotion(true);
|
|
scene.Click();
|
|
scene.Step(0);
|
|
scene.Step(400);
|
|
var opacity = scene.Actor.Opacity;
|
|
opacity.ShouldBeGreaterThan(0);
|
|
opacity.ShouldBeLessThan(1);
|
|
scene.Click();
|
|
|
|
scene.Actor.Opacity.ShouldBe(opacity, 0.001,
|
|
"Cancelling and reversing must not restore a fixed opacity endpoint.");
|
|
scene.Step(400);
|
|
scene.Actor.Opacity.ShouldBe(opacity, 0.001);
|
|
|
|
scene.Step(1001);
|
|
scene.IsExpanded().ShouldBeTrue();
|
|
scene.Actor.IsVisible.ShouldBeTrue();
|
|
scene.Actor.Opacity.ShouldBeGreaterThan(opacity);
|
|
scene.Actor.Opacity.ShouldBeLessThan(1);
|
|
scene.Step(1401);
|
|
scene.Actor.IsVisible.ShouldBeTrue();
|
|
scene.Actor.Opacity.ShouldBe(1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false, false)]
|
|
[InlineData(false, true)]
|
|
[InlineData(true, false)]
|
|
[InlineData(true, true)]
|
|
public void Disabling_Motion_Immediately_Projects_The_Current_Target(bool expander, bool opening)
|
|
{
|
|
using var scene = new Scene(expander);
|
|
scene.SetExpanded(opening);
|
|
var targetTop = scene.OccupiedHeight;
|
|
scene.SetExpanded(!opening);
|
|
scene.EnableMotion(true);
|
|
scene.Click();
|
|
scene.Step(0);
|
|
scene.Step(400);
|
|
scene.EnableMotion(false);
|
|
scene.Pump();
|
|
|
|
scene.Actor.IsVisible.ShouldBe(opening);
|
|
scene.OccupiedHeight.ShouldBe(targetTop, 1,
|
|
"Disabling motion during playback must settle without another state change or clock tick.");
|
|
if (opening)
|
|
scene.Actor.Opacity.ShouldBe(1);
|
|
scene.Step(1600);
|
|
scene.OccupiedHeight.ShouldBe(targetTop, 1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false, false)]
|
|
[InlineData(false, true)]
|
|
[InlineData(true, false)]
|
|
[InlineData(true, true)]
|
|
public void Final_Tick_Holds_Target_While_Completion_Continuation_Is_Pending(bool expander, bool opening)
|
|
{
|
|
using var scene = new Scene(expander);
|
|
scene.SetExpanded(opening);
|
|
var targetTop = scene.OccupiedHeight;
|
|
scene.SetExpanded(!opening);
|
|
scene.EnableMotion(true);
|
|
scene.Click();
|
|
scene.Step(0);
|
|
scene.Step(990);
|
|
scene.StepBeforeContinuations(1100);
|
|
|
|
scene.OccupiedHeight.ShouldBe(targetTop, 1,
|
|
"The clock's final tick must not restore the previous layout before asynchronous cleanup.");
|
|
scene.Actor.Opacity.ShouldBe(opening ? 1 : 0, 0.001);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ExpandDirection.Down, 1)]
|
|
[InlineData(ExpandDirection.Up, 1)]
|
|
[InlineData(ExpandDirection.Left, 1)]
|
|
[InlineData(ExpandDirection.Right, 1)]
|
|
[InlineData(ExpandDirection.Left, 2)]
|
|
[InlineData(ExpandDirection.Right, 2)]
|
|
public void Expander_Content_Keeps_Normal_Text_Size_And_Header_Anchored_Separator(
|
|
ExpandDirection direction, double scaling)
|
|
{
|
|
using var scene = new Scene(true, scaling, direction);
|
|
scene.SetExpanded(true);
|
|
var text = scene.Text;
|
|
var normalBounds = RenderedBounds(text, scene.Expander!);
|
|
var normalLines = text.TextLayout.TextLines.Select(line => line.Length).ToArray();
|
|
var border = scene.Actor.GetVisualDescendants().OfType<PixelAlignedBorder>().First();
|
|
var thickness = border.BorderThickness;
|
|
var normalAnchorDistance = AnchorDistance(RenderedBounds(text, scene.Actor), scene.Actor.Bounds.Size, direction);
|
|
var horizontal = direction is ExpandDirection.Left or ExpandDirection.Right;
|
|
var fullLength = horizontal ? scene.Actor.Bounds.Width : scene.Actor.Bounds.Height;
|
|
scene.EnableMotion(true);
|
|
scene.Click();
|
|
scene.Step(0);
|
|
scene.Step(500);
|
|
|
|
scene.IsExpanded().ShouldBeFalse();
|
|
scene.Actor.IsVisible.ShouldBeTrue();
|
|
var currentLength = horizontal ? scene.Actor.Bounds.Width : scene.Actor.Bounds.Height;
|
|
currentLength.ShouldBeGreaterThan(0);
|
|
currentLength.ShouldBeLessThan(fullLength);
|
|
scene.Actor.Opacity.ShouldBeGreaterThan(0);
|
|
scene.Actor.Opacity.ShouldBeLessThan(1);
|
|
var current = RenderedBounds(text, scene.Expander!);
|
|
current.Width.ShouldBe(normalBounds.Width, 1 / scaling, "Text must retain normal rendered width while its viewport clips.");
|
|
current.Height.ShouldBe(normalBounds.Height, 1 / scaling, "Text must not be scaled vertically.");
|
|
text.TextLayout.TextLines.Select(line => line.Length).ShouldBe(normalLines, "Horizontal expansion must not rewrap text at the animated viewport width.");
|
|
AnchorDistance(RenderedBounds(text, scene.Actor), scene.Actor.Bounds.Size, direction).ShouldBe(normalAnchorDistance, 1 / scaling);
|
|
border.BorderThickness.ShouldBe(thickness, "The separator must not animate its thickness.");
|
|
var contentBounds = RenderedBounds(border, scene.Expander!);
|
|
var headerBounds = RenderedBounds(scene.Header, scene.Expander!);
|
|
var separation = direction switch
|
|
{
|
|
ExpandDirection.Down => contentBounds.Top - headerBounds.Bottom,
|
|
ExpandDirection.Up => headerBounds.Top - contentBounds.Bottom,
|
|
ExpandDirection.Left => headerBounds.Left - contentBounds.Right,
|
|
_ => contentBounds.Left - headerBounds.Right
|
|
};
|
|
separation.ShouldBe(0, 1 / scaling, "The content separator must remain adjacent to the header.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ExpandDirection.Up, 1)]
|
|
[InlineData(ExpandDirection.Left, 1)]
|
|
[InlineData(ExpandDirection.Up, 2)]
|
|
[InlineData(ExpandDirection.Left, 2)]
|
|
public void Reversing_A_Trailing_Anchored_Viewport_Preserves_Text_Position_Before_The_First_Tick(
|
|
ExpandDirection direction, double scaling)
|
|
{
|
|
foreach (var time in new[] { 200, 300, 400, 600 })
|
|
{
|
|
using var scene = new Scene(true, scaling, direction);
|
|
scene.SetExpanded(true);
|
|
scene.EnableMotion(true);
|
|
scene.Click();
|
|
scene.Step(0);
|
|
scene.Step(time);
|
|
var position = scene.Text.TranslatePoint(default, scene.Expander!)!.Value;
|
|
var size = scene.Actor.Bounds.Size;
|
|
|
|
scene.Click();
|
|
|
|
scene.Actor.Bounds.Size.ShouldBe(size);
|
|
scene.Text.TranslatePoint(default, scene.Expander!)!.Value.ShouldBe(position,
|
|
$"Reversal at {time} ms must retain the same pixel anchor as the captured viewport.");
|
|
}
|
|
}
|
|
|
|
private static double AnchorDistance(Rect text, Size viewport, ExpandDirection direction) => direction switch
|
|
{
|
|
ExpandDirection.Down => text.Top,
|
|
ExpandDirection.Up => viewport.Height - text.Bottom,
|
|
ExpandDirection.Left => viewport.Width - text.Right,
|
|
_ => text.Left
|
|
};
|
|
|
|
private static Rect RenderedBounds(Visual visual, Visual relativeTo)
|
|
{
|
|
var start = visual.TranslatePoint(default, relativeTo)!.Value;
|
|
var end = visual.TranslatePoint(new Point(visual.Bounds.Width, visual.Bounds.Height), relativeTo)!.Value;
|
|
return new Rect(start, end).Normalize();
|
|
}
|
|
|
|
private sealed class Scene : IDisposable
|
|
{
|
|
private readonly ManualClock _clock = new();
|
|
private readonly Avalonia.Controls.Window _window;
|
|
private readonly AtomCollapse? _collapse;
|
|
private readonly CollapseItem[] _items = [];
|
|
public AtomExpander? Expander { get; }
|
|
public BaseMotionActor Actor { get; }
|
|
public Control Header { get; }
|
|
public double OccupiedHeight => Expander?.Bounds.Height ?? Footer.Bounds.Top;
|
|
public TextBlock Footer { get; } = new() { Text = "Following content" };
|
|
public TextBlock Text { get; } = new()
|
|
{
|
|
Text = "Wrapped text preserves its normal line layout throughout horizontal and vertical expansion. " +
|
|
"The next paragraph must remain readable at every intermediate animation frame.",
|
|
TextWrapping = TextWrapping.Wrap
|
|
};
|
|
|
|
public Scene(bool expander, double scaling = 1, ExpandDirection direction = ExpandDirection.Down)
|
|
{
|
|
Control subject;
|
|
Control owner;
|
|
if (expander)
|
|
{
|
|
Expander = new AtomExpander
|
|
{
|
|
Header = "Panel header", Content = Text, IsMotionEnabled = false,
|
|
ExpandDirection = direction, MotionDuration = TimeSpan.FromSeconds(1)
|
|
};
|
|
owner = Expander;
|
|
// Keep the Gallery's fixed-height host and the template's DockPanel constraints.
|
|
subject = new Avalonia.Controls.Grid { Height = 280, Children = { Expander } };
|
|
}
|
|
else
|
|
{
|
|
_items = Enumerable.Range(0, 2).Select(i => new CollapseItem
|
|
{
|
|
Header = $"Panel {i}", Content = new Border { Height = 120 },
|
|
MotionDuration = TimeSpan.FromSeconds(1)
|
|
}).ToArray();
|
|
_collapse = new AtomCollapse { IsAccordion = true, IsMotionEnabled = false };
|
|
foreach (var item in _items)
|
|
_collapse.Items.Add(item);
|
|
subject = _collapse;
|
|
owner = _items[0];
|
|
}
|
|
_window = new Avalonia.Controls.Window
|
|
{
|
|
Width = 420, Height = 360,
|
|
Content = new Avalonia.Controls.ScrollViewer
|
|
{
|
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
|
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
|
Content = new StackPanel { Children = { subject, Footer } }
|
|
}
|
|
};
|
|
_window.Show();
|
|
_window.SetRenderScaling(scaling);
|
|
Pump();
|
|
Actor = Part<BaseMotionActor>(owner, "PART_ContentMotionActor");
|
|
Header = Part<Control>(owner, expander ? "PART_HeaderLayoutTransform" : "PART_HeaderDecorator");
|
|
var clockProperty = typeof(Animatable).GetProperty("Clock", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
foreach (var actor in _window.GetVisualDescendants().OfType<BaseMotionActor>())
|
|
clockProperty.SetValue(actor, _clock.Instance);
|
|
}
|
|
|
|
public void SetExpanded(bool expanded)
|
|
{
|
|
if (Expander != null) Expander.IsExpanded = expanded;
|
|
else _items[0].IsSelected = expanded;
|
|
Pump();
|
|
}
|
|
public bool IsExpanded(int index = 0) => Expander?.IsExpanded ?? _items[index].IsSelected;
|
|
public BaseMotionActor ContentActor(int index) =>
|
|
Part<BaseMotionActor>(_items[index], "PART_ContentMotionActor");
|
|
public void EnableMotion(bool enabled)
|
|
{
|
|
if (Expander != null) Expander.IsMotionEnabled = enabled;
|
|
else _collapse!.IsMotionEnabled = enabled;
|
|
}
|
|
public void Click(int index = 0)
|
|
{
|
|
var header = Expander != null ? Part<Control>(Expander, "PART_HeaderDecorator") : Part<Control>(_items[index], "PART_HeaderDecorator");
|
|
var point = header.TranslatePoint(new Point(header.Bounds.Width / 2, header.Bounds.Height / 2), _window)!.Value;
|
|
_window.MouseMove(point);
|
|
_window.MouseDown(point, MouseButton.Left);
|
|
_window.MouseUp(point, MouseButton.Left);
|
|
Pump();
|
|
}
|
|
public void Step(int milliseconds) { _clock.Step(milliseconds); Pump(); }
|
|
public void StepBeforeContinuations(int milliseconds) { _clock.Step(milliseconds); _window.UpdateLayout(); }
|
|
public void Pump() { Dispatcher.UIThread.RunJobs(); _window.UpdateLayout(); }
|
|
public void Dispose() { _window.Close(); Pump(); }
|
|
private static T Part<T>(Control owner, string name) where T : Control =>
|
|
owner.GetVisualDescendants().OfType<T>().First(control => control.Name == name);
|
|
}
|
|
|
|
private sealed class ManualClock
|
|
{
|
|
// Avalonia's animation clock is internal; reflection remains confined to tests.
|
|
private static readonly Type ClockType = typeof(Animation).Assembly.GetType("Avalonia.Animation.ClockBase", true)!;
|
|
private static readonly MethodInfo Pulse = ClockType.GetMethod("Pulse", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
public object Instance { get; } = Activator.CreateInstance(ClockType, nonPublic: true)!;
|
|
public void Step(int milliseconds) => Pulse.Invoke(Instance, [TimeSpan.FromMilliseconds(milliseconds)]);
|
|
}
|
|
}
|