Browse Source

Merge pull request #11326 from AvaloniaUI/fixes/11167-transitioningcontentcontrol

Refactor TransitioningContentControl
treeview-issue-repro
Max Katz 3 years ago
committed by GitHub
parent
commit
3efec1fa6e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs
  2. 2
      src/Avalonia.Controls/ContentControl.cs
  3. 126
      src/Avalonia.Controls/TransitioningContentControl.cs
  4. 31
      src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml
  5. 31
      src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml
  6. 251
      tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs

2
samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs

@ -216,7 +216,6 @@ namespace ControlCatalog.ViewModels
{
var animation = new Animation
{
FillMode = FillMode.Forward,
Children =
{
new KeyFrame
@ -247,7 +246,6 @@ namespace ControlCatalog.ViewModels
to.IsVisible = true;
var animation = new Animation
{
FillMode = FillMode.Forward,
Children =
{
new KeyFrame

2
src/Avalonia.Controls/ContentControl.cs

@ -11,7 +11,7 @@ using Avalonia.Metadata;
namespace Avalonia.Controls
{
/// <summary>
/// Displays <see cref="Content"/> according to a <see cref="FuncDataTemplate"/>.
/// Displays <see cref="Content"/> according to an <see cref="IDataTemplate"/>.
/// </summary>
[TemplatePart("PART_ContentPresenter", typeof(IContentPresenter))]
public class ContentControl : TemplatedControl, IContentControl, IContentPresenterHost

126
src/Avalonia.Controls/TransitioningContentControl.cs

@ -1,33 +1,30 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Animation;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Templates;
using Avalonia.Threading;
using Avalonia.Data;
namespace Avalonia.Controls;
/// <summary>
/// Displays <see cref="ContentControl.Content"/> according to a <see cref="FuncDataTemplate"/>.
/// Uses <see cref="PageTransition"/> to move between the old and new content values.
/// Displays <see cref="ContentControl.Content"/> according to an <see cref="IDataTemplate"/>,
/// using a <see cref="PageTransition"/> to move between the old and new content.
/// </summary>
public class TransitioningContentControl : ContentControl
{
private CancellationTokenSource? _lastTransitionCts;
private object? _currentContent;
private CancellationTokenSource? _currentTransition;
private ContentPresenter? _transitionPresenter;
private Optional<object?> _transitionFrom;
/// <summary>
/// Defines the <see cref="PageTransition"/> property.
/// </summary>
public static readonly StyledProperty<IPageTransition?> PageTransitionProperty =
AvaloniaProperty.Register<TransitioningContentControl, IPageTransition?>(nameof(PageTransition),
new CrossFade(TimeSpan.FromSeconds(0.125)));
/// <summary>
/// Defines the <see cref="CurrentContent"/> property.
/// </summary>
public static readonly DirectProperty<TransitioningContentControl, object?> CurrentContentProperty =
AvaloniaProperty.RegisterDirect<TransitioningContentControl, object?>(nameof(CurrentContent),
o => o.CurrentContent);
AvaloniaProperty.Register<TransitioningContentControl, IPageTransition?>(
nameof(PageTransition),
defaultValue: new ImmutableCrossFade(TimeSpan.FromMilliseconds(125)));
/// <summary>
/// Gets or sets the animation played when content appears and disappears.
@ -38,74 +35,79 @@ public class TransitioningContentControl : ContentControl
set => SetValue(PageTransitionProperty, value);
}
/// <summary>
/// Gets the content currently displayed on the screen.
/// </summary>
public object? CurrentContent
protected override Size ArrangeOverride(Size finalSize)
{
get => _currentContent;
private set => SetAndRaise(CurrentContentProperty, ref _currentContent, value);
}
var result = base.ArrangeOverride(finalSize);
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (_transitionFrom.HasValue)
{
_currentTransition?.Cancel();
if (_transitionPresenter is not null &&
Presenter is Visual presenter &&
PageTransition is { } transition &&
(_transitionFrom.Value is not Visual v || v.VisualParent is null))
{
_transitionPresenter.Content = _transitionFrom.Value;
_transitionPresenter.IsVisible = true;
_transitionFrom = Optional<object?>.Empty;
var cancel = new CancellationTokenSource();
_currentTransition = cancel;
transition.Start(_transitionPresenter, presenter, true, cancel.Token).ContinueWith(x =>
{
if (!cancel.IsCancellationRequested)
{
_transitionPresenter.Content = null;
_transitionPresenter.IsVisible = false;
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
_transitionFrom = Optional<object?>.Empty;
}
Dispatcher.UIThread.Post(() => UpdateContentWithTransition(Content));
return result;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
protected override bool RegisterContentPresenter(IContentPresenter presenter)
{
base.OnDetachedFromVisualTree(e);
_lastTransitionCts?.Cancel();
if (!base.RegisterContentPresenter(presenter) &&
presenter is ContentPresenter p &&
p.Name == "PART_TransitionContentPresenter")
{
_transitionPresenter = p;
_transitionPresenter.IsVisible = false;
return true;
}
return false;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ContentProperty)
if (change.Property == ContentProperty &&
_transitionPresenter is not null &&
Presenter is Visual &&
PageTransition is not null)
{
Dispatcher.UIThread.Post(() => UpdateContentWithTransition(Content));
}
else if (change.Property == CurrentContentProperty)
{
UpdateLogicalTree(change.OldValue, change.NewValue);
_transitionFrom = change.GetOldValue<object?>();
InvalidateArrange();
}
}
protected override void ContentChanged(AvaloniaPropertyChangedEventArgs e)
private class ImmutableCrossFade : IPageTransition
{
// We do nothing becuse we should not remove old Content until the animation is over
}
private readonly CrossFade _inner;
/// <summary>
/// Updates the content with transitions.
/// </summary>
/// <param name="content">New content to set.</param>
private async void UpdateContentWithTransition(object? content)
{
if (VisualRoot is null)
{
return;
}
_lastTransitionCts?.Cancel();
_lastTransitionCts = new CancellationTokenSource();
var localToken = _lastTransitionCts.Token;
if (PageTransition != null)
await PageTransition.Start(this, null, true, localToken);
public ImmutableCrossFade(TimeSpan duration) => _inner = new CrossFade(duration);
if (localToken.IsCancellationRequested)
public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
{
return;
return _inner.Start(from, to, cancellationToken);
}
CurrentContent = content;
if (PageTransition != null)
await PageTransition.Start(null, this, true, localToken);
}
}

31
src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml

@ -3,16 +3,27 @@
<ControlTheme x:Key="{x:Type TransitioningContentControl}" TargetType="TransitioningContentControl">
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding CurrentContent}"
Padding="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
<Panel>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
Padding="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
<ContentPresenter Name="PART_TransitionContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
</Panel>
</ControlTemplate>
</Setter>
</ControlTheme>

31
src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml

@ -5,16 +5,27 @@
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding CurrentContent}"
ContentTemplate="{TemplateBinding ContentTemplate}"
CornerRadius="{TemplateBinding CornerRadius}" />
<Panel>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
Padding="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
<ContentPresenter Name="PART_TransitionContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
</Panel>
</ControlTemplate>
</Setter>
</ControlTheme>

251
tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs

@ -1,66 +1,243 @@
using System;
using Avalonia.LogicalTree;
using Avalonia.UnitTests;
using Xunit;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Animation;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Templates;
using Avalonia.Headless;
using Avalonia.Layout;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Xunit;
#nullable enable
namespace Avalonia.Controls.UnitTests
{
public class TransitioningContentControlTests
{
[Fact]
public void Old_Content_Shuold_Be_Removed__From_Logical_Tree_After_Out_Animation()
public void Transition_Should_Not_Be_Run_When_First_Shown()
{
using var app = Start();
var (target, transition) = CreateTarget("foo");
Assert.Equal(0, transition.StartCount);
}
[Fact]
public void TransitionContentPresenter_Should_Initially_Be_Hidden()
{
using var app = Start();
var (target, transition) = CreateTarget("foo");
var transitionPresenter = GetTransitionContentPresenter(target);
Assert.False(transitionPresenter.IsVisible);
}
[Fact]
public void Transition_Should_Be_Run_On_Layout()
{
using var app = Start();
var (target, transition) = CreateTarget("foo");
target.Content = "bar";
Assert.Equal(0, transition.StartCount);
Layout(target);
Assert.Equal(1, transition.StartCount);
}
[Fact]
public void Control_Transition_Should_Be_Run_On_Layout()
{
using var app = Start();
var (target, transition) = CreateTarget(new Button());
target.Content = new Canvas();
Assert.Equal(0, transition.StartCount);
Layout(target);
Assert.Equal(1, transition.StartCount);
}
[Fact]
public void ContentPresenters_Should_Be_Setup_For_Transition()
{
using var app = Start();
var (target, transition) = CreateTarget("foo");
var transitionPresenter = GetTransitionContentPresenter(target);
target.Content = "bar";
Layout(target);
Assert.True(transitionPresenter.IsVisible);
Assert.Equal("bar", target.Presenter!.Content);
Assert.Equal("foo", transitionPresenter.Content);
}
[Fact]
public void TransitionContentPresenter_Should_Be_Hidden_When_Transition_Completes()
{
using var app = Start();
using var sync = UnitTestSynchronizationContext.Begin();
var (target, transition) = CreateTarget("foo");
var transitionPresenter = GetTransitionContentPresenter(target);
target.Content = "bar";
Layout(target);
Assert.True(transitionPresenter.IsVisible);
transition.Complete();
sync.ExecutePostedCallbacks();
Assert.False(transitionPresenter.IsVisible);
}
[Fact]
public void Transition_Should_Be_Canceled_If_Content_Changes_While_Running()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using var app = Start();
using var sync = UnitTestSynchronizationContext.Begin();
var (target, transition) = CreateTarget("foo");
var transitionPresenter = GetTransitionContentPresenter(target);
target.Content = "bar";
Layout(target);
target.Content = "baz";
Assert.Equal(0, transition.CancelCount);
Layout(target);
Assert.Equal(1, transition.CancelCount);
}
[Fact]
public void New_Transition_Should_Be_Started_If_Content_Changes_While_Running()
{
using var app = Start();
using var sync = UnitTestSynchronizationContext.Begin();
var (target, transition) = CreateTarget("foo");
var transitionPresenter = GetTransitionContentPresenter(target);
target.Content = "bar";
Layout(target);
target.Content = "baz";
var startedRaised = 0;
transition.Started += (from, to, forward) =>
{
var testTransition = new TestTransition();
var fromPresenter = Assert.IsType<ContentPresenter>(from);
var toPresenter = Assert.IsType<ContentPresenter>(to);
Assert.Same(transitionPresenter, fromPresenter);
Assert.Same(target.Presenter, toPresenter);
Assert.Equal("bar", fromPresenter.Content);
Assert.Equal("baz", toPresenter.Content);
Assert.True(forward);
Assert.Equal(1, transition.CancelCount);
var target = new TransitioningContentControl();
target.PageTransition = testTransition;
++startedRaised;
};
var root = new TestRoot() { Child = target };
Layout(target);
sync.ExecutePostedCallbacks();
Assert.Equal(1, startedRaised);
Assert.Equal("baz", target.Presenter!.Content);
Assert.Equal("bar", transitionPresenter.Content);
}
var oldControl = new Control();
var newControl = new Control();
private static IDisposable Start()
{
return UnitTestApplication.Start(
TestServices.MockThreadingInterface.With(
fontManagerImpl: new HeadlessFontManagerStub(),
renderInterface: new HeadlessPlatformRenderInterface(),
textShaperImpl: new HeadlessTextShaperStub()));
}
target.Content = oldControl;
Threading.Dispatcher.UIThread.RunJobs();
private static (TransitioningContentControl, TestTransition) CreateTarget(object content)
{
var transition = new TestTransition();
var target = new TransitioningContentControl
{
Content = content,
PageTransition = transition,
Template = CreateTemplate(),
};
Assert.Equal(target, oldControl.GetLogicalParent());
Assert.Equal(null, newControl.GetLogicalParent());
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
return (target, transition);
}
testTransition.BeginTransition += isFrom =>
private static IControlTemplate CreateTemplate()
{
return new FuncControlTemplate((x, ns) =>
{
return new Panel
{
// Old out
if (isFrom)
Children =
{
Assert.Equal(target, oldControl.GetLogicalParent());
Assert.Equal(null, newControl.GetLogicalParent());
}
// New in
else
{
Assert.Equal(null, oldControl.GetLogicalParent());
Assert.Equal(target, newControl.GetLogicalParent());
new ContentPresenter
{
Name = "PART_ContentPresenter",
[!ContentPresenter.ContentProperty] = x[!ContentControl.ContentProperty],
},
new ContentPresenter
{
Name = "PART_TransitionContentPresenter",
},
}
};
});
}
target.Content = newControl;
Threading.Dispatcher.UIThread.RunJobs();
}
private static ContentPresenter GetTransitionContentPresenter(TransitioningContentControl target)
{
return Assert.IsType<ContentPresenter>(target
.GetTemplateChildren()
.First(x => x.Name == "PART_TransitionContentPresenter"));
}
}
public class TestTransition : IPageTransition
{
public event Action<bool> BeginTransition;
public Task Start(Visual from, Visual to, bool forward, CancellationToken cancellationToken)
private void Layout(Control c)
{
bool isFrom = from != null && to == null;
BeginTransition?.Invoke(isFrom);
return Task.CompletedTask;
(c.GetVisualRoot() as ILayoutRoot)?.LayoutManager.ExecuteLayoutPass();
}
private class TestTransition : IPageTransition
{
private TaskCompletionSource? _tcs;
public int StartCount { get; private set; }
public int FinishCount { get; private set; }
public int CancelCount { get; private set; }
public event Action<Visual?, Visual?, bool>? Started;
public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
{
++StartCount;
Started?.Invoke(from, to, forward);
if (_tcs is not null)
throw new InvalidOperationException("Transition already running");
_tcs = new TaskCompletionSource();
cancellationToken.Register(() => _tcs.TrySetResult());
await _tcs.Task;
_tcs = null;
if (!cancellationToken.IsCancellationRequested)
++FinishCount;
else
++CancelCount;
}
public void Complete() => _tcs!.TrySetResult();
}
}
}

Loading…
Cancel
Save