csharpc-sharpdotnetxamlavaloniauicross-platformcross-platform-xamlavaloniaguimulti-platformuser-interfacedotnetcore
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
558 lines
24 KiB
558 lines
24 KiB
using System;
|
|
using System.Numerics;
|
|
using Avalonia.Animation.Easings;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Controls.PullToRefresh;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Reactive;
|
|
using Avalonia.Rendering.Composition;
|
|
using Avalonia.Rendering.Composition.Animations;
|
|
|
|
namespace Avalonia.Controls
|
|
{
|
|
public class RefreshVisualizer : ContentControl
|
|
{
|
|
private const int DefaultIndicatorSize = 24;
|
|
private const float MinimumIndicatorOpacity = 0.4f;
|
|
private const float ParallaxPositionRatio = 0.5f;
|
|
private double _executingRatio = 0.8;
|
|
|
|
private RefreshVisualizerState _refreshVisualizerState;
|
|
private RefreshInfoProvider? _refreshInfoProvider;
|
|
private IDisposable? _isInteractingSubscription;
|
|
private IDisposable? _interactionRatioSubscription;
|
|
private bool _isInteractingForRefresh;
|
|
private Grid? _root;
|
|
private Control? _content;
|
|
private RefreshVisualizerOrientation _orientation;
|
|
private float _startingRotationAngle;
|
|
private double _interactionRatio;
|
|
private bool _played;
|
|
private ScalarKeyFrameAnimation? _rotateAnimation;
|
|
|
|
private bool IsPullDirectionVertical => PullDirection == PullDirection.TopToBottom || PullDirection == PullDirection.BottomToTop;
|
|
private bool IsPullDirectionFar => PullDirection == PullDirection.BottomToTop || PullDirection == PullDirection.RightToLeft;
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="PullDirection"/> property.
|
|
/// </summary>
|
|
internal static readonly StyledProperty<PullDirection> PullDirectionProperty =
|
|
AvaloniaProperty.Register<RefreshVisualizer, PullDirection>(nameof(PullDirection), PullDirection.TopToBottom);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="RefreshRequested"/> event.
|
|
/// </summary>
|
|
public static readonly RoutedEvent<RefreshRequestedEventArgs> RefreshRequestedEvent =
|
|
RoutedEvent.Register<RefreshVisualizer, RefreshRequestedEventArgs>(nameof(RefreshRequested), RoutingStrategies.Bubble);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="RefreshVisualizerState"/> property.
|
|
/// </summary>
|
|
public static readonly DirectProperty<RefreshVisualizer, RefreshVisualizerState> RefreshVisualizerStateProperty =
|
|
AvaloniaProperty.RegisterDirect<RefreshVisualizer, RefreshVisualizerState>(nameof(RefreshVisualizerState),
|
|
s => s.RefreshVisualizerState);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="Orientation"/> property.
|
|
/// </summary>
|
|
public static readonly DirectProperty<RefreshVisualizer, RefreshVisualizerOrientation> OrientationProperty =
|
|
AvaloniaProperty.RegisterDirect<RefreshVisualizer, RefreshVisualizerOrientation>(nameof(Orientation),
|
|
s => s.Orientation, (s, o) => s.Orientation = o);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="RefreshInfoProvider"/> property.
|
|
/// </summary>
|
|
internal static readonly DirectProperty<RefreshVisualizer, RefreshInfoProvider?> RefreshInfoProviderProperty =
|
|
AvaloniaProperty.RegisterDirect<RefreshVisualizer, RefreshInfoProvider?>(nameof(RefreshInfoProvider),
|
|
s => s.RefreshInfoProvider, (s, o) => s.RefreshInfoProvider = o);
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value that indicates the refresh state of the visualizer.
|
|
/// </summary>
|
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1032", Justification = "False positive")]
|
|
protected RefreshVisualizerState RefreshVisualizerState
|
|
{
|
|
get
|
|
{
|
|
return _refreshVisualizerState;
|
|
}
|
|
private set
|
|
{
|
|
SetAndRaise(RefreshVisualizerStateProperty, ref _refreshVisualizerState, value);
|
|
UpdateContent();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value that indicates the orientation of the visualizer.
|
|
/// </summary>
|
|
public RefreshVisualizerOrientation Orientation
|
|
{
|
|
get
|
|
{
|
|
return _orientation;
|
|
}
|
|
set
|
|
{
|
|
SetAndRaise(OrientationProperty, ref _orientation, value);
|
|
}
|
|
}
|
|
|
|
internal PullDirection PullDirection
|
|
{
|
|
get => GetValue(PullDirectionProperty);
|
|
set => SetValue(PullDirectionProperty, value);
|
|
}
|
|
|
|
internal RefreshInfoProvider? RefreshInfoProvider
|
|
{
|
|
get => _refreshInfoProvider;
|
|
set
|
|
{
|
|
if (_refreshInfoProvider != null)
|
|
{
|
|
_refreshInfoProvider.RenderTransform = null;
|
|
}
|
|
SetAndRaise(RefreshInfoProviderProperty, ref _refreshInfoProvider, value);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Occurs when an update of the content has been initiated.
|
|
/// </summary>
|
|
public event EventHandler<RefreshRequestedEventArgs>? RefreshRequested
|
|
{
|
|
add => AddHandler(RefreshRequestedEvent, value);
|
|
remove => RemoveHandler(RefreshRequestedEvent, value);
|
|
}
|
|
|
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
|
{
|
|
base.OnApplyTemplate(e);
|
|
|
|
this.ClipToBounds = false;
|
|
|
|
_root = e.NameScope.Find<Grid>("PART_Root");
|
|
|
|
if (_root != null)
|
|
{
|
|
_content = Content as Control;
|
|
|
|
if (_content == null)
|
|
{
|
|
_content = new PathIcon()
|
|
{
|
|
Height = DefaultIndicatorSize,
|
|
Width = DefaultIndicatorSize,
|
|
Name = "PART_Icon"
|
|
};
|
|
|
|
_content.Loaded += (s, e) =>
|
|
{
|
|
var composition = ElementComposition.GetElementVisual(_content);
|
|
|
|
if(composition == null)
|
|
return;
|
|
|
|
var compositor = composition.Compositor;
|
|
composition.Opacity = 0;
|
|
|
|
var smoothRotationAnimation
|
|
= compositor.CreateScalarKeyFrameAnimation();
|
|
smoothRotationAnimation.Target = "RotationAngle";
|
|
smoothRotationAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing());
|
|
smoothRotationAnimation.Duration = TimeSpan.FromMilliseconds(100);
|
|
|
|
var opacityAnimation
|
|
= compositor.CreateScalarKeyFrameAnimation();
|
|
opacityAnimation.Target = "Opacity";
|
|
opacityAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing());
|
|
opacityAnimation.Duration = TimeSpan.FromMilliseconds(100);
|
|
|
|
var offsetAnimation = compositor.CreateVector3KeyFrameAnimation();
|
|
offsetAnimation.Target = "Offset";
|
|
offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing());
|
|
offsetAnimation.Duration = TimeSpan.FromMilliseconds(150);
|
|
|
|
var scaleAnimation
|
|
= compositor.CreateVector3KeyFrameAnimation();
|
|
scaleAnimation.Target = "Scale";
|
|
scaleAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing());
|
|
scaleAnimation.Duration = TimeSpan.FromMilliseconds(100);
|
|
|
|
var animation = compositor.CreateImplicitAnimationCollection();
|
|
animation["RotationAngle"] = smoothRotationAnimation;
|
|
animation["Offset"] = offsetAnimation;
|
|
animation["Scale"] = scaleAnimation;
|
|
animation["Opacity"] = opacityAnimation;
|
|
|
|
composition.ImplicitAnimations = animation;
|
|
|
|
UpdateContent();
|
|
};
|
|
|
|
Content = _content;
|
|
}
|
|
else
|
|
{
|
|
RaisePropertyChanged(ContentProperty, null, Content, Data.BindingPriority.Style, false);
|
|
}
|
|
}
|
|
|
|
OnOrientationChanged();
|
|
|
|
UpdateContent();
|
|
}
|
|
|
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
|
{
|
|
base.OnAttachedToVisualTree(e);
|
|
|
|
UpdateContent();
|
|
}
|
|
|
|
private void UpdateContent()
|
|
{
|
|
if (_content != null && _root != null)
|
|
{
|
|
var root = _root;
|
|
var visual = _refreshInfoProvider?.Visual;
|
|
var contentVisual = ElementComposition.GetElementVisual(_content);
|
|
var visualizerVisual = ElementComposition.GetElementVisual(this);
|
|
if (visual != null && contentVisual != null && visualizerVisual != null)
|
|
{
|
|
contentVisual.CenterPoint = new Vector3((float)(_content.Bounds.Width / 2), (float)(_content.Bounds.Height / 2), 0);
|
|
switch (RefreshVisualizerState)
|
|
{
|
|
case RefreshVisualizerState.Idle:
|
|
_played = false;
|
|
if(_rotateAnimation != null)
|
|
{
|
|
_rotateAnimation.IterationBehavior = AnimationIterationBehavior.Count;
|
|
_rotateAnimation = null;
|
|
}
|
|
contentVisual.Opacity = MinimumIndicatorOpacity;
|
|
contentVisual.RotationAngle = _startingRotationAngle;
|
|
visualizerVisual.Offset = IsPullDirectionVertical ?
|
|
new Vector3(visualizerVisual.Offset.X, 0, 0) :
|
|
new Vector3(0, visualizerVisual.Offset.Y, 0);
|
|
visual.Offset = default;
|
|
_content.InvalidateMeasure();
|
|
break;
|
|
case RefreshVisualizerState.Interacting:
|
|
_played = false;
|
|
contentVisual.Opacity = MinimumIndicatorOpacity;
|
|
contentVisual.RotationAngle = (float)(_startingRotationAngle + _interactionRatio * 2 * Math.PI);
|
|
Vector3 offset = default;
|
|
if (IsPullDirectionVertical)
|
|
{
|
|
offset = new Vector3(0, (float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0);
|
|
}
|
|
else
|
|
{
|
|
offset = new Vector3((float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0);
|
|
}
|
|
visual.Offset = offset;
|
|
visualizerVisual.Offset = IsPullDirectionVertical ?
|
|
new Vector3(visualizerVisual.Offset.X, offset.Y, 0) :
|
|
new Vector3(offset.X, visualizerVisual.Offset.Y, 0);
|
|
break;
|
|
case RefreshVisualizerState.Pending:
|
|
contentVisual.Opacity = 1;
|
|
contentVisual.RotationAngle = _startingRotationAngle + (float)(2 * Math.PI);
|
|
if (IsPullDirectionVertical)
|
|
{
|
|
offset = new Vector3(0, (float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0);
|
|
}
|
|
else
|
|
{
|
|
offset = new Vector3((float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0);
|
|
}
|
|
visual.Offset = offset;
|
|
visualizerVisual.Offset = IsPullDirectionVertical ?
|
|
new Vector3(visualizerVisual.Offset.X, offset.Y, 0) :
|
|
new Vector3(offset.X, visualizerVisual.Offset.Y, 0);
|
|
|
|
if (!_played)
|
|
{
|
|
_played = true;
|
|
var scaleAnimation = contentVisual.Compositor!.CreateVector3KeyFrameAnimation();
|
|
scaleAnimation.Target = "Scale";
|
|
scaleAnimation.InsertKeyFrame(0.5f, new Vector3(1.5f, 1.5f, 1));
|
|
scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1));
|
|
scaleAnimation.Duration = TimeSpan.FromSeconds(0.3);
|
|
|
|
contentVisual.StartAnimation("Scale", scaleAnimation);
|
|
}
|
|
break;
|
|
case RefreshVisualizerState.Refreshing:
|
|
_rotateAnimation = contentVisual.Compositor!.CreateScalarKeyFrameAnimation();
|
|
_rotateAnimation.Target = "RotationAngle";
|
|
_rotateAnimation.InsertKeyFrame(0, _startingRotationAngle, new LinearEasing());
|
|
_rotateAnimation.InsertKeyFrame(1, _startingRotationAngle + (float)(2 * Math.PI), new LinearEasing());
|
|
_rotateAnimation.IterationBehavior = AnimationIterationBehavior.Forever;
|
|
_rotateAnimation.StopBehavior = AnimationStopBehavior.LeaveCurrentValue;
|
|
_rotateAnimation.Duration = TimeSpan.FromSeconds(0.5);
|
|
|
|
contentVisual.StartAnimation("RotationAngle", _rotateAnimation);
|
|
contentVisual.Opacity = 1;
|
|
float translationRatio = (float)(_refreshInfoProvider != null ? (1.0f - _refreshInfoProvider.ExecutionRatio) * ParallaxPositionRatio : 1.0f)
|
|
* (IsPullDirectionFar ? -1f : 1f);
|
|
if (IsPullDirectionVertical)
|
|
{
|
|
offset = new Vector3(0, (float)(_executingRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0);
|
|
}
|
|
else
|
|
{
|
|
offset = new Vector3((float)(_executingRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0);
|
|
}
|
|
visual.Offset = offset;
|
|
contentVisual.Offset += IsPullDirectionVertical ? new Vector3(0, (float)(translationRatio * root.Bounds.Height), 0) :
|
|
new Vector3((float)(translationRatio * root.Bounds.Width), 0, 0);
|
|
visualizerVisual.Offset = IsPullDirectionVertical ?
|
|
new Vector3(visualizerVisual.Offset.X, offset.Y, 0) :
|
|
new Vector3(offset.X, visualizerVisual.Offset.Y, 0);
|
|
break;
|
|
case RefreshVisualizerState.Peeking:
|
|
contentVisual.Opacity = 1;
|
|
contentVisual.RotationAngle = _startingRotationAngle;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initiates an update of the content.
|
|
/// </summary>
|
|
public void RequestRefresh()
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Refreshing;
|
|
RefreshInfoProvider?.OnRefreshStarted();
|
|
|
|
RaiseRefreshRequested();
|
|
}
|
|
|
|
private void RefreshCompleted()
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Idle;
|
|
|
|
RefreshInfoProvider?.OnRefreshCompleted();
|
|
}
|
|
|
|
private void RaiseRefreshRequested()
|
|
{
|
|
var refreshArgs = new RefreshRequestedEventArgs(RefreshCompleted, RefreshRequestedEvent);
|
|
|
|
refreshArgs.IncrementCount();
|
|
|
|
RaiseEvent(refreshArgs);
|
|
|
|
refreshArgs.DecrementCount();
|
|
}
|
|
|
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
{
|
|
base.OnPropertyChanged(change);
|
|
|
|
if (change.Property == RefreshInfoProviderProperty)
|
|
{
|
|
OnRefreshInfoProviderChanged();
|
|
}
|
|
else if (change.Property == ContentProperty)
|
|
{
|
|
if (_root != null && _content != null)
|
|
{
|
|
_root.Children.Insert(0, _content);
|
|
_content.VerticalAlignment = Layout.VerticalAlignment.Center;
|
|
_content.HorizontalAlignment = Layout.HorizontalAlignment.Center;
|
|
}
|
|
|
|
UpdateContent();
|
|
}
|
|
else if (change.Property == OrientationProperty)
|
|
{
|
|
OnOrientationChanged();
|
|
|
|
UpdateContent();
|
|
}
|
|
else if (change.Property == BoundsProperty)
|
|
{
|
|
switch (PullDirection)
|
|
{
|
|
case PullDirection.TopToBottom:
|
|
RenderTransform = new TranslateTransform(0, -Bounds.Height);
|
|
break;
|
|
case PullDirection.BottomToTop:
|
|
RenderTransform = new TranslateTransform(0, Bounds.Height);
|
|
break;
|
|
case PullDirection.LeftToRight:
|
|
RenderTransform = new TranslateTransform(-Bounds.Width, 0);
|
|
break;
|
|
case PullDirection.RightToLeft:
|
|
RenderTransform = new TranslateTransform(Bounds.Width, 0);
|
|
break;
|
|
}
|
|
|
|
UpdateContent();
|
|
}
|
|
else if(change.Property == PullDirectionProperty)
|
|
{
|
|
OnOrientationChanged();
|
|
|
|
UpdateContent();
|
|
}
|
|
}
|
|
|
|
private void OnOrientationChanged()
|
|
{
|
|
switch (_orientation)
|
|
{
|
|
case RefreshVisualizerOrientation.Auto:
|
|
switch (PullDirection)
|
|
{
|
|
case PullDirection.TopToBottom:
|
|
case PullDirection.BottomToTop:
|
|
_startingRotationAngle = 0.0f;
|
|
break;
|
|
case PullDirection.LeftToRight:
|
|
_startingRotationAngle = (float)(-Math.PI / 2);
|
|
break;
|
|
case PullDirection.RightToLeft:
|
|
_startingRotationAngle = (float)(Math.PI / 2);
|
|
break;
|
|
}
|
|
break;
|
|
case RefreshVisualizerOrientation.Normal:
|
|
_startingRotationAngle = 0.0f;
|
|
break;
|
|
case RefreshVisualizerOrientation.Rotate90DegreesCounterclockwise:
|
|
_startingRotationAngle = (float)(Math.PI / 2);
|
|
break;
|
|
case RefreshVisualizerOrientation.Rotate270DegreesCounterclockwise:
|
|
_startingRotationAngle = (float)(-Math.PI / 2);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void OnRefreshInfoProviderChanged()
|
|
{
|
|
_isInteractingSubscription?.Dispose();
|
|
_isInteractingSubscription = null;
|
|
_interactionRatioSubscription?.Dispose();
|
|
_interactionRatioSubscription = null;
|
|
|
|
if (RefreshInfoProvider != null)
|
|
{
|
|
_isInteractingSubscription = RefreshInfoProvider.GetObservable(RefreshInfoProvider.IsInteractingForRefreshProperty)
|
|
.Subscribe(InteractingForRefreshObserver);
|
|
|
|
_interactionRatioSubscription = RefreshInfoProvider.GetObservable(RefreshInfoProvider.InteractionRatioProperty)
|
|
.Subscribe(InteractionRatioObserver);
|
|
|
|
var visual = RefreshInfoProvider.Visual;
|
|
|
|
_executingRatio = RefreshInfoProvider.ExecutionRatio;
|
|
}
|
|
else
|
|
{
|
|
_executingRatio = 1;
|
|
}
|
|
}
|
|
|
|
private void InteractionRatioObserver(double obj)
|
|
{
|
|
var wasAtZero = _interactionRatio == 0.0;
|
|
_interactionRatio = obj;
|
|
|
|
if (_isInteractingForRefresh)
|
|
{
|
|
if (RefreshVisualizerState == RefreshVisualizerState.Idle)
|
|
{
|
|
if (wasAtZero)
|
|
{
|
|
if (_interactionRatio > _executingRatio)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Pending;
|
|
}
|
|
else if (_interactionRatio > 0)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Interacting;
|
|
}
|
|
}
|
|
else if (_interactionRatio > 0)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Peeking;
|
|
}
|
|
}
|
|
else if (RefreshVisualizerState == RefreshVisualizerState.Interacting)
|
|
{
|
|
if (_interactionRatio <= 0)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Idle;
|
|
}
|
|
else if (_interactionRatio > _executingRatio)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Pending;
|
|
}
|
|
else
|
|
{
|
|
UpdateContent();
|
|
}
|
|
}
|
|
else if (RefreshVisualizerState == RefreshVisualizerState.Pending)
|
|
{
|
|
if (_interactionRatio <= _executingRatio)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Interacting;
|
|
}
|
|
else if (_interactionRatio <= 0)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Idle;
|
|
}
|
|
else
|
|
{
|
|
UpdateContent();
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (RefreshVisualizerState != RefreshVisualizerState.Refreshing)
|
|
{
|
|
if (_interactionRatio > 0)
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Peeking;
|
|
}
|
|
else
|
|
{
|
|
RefreshVisualizerState = RefreshVisualizerState.Idle;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void InteractingForRefreshObserver(bool obj)
|
|
{
|
|
_isInteractingForRefresh = obj;
|
|
|
|
if (!_isInteractingForRefresh)
|
|
{
|
|
switch (_refreshVisualizerState)
|
|
{
|
|
case RefreshVisualizerState.Pending:
|
|
RequestRefresh();
|
|
break;
|
|
case RefreshVisualizerState.Refreshing:
|
|
// We don't want to interrupt a currently executing refresh.
|
|
break;
|
|
default:
|
|
RefreshVisualizerState = RefreshVisualizerState.Idle;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|