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 var animation = new Animation
{ {
FillMode = FillMode.Forward,
Children = Children =
{ {
new KeyFrame new KeyFrame
@ -247,7 +246,6 @@ namespace ControlCatalog.ViewModels
to.IsVisible = true; to.IsVisible = true;
var animation = new Animation var animation = new Animation
{ {
FillMode = FillMode.Forward,
Children = Children =
{ {
new KeyFrame new KeyFrame

2
src/Avalonia.Controls/ContentControl.cs

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

126
src/Avalonia.Controls/TransitioningContentControl.cs

@ -1,33 +1,30 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using Avalonia.Animation; using Avalonia.Animation;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Avalonia.Threading; using Avalonia.Data;
namespace Avalonia.Controls; namespace Avalonia.Controls;
/// <summary> /// <summary>
/// Displays <see cref="ContentControl.Content"/> according to a <see cref="FuncDataTemplate"/>. /// Displays <see cref="ContentControl.Content"/> according to an <see cref="IDataTemplate"/>,
/// Uses <see cref="PageTransition"/> to move between the old and new content values. /// using a <see cref="PageTransition"/> to move between the old and new content.
/// </summary> /// </summary>
public class TransitioningContentControl : ContentControl public class TransitioningContentControl : ContentControl
{ {
private CancellationTokenSource? _lastTransitionCts; private CancellationTokenSource? _currentTransition;
private object? _currentContent; private ContentPresenter? _transitionPresenter;
private Optional<object?> _transitionFrom;
/// <summary> /// <summary>
/// Defines the <see cref="PageTransition"/> property. /// Defines the <see cref="PageTransition"/> property.
/// </summary> /// </summary>
public static readonly StyledProperty<IPageTransition?> PageTransitionProperty = public static readonly StyledProperty<IPageTransition?> PageTransitionProperty =
AvaloniaProperty.Register<TransitioningContentControl, IPageTransition?>(nameof(PageTransition), AvaloniaProperty.Register<TransitioningContentControl, IPageTransition?>(
new CrossFade(TimeSpan.FromSeconds(0.125))); nameof(PageTransition),
defaultValue: new ImmutableCrossFade(TimeSpan.FromMilliseconds(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);
/// <summary> /// <summary>
/// Gets or sets the animation played when content appears and disappears. /// Gets or sets the animation played when content appears and disappears.
@ -38,74 +35,79 @@ public class TransitioningContentControl : ContentControl
set => SetValue(PageTransitionProperty, value); set => SetValue(PageTransitionProperty, value);
} }
/// <summary> protected override Size ArrangeOverride(Size finalSize)
/// Gets the content currently displayed on the screen.
/// </summary>
public object? CurrentContent
{ {
get => _currentContent; var result = base.ArrangeOverride(finalSize);
private set => SetAndRaise(CurrentContentProperty, ref _currentContent, value);
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) if (_transitionFrom.HasValue)
{ {
base.OnAttachedToVisualTree(e); _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); if (!base.RegisterContentPresenter(presenter) &&
presenter is ContentPresenter p &&
_lastTransitionCts?.Cancel(); p.Name == "PART_TransitionContentPresenter")
{
_transitionPresenter = p;
_transitionPresenter.IsVisible = false;
return true;
}
return false;
} }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
base.OnPropertyChanged(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)); _transitionFrom = change.GetOldValue<object?>();
} InvalidateArrange();
else if (change.Property == CurrentContentProperty)
{
UpdateLogicalTree(change.OldValue, change.NewValue);
} }
} }
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> public ImmutableCrossFade(TimeSpan duration) => _inner = new CrossFade(duration);
/// 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);
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"> <ControlTheme x:Key="{x:Type TransitioningContentControl}" TargetType="TransitioningContentControl">
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate> <ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter" <Panel>
Background="{TemplateBinding Background}" <ContentPresenter Name="PART_ContentPresenter"
BorderBrush="{TemplateBinding BorderBrush}" Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"
CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}"
ContentTemplate="{TemplateBinding ContentTemplate}" CornerRadius="{TemplateBinding CornerRadius}"
Content="{TemplateBinding CurrentContent}" ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}" Content="{TemplateBinding Content}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" /> 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> </ControlTemplate>
</Setter> </Setter>
</ControlTheme> </ControlTheme>

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

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

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

@ -1,66 +1,243 @@
using System; using System;
using Avalonia.LogicalTree; using System.Linq;
using Avalonia.UnitTests;
using Xunit;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Animation; 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 namespace Avalonia.Controls.UnitTests
{ {
public class TransitioningContentControlTests public class TransitioningContentControlTests
{ {
[Fact] [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(); ++startedRaised;
target.PageTransition = testTransition; };
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(); private static IDisposable Start()
var newControl = new Control(); {
return UnitTestApplication.Start(
TestServices.MockThreadingInterface.With(
fontManagerImpl: new HeadlessFontManagerStub(),
renderInterface: new HeadlessPlatformRenderInterface(),
textShaperImpl: new HeadlessTextShaperStub()));
}
target.Content = oldControl; private static (TransitioningContentControl, TestTransition) CreateTarget(object content)
Threading.Dispatcher.UIThread.RunJobs(); {
var transition = new TestTransition();
var target = new TransitioningContentControl
{
Content = content,
PageTransition = transition,
Template = CreateTemplate(),
};
Assert.Equal(target, oldControl.GetLogicalParent()); var root = new TestRoot(target);
Assert.Equal(null, newControl.GetLogicalParent()); root.LayoutManager.ExecuteInitialLayoutPass();
return (target, transition);
}
testTransition.BeginTransition += isFrom => private static IControlTemplate CreateTemplate()
{
return new FuncControlTemplate((x, ns) =>
{
return new Panel
{ {
// Old out Children =
if (isFrom)
{ {
Assert.Equal(target, oldControl.GetLogicalParent()); new ContentPresenter
Assert.Equal(null, newControl.GetLogicalParent()); {
} Name = "PART_ContentPresenter",
// New in [!ContentPresenter.ContentProperty] = x[!ContentControl.ContentProperty],
else },
{ new ContentPresenter
Assert.Equal(null, oldControl.GetLogicalParent()); {
Assert.Equal(target, newControl.GetLogicalParent()); Name = "PART_TransitionContentPresenter",
},
} }
}; };
});
}
target.Content = newControl; private static ContentPresenter GetTransitionContentPresenter(TransitioningContentControl target)
Threading.Dispatcher.UIThread.RunJobs(); {
} 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; (c.GetVisualRoot() as ILayoutRoot)?.LayoutManager.ExecuteLayoutPass();
BeginTransition?.Invoke(isFrom); }
return Task.CompletedTask;
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