diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index b95b455ca4..4a5f5bc96c 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -135,6 +135,9 @@ + + + diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml new file mode 100644 index 0000000000..f3bf1724b4 --- /dev/null +++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs new file mode 100644 index 0000000000..f9d0328d9a --- /dev/null +++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using ControlCatalog.ViewModels; + +namespace ControlCatalog.Pages +{ + public class RefreshContainerPage : UserControl + { + private RefreshContainerViewModel _viewModel; + + public RefreshContainerPage() + { + this.InitializeComponent(); + + _viewModel = new RefreshContainerViewModel(); + + DataContext = _viewModel; + } + + private async void RefreshContainerPage_RefreshRequested(object? sender, RefreshRequestedEventArgs e) + { + var deferral = e.GetDeferral(); + + await _viewModel.AddToTop(); + + deferral.Complete(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs new file mode 100644 index 0000000000..d4b43043be --- /dev/null +++ b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs @@ -0,0 +1,26 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using Avalonia.Controls.Notifications; +using ControlCatalog.Pages; +using MiniMvvm; + +namespace ControlCatalog.ViewModels +{ + public class RefreshContainerViewModel : ViewModelBase + { + public ObservableCollection Items { get; } + + public RefreshContainerViewModel() + { + Items = new ObservableCollection(Enumerable.Range(1, 200).Select(i => $"Item {i}")); + } + + public async Task AddToTop() + { + await Task.Delay(3000); + Items.Insert(0, $"Item {200 - Items.Count}"); + } + } +} diff --git a/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs new file mode 100644 index 0000000000..fedd07ec32 --- /dev/null +++ b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs @@ -0,0 +1,152 @@ +using Avalonia.Input.GestureRecognizers; + +namespace Avalonia.Input +{ + public class PullGestureRecognizer : StyledElement, IGestureRecognizer + { + private IInputElement? _target; + private IGestureRecognizerActionsDispatcher? _actions; + private Point _initialPosition; + private int _gestureId; + private IPointer? _tracking; + private PullDirection _pullDirection; + + /// + /// Defines the property. + /// + public static readonly DirectProperty PullDirectionProperty = + AvaloniaProperty.RegisterDirect( + nameof(PullDirection), + o => o.PullDirection, + (o, v) => o.PullDirection = v); + + public PullDirection PullDirection + { + get => _pullDirection; + set => SetAndRaise(PullDirectionProperty, ref _pullDirection, value); + } + + public PullGestureRecognizer(PullDirection pullDirection) + { + PullDirection = pullDirection; + } + + public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions) + { + _target = target; + _actions = actions; + + _target?.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble); + _target?.AddHandler(InputElement.PointerReleasedEvent, OnPointerReleased, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble); + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + PointerPressed(e); + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + PointerReleased(e); + } + + public void PointerCaptureLost(IPointer pointer) + { + if (_tracking == pointer) + { + EndPull(); + } + } + + public void PointerMoved(PointerEventArgs e) + { + if (_tracking == e.Pointer && _target is Visual visual) + { + var currentPosition = e.GetPosition(visual); + _actions!.Capture(e.Pointer, this); + + Vector delta = default; + switch (PullDirection) + { + case PullDirection.TopToBottom: + if (currentPosition.Y > _initialPosition.Y) + { + delta = new Vector(0, currentPosition.Y - _initialPosition.Y); + } + break; + case PullDirection.BottomToTop: + if (currentPosition.Y < _initialPosition.Y) + { + delta = new Vector(0, _initialPosition.Y - currentPosition.Y); + } + break; + case PullDirection.LeftToRight: + if (currentPosition.X > _initialPosition.X) + { + delta = new Vector(currentPosition.X - _initialPosition.X, 0); + } + break; + case PullDirection.RightToLeft: + if (currentPosition.X < _initialPosition.X) + { + delta = new Vector(_initialPosition.X - currentPosition.X, 0); + } + break; + } + + _target?.RaiseEvent(new PullGestureEventArgs(_gestureId, delta, PullDirection)); + } + } + + public void PointerPressed(PointerPressedEventArgs e) + { + if (_target != null && _target is Visual visual && (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen)) + { + var position = e.GetPosition(visual); + + var canPull = false; + + var bounds = visual.Bounds; + + switch (PullDirection) + { + case PullDirection.TopToBottom: + canPull = position.Y < bounds.Height * 0.1; + break; + case PullDirection.BottomToTop: + canPull = position.Y > bounds.Height - (bounds.Height * 0.1); + break; + case PullDirection.LeftToRight: + canPull = position.X < bounds.Width * 0.1; + break; + case PullDirection.RightToLeft: + canPull = position.X > bounds.Width - (bounds.Width * 0.1); + break; + } + + if (canPull) + { + _gestureId = PullGestureEventArgs.GetNextFreeId(); + _tracking = e.Pointer; + _initialPosition = position; + } + } + } + + public void PointerReleased(PointerReleasedEventArgs e) + { + if (_tracking == e.Pointer) + { + EndPull(); + } + } + + private void EndPull() + { + _tracking = null; + _initialPosition = default; + + _target?.RaiseEvent(new PullGestureEndedEventArgs(_gestureId, PullDirection)); + } + } +} diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index 496052ad35..1ea88fe824 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -46,6 +46,14 @@ namespace Avalonia.Input private static readonly WeakReference s_lastPress = new WeakReference(null); private static Point s_lastPressPoint; + public static readonly RoutedEvent PullGestureEvent = + RoutedEvent.Register( + "PullGesture", RoutingStrategies.Bubble, typeof(Gestures)); + + public static readonly RoutedEvent PullGestureEndedEvent = + RoutedEvent.Register( + "PullGestureEnded", RoutingStrategies.Bubble, typeof(Gestures)); + static Gestures() { InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed); diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index d0130258c3..fa755277cc 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -442,6 +442,11 @@ namespace Avalonia.Input { SetAndRaise(IsEffectivelyEnabledProperty, ref _isEffectivelyEnabled, value); PseudoClasses.Set(":disabled", !value); + + if (!IsEffectivelyEnabled && FocusManager.Instance?.Current == this) + { + FocusManager.Instance?.Focus(null); + } } } diff --git a/src/Avalonia.Base/Input/PullGestureEventArgs.cs b/src/Avalonia.Base/Input/PullGestureEventArgs.cs new file mode 100644 index 0000000000..34d95c87f4 --- /dev/null +++ b/src/Avalonia.Base/Input/PullGestureEventArgs.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia.Interactivity; + +namespace Avalonia.Input +{ + public class PullGestureEventArgs : RoutedEventArgs + { + public int Id { get; } + public Vector Delta { get; } + public PullDirection PullDirection { get; } + + private static int _nextId = 1; + + internal static int GetNextFreeId() => _nextId++; + + public PullGestureEventArgs(int id, Vector delta, PullDirection pullDirection) : base(Gestures.PullGestureEvent) + { + Id = id; + Delta = delta; + PullDirection = pullDirection; + } + } + + public class PullGestureEndedEventArgs : RoutedEventArgs + { + public int Id { get; } + public PullDirection PullDirection { get; } + + public PullGestureEndedEventArgs(int id, PullDirection pullDirection) : base(Gestures.PullGestureEndedEvent) + { + Id = id; + PullDirection = pullDirection; + } + } + + public enum PullDirection + { + TopToBottom, + BottomToTop, + LeftToRight, + RightToLeft + } +} diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 7694119589..e3dc4fbb75 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -89,6 +89,14 @@ namespace Avalonia public static readonly StyledProperty RenderTransformOriginProperty = AvaloniaProperty.Register(nameof(RenderTransformOrigin), defaultValue: RelativePoint.Center); + /// + /// Defines the property. + /// + public static readonly AttachedProperty FlowDirectionProperty = + AvaloniaProperty.RegisterAttached( + nameof(FlowDirection), + inherits: true); + /// /// Defines the property. /// @@ -263,6 +271,15 @@ namespace Avalonia set { SetValue(RenderTransformOriginProperty, value); } } + /// + /// Gets or sets the text flow direction. + /// + public FlowDirection FlowDirection + { + get => GetValue(FlowDirectionProperty); + set => SetValue(FlowDirectionProperty, value); + } + /// /// Gets or sets the Z index of the control. /// @@ -306,6 +323,36 @@ namespace Avalonia /// internal Visual? VisualParent => _visualParent; + /// + /// Gets a value indicating whether control bypass FlowDirecton policies. + /// + /// + /// Related to FlowDirection system and returns false as default, so if + /// is RTL then control will get a mirror presentation. + /// For controls that want to avoid this behavior, override this property and return true. + /// + protected virtual bool BypassFlowDirectionPolicies => false; + + /// + /// Gets the value of the attached on a control. + /// + /// The control. + /// The flow direction. + public static FlowDirection GetFlowDirection(Visual visual) + { + return visual.GetValue(FlowDirectionProperty); + } + + /// + /// Sets the value of the attached on a control. + /// + /// The control. + /// The property value to set. + public static void SetFlowDirection(Visual visual, FlowDirection value) + { + visual.SetValue(FlowDirectionProperty, value); + } + /// /// Invalidates the visual and queues a repaint. /// @@ -387,6 +434,22 @@ namespace Avalonia } } + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FlowDirectionProperty) + { + InvalidateMirrorTransform(); + + foreach (var child in VisualChildren) + { + child.InvalidateMirrorTransform(); + } + } + } + protected override void LogicalChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { base.LogicalChildrenCollectionChanged(sender, e); @@ -682,5 +745,32 @@ namespace Avalonia visual.SetVisualParent(parent); } } + + /// + /// Computes the value according to the + /// and + /// + public virtual void InvalidateMirrorTransform() + { + var flowDirection = this.FlowDirection; + var parentFlowDirection = FlowDirection.LeftToRight; + + bool bypassFlowDirectionPolicies = BypassFlowDirectionPolicies; + bool parentBypassFlowDirectionPolicies = false; + + var parent = VisualParent; + if (parent != null) + { + parentFlowDirection = parent.FlowDirection; + parentBypassFlowDirectionPolicies = parent.BypassFlowDirectionPolicies; + } + + bool thisShouldBeMirrored = flowDirection == FlowDirection.RightToLeft && !bypassFlowDirectionPolicies; + bool parentShouldBeMirrored = parentFlowDirection == FlowDirection.RightToLeft && !parentBypassFlowDirectionPolicies; + + bool shouldApplyMirrorTransform = thisShouldBeMirrored != parentShouldBeMirrored; + + HasMirrorTransform = shouldApplyMirrorTransform; + } } } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 2b407cc42a..f02df2e9c1 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -454,10 +454,9 @@ namespace Avalonia.Controls { if (SelectionBoxItem is Rectangle rectangle) { - if ((rectangle.Fill as VisualBrush)?.Visual is Control content) + if ((rectangle.Fill as VisualBrush)?.Visual is Visual content) { - var flowDirection = (((Visual)content!).VisualParent as Control)?.FlowDirection ?? - FlowDirection.LeftToRight; + var flowDirection = content.VisualParent?.FlowDirection ?? FlowDirection.LeftToRight; rectangle.FlowDirection = flowDirection; } } diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 063e6ae7c8..88c9823952 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -91,13 +91,6 @@ namespace Avalonia.Controls RoutedEvent.Register( nameof(SizeChanged), RoutingStrategies.Direct); - /// - /// Defines the property. - /// - public static readonly AttachedProperty FlowDirectionProperty = - AvaloniaProperty.RegisterAttached( - nameof(FlowDirection), - inherits: true); // Note the following: // _loadedQueue : @@ -170,15 +163,6 @@ namespace Avalonia.Controls get => GetValue(TagProperty); set => SetValue(TagProperty, value); } - - /// - /// Gets or sets the text flow direction. - /// - public FlowDirection FlowDirection - { - get => GetValue(FlowDirectionProperty); - set => SetValue(FlowDirectionProperty, value); - } /// /// Occurs when the user has completed a context input gesture, such as a right-click. @@ -229,39 +213,9 @@ namespace Avalonia.Controls public new Control? Parent => (Control?)base.Parent; - /// - /// Gets the value of the attached on a control. - /// - /// The control. - /// The flow direction. - public static FlowDirection GetFlowDirection(Control control) - { - return control.GetValue(FlowDirectionProperty); - } - - /// - /// Sets the value of the attached on a control. - /// - /// The control. - /// The property value to set. - public static void SetFlowDirection(Control control, FlowDirection value) - { - control.SetValue(FlowDirectionProperty, value); - } - /// bool IDataTemplateHost.IsDataTemplatesInitialized => _dataTemplates != null; - /// - /// Gets a value indicating whether control bypass FlowDirecton policies. - /// - /// - /// Related to FlowDirection system and returns false as default, so if - /// is RTL then control will get a mirror presentation. - /// For controls that want to avoid this behavior, override this property and return true. - /// - protected virtual bool BypassFlowDirectionPolicies => false; - /// void ISetterValue.Initialize(ISetter setter) { @@ -571,45 +525,6 @@ namespace Avalonia.Controls RaiseEvent(sizeChangedEventArgs); } } - else if (change.Property == FlowDirectionProperty) - { - InvalidateMirrorTransform(); - - foreach (var visual in VisualChildren) - { - if (visual is Control child) - { - child.InvalidateMirrorTransform(); - } - } - } - } - - /// - /// Computes the value according to the - /// and - /// - public virtual void InvalidateMirrorTransform() - { - var flowDirection = this.FlowDirection; - var parentFlowDirection = FlowDirection.LeftToRight; - - bool bypassFlowDirectionPolicies = BypassFlowDirectionPolicies; - bool parentBypassFlowDirectionPolicies = false; - - var parent = this.VisualParent as Control; - if (parent != null) - { - parentFlowDirection = parent.FlowDirection; - parentBypassFlowDirectionPolicies = parent.BypassFlowDirectionPolicies; - } - - bool thisShouldBeMirrored = flowDirection == FlowDirection.RightToLeft && !bypassFlowDirectionPolicies; - bool parentShouldBeMirrored = parentFlowDirection == FlowDirection.RightToLeft && !parentBypassFlowDirectionPolicies; - - bool shouldApplyMirrorTransform = thisShouldBeMirrored != parentShouldBeMirrored; - - HasMirrorTransform = shouldApplyMirrorTransform; } } } diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshCompletionDeferral.cs b/src/Avalonia.Controls/PullToRefresh/RefreshCompletionDeferral.cs new file mode 100644 index 0000000000..a18b3c2934 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshCompletionDeferral.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; + +namespace Avalonia.Controls +{ + /// + /// Deferral class for notify that a work done in RefreshRequested event is done. + /// + public class RefreshCompletionDeferral + { + private Action _deferredAction; + private int _deferCount; + + public RefreshCompletionDeferral(Action deferredAction) + { + _deferredAction = deferredAction; + } + + public void Complete() + { + Interlocked.Decrement(ref _deferCount); + + if (_deferCount == 0) + { + _deferredAction?.Invoke(); + } + } + + public RefreshCompletionDeferral Get() + { + Interlocked.Increment(ref _deferCount); + + return this; + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs new file mode 100644 index 0000000000..d0b8178add --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs @@ -0,0 +1,252 @@ +using System; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.PullToRefresh; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace Avalonia.Controls +{ + /// + /// Represents a container control that provides a and pull-to-refresh functionality for scrollable content. + /// + public class RefreshContainer : ContentControl + { + internal const int DefaultPullDimensionSize = 100; + + private bool _hasDefaultRefreshInfoProviderAdapter; + + private ScrollViewerIRefreshInfoProviderAdapter? _refreshInfoProviderAdapter; + private RefreshInfoProvider? _refreshInfoProvider; + private IDisposable? _visualizerSizeSubscription; + private Grid? _visualizerPresenter; + private RefreshVisualizer? _refreshVisualizer; + private bool _hasDefaultRefreshVisualizer; + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshRequestedEvent = + RoutedEvent.Register(nameof(RefreshRequested), RoutingStrategies.Bubble); + + internal static readonly DirectProperty RefreshInfoProviderAdapterProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshInfoProviderAdapter), + (s) => s.RefreshInfoProviderAdapter, (s, o) => s.RefreshInfoProviderAdapter = o); + + /// + /// Defines the event. + /// + public static readonly DirectProperty VisualizerProperty = + AvaloniaProperty.RegisterDirect(nameof(Visualizer), + s => s.Visualizer, (s, o) => s.Visualizer = o); + + /// + /// Defines the event. + /// + public static readonly StyledProperty PullDirectionProperty = + AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom); + + internal ScrollViewerIRefreshInfoProviderAdapter? RefreshInfoProviderAdapter + { + get => _refreshInfoProviderAdapter; set + { + _hasDefaultRefreshInfoProviderAdapter = false; + SetAndRaise(RefreshInfoProviderAdapterProperty, ref _refreshInfoProviderAdapter, value); + } + } + + /// + /// Gets or sets the for this container. + /// + public RefreshVisualizer? Visualizer + { + get => _refreshVisualizer; set + { + if (_refreshVisualizer != null) + { + _visualizerSizeSubscription?.Dispose(); + _refreshVisualizer.RefreshRequested -= Visualizer_RefreshRequested; + } + + SetAndRaise(VisualizerProperty, ref _refreshVisualizer, value); + } + } + + /// + /// Gets or sets a value that specifies the direction to pull to initiate a refresh. + /// + public PullDirection PullDirection + { + get => GetValue(PullDirectionProperty); + set => SetValue(PullDirectionProperty, value); + } + + /// + /// Occurs when an update of the content has been initiated. + /// + public event EventHandler? RefreshRequested + { + add => AddHandler(RefreshRequestedEvent, value); + remove => RemoveHandler(RefreshRequestedEvent, value); + } + + public RefreshContainer() + { + _hasDefaultRefreshInfoProviderAdapter = true; + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _visualizerPresenter = e.NameScope.Find("PART_RefreshVisualizerPresenter"); + + if (_refreshVisualizer == null) + { + _hasDefaultRefreshVisualizer = true; + Visualizer = new RefreshVisualizer(); + } + else + { + _hasDefaultRefreshVisualizer = false; + RaisePropertyChanged(VisualizerProperty, default, _refreshVisualizer); + } + + OnPullDirectionChanged(); + } + + private void OnVisualizerSizeChanged(Rect obj) + { + if (_hasDefaultRefreshInfoProviderAdapter) + { + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); + } + } + + private void Visualizer_RefreshRequested(object? sender, RefreshRequestedEventArgs e) + { + var ev = new RefreshRequestedEventArgs(e.GetDeferral(), RefreshRequestedEvent); + RaiseEvent(ev); + ev.DecrementCount(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RefreshInfoProviderAdapterProperty) + { + if (_refreshVisualizer != null) + { + if (_refreshInfoProvider != null) + { + _refreshVisualizer.RefreshInfoProvider = _refreshInfoProvider; + } + else + { + if (RefreshInfoProviderAdapter != null && _refreshVisualizer != null) + { + _refreshInfoProvider = RefreshInfoProviderAdapter?.AdaptFromTree(this, _refreshVisualizer.Bounds.Size); + + if (_refreshInfoProvider != null) + { + _refreshVisualizer.RefreshInfoProvider = _refreshInfoProvider; + RefreshInfoProviderAdapter?.SetAnimations(_refreshVisualizer); + } + } + } + } + } + else if (change.Property == VisualizerProperty) + { + if (_visualizerPresenter != null) + { + _visualizerPresenter.Children.Clear(); + if (_refreshVisualizer != null) + { + _visualizerPresenter.Children.Add(_refreshVisualizer); + } + } + + if (_refreshVisualizer != null) + { + _refreshVisualizer.RefreshRequested += Visualizer_RefreshRequested; + _visualizerSizeSubscription = _refreshVisualizer.GetObservable(Control.BoundsProperty).Subscribe(OnVisualizerSizeChanged); + } + } + else if (change.Property == PullDirectionProperty) + { + OnPullDirectionChanged(); + } + } + + private void OnPullDirectionChanged() + { + if (_visualizerPresenter != null && _refreshVisualizer != null) + { + switch (PullDirection) + { + case PullDirection.TopToBottom: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Top; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Stretch; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.TopToBottom; + _refreshVisualizer.Height = DefaultPullDimensionSize; + _refreshVisualizer.Width = double.NaN; + } + break; + case PullDirection.BottomToTop: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Bottom; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Stretch; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.BottomToTop; + _refreshVisualizer.Height = DefaultPullDimensionSize; + _refreshVisualizer.Width = double.NaN; + } + break; + case PullDirection.LeftToRight: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Stretch; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Left; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.LeftToRight; + _refreshVisualizer.Width = DefaultPullDimensionSize; + _refreshVisualizer.Height = double.NaN; + } + break; + case PullDirection.RightToLeft: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Stretch; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Right; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.RightToLeft; + _refreshVisualizer.Width = DefaultPullDimensionSize; + _refreshVisualizer.Height = double.NaN; + } + break; + } + + if (_hasDefaultRefreshInfoProviderAdapter && + _hasDefaultRefreshVisualizer && + _refreshVisualizer.Bounds.Height == DefaultPullDimensionSize && + _refreshVisualizer.Bounds.Width == DefaultPullDimensionSize) + { + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); + } + } + } + + /// + /// Initiates an update of the content. + /// + public void RequestRefresh() + { + _refreshVisualizer?.RequestRefresh(); + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs new file mode 100644 index 0000000000..847f011fa9 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs @@ -0,0 +1,141 @@ +using System; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Controls.PullToRefresh +{ + internal class RefreshInfoProvider : Interactive + { + internal const double DefaultExecutionRatio = 0.8; + + private readonly PullDirection _refreshPullDirection; + private readonly Size _refreshVisualizerSize; + + private readonly CompositionVisual? _visual; + private bool _isInteractingForRefresh; + private double _interactionRatio; + private bool _entered; + + public DirectProperty IsInteractingForRefreshProperty = + AvaloniaProperty.RegisterDirect(nameof(IsInteractingForRefresh), + s => s.IsInteractingForRefresh, (s, o) => s.IsInteractingForRefresh = o); + + + public DirectProperty ExecutionRatioProperty = + AvaloniaProperty.RegisterDirect(nameof(ExecutionRatio), + s => s.ExecutionRatio); + + public DirectProperty InteractionRatioProperty = + AvaloniaProperty.RegisterDirect(nameof(InteractionRatio), + s => s.InteractionRatio, (s, o) => s.InteractionRatio = o); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshStartedEvent = + RoutedEvent.Register(nameof(RefreshStarted), RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshCompletedEvent = + RoutedEvent.Register(nameof(RefreshCompleted), RoutingStrategies.Bubble); + + public bool PeekingMode { get; internal set; } + + public bool IsInteractingForRefresh + { + get => _isInteractingForRefresh; + internal set + { + var isInteractingForRefresh = value && !PeekingMode; + + if (isInteractingForRefresh != _isInteractingForRefresh) + { + SetAndRaise(IsInteractingForRefreshProperty, ref _isInteractingForRefresh, isInteractingForRefresh); + } + } + } + + public double InteractionRatio + { + get => _interactionRatio; + set + { + SetAndRaise(InteractionRatioProperty, ref _interactionRatio, value); + } + } + + public double ExecutionRatio + { + get => DefaultExecutionRatio; + } + + internal CompositionVisual? Visual => _visual; + + public event EventHandler RefreshStarted + { + add => AddHandler(RefreshStartedEvent, value); + remove => RemoveHandler(RefreshStartedEvent, value); + } + + public event EventHandler RefreshCompleted + { + add => AddHandler(RefreshCompletedEvent, value); + remove => RemoveHandler(RefreshCompletedEvent, value); + } + + internal void InteractingStateEntered(object? sender, PullGestureEventArgs e) + { + if (!_entered) + { + IsInteractingForRefresh = true; + _entered = true; + } + + ValuesChanged(e.Delta); + } + + internal void InteractingStateExited(object? sender, PullGestureEndedEventArgs e) + { + IsInteractingForRefresh = false; + _entered = false; + + ValuesChanged(default); + } + + + public RefreshInfoProvider(PullDirection refreshPullDirection, Size? refreshVIsualizerSize, CompositionVisual? visual) + { + _refreshPullDirection = refreshPullDirection; + _refreshVisualizerSize = refreshVIsualizerSize ?? default; + _visual = visual; + } + + public void OnRefreshStarted() + { + RaiseEvent(new RoutedEventArgs(RefreshStartedEvent)); + } + + public void OnRefreshCompleted() + { + RaiseEvent(new RoutedEventArgs(RefreshCompletedEvent)); + } + + internal void ValuesChanged(Vector value) + { + switch (_refreshPullDirection) + { + case PullDirection.TopToBottom: + case PullDirection.BottomToTop: + InteractionRatio = _refreshVisualizerSize.Height == 0 ? 1 : Math.Min(1, value.Y / _refreshVisualizerSize.Height); + break; + case PullDirection.LeftToRight: + case PullDirection.RightToLeft: + InteractionRatio = _refreshVisualizerSize.Height == 0 ? 1 : Math.Min(1, value.X / _refreshVisualizerSize.Width); + break; + } + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshRequestedEventArgs.cs b/src/Avalonia.Controls/PullToRefresh/RefreshRequestedEventArgs.cs new file mode 100644 index 0000000000..4bb25d3b2c --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshRequestedEventArgs.cs @@ -0,0 +1,42 @@ +using System; +using Avalonia.Interactivity; + +namespace Avalonia.Controls +{ + /// + /// Provides event data for RefreshRequested events. + /// + public class RefreshRequestedEventArgs : RoutedEventArgs + { + private RefreshCompletionDeferral _refreshCompletionDeferral; + + /// + /// Gets a deferral object for managing the work done in the RefreshRequested event handler. + /// + /// A object + public RefreshCompletionDeferral GetDeferral() + { + return _refreshCompletionDeferral.Get(); + } + + public RefreshRequestedEventArgs(Action deferredAction, RoutedEvent? routedEvent) : base(routedEvent) + { + _refreshCompletionDeferral = new RefreshCompletionDeferral(deferredAction); + } + + public RefreshRequestedEventArgs(RefreshCompletionDeferral completionDeferral, RoutedEvent? routedEvent) : base(routedEvent) + { + _refreshCompletionDeferral = completionDeferral; + } + + internal void IncrementCount() + { + _refreshCompletionDeferral?.Get(); + } + + internal void DecrementCount() + { + _refreshCompletionDeferral?.Complete(); + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs new file mode 100644 index 0000000000..f2f735aaa9 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs @@ -0,0 +1,553 @@ +using System; +using System.Numerics; +using System.Reactive.Linq; +using Avalonia.Animation.Easings; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.PullToRefresh; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +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; + + /// + /// Defines the property. + /// + public static readonly StyledProperty PullDirectionProperty = + AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshRequestedEvent = + RoutedEvent.Register(nameof(RefreshRequested), RoutingStrategies.Bubble); + + /// + /// Defines the property. + /// + public static readonly DirectProperty RefreshVisualizerStateProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshVisualizerState), + s => s.RefreshVisualizerState); + + /// + /// Defines the property. + /// + public static readonly DirectProperty OrientationProperty = + AvaloniaProperty.RegisterDirect(nameof(Orientation), + s => s.Orientation, (s, o) => s.Orientation = o); + + /// + /// Defines the property. + /// + internal DirectProperty RefreshInfoProviderProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshInfoProvider), + s => s.RefreshInfoProvider, (s, o) => s.RefreshInfoProvider = o); + + /// + /// Gets or sets a value that indicates the refresh state of the visualizer. + /// + protected RefreshVisualizerState RefreshVisualizerState + { + get + { + return _refreshVisualizerState; + } + private set + { + SetAndRaise(RefreshVisualizerStateProperty, ref _refreshVisualizerState, value); + UpdateContent(); + } + } + + /// + /// Gets or sets a value that indicates the orientation of the visualizer. + /// + 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); + } + } + + /// + /// Occurs when an update of the content has been initiated. + /// + public event EventHandler? 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("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); + 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; + } + } + } + } + + /// + /// Initiates an update of the content. + /// + 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; + } + } + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerOrientation.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerOrientation.cs new file mode 100644 index 0000000000..1ea37f67b9 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerOrientation.cs @@ -0,0 +1,13 @@ +namespace Avalonia.Controls +{ + /// + /// Defines constants that specify the orientation of a RefreshVisualizer. + /// + public enum RefreshVisualizerOrientation + { + Auto, + Normal, + Rotate90DegreesCounterclockwise, + Rotate270DegreesCounterclockwise + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerState.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerState.cs new file mode 100644 index 0000000000..5ab52f4de6 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerState.cs @@ -0,0 +1,14 @@ +namespace Avalonia.Controls +{ + /// + /// Defines constants that specify the state of a RefreshVisualizer + /// + public enum RefreshVisualizerState + { + Idle, + Peeking, + Interacting, + Pending, + Refreshing + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs new file mode 100644 index 0000000000..c3aebc82c5 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs @@ -0,0 +1,274 @@ +using System; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Rendering.Composition; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.PullToRefresh +{ + internal class ScrollViewerIRefreshInfoProviderAdapter + { + private const int MaxSearchDepth = 10; + private const int InitialOffsetThreshold = 1; + + private PullDirection _refreshPullDirection; + private ScrollViewer? _scrollViewer; + private RefreshInfoProvider? _refreshInfoProvider; + private PullGestureRecognizer? _pullGestureRecognizer; + private InputElement? _interactionSource; + private bool _isVisualizerInteractionSourceAttached; + + public ScrollViewerIRefreshInfoProviderAdapter(PullDirection pullDirection) + { + _refreshPullDirection = pullDirection; + } + + public RefreshInfoProvider? AdaptFromTree(Visual root, Size? refreshVIsualizerSize) + { + if (root is ScrollViewer scrollViewer) + { + return Adapt(scrollViewer, refreshVIsualizerSize); + } + else + { + int depth = 0; + while (depth < MaxSearchDepth) + { + var scroll = AdaptFromTreeRecursiveHelper(root, depth); + + if (scroll != null) + { + return Adapt(scroll, refreshVIsualizerSize); + } + + depth++; + } + } + + ScrollViewer? AdaptFromTreeRecursiveHelper(Visual root, int depth) + { + if (depth == 0) + { + foreach (var child in root.VisualChildren) + { + if (child is ScrollViewer viewer) + { + return viewer; + } + } + } + else + { + foreach (var child in root.VisualChildren) + { + var viewer = AdaptFromTreeRecursiveHelper(child, depth - 1); + if (viewer != null) + { + return viewer; + } + } + } + + return null; + } + + return null; + } + + public RefreshInfoProvider Adapt(ScrollViewer adaptee, Size? refreshVIsualizerSize) + { + if (adaptee == null) + { + throw new ArgumentNullException(nameof(adaptee), "Adaptee cannot be null"); + } + + if (_scrollViewer != null) + { + CleanUpScrollViewer(); + } + + if (_refreshInfoProvider != null && _interactionSource != null) + { + _interactionSource.RemoveHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered); + _interactionSource.RemoveHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); + } + + _refreshInfoProvider = null; + _scrollViewer = adaptee; + + if (_scrollViewer.Content == null) + { + throw new ArgumentException(nameof(adaptee), "Adaptee's content property cannot be null."); + } + + var content = adaptee.Content as Visual; + + if (content == null) + { + throw new ArgumentException(nameof(adaptee), "Adaptee's content property must be a Visual"); + } + + if (content.GetVisualParent() == null) + { + _scrollViewer.Loaded += ScrollViewer_Loaded; + } + else + { + ScrollViewer_Loaded(null, null); + + if (content.Parent is not InputElement) + { + throw new ArgumentException(nameof(adaptee), "Adaptee's content's parent must be a InputElement"); + } + } + + _refreshInfoProvider = new RefreshInfoProvider(_refreshPullDirection, refreshVIsualizerSize, ElementComposition.GetElementVisual(content)); + + _pullGestureRecognizer = new PullGestureRecognizer(_refreshPullDirection); + + if (_interactionSource != null) + { + _interactionSource.GestureRecognizers.Add(_pullGestureRecognizer); + _interactionSource.AddHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered); + _interactionSource.AddHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); + _isVisualizerInteractionSourceAttached = true; + } + + _scrollViewer.PointerPressed += ScrollViewer_PointerPressed; + _scrollViewer.PointerReleased += ScrollViewer_PointerReleased; + _scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged; + + return _refreshInfoProvider; + } + + private void ScrollViewer_ScrollChanged(object? sender, ScrollChangedEventArgs e) + { + if (_isVisualizerInteractionSourceAttached && _refreshInfoProvider != null && _refreshInfoProvider.IsInteractingForRefresh) + { + if (!IsWithinOffsetThreashold()) + { + _refreshInfoProvider.IsInteractingForRefresh = false; + } + } + } + + public void SetAnimations(RefreshVisualizer refreshVisualizer) + { + var visualizerComposition = ElementComposition.GetElementVisual(refreshVisualizer); + if (visualizerComposition != null) + { + var compositor = visualizerComposition.Compositor; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(150); + + var animation = compositor.CreateImplicitAnimationCollection(); + animation["Offset"] = offsetAnimation; + visualizerComposition.ImplicitAnimations = animation; + } + + if(_scrollViewer != null && _scrollViewer.Content is Visual visual) + { + var scollContentComposition = ElementComposition.GetElementVisual(visual); + + if(scollContentComposition != null) + { + var compositor = scollContentComposition.Compositor; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(150); + + var animation = compositor.CreateImplicitAnimationCollection(); + animation["Offset"] = offsetAnimation; + scollContentComposition.ImplicitAnimations = animation; + } + } + } + + private void ScrollViewer_Loaded(object? sender, RoutedEventArgs? e) + { + var content = _scrollViewer?.Content as Visual; + if (content == null) + { + throw new ArgumentException(nameof(_scrollViewer), "Adaptee's content property must be a Visual"); + } + + if (content.Parent is not InputElement) + { + throw new ArgumentException(nameof(_scrollViewer), "Adaptee's content parent must be an InputElement"); + } + + MakeInteractionSource(content.Parent as InputElement); + + if (_scrollViewer != null) + { + _scrollViewer.Loaded -= ScrollViewer_Loaded; + } + } + + private void MakeInteractionSource(InputElement? element) + { + _interactionSource = element; + + if (_pullGestureRecognizer != null && _refreshInfoProvider != null) + { + element?.GestureRecognizers.Add(_pullGestureRecognizer); + _interactionSource?.AddHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered); + _interactionSource?.AddHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); + _isVisualizerInteractionSourceAttached = true; + } + } + + private void ScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.IsInteractingForRefresh = false; + } + } + + private void ScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.PeekingMode = !IsWithinOffsetThreashold(); + } + } + + private bool IsWithinOffsetThreashold() + { + if (_scrollViewer != null) + { + var offset = _scrollViewer.Offset; + + switch (_refreshPullDirection) + { + case PullDirection.TopToBottom: + return offset.Y < InitialOffsetThreshold; + case PullDirection.LeftToRight: + return offset.X < InitialOffsetThreshold; + case PullDirection.RightToLeft: + return offset.X > _scrollViewer.Extent.Width - _scrollViewer.Viewport.Width - InitialOffsetThreshold; + case PullDirection.BottomToTop: + return offset.Y > _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height - InitialOffsetThreshold; + } + } + + return false; + } + + private void CleanUpScrollViewer() + { + if (_scrollViewer != null) + { + _scrollViewer.PointerPressed -= ScrollViewer_PointerPressed; + _scrollViewer.PointerReleased -= ScrollViewer_PointerReleased; + _scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged; + } + } + } +} diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 59ad696148..d2d5a8309c 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -357,12 +357,6 @@ namespace Avalonia.Controls /// protected virtual ILayoutManager CreateLayoutManager() => new LayoutManager(this); - public override void InvalidateMirrorTransform() - { - } - - protected override bool BypassFlowDirectionPolicies => true; - /// /// Handles a paint notification from . /// diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 559d674c02..a893c74324 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -176,6 +176,8 @@ namespace Avalonia.Controls private object? _dialogResult; private readonly Size _maxPlatformClientSize; private WindowStartupLocation _windowStartupLocation; + private bool _shown; + private bool _showingAsDialog; /// /// Initializes static members of the class. @@ -508,6 +510,8 @@ namespace Avalonia.Controls Owner = null; PlatformImpl?.Dispose(); + + _showingAsDialog = false; } private bool ShouldCancelClose(CancelEventArgs? args = null) @@ -563,29 +567,33 @@ namespace Avalonia.Controls /// public override void Hide() { - if (!IsVisible) + using (FreezeVisibilityChangeHandling()) { - return; - } + if (!_shown) + { + return; + } - Renderer?.Stop(); + Renderer?.Stop(); - if (Owner is Window owner) - { - owner.RemoveChild(this); - } + if (Owner is Window owner) + { + owner.RemoveChild(this); + } - if (_children.Count > 0) - { - foreach (var child in _children.ToArray()) + if (_children.Count > 0) { - child.child.Hide(); + foreach (var child in _children.ToArray()) + { + child.child.Hide(); + } } - } - Owner = null; - PlatformImpl?.Hide(); - IsVisible = false; + Owner = null; + PlatformImpl?.Hide(); + IsVisible = false; + _shown = false; + } } /// @@ -599,81 +607,124 @@ namespace Avalonia.Controls ShowCore(null); } + protected override void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) + { + if (!IgnoreVisibilityChanges) + { + var isVisible = e.GetNewValue(); + + if (_shown != isVisible) + { + if(!_shown) + { + Show(); + } + else + { + if (_showingAsDialog) + { + Close(false); + } + else + { + Hide(); + } + } + } + } + } + /// - /// Shows the window as a child of . + /// Shows the window as a child of . /// - /// Window that will be a parent of the shown window. + /// Window that will be the owner of the shown window. /// /// The window has already been closed. /// - public void Show(Window parent) + public void Show(Window owner) { - if (parent is null) + if (owner is null) { - throw new ArgumentNullException(nameof(parent), "Showing a child window requires valid parent."); + throw new ArgumentNullException(nameof(owner), "Showing a child window requires valid parent."); } - ShowCore(parent); + ShowCore(owner); } - private void ShowCore(Window? parent) + private void EnsureStateBeforeShow() { if (PlatformImpl == null) { throw new InvalidOperationException("Cannot re-show a closed window."); } + } + + private void EnsureParentStateBeforeShow(Window owner) + { + if (owner.PlatformImpl == null) + { + throw new InvalidOperationException("Cannot show a window with a closed owner."); + } - if (parent != null) + if (owner == this) { - if (parent.PlatformImpl == null) - { - throw new InvalidOperationException("Cannot show a window with a closed parent."); - } - else if (parent == this) - { - throw new InvalidOperationException("A Window cannot be its own parent."); - } - else if (!parent.IsVisible) - { - throw new InvalidOperationException("Cannot show window with non-visible parent."); - } + throw new InvalidOperationException("A Window cannot be its own owner."); } - if (IsVisible) + if (!owner.IsVisible) { - return; + throw new InvalidOperationException("Cannot show window with non-visible owner."); } + } - RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); + private void ShowCore(Window? owner) + { + using (FreezeVisibilityChangeHandling()) + { + EnsureStateBeforeShow(); - EnsureInitialized(); - ApplyStyling(); - IsVisible = true; + if (owner != null) + { + EnsureParentStateBeforeShow(owner); + } - var initialSize = new Size( - double.IsNaN(Width) ? Math.Max(MinWidth, ClientSize.Width) : Width, - double.IsNaN(Height) ? Math.Max(MinHeight, ClientSize.Height) : Height); + if (_shown) + { + return; + } - if (initialSize != ClientSize) - { - PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); - } + RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); - LayoutManager.ExecuteInitialLayoutPass(); + EnsureInitialized(); + ApplyStyling(); + _shown = true; + IsVisible = true; - if (PlatformImpl != null && parent?.PlatformImpl is not null) - { - PlatformImpl.SetParent(parent.PlatformImpl); - } + var initialSize = new Size( + double.IsNaN(Width) ? Math.Max(MinWidth, ClientSize.Width) : Width, + double.IsNaN(Height) ? Math.Max(MinHeight, ClientSize.Height) : Height); - Owner = parent; - parent?.AddChild(this, false); + if (initialSize != ClientSize) + { + PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); + } - SetWindowStartupLocation(parent?.PlatformImpl); + LayoutManager.ExecuteInitialLayoutPass(); - PlatformImpl?.Show(ShowActivated, false); - Renderer?.Start(); - OnOpened(EventArgs.Empty); + if (PlatformImpl != null && owner?.PlatformImpl is not null) + { + PlatformImpl.SetParent(owner.PlatformImpl); + } + + Owner = owner; + owner?.AddChild(this, false); + + SetWindowStartupLocation(owner?.PlatformImpl); + + PlatformImpl?.Show(ShowActivated, false); + Renderer?.Start(); + OnOpened(EventArgs.Empty); + } } /// @@ -703,68 +754,66 @@ namespace Avalonia.Controls /// public Task ShowDialog(Window owner) { - if (owner == null) - { - throw new ArgumentNullException(nameof(owner)); - } - else if (owner.PlatformImpl == null) - { - throw new InvalidOperationException("Cannot show a window with a closed owner."); - } - else if (owner == this) + using (FreezeVisibilityChangeHandling()) { - throw new InvalidOperationException("A Window cannot be its own owner."); - } - else if (IsVisible) - { - throw new InvalidOperationException("The window is already being shown."); - } - else if (!owner.IsVisible) - { - throw new InvalidOperationException("Cannot show window with non-visible parent."); - } + EnsureStateBeforeShow(); - RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); + if (owner == null) + { + throw new ArgumentNullException(nameof(owner)); + } - EnsureInitialized(); - ApplyStyling(); - IsVisible = true; + EnsureParentStateBeforeShow(owner); - var initialSize = new Size( - double.IsNaN(Width) ? ClientSize.Width : Width, - double.IsNaN(Height) ? ClientSize.Height : Height); + if (_shown) + { + throw new InvalidOperationException("The window is already being shown."); + } - if (initialSize != ClientSize) - { - PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); - } + RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); - LayoutManager.ExecuteInitialLayoutPass(); + EnsureInitialized(); + ApplyStyling(); + _shown = true; + _showingAsDialog = true; + IsVisible = true; - var result = new TaskCompletionSource(); + var initialSize = new Size( + double.IsNaN(Width) ? ClientSize.Width : Width, + double.IsNaN(Height) ? ClientSize.Height : Height); - PlatformImpl?.SetParent(owner.PlatformImpl); - Owner = owner; - owner.AddChild(this, true); + if (initialSize != ClientSize) + { + PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); + } - SetWindowStartupLocation(owner.PlatformImpl); + LayoutManager.ExecuteInitialLayoutPass(); - PlatformImpl?.Show(ShowActivated, true); + var result = new TaskCompletionSource(); - Renderer?.Start(); + PlatformImpl?.SetParent(owner.PlatformImpl!); + Owner = owner; + owner.AddChild(this, true); - Observable.FromEventPattern( - x => Closed += x, - x => Closed -= x) - .Take(1) - .Subscribe(_ => - { - owner.Activate(); - result.SetResult((TResult)(_dialogResult ?? default(TResult)!)); - }); + SetWindowStartupLocation(owner.PlatformImpl); - OnOpened(EventArgs.Empty); - return result.Task; + PlatformImpl?.Show(ShowActivated, true); + + Renderer?.Start(); + + Observable.FromEventPattern( + x => Closed += x, + x => Closed -= x) + .Take(1) + .Subscribe(_ => + { + owner.Activate(); + result.SetResult((TResult)(_dialogResult ?? default(TResult)!)); + }); + + OnOpened(EventArgs.Empty); + return result.Task; + } } private void UpdateEnabled() diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index 8f1b2198ad..b71dc6df44 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -42,9 +42,11 @@ namespace Avalonia.Controls private bool _hasExecutedInitialLayoutPass; private bool _isActive; - private bool _ignoreVisibilityChange; + private int _ignoreVisibilityChanges; private WindowBase? _owner; + protected bool IgnoreVisibilityChanges => _ignoreVisibilityChanges > 0; + static WindowBase() { IsVisibleProperty.OverrideDefaultValue(false); @@ -66,6 +68,11 @@ namespace Avalonia.Controls impl.PositionChanged = HandlePositionChanged; } + protected IDisposable FreezeVisibilityChangeHandling() + { + return new IgnoreVisibilityChangesDisposable(this); + } + /// /// Fired when the window is activated. /// @@ -125,18 +132,12 @@ namespace Avalonia.Controls /// public virtual void Hide() { - _ignoreVisibilityChange = true; - - try + using (FreezeVisibilityChangeHandling()) { Renderer?.Stop(); PlatformImpl?.Hide(); IsVisible = false; } - finally - { - _ignoreVisibilityChange = false; - } } /// @@ -144,9 +145,7 @@ namespace Avalonia.Controls /// public virtual void Show() { - _ignoreVisibilityChange = true; - - try + using (FreezeVisibilityChangeHandling()) { EnsureInitialized(); ApplyStyling(); @@ -157,14 +156,11 @@ namespace Avalonia.Controls LayoutManager.ExecuteInitialLayoutPass(); _hasExecutedInitialLayoutPass = true; } + PlatformImpl?.Show(true, false); Renderer?.Start(); OnOpened(EventArgs.Empty); } - finally - { - _ignoreVisibilityChange = false; - } } /// @@ -202,23 +198,17 @@ namespace Avalonia.Controls protected override void HandleClosed() { - _ignoreVisibilityChange = true; - - try + using (FreezeVisibilityChangeHandling()) { IsVisible = false; - + if (this is IFocusScope scope) { FocusManager.Instance?.RemoveFocusScope(scope); } - + base.HandleClosed(); } - finally - { - _ignoreVisibilityChange = false; - } } /// @@ -318,9 +308,9 @@ namespace Avalonia.Controls Deactivated?.Invoke(this, EventArgs.Empty); } - private void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) + protected virtual void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) { - if (!_ignoreVisibilityChange) + if (_ignoreVisibilityChanges == 0) { if ((bool)e.NewValue!) { @@ -332,5 +322,21 @@ namespace Avalonia.Controls } } } + + private readonly struct IgnoreVisibilityChangesDisposable : IDisposable + { + private readonly WindowBase _windowBase; + + public IgnoreVisibilityChangesDisposable(WindowBase windowBase) + { + _windowBase = windowBase; + _windowBase._ignoreVisibilityChanges++; + } + + public void Dispose() + { + _windowBase._ignoreVisibilityChanges--; + } + } } } diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml index 71a8bc3a3c..810065fc9b 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml @@ -636,4 +636,8 @@ + + + + diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml index d764e1616c..bccc47b9b8 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml @@ -631,4 +631,8 @@ - + + + + + \ No newline at end of file diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index a029be6b8d..9156d11a96 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -68,6 +68,8 @@ + + diff --git a/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml b/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml new file mode 100644 index 0000000000..8e29e6208f --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml b/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml new file mode 100644 index 0000000000..4e76bee9f5 --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml b/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml index 9ad9f70c98..88c2681f65 100644 --- a/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml +++ b/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml @@ -31,5 +31,8 @@ + + + diff --git a/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml b/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml index f96425cf06..77166a9d8a 100644 --- a/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml @@ -31,5 +31,7 @@ - + + + diff --git a/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml b/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml new file mode 100644 index 0000000000..04b2a1bf29 --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml b/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml new file mode 100644 index 0000000000..bd7e43530a --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml index 4aefa0136c..4bad556338 100644 --- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml +++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml @@ -65,6 +65,8 @@ + + diff --git a/tests/Avalonia.Controls.UnitTests/FlowDirectionTests.cs b/tests/Avalonia.Base.UnitTests/FlowDirectionTests.cs similarity index 70% rename from tests/Avalonia.Controls.UnitTests/FlowDirectionTests.cs rename to tests/Avalonia.Base.UnitTests/FlowDirectionTests.cs index 6c43103ecb..f790ed7412 100644 --- a/tests/Avalonia.Controls.UnitTests/FlowDirectionTests.cs +++ b/tests/Avalonia.Base.UnitTests/FlowDirectionTests.cs @@ -8,7 +8,7 @@ namespace Avalonia.Controls.UnitTests [Fact] public void HasMirrorTransform_Should_Be_True() { - var target = new Control + var target = new Visual { FlowDirection = FlowDirection.RightToLeft, }; @@ -19,31 +19,36 @@ namespace Avalonia.Controls.UnitTests [Fact] public void HasMirrorTransform_Of_LTR_Children_Should_Be_True_For_RTL_Parent() { - Control child; - var target = new Decorator + var child = new Visual() + { + FlowDirection = FlowDirection.LeftToRight, + }; + + var target = new Visual { FlowDirection = FlowDirection.RightToLeft, - Child = child = new Control() }; + target.VisualChildren.Add(child); - child.FlowDirection = FlowDirection.LeftToRight; + child.InvalidateMirrorTransform(); Assert.True(target.HasMirrorTransform); Assert.True(child.HasMirrorTransform); } [Fact] - public void HasMirrorTransform_Of_Children_Is_Updated_After_Parent_Changeed() + public void HasMirrorTransform_Of_Children_Is_Updated_After_Parent_Changed() { - Control child; + var child = new Visual() + { + FlowDirection = FlowDirection.LeftToRight, + }; + var target = new Decorator { FlowDirection = FlowDirection.LeftToRight, - Child = child = new Control() - { - FlowDirection = FlowDirection.LeftToRight, - } }; + target.VisualChildren.Add(child); Assert.False(target.HasMirrorTransform); Assert.False(child.HasMirrorTransform); diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index 2d8ee62ef2..e36ce21009 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -1,5 +1,7 @@ +using System; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.UnitTests; using Xunit; @@ -25,6 +27,33 @@ namespace Avalonia.Base.UnitTests.Input } } + [Fact] + public void Focus_Should_Not_Get_Restored_To_Enabled_Control() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var sp = new StackPanel(); + Button target = new Button(); + Button target1 = new Button(); + target.Click += (s, e) => target.IsEnabled = false; + target1.Click += (s, e) => target.IsEnabled = true; + sp.Children.Add(target); + sp.Children.Add(target1); + var root = new TestRoot + { + Child = sp + }; + + target.Focus(); + target.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent)); + Assert.False(target.IsEnabled); + Assert.False(target.IsFocused); + target1.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent)); + Assert.True(target.IsEnabled); + Assert.False(target.IsFocused); + } + } + [Fact] public void Focus_Should_Be_Cleared_When_Control_Is_Removed_From_VisualTree() { diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 6b9105ccb5..bd6d5d55e2 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -59,6 +59,28 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void TextBox_Should_Lose_Focus_When_Disabled() + { + using (UnitTestApplication.Start(FocusServices)) + { + var target = new TextBox + { + Template = CreateTemplate() + }; + + target.ApplyTemplate(); + + var root = new TestRoot() { Child = target }; + + target.Focus(); + Assert.True(target.IsFocused); + target.IsEnabled = false; + Assert.False(target.IsFocused); + Assert.False(target.IsEnabled); + } + } + [Fact] public void Opening_Context_Flyout_Does_not_Lose_Selection() { diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index bb8a683a60..435d0d92ce 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -403,7 +403,7 @@ namespace Avalonia.Controls.UnitTests parent.Close(); var ex = Assert.Throws(() => target.Show(parent)); - Assert.Equal("Cannot show a window with a closed parent.", ex.Message); + Assert.Equal("Cannot show a window with a closed owner.", ex.Message); } } @@ -431,7 +431,7 @@ namespace Avalonia.Controls.UnitTests var target = new Window(); var ex = Assert.Throws(() => target.Show(parent)); - Assert.Equal("Cannot show window with non-visible parent.", ex.Message); + Assert.Equal("Cannot show window with non-visible owner.", ex.Message); } } @@ -444,7 +444,7 @@ namespace Avalonia.Controls.UnitTests var target = new Window(); var ex = await Assert.ThrowsAsync(() => target.ShowDialog(parent)); - Assert.Equal("Cannot show window with non-visible parent.", ex.Message); + Assert.Equal("Cannot show window with non-visible owner.", ex.Message); } } @@ -456,7 +456,7 @@ namespace Avalonia.Controls.UnitTests var target = new Window(); var ex = Assert.Throws(() => target.Show(target)); - Assert.Equal("A Window cannot be its own parent.", ex.Message); + Assert.Equal("A Window cannot be its own owner.", ex.Message); } } @@ -986,7 +986,46 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(SizeToContent.WidthAndHeight, target.SizeToContent); } } + + [Fact] + public void IsVisible_Should_Open_Window() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var target = new Window(); + var raised = false; + + target.Opened += (s, e) => raised = true; + target.IsVisible = true; + Assert.True(raised); + } + } + + [Fact] + public void IsVisible_Should_Close_DialogWindow() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var parent = new Window(); + parent.Show(); + + var target = new Window(); + + var raised = false; + + var task = target.ShowDialog(parent); + + target.Closed += (sender, args) => raised = true; + + target.IsVisible = false; + + Assert.True(raised); + + Assert.False(task.Result); + } + } + protected virtual void Show(Window window) { window.Show();