From 8dcfc7ebec6f27b458315b81dc110a7bb0960510 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 16 Mar 2026 08:27:34 +0000 Subject: [PATCH 01/16] Make SelectionHandleType internal (#20908) * made selection handle type internal * update apidiff --- api/Avalonia.nupkg.xml | 12 ++++++++++++ .../Primitives/SelectionHandleType.cs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 44617ccf64..e160bda11e 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -409,6 +409,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0001 + T:Avalonia.Controls.Primitives.SelectionHandleType + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0001 T:Avalonia.Controls.Remote.RemoteServer @@ -883,6 +889,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0001 + T:Avalonia.Controls.Primitives.SelectionHandleType + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0001 T:Avalonia.Controls.Remote.RemoteServer diff --git a/src/Avalonia.Controls/Primitives/SelectionHandleType.cs b/src/Avalonia.Controls/Primitives/SelectionHandleType.cs index 2e1955de26..58b2b01f97 100644 --- a/src/Avalonia.Controls/Primitives/SelectionHandleType.cs +++ b/src/Avalonia.Controls/Primitives/SelectionHandleType.cs @@ -3,7 +3,7 @@ /// /// Represents which part of the selection the TextSelectionHandle controls. /// - public enum SelectionHandleType + internal enum SelectionHandleType { /// /// The Handle controls the caret position. From 97f36b9f341ec1d873f7e05b93f9ae4390282dde Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 16 Mar 2026 13:45:58 +0500 Subject: [PATCH 02/16] Use the correct flag to determine if extra dirty rect needs to be combined with existing one (#20896) --- ...verCompositionVisual.ComputedProperties.cs | 4 +-- .../ServerCompositionVisual.DirtyInputs.cs | 2 +- .../ServerCompositionVisual.Update.cs | 6 ++--- .../Rendering/CompositorInvalidationTests.cs | 25 +++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs index ed8860e04a..e2ce331318 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs @@ -50,7 +50,7 @@ partial class ServerCompositionVisual private LtrbRect? _ownClipRect; - private bool _hasExtraDirtyRect; + private bool _needsToAddExtraDirtyRectToDirtyRegion; private LtrbRect _extraDirtyRect; public virtual LtrbRect? ComputeOwnContentBounds() => null; @@ -107,7 +107,7 @@ partial class ServerCompositionVisual _isDirtyForRender |= dirtyForRender; // If node itself is dirty for render, we don't need to keep track of extra dirty rects - _hasExtraDirtyRect = !dirtyForRender && (_hasExtraDirtyRect || additionalDirtyRegion); + _needsToAddExtraDirtyRectToDirtyRegion = !dirtyForRender && (_needsToAddExtraDirtyRectToDirtyRegion || additionalDirtyRegion); } public void RecomputeOwnProperties() diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs index 8352fc70e2..35debea184 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs @@ -166,7 +166,7 @@ partial class ServerCompositionVisual protected void AddExtraDirtyRect(LtrbRect rect) { - _extraDirtyRect = _hasExtraDirtyRect ? _extraDirtyRect.Union(rect) : rect; + _extraDirtyRect = _delayPropagateHasExtraDirtyRects ? _extraDirtyRect.Union(rect) : rect; _delayPropagateHasExtraDirtyRects = true; EnqueueOwnPropertiesRecompute(); } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs index b8322225bd..f9b65e01e0 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs @@ -56,7 +56,7 @@ internal partial class ServerCompositionVisual private bool NeedToPushBoundsAffectingProperties(ServerCompositionVisual node) { - return (node._isDirtyForRenderInSubgraph || node._hasExtraDirtyRect || node._contentChanged); + return (node._isDirtyForRenderInSubgraph || node._needsToAddExtraDirtyRectToDirtyRegion || node._contentChanged); } public void PreSubgraph(ServerCompositionVisual node, out bool visitChildren) @@ -142,7 +142,7 @@ internal partial class ServerCompositionVisual // specified before the tranform, i.e. in inner space, hence we have to pick them // up before we pop the transform from the transform stack. // - if (node._hasExtraDirtyRect) + if (node._needsToAddExtraDirtyRectToDirtyRegion) { AddToDirtyRegion(node._extraDirtyRect); } @@ -169,7 +169,7 @@ internal partial class ServerCompositionVisual node._isDirtyForRender = false; node._isDirtyForRenderInSubgraph = false; node._needsBoundingBoxUpdate = false; - node._hasExtraDirtyRect = false; + node._needsToAddExtraDirtyRectToDirtyRegion = false; node._contentChanged = false; } diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs index 699f450223..ef0e01a104 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs @@ -38,6 +38,31 @@ public class CompositorInvalidationTests : CompositorTestsBase s.AssertRects(new Rect(30, 50, 20, 10)); } } + + [Fact] + public void Sibling_Controls_Should_Invalidate_Union_Rect_When_Removed() + { + using (var s = new CompositorCanvas()) + { + var control = new Border() + { + Background = Brushes.Red, Width = 20, Height = 10, + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 10 + }; + var control2 = new Border() + { + Background = Brushes.Blue, Width = 20, Height = 10, + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50 + }; + s.Canvas.Children.Add(control); + s.Canvas.Children.Add(control2); + s.RunJobs(); + s.Events.Rects.Clear(); + s.Canvas.Children.Remove(control); + s.Canvas.Children.Remove(control2); + s.AssertRects(new Rect(30, 10, 20, 50)); + } + } [Fact] public void Control_Should_Invalidate_Both_Own_Rects_When_Moved() From 536daf0b4c7b9e3bf0a24b93058c8157cbaf1d9b Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 16 Mar 2026 10:19:41 +0100 Subject: [PATCH 03/16] Open FocusManager API (#20854) * Open FocusManager API * Merge some FocusManager overloads * Update API suppressions * Properly reset reused XYFocusOptions instances * Clarify Focus documentation * Improve FocusManager documentation * Update API suppressions --- api/Avalonia.nupkg.xml | 132 ++++++++++++++ .../Input/FindNextElementOptions.cs | 37 ++++ src/Avalonia.Base/Input/FocusManager.cs | 169 ++++++++---------- src/Avalonia.Base/Input/IFocusManager.cs | 63 ++++++- src/Avalonia.Base/Input/InputElement.cs | 4 +- .../Input/Navigation/XYFocusOptions.cs | 29 ++- .../PresentationSource/PresentationSource.cs | 4 +- .../Input/InputElement_Focus.cs | 2 +- .../Input/KeyboardDeviceTests.cs | 4 +- tests/Avalonia.UnitTests/TestRoot.cs | 2 +- 10 files changed, 337 insertions(+), 109 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index e160bda11e..dd20d0f39e 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1117,12 +1117,48 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.FocusManager.#ctor(Avalonia.Input.IInputElement) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.ClearFocus + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.ClearFocusOnElementRemoved(Avalonia.Input.IInputElement,Avalonia.Visual) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.FindNextElement(Avalonia.Input.NavigationDirection) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType) baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.IFocusManager.ClearFocus + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.IInputRoot.get_KeyboardNavigationHandler @@ -2611,12 +2647,48 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.FocusManager.#ctor(Avalonia.Input.IInputElement) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.ClearFocus + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.ClearFocusOnElementRemoved(Avalonia.Input.IInputElement,Avalonia.Visual) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.FindNextElement(Avalonia.Input.NavigationDirection) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.FocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType) baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.IFocusManager.ClearFocus + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.IInputRoot.get_KeyboardNavigationHandler @@ -4027,6 +4099,36 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0006 + M:Avalonia.Input.IFocusManager.FindFirstFocusableElement + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.FindLastFocusableElement + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.FindNextElement(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.Focus(Avalonia.Input.IInputElement,Avalonia.Input.NavigationMethod,Avalonia.Input.KeyModifiers) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0006 M:Avalonia.Input.IKeyboardNavigationHandler.Move(Avalonia.Input.IInputElement,Avalonia.Input.NavigationDirection,Avalonia.Input.KeyModifiers,System.Nullable{Avalonia.Input.KeyDeviceType}) @@ -4315,6 +4417,36 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0006 + M:Avalonia.Input.IFocusManager.FindFirstFocusableElement + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.FindLastFocusableElement + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.FindNextElement(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.Focus(Avalonia.Input.IInputElement,Avalonia.Input.NavigationMethod,Avalonia.Input.KeyModifiers) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Input.IFocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0006 M:Avalonia.Input.IKeyboardNavigationHandler.Move(Avalonia.Input.IInputElement,Avalonia.Input.NavigationDirection,Avalonia.Input.KeyModifiers,System.Nullable{Avalonia.Input.KeyDeviceType}) diff --git a/src/Avalonia.Base/Input/FindNextElementOptions.cs b/src/Avalonia.Base/Input/FindNextElementOptions.cs index e6062daf9b..72d83ec419 100644 --- a/src/Avalonia.Base/Input/FindNextElementOptions.cs +++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs @@ -6,12 +6,49 @@ using System.Threading.Tasks; namespace Avalonia.Input { + /// + /// Provides options to customize the behavior when identifying the next element to focus + /// during a navigation operation. + /// public sealed class FindNextElementOptions { + /// + /// Gets or sets the root within which the search for the next + /// focusable element will be conducted. + /// + /// + /// This property defines the boundary for focus navigation operations. It determines the root element + /// in the visual tree under which the focusable item search is performed. If not specified, the search + /// will default to the current scope. + /// public InputElement? SearchRoot { get; init; } + + /// + /// Gets or sets the rectangular region within the visual hierarchy that will be excluded + /// from consideration during focus navigation. + /// public Rect ExclusionRect { get; init; } + + /// + /// Gets or sets a rectangular region that serves as a hint for focus navigation. + /// This property specifies a rectangle, relative to the coordinate system of the search root, + /// which can be used as a preferred or prioritized target when navigating focus. + /// It can be null if no specific hint region is provided. + /// public Rect? FocusHintRectangle { get; init; } + + /// + /// Specifies an optional override for the navigation strategy used in XY focus navigation. + /// This property allows customizing the focus movement behavior when navigating between UI elements. + /// public XYFocusNavigationStrategy? NavigationStrategyOverride { get; init; } + + /// + /// Specifies whether occlusivity (overlapping of elements or obstructions) + /// should be ignored during focus navigation. When set to true, + /// the navigation logic disregards obstructions that may block a potential + /// focus target, allowing elements behind such obstructions to be considered. + /// public bool IgnoreOcclusivity { get; init; } } } diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index 15b8fea77d..dc62171f48 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -4,7 +4,6 @@ using System.Linq; using Avalonia.Input.Navigation; using Avalonia.Interactivity; using Avalonia.Metadata; -using Avalonia.Reactive; using Avalonia.VisualTree; namespace Avalonia.Input @@ -12,7 +11,6 @@ namespace Avalonia.Input /// /// Manages focus for the application. /// - [PrivateApi] public class FocusManager : IFocusManager { /// @@ -42,58 +40,51 @@ namespace Avalonia.Input RoutingStrategies.Tunnel); } + [PrivateApi] public FocusManager() { - _contentRoot = null; } - public FocusManager(IInputElement contentRoot) - { - _contentRoot = contentRoot; - } - - internal void SetContentRoot(IInputElement? contentRoot) + /// + /// Gets or sets the content root for the focus management system. + /// + [PrivateApi] + public IInputElement? ContentRoot { - _contentRoot = contentRoot; + get => _contentRoot; + set => _contentRoot = value; } private IInputElement? Current => KeyboardDevice.Instance?.FocusedElement; - private XYFocus _xyFocus = new(); - private XYFocusOptions _xYFocusOptions = new XYFocusOptions(); + private readonly XYFocus _xyFocus = new(); private IInputElement? _contentRoot; + private XYFocusOptions? _reusableFocusOptions; - /// - /// Gets the currently focused . - /// + /// public IInputElement? GetFocusedElement() => Current; - /// - /// Focuses a control. - /// - /// The control to focus. - /// The method by which focus was changed. - /// Any key modifiers active at the time of focus. + /// public bool Focus( - IInputElement? control, + IInputElement? element, NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) { if (KeyboardDevice.Instance is not { } keyboardDevice) return false; - if (control is not null) + if (element is not null) { - if (!CanFocus(control)) + if (!CanFocus(element)) return false; - if (GetFocusScope(control) is StyledElement scope) + if (GetFocusScope(element) is StyledElement scope) { - scope.SetValue(FocusedElementProperty, control); + scope.SetValue(FocusedElementProperty, element); _focusRoot = GetFocusRoot(scope); } - keyboardDevice.SetFocusedElement(control, method, keyModifiers); + keyboardDevice.SetFocusedElement(element, method, keyModifiers); return true; } else if (_focusRoot?.GetValue(FocusedElementProperty) is { } restore && @@ -110,12 +101,7 @@ namespace Avalonia.Input } } - public void ClearFocus() - { - Focus(null); - } - - public void ClearFocusOnElementRemoved(IInputElement removedElement, Visual oldParent) + internal void ClearFocusOnElementRemoved(IInputElement removedElement, Visual oldParent) { if (oldParent is IInputElement parentElement && GetFocusScope(parentElement) is StyledElement scope && @@ -129,6 +115,7 @@ namespace Avalonia.Input Focus(null); } + [PrivateApi] public IInputElement? GetFocusedElement(IFocusScope scope) { return (scope as StyledElement)?.GetValue(FocusedElementProperty); @@ -138,6 +125,7 @@ namespace Avalonia.Input /// Notifies the focus manager of a change in focus scope. /// /// The new focus scope. + [PrivateApi] public void SetFocusScope(IFocusScope scope) { if (GetFocusedElement(scope) is { } focused) @@ -153,12 +141,14 @@ namespace Avalonia.Input } } + [PrivateApi] public void RemoveFocusRoot(IFocusScope scope) { if (scope == _focusRoot) - ClearFocus(); + Focus(null); } + [PrivateApi] public static bool GetIsFocusScope(IInputElement e) => e is IFocusScope; /// @@ -176,25 +166,15 @@ namespace Avalonia.Input ?? (FocusManager?)AvaloniaLocator.Current.GetService(); } - /// - /// Attempts to change focus from the element with focus to the next focusable element in the specified direction. - /// - /// The direction to traverse (in tab order). - /// true if focus moved; otherwise, false. - public bool TryMoveFocus(NavigationDirection direction) + /// + public bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null) { - return FindAndSetNextFocus(direction, _xYFocusOptions); - } + ValidateDirection(direction); - /// - /// Attempts to change focus from the element with focus to the next focusable element in the specified direction, using the specified navigation options. - /// - /// The direction to traverse (in tab order). - /// The options to help identify the next element to receive focus with keyboard/controller/remote navigation. - /// true if focus moved; otherwise, false. - public bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions options) - { - return FindAndSetNextFocus(direction, ValidateAndCreateFocusOptions(direction, options)); + var focusOptions = ToFocusOptions(options, true); + var result = FindAndSetNextFocus(direction, focusOptions); + _reusableFocusOptions = focusOptions; + return result; } /// @@ -295,10 +275,7 @@ namespace Avalonia.Input return true; } - /// - /// Retrieves the first element that can receive focus. - /// - /// The first focusable element. + /// public IInputElement? FindFirstFocusableElement() { var root = (_contentRoot as Visual)?.GetSelfAndVisualDescendants().FirstOrDefault(x => x is IInputElement) as IInputElement; @@ -317,10 +294,7 @@ namespace Avalonia.Input return GetFirstFocusableElement(searchScope); } - /// - /// Retrieves the last element that can receive focus. - /// - /// The last focusable element. + /// public IInputElement? FindLastFocusableElement() { var root = (_contentRoot as Visual)?.GetSelfAndVisualDescendants().FirstOrDefault(x => x is IInputElement) as IInputElement; @@ -339,52 +313,59 @@ namespace Avalonia.Input return GetFocusManager(searchScope)?.GetLastFocusableElement(searchScope); } - /// - /// Retrieves the element that should receive focus based on the specified navigation direction. - /// - /// - /// - public IInputElement? FindNextElement(NavigationDirection direction) + /// + public IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null) { - var xyOption = new XYFocusOptions() - { - UpdateManifold = false - }; + ValidateDirection(direction); - return FindNextFocus(direction, xyOption); + var focusOptions = ToFocusOptions(options, false); + var result = FindNextFocus(direction, focusOptions); + _reusableFocusOptions = focusOptions; + return result; } - /// - /// Retrieves the element that should receive focus based on the specified navigation direction (cannot be used with tab navigation). - /// - /// The direction that focus moves from element to element within the app UI. - /// The options to help identify the next element to receive focus with the provided navigation. - /// The next element to receive focus. - public IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions options) + private static void ValidateDirection(NavigationDirection direction) { - return FindNextFocus(direction, ValidateAndCreateFocusOptions(direction, options)); + if (direction is not ( + NavigationDirection.Next or + NavigationDirection.Previous or + NavigationDirection.Up or + NavigationDirection.Down or + NavigationDirection.Left or + NavigationDirection.Right)) + { + throw new ArgumentOutOfRangeException( + nameof(direction), + direction, + $"Only {nameof(NavigationDirection.Next)}, {nameof(NavigationDirection.Previous)}, " + + $"{nameof(NavigationDirection.Up)}, {nameof(NavigationDirection.Down)}," + + $" {nameof(NavigationDirection.Left)} and {nameof(NavigationDirection.Right)} directions are supported"); + } } - private static XYFocusOptions ValidateAndCreateFocusOptions(NavigationDirection direction, FindNextElementOptions options) + private XYFocusOptions ToFocusOptions(FindNextElementOptions? options, bool updateManifold) { - if (direction is not NavigationDirection.Up - and not NavigationDirection.Down - and not NavigationDirection.Left - and not NavigationDirection.Right) + // XYFocus only uses the options and never modifies them; we can cache and reset them between calls. + var focusOptions = _reusableFocusOptions; + _reusableFocusOptions = null; + + if (focusOptions is null) + focusOptions = new XYFocusOptions(); + else + focusOptions.Reset(); + + if (options is not null) { - throw new ArgumentOutOfRangeException(nameof(direction), - $"{direction} is not supported with FindNextElementOptions. Only Up, Down, Left and right are supported"); + focusOptions.SearchRoot = options.SearchRoot; + focusOptions.ExclusionRect = options.ExclusionRect; + focusOptions.FocusHintRectangle = options.FocusHintRectangle; + focusOptions.NavigationStrategyOverride = options.NavigationStrategyOverride; + focusOptions.IgnoreOcclusivity = options.IgnoreOcclusivity; } - return new XYFocusOptions - { - UpdateManifold = false, - SearchRoot = options.SearchRoot, - ExclusionRect = options.ExclusionRect, - FocusHintRectangle = options.FocusHintRectangle, - NavigationStrategyOverride = options.NavigationStrategyOverride, - IgnoreOcclusivity = options.IgnoreOcclusivity - }; + focusOptions.UpdateManifold = updateManifold; + + return focusOptions; } internal IInputElement? FindNextFocus(NavigationDirection direction, XYFocusOptions focusOptions, bool updateManifolds = true) diff --git a/src/Avalonia.Base/Input/IFocusManager.cs b/src/Avalonia.Base/Input/IFocusManager.cs index 5691172f3f..9bd1fb4239 100644 --- a/src/Avalonia.Base/Input/IFocusManager.cs +++ b/src/Avalonia.Base/Input/IFocusManager.cs @@ -14,9 +14,66 @@ namespace Avalonia.Input IInputElement? GetFocusedElement(); /// - /// Clears currently focused element. + /// Focuses a control. /// - [Unstable("This API might be removed in 11.x minor updates. Please consider focusing another element instead of removing focus at all for better UX.")] - void ClearFocus(); + /// The control to focus. + /// The method by which focus was changed. + /// Any key modifiers active at the time of focus. + /// true if the focus moved to a control; otherwise, false. + /// + /// If is null, this method tries to clear the focus. However, it is not advised. + /// For a better user experience, focus should be moved to another element when possible. + /// + /// When this method return true, it is not guaranteed that the focus has been moved + /// to . The focus might have been redirected to another element. + /// + bool Focus( + IInputElement? element, + NavigationMethod method = NavigationMethod.Unspecified, + KeyModifiers keyModifiers = KeyModifiers.None); + + /// + /// Attempts to change focus from the element with focus to the next focusable element in the specified direction. + /// + /// + /// The direction that focus moves from element to element. + /// Must be one of , , + /// , , + /// and . + /// + /// + /// The options to help identify the next element to receive focus. + /// They only apply to directional navigation. + /// + /// true if focus moved; otherwise, false. + bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null); + + /// + /// Retrieves the first element that can receive focus. + /// + /// The first focusable element. + IInputElement? FindFirstFocusableElement(); + + /// + /// Retrieves the last element that can receive focus. + /// + /// The last focusable element. + IInputElement? FindLastFocusableElement(); + + /// + /// Retrieves the element that should receive focus based on the specified navigation direction. + /// + /// + /// The direction that focus moves from element to element. + /// Must be one of , , + /// , , + /// and . + /// + /// + /// The options to help identify the next element to receive focus. + /// They only apply to directional navigation. + /// + /// The next element to receive focus, if any. + IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null); } } diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index 1beccf341e..e908e818e8 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -523,7 +523,7 @@ namespace Avalonia.Input if (!IsEffectivelyEnabled && FocusManager.GetFocusManager(this) is { } focusManager && Equals(focusManager.GetFocusedElement(), this)) { - focusManager.ClearFocus(); + focusManager.Focus(null); } } } @@ -995,7 +995,7 @@ namespace Avalonia.Input } else { - focusManager.ClearFocus(); + focusManager.Focus(null); } } } diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs b/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs index 4bfcb22502..8e4c847aa9 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs @@ -1,17 +1,38 @@ namespace Avalonia.Input.Navigation; -internal class XYFocusOptions +internal sealed class XYFocusOptions { public InputElement? SearchRoot { get; set; } public Rect ExclusionRect { get; set; } public Rect? FocusHintRectangle { get; set; } public Rect? FocusedElementBounds { get; set; } public XYFocusNavigationStrategy? NavigationStrategyOverride { get; set; } - public bool IgnoreClipping { get; set; } = true; + public bool IgnoreClipping { get; set; } public bool IgnoreCone { get; set; } public KeyDeviceType? KeyDeviceType { get; set; } - public bool ConsiderEngagement { get; set; } = true; - public bool UpdateManifold { get; set; } = true; + public bool ConsiderEngagement { get; set; } + public bool UpdateManifold { get; set; } public bool UpdateManifoldsFromFocusHintRect { get; set; } public bool IgnoreOcclusivity { get; set; } + + public XYFocusOptions() + { + Reset(); + } + + internal void Reset() + { + SearchRoot = null; + ExclusionRect = default; + FocusHintRectangle = null; + FocusedElementBounds = null; + NavigationStrategyOverride = null; + IgnoreClipping = true; + IgnoreCone = false; + KeyDeviceType = null; + ConsiderEngagement = true; + UpdateManifold = true; + UpdateManifoldsFromFocusHintRect = false; + IgnoreOcclusivity = false; + } } diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs index c98a380640..9917f82c93 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs @@ -61,7 +61,7 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi field?.SetPresentationSourceForRootVisual(this); Renderer.CompositionTarget.Root = field?.CompositionVisual; - FocusManager.SetContentRoot(value as IInputElement); + FocusManager.ContentRoot = value; } } @@ -152,4 +152,4 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi } return null; } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index 7755eb80cf..cdb4588fff 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -577,7 +577,7 @@ namespace Avalonia.Base.UnitTests.Input }; target.Focus(); - root.FocusManager.ClearFocus(); + root.FocusManager.Focus(null); Assert.Null(root.FocusManager.GetFocusedElement()); } diff --git a/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs index d11872ba6a..b1446d961f 100644 --- a/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs @@ -15,7 +15,7 @@ namespace Avalonia.Base.UnitTests.Input using (UnitTestApplication.Start(TestServices.FocusableWindow)) { var window = new Window(); - window.FocusManager.ClearFocus(); + window.FocusManager.Focus(null); int raised = 0; window.KeyDown += (sender, ev) => { @@ -71,7 +71,7 @@ namespace Avalonia.Base.UnitTests.Input using (UnitTestApplication.Start(TestServices.FocusableWindow)) { var window = new Window(); - window.FocusManager.ClearFocus(); + window.FocusManager.Focus(null); int raised = 0; window.TextInput += (sender, ev) => { diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index ed91463346..4400d77267 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -74,7 +74,7 @@ namespace Avalonia.UnitTests IRenderer IPresentationSource.Renderer => Renderer; IHitTester IPresentationSource.HitTester => HitTester; - public IFocusManager FocusManager => _focusManager ??= new FocusManager(this); + public IFocusManager FocusManager => _focusManager ??= new FocusManager { ContentRoot = this }; public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService(); public IInputElement? PointerOverElement { get; set; } From be262bf45cfd13b6251910207ea4958816baec27 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:05:17 +1100 Subject: [PATCH 04/16] Defer default icon loading until Window is shown (#20898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: verify ShowCore applies default icon when no custom icon is set Adds a test that verifies Window.Show() applies the default icon via SetIcon when no custom icon has been set. Currently fails because ShowCore has no default icon logic — the fallback only exists in the constructor binding where it eagerly loads the icon. Relates to #20478 * fix: defer default icon loading from constructor to ShowCore The default icon was eagerly loaded during Window construction via CreatePlatformImplBinding, even when a custom icon would be set or no icon was needed. This caused unnecessary I/O (assembly resource loading) on every first Window instantiation. Move the default icon fallback from the binding lambda to ShowCore, so LoadDefaultIcon only runs when the window is actually shown and no custom icon has been set. Fixes #20478 --- src/Avalonia.Controls/Window.cs | 12 +++++++++-- src/Avalonia.X11/X11Window.cs | 6 ++++++ src/Windows/Avalonia.Win32/WindowImpl.cs | 3 +++ .../WindowTests.cs | 20 +++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index d92a46a70a..db3ec6a077 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -248,7 +248,7 @@ namespace Avalonia.Controls this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => PlatformImpl?.Resize(x, WindowResizeReason.Application)); CreatePlatformImplBinding(TitleProperty, title => PlatformImpl!.SetTitle(title)); - CreatePlatformImplBinding(IconProperty, icon => PlatformImpl!.SetIcon((icon ?? s_defaultIcon.Value)?.PlatformImpl)); + CreatePlatformImplBinding(IconProperty, SetEffectiveIcon); CreatePlatformImplBinding(CanResizeProperty, canResize => PlatformImpl!.CanResize(canResize)); CreatePlatformImplBinding(CanMinimizeProperty, canMinimize => PlatformImpl!.SetCanMinimize(canMinimize)); CreatePlatformImplBinding(CanMaximizeProperty, canMaximize => PlatformImpl!.SetCanMaximize(canMaximize)); @@ -892,6 +892,8 @@ namespace Avalonia.Controls _shown = true; IsVisible = true; + SetEffectiveIcon(Icon); + // If window position was not set before then platform may provide incorrect scaling at this time, // but we need it for proper calculation of position and in some cases size (size to content) SetExpectedScaling(owner); @@ -1378,7 +1380,7 @@ namespace Avalonia.Controls private static WindowIcon? LoadDefaultIcon() { - // Use AvaloniaLocator instead of static AssetLoader, so it won't fail on Unit Tests without any asset loader. + // Use AvaloniaLocator instead of static AssetLoader, so it won't fail on Unit Tests without any asset loader. if (AvaloniaLocator.Current.GetService() is { } assetLoader && Assembly.GetEntryAssembly()?.GetName()?.Name is { } assemblyName && Uri.TryCreate($"avares://{assemblyName}/!__AvaloniaDefaultWindowIcon", UriKind.Absolute, out var path) @@ -1390,6 +1392,12 @@ namespace Avalonia.Controls return null; } + private void SetEffectiveIcon(WindowIcon? icon) + { + icon ??= _shown ? s_defaultIcon.Value : null; + PlatformImpl?.SetIcon(icon?.PlatformImpl); + } + private static bool CoerceCanMaximize(AvaloniaObject target, bool value) => value && target is not Window { CanResize: false }; } diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index a57e1986ac..14e0f0dea8 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -71,6 +71,7 @@ namespace Avalonia.X11 private bool _useCompositorDrivenRenderWindowResize = false; private bool _usePositioningFlags = false; private X11WindowMode _mode; + private IWindowIconImpl? _iconImpl; private enum XSyncState { @@ -1530,6 +1531,11 @@ namespace Avalonia.X11 public void SetIcon(IWindowIconImpl? icon) { + if (ReferenceEquals(_iconImpl, icon)) + return; + + _iconImpl = icon; + if (icon != null) { var data = ((X11IconData)icon).Data; diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index cc2e7211f1..db42fca251 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -809,6 +809,9 @@ namespace Avalonia.Win32 public void SetIcon(IWindowIconImpl? icon) { + if (ReferenceEquals(_iconImpl, icon)) + return; + _iconImpl = (IconImpl?)icon; ClearIconCache(); RefreshIcon(); diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index 7ab69c8d86..59a84462ef 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -1188,6 +1188,26 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Show_Should_Apply_Default_Icon_When_No_Custom_Icon_Is_Set() + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object); + + using (UnitTestApplication.Start(TestServices.StyledWindow.With(windowingPlatform: windowingPlatform))) + { + var target = new Window(); + + // Clear any SetIcon calls from construction. + windowImpl.Invocations.Clear(); + + target.Show(); + + // ShowCore should apply the default icon when no custom icon was set. + windowImpl.Verify(x => x.SetIcon(It.IsAny()), Times.AtLeastOnce()); + } + } + private class TopmostWindow : Window { static TopmostWindow() From 38880eef0989bc6f686c07a55ee3d4b7dfe8a0b9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 16 Mar 2026 12:02:59 +0100 Subject: [PATCH 05/16] Allow `TextSearch.TextBinding` on non-controls. (#20884) * Allow `TextSearch.TextBinding` on non-controls. Allow setting `TextSearch.TextBinding` on non-controls: in particular I would like to be able to set it on (tree) data grid columns. For example: ``` ``` * Allow TextSearch.Text on non-controls * Update API suppressions * Rename TextSearch.GetText parameter --------- Co-authored-by: Max Katz Co-authored-by: Julien Lebosquain --- api/Avalonia.nupkg.xml | 48 +++++++++++++++++++ .../Primitives/TextSearch.cs | 37 +++++++------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index dd20d0f39e..c03d1fe6cc 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -2125,12 +2125,36 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.GetText(Avalonia.Interactivity.Interactive) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.GetTextBinding(Avalonia.Interactivity.Interactive) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Controls.Control,System.String) baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Interactivity.Interactive,System.String) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.SetTextBinding(Avalonia.Interactivity.Interactive,Avalonia.Data.BindingBase) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.ToggleButton.add_Checked(System.EventHandler{Avalonia.Interactivity.RoutedEventArgs}) @@ -3655,12 +3679,36 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.GetText(Avalonia.Interactivity.Interactive) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.GetTextBinding(Avalonia.Interactivity.Interactive) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Controls.Control,System.String) baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Interactivity.Interactive,System.String) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.Primitives.TextSearch.SetTextBinding(Avalonia.Interactivity.Interactive,Avalonia.Data.BindingBase) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.ToggleButton.add_Checked(System.EventHandler{Avalonia.Interactivity.RoutedEventArgs}) diff --git a/src/Avalonia.Controls/Primitives/TextSearch.cs b/src/Avalonia.Controls/Primitives/TextSearch.cs index aa83266683..31e471845b 100644 --- a/src/Avalonia.Controls/Primitives/TextSearch.cs +++ b/src/Avalonia.Controls/Primitives/TextSearch.cs @@ -1,6 +1,5 @@ using Avalonia.Controls.Utils; using Avalonia.Data; -using Avalonia.Interactivity; namespace Avalonia.Controls.Primitives { @@ -15,47 +14,47 @@ namespace Avalonia.Controls.Primitives /// This property is usually applied to an item container directly. /// public static readonly AttachedProperty TextProperty - = AvaloniaProperty.RegisterAttached("Text", typeof(TextSearch)); + = AvaloniaProperty.RegisterAttached("Text", typeof(TextSearch)); /// /// Defines the TextBinding attached property. /// The binding will be applied to each item during text search in (such as ). /// public static readonly AttachedProperty TextBindingProperty - = AvaloniaProperty.RegisterAttached("TextBinding", typeof(TextSearch)); + = AvaloniaProperty.RegisterAttached("TextBinding", typeof(TextSearch)); /// /// Sets the value of the attached property to a given . /// - /// The control. + /// The control. /// The search text to set. - public static void SetText(Interactive control, string? text) - => control.SetValue(TextProperty, text); + public static void SetText(AvaloniaObject element, string? text) + => element.SetValue(TextProperty, text); /// /// Gets the value of the attached property from a given . /// - /// The control. + /// The control. /// The search text. - public static string? GetText(Interactive control) - => control.GetValue(TextProperty); + public static string? GetText(AvaloniaObject element) + => element.GetValue(TextProperty); /// - /// Sets the value of the attached property to a given . + /// Sets the value of the attached property to a given element. /// - /// The interactive element. + /// The element. /// The search text binding to set. - public static void SetTextBinding(Interactive interactive, BindingBase? value) - => interactive.SetValue(TextBindingProperty, value); + public static void SetTextBinding(AvaloniaObject element, BindingBase? value) + => element.SetValue(TextBindingProperty, value); /// - /// Gets the value of the attached property from a given . + /// Gets the value of the attached property from a given element. /// - /// The interactive element. + /// The element. /// The search text binding. [AssignBinding] - public static BindingBase? GetTextBinding(Interactive interactive) - => interactive.GetValue(TextBindingProperty); + public static BindingBase? GetTextBinding(AvaloniaObject element) + => element.GetValue(TextBindingProperty); /// /// Gets the effective text of a given item. @@ -80,9 +79,9 @@ namespace Avalonia.Controls.Primitives string? text; - if (item is Interactive interactive) + if (item is AvaloniaObject obj) { - text = interactive.GetValue(TextProperty); + text = obj.GetValue(TextProperty); if (!string.IsNullOrEmpty(text)) return text; } From 80b8509bfc20015bb178ecbcd80bce348b743076 Mon Sep 17 00:00:00 2001 From: Tom Edwards <109803929+TomEdwardsEnscape@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:20:32 +0100 Subject: [PATCH 06/16] Validate layoutable Thickness properties to block NaN or infinite values (#20899) * Validate layoutable Thickness properties to block NaN or infinite values * Use double.IsFinite --- src/Avalonia.Base/Layout/Layoutable.cs | 4 ++- src/Avalonia.Controls/Border.cs | 2 +- .../Chrome/WindowDrawnDecorations.cs | 4 +-- src/Avalonia.Controls/Decorator.cs | 2 +- src/Avalonia.Controls/Page/Page.cs | 2 +- .../BorderTests.cs | 28 ++++++++++++++----- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index fedea332b6..e0c316c60a 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -113,7 +113,7 @@ namespace Avalonia.Layout /// Defines the property. /// public static readonly StyledProperty MarginProperty = - AvaloniaProperty.Register(nameof(Margin)); + AvaloniaProperty.Register(nameof(Margin), validate: ValidateThickness); /// /// Defines the property. @@ -161,6 +161,8 @@ namespace Avalonia.Layout private static bool ValidateMinimumDimension(double value) => !double.IsPositiveInfinity(value) && ValidateMaximumDimension(value); private static bool ValidateMaximumDimension(double value) => value >= 0; + private static bool ValidateThickness(Thickness value) => double.IsFinite(value.Left) && double.IsFinite(value.Top) && double.IsFinite(value.Right) && double.IsFinite(value.Bottom); + /// /// Occurs when the element's effective viewport changes. /// diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index b816858632..29a31d8070 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -38,7 +38,7 @@ namespace Avalonia.Controls /// Defines the property. /// public static readonly StyledProperty BorderThicknessProperty = - AvaloniaProperty.Register(nameof(BorderThickness)); + AvaloniaProperty.Register(nameof(BorderThickness), validate: MarginProperty.ValidateValue); /// /// Defines the property. diff --git a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs index ae279d6ab3..48847b5f59 100644 --- a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs +++ b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs @@ -58,13 +58,13 @@ public class WindowDrawnDecorations : StyledElement /// Defines the property. /// public static readonly StyledProperty DefaultFrameThicknessProperty = - AvaloniaProperty.Register(nameof(DefaultFrameThickness)); + AvaloniaProperty.Register(nameof(DefaultFrameThickness), validate: Border.BorderThicknessProperty.ValidateValue); /// /// Defines the property. /// public static readonly StyledProperty DefaultShadowThicknessProperty = - AvaloniaProperty.Register(nameof(DefaultShadowThickness)); + AvaloniaProperty.Register(nameof(DefaultShadowThickness), validate: Border.BorderThicknessProperty.ValidateValue); /// /// Defines the property. diff --git a/src/Avalonia.Controls/Decorator.cs b/src/Avalonia.Controls/Decorator.cs index e62ca0000b..8cd1916718 100644 --- a/src/Avalonia.Controls/Decorator.cs +++ b/src/Avalonia.Controls/Decorator.cs @@ -20,7 +20,7 @@ namespace Avalonia.Controls /// Defines the property. /// public static readonly StyledProperty PaddingProperty = - AvaloniaProperty.Register(nameof(Padding)); + AvaloniaProperty.Register(nameof(Padding), validate: MarginProperty.ValidateValue); /// /// Initializes static members of the class. diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs index 601af92580..48b7bd1b0c 100644 --- a/src/Avalonia.Controls/Page/Page.cs +++ b/src/Avalonia.Controls/Page/Page.cs @@ -17,7 +17,7 @@ namespace Avalonia.Controls /// Defines the property. /// public static readonly StyledProperty SafeAreaPaddingProperty = - AvaloniaProperty.Register(nameof(SafeAreaPadding)); + AvaloniaProperty.Register(nameof(SafeAreaPadding), validate: PaddingProperty.ValidateValue); /// /// Defines the property. diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs index e31eb08964..df80998b05 100644 --- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs @@ -1,9 +1,6 @@ +using System; using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Rendering; using Avalonia.UnitTests; -using Avalonia.VisualTree; -using Moq; using Xunit; namespace Avalonia.Controls.UnitTests @@ -45,14 +42,31 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Rect(6, 6, 0, 0), content.Bounds); } - + + [Fact] + public void Should_Reject_NaN_Or_Infinite_Thicknesses() + { + var target = new Border(); + + SetValues(target, Layoutable.MarginProperty); + SetValues(target, Decorator.PaddingProperty); + SetValues(target, Border.BorderThicknessProperty); + + static void SetValues(Border target, AvaloniaProperty property) + { + Assert.Throws(() => target.SetValue(property, new Thickness(0, 0, 0, double.NaN))); + Assert.Throws(() => target.SetValue(property, new Thickness(0, 0, 0, double.PositiveInfinity))); + Assert.Throws(() => target.SetValue(property, new Thickness(0, 0, 0, double.NegativeInfinity))); + } + } + public class UseLayoutRounding : ScopedTestBase { [Fact] public void Measure_Rounds_Padding() { - var target = new Border - { + var target = new Border + { Padding = new Thickness(1), Child = new Canvas { From 8498a51f072f2d26484f9dc800487cf055d873f9 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 16 Mar 2026 14:38:28 +0100 Subject: [PATCH 07/16] Update .NET SDK to 10.0.201 (#20912) --- global.json | 2 +- src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index 3773c7d736..f6ed3dfdfb 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.101", + "version": "10.0.201", "rollForward": "latestFeature" }, "test": { diff --git a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs index b0e2af2f3a..8fbcc24346 100644 --- a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs +++ b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs @@ -19,7 +19,7 @@ namespace Avalonia.Controls.Presenters public PanelContainerGenerator(ItemsPresenter presenter) { Debug.Assert(presenter.ItemsControl is not null); - Debug.Assert(presenter.Panel is not null or VirtualizingPanel); + Debug.Assert(presenter.Panel is not (null or VirtualizingPanel)); _presenter = presenter; _presenter.ItemsControl.ItemsView.PostCollectionChanged += OnItemsChanged; From 74c20f1fdc3f55d46d701d966911b85ca8c40d82 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 16 Mar 2026 23:37:04 +0900 Subject: [PATCH 08/16] New HeadlessWindow.SetRenderScaling API (#20888) * Add `void IHeadlessWindow.SetRenderScaling` API * Add tests * Enforce Window * Try to fix failing test --- .../HeadlessWindowExtensions.cs | 15 ++++- .../Avalonia.Headless/HeadlessWindowImpl.cs | 16 ++++- .../Avalonia.Headless/IHeadlessWindow.cs | 1 + .../RenderingTests.cs | 62 +++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs index 79c0d331cd..78b89d6cb1 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs @@ -20,9 +20,9 @@ public static class HeadlessWindowExtensions /// Bitmap with last rendered frame. Null, if nothing was rendered. public static WriteableBitmap? CaptureRenderedFrame(this TopLevel topLevel) { - Dispatcher.UIThread.RunJobs(); - AvaloniaHeadlessPlatform.ForceRenderTimerTick(); - return topLevel.GetLastRenderedFrame(); + WriteableBitmap? bitmap = null; + topLevel.RunJobsOnImpl(w => bitmap = w.GetLastRenderedFrame()); + return bitmap; } /// @@ -114,6 +114,15 @@ public static class HeadlessWindowExtensions DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) => RunJobsOnImpl(topLevel, w => w.DragDrop(point, type, data, effects, modifiers)); + /// + /// Changes the render scaling (DPI) of the headless window/toplevel. + /// This simulates a DPI change, triggering scaling changed notifications and a layout pass. + /// + /// The target headless top level. + /// The new render scaling factor. Must be greater than zero. + public static void SetRenderScaling(this TopLevel topLevel, double scaling) => + RunJobsOnImpl(topLevel, w => w.SetRenderScaling(scaling)); + private static void RunJobsOnImpl(this TopLevel topLevel, Action action) { RunJobsAndRender(); diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index 275dc7f48a..999a20644f 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -49,7 +49,7 @@ namespace Avalonia.Headless public Size ClientSize { get; set; } public Size? FrameSize => null; - public double RenderScaling { get; } = 1; + public double RenderScaling { get; private set; } = 1; public double DesktopScaling => RenderScaling; public IPlatformRenderSurface[] Surfaces { get; } public Action? Input { get; set; } @@ -358,6 +358,20 @@ namespace Avalonia.Headless Input?.Invoke(new RawDragEvent(device, type, InputRoot!, point, data, effects, modifiers)); } + void IHeadlessWindow.SetRenderScaling(double scaling) + { + if (scaling <= 0) + throw new ArgumentOutOfRangeException(nameof(scaling), "Scaling must be greater than zero."); + + if (RenderScaling == scaling) + return; + + var oldScaledSize = ClientSize; + RenderScaling = scaling; + ScalingChanged?.Invoke(scaling); + Resize(oldScaledSize, WindowResizeReason.DpiChange); + } + void IWindowImpl.Move(PixelPoint point) { Position = point; diff --git a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs index 30c2390f64..44ac0a5ace 100644 --- a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs +++ b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs @@ -16,5 +16,6 @@ namespace Avalonia.Headless void MouseUp(Point point, MouseButton button, RawInputModifiers modifiers = RawInputModifiers.None); void MouseWheel(Point point, Vector delta, RawInputModifiers modifiers = RawInputModifiers.None); void DragDrop(Point point, RawDragEventType type, IDataTransfer data, DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None); + void SetRenderScaling(double scaling); } } diff --git a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs index 1541b74fd9..24db2d2285 100644 --- a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs @@ -169,4 +169,66 @@ public class RenderingTests AssertHelper.Equal(100, snapshot.Size.Width); AssertHelper.Equal(100, snapshot.Size.Height); } + +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Should_Change_Render_Scaling() + { + var window = new Window + { + Content = new Border + { + Background = Brushes.Red + }, + Width = 100, + Height = 100, + }; + + window.Show(); + + var frameBefore = window.CaptureRenderedFrame(); + AssertHelper.NotNull(frameBefore); + + var sizeBefore = frameBefore!.PixelSize; + + window.SetRenderScaling(2.0); + + AssertHelper.Equal(2.0, window.RenderScaling); + + var frameAfter = window.CaptureRenderedFrame(); + AssertHelper.NotNull(frameAfter); + + var sizeAfter = frameAfter!.PixelSize; + + AssertHelper.Equal(sizeBefore.Width * 2, sizeAfter.Width); + AssertHelper.Equal(sizeBefore.Height * 2, sizeAfter.Height); + } + +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Should_Keep_Client_Size_After_Scaling_Change() + { + var window = new Window + { + Width = 200, + Height = 150 + }; + + window.Show(); + window.CaptureRenderedFrame(); + + var clientSizeBefore = window.ClientSize; + + window.SetRenderScaling(2.0); + window.CaptureRenderedFrame(); + + AssertHelper.Equal(clientSizeBefore.Width, window.ClientSize.Width); + AssertHelper.Equal(clientSizeBefore.Height, window.ClientSize.Height); + } } From 5a3e66e1f067f56dadcf32ddaaa7b67ed13303b0 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 16 Mar 2026 15:02:22 +0100 Subject: [PATCH 09/16] Fix X11IconLoader exception for icons < 128px (#20914) --- src/Avalonia.X11/X11IconLoader.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.X11/X11IconLoader.cs b/src/Avalonia.X11/X11IconLoader.cs index f0cd6f0192..ab0946f531 100644 --- a/src/Avalonia.X11/X11IconLoader.cs +++ b/src/Avalonia.X11/X11IconLoader.cs @@ -40,14 +40,15 @@ namespace Avalonia.X11 _width = Math.Min(bitmap.PixelSize.Width, 128); _height = Math.Min(bitmap.PixelSize.Height, 128); var pixels = new uint[_width * _height]; + var size = new PixelSize(_width, _height); - using (var rtb = new RenderTargetBitmap(new PixelSize(128, 128))) + using (var rtb = new RenderTargetBitmap(size)) { using (var ctx = rtb.CreateDrawingContext(true)) ctx.DrawImage(bitmap, new Rect(rtb.Size)); fixed (void* pPixels = pixels) - rtb.CopyPixels(new LockedFramebuffer((IntPtr)pPixels, new PixelSize(_width, _height), _width * 4, + rtb.CopyPixels(new LockedFramebuffer((IntPtr)pPixels, size, _width * 4, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul, null)); } From 3a9ef06db5329727b4ea5049527d44b7d95bcb26 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 16 Mar 2026 15:02:55 +0100 Subject: [PATCH 10/16] X11: Fix bitmap transferred with INCR (#20895) --- src/Avalonia.X11/Clipboard/ClipboardReadSession.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs index f53d8fe3d4..7c83ecea40 100644 --- a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs +++ b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs @@ -127,6 +127,7 @@ class ClipboardReadSession : IDisposable Append(part); } + ms.Position = 0L; return new(null, ms, actualTypeAtom); } @@ -150,4 +151,4 @@ class ClipboardReadSession : IDisposable } } -} \ No newline at end of file +} From c122e5957bcc5f6c85265446c425d9407ff30660 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 16 Mar 2026 15:04:52 +0100 Subject: [PATCH 11/16] Fix values of known atoms (#20894) * Fix values of known atoms * Move AnyPropertyType outside of X11Atoms --- src/Avalonia.X11/ActivityTrackingHelper.cs | 4 +- .../Clipboard/ClipboardDataFormatHelper.cs | 6 +- src/Avalonia.X11/Clipboard/X11Clipboard.cs | 6 +- .../Screens/X11Screen.Providers.cs | 6 +- src/Avalonia.X11/TransparencyHelper.cs | 2 +- src/Avalonia.X11/X11Atoms.cs | 147 +++++++++--------- src/Avalonia.X11/X11Globals.cs | 6 +- src/Avalonia.X11/X11Window.cs | 14 +- src/Avalonia.X11/XLib.cs | 2 + src/Avalonia.X11/XResources.cs | 6 +- src/tools/DevGenerators/X11AtomsGenerator.cs | 45 ++++-- 11 files changed, 134 insertions(+), 110 deletions(-) diff --git a/src/Avalonia.X11/ActivityTrackingHelper.cs b/src/Avalonia.X11/ActivityTrackingHelper.cs index 846802d123..e5b56f86bd 100644 --- a/src/Avalonia.X11/ActivityTrackingHelper.cs +++ b/src/Avalonia.X11/ActivityTrackingHelper.cs @@ -38,7 +38,7 @@ internal class WindowActivationTrackingHelper : IDisposable if (Mode == X11Globals.WindowActivationTrackingMode._NET_WM_STATE_FOCUSED) OnNetWmStateChanged(XLib.XGetWindowPropertyAsIntPtrArray(_platform.Display, _window.Handle.Handle, - _platform.Info.Atoms._NET_WM_STATE, _platform.Info.Atoms.XA_ATOM) ?? []); + _platform.Info.Atoms._NET_WM_STATE, _platform.Info.Atoms.ATOM) ?? []); } private void OnWindowActivationTrackingModeChanged() => @@ -70,7 +70,7 @@ internal class WindowActivationTrackingHelper : IDisposable { var value = XLib.XGetWindowPropertyAsIntPtrArray(_platform.Display, _platform.Info.RootWindow, _platform.Info.Atoms._NET_ACTIVE_WINDOW, - (IntPtr)_platform.Info.Atoms.XA_WINDOW); + (IntPtr)_platform.Info.Atoms.WINDOW); if (value == null || value.Length == 0) SetActive(false); else diff --git a/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs b/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs index aed323ddb0..5e53ff96c1 100644 --- a/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs +++ b/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs @@ -19,7 +19,7 @@ internal static class ClipboardDataFormatHelper if (formatAtom == atoms.UTF16_STRING || formatAtom == atoms.UTF8_STRING || - formatAtom == atoms.XA_STRING || + formatAtom == atoms.STRING || formatAtom == atoms.OEMTEXT) { return DataFormat.Text; @@ -92,7 +92,7 @@ internal static class ClipboardDataFormatHelper private static IntPtr GetPreferredStringFormatAtom(IntPtr[] textFormatAtoms, X11Atoms atoms) { - ReadOnlySpan preferredFormats = [atoms.UTF16_STRING, atoms.UTF8_STRING, atoms.XA_STRING]; + ReadOnlySpan preferredFormats = [atoms.UTF16_STRING, atoms.UTF8_STRING, atoms.STRING]; foreach (var preferredFormat in preferredFormats) { @@ -111,7 +111,7 @@ internal static class ClipboardDataFormatHelper if (formatAtom == atoms.UTF8_STRING) return Encoding.UTF8; - if (formatAtom == atoms.XA_STRING || formatAtom == atoms.OEMTEXT) + if (formatAtom == atoms.STRING || formatAtom == atoms.OEMTEXT) return Encoding.ASCII; return null; diff --git a/src/Avalonia.X11/Clipboard/X11Clipboard.cs b/src/Avalonia.X11/Clipboard/X11Clipboard.cs index 6435e42e32..35abbf11c1 100644 --- a/src/Avalonia.X11/Clipboard/X11Clipboard.cs +++ b/src/Avalonia.X11/Clipboard/X11Clipboard.cs @@ -32,7 +32,7 @@ namespace Avalonia.X11.Clipboard _avaloniaSaveTargetsAtom = XInternAtom(_x11.Display, "AVALONIA_SAVE_TARGETS_PROPERTY_ATOM", false); _textAtoms = new[] { - _x11.Atoms.XA_STRING, + _x11.Atoms.STRING, _x11.Atoms.OEMTEXT, _x11.Atoms.UTF8_STRING, _x11.Atoms.UTF16_STRING @@ -99,7 +99,7 @@ namespace Avalonia.X11.Clipboard { var atoms = ConvertDataTransfer(_storedDataTransfer); XChangeProperty(_x11.Display, window, property, - _x11.Atoms.XA_ATOM, 32, PropertyMode.Replace, atoms, atoms.Length); + _x11.Atoms.ATOM, 32, PropertyMode.Replace, atoms, atoms.Length); return property; } else if (target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) @@ -287,7 +287,7 @@ namespace Avalonia.X11.Clipboard _storeAtomTcs = new TaskCompletionSource(); var atoms = ConvertDataTransfer(dataTransfer); - XChangeProperty(_x11.Display, _handle, _avaloniaSaveTargetsAtom, _x11.Atoms.XA_ATOM, 32, + XChangeProperty(_x11.Display, _handle, _avaloniaSaveTargetsAtom, _x11.Atoms.ATOM, 32, PropertyMode.Replace, atoms, atoms.Length); XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD_MANAGER, _x11.Atoms.SAVE_TARGETS, _avaloniaSaveTargetsAtom, _handle, IntPtr.Zero); diff --git a/src/Avalonia.X11/Screens/X11Screen.Providers.cs b/src/Avalonia.X11/Screens/X11Screen.Providers.cs index f516e0f44f..1a12a279a1 100644 --- a/src/Avalonia.X11/Screens/X11Screen.Providers.cs +++ b/src/Avalonia.X11/Screens/X11Screen.Providers.cs @@ -56,9 +56,9 @@ internal partial class X11Screens if (!hasEDID) return null; XRRGetOutputProperty(x11.Display, rrOutput, x11.Atoms.EDID, 0, EDIDStructureLength, false, false, - x11.Atoms.AnyPropertyType, out IntPtr actualType, out int actualFormat, out int bytesAfter, out _, + AnyPropertyType, out IntPtr actualType, out int actualFormat, out int bytesAfter, out _, out IntPtr prop); - if (actualType != x11.Atoms.XA_INTEGER) + if (actualType != x11.Atoms.INTEGER) return null; if (actualFormat != 8) // Expecting an byte array return null; @@ -89,7 +89,7 @@ internal partial class X11Screens IntPtr.Zero, new IntPtr(128), false, - x11.Atoms.AnyPropertyType, + AnyPropertyType, out var type, out var format, out var count, diff --git a/src/Avalonia.X11/TransparencyHelper.cs b/src/Avalonia.X11/TransparencyHelper.cs index 50a73a36ce..427ec647e6 100644 --- a/src/Avalonia.X11/TransparencyHelper.cs +++ b/src/Avalonia.X11/TransparencyHelper.cs @@ -89,7 +89,7 @@ namespace Avalonia.X11 { IntPtr value = IntPtr.Zero; XLib.XChangeProperty(_x11.Display, _window, _x11.Atoms._KDE_NET_WM_BLUR_BEHIND_REGION, - _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref value, 1); + _x11.Atoms.CARDINAL, 32, PropertyMode.Replace, ref value, 1); _blurAtomsAreSet = true; } } diff --git a/src/Avalonia.X11/X11Atoms.cs b/src/Avalonia.X11/X11Atoms.cs index b851974bad..64b00c411b 100644 --- a/src/Avalonia.X11/X11Atoms.cs +++ b/src/Avalonia.X11/X11Atoms.cs @@ -44,75 +44,74 @@ namespace Avalonia.X11 private readonly IntPtr _display; // Our atoms - public IntPtr AnyPropertyType = (IntPtr)0; - public IntPtr XA_PRIMARY = (IntPtr)1; - public IntPtr XA_SECONDARY = (IntPtr)2; - public IntPtr XA_ARC = (IntPtr)3; - public IntPtr XA_ATOM = (IntPtr)4; - public IntPtr XA_BITMAP = (IntPtr)5; - public IntPtr XA_CARDINAL = (IntPtr)6; - public IntPtr XA_COLORMAP = (IntPtr)7; - public IntPtr XA_CURSOR = (IntPtr)8; - public IntPtr XA_CUT_BUFFER0 = (IntPtr)9; - public IntPtr XA_CUT_BUFFER1 = (IntPtr)10; - public IntPtr XA_CUT_BUFFER2 = (IntPtr)11; - public IntPtr XA_CUT_BUFFER3 = (IntPtr)12; - public IntPtr XA_CUT_BUFFER4 = (IntPtr)13; - public IntPtr XA_CUT_BUFFER5 = (IntPtr)14; - public IntPtr XA_CUT_BUFFER6 = (IntPtr)15; - public IntPtr XA_CUT_BUFFER7 = (IntPtr)16; - public IntPtr XA_DRAWABLE = (IntPtr)17; - public IntPtr XA_FONT = (IntPtr)18; - public IntPtr XA_INTEGER = (IntPtr)19; - public IntPtr XA_PIXMAP = (IntPtr)20; - public IntPtr XA_POINT = (IntPtr)21; - public IntPtr XA_RECTANGLE = (IntPtr)22; - public IntPtr XA_RESOURCE_MANAGER = (IntPtr)23; - public IntPtr XA_RGB_COLOR_MAP = (IntPtr)24; - public IntPtr XA_RGB_BEST_MAP = (IntPtr)25; - public IntPtr XA_RGB_BLUE_MAP = (IntPtr)26; - public IntPtr XA_RGB_DEFAULT_MAP = (IntPtr)27; - public IntPtr XA_RGB_GRAY_MAP = (IntPtr)28; - public IntPtr XA_RGB_GREEN_MAP = (IntPtr)29; - public IntPtr XA_RGB_RED_MAP = (IntPtr)30; - public IntPtr XA_STRING = (IntPtr)31; - public IntPtr XA_VISUALID = (IntPtr)32; - public IntPtr XA_WINDOW = (IntPtr)33; - public IntPtr XA_WM_COMMAND = (IntPtr)34; - public IntPtr XA_WM_HINTS = (IntPtr)35; - public IntPtr XA_WM_CLIENT_MACHINE = (IntPtr)36; - public IntPtr XA_WM_ICON_NAME = (IntPtr)37; - public IntPtr XA_WM_ICON_SIZE = (IntPtr)38; - public IntPtr XA_WM_NAME = (IntPtr)39; - public IntPtr XA_WM_NORMAL_HINTS = (IntPtr)40; - public IntPtr XA_WM_SIZE_HINTS = (IntPtr)41; - public IntPtr XA_WM_ZOOM_HINTS = (IntPtr)42; - public IntPtr XA_MIN_SPACE = (IntPtr)43; - public IntPtr XA_NORM_SPACE = (IntPtr)44; - public IntPtr XA_MAX_SPACE = (IntPtr)45; - public IntPtr XA_END_SPACE = (IntPtr)46; - public IntPtr XA_SUPERSCRIPT_X = (IntPtr)47; - public IntPtr XA_SUPERSCRIPT_Y = (IntPtr)48; - public IntPtr XA_SUBSCRIPT_X = (IntPtr)49; - public IntPtr XA_SUBSCRIPT_Y = (IntPtr)50; - public IntPtr XA_UNDERLINE_POSITION = (IntPtr)51; - public IntPtr XA_UNDERLINE_THICKNESS = (IntPtr)52; - public IntPtr XA_STRIKEOUT_ASCENT = (IntPtr)53; - public IntPtr XA_STRIKEOUT_DESCENT = (IntPtr)54; - public IntPtr XA_ITALIC_ANGLE = (IntPtr)55; - public IntPtr XA_X_HEIGHT = (IntPtr)56; - public IntPtr XA_QUAD_WIDTH = (IntPtr)57; - public IntPtr XA_WEIGHT = (IntPtr)58; - public IntPtr XA_POINT_SIZE = (IntPtr)59; - public IntPtr XA_RESOLUTION = (IntPtr)60; - public IntPtr XA_COPYRIGHT = (IntPtr)61; - public IntPtr XA_NOTICE = (IntPtr)62; - public IntPtr XA_FONT_NAME = (IntPtr)63; - public IntPtr XA_FAMILY_NAME = (IntPtr)64; - public IntPtr XA_FULL_NAME = (IntPtr)65; - public IntPtr XA_CAP_HEIGHT = (IntPtr)66; - public IntPtr XA_WM_CLASS = (IntPtr)67; - public IntPtr XA_WM_TRANSIENT_FOR = (IntPtr)68; + public readonly IntPtr PRIMARY = 1; + public readonly IntPtr SECONDARY = 2; + public readonly IntPtr ARC = 3; + public readonly IntPtr ATOM = 4; + public readonly IntPtr BITMAP = 5; + public readonly IntPtr CARDINAL = 6; + public readonly IntPtr COLORMAP = 7; + public readonly IntPtr CURSOR = 8; + public readonly IntPtr CUT_BUFFER0 = 9; + public readonly IntPtr CUT_BUFFER1 = 10; + public readonly IntPtr CUT_BUFFER2 = 11; + public readonly IntPtr CUT_BUFFER3 = 12; + public readonly IntPtr CUT_BUFFER4 = 13; + public readonly IntPtr CUT_BUFFER5 = 14; + public readonly IntPtr CUT_BUFFER6 = 15; + public readonly IntPtr CUT_BUFFER7 = 16; + public readonly IntPtr DRAWABLE = 17; + public readonly IntPtr FONT = 18; + public readonly IntPtr INTEGER = 19; + public readonly IntPtr PIXMAP = 20; + public readonly IntPtr POINT = 21; + public readonly IntPtr RECTANGLE = 22; + public readonly IntPtr RESOURCE_MANAGER = 23; + public readonly IntPtr RGB_COLOR_MAP = 24; + public readonly IntPtr RGB_BEST_MAP = 25; + public readonly IntPtr RGB_BLUE_MAP = 26; + public readonly IntPtr RGB_DEFAULT_MAP = 27; + public readonly IntPtr RGB_GRAY_MAP = 28; + public readonly IntPtr RGB_GREEN_MAP = 29; + public readonly IntPtr RGB_RED_MAP = 30; + public readonly IntPtr STRING = 31; + public readonly IntPtr VISUALID = 32; + public readonly IntPtr WINDOW = 33; + public readonly IntPtr WM_COMMAND = 34; + public readonly IntPtr WM_HINTS = 35; + public readonly IntPtr WM_CLIENT_MACHINE = 36; + public readonly IntPtr WM_ICON_NAME = 37; + public readonly IntPtr WM_ICON_SIZE = 38; + public readonly IntPtr WM_NAME = 39; + public readonly IntPtr WM_NORMAL_HINTS = 40; + public readonly IntPtr WM_SIZE_HINTS = 41; + public readonly IntPtr WM_ZOOM_HINTS = 42; + public readonly IntPtr MIN_SPACE = 43; + public readonly IntPtr NORM_SPACE = 44; + public readonly IntPtr MAX_SPACE = 45; + public readonly IntPtr END_SPACE = 46; + public readonly IntPtr SUPERSCRIPT_X = 47; + public readonly IntPtr SUPERSCRIPT_Y = 48; + public readonly IntPtr SUBSCRIPT_X = 49; + public readonly IntPtr SUBSCRIPT_Y = 50; + public readonly IntPtr UNDERLINE_POSITION = 51; + public readonly IntPtr UNDERLINE_THICKNESS = 52; + public readonly IntPtr STRIKEOUT_ASCENT = 53; + public readonly IntPtr STRIKEOUT_DESCENT = 54; + public readonly IntPtr ITALIC_ANGLE = 55; + public readonly IntPtr X_HEIGHT = 56; + public readonly IntPtr QUAD_WIDTH = 57; + public readonly IntPtr WEIGHT = 58; + public readonly IntPtr POINT_SIZE = 59; + public readonly IntPtr RESOLUTION = 60; + public readonly IntPtr COPYRIGHT = 61; + public readonly IntPtr NOTICE = 62; + public readonly IntPtr FONT_NAME = 63; + public readonly IntPtr FAMILY_NAME = 64; + public readonly IntPtr FULL_NAME = 65; + public readonly IntPtr CAP_HEIGHT = 66; + public readonly IntPtr WM_CLASS = 67; + public readonly IntPtr WM_TRANSIENT_FOR = 68; public IntPtr EDID; @@ -183,7 +182,6 @@ namespace Avalonia.X11 public IntPtr CLIPBOARD_MANAGER; public IntPtr SAVE_TARGETS; public IntPtr MULTIPLE; - public IntPtr PRIMARY; public IntPtr OEMTEXT; public IntPtr UNICODETEXT; public IntPtr TARGETS; @@ -208,11 +206,16 @@ namespace Avalonia.X11 if (value != IntPtr.Zero) { field = value; - _namesToAtoms[name] = value; - _atomsToNames[value] = name; + SetName(name, value); } } + private void SetName(string name, IntPtr value) + { + _namesToAtoms[name] = value; + _atomsToNames[value] = name; + } + public IntPtr GetAtom(string name) { if (_namesToAtoms.TryGetValue(name, out var rv)) diff --git a/src/Avalonia.X11/X11Globals.cs b/src/Avalonia.X11/X11Globals.cs index 3f16f8a88f..b9e4058b2d 100644 --- a/src/Avalonia.X11/X11Globals.cs +++ b/src/Avalonia.X11/X11Globals.cs @@ -109,13 +109,13 @@ namespace Avalonia.X11 { XGetWindowProperty(_x11.Display, _rootWindow, _x11.Atoms._NET_SUPPORTING_WM_CHECK, IntPtr.Zero, new IntPtr(IntPtr.Size), false, - _x11.Atoms.XA_WINDOW, out IntPtr actualType, out int actualFormat, out IntPtr nitems, + _x11.Atoms.WINDOW, out IntPtr actualType, out int actualFormat, out IntPtr nitems, out IntPtr bytesAfter, out IntPtr prop); if (nitems.ToInt32() != 1) return IntPtr.Zero; try { - if (actualType != _x11.Atoms.XA_WINDOW) + if (actualType != _x11.Atoms.WINDOW) return IntPtr.Zero; return *(IntPtr*)prop.ToPointer(); } @@ -197,7 +197,7 @@ namespace Avalonia.X11 if (wm == IntPtr.Zero) return WindowActivationTrackingMode.FocusEvents; var supportedFeatures = XGetWindowPropertyAsIntPtrArray(_x11.Display, _x11.RootWindow, - _x11.Atoms._NET_SUPPORTED, _x11.Atoms.XA_ATOM) ?? []; + _x11.Atoms._NET_SUPPORTED, _x11.Atoms.ATOM) ?? []; if (supportedFeatures.Contains(_x11.Atoms._NET_WM_STATE_FOCUSED)) return WindowActivationTrackingMode._NET_WM_STATE_FOCUSED; diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 14e0f0dea8..bf20600a18 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -253,14 +253,14 @@ namespace Avalonia.X11 _mode.AppendWmProtocols(data); - XChangeProperty(_x11.Display, _handle, _x11.Atoms.WM_PROTOCOLS, _x11.Atoms.XA_ATOM, 32, + XChangeProperty(_x11.Display, _handle, _x11.Atoms.WM_PROTOCOLS, _x11.Atoms.ATOM, 32, PropertyMode.Replace, data.ToArray(), data.Count); if (_x11.HasXSync) { _xSyncCounter = XSyncCreateCounter(_x11.Display, _xSyncValue); XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_SYNC_REQUEST_COUNTER, - _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1); + _x11.Atoms.CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1); } _storageProvider = new FallbackStorageProvider(new[] @@ -366,7 +366,7 @@ namespace Avalonia.X11 var pid = (uint)s_pid; // The type of `_NET_WM_PID` is `CARDINAL` which is 32-bit unsigned integer, see https://specifications.freedesktop.org/wm-spec/1.3/ar01s05.html XChangeProperty(_x11.Display, windowXId, - _x11.Atoms._NET_WM_PID, _x11.Atoms.XA_CARDINAL, 32, + _x11.Atoms._NET_WM_PID, _x11.Atoms.CARDINAL, 32, PropertyMode.Replace, ref pid, 1); const int maxLength = 1024; @@ -385,7 +385,7 @@ namespace Avalonia.X11 } XChangeProperty(_x11.Display, windowXId, - _x11.Atoms.XA_WM_CLIENT_MACHINE, _x11.Atoms.XA_STRING, 8, + _x11.Atoms.WM_CLIENT_MACHINE, _x11.Atoms.STRING, 8, PropertyMode.Replace, name, length); } @@ -1150,7 +1150,7 @@ namespace Avalonia.X11 public void SetParent(IWindowImpl? parent) { if (parent == null || parent.Handle == null || parent.Handle.Handle == IntPtr.Zero) - XDeleteProperty(_x11.Display, _handle, _x11.Atoms.XA_WM_TRANSIENT_FOR); + XDeleteProperty(_x11.Display, _handle, _x11.Atoms.WM_TRANSIENT_FOR); else XSetTransientForHint(_x11.Display, _handle, parent.Handle.Handle); } @@ -1394,7 +1394,7 @@ namespace Avalonia.X11 if (string.IsNullOrEmpty(title)) { XDeleteProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_NAME); - XDeleteProperty(_x11.Display, _handle, _x11.Atoms.XA_WM_NAME); + XDeleteProperty(_x11.Display, _handle, _x11.Atoms.WM_NAME); } else { @@ -1648,7 +1648,7 @@ namespace Avalonia.X11 _ => _x11.Atoms._NET_WM_WINDOW_TYPE_NORMAL }; - XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_WINDOW_TYPE, _x11.Atoms.XA_ATOM, + XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_WINDOW_TYPE, _x11.Atoms.ATOM, 32, PropertyMode.Replace, new[] { atom }, 1); } diff --git a/src/Avalonia.X11/XLib.cs b/src/Avalonia.X11/XLib.cs index 2c8ecf2c94..595e996733 100644 --- a/src/Avalonia.X11/XLib.cs +++ b/src/Avalonia.X11/XLib.cs @@ -22,6 +22,8 @@ namespace Avalonia.X11 private const string libXInput = "libXi.so.6"; private const string libXCursor = "libXcursor.so.1"; + public const IntPtr AnyPropertyType = 0; + [DllImport(libX11)] public static extern IntPtr XOpenDisplay(IntPtr display); diff --git a/src/Avalonia.X11/XResources.cs b/src/Avalonia.X11/XResources.cs index ee1a0d5d99..982954bfcb 100644 --- a/src/Avalonia.X11/XResources.cs +++ b/src/Avalonia.X11/XResources.cs @@ -51,9 +51,9 @@ internal class XResources string? ReadResourcesString() { - XGetWindowProperty(_x11.Display, _x11.RootWindow, _x11.Atoms.XA_RESOURCE_MANAGER, + XGetWindowProperty(_x11.Display, _x11.RootWindow, _x11.Atoms.RESOURCE_MANAGER, IntPtr.Zero, new IntPtr(0x7fffffff), - false, _x11.Atoms.XA_STRING, out _, out var actualFormat, + false, _x11.Atoms.STRING, out _, out var actualFormat, out var nitems, out _, out var prop); try { @@ -69,7 +69,7 @@ internal class XResources private void OnRootPropertyChanged(IntPtr atom) { - if (atom == _x11.Atoms.XA_RESOURCE_MANAGER) + if (atom == _x11.Atoms.RESOURCE_MANAGER) UpdateResources(); } } diff --git a/src/tools/DevGenerators/X11AtomsGenerator.cs b/src/tools/DevGenerators/X11AtomsGenerator.cs index daf003c4c4..920b3477dc 100644 --- a/src/tools/DevGenerators/X11AtomsGenerator.cs +++ b/src/tools/DevGenerators/X11AtomsGenerator.cs @@ -1,9 +1,8 @@ -using System.IO; +using System.Collections.Generic; using System.Linq; using System.Text; using Generator; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace DevGenerators; @@ -40,24 +39,44 @@ public class X11AtomsGenerator : IIncrementalGenerator .AppendLine(cl.Name) .AppendLine("{"); - var fields = cl.GetMembers().OfType() + var allFields = cl.GetMembers().OfType() .Where(f => f.Type.Name == "IntPtr" - && f.DeclaredAccessibility == Accessibility.Public).ToList(); - + && f.DeclaredAccessibility == Accessibility.Public); + + var writeableFields = new List(128); + var readonlyFields = new List(128); + + foreach (var field in allFields) + { + var fields = field.IsReadOnly ? readonlyFields : writeableFields; + fields.Add(field); + } + classBuilder.Pad(1).AppendLine("private void PopulateAtoms(IntPtr display)").Pad(1).AppendLine("{"); - classBuilder.Pad(2).Append("var atoms = new IntPtr[").Append(fields.Count).AppendLine("];"); - classBuilder.Pad(2).Append("var atomNames = new string[").Append(fields.Count).AppendLine("] {"); + for (int c = 0; c < readonlyFields.Count; c++) + { + var field = readonlyFields[c]; + var initializer = + (field.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(context.CancellationToken) as VariableDeclaratorSyntax) + ?.Initializer?.Value; + + classBuilder.Pad(2).Append("SetName(").Append('\"') + .Append(field.Name).Append("\", ").Append(initializer).AppendLine(");"); + } + + classBuilder.Pad(2).Append("var atoms = new IntPtr[").Append(writeableFields.Count).AppendLine("];"); + classBuilder.Pad(2).Append("var atomNames = new string[").Append(writeableFields.Count).AppendLine("] {"); - for (int c = 0; c < fields.Count; c++) - classBuilder.Pad(3).Append("\"").Append(fields[c].Name).AppendLine("\","); + for (int c = 0; c < writeableFields.Count; c++) + classBuilder.Pad(3).Append("\"").Append(writeableFields[c].Name).AppendLine("\","); classBuilder.Pad(2).AppendLine("};"); classBuilder.Pad(2).AppendLine("XInternAtoms(display, atomNames, atomNames.Length, true, atoms);"); - for (int c = 0; c < fields.Count; c++) - classBuilder.Pad(2).Append("InitAtom(ref ").Append(fields[c].Name).Append(", \"") - .Append(fields[c].Name).Append("\", atoms[").Append(c).AppendLine("]);"); + for (int c = 0; c < writeableFields.Count; c++) + classBuilder.Pad(2).Append("InitAtom(ref ").Append(writeableFields[c].Name).Append(", \"") + .Append(writeableFields[c].Name).Append("\", atoms[").Append(c).AppendLine("]);"); classBuilder.Pad(1).AppendLine("}"); @@ -70,4 +89,4 @@ public class X11AtomsGenerator : IIncrementalGenerator } -} \ No newline at end of file +} From e9dbf08c84003fbd8e3c845bca220b1c6c592a90 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 16 Mar 2026 15:05:17 +0100 Subject: [PATCH 12/16] Add WinForms message filter for Avalonia windows (#20814) * Add WinForms message filter for Avalonia windows * Do not use filter messages for WinFormsAvaloniaControlHost --- .../EmbedToWinFormsDemo.Designer.cs | 145 +++++++++--------- .../WindowsInteropTest/EmbedToWinFormsDemo.cs | 25 ++- samples/interop/WindowsInteropTest/Program.cs | 3 + .../WindowsInteropTest.csproj | 1 + .../WinForms/WinFormsAvaloniaMessageFilter.cs | 48 ++++++ .../Avalonia.Win32/WindowImpl.AppWndProc.cs | 8 + 6 files changed, 157 insertions(+), 73 deletions(-) create mode 100644 src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs diff --git a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs index d8b0724520..48087a9058 100644 --- a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs +++ b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs @@ -30,87 +30,88 @@ namespace WindowsInteropTest /// private void InitializeComponent() { - this.button1 = new System.Windows.Forms.Button(); - this.monthCalendar1 = new System.Windows.Forms.MonthCalendar(); - this.groupBox1 = new System.Windows.Forms.GroupBox(); - this.groupBox2 = new System.Windows.Forms.GroupBox(); - this.avaloniaHost = new WinFormsAvaloniaControlHost(); - this.groupBox1.SuspendLayout(); - this.groupBox2.SuspendLayout(); - this.SuspendLayout(); - // - // button1 - // - this.button1.Location = new System.Drawing.Point(28, 29); - this.button1.Name = "button1"; - this.button1.Size = new System.Drawing.Size(164, 73); - this.button1.TabIndex = 0; - this.button1.Text = "button1"; - this.button1.UseVisualStyleBackColor = true; - // + OpenWindowButton = new System.Windows.Forms.Button(); + monthCalendar1 = new System.Windows.Forms.MonthCalendar(); + groupBox1 = new System.Windows.Forms.GroupBox(); + groupBox2 = new System.Windows.Forms.GroupBox(); + avaloniaHost = new Avalonia.Win32.Interoperability.WinFormsAvaloniaControlHost(); + groupBox1.SuspendLayout(); + groupBox2.SuspendLayout(); + SuspendLayout(); + // + // OpenWindowButton + // + OpenWindowButton.Location = new System.Drawing.Point(33, 33); + OpenWindowButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + OpenWindowButton.Name = "OpenWindowButton"; + OpenWindowButton.Size = new System.Drawing.Size(191, 84); + OpenWindowButton.TabIndex = 0; + OpenWindowButton.Text = "Open Avalonia Window"; + OpenWindowButton.UseVisualStyleBackColor = true; + OpenWindowButton.Click += OpenWindowButton_Click; + // // monthCalendar1 - // - this.monthCalendar1.Location = new System.Drawing.Point(28, 114); - this.monthCalendar1.Name = "monthCalendar1"; - this.monthCalendar1.TabIndex = 1; - // + // + monthCalendar1.Location = new System.Drawing.Point(33, 132); + monthCalendar1.Margin = new System.Windows.Forms.Padding(10); + monthCalendar1.Name = "monthCalendar1"; + monthCalendar1.TabIndex = 1; + // // groupBox1 - // - this.groupBox1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left))); - this.groupBox1.Controls.Add(this.button1); - this.groupBox1.Controls.Add(this.monthCalendar1); - this.groupBox1.Location = new System.Drawing.Point(12, 12); - this.groupBox1.Name = "groupBox1"; - this.groupBox1.Size = new System.Drawing.Size(227, 418); - this.groupBox1.TabIndex = 2; - this.groupBox1.TabStop = false; - this.groupBox1.Text = "WinForms"; - // + // + groupBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left)); + groupBox1.Controls.Add(OpenWindowButton); + groupBox1.Controls.Add(monthCalendar1); + groupBox1.Location = new System.Drawing.Point(14, 14); + groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + groupBox1.Name = "groupBox1"; + groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); + groupBox1.Size = new System.Drawing.Size(265, 482); + groupBox1.TabIndex = 2; + groupBox1.TabStop = false; + groupBox1.Text = "WinForms"; + // // groupBox2 - // - this.groupBox2.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.groupBox2.Controls.Add(this.avaloniaHost); - this.groupBox2.Location = new System.Drawing.Point(245, 12); - this.groupBox2.Name = "groupBox2"; - this.groupBox2.Size = new System.Drawing.Size(501, 418); - this.groupBox2.TabIndex = 3; - this.groupBox2.TabStop = false; - this.groupBox2.Text = "Avalonia"; - // + // + groupBox2.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)); + groupBox2.Controls.Add(avaloniaHost); + groupBox2.Location = new System.Drawing.Point(286, 14); + groupBox2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + groupBox2.Name = "groupBox2"; + groupBox2.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); + groupBox2.Size = new System.Drawing.Size(584, 482); + groupBox2.TabIndex = 3; + groupBox2.TabStop = false; + groupBox2.Text = "Avalonia"; + // // avaloniaHost - // - this.avaloniaHost.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.avaloniaHost.Content = null; - this.avaloniaHost.Location = new System.Drawing.Point(6, 19); - this.avaloniaHost.Name = "avaloniaHost"; - this.avaloniaHost.Size = new System.Drawing.Size(489, 393); - this.avaloniaHost.TabIndex = 0; - this.avaloniaHost.Text = "avaloniaHost"; - // + // + avaloniaHost.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)); + avaloniaHost.Location = new System.Drawing.Point(7, 22); + avaloniaHost.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + avaloniaHost.Name = "avaloniaHost"; + avaloniaHost.Size = new System.Drawing.Size(570, 453); + avaloniaHost.TabIndex = 0; + avaloniaHost.Text = "avaloniaHost"; + // // EmbedToWinFormsDemo - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(758, 442); - this.Controls.Add(this.groupBox2); - this.Controls.Add(this.groupBox1); - this.MinimumSize = new System.Drawing.Size(600, 400); - this.Name = "EmbedToWinFormsDemo"; - this.Text = "EmbedToWinFormsDemo"; - this.groupBox1.ResumeLayout(false); - this.groupBox2.ResumeLayout(false); - this.ResumeLayout(false); - + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(884, 510); + Controls.Add(groupBox2); + Controls.Add(groupBox1); + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + MinimumSize = new System.Drawing.Size(697, 456); + Text = "EmbedToWinFormsDemo"; + groupBox1.ResumeLayout(false); + groupBox2.ResumeLayout(false); + ResumeLayout(false); } #endregion - private System.Windows.Forms.Button button1; + private System.Windows.Forms.Button OpenWindowButton; private System.Windows.Forms.MonthCalendar monthCalendar1; private System.Windows.Forms.GroupBox groupBox1; private System.Windows.Forms.GroupBox groupBox2; diff --git a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs index d37ed13559..69dfcb1bbc 100644 --- a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs +++ b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs @@ -1,5 +1,10 @@ -using System.Windows.Forms; +using System; +using System.Windows.Forms; using ControlCatalog; +using AvaloniaButton = Avalonia.Controls.Button; +using AvaloniaStackPanel = Avalonia.Controls.StackPanel; +using AvaloniaTextBox = Avalonia.Controls.TextBox; +using AvaloniaWindow = Avalonia.Controls.Window; namespace WindowsInteropTest { @@ -10,5 +15,23 @@ namespace WindowsInteropTest InitializeComponent(); avaloniaHost.Content = new MainView(); } + + private void OpenWindowButton_Click(object sender, EventArgs e) + { + var window = new AvaloniaWindow + { + Width = 300, + Height = 300, + Content = new AvaloniaStackPanel + { + Children = + { + new AvaloniaButton { Content = "Button" }, + new AvaloniaTextBox { Text = "Text" } + } + } + }; + window.Show(); + } } } diff --git a/samples/interop/WindowsInteropTest/Program.cs b/samples/interop/WindowsInteropTest/Program.cs index 4ebb88642b..8ef01523d9 100644 --- a/samples/interop/WindowsInteropTest/Program.cs +++ b/samples/interop/WindowsInteropTest/Program.cs @@ -1,6 +1,7 @@ using System; using ControlCatalog; using Avalonia; +using Avalonia.Win32.Interoperability; namespace WindowsInteropTest { @@ -14,9 +15,11 @@ namespace WindowsInteropTest { System.Windows.Forms.Application.EnableVisualStyles(); System.Windows.Forms.Application.SetCompatibleTextRenderingDefault(false); + System.Windows.Forms.Application.AddMessageFilter(new WinFormsAvaloniaMessageFilter()); AppBuilder.Configure() .UseWin32() .UseSkia() + .UseHarfBuzz() .SetupWithoutStarting(); System.Windows.Forms.Application.Run(new EmbedToWinFormsDemo()); } diff --git a/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj b/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj index 576910ca3d..e282d93121 100644 --- a/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj +++ b/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs b/src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs new file mode 100644 index 0000000000..3df4b89ce9 --- /dev/null +++ b/src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs @@ -0,0 +1,48 @@ +using System; +using System.Windows.Forms; +using static Avalonia.Win32.Interop.UnmanagedMethods; + +namespace Avalonia.Win32.Interoperability; + +/// +/// Provides a message filter for integrating Avalonia within a WinForms application. +/// +/// +/// This filter ensures that key messages, which are typically handled specially by WinForms, +/// are intercepted and routed to Avalonia's windows. This is necessary to preserve proper input handling +/// in mixed WinForms and Avalonia application scenarios. +/// +public class WinFormsAvaloniaMessageFilter : IMessageFilter +{ + /// + public bool PreFilterMessage(ref Message m) + { + // WinForms handles key messages specially, preventing them from reaching Avalonia's windows. + // Handle them first. + if (m.Msg >= (int)WindowsMessage.WM_KEYFIRST && + m.Msg <= (int)WindowsMessage.WM_KEYLAST && + WindowImpl.IsOurWindowGlobal(m.HWnd) && + !IsInsideWinForms(m.HWnd)) + { + var msg = new MSG + { + hwnd = m.HWnd, + message = (uint)m.Msg, + wParam = m.WParam, + lParam = m.LParam + }; + + TranslateMessage(ref msg); + DispatchMessage(ref msg); + return true; + } + + return false; + } + + private static bool IsInsideWinForms(IntPtr hwnd) + { + var parentHwnd = GetParent(hwnd); + return parentHwnd != IntPtr.Zero && Control.FromHandle(parentHwnd) is WinFormsAvaloniaControlHost; + } +} diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index 5295e2c03a..82aaac226c 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -1004,6 +1004,14 @@ namespace Avalonia.Win32 if (hwnd == _hwnd) return true; + return IsOurWindowGlobal(hwnd); + } + + internal static bool IsOurWindowGlobal(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) + return false; + lock (s_instances) for (int i = 0; i < s_instances.Count; i++) if (s_instances[i]._hwnd == hwnd) From 12b7a5615a29da5cc2d519a78f21f26da96544ff Mon Sep 17 00:00:00 2001 From: Compunet <117437050+dme-compunet@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:28:19 +0200 Subject: [PATCH 13/16] Do not resolve markup extensions when resolving selector types (#20903) * Do not resolve markup extensions when resolving selector types * Added commen * Added unit test --- .../AvaloniaXamlIlSelectorTransformer.cs | 4 +++- .../Xaml/StyleTests.cs | 20 +++++++++++++++++++ .../Xaml/TestSelectorControl.cs | 5 +++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs index d37cffc360..1a8a80329f 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs @@ -209,8 +209,10 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers throw new XamlSelectorsTransformException("Unable to parse selector: " + e.Message, node, e); } + // Selectors should resolve control types only. + // isMarkupExtension = false to prevent resolving selector types to XExtension. var selector = Create(parsed, (p, n) - => TypeReferenceResolver.ResolveType(context, $"{p}:{n}", true, node, true)); + => TypeReferenceResolver.ResolveType(context, $"{p}:{n}", false, node, true)); pn.Values[0] = selector; var templateType = GetLastTemplateTypeFromSelector(selector); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index 54f80984ff..95d6cbca94 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -788,5 +788,25 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml Assert.Equal("Cannot add a Style without selector to a ControlTheme. Line 5, position 14.", exception.Message); } + + [Fact] + public void Selector_Should_Not_Resolve_To_MarkupExtension_Type() + { + using var _ = UnitTestApplication.Start(TestServices.StyledWindow); + + var style = (Style)AvaloniaRuntimeXamlLoader.Load( + $""" + + """); + + Assert.NotNull(style.Selector); + + var targetType = style.Selector.TargetType; + + Assert.NotEqual(typeof(TestSelectorControlExtension), targetType); + } } } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs new file mode 100644 index 0000000000..a1cb89c002 --- /dev/null +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs @@ -0,0 +1,5 @@ +namespace Avalonia.Markup.Xaml.UnitTests.Xaml; + +public class TestSelectorControl; + +public class TestSelectorControlExtension; From 4e1b90b0617bd5316d6e8cbaad99d3e86e685945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Tue, 17 Mar 2026 21:22:19 +0100 Subject: [PATCH 14/16] [Feature] Add gestures and WrapSelection (loops) support to Carousel (#20659) * Format Carousel sample UI * Added WrapSelection property support * Implement gestures * Update sample adding custom page transitions * More changes * Added swipe velocity * Optimize completion timer * Verify gesture id * Improve CrossFade animation * Fix in swipe gesture getting direction * More changes * Fix mistake * More protections * Remove redundant ItemCount > 0 checks in OnKeyDown * Renamed GestureId to Id in SwipeGestureEventArgs * Remove size parameter from PageTransition Update method * Changes based on feedback * Update VirtualizingCarouselPanel.cs * Refactor and complete swipe gesture (added more tests) * Updated Avalonia.nupkg.xml * Changes based on feedback * Polish carousel snap-back animation * Implement ViewportFractionProperty * Fixed test * Fix FillMode in Rotate3DTransition * Updated comment * Added vertical swipe tests * More changes * Fix interrupted carousel transition lifecycle --- api/Avalonia.nupkg.xml | 96 ++ .../ControlCatalog/Pages/CarouselPage.xaml | 117 +- .../ControlCatalog/Pages/CarouselPage.xaml.cs | 116 +- .../DrawerPageCustomizationPage.xaml.cs | 13 + .../DrawerPageFirstLookPage.xaml.cs | 13 + .../NavigationPageGesturePage.xaml.cs | 13 + .../TabbedPage/TabbedPageGesturePage.xaml.cs | 13 + .../Transitions/CardStackPageTransition.cs | 447 ++++++ .../Transitions/WaveRevealPageTransition.cs | 380 +++++ .../Animation/CompositePageTransition.cs | 32 +- src/Avalonia.Base/Animation/CrossFade.cs | 84 +- .../Animation/IProgressPageTransition.cs | 39 + src/Avalonia.Base/Animation/PageSlide.cs | 57 +- .../Animation/PageTransitionItem.cs | 12 + .../Transitions/Rotate3DTransition.cs | 161 ++- .../GestureRecognizerCollection.cs | 18 + .../SwipeGestureRecognizer.cs | 265 ++-- src/Avalonia.Base/Input/Gestures.cs | 2 - .../Input/InputElement.Gestures.cs | 16 + src/Avalonia.Base/Input/SwipeDirection.cs | 28 + .../Input/SwipeGestureEventArgs.cs | 73 +- src/Avalonia.Controls/Carousel.cs | 178 ++- src/Avalonia.Controls/Page/DrawerPage.cs | 23 +- src/Avalonia.Controls/Page/NavigationPage.cs | 24 +- src/Avalonia.Controls/Page/TabbedPage.cs | 17 +- .../VirtualizingCarouselPanel.cs | 1270 ++++++++++++++++- .../Input/SwipeGestureRecognizerTests.cs | 158 ++ .../CarouselTests.cs | 225 ++- .../DrawerPageTests.cs | 78 + .../InputElementGestureTests.cs | 23 + .../NavigationPageTests.cs | 115 ++ .../TabbedPageTests.cs | 89 ++ .../VirtualizingCarouselPanelTests.cs | 768 +++++++++- .../Controls/CarouselTests.cs | 127 ++ ...leItemSelected_ShowsSidePeeks.expected.png | Bin 0 -> 5615 bytes 35 files changed, 4869 insertions(+), 221 deletions(-) create mode 100644 samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs create mode 100644 samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs create mode 100644 src/Avalonia.Base/Animation/IProgressPageTransition.cs create mode 100644 src/Avalonia.Base/Animation/PageTransitionItem.cs create mode 100644 src/Avalonia.Base/Input/SwipeDirection.cs create mode 100644 tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs create mode 100644 tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs create mode 100644 tests/Avalonia.RenderTests/Controls/CarouselTests.cs create mode 100644 tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index c03d1fe6cc..8e6173a6cb 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -991,6 +991,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 F:Avalonia.Input.HoldingState.Cancelled @@ -1147,6 +1159,30 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType) @@ -1411,6 +1447,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel @@ -2545,6 +2593,18 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 F:Avalonia.Input.HoldingState.Cancelled @@ -2701,6 +2761,30 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType) @@ -2965,6 +3049,18 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml b/samples/ControlCatalog/Pages/CarouselPage.xaml index 352fa32e30..c6e20fec5b 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml @@ -1,44 +1,117 @@ - - An items control that displays its items as pages that fill the control. + + A swipeable items control that can reveal adjacent pages with ViewportFraction. - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - Transition - + + + + Transition + None - Slide - Crossfade - 3D Rotation + Page Slide + Cross Fade + Rotate 3D + Card Stack + Wave Reveal + Composite (Slide + Fade) - - - Orientation - + Orientation + Horizontal Vertical + + Viewport Fraction + + + + 1.00 + + + + + + + + + Wrap Selection + Swipe Enabled + + + + + + + + Total Items: + 0 + + + Selected Index: + 0 + + diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs index 713da34051..0a0c973b90 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs @@ -1,6 +1,9 @@ using System; +using Avalonia; using Avalonia.Animation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using ControlCatalog.Pages.Transitions; namespace ControlCatalog.Pages { @@ -9,28 +12,137 @@ namespace ControlCatalog.Pages public CarouselPage() { InitializeComponent(); + left.Click += (s, e) => carousel.Previous(); right.Click += (s, e) => carousel.Next(); transition.SelectionChanged += TransitionChanged; orientation.SelectionChanged += TransitionChanged; + viewportFraction.ValueChanged += ViewportFractionChanged; + + wrapSelection.IsChecked = carousel.WrapSelection; + wrapSelection.IsCheckedChanged += (s, e) => + { + carousel.WrapSelection = wrapSelection.IsChecked ?? false; + UpdateButtonState(); + }; + + swipeEnabled.IsChecked = carousel.IsSwipeEnabled; + swipeEnabled.IsCheckedChanged += (s, e) => + { + carousel.IsSwipeEnabled = swipeEnabled.IsChecked ?? false; + }; + + carousel.PropertyChanged += (s, e) => + { + if (e.Property == SelectingItemsControl.SelectedIndexProperty) + { + UpdateButtonState(); + } + else if (e.Property == Carousel.ViewportFractionProperty) + { + UpdateViewportFractionDisplay(); + } + }; + + carousel.ViewportFraction = viewportFraction.Value; + UpdateButtonState(); + UpdateViewportFractionDisplay(); + } + + private void UpdateButtonState() + { + itemsCountIndicator.Text = carousel.ItemCount.ToString(); + selectedIndexIndicator.Text = carousel.SelectedIndex.ToString(); + + var wrap = carousel.WrapSelection; + left.IsEnabled = wrap || carousel.SelectedIndex > 0; + right.IsEnabled = wrap || carousel.SelectedIndex < carousel.ItemCount - 1; + } + + private void ViewportFractionChanged(object? sender, RangeBaseValueChangedEventArgs e) + { + carousel.ViewportFraction = Math.Round(e.NewValue, 2); + UpdateViewportFractionDisplay(); + } + + private void UpdateViewportFractionDisplay() + { + var value = carousel.ViewportFraction; + viewportFractionIndicator.Text = value.ToString("0.00"); + + var pagesInView = 1d / value; + viewportFractionHint.Text = value >= 1d + ? "1.00 shows a single full page." + : $"{pagesInView:0.##} pages fit in view. Try 0.80 for peeking or 0.33 for three full items."; } private void TransitionChanged(object? sender, SelectionChangedEventArgs e) { + var isVertical = orientation.SelectedIndex == 1; + var axis = isVertical ? PageSlide.SlideAxis.Vertical : PageSlide.SlideAxis.Horizontal; + switch (transition.SelectedIndex) { case 0: carousel.PageTransition = null; break; case 1: - carousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical); + carousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), axis); break; case 2: carousel.PageTransition = new CrossFade(TimeSpan.FromSeconds(0.25)); break; case 3: - carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical); + carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), axis); + break; + case 4: + carousel.PageTransition = new CardStackPageTransition(TimeSpan.FromSeconds(0.5), axis); + break; + case 5: + carousel.PageTransition = new WaveRevealPageTransition(TimeSpan.FromSeconds(0.8), axis); break; + case 6: + carousel.PageTransition = new CompositePageTransition + { + PageTransitions = + { + new PageSlide(TimeSpan.FromSeconds(0.25), axis), + new CrossFade(TimeSpan.FromSeconds(0.25)), + } + }; + break; + } + + UpdateLayoutForOrientation(isVertical); + } + + private void UpdateLayoutForOrientation(bool isVertical) + { + if (isVertical) + { + Grid.SetColumn(left, 1); + Grid.SetRow(left, 0); + Grid.SetColumn(right, 1); + Grid.SetRow(right, 2); + + left.Padding = new Thickness(20, 10); + right.Padding = new Thickness(20, 10); + + leftArrow.RenderTransform = new Avalonia.Media.RotateTransform(90); + rightArrow.RenderTransform = new Avalonia.Media.RotateTransform(90); + } + else + { + Grid.SetColumn(left, 0); + Grid.SetRow(left, 1); + Grid.SetColumn(right, 2); + Grid.SetRow(right, 1); + + left.Padding = new Thickness(10, 20); + right.Padding = new Thickness(10, 20); + + leftArrow.RenderTransform = null; + rightArrow.RenderTransform = null; } } } diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs index 697e67f0f4..243bc5868b 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs @@ -1,5 +1,7 @@ +using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; using Avalonia.Media; @@ -22,6 +24,7 @@ namespace ControlCatalog.Pages public DrawerPageCustomizationPage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoDrawer); } protected override void OnLoaded(RoutedEventArgs e) @@ -188,5 +191,15 @@ namespace ControlCatalog.Pages if (DemoDrawer.DrawerBehavior != DrawerBehavior.Locked) DemoDrawer.IsOpen = false; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs index 58a981f640..de72957d73 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs @@ -1,4 +1,6 @@ +using System.Linq; using Avalonia.Controls; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; namespace ControlCatalog.Pages @@ -8,6 +10,7 @@ namespace ControlCatalog.Pages public DrawerPageFirstLookPage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoDrawer); } protected override void OnLoaded(RoutedEventArgs e) @@ -61,5 +64,15 @@ namespace ControlCatalog.Pages { StatusText.Text = $"Drawer: {(DemoDrawer.IsOpen ? "Open" : "Closed")}"; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs index ff711f3a63..c18cfebc7e 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs @@ -1,4 +1,6 @@ +using System.Linq; using Avalonia.Controls; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; namespace ControlCatalog.Pages @@ -8,6 +10,7 @@ namespace ControlCatalog.Pages public NavigationPageGesturePage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoNav); Loaded += OnLoaded; } @@ -43,5 +46,15 @@ namespace ControlCatalog.Pages { StatusText.Text = $"Depth: {DemoNav.StackDepth}"; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs index bee2c43efd..e17ebc5ed8 100644 --- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs +++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs @@ -1,4 +1,6 @@ +using System.Linq; using Avalonia.Controls; +using Avalonia.Input.GestureRecognizers; namespace ControlCatalog.Pages { @@ -7,6 +9,7 @@ namespace ControlCatalog.Pages public TabbedPageGesturePage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoTabs); } private void OnGestureEnabledChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) @@ -26,5 +29,15 @@ namespace ControlCatalog.Pages _ => TabPlacement.Top }; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs b/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs new file mode 100644 index 0000000000..89ae1e5e8a --- /dev/null +++ b/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; +using Avalonia.Styling; + +namespace ControlCatalog.Pages.Transitions; + +/// +/// Transitions between two pages with a card-stack effect: +/// the top page moves/rotates away while the next page scales up underneath. +/// +public class CardStackPageTransition : PageSlide +{ + private const double ViewportLiftScale = 0.03; + private const double ViewportPromotionScale = 0.02; + private const double ViewportDepthOpacityFalloff = 0.08; + private const double SidePeekAngle = 4.0; + private const double FarPeekAngle = 7.0; + + /// + /// Initializes a new instance of the class. + /// + public CardStackPageTransition() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The duration of the animation. + /// The axis on which the animation should occur. + public CardStackPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal) + : base(duration, orientation) + { + } + + /// + /// Gets or sets the maximum rotation angle (degrees) applied to the top card. + /// + public double MaxSwipeAngle { get; set; } = 15.0; + + /// + /// Gets or sets the scale reduction applied to the back card (0.05 = 5%). + /// + public double BackCardScale { get; set; } = 0.05; + + /// + /// Gets or sets the vertical offset (pixels) applied to the back card. + /// + public double BackCardOffset { get; set; } = 0.0; + + /// + public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var tasks = new List(); + var parent = GetVisualParent(from, to); + var distance = Orientation == PageSlide.SlideAxis.Horizontal ? parent.Bounds.Width : parent.Bounds.Height; + var translateProperty = Orientation == PageSlide.SlideAxis.Horizontal ? TranslateTransform.XProperty : TranslateTransform.YProperty; + var rotationTarget = Orientation == PageSlide.SlideAxis.Horizontal ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) : 0.0; + var startScale = 1.0 - BackCardScale; + + if (from != null) + { + var (rotate, translate) = EnsureTopTransforms(from); + rotate.Angle = 0; + translate.X = 0; + translate.Y = 0; + from.Opacity = 1; + from.ZIndex = 1; + + var animation = new Animation + { + Easing = SlideOutEasing, + Duration = Duration, + FillMode = FillMode, + Children = + { + new KeyFrame + { + Setters = + { + new Setter(translateProperty, 0d), + new Setter(RotateTransform.AngleProperty, 0d) + }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = + { + new Setter(translateProperty, forward ? -distance : distance), + new Setter(RotateTransform.AngleProperty, rotationTarget) + }, + Cue = new Cue(1d) + } + } + }; + tasks.Add(animation.RunAsync(from, cancellationToken)); + } + + if (to != null) + { + var (scale, translate) = EnsureBackTransforms(to); + scale.ScaleX = startScale; + scale.ScaleY = startScale; + translate.X = 0; + translate.Y = BackCardOffset; + to.IsVisible = true; + to.Opacity = 1; + to.ZIndex = 0; + + var animation = new Animation + { + Easing = SlideInEasing, + Duration = Duration, + FillMode = FillMode, + Children = + { + new KeyFrame + { + Setters = + { + new Setter(ScaleTransform.ScaleXProperty, startScale), + new Setter(ScaleTransform.ScaleYProperty, startScale), + new Setter(TranslateTransform.YProperty, BackCardOffset) + }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = + { + new Setter(ScaleTransform.ScaleXProperty, 1d), + new Setter(ScaleTransform.ScaleYProperty, 1d), + new Setter(TranslateTransform.YProperty, 0d) + }, + Cue = new Cue(1d) + } + } + }; + + tasks.Add(animation.RunAsync(to, cancellationToken)); + } + + await Task.WhenAll(tasks); + + if (from != null && !cancellationToken.IsCancellationRequested) + { + from.IsVisible = false; + } + + if (!cancellationToken.IsCancellationRequested && to != null) + { + var (scale, translate) = EnsureBackTransforms(to); + scale.ScaleX = 1; + scale.ScaleY = 1; + translate.X = 0; + translate.Y = 0; + } + } + + /// + public override void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(progress, from, to, forward, pageLength, visibleItems); + return; + } + + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var size = parent.Bounds.Size; + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + var distance = pageLength > 0 + ? pageLength + : (isHorizontal ? size.Width : size.Height); + var rotationTarget = isHorizontal ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) : 0.0; + var startScale = 1.0 - BackCardScale; + + if (from != null) + { + var (rotate, translate) = EnsureTopTransforms(from); + if (isHorizontal) + { + translate.X = forward ? -distance * progress : distance * progress; + translate.Y = 0; + } + else + { + translate.X = 0; + translate.Y = forward ? -distance * progress : distance * progress; + } + + rotate.Angle = rotationTarget * progress; + from.IsVisible = true; + from.Opacity = 1; + from.ZIndex = 1; + } + + if (to != null) + { + var (scale, translate) = EnsureBackTransforms(to); + var currentScale = startScale + (1.0 - startScale) * progress; + var currentOffset = BackCardOffset * (1.0 - progress); + + scale.ScaleX = currentScale; + scale.ScaleY = currentScale; + if (isHorizontal) + { + translate.X = 0; + translate.Y = currentOffset; + } + else + { + translate.X = currentOffset; + translate.Y = 0; + } + + to.IsVisible = true; + to.Opacity = 1; + to.ZIndex = 0; + } + } + + /// + public override void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.RenderTransformOrigin = default; + visual.Opacity = 1; + visual.ZIndex = 0; + } + + private void UpdateVisibleItems( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + var rotationTarget = isHorizontal + ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) + : 0.0; + var stackOffset = GetViewportStackOffset(pageLength); + var lift = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI); + + foreach (var item in visibleItems) + { + var visual = item.Visual; + var (rotate, scale, translate) = EnsureViewportTransforms(visual); + var depth = GetViewportDepth(item.ViewportCenterOffset); + var scaleValue = Math.Max(0.84, 1.0 - (BackCardScale * depth)); + var stackValue = stackOffset * depth; + var baseOpacity = Math.Max(0.8, 1.0 - (ViewportDepthOpacityFalloff * depth)); + var restingAngle = isHorizontal ? GetViewportRestingAngle(item.ViewportCenterOffset) : 0.0; + + rotate.Angle = restingAngle; + scale.ScaleX = scaleValue; + scale.ScaleY = scaleValue; + translate.X = 0; + translate.Y = 0; + + if (ReferenceEquals(visual, from)) + { + rotate.Angle = restingAngle + (rotationTarget * progress); + stackValue -= stackOffset * 0.2 * lift; + baseOpacity = Math.Min(1.0, baseOpacity + 0.08); + } + + if (ReferenceEquals(visual, to)) + { + var promotedScale = Math.Min(1.0, scaleValue + (ViewportLiftScale * lift) + (ViewportPromotionScale * progress)); + scale.ScaleX = promotedScale; + scale.ScaleY = promotedScale; + rotate.Angle = restingAngle * (1.0 - progress); + stackValue = Math.Max(0.0, stackValue - (stackOffset * (0.45 + (0.2 * lift)) * progress)); + baseOpacity = Math.Min(1.0, baseOpacity + (0.12 * lift)); + } + + if (isHorizontal) + translate.Y = stackValue; + else + translate.X = stackValue; + + visual.IsVisible = true; + visual.Opacity = baseOpacity; + visual.ZIndex = GetViewportZIndex(item.ViewportCenterOffset, visual, from, to); + } + } + + private static (RotateTransform rotate, TranslateTransform translate) EnsureTopTransforms(Visual visual) + { + if (visual.RenderTransform is TransformGroup group && + group.Children.Count == 2 && + group.Children[0] is RotateTransform rotateTransform && + group.Children[1] is TranslateTransform translateTransform) + { + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotateTransform, translateTransform); + } + + var rotate = new RotateTransform(); + var translate = new TranslateTransform(); + visual.RenderTransform = new TransformGroup + { + Children = + { + rotate, + translate + } + }; + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotate, translate); + } + + private static (ScaleTransform scale, TranslateTransform translate) EnsureBackTransforms(Visual visual) + { + if (visual.RenderTransform is TransformGroup group && + group.Children.Count == 2 && + group.Children[0] is ScaleTransform scaleTransform && + group.Children[1] is TranslateTransform translateTransform) + { + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (scaleTransform, translateTransform); + } + + var scale = new ScaleTransform(); + var translate = new TranslateTransform(); + visual.RenderTransform = new TransformGroup + { + Children = + { + scale, + translate + } + }; + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (scale, translate); + } + + private static (RotateTransform rotate, ScaleTransform scale, TranslateTransform translate) EnsureViewportTransforms(Visual visual) + { + if (visual.RenderTransform is TransformGroup group && + group.Children.Count == 3 && + group.Children[0] is RotateTransform rotateTransform && + group.Children[1] is ScaleTransform scaleTransform && + group.Children[2] is TranslateTransform translateTransform) + { + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotateTransform, scaleTransform, translateTransform); + } + + var rotate = new RotateTransform(); + var scale = new ScaleTransform(1, 1); + var translate = new TranslateTransform(); + visual.RenderTransform = new TransformGroup + { + Children = + { + rotate, + scale, + translate + } + }; + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotate, scale, translate); + } + + private double GetViewportStackOffset(double pageLength) + { + if (BackCardOffset > 0) + return BackCardOffset; + + return Math.Clamp(pageLength * 0.045, 10.0, 18.0); + } + + private static double GetViewportDepth(double offsetFromCenter) + { + var distance = Math.Abs(offsetFromCenter); + + if (distance <= 1.0) + return distance; + + if (distance <= 2.0) + return 1.0 + ((distance - 1.0) * 0.8); + + return 1.8; + } + + private static double GetViewportRestingAngle(double offsetFromCenter) + { + var sign = Math.Sign(offsetFromCenter); + if (sign == 0) + return 0; + + var distance = Math.Abs(offsetFromCenter); + if (distance <= 1.0) + return sign * Lerp(0.0, SidePeekAngle, distance); + + if (distance <= 2.0) + return sign * Lerp(SidePeekAngle, FarPeekAngle, distance - 1.0); + + return sign * FarPeekAngle; + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * Math.Clamp(t, 0.0, 1.0)); + } + + private static int GetViewportZIndex(double offsetFromCenter, Visual visual, Visual? from, Visual? to) + { + if (ReferenceEquals(visual, from)) + return 5; + + if (ReferenceEquals(visual, to)) + return 4; + + var distance = Math.Abs(offsetFromCenter); + if (distance < 0.5) + return 4; + if (distance < 1.5) + return 3; + return 2; + } +} diff --git a/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs b/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs new file mode 100644 index 0000000000..9d8e80bf9c --- /dev/null +++ b/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; + +namespace ControlCatalog.Pages.Transitions; + +/// +/// Transitions between two pages using a wave clip that reveals the next page. +/// +public class WaveRevealPageTransition : PageSlide +{ + /// + /// Initializes a new instance of the class. + /// + public WaveRevealPageTransition() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The duration of the animation. + /// The axis on which the animation should occur. + public WaveRevealPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal) + : base(duration, orientation) + { + } + + /// + /// Gets or sets the maximum wave bulge (pixels) along the movement axis. + /// + public double MaxBulge { get; set; } = 120.0; + + /// + /// Gets or sets the bulge factor along the movement axis (0-1). + /// + public double BulgeFactor { get; set; } = 0.35; + + /// + /// Gets or sets the bulge factor along the cross axis (0-1). + /// + public double CrossBulgeFactor { get; set; } = 0.3; + + /// + /// Gets or sets a cross-axis offset (pixels) to shift the wave center. + /// + public double WaveCenterOffset { get; set; } = 0.0; + + /// + /// Gets or sets how strongly the wave center follows the provided offset. + /// + public double CenterSensitivity { get; set; } = 1.0; + + /// + /// Gets or sets the bulge exponent used to shape the wave (1.0 = linear). + /// Higher values tighten the bulge; lower values broaden it. + /// + public double BulgeExponent { get; set; } = 1.0; + + /// + /// Gets or sets the easing applied to the wave progress (clip only). + /// + public Easing WaveEasing { get; set; } = new CubicEaseOut(); + + /// + public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (to != null) + { + to.IsVisible = true; + to.ZIndex = 1; + } + + if (from != null) + { + from.ZIndex = 0; + } + + await AnimateProgress(0.0, 1.0, from, to, forward, cancellationToken); + + if (to != null && !cancellationToken.IsCancellationRequested) + { + to.Clip = null; + } + + if (from != null && !cancellationToken.IsCancellationRequested) + { + from.IsVisible = false; + } + } + + /// + public override void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(from, to, forward, pageLength, visibleItems); + return; + } + + if (from is null && to is null) + return; + var parent = GetVisualParent(from, to); + var size = parent.Bounds.Size; + var centerOffset = WaveCenterOffset * CenterSensitivity; + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + + if (to != null) + { + to.IsVisible = progress > 0.0; + to.ZIndex = 1; + to.Opacity = 1; + + if (progress >= 1.0) + { + to.Clip = null; + } + else + { + var waveProgress = WaveEasing?.Ease(progress) ?? progress; + var clip = LiquidSwipeClipper.CreateWavePath( + waveProgress, + size, + centerOffset, + forward, + isHorizontal, + MaxBulge, + BulgeFactor, + CrossBulgeFactor, + BulgeExponent); + to.Clip = clip; + } + } + + if (from != null) + { + from.IsVisible = true; + from.ZIndex = 0; + from.Opacity = 1; + } + } + + private void UpdateVisibleItems( + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var size = parent.Bounds.Size; + var centerOffset = WaveCenterOffset * CenterSensitivity; + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + var resolvedPageLength = pageLength > 0 + ? pageLength + : (isHorizontal ? size.Width : size.Height); + foreach (var item in visibleItems) + { + var visual = item.Visual; + visual.IsVisible = true; + visual.Opacity = 1; + visual.Clip = null; + visual.ZIndex = ReferenceEquals(visual, to) ? 1 : 0; + + if (!ReferenceEquals(visual, to)) + continue; + + var visibleFraction = GetVisibleFraction(item.ViewportCenterOffset, size, resolvedPageLength, isHorizontal); + if (visibleFraction >= 1.0) + continue; + + visual.Clip = LiquidSwipeClipper.CreateWavePath( + visibleFraction, + size, + centerOffset, + forward, + isHorizontal, + MaxBulge, + BulgeFactor, + CrossBulgeFactor, + BulgeExponent); + } + } + + private static double GetVisibleFraction(double offsetFromCenter, Size viewportSize, double pageLength, bool isHorizontal) + { + if (pageLength <= 0) + return 1.0; + + var viewportLength = isHorizontal ? viewportSize.Width : viewportSize.Height; + if (viewportLength <= 0) + return 0.0; + + var viewportUnits = viewportLength / pageLength; + var edgePeek = Math.Max(0.0, (viewportUnits - 1.0) / 2.0); + return Math.Clamp(1.0 + edgePeek - Math.Abs(offsetFromCenter), 0.0, 1.0); + } + + /// + public override void Reset(Visual visual) + { + visual.Clip = null; + visual.ZIndex = 0; + visual.Opacity = 1; + } + + private async Task AnimateProgress( + double from, + double to, + Visual? fromVisual, + Visual? toVisual, + bool forward, + CancellationToken cancellationToken) + { + var parent = GetVisualParent(fromVisual, toVisual); + var pageLength = Orientation == PageSlide.SlideAxis.Horizontal + ? parent.Bounds.Width + : parent.Bounds.Height; + var durationMs = Math.Max(Duration.TotalMilliseconds * Math.Abs(to - from), 50); + var startTicks = Stopwatch.GetTimestamp(); + var tickFreq = Stopwatch.Frequency; + + while (!cancellationToken.IsCancellationRequested) + { + var elapsedMs = (Stopwatch.GetTimestamp() - startTicks) * 1000.0 / tickFreq; + var t = Math.Clamp(elapsedMs / durationMs, 0.0, 1.0); + var eased = SlideInEasing?.Ease(t) ?? t; + var progress = from + (to - from) * eased; + + Update(progress, fromVisual, toVisual, forward, pageLength, Array.Empty()); + + if (t >= 1.0) + break; + + await Task.Delay(16, cancellationToken); + } + + if (!cancellationToken.IsCancellationRequested) + { + Update(to, fromVisual, toVisual, forward, pageLength, Array.Empty()); + } + } + + private static class LiquidSwipeClipper + { + public static Geometry CreateWavePath( + double progress, + Size size, + double waveCenterOffset, + bool forward, + bool isHorizontal, + double maxBulge, + double bulgeFactor, + double crossBulgeFactor, + double bulgeExponent) + { + var width = size.Width; + var height = size.Height; + + if (progress <= 0) + return new RectangleGeometry(new Rect(0, 0, 0, 0)); + + if (progress >= 1) + return new RectangleGeometry(new Rect(0, 0, width, height)); + + if (width <= 0 || height <= 0) + return new RectangleGeometry(new Rect(0, 0, 0, 0)); + + var mainLength = isHorizontal ? width : height; + var crossLength = isHorizontal ? height : width; + + var wavePhase = Math.Sin(progress * Math.PI); + var bulgeProgress = bulgeExponent == 1.0 ? wavePhase : Math.Pow(wavePhase, bulgeExponent); + var revealedLength = mainLength * progress; + var bulgeMain = Math.Min(mainLength * bulgeFactor, maxBulge) * bulgeProgress; + bulgeMain = Math.Min(bulgeMain, revealedLength * 0.45); + var bulgeCross = crossLength * crossBulgeFactor; + + var waveCenter = crossLength / 2 + waveCenterOffset; + waveCenter = Math.Clamp(waveCenter, bulgeCross, crossLength - bulgeCross); + + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + if (isHorizontal) + { + if (forward) + { + var waveX = width * (1 - progress); + context.BeginFigure(new Point(width, 0), true); + context.LineTo(new Point(waveX, 0)); + context.CubicBezierTo( + new Point(waveX, waveCenter - bulgeCross), + new Point(waveX - bulgeMain, waveCenter - bulgeCross * 0.5), + new Point(waveX - bulgeMain, waveCenter)); + context.CubicBezierTo( + new Point(waveX - bulgeMain, waveCenter + bulgeCross * 0.5), + new Point(waveX, waveCenter + bulgeCross), + new Point(waveX, height)); + context.LineTo(new Point(width, height)); + context.EndFigure(true); + } + else + { + var waveX = width * progress; + context.BeginFigure(new Point(0, 0), true); + context.LineTo(new Point(waveX, 0)); + context.CubicBezierTo( + new Point(waveX, waveCenter - bulgeCross), + new Point(waveX + bulgeMain, waveCenter - bulgeCross * 0.5), + new Point(waveX + bulgeMain, waveCenter)); + context.CubicBezierTo( + new Point(waveX + bulgeMain, waveCenter + bulgeCross * 0.5), + new Point(waveX, waveCenter + bulgeCross), + new Point(waveX, height)); + context.LineTo(new Point(0, height)); + context.EndFigure(true); + } + } + else + { + if (forward) + { + var waveY = height * (1 - progress); + context.BeginFigure(new Point(0, height), true); + context.LineTo(new Point(0, waveY)); + context.CubicBezierTo( + new Point(waveCenter - bulgeCross, waveY), + new Point(waveCenter - bulgeCross * 0.5, waveY - bulgeMain), + new Point(waveCenter, waveY - bulgeMain)); + context.CubicBezierTo( + new Point(waveCenter + bulgeCross * 0.5, waveY - bulgeMain), + new Point(waveCenter + bulgeCross, waveY), + new Point(width, waveY)); + context.LineTo(new Point(width, height)); + context.EndFigure(true); + } + else + { + var waveY = height * progress; + context.BeginFigure(new Point(0, 0), true); + context.LineTo(new Point(0, waveY)); + context.CubicBezierTo( + new Point(waveCenter - bulgeCross, waveY), + new Point(waveCenter - bulgeCross * 0.5, waveY + bulgeMain), + new Point(waveCenter, waveY + bulgeMain)); + context.CubicBezierTo( + new Point(waveCenter + bulgeCross * 0.5, waveY + bulgeMain), + new Point(waveCenter + bulgeCross, waveY), + new Point(width, waveY)); + context.LineTo(new Point(width, 0)); + context.EndFigure(true); + } + } + } + + return geometry; + } + } +} diff --git a/src/Avalonia.Base/Animation/CompositePageTransition.cs b/src/Avalonia.Base/Animation/CompositePageTransition.cs index 62119a0051..e5e3511337 100644 --- a/src/Avalonia.Base/Animation/CompositePageTransition.cs +++ b/src/Avalonia.Base/Animation/CompositePageTransition.cs @@ -28,7 +28,7 @@ namespace Avalonia.Animation /// /// /// - public class CompositePageTransition : IPageTransition + public class CompositePageTransition : IPageTransition, IProgressPageTransition { /// /// Gets or sets the transitions to be executed. Can be defined from XAML. @@ -44,5 +44,35 @@ namespace Avalonia.Animation .ToArray(); return Task.WhenAll(transitionTasks); } + + /// + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + foreach (var transition in PageTransitions) + { + if (transition is IProgressPageTransition progressive) + { + progressive.Update(progress, from, to, forward, pageLength, visibleItems); + } + } + } + + /// + public void Reset(Visual visual) + { + foreach (var transition in PageTransitions) + { + if (transition is IProgressPageTransition progressive) + { + progressive.Reset(visual); + } + } + } } } diff --git a/src/Avalonia.Base/Animation/CrossFade.cs b/src/Avalonia.Base/Animation/CrossFade.cs index f00d835020..45a4300e5b 100644 --- a/src/Avalonia.Base/Animation/CrossFade.cs +++ b/src/Avalonia.Base/Animation/CrossFade.cs @@ -12,8 +12,13 @@ namespace Avalonia.Animation /// /// Defines a cross-fade animation between two s. /// - public class CrossFade : IPageTransition + public class CrossFade : IPageTransition, IProgressPageTransition { + private const double SidePeekOpacity = 0.72; + private const double FarPeekOpacity = 0.42; + private const double OutgoingDip = 0.22; + private const double IncomingBoost = 0.12; + private const double PassiveDip = 0.05; private readonly Animation _fadeOutAnimation; private readonly Animation _fadeInAnimation; @@ -182,5 +187,82 @@ namespace Avalonia.Animation { return Start(from, to, cancellationToken); } + + /// + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(progress, from, to, visibleItems); + return; + } + + if (from != null) + from.Opacity = 1 - progress; + if (to != null) + { + to.IsVisible = true; + to.Opacity = progress; + } + } + + /// + public void Reset(Visual visual) + { + visual.Opacity = 1; + } + + private static void UpdateVisibleItems( + double progress, + Visual? from, + Visual? to, + IReadOnlyList visibleItems) + { + var emphasis = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI); + foreach (var item in visibleItems) + { + item.Visual.IsVisible = true; + var opacity = GetOpacityForOffset(item.ViewportCenterOffset); + + if (ReferenceEquals(item.Visual, from)) + { + opacity = Math.Max(FarPeekOpacity, opacity - (OutgoingDip * emphasis)); + } + else if (ReferenceEquals(item.Visual, to)) + { + opacity = Math.Min(1.0, opacity + (IncomingBoost * emphasis)); + } + else + { + opacity = Math.Max(FarPeekOpacity, opacity - (PassiveDip * emphasis)); + } + + item.Visual.Opacity = opacity; + } + } + + private static double GetOpacityForOffset(double offsetFromCenter) + { + var distance = Math.Abs(offsetFromCenter); + + if (distance <= 1.0) + return Lerp(1.0, SidePeekOpacity, distance); + + if (distance <= 2.0) + return Lerp(SidePeekOpacity, FarPeekOpacity, distance - 1.0); + + return FarPeekOpacity; + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * Math.Clamp(t, 0.0, 1.0)); + } } } diff --git a/src/Avalonia.Base/Animation/IProgressPageTransition.cs b/src/Avalonia.Base/Animation/IProgressPageTransition.cs new file mode 100644 index 0000000000..01f892d1fd --- /dev/null +++ b/src/Avalonia.Base/Animation/IProgressPageTransition.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Avalonia.VisualTree; + +namespace Avalonia.Animation +{ + /// + /// An that supports progress-driven updates. + /// + /// + /// Transitions implementing this interface can be driven by a normalized progress value + /// (0.0 to 1.0) during swipe gestures or programmatic animations, rather than running + /// as a timed animation via . + /// + public interface IProgressPageTransition : IPageTransition + { + /// + /// Updates the transition to reflect the given progress. + /// + /// The normalized progress value from 0.0 (start) to 1.0 (complete). + /// The visual being transitioned away from. May be null. + /// The visual being transitioned to. May be null. + /// Whether the transition direction is forward (next) or backward (previous). + /// The size of a page along the transition axis. + /// The currently visible realized pages, if more than one page is visible. + void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems); + + /// + /// Resets any visual state applied to the given visual by this transition. + /// + /// The visual to reset. + void Reset(Visual visual); + } +} diff --git a/src/Avalonia.Base/Animation/PageSlide.cs b/src/Avalonia.Base/Animation/PageSlide.cs index 24797a6d80..d75f391c79 100644 --- a/src/Avalonia.Base/Animation/PageSlide.cs +++ b/src/Avalonia.Base/Animation/PageSlide.cs @@ -12,7 +12,7 @@ namespace Avalonia.Animation /// /// Transitions between two pages by sliding them horizontally or vertically. /// - public class PageSlide : IPageTransition + public class PageSlide : IPageTransition, IProgressPageTransition { /// /// The axis on which the PageSlide should occur @@ -50,12 +50,12 @@ namespace Avalonia.Animation /// Gets the orientation of the animation. /// public SlideAxis Orientation { get; set; } - + /// /// Gets or sets element entrance easing. /// public Easing SlideInEasing { get; set; } = new LinearEasing(); - + /// /// Gets or sets element exit easing. /// @@ -152,8 +152,6 @@ namespace Avalonia.Animation if (from != null) { - // Hide BEFORE resetting transform so there is no single-frame flash - // where the element snaps back to position 0 while still visible. from.IsVisible = false; if (FillMode != FillMode.None) from.RenderTransform = null; @@ -163,6 +161,55 @@ namespace Avalonia.Animation to.RenderTransform = null; } + /// + public virtual void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + return; + + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var distance = pageLength > 0 + ? pageLength + : (Orientation == SlideAxis.Horizontal ? parent.Bounds.Width : parent.Bounds.Height); + var offset = distance * progress; + + if (from != null) + { + if (from.RenderTransform is not TranslateTransform ft) + from.RenderTransform = ft = new TranslateTransform(); + if (Orientation == SlideAxis.Horizontal) + ft.X = forward ? -offset : offset; + else + ft.Y = forward ? -offset : offset; + } + + if (to != null) + { + to.IsVisible = true; + if (to.RenderTransform is not TranslateTransform tt) + to.RenderTransform = tt = new TranslateTransform(); + if (Orientation == SlideAxis.Horizontal) + tt.X = forward ? distance - offset : -(distance - offset); + else + tt.Y = forward ? distance - offset : -(distance - offset); + } + } + + /// + public virtual void Reset(Visual visual) + { + visual.RenderTransform = null; + } + /// /// Gets the common visual parent of the two control. /// diff --git a/src/Avalonia.Base/Animation/PageTransitionItem.cs b/src/Avalonia.Base/Animation/PageTransitionItem.cs new file mode 100644 index 0000000000..fed0145a2a --- /dev/null +++ b/src/Avalonia.Base/Animation/PageTransitionItem.cs @@ -0,0 +1,12 @@ +using Avalonia.VisualTree; + +namespace Avalonia.Animation +{ + /// + /// Describes a single visible page within a carousel viewport. + /// + public readonly record struct PageTransitionItem( + int Index, + Visual Visual, + double ViewportCenterOffset); +} diff --git a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs index 239f3aea08..1075198881 100644 --- a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs +++ b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Avalonia.Media; @@ -8,6 +9,8 @@ namespace Avalonia.Animation; public class Rotate3DTransition: PageSlide { + private const double SidePeekAngle = 24.0; + private const double FarPeekAngle = 38.0; /// /// Creates a new instance of the @@ -20,7 +23,7 @@ public class Rotate3DTransition: PageSlide { Depth = depth; } - + /// /// Defines the depth of the 3D Effect. If null, depth will be calculated automatically from the width or height /// of the common parent of the visual being rotated. @@ -28,12 +31,12 @@ public class Rotate3DTransition: PageSlide public double? Depth { get; set; } /// - /// Creates a new instance of the + /// Initializes a new instance of the class. /// public Rotate3DTransition() { } /// - public override async Task Start(Visual? @from, Visual? to, bool forward, CancellationToken cancellationToken) + public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { @@ -49,11 +52,12 @@ public class Rotate3DTransition: PageSlide _ => throw new ArgumentOutOfRangeException() }; - var depthSetter = new Setter {Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center}; - var centerZSetter = new Setter {Property = Rotate3DTransform.CenterZProperty, Value = -center / 2}; + var depthSetter = new Setter { Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center }; + var centerZSetter = new Setter { Property = Rotate3DTransform.CenterZProperty, Value = -center / 2 }; - KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) => - new() { + KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) => + new() + { Setters = { new Setter { Property = rotateProperty, Value = rotation }, @@ -71,7 +75,7 @@ public class Rotate3DTransition: PageSlide { Easing = SlideOutEasing, Duration = Duration, - FillMode = FillMode.Forward, + FillMode = FillMode, Children = { CreateKeyFrame(0d, 0d, 2), @@ -90,7 +94,7 @@ public class Rotate3DTransition: PageSlide { Easing = SlideInEasing, Duration = Duration, - FillMode = FillMode.Forward, + FillMode = FillMode, Children = { CreateKeyFrame(0d, 90d * (forward ? 1 : -1), 1), @@ -107,10 +111,8 @@ public class Rotate3DTransition: PageSlide if (!cancellationToken.IsCancellationRequested) { if (to != null) - { to.ZIndex = 2; - } - + if (from != null) { from.IsVisible = false; @@ -118,4 +120,139 @@ public class Rotate3DTransition: PageSlide } } } + + /// + public override void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(progress, from, to, pageLength, visibleItems); + return; + } + + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var center = pageLength > 0 + ? pageLength + : (Orientation == SlideAxis.Vertical ? parent.Bounds.Height : parent.Bounds.Width); + var depth = Depth ?? center; + var sign = forward ? 1.0 : -1.0; + + if (from != null) + { + if (from.RenderTransform is not Rotate3DTransform ft) + from.RenderTransform = ft = new Rotate3DTransform(); + ft.Depth = depth; + ft.CenterZ = -center / 2; + from.ZIndex = progress < 0.5 ? 2 : 1; + if (Orientation == SlideAxis.Horizontal) + ft.AngleY = -sign * 90.0 * progress; + else + ft.AngleX = -sign * 90.0 * progress; + } + + if (to != null) + { + to.IsVisible = true; + if (to.RenderTransform is not Rotate3DTransform tt) + to.RenderTransform = tt = new Rotate3DTransform(); + tt.Depth = depth; + tt.CenterZ = -center / 2; + to.ZIndex = progress < 0.5 ? 1 : 2; + if (Orientation == SlideAxis.Horizontal) + tt.AngleY = sign * 90.0 * (1.0 - progress); + else + tt.AngleX = sign * 90.0 * (1.0 - progress); + } + } + + private void UpdateVisibleItems( + double progress, + Visual? from, + Visual? to, + double pageLength, + IReadOnlyList visibleItems) + { + var anchor = from ?? to ?? visibleItems[0].Visual; + if (anchor.VisualParent is not Visual parent) + return; + + var center = pageLength > 0 + ? pageLength + : (Orientation == SlideAxis.Vertical ? parent.Bounds.Height : parent.Bounds.Width); + var depth = Depth ?? center; + var angleStrength = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI); + + foreach (var item in visibleItems) + { + var visual = item.Visual; + visual.IsVisible = true; + visual.ZIndex = GetZIndex(item.ViewportCenterOffset); + + if (visual.RenderTransform is not Rotate3DTransform transform) + visual.RenderTransform = transform = new Rotate3DTransform(); + + transform.Depth = depth; + transform.CenterZ = -center / 2; + + var angle = GetAngleForOffset(item.ViewportCenterOffset) * angleStrength; + if (Orientation == SlideAxis.Horizontal) + { + transform.AngleY = angle; + transform.AngleX = 0; + } + else + { + transform.AngleX = angle; + transform.AngleY = 0; + } + } + } + + private static double GetAngleForOffset(double offsetFromCenter) + { + var sign = Math.Sign(offsetFromCenter); + if (sign == 0) + return 0; + + var distance = Math.Abs(offsetFromCenter); + if (distance <= 1.0) + return sign * Lerp(0.0, SidePeekAngle, distance); + + if (distance <= 2.0) + return sign * Lerp(SidePeekAngle, FarPeekAngle, distance - 1.0); + + return sign * FarPeekAngle; + } + + private static int GetZIndex(double offsetFromCenter) + { + var distance = Math.Abs(offsetFromCenter); + + if (distance < 0.5) + return 3; + if (distance < 1.5) + return 2; + return 1; + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * Math.Clamp(t, 0.0, 1.0)); + } + + /// + public override void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.ZIndex = 0; + } } diff --git a/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs b/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs index 74e8061292..34e900c7d7 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs @@ -39,6 +39,24 @@ namespace Avalonia.Input.GestureRecognizers } } + public bool Remove(GestureRecognizer recognizer) + { + if (_recognizers == null) + return false; + + var removed = _recognizers.Remove(recognizer); + + if (removed) + { + recognizer.Target = null; + + if (recognizer is ISetLogicalParent logical) + logical.SetParent(null); + } + + return removed; + } + static readonly List s_Empty = new List(); public IEnumerator GetEnumerator() diff --git a/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs index 5d17940c8a..2328e5e874 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs @@ -1,87 +1,102 @@ using System; -using Avalonia.Logging; -using Avalonia.Media; +using System.Diagnostics; +using Avalonia.Platform; namespace Avalonia.Input.GestureRecognizers { /// - /// A gesture recognizer that detects swipe gestures and raises - /// on the target element when a swipe is confirmed. + /// A gesture recognizer that detects swipe gestures for paging interactions. /// + /// + /// Unlike , this recognizer is optimized for discrete + /// paging interactions (e.g., carousel navigation) rather than continuous scrolling. + /// It does not include inertia or friction physics. + /// public class SwipeGestureRecognizer : GestureRecognizer { + private bool _swiping; + private Point _trackedRootPoint; private IPointer? _tracking; - private IPointer? _captured; - private Point _initialPosition; - private int _gestureId; + private int _id; + + private Vector _velocity; + private long _lastTimestamp; /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty ThresholdProperty = - AvaloniaProperty.Register(nameof(Threshold), 30d); + public static readonly StyledProperty CanHorizontallySwipeProperty = + AvaloniaProperty.Register(nameof(CanHorizontallySwipe)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty CrossAxisCancelThresholdProperty = - AvaloniaProperty.Register( - nameof(CrossAxisCancelThreshold), 8d); + public static readonly StyledProperty CanVerticallySwipeProperty = + AvaloniaProperty.Register(nameof(CanVerticallySwipe)); /// - /// Defines the property. - /// Leading-edge start zone in px. 0 (default) = full area. - /// When > 0, only starts tracking if the pointer is within this many px - /// of the leading edge (LTR: left; RTL: right). + /// Defines the property. /// - public static readonly StyledProperty EdgeSizeProperty = - AvaloniaProperty.Register(nameof(EdgeSize), 0d); + /// + /// A value of 0 (the default) causes the distance to be read from + /// at the time of the first gesture. + /// + public static readonly StyledProperty ThresholdProperty = + AvaloniaProperty.Register(nameof(Threshold), defaultValue: 0d); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsMouseEnabledProperty = + AvaloniaProperty.Register(nameof(IsMouseEnabled), defaultValue: false); /// /// Defines the property. - /// When false, the recognizer ignores all pointer events. - /// Lets callers toggle the recognizer at runtime without needing to remove it from the - /// collection (GestureRecognizerCollection has Add but no Remove). - /// Default: true. /// public static readonly StyledProperty IsEnabledProperty = - AvaloniaProperty.Register(nameof(IsEnabled), true); + AvaloniaProperty.Register(nameof(IsEnabled), defaultValue: true); /// - /// Gets or sets the minimum distance in pixels the pointer must travel before a swipe - /// is recognized. Default is 30px. + /// Gets or sets a value indicating whether horizontal swipes are tracked. /// - public double Threshold + public bool CanHorizontallySwipe { - get => GetValue(ThresholdProperty); - set => SetValue(ThresholdProperty, value); + get => GetValue(CanHorizontallySwipeProperty); + set => SetValue(CanHorizontallySwipeProperty, value); } /// - /// Gets or sets the maximum cross-axis drift in pixels allowed before the gesture is - /// cancelled. Default is 8px. + /// Gets or sets a value indicating whether vertical swipes are tracked. /// - public double CrossAxisCancelThreshold + public bool CanVerticallySwipe { - get => GetValue(CrossAxisCancelThresholdProperty); - set => SetValue(CrossAxisCancelThresholdProperty, value); + get => GetValue(CanVerticallySwipeProperty); + set => SetValue(CanVerticallySwipeProperty, value); } /// - /// Gets or sets the leading-edge start zone in pixels. When greater than zero, tracking - /// only begins if the pointer is within this distance of the leading edge. Default is 0 - /// (full area). + /// Gets or sets the minimum pointer movement in pixels before a swipe is recognized. + /// A value of 0 reads the threshold from at gesture time. /// - public double EdgeSize + public double Threshold { - get => GetValue(EdgeSizeProperty); - set => SetValue(EdgeSizeProperty, value); + get => GetValue(ThresholdProperty); + set => SetValue(ThresholdProperty, value); + } + + /// + /// Gets or sets a value indicating whether mouse pointer events trigger swipe gestures. + /// Defaults to ; touch and pen are always enabled. + /// + public bool IsMouseEnabled + { + get => GetValue(IsMouseEnabledProperty); + set => SetValue(IsMouseEnabledProperty, value); } /// - /// Gets or sets a value indicating whether the recognizer responds to pointer events. - /// Setting this to false is a lightweight alternative to removing the recognizer from - /// the collection. Default is true. + /// Gets or sets a value indicating whether this recognizer responds to pointer events. + /// Defaults to . /// public bool IsEnabled { @@ -89,104 +104,122 @@ namespace Avalonia.Input.GestureRecognizers set => SetValue(IsEnabledProperty, value); } + /// protected override void PointerPressed(PointerPressedEventArgs e) { - if (!IsEnabled) return; - if (!e.GetCurrentPoint(null).Properties.IsLeftButtonPressed) return; - if (Target is not Visual visual) return; + if (!IsEnabled) + return; - var pos = e.GetPosition(visual); - var edgeSize = EdgeSize; + var point = e.GetCurrentPoint(null); - if (edgeSize > 0) + if ((e.Pointer.Type is PointerType.Touch or PointerType.Pen || + (IsMouseEnabled && e.Pointer.Type == PointerType.Mouse)) + && point.Properties.IsLeftButtonPressed) { - bool isRtl = visual.FlowDirection == FlowDirection.RightToLeft; - bool inEdge = isRtl - ? pos.X >= visual.Bounds.Width - edgeSize - : pos.X <= edgeSize; - if (!inEdge) - { - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: press at {Pos} outside edge zone ({EdgeSize}px), ignoring", - pos, edgeSize); - return; - } + EndGesture(); + _tracking = e.Pointer; + _id = SwipeGestureEventArgs.GetNextFreeId(); + _trackedRootPoint = point.Position; + _velocity = default; + _lastTimestamp = 0; } - - _gestureId = SwipeGestureEventArgs.GetNextFreeId(); - _tracking = e.Pointer; - _initialPosition = pos; - - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: tracking started at {Pos} (pointer={PointerType})", - pos, e.Pointer.Type); } + /// protected override void PointerMoved(PointerEventArgs e) { - if (_tracking != e.Pointer || Target is not Visual visual) return; - - var pos = e.GetPosition(visual); - double dx = pos.X - _initialPosition.X; - double dy = pos.Y - _initialPosition.Y; - double absDx = Math.Abs(dx); - double absDy = Math.Abs(dy); - double threshold = Threshold; - - if (absDx < threshold && absDy < threshold) - return; - - SwipeDirection dir; - Vector delta; - if (absDx >= absDy) + if (e.Pointer == _tracking) { - dir = dx > 0 ? SwipeDirection.Right : SwipeDirection.Left; - delta = new Vector(dx, 0); - } - else - { - dir = dy > 0 ? SwipeDirection.Down : SwipeDirection.Up; - delta = new Vector(0, dy); - } + var rootPoint = e.GetPosition(null); + var threshold = GetEffectiveThreshold(); - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: swipe recognized — direction={Direction}, delta={Delta}", - dir, delta); + if (!_swiping) + { + var horizontalTriggered = CanHorizontallySwipe && Math.Abs(_trackedRootPoint.X - rootPoint.X) > threshold; + var verticalTriggered = CanVerticallySwipe && Math.Abs(_trackedRootPoint.Y - rootPoint.Y) > threshold; + + if (horizontalTriggered || verticalTriggered) + { + _swiping = true; + + _trackedRootPoint = new Point( + horizontalTriggered + ? _trackedRootPoint.X - (_trackedRootPoint.X >= rootPoint.X ? threshold : -threshold) + : rootPoint.X, + verticalTriggered + ? _trackedRootPoint.Y - (_trackedRootPoint.Y >= rootPoint.Y ? threshold : -threshold) + : rootPoint.Y); + + Capture(e.Pointer); + } + } - _tracking = null; - _captured = e.Pointer; - Capture(e.Pointer); - e.Handled = true; + if (_swiping) + { + var delta = _trackedRootPoint - rootPoint; + + var now = Stopwatch.GetTimestamp(); + if (_lastTimestamp > 0) + { + var elapsedSeconds = (double)(now - _lastTimestamp) / Stopwatch.Frequency; + if (elapsedSeconds > 0) + { + var instantVelocity = delta / elapsedSeconds; + _velocity = _velocity * 0.5 + instantVelocity * 0.5; + } + } + _lastTimestamp = now; + + Target!.RaiseEvent(new SwipeGestureEventArgs(_id, delta, _velocity)); + _trackedRootPoint = rootPoint; + e.Handled = true; + } + } + } - var args = new SwipeGestureEventArgs(_gestureId, dir, delta, _initialPosition); - Target?.RaiseEvent(args); + /// + protected override void PointerCaptureLost(IPointer pointer) + { + if (pointer == _tracking) + EndGesture(); } + /// protected override void PointerReleased(PointerReleasedEventArgs e) { - if (_tracking == e.Pointer) + if (e.Pointer == _tracking && _swiping) { - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: pointer released without crossing threshold — gesture discarded"); - _tracking = null; + e.Handled = true; + EndGesture(); } + } - if (_captured == e.Pointer) + private void EndGesture() + { + _tracking = null; + if (_swiping) { - (e.Pointer as Pointer)?.CaptureGestureRecognizer(null); - _captured = null; + _swiping = false; + var endedArgs = new SwipeGestureEndedEventArgs(_id, _velocity); + _velocity = default; + _lastTimestamp = 0; + _id = 0; + Target!.RaiseEvent(endedArgs); } } - protected override void PointerCaptureLost(IPointer pointer) + private const double DefaultTapSize = 10; + + private double GetEffectiveThreshold() { - if (_tracking == pointer) - { - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: capture lost — gesture cancelled"); - _tracking = null; - } - _captured = null; + var configured = Threshold; + if (configured > 0) + return configured; + + var tapSize = AvaloniaLocator.Current?.GetService() + ?.GetTapSize(PointerType.Touch).Height ?? DefaultTapSize; + + return tapSize / 2; } } } diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index 3ae504a77f..07c9ab18be 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -30,14 +30,12 @@ namespace Avalonia.Input private static readonly WeakReference s_lastPress = new WeakReference(null); private static Point s_lastPressPoint; private static CancellationTokenSource? s_holdCancellationToken; - static Gestures() { InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed); InputElement.PointerReleasedEvent.RouteFinished.Subscribe(PointerReleased); InputElement.PointerMovedEvent.RouteFinished.Subscribe(PointerMoved); } - private static object? GetCaptured(RoutedEventArgs? args) { if (args is not PointerEventArgs pointerEventArgs) diff --git a/src/Avalonia.Base/Input/InputElement.Gestures.cs b/src/Avalonia.Base/Input/InputElement.Gestures.cs index 83f350f0e7..1323e4d35e 100644 --- a/src/Avalonia.Base/Input/InputElement.Gestures.cs +++ b/src/Avalonia.Base/Input/InputElement.Gestures.cs @@ -54,6 +54,13 @@ namespace Avalonia.Input RoutedEvent.Register( nameof(SwipeGesture), RoutingStrategies.Bubble); + /// + /// Defines the event. + /// + public static readonly RoutedEvent SwipeGestureEndedEvent = + RoutedEvent.Register( + nameof(SwipeGestureEnded), RoutingStrategies.Bubble); + /// /// Defines the event. /// @@ -238,6 +245,15 @@ namespace Avalonia.Input remove { RemoveHandler(SwipeGestureEvent, value); } } + /// + /// Occurs when a swipe gesture ends on the control. + /// + public event EventHandler? SwipeGestureEnded + { + add { AddHandler(SwipeGestureEndedEvent, value); } + remove { RemoveHandler(SwipeGestureEndedEvent, value); } + } + /// /// Occurs when the user performs a rapid dragging motion in a single direction on a touchpad. /// diff --git a/src/Avalonia.Base/Input/SwipeDirection.cs b/src/Avalonia.Base/Input/SwipeDirection.cs new file mode 100644 index 0000000000..3043b443e6 --- /dev/null +++ b/src/Avalonia.Base/Input/SwipeDirection.cs @@ -0,0 +1,28 @@ +namespace Avalonia.Input +{ + /// + /// Specifies the direction of a swipe gesture. + /// + public enum SwipeDirection + { + /// + /// The swipe moved to the left. + /// + Left, + + /// + /// The swipe moved to the right. + /// + Right, + + /// + /// The swipe moved upward. + /// + Up, + + /// + /// The swipe moved downward. + /// + Down + } +} diff --git a/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs b/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs index 0c2a91556a..3fa9aede82 100644 --- a/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs +++ b/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs @@ -1,50 +1,81 @@ +using System; +using System.Threading; using Avalonia.Interactivity; namespace Avalonia.Input { /// - /// Specifies the direction of a swipe gesture. - /// - public enum SwipeDirection { Left, Right, Up, Down } - - /// - /// Provides data for the routed event. + /// Provides data for swipe gesture events. /// public class SwipeGestureEventArgs : RoutedEventArgs { - private static int _nextId = 1; - internal static int GetNextFreeId() => _nextId++; + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this gesture. + /// The pixel delta since the last event. + /// The current swipe velocity in pixels per second. + public SwipeGestureEventArgs(int id, Vector delta, Vector velocity) + : base(InputElement.SwipeGestureEvent) + { + Id = id; + Delta = delta; + Velocity = velocity; + SwipeDirection = Math.Abs(delta.X) >= Math.Abs(delta.Y) + ? (delta.X <= 0 ? SwipeDirection.Right : SwipeDirection.Left) + : (delta.Y <= 0 ? SwipeDirection.Down : SwipeDirection.Up); + } /// - /// Gets the unique identifier for this swipe gesture instance. + /// Gets the unique identifier for this gesture sequence. /// public int Id { get; } /// - /// Gets the direction of the swipe gesture. + /// Gets the pixel delta since the last event. /// - public SwipeDirection SwipeDirection { get; } + public Vector Delta { get; } /// - /// Gets the total translation vector of the swipe gesture. + /// Gets the current swipe velocity in pixels per second. /// - public Vector Delta { get; } + public Vector Velocity { get; } /// - /// Gets the position, relative to the target element, where the swipe started. + /// Gets the direction of the dominant swipe axis. /// - public Point StartPoint { get; } + public SwipeDirection SwipeDirection { get; } + + private static int s_nextId; + internal static int GetNextFreeId() => Interlocked.Increment(ref s_nextId); + } + + /// + /// Provides data for the swipe gesture ended event. + /// + public class SwipeGestureEndedEventArgs : RoutedEventArgs + { /// - /// Initializes a new instance of . + /// Initializes a new instance of the class. /// - public SwipeGestureEventArgs(int id, SwipeDirection direction, Vector delta, Point startPoint) - : base(InputElement.SwipeGestureEvent) + /// The unique identifier for this gesture. + /// The swipe velocity at release in pixels per second. + public SwipeGestureEndedEventArgs(int id, Vector velocity) + : base(InputElement.SwipeGestureEndedEvent) { Id = id; - SwipeDirection = direction; - Delta = delta; - StartPoint = startPoint; + Velocity = velocity; } + + /// + /// Gets the unique identifier for this gesture sequence. + /// + public int Id { get; } + + /// + /// Gets the swipe velocity at release in pixels per second. + /// + public Vector Velocity { get; } } } diff --git a/src/Avalonia.Controls/Carousel.cs b/src/Avalonia.Controls/Carousel.cs index 533f7bb626..bf22671462 100644 --- a/src/Avalonia.Controls/Carousel.cs +++ b/src/Avalonia.Controls/Carousel.cs @@ -1,11 +1,13 @@ using Avalonia.Animation; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Input; namespace Avalonia.Controls { /// - /// An items control that displays its items as pages that fill the control. + /// An items control that displays its items as pages and can reveal adjacent pages + /// using . /// public class Carousel : SelectingItemsControl { @@ -16,13 +18,36 @@ namespace Avalonia.Controls AvaloniaProperty.Register(nameof(PageTransition)); /// - /// The default value of for + /// Defines the property. + /// + public static readonly StyledProperty IsSwipeEnabledProperty = + AvaloniaProperty.Register(nameof(IsSwipeEnabled), defaultValue: false); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ViewportFractionProperty = + AvaloniaProperty.Register( + nameof(ViewportFraction), + defaultValue: 1d, + coerce: (_, value) => double.IsFinite(value) && value > 0 ? value : 1d); + + /// + /// Defines the property. + /// + public static readonly DirectProperty IsSwipingProperty = + AvaloniaProperty.RegisterDirect(nameof(IsSwiping), + o => o.IsSwiping); + + /// + /// The default value of for /// . /// private static readonly FuncTemplate DefaultPanel = new(() => new VirtualizingCarouselPanel()); private IScrollable? _scroller; + private bool _isSwiping; /// /// Initializes static members of the class. @@ -42,15 +67,51 @@ namespace Avalonia.Controls set => SetValue(PageTransitionProperty, value); } + /// + /// Gets or sets whether swipe gestures are enabled for navigating between pages. + /// When enabled, mouse pointer events are also accepted in addition to touch and pen. + /// + public bool IsSwipeEnabled + { + get => GetValue(IsSwipeEnabledProperty); + set => SetValue(IsSwipeEnabledProperty, value); + } + + /// + /// Gets or sets the fraction of the viewport occupied by each page. + /// A value of 1 shows a single full page; values below 1 reveal adjacent pages. + /// + public double ViewportFraction + { + get => GetValue(ViewportFractionProperty); + set => SetValue(ViewportFractionProperty, value); + } + + /// + /// Gets a value indicating whether a swipe gesture is currently in progress. + /// + public bool IsSwiping + { + get => _isSwiping; + internal set => SetAndRaise(IsSwipingProperty, ref _isSwiping, value); + } + /// /// Moves to the next item in the carousel. /// public void Next() { + if (ItemCount == 0) + return; + if (SelectedIndex < ItemCount - 1) { ++SelectedIndex; } + else if (WrapSelection) + { + SelectedIndex = 0; + } } /// @@ -58,18 +119,78 @@ namespace Avalonia.Controls /// public void Previous() { + if (ItemCount == 0) + return; + if (SelectedIndex > 0) { --SelectedIndex; } + else if (WrapSelection) + { + SelectedIndex = ItemCount - 1; + } + } + + internal PageSlide.SlideAxis? GetTransitionAxis() + { + var transition = PageTransition; + + if (transition is CompositePageTransition composite) + { + foreach (var t in composite.PageTransitions) + { + if (t is PageSlide slide) + return slide.Orientation; + } + + return null; + } + + return transition is PageSlide ps ? ps.Orientation : null; + } + + internal PageSlide.SlideAxis GetLayoutAxis() => GetTransitionAxis() ?? PageSlide.SlideAxis.Horizontal; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Handled || ItemCount == 0) + return; + + var axis = ViewportFraction != 1d ? GetLayoutAxis() : GetTransitionAxis(); + var isVertical = axis == PageSlide.SlideAxis.Vertical; + var isHorizontal = axis == PageSlide.SlideAxis.Horizontal; + + switch (e.Key) + { + case Key.Left when !isVertical: + case Key.Up when !isHorizontal: + Previous(); + e.Handled = true; + break; + case Key.Right when !isVertical: + case Key.Down when !isHorizontal: + Next(); + e.Handled = true; + break; + case Key.Home: + SelectedIndex = 0; + e.Handled = true; + break; + case Key.End: + SelectedIndex = ItemCount - 1; + e.Handled = true; + break; + } } protected override Size ArrangeOverride(Size finalSize) { var result = base.ArrangeOverride(finalSize); - if (_scroller is not null) - _scroller.Offset = new(SelectedIndex, 0); + SyncScrollOffset(); return result; } @@ -84,11 +205,54 @@ namespace Avalonia.Controls { base.OnPropertyChanged(change); - if (change.Property == SelectedIndexProperty && _scroller is not null) + if (change.Property == SelectedIndexProperty) + { + SyncScrollOffset(); + } + + if (change.Property == IsSwipeEnabledProperty || + change.Property == PageTransitionProperty || + change.Property == ViewportFractionProperty || + change.Property == WrapSelectionProperty) + { + if (ItemsPanelRoot is VirtualizingCarouselPanel panel) + { + if (change.Property == ViewportFractionProperty && !panel.IsManagingInteractionOffset) + panel.SyncSelectionOffset(SelectedIndex); + + panel.RefreshGestureRecognizer(); + panel.InvalidateMeasure(); + } + + SyncScrollOffset(); + } + } + + private void SyncScrollOffset() + { + if (ItemsPanelRoot is VirtualizingCarouselPanel panel) { - var value = change.GetNewValue(); - _scroller.Offset = new(value, 0); + if (panel.IsManagingInteractionOffset) + return; + + panel.SyncSelectionOffset(SelectedIndex); + + if (ViewportFraction != 1d) + return; } + + if (_scroller is null) + return; + + _scroller.Offset = CreateScrollOffset(SelectedIndex); + } + + private Vector CreateScrollOffset(int index) + { + if (ViewportFraction != 1d && GetLayoutAxis() == PageSlide.SlideAxis.Vertical) + return new(0, index); + + return new(index, 0); } } } diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs index 814e533939..1392e98fbb 100644 --- a/src/Avalonia.Controls/Page/DrawerPage.cs +++ b/src/Avalonia.Controls/Page/DrawerPage.cs @@ -211,6 +211,7 @@ namespace Avalonia.Controls private Border? _topBar; private ToggleButton? _paneButton; private Border? _backdrop; + private Point _swipeStartPoint; private IDisposable? _navBarVisibleSub; private const double EdgeGestureWidth = 20; @@ -292,6 +293,8 @@ namespace Avalonia.Controls public DrawerPage() { GestureRecognizers.Add(_swipeRecognizer); + AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true); + UpdateSwipeRecognizerAxes(); } /// @@ -617,6 +620,7 @@ namespace Avalonia.Controls } else if (change.Property == DrawerPlacementProperty) { + UpdateSwipeRecognizerAxes(); UpdatePanePlacement(); UpdateContentSafeAreaPadding(); } @@ -664,6 +668,12 @@ namespace Avalonia.Controls nav.SetDrawerPage(null); } + private void UpdateSwipeRecognizerAxes() + { + _swipeRecognizer.CanVerticallySwipe = IsVerticalPlacement; + _swipeRecognizer.CanHorizontallySwipe = !IsVerticalPlacement; + } + protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); @@ -675,6 +685,11 @@ namespace Avalonia.Controls } } + private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e) + { + _swipeStartPoint = e.GetPosition(this); + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); @@ -714,8 +729,8 @@ namespace Avalonia.Controls : EdgeGestureWidth; bool inEdge = DrawerPlacement == DrawerPlacement.Bottom - ? e.StartPoint.Y >= Bounds.Height - openGestureEdge - : e.StartPoint.Y <= openGestureEdge; + ? _swipeStartPoint.Y >= Bounds.Height - openGestureEdge + : _swipeStartPoint.Y <= openGestureEdge; if (towardPane && inEdge) { @@ -746,8 +761,8 @@ namespace Avalonia.Controls : EdgeGestureWidth; bool inEdge = IsPaneOnRight - ? e.StartPoint.X >= Bounds.Width - openGestureEdge - : e.StartPoint.X <= openGestureEdge; + ? _swipeStartPoint.X >= Bounds.Width - openGestureEdge + : _swipeStartPoint.X <= openGestureEdge; if (towardPane && inEdge) { diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index dd14d71a04..7f496ab10b 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -68,6 +68,8 @@ namespace Avalonia.Controls private bool _isBackButtonEffectivelyEnabled; private DrawerPage? _drawerPage; private IPageTransition? _overrideTransition; + private Point _swipeStartPoint; + private int _lastSwipeGestureId; private bool _hasOverrideTransition; private readonly HashSet _pageSet = new(ReferenceEqualityComparer.Instance); @@ -257,7 +259,12 @@ namespace Avalonia.Controls public NavigationPage() { SetCurrentValue(PagesProperty, new Stack()); - GestureRecognizers.Add(new SwipeGestureRecognizer { EdgeSize = EdgeGestureWidth }); + GestureRecognizers.Add(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + CanVerticallySwipe = false + }); + AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true); } /// @@ -1871,18 +1878,31 @@ namespace Avalonia.Controls private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) { - if (!IsGestureEnabled || StackDepth <= 1 || _isNavigating || _modalStack.Count > 0) + if (!IsGestureEnabled || StackDepth <= 1 || _isNavigating || _modalStack.Count > 0 || e.Id == _lastSwipeGestureId) + return; + + bool inEdge = IsRtl + ? _swipeStartPoint.X >= Bounds.Width - EdgeGestureWidth + : _swipeStartPoint.X <= EdgeGestureWidth; + if (!inEdge) return; + bool shouldPop = IsRtl ? e.SwipeDirection == SwipeDirection.Left : e.SwipeDirection == SwipeDirection.Right; if (shouldPop) { e.Handled = true; + _lastSwipeGestureId = e.Id; _ = PopAsync(); } } + private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e) + { + _swipeStartPoint = e.GetPosition(this); + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs index 6a5422b365..8fccb45223 100644 --- a/src/Avalonia.Controls/Page/TabbedPage.cs +++ b/src/Avalonia.Controls/Page/TabbedPage.cs @@ -26,6 +26,7 @@ namespace Avalonia.Controls private TabControl? _tabControl; private readonly Dictionary _containerPageMap = new(); private readonly Dictionary _pageContainerMap = new(); + private int _lastSwipeGestureId; private readonly SwipeGestureRecognizer _swipeRecognizer = new SwipeGestureRecognizer { IsEnabled = false @@ -92,6 +93,7 @@ namespace Avalonia.Controls Focusable = true; GestureRecognizers.Add(_swipeRecognizer); AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); + UpdateSwipeRecognizerAxes(); } /// @@ -194,7 +196,10 @@ namespace Avalonia.Controls base.OnPropertyChanged(change); if (change.Property == TabPlacementProperty) + { ApplyTabPlacement(); + UpdateSwipeRecognizerAxes(); + } else if (change.Property == PageTransitionProperty && _tabControl != null) _tabControl.PageTransition = change.GetNewValue(); else if (change.Property == IndicatorTemplateProperty) @@ -227,6 +232,14 @@ namespace Avalonia.Controls }; } + private void UpdateSwipeRecognizerAxes() + { + var placement = ResolveTabPlacement(); + var isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom; + _swipeRecognizer.CanHorizontallySwipe = isHorizontal; + _swipeRecognizer.CanVerticallySwipe = !isHorizontal; + } + private void ApplyIndicatorTemplate() { if (_tabControl == null) @@ -500,7 +513,8 @@ namespace Avalonia.Controls private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) { - if (!IsGestureEnabled || _tabControl == null) return; + if (!IsGestureEnabled || _tabControl == null || e.Id == _lastSwipeGestureId) + return; var placement = ResolveTabPlacement(); bool isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom; @@ -524,6 +538,7 @@ namespace Avalonia.Controls { _tabControl.SelectedIndex = next; e.Handled = true; + _lastSwipeGestureId = e.Id; } } diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs index 454069b4b2..66e717d265 100644 --- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs +++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs @@ -2,11 +2,17 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -15,23 +21,76 @@ namespace Avalonia.Controls /// public class VirtualizingCarouselPanel : VirtualizingPanel, ILogicalScrollable { + private sealed class ViewportRealizedItem + { + public ViewportRealizedItem(int itemIndex, Control control) + { + ItemIndex = itemIndex; + Control = control; + } + + public int ItemIndex { get; } + public Control Control { get; } + } + private static readonly AttachedProperty RecycleKeyProperty = - AvaloniaProperty.RegisterAttached("RecycleKey"); + AvaloniaProperty.RegisterAttached("RecycleKey"); private static readonly object s_itemIsItsOwnContainer = new object(); private Size _extent; private Vector _offset; private Size _viewport; private Dictionary>? _recyclePool; + private readonly Dictionary _viewportRealized = new(); private Control? _realized; private int _realizedIndex = -1; private Control? _transitionFrom; private int _transitionFromIndex = -1; private CancellationTokenSource? _transition; + private Task? _transitionTask; private EventHandler? _scrollInvalidated; private bool _canHorizontallyScroll; private bool _canVerticallyScroll; + private SwipeGestureRecognizer? _swipeGestureRecognizer; + private int _swipeGestureId; + private bool _isDragging; + private double _totalDelta; + private bool _isForward; + private Control? _swipeTarget; + private int _swipeTargetIndex = -1; + private PageSlide.SlideAxis? _swipeAxis; + private PageSlide.SlideAxis _lockedAxis; + + private const double SwipeCommitThreshold = 0.25; + private const double VelocityCommitThreshold = 800; + private const double MinSwipeDistanceForVelocityCommit = 0.05; + private const double RubberBandFactor = 0.3; + private const double RubberBandReturnDuration = 0.16; + private const double MaxCompletionDuration = 0.35; + private const double MinCompletionDuration = 0.12; + + private static readonly StyledProperty CompletionProgressProperty = + AvaloniaProperty.Register("CompletionProgress"); + private static readonly StyledProperty OffsetAnimationProgressProperty = + AvaloniaProperty.Register("OffsetAnimationProgress"); + + private CancellationTokenSource? _completionCts; + private CancellationTokenSource? _offsetAnimationCts; + private double _completionEndProgress; + private bool _isRubberBanding; + private double _dragStartOffset; + private double _progressStartOffset; + private double _offsetAnimationStart; + private double _offsetAnimationTarget; + private double _activeViewportTargetOffset; + private int _progressFromIndex = -1; + private int _progressToIndex = -1; + + internal bool IsManagingInteractionOffset => + UsesViewportFractionLayout() && + (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false }); + bool ILogicalScrollable.CanHorizontallyScroll { get => _canHorizontallyScroll; @@ -55,12 +114,7 @@ namespace Avalonia.Controls Vector IScrollable.Offset { get => _offset; - set - { - if ((int)_offset.X != value.X) - InvalidateMeasure(); - _offset = value; - } + set => SetOffset(value); } private Size Extent @@ -99,37 +153,335 @@ namespace Avalonia.Controls Control? ILogicalScrollable.GetControlInDirection(NavigationDirection direction, Control? from) => null; void ILogicalScrollable.RaiseScrollInvalidated(EventArgs e) => _scrollInvalidated?.Invoke(this, e); + private bool UsesViewportFractionLayout() + { + return ItemsControl is Carousel carousel && + !MathUtilities.AreClose(carousel.ViewportFraction, 1d); + } + + private PageSlide.SlideAxis GetLayoutAxis() + { + return (ItemsControl as Carousel)?.GetLayoutAxis() ?? PageSlide.SlideAxis.Horizontal; + } + + private double GetViewportFraction() + { + return (ItemsControl as Carousel)?.ViewportFraction ?? 1d; + } + + private double GetViewportUnits() + { + return 1d / GetViewportFraction(); + } + + private double GetPrimaryOffset(Vector offset) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? offset.Y : offset.X; + } + + private double GetPrimarySize(Size size) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Height : size.Width; + } + + private double GetCrossSize(Size size) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Width : size.Height; + } + + private Size CreateLogicalSize(double primary) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Size(1, primary) : + new Size(primary, 1); + } + + private Size CreateItemSize(double primary, double cross) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Size(cross, primary) : + new Size(primary, cross); + } + + private Rect CreateItemRect(double primaryOffset, double primarySize, double crossSize) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Rect(0, primaryOffset, crossSize, primarySize) : + new Rect(primaryOffset, 0, primarySize, crossSize); + } + + private Vector WithPrimaryOffset(Vector offset, double primary) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Vector(offset.X, primary) : + new Vector(primary, offset.Y); + } + + private Size ResolveLayoutSize(Size availableSize) + { + var owner = ItemsControl as Control; + + double ResolveDimension(double available, double bounds, double ownerBounds, double ownerExplicit) + { + if (!double.IsInfinity(available) && available > 0) + return available; + + if (bounds > 0) + return bounds; + + if (ownerBounds > 0) + return ownerBounds; + + return double.IsNaN(ownerExplicit) ? 0 : ownerExplicit; + } + + var width = ResolveDimension(availableSize.Width, Bounds.Width, owner?.Bounds.Width ?? 0, owner?.Width ?? double.NaN); + var height = ResolveDimension(availableSize.Height, Bounds.Height, owner?.Bounds.Height ?? 0, owner?.Height ?? double.NaN); + return new Size(width, height); + } + + private double GetViewportItemExtent(Size size) + { + var viewportUnits = GetViewportUnits(); + return viewportUnits <= 0 ? 0 : GetPrimarySize(size) / viewportUnits; + } + + private bool UsesViewportWrapLayout() + { + return UsesViewportFractionLayout() && + ItemsControl is Carousel { WrapSelection: true } && + Items.Count > 1; + } + + private static int NormalizeIndex(int index, int count) + { + return ((index % count) + count) % count; + } + + private double GetNearestLogicalOffset(int itemIndex, double referenceOffset) + { + if (!UsesViewportWrapLayout() || Items.Count == 0) + return Math.Clamp(itemIndex, 0, Math.Max(0, Items.Count - 1)); + + var wrapSpan = Items.Count; + var wrapMultiplier = Math.Round((referenceOffset - itemIndex) / wrapSpan); + return itemIndex + (wrapMultiplier * wrapSpan); + } + + private bool IsPreferredViewportSlot(int candidateLogicalIndex, int existingLogicalIndex, double primaryOffset) + { + var candidateDistance = Math.Abs(candidateLogicalIndex - primaryOffset); + var existingDistance = Math.Abs(existingLogicalIndex - primaryOffset); + + if (!MathUtilities.AreClose(candidateDistance, existingDistance)) + return candidateDistance < existingDistance; + + var candidateInRange = candidateLogicalIndex >= 0 && candidateLogicalIndex < Items.Count; + var existingInRange = existingLogicalIndex >= 0 && existingLogicalIndex < Items.Count; + + if (candidateInRange != existingInRange) + return candidateInRange; + + if (_isDragging) + return _isForward ? candidateLogicalIndex > existingLogicalIndex : candidateLogicalIndex < existingLogicalIndex; + + return candidateLogicalIndex < existingLogicalIndex; + } + + private IReadOnlyList<(int LogicalIndex, int ItemIndex)> GetRequiredViewportSlots(double primaryOffset) + { + if (Items.Count == 0) + return Array.Empty<(int LogicalIndex, int ItemIndex)>(); + + var viewportUnits = GetViewportUnits(); + var edgeInset = (viewportUnits - 1) / 2; + var start = (int)Math.Floor(primaryOffset - edgeInset); + var end = (int)Math.Ceiling(primaryOffset + viewportUnits - edgeInset) - 1; + + if (!UsesViewportWrapLayout()) + { + start = Math.Max(0, start); + end = Math.Min(Items.Count - 1, end); + + if (start > end) + return Array.Empty<(int LogicalIndex, int ItemIndex)>(); + + var result = new (int LogicalIndex, int ItemIndex)[end - start + 1]; + + for (var i = 0; i < result.Length; ++i) + { + var index = start + i; + result[i] = (index, index); + } + + return result; + } + + var bestSlots = new Dictionary(); + + for (var logicalIndex = start; logicalIndex <= end; ++logicalIndex) + { + var itemIndex = NormalizeIndex(logicalIndex, Items.Count); + + if (!bestSlots.TryGetValue(itemIndex, out var existingLogicalIndex) || + IsPreferredViewportSlot(logicalIndex, existingLogicalIndex, primaryOffset)) + { + bestSlots[itemIndex] = logicalIndex; + } + } + + return bestSlots + .Select(x => (LogicalIndex: x.Value, ItemIndex: x.Key)) + .OrderBy(x => x.LogicalIndex) + .ToArray(); + } + + private bool ViewportSlotsChanged(double oldPrimaryOffset, double newPrimaryOffset) + { + var oldSlots = GetRequiredViewportSlots(oldPrimaryOffset); + var newSlots = GetRequiredViewportSlots(newPrimaryOffset); + + if (oldSlots.Count != newSlots.Count) + return true; + + for (var i = 0; i < oldSlots.Count; ++i) + { + if (oldSlots[i].LogicalIndex != newSlots[i].LogicalIndex || + oldSlots[i].ItemIndex != newSlots[i].ItemIndex) + { + return true; + } + } + + return false; + } + + private void SetOffset(Vector value) + { + if (UsesViewportFractionLayout()) + { + var oldPrimaryOffset = GetPrimaryOffset(_offset); + var newPrimaryOffset = GetPrimaryOffset(value); + + if (MathUtilities.AreClose(oldPrimaryOffset, newPrimaryOffset)) + { + _offset = value; + return; + } + + _offset = value; + + var rangeChanged = ViewportSlotsChanged(oldPrimaryOffset, newPrimaryOffset); + + if (rangeChanged) + InvalidateMeasure(); + else + InvalidateArrange(); + + _scrollInvalidated?.Invoke(this, EventArgs.Empty); + return; + } + + if ((int)_offset.X != value.X) + InvalidateMeasure(); + + _offset = value; + } + + private void ClearViewportRealized() + { + if (_viewportRealized.Count == 0) + return; + + foreach (var element in _viewportRealized.Values.Select(x => x.Control).ToArray()) + RecycleElement(element); + + _viewportRealized.Clear(); + } + + private void ResetSinglePageState() + { + _transition?.Cancel(); + _transition = null; + _transitionTask = null; + + if (_transitionFrom is not null) + RecycleElement(_transitionFrom); + + if (_swipeTarget is not null) + RecycleElement(_swipeTarget); + + if (_realized is not null) + RecycleElement(_realized); + + _transitionFrom = null; + _transitionFromIndex = -1; + _swipeTarget = null; + _swipeTargetIndex = -1; + _realized = null; + _realizedIndex = -1; + } + + private void CancelOffsetAnimation() + { + _offsetAnimationCts?.Cancel(); + _offsetAnimationCts = null; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + RefreshGestureRecognizer(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + TeardownGestureRecognizer(); + } + protected override Size MeasureOverride(Size availableSize) + { + if (UsesViewportFractionLayout()) + return MeasureViewportFractionOverride(availableSize); + + ClearViewportRealized(); + CancelOffsetAnimation(); + + return MeasureSinglePageOverride(availableSize); + } + + private Size MeasureSinglePageOverride(Size availableSize) { var items = Items; var index = (int)_offset.X; + CompleteFinishedTransitionIfNeeded(); + if (index != _realizedIndex) { if (_realized is not null) { - var cancelTransition = _transition is not null; - // Cancel any already running transition, and recycle the element we're transitioning from. - if (cancelTransition) + if (_transition is not null) { - _transition!.Cancel(); + _transition.Cancel(); _transition = null; + _transitionTask = null; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transitionFrom = null; _transitionFromIndex = -1; + ResetTransitionState(_realized); } - if (cancelTransition || GetTransition() is null) + if (GetTransition() is null) { - // If don't have a transition or we've just canceled a transition then recycle the element - // we're moving from. RecycleElement(_realized); } else { - // We have a transition to do: record the current element as the element we're transitioning + // Record the current element as the element we're transitioning // from and we'll start the transition in the arrange pass. _transitionFrom = _realized; _transitionFromIndex = _realizedIndex; @@ -163,6 +515,14 @@ namespace Avalonia.Controls } protected override Size ArrangeOverride(Size finalSize) + { + if (UsesViewportFractionLayout()) + return ArrangeViewportFractionOverride(finalSize); + + return ArrangeSinglePageOverride(finalSize); + } + + private Size ArrangeSinglePageOverride(Size finalSize) { var result = base.ArrangeOverride(finalSize); @@ -180,19 +540,115 @@ namespace Avalonia.Controls forward = forward && !(_transitionFromIndex == 0 && _realizedIndex == Items.Count - 1); } - transition.Start(_transitionFrom, to, forward, _transition.Token) - .ContinueWith(TransitionFinished, TaskScheduler.FromCurrentSynchronizationContext()); + _transitionTask = RunTransitionAsync(_transition, _transitionFrom, to, forward, transition); } return result; } + private Size MeasureViewportFractionOverride(Size availableSize) + { + ResetSinglePageState(); + + if (Items.Count == 0) + { + ClearViewportRealized(); + Extent = Viewport = new(0, 0); + return default; + } + + var layoutSize = ResolveLayoutSize(availableSize); + var primarySize = GetPrimarySize(layoutSize); + var crossSize = GetCrossSize(layoutSize); + var viewportUnits = GetViewportUnits(); + + if (primarySize <= 0 || viewportUnits <= 0) + { + ClearViewportRealized(); + Extent = Viewport = new(0, 0); + return default; + } + + var itemPrimarySize = primarySize / viewportUnits; + var itemSize = CreateItemSize(itemPrimarySize, crossSize); + var requiredSlots = GetRequiredViewportSlots(GetPrimaryOffset(_offset)); + var requiredMap = requiredSlots.ToDictionary(x => x.LogicalIndex, x => x.ItemIndex); + + foreach (var entry in _viewportRealized.ToArray()) + { + if (!requiredMap.TryGetValue(entry.Key, out var itemIndex) || + entry.Value.ItemIndex != itemIndex) + { + RecycleElement(entry.Value.Control); + _viewportRealized.Remove(entry.Key); + } + } + + foreach (var slot in requiredSlots) + { + if (!_viewportRealized.ContainsKey(slot.LogicalIndex)) + { + _viewportRealized[slot.LogicalIndex] = new ViewportRealizedItem( + slot.ItemIndex, + GetOrCreateElement(Items, slot.ItemIndex)); + } + } + + var maxCrossDesiredSize = 0d; + + foreach (var element in _viewportRealized.Values.Select(x => x.Control)) + { + element.Measure(itemSize); + maxCrossDesiredSize = Math.Max(maxCrossDesiredSize, GetCrossSize(element.DesiredSize)); + } + + Viewport = CreateLogicalSize(viewportUnits); + Extent = CreateLogicalSize(Math.Max(0, Items.Count + viewportUnits - 1)); + + var desiredPrimary = double.IsInfinity(primarySize) ? itemPrimarySize * viewportUnits : primarySize; + var desiredCross = double.IsInfinity(crossSize) ? maxCrossDesiredSize : crossSize; + return CreateItemSize(desiredPrimary, desiredCross); + } + + private Size ArrangeViewportFractionOverride(Size finalSize) + { + var primarySize = GetPrimarySize(finalSize); + var crossSize = GetCrossSize(finalSize); + var viewportUnits = GetViewportUnits(); + + if (primarySize <= 0 || viewportUnits <= 0) + return finalSize; + + if (_viewportRealized.Count == 0 && Items.Count > 0) + { + InvalidateMeasure(); + return finalSize; + } + + var itemPrimarySize = primarySize / viewportUnits; + var edgeInset = (viewportUnits - 1) / 2; + var primaryOffset = GetPrimaryOffset(_offset); + + foreach (var entry in _viewportRealized.OrderBy(x => x.Key)) + { + var itemOffset = (edgeInset + entry.Key - primaryOffset) * itemPrimarySize; + var rect = CreateItemRect(itemOffset, itemPrimarySize, crossSize); + entry.Value.Control.IsVisible = true; + entry.Value.Control.Arrange(rect); + } + + return finalSize; + } + protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) => null; protected internal override Control? ContainerFromIndex(int index) { if (index < 0 || index >= Items.Count) return null; + var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index); + if (viewportRealized is not null) + return viewportRealized.Control; if (index == _realizedIndex) return _realized; if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer) @@ -202,11 +658,20 @@ namespace Avalonia.Controls protected internal override IEnumerable? GetRealizedContainers() { + if (_viewportRealized.Count > 0) + return _viewportRealized.OrderBy(x => x.Key).Select(x => x.Value.Control); + return _realized is not null ? new[] { _realized } : null; } protected internal override int IndexFromContainer(Control container) { + foreach (var entry in _viewportRealized) + { + if (ReferenceEquals(entry.Value.Control, container)) + return entry.Value.ItemIndex; + } + return container == _realized ? _realizedIndex : -1; } @@ -219,8 +684,21 @@ namespace Avalonia.Controls { base.OnItemsChanged(items, e); + if (UsesViewportFractionLayout() || _viewportRealized.Count > 0) + { + ClearViewportRealized(); + InvalidateMeasure(); + return; + } + void Add(int index, int count) { + if (_realized is null) + { + InvalidateMeasure(); + return; + } + if (index <= _realizedIndex) _realizedIndex += count; } @@ -314,6 +792,10 @@ namespace Avalonia.Controls private Control? GetRealizedElement(int index) { + var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index); + if (viewportRealized is not null) + return viewportRealized.Control; + return _realizedIndex == index ? _realized : null; } @@ -379,9 +861,13 @@ namespace Avalonia.Controls var recycleKey = element.GetValue(RecycleKeyProperty); Debug.Assert(recycleKey is not null); + // Hide first so cleanup doesn't visibly snap transforms/opacity for a frame. + element.IsVisible = false; + ResetTransitionState(element); + if (recycleKey == s_itemIsItsOwnContainer) { - element.IsVisible = false; + return; } else { @@ -395,22 +881,764 @@ namespace Avalonia.Controls } pool.Push(element); - element.IsVisible = false; } } private IPageTransition? GetTransition() => (ItemsControl as Carousel)?.PageTransition; - private void TransitionFinished(Task task) + private void CompleteFinishedTransitionIfNeeded() + { + if (_transition is not null && _transitionTask?.IsCompleted == true) + { + if (_transitionFrom is not null) + RecycleElement(_transitionFrom); + + _transition = null; + _transitionTask = null; + _transitionFrom = null; + _transitionFromIndex = -1; + } + } + + private async Task RunTransitionAsync( + CancellationTokenSource transitionCts, + Control transitionFrom, + Control transitionTo, + bool forward, + IPageTransition transition) { - if (task.IsCanceled) + try + { + await transition.Start(transitionFrom, transitionTo, forward, transitionCts.Token); + } + catch (OperationCanceledException) + { + // Expected when a transition is interrupted by a newer navigation action. + } + catch (Exception e) + { + _ = e; + } + + if (transitionCts.IsCancellationRequested || !ReferenceEquals(_transition, transitionCts)) return; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transition = null; + _transitionTask = null; _transitionFrom = null; _transitionFromIndex = -1; } + + internal void SyncSelectionOffset(int selectedIndex) + { + if (!UsesViewportFractionLayout()) + { + SetOffset(WithPrimaryOffset(_offset, selectedIndex)); + return; + } + + var currentOffset = GetPrimaryOffset(_offset); + var targetOffset = GetNearestLogicalOffset(selectedIndex, currentOffset); + + if (MathUtilities.AreClose(currentOffset, targetOffset)) + { + SetOffset(WithPrimaryOffset(_offset, targetOffset)); + return; + } + + if (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false }) + return; + + var transition = GetTransition(); + var canAnimate = transition is not null && Math.Abs(targetOffset - currentOffset) <= 1.001; + + if (!canAnimate) + { + ResetViewportTransitionState(); + ClearFractionalProgressContext(); + SetOffset(WithPrimaryOffset(_offset, targetOffset)); + return; + } + + var fromIndex = Items.Count > 0 ? NormalizeIndex((int)Math.Round(currentOffset), Items.Count) : -1; + var forward = targetOffset > currentOffset; + + ResetViewportTransitionState(); + SetFractionalProgressContext(fromIndex, selectedIndex, forward, currentOffset, targetOffset); + _ = AnimateViewportOffsetAsync( + currentOffset, + targetOffset, + TimeSpan.FromSeconds(MaxCompletionDuration), + new QuadraticEaseOut(), + () => + { + ResetViewportTransitionState(); + ClearFractionalProgressContext(); + }); + } + + /// + /// Refreshes the gesture recognizer based on the carousel's IsSwipeEnabled and PageTransition settings. + /// + internal void RefreshGestureRecognizer() + { + TeardownGestureRecognizer(); + + if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled) + return; + + _swipeAxis = UsesViewportFractionLayout() ? carousel.GetLayoutAxis() : carousel.GetTransitionAxis(); + + _swipeGestureRecognizer = new SwipeGestureRecognizer + { + CanHorizontallySwipe = _swipeAxis != PageSlide.SlideAxis.Vertical, + CanVerticallySwipe = _swipeAxis != PageSlide.SlideAxis.Horizontal, + IsMouseEnabled = true, + }; + + GestureRecognizers.Add(_swipeGestureRecognizer); + AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); + AddHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded); + } + + private void TeardownGestureRecognizer() + { + _completionCts?.Cancel(); + _completionCts = null; + CancelOffsetAnimation(); + + if (_swipeGestureRecognizer is not null) + { + GestureRecognizers.Remove(_swipeGestureRecognizer); + _swipeGestureRecognizer = null; + } + + RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); + RemoveHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded); + ResetSwipeState(); + } + + private Control? FindViewportControl(int itemIndex) + { + return _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == itemIndex)?.Control; + } + + private void SetFractionalProgressContext(int fromIndex, int toIndex, bool forward, double startOffset, double targetOffset) + { + _progressFromIndex = fromIndex; + _progressToIndex = toIndex; + _isForward = forward; + _progressStartOffset = startOffset; + _activeViewportTargetOffset = targetOffset; + } + + private void ClearFractionalProgressContext() + { + _progressFromIndex = -1; + _progressToIndex = -1; + _progressStartOffset = 0; + _activeViewportTargetOffset = 0; + } + + private double GetFractionalTransitionProgress(double currentOffset) + { + var totalDistance = Math.Abs(_activeViewportTargetOffset - _progressStartOffset); + if (totalDistance <= 0) + return 0; + + return Math.Clamp(Math.Abs(currentOffset - _progressStartOffset) / totalDistance, 0, 1); + } + + private void ResetViewportTransitionState() + { + foreach (var element in _viewportRealized.Values.Select(x => x.Control)) + ResetTransitionState(element); + } + + private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) + { + if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled) + return; + + if (UsesViewportFractionLayout()) + { + OnViewportFractionSwipeGesture(carousel, e); + return; + } + + if (_realizedIndex < 0 || Items.Count == 0) + return; + + if (_completionCts is { IsCancellationRequested: false }) + { + _completionCts.Cancel(); + _completionCts = null; + + var wasCommit = _completionEndProgress > 0.5; + if (wasCommit && _swipeTarget is not null) + { + if (_realized != null) + RecycleElement(_realized); + + _realized = _swipeTarget; + _realizedIndex = _swipeTargetIndex; + carousel.SelectedIndex = _swipeTargetIndex; + } + else + { + ResetSwipeState(); + } + + _swipeTarget = null; + _swipeTargetIndex = -1; + _totalDelta = 0; + } + + if (_isDragging && e.Id != _swipeGestureId) + return; + + if (!_isDragging) + { + // Lock the axis on gesture start to keep diagonal drags stable. + _lockedAxis = _swipeAxis ?? (Math.Abs(e.Delta.X) >= Math.Abs(e.Delta.Y) ? + PageSlide.SlideAxis.Horizontal : + PageSlide.SlideAxis.Vertical); + } + + var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y; + + if (!_isDragging) + { + _isForward = delta > 0; + _isRubberBanding = false; + var currentIndex = _realizedIndex; + var targetIndex = _isForward ? currentIndex + 1 : currentIndex - 1; + + if (targetIndex >= Items.Count) + { + if (carousel.WrapSelection) + targetIndex = 0; + else + _isRubberBanding = true; + } + else if (targetIndex < 0) + { + if (carousel.WrapSelection) + targetIndex = Items.Count - 1; + else + _isRubberBanding = true; + } + + if (!_isRubberBanding && (targetIndex == currentIndex || targetIndex < 0 || targetIndex >= Items.Count)) + return; + + _isDragging = true; + _swipeGestureId = e.Id; + _totalDelta = 0; + _swipeTargetIndex = _isRubberBanding ? -1 : targetIndex; + carousel.IsSwiping = true; + + if (_transition is not null) + { + _transition.Cancel(); + _transition = null; + if (_transitionFrom is not null) + RecycleElement(_transitionFrom); + _transitionFrom = null; + _transitionFromIndex = -1; + } + + if (!_isRubberBanding) + { + _swipeTarget = GetOrCreateElement(Items, _swipeTargetIndex); + _swipeTarget.Measure(Bounds.Size); + _swipeTarget.Arrange(new Rect(Bounds.Size)); + _swipeTarget.IsVisible = true; + } + } + + _totalDelta += delta; + + // Clamp so totalDelta cannot cross zero (absorbs touch jitter). + if (_isForward) + _totalDelta = Math.Max(0, _totalDelta); + else + _totalDelta = Math.Min(0, _totalDelta); + + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + if (size <= 0) + return; + + var rawProgress = Math.Clamp(Math.Abs(_totalDelta) / size, 0, 1); + var progress = _isRubberBanding + ? RubberBandFactor * Math.Sqrt(rawProgress) + : rawProgress; + + if (GetTransition() is IProgressPageTransition progressive) + { + progressive.Update( + progress, + _realized, + _isRubberBanding ? null : _swipeTarget, + _isForward, + size, + Array.Empty()); + } + + e.Handled = true; + } + + private void OnViewportFractionSwipeGesture(Carousel carousel, SwipeGestureEventArgs e) + { + if (_offsetAnimationCts is { IsCancellationRequested: false }) + { + CancelOffsetAnimation(); + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset)))); + } + + if (_isDragging && e.Id != _swipeGestureId) + return; + + var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y; + + if (!_isDragging) + { + _lockedAxis = carousel.GetLayoutAxis(); + _swipeGestureId = e.Id; + _dragStartOffset = GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset)); + _totalDelta = 0; + _isDragging = true; + _isRubberBanding = false; + carousel.IsSwiping = true; + _isForward = delta > 0; + var targetIndex = _isForward ? carousel.SelectedIndex + 1 : carousel.SelectedIndex - 1; + + if (targetIndex >= Items.Count || targetIndex < 0) + { + if (carousel.WrapSelection && Items.Count > 1) + targetIndex = NormalizeIndex(targetIndex, Items.Count); + else + _isRubberBanding = true; + } + + var targetOffset = _isForward ? _dragStartOffset + 1 : _dragStartOffset - 1; + SetFractionalProgressContext( + carousel.SelectedIndex, + _isRubberBanding ? -1 : targetIndex, + _isForward, + _dragStartOffset, + targetOffset); + ResetViewportTransitionState(); + } + + _totalDelta += delta; + + if (_isForward) + _totalDelta = Math.Max(0, _totalDelta); + else + _totalDelta = Math.Min(0, _totalDelta); + + var itemExtent = GetViewportItemExtent(Bounds.Size); + if (itemExtent <= 0) + return; + + var logicalDelta = Math.Clamp(Math.Abs(_totalDelta) / itemExtent, 0, 1); + var proposedOffset = _dragStartOffset + (_isForward ? logicalDelta : -logicalDelta); + + if (!_isRubberBanding) + { + proposedOffset = Math.Clamp( + proposedOffset, + Math.Min(_dragStartOffset, _activeViewportTargetOffset), + Math.Max(_dragStartOffset, _activeViewportTargetOffset)); + } + else if (proposedOffset < 0) + { + proposedOffset = -(RubberBandFactor * Math.Sqrt(-proposedOffset)); + } + else + { + var maxOffset = Math.Max(0, Items.Count - 1); + proposedOffset = maxOffset + (RubberBandFactor * Math.Sqrt(proposedOffset - maxOffset)); + } + + SetOffset(WithPrimaryOffset(_offset, proposedOffset)); + + if (GetTransition() is IProgressPageTransition progressive) + { + var currentOffset = GetPrimaryOffset(_offset); + var progress = Math.Clamp(Math.Abs(currentOffset - _dragStartOffset), 0, 1); + progressive.Update( + progress, + FindViewportControl(_progressFromIndex), + FindViewportControl(_progressToIndex), + _isForward, + GetViewportItemExtent(Bounds.Size), + BuildFractionalVisibleItems(currentOffset)); + } + + e.Handled = true; + } + + private void OnViewportFractionSwipeGestureEnded(Carousel carousel, SwipeGestureEndedEventArgs e) + { + var itemExtent = GetViewportItemExtent(Bounds.Size); + var currentOffset = GetPrimaryOffset(_offset); + var currentProgress = Math.Abs(currentOffset - _dragStartOffset); + var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Math.Abs(e.Velocity.X) : Math.Abs(e.Velocity.Y); + var targetIndex = _progressToIndex; + var canCommit = !_isRubberBanding && targetIndex >= 0; + var commit = canCommit && + (currentProgress >= SwipeCommitThreshold || + (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit)); + var endOffset = commit + ? _activeViewportTargetOffset + : GetNearestLogicalOffset(carousel.SelectedIndex, currentOffset); + var remainingDistance = Math.Abs(endOffset - currentOffset); + var durationSeconds = _isRubberBanding + ? RubberBandReturnDuration + : velocity > 0 && itemExtent > 0 + ? Math.Clamp(remainingDistance * itemExtent / velocity, MinCompletionDuration, MaxCompletionDuration) + : MaxCompletionDuration; + var easing = _isRubberBanding ? (Easing)new SineEaseOut() : new QuadraticEaseOut(); + + _isDragging = false; + _ = AnimateViewportOffsetAsync( + currentOffset, + endOffset, + TimeSpan.FromSeconds(durationSeconds), + easing, + () => + { + _totalDelta = 0; + _isRubberBanding = false; + carousel.IsSwiping = false; + + if (commit) + { + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(targetIndex, endOffset))); + carousel.SelectedIndex = targetIndex; + } + else + { + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, endOffset))); + } + + ResetViewportTransitionState(); + ClearFractionalProgressContext(); + }); + } + + private async Task AnimateViewportOffsetAsync( + double fromOffset, + double toOffset, + TimeSpan duration, + Easing easing, + Action onCompleted) + { + CancelOffsetAnimation(); + var offsetAnimationCts = new CancellationTokenSource(); + _offsetAnimationCts = offsetAnimationCts; + var cancellationToken = offsetAnimationCts.Token; + + var animation = new Animation.Animation + { + FillMode = FillMode.Forward, + Duration = duration, + Easing = easing, + Children = + { + new KeyFrame + { + Setters = { new Setter(OffsetAnimationProgressProperty, 0d) }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = { new Setter(OffsetAnimationProgressProperty, 1d) }, + Cue = new Cue(1d) + } + } + }; + + _offsetAnimationStart = fromOffset; + _offsetAnimationTarget = toOffset; + SetValue(OffsetAnimationProgressProperty, 0d); + + try + { + await animation.RunAsync(this, null, cancellationToken); + + if (cancellationToken.IsCancellationRequested) + return; + + SetOffset(WithPrimaryOffset(_offset, toOffset)); + + if (UsesViewportFractionLayout() && + GetTransition() is IProgressPageTransition progressive) + { + var transitionProgress = GetFractionalTransitionProgress(toOffset); + progressive.Update( + transitionProgress, + FindViewportControl(_progressFromIndex), + FindViewportControl(_progressToIndex), + _isForward, + GetViewportItemExtent(Bounds.Size), + BuildFractionalVisibleItems(toOffset)); + } + + onCompleted(); + } + finally + { + if (ReferenceEquals(_offsetAnimationCts, offsetAnimationCts)) + _offsetAnimationCts = null; + } + } + + private void OnSwipeGestureEnded(object? sender, SwipeGestureEndedEventArgs e) + { + if (!_isDragging || e.Id != _swipeGestureId || ItemsControl is not Carousel carousel) + return; + + if (UsesViewportFractionLayout()) + { + OnViewportFractionSwipeGestureEnded(carousel, e); + return; + } + + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + var rawProgress = size > 0 ? Math.Abs(_totalDelta) / size : 0; + var currentProgress = _isRubberBanding + ? RubberBandFactor * Math.Sqrt(rawProgress) + : rawProgress; + var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal + ? Math.Abs(e.Velocity.X) + : Math.Abs(e.Velocity.Y); + var commit = !_isRubberBanding + && (currentProgress >= SwipeCommitThreshold || + (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit)) + && _swipeTarget is not null; + + _completionEndProgress = commit ? 1.0 : 0.0; + var remainingDistance = Math.Abs(_completionEndProgress - currentProgress); + var durationSeconds = _isRubberBanding + ? RubberBandReturnDuration + : velocity > 0 + ? Math.Clamp(remainingDistance * size / velocity, MinCompletionDuration, MaxCompletionDuration) + : MaxCompletionDuration; + Easing easing = _isRubberBanding ? new SineEaseOut() : new QuadraticEaseOut(); + + _completionCts?.Cancel(); + var completionCts = new CancellationTokenSource(); + _completionCts = completionCts; + + SetValue(CompletionProgressProperty, currentProgress); + + var animation = new Animation.Animation + { + FillMode = FillMode.Forward, + Easing = easing, + Duration = TimeSpan.FromSeconds(durationSeconds), + Children = + { + new KeyFrame + { + Setters = { new Setter { Property = CompletionProgressProperty, Value = currentProgress } }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = { new Setter { Property = CompletionProgressProperty, Value = _completionEndProgress } }, + Cue = new Cue(1d) + } + } + }; + + _isDragging = false; + _ = RunCompletionAnimation(animation, carousel, completionCts); + } + + private async Task RunCompletionAnimation( + Animation.Animation animation, + Carousel carousel, + CancellationTokenSource completionCts) + { + var cancellationToken = completionCts.Token; + + try + { + await animation.RunAsync(this, null, cancellationToken); + + if (cancellationToken.IsCancellationRequested) + return; + + if (GetTransition() is IProgressPageTransition progressive) + { + var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget; + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + progressive.Update( + _completionEndProgress, + _realized, + swipeTarget, + _isForward, + size, + Array.Empty()); + } + + var commit = _completionEndProgress > 0.5; + + if (commit && _swipeTarget is not null) + { + var targetIndex = _swipeTargetIndex; + var targetElement = _swipeTarget; + + // Clear swipe target state before promoting it to the realized element so + // interactive transitions never receive the same control as both from/to. + _swipeTarget = null; + _swipeTargetIndex = -1; + + if (_realized != null) + RecycleElement(_realized); + + _realized = targetElement; + _realizedIndex = targetIndex; + + carousel.SelectedIndex = targetIndex; + } + else + { + ResetSwipeState(); + } + + _totalDelta = 0; + _swipeTarget = null; + _swipeTargetIndex = -1; + _isRubberBanding = false; + carousel.IsSwiping = false; + } + finally + { + if (ReferenceEquals(_completionCts, completionCts)) + _completionCts = null; + } + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OffsetAnimationProgressProperty) + { + if (_offsetAnimationCts is { IsCancellationRequested: false }) + { + var animProgress = change.GetNewValue(); + var primaryOffset = _offsetAnimationStart + + ((_offsetAnimationTarget - _offsetAnimationStart) * animProgress); + SetOffset(WithPrimaryOffset(_offset, primaryOffset)); + + if (UsesViewportFractionLayout() && + GetTransition() is IProgressPageTransition progressive) + { + var transitionProgress = GetFractionalTransitionProgress(primaryOffset); + progressive.Update( + transitionProgress, + FindViewportControl(_progressFromIndex), + FindViewportControl(_progressToIndex), + _isForward, + GetViewportItemExtent(Bounds.Size), + BuildFractionalVisibleItems(primaryOffset)); + } + } + } + else if (change.Property == CompletionProgressProperty) + { + var isCompletionAnimating = _completionCts is { IsCancellationRequested: false }; + + if (!_isDragging && _swipeTarget is null && !isCompletionAnimating) + return; + + var progress = change.GetNewValue(); + if (GetTransition() is IProgressPageTransition progressive) + { + var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget; + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + progressive.Update( + progress, + _realized, + swipeTarget, + _isForward, + size, + Array.Empty()); + } + } + } + + private IReadOnlyList BuildFractionalVisibleItems(double currentOffset) + { + var items = new PageTransitionItem[_viewportRealized.Count]; + var i = 0; + foreach (var entry in _viewportRealized.OrderBy(x => x.Key)) + { + items[i++] = new PageTransitionItem( + entry.Value.ItemIndex, + entry.Value.Control, + entry.Key - currentOffset); + } + + return items; + } + + private void ResetSwipeState() + { + if (ItemsControl is Carousel carousel) + carousel.IsSwiping = false; + + CancelOffsetAnimation(); + + ResetViewportTransitionState(); + ResetTransitionState(_realized); + + if (_swipeTarget is not null) + RecycleElement(_swipeTarget); + + _isDragging = false; + _totalDelta = 0; + _swipeTarget = null; + _swipeTargetIndex = -1; + _isRubberBanding = false; + ClearFractionalProgressContext(); + + if (UsesViewportFractionLayout() && ItemsControl is Carousel viewportCarousel) + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(viewportCarousel.SelectedIndex, GetPrimaryOffset(_offset)))); + } + + private void ResetTransitionState(Control? control) + { + if (control is null) + return; + + if (GetTransition() is IProgressPageTransition progressive) + { + progressive.Reset(control); + } + else + { + ResetVisualState(control); + } + } + + private static void ResetVisualState(Control? control) + { + if (control is null) + return; + control.RenderTransform = null; + control.Opacity = 1; + control.ZIndex = 0; + control.Clip = null; + } } } diff --git a/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs b/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs new file mode 100644 index 0000000000..d0821c91b1 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using System.Threading; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Input; + +public class SwipeGestureRecognizerTests : ScopedTestBase +{ + [Fact] + public void Does_Not_Raise_Swipe_When_Both_Axes_Are_Disabled() + { + var (border, root) = CreateTarget(new SwipeGestureRecognizer { Threshold = 1 }); + var touch = new TouchTestHelper(); + var swipeRaised = false; + var endedRaised = false; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true); + root.AddHandler(InputElement.SwipeGestureEndedEvent, (_, _) => endedRaised = true); + + touch.Down(border, new Point(50, 50)); + touch.Move(border, new Point(20, 20)); + touch.Up(border, new Point(20, 20)); + + Assert.False(swipeRaised); + Assert.False(endedRaised); + } + + [Fact] + public void Defaults_Disable_Both_Axes() + { + var recognizer = new SwipeGestureRecognizer(); + + Assert.False(recognizer.CanHorizontallySwipe); + Assert.False(recognizer.CanVerticallySwipe); + } + + [Fact] + public void Starts_Only_After_Threshold_Is_Exceeded() + { + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 50 + }); + var touch = new TouchTestHelper(); + var deltas = new List(); + + root.AddHandler(InputElement.SwipeGestureEvent, (_, e) => deltas.Add(e.Delta)); + + touch.Down(border, new Point(5, 5)); + touch.Move(border, new Point(40, 5)); + + Assert.Empty(deltas); + + touch.Move(border, new Point(80, 5)); + + Assert.Single(deltas); + Assert.NotEqual(Vector.Zero, deltas[0]); + } + + [Fact] + public void Ended_Event_Uses_Same_Id_And_Last_Velocity() + { + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 1 + }); + var touch = new TouchTestHelper(); + var updateIds = new List(); + var velocities = new List(); + var endedId = 0; + var endedVelocity = Vector.Zero; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, e) => + { + updateIds.Add(e.Id); + velocities.Add(e.Velocity); + }); + root.AddHandler(InputElement.SwipeGestureEndedEvent, (_, e) => + { + endedId = e.Id; + endedVelocity = e.Velocity; + }); + + touch.Down(border, new Point(50, 50)); + touch.Move(border, new Point(40, 50)); + touch.Move(border, new Point(30, 50)); + touch.Up(border, new Point(30, 50)); + + Assert.True(updateIds.Count >= 2); + Assert.All(updateIds, id => Assert.Equal(updateIds[0], id)); + Assert.Equal(updateIds[0], endedId); + Assert.Equal(velocities[^1], endedVelocity); + } + + [Fact] + public void Mouse_Swipe_Requires_IsMouseEnabled() + { + var mouse = new MouseTestHelper(); + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 1 + }); + var swipeRaised = false; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true); + + mouse.Down(border, position: new Point(50, 50)); + mouse.Move(border, new Point(30, 50)); + mouse.Up(border, position: new Point(30, 50)); + + Assert.False(swipeRaised); + } + + [Fact] + public void Mouse_Swipe_Is_Raised_When_Enabled() + { + var mouse = new MouseTestHelper(); + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 1, + IsMouseEnabled = true + }); + var swipeRaised = false; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true); + + mouse.Down(border, position: new Point(50, 50)); + mouse.Move(border, new Point(30, 50)); + mouse.Up(border, position: new Point(30, 50)); + + Assert.True(swipeRaised); + } + + private static (Border Border, TestRoot Root) CreateTarget(SwipeGestureRecognizer recognizer) + { + var border = new Border + { + Width = 100, + Height = 100 + }; + border.GestureRecognizers.Add(recognizer); + + var root = new TestRoot + { + Child = border + }; + + return (border, root); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index ab93686966..11221eb7d1 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -2,10 +2,12 @@ using System; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Subjects; +using Avalonia.Animation; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.UnitTests; @@ -59,6 +61,28 @@ namespace Avalonia.Controls.UnitTests Assert.Equal("Foo", child.Text); } + [Fact] + public void ViewportFraction_Defaults_To_One() + { + using var app = Start(); + var target = new Carousel(); + + Assert.Equal(1d, target.ViewportFraction); + } + + [Fact] + public void ViewportFraction_Coerces_Invalid_Values_To_One() + { + using var app = Start(); + var target = new Carousel(); + + target.ViewportFraction = 0; + Assert.Equal(1d, target.ViewportFraction); + + target.ViewportFraction = double.NaN; + Assert.Equal(1d, target.ViewportFraction); + } + [Fact] public void Selected_Item_Changes_To_First_Item_When_Items_Property_Changes() { @@ -147,8 +171,7 @@ namespace Avalonia.Controls.UnitTests target.ItemsSource = null; Layout(target); - var numChildren = target.GetRealizedContainers().Count(); - + Assert.Empty(target.GetRealizedContainers()); Assert.Equal(-1, target.SelectedIndex); } @@ -326,6 +349,204 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, target.SelectedIndex); } + public class WrapSelectionTests : ScopedTestBase + { + [Fact] + public void Next_Loops_When_WrapSelection_Is_True() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = true, + SelectedIndex = 2 + }; + + Prepare(target); + + target.Next(); + Layout(target); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Previous_Loops_When_WrapSelection_Is_True() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = true, + SelectedIndex = 0 + }; + + Prepare(target); + + target.Previous(); + Layout(target); + + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Next_Does_Not_Loop_When_WrapSelection_Is_False() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = false, + SelectedIndex = 2 + }; + + Prepare(target); + + target.Next(); + Layout(target); + + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Previous_Does_Not_Loop_When_WrapSelection_Is_False() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = false, + SelectedIndex = 0 + }; + + Prepare(target); + + target.Previous(); + Layout(target); + + Assert.Equal(0, target.SelectedIndex); + } + } + + + + [Fact] + public void Right_Arrow_Navigates_To_Next_With_Horizontal_PageSlide() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal), + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Right }); + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Down_Arrow_Navigates_To_Next_With_Vertical_PageSlide() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Vertical), + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down }); + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Home_Navigates_To_First_Item() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + SelectedIndex = 2, + }; + + Prepare(target); + Layout(target); + Assert.Equal(2, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Home }); + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void End_Navigates_To_Last_Item() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.End }); + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Wrong_Axis_Arrow_Is_Ignored() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal), + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down }); + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Left_Arrow_Wraps_With_WrapSelection() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal), + WrapSelection = true, + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Left }); + Assert.Equal(2, target.SelectedIndex); + } + private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); private static void Prepare(Carousel target) diff --git a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs index ca3b1267bd..d8f50b81de 100644 --- a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.LogicalTree; @@ -1049,6 +1051,82 @@ public class DrawerPageTests } } + public class SwipeGestureTests : ScopedTestBase + { + [Fact] + public void HandledPointerPressedAtEdge_AllowsSwipeOpen() + { + var dp = new DrawerPage + { + DrawerPlacement = DrawerPlacement.Left, + DisplayMode = SplitViewDisplayMode.Overlay, + Width = 400, + Height = 300 + }; + dp.GestureRecognizers.OfType().First().IsMouseEnabled = true; + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = dp + }; + root.ExecuteInitialLayoutPass(); + + RaiseHandledPointerPressed(dp, new Point(5, 5)); + + var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default); + dp.RaiseEvent(swipe); + + Assert.True(swipe.Handled); + Assert.True(dp.IsOpen); + } + + [Fact] + public void MouseEdgeDrag_AllowsSwipeOpen() + { + var dp = new DrawerPage + { + DrawerPlacement = DrawerPlacement.Left, + DisplayMode = SplitViewDisplayMode.Overlay, + Width = 400, + Height = 300 + }; + dp.GestureRecognizers.OfType().First().IsMouseEnabled = true; + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = dp + }; + root.ExecuteInitialLayoutPass(); + + var mouse = new MouseTestHelper(); + mouse.Down(dp, position: new Point(5, 5)); + mouse.Move(dp, new Point(40, 5)); + mouse.Up(dp, position: new Point(40, 5)); + + Assert.True(dp.IsOpen); + } + + private static void RaiseHandledPointerPressed(Interactive target, Point position) + { + var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true); + var args = new PointerPressedEventArgs( + target, + pointer, + (Visual)target, + position, + timestamp: 1, + new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed), + KeyModifiers.None) + { + Handled = true + }; + + target.RaiseEvent(args); + } + } + public class DetachmentTests : ScopedTestBase { [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs b/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs new file mode 100644 index 0000000000..20f5f2ec2e --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs @@ -0,0 +1,23 @@ +using Avalonia.Input; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests; + +public class InputElementGestureTests : ScopedTestBase +{ + [Fact] + public void SwipeGestureEnded_PublicEvent_CanBeObserved() + { + var target = new Border(); + SwipeGestureEndedEventArgs? received = null; + + target.SwipeGestureEnded += (_, e) => received = e; + + var args = new SwipeGestureEndedEventArgs(42, new Vector(12, 34)); + target.RaiseEvent(args); + + Assert.Same(args, received); + Assert.Equal(InputElement.SwipeGestureEndedEvent, args.RoutedEvent); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs index 9602256fe8..2d15825f72 100644 --- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs @@ -1,13 +1,18 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; using Avalonia.UnitTests; using Xunit; @@ -1578,6 +1583,116 @@ public class NavigationPageTests } } + public class SwipeGestureTests : ScopedTestBase + { + [Fact] + public async Task HandledPointerPressedAtEdge_AllowsSwipePop() + { + var nav = new NavigationPage(); + var rootPage = new ContentPage { Header = "Root" }; + var topPage = new ContentPage { Header = "Top" }; + + await nav.PushAsync(rootPage); + await nav.PushAsync(topPage); + + var root = new TestRoot { Child = nav }; + root.ExecuteInitialLayoutPass(); + + RaiseHandledPointerPressed(nav, new Point(5, 5)); + + var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default); + nav.RaiseEvent(swipe); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(swipe.Handled); + Assert.Equal(1, nav.StackDepth); + Assert.Same(rootPage, nav.CurrentPage); + } + + [Fact] + public async Task MouseEdgeDrag_AllowsSwipePop() + { + var nav = new NavigationPage + { + Width = 400, + Height = 300 + }; + nav.GestureRecognizers.OfType().First().IsMouseEnabled = true; + var rootPage = new ContentPage { Header = "Root" }; + var topPage = new ContentPage { Header = "Top" }; + + await nav.PushAsync(rootPage); + await nav.PushAsync(topPage); + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = nav + }; + root.ExecuteInitialLayoutPass(); + + var mouse = new MouseTestHelper(); + mouse.Down(nav, position: new Point(5, 5)); + mouse.Move(nav, new Point(40, 5)); + mouse.Up(nav, position: new Point(40, 5)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(1, nav.StackDepth); + Assert.Same(rootPage, nav.CurrentPage); + } + + [Fact] + public async Task SameGestureId_OnlyPops_One_Page() + { + var nav = new NavigationPage + { + Width = 400, + Height = 300 + }; + var page1 = new ContentPage { Header = "1" }; + var page2 = new ContentPage { Header = "2" }; + var page3 = new ContentPage { Header = "3" }; + + await nav.PushAsync(page1); + await nav.PushAsync(page2); + await nav.PushAsync(page3); + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = nav + }; + root.ExecuteInitialLayoutPass(); + + RaiseHandledPointerPressed(nav, new Point(5, 5)); + + nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-20, 0), default)); + nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-30, 0), default)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(2, nav.StackDepth); + Assert.Same(page2, nav.CurrentPage); + } + + private static void RaiseHandledPointerPressed(Interactive target, Point position) + { + var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true); + var args = new PointerPressedEventArgs( + target, + pointer, + (Visual)target, + position, + timestamp: 1, + new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed), + KeyModifiers.None) + { + Handled = true + }; + + target.RaiseEvent(args); + } + } + public class LifecycleAfterTransitionTests : ScopedTestBase { [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs index c6c567e315..9034161e39 100644 --- a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Linq; using Avalonia.Animation; using Avalonia.Collections; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; using Avalonia.LogicalTree; using Avalonia.Media; +using Avalonia.Threading; using Avalonia.UnitTests; using Xunit; @@ -809,6 +813,91 @@ public class TabbedPageTests } } + public class SwipeGestureTests : ScopedTestBase + { + [Fact] + public void SameGestureId_OnlyAdvancesOneTab() + { + var tp = CreateSwipeReadyTabbedPage(); + + var firstSwipe = new SwipeGestureEventArgs(7, new Vector(20, 0), default); + var repeatedSwipe = new SwipeGestureEventArgs(7, new Vector(20, 0), default); + + tp.RaiseEvent(firstSwipe); + tp.RaiseEvent(repeatedSwipe); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(firstSwipe.Handled); + Assert.False(repeatedSwipe.Handled); + Assert.Equal(1, tp.SelectedIndex); + } + + [Fact] + public void NewGestureId_CanAdvanceAgain() + { + var tp = CreateSwipeReadyTabbedPage(); + + tp.RaiseEvent(new SwipeGestureEventArgs(7, new Vector(20, 0), default)); + tp.RaiseEvent(new SwipeGestureEventArgs(8, new Vector(20, 0), default)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(2, tp.SelectedIndex); + } + + [Fact] + public void MouseSwipe_Advances_Tab() + { + var tp = CreateSwipeReadyTabbedPage(); + var mouse = new MouseTestHelper(); + + mouse.Down(tp, position: new Point(200, 100)); + mouse.Move(tp, new Point(160, 100)); + mouse.Up(tp, position: new Point(160, 100)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(1, tp.SelectedIndex); + } + + private static TabbedPage CreateSwipeReadyTabbedPage() + { + var tp = new TabbedPage + { + IsGestureEnabled = true, + Width = 400, + Height = 300, + TabPlacement = TabPlacement.Top, + SelectedIndex = 0, + Pages = new AvaloniaList + { + new ContentPage { Header = "A" }, + new ContentPage { Header = "B" }, + new ContentPage { Header = "C" } + }, + Template = new FuncControlTemplate((parent, scope) => + { + var tabControl = new TabControl + { + Name = "PART_TabControl", + ItemsSource = parent.Pages + }; + scope.Register("PART_TabControl", tabControl); + return tabControl; + }) + }; + tp.GestureRecognizers.OfType().First().IsMouseEnabled = true; + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = tp + }; + tp.ApplyTemplate(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + return tp; + } + } + private sealed class TestableTabbedPage : TabbedPage { public void CallCommitSelection(int index, Page? page) => CommitSelection(index, page); diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs index cc506dd7a9..11687fa81d 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; @@ -9,7 +10,9 @@ using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Input; using Avalonia.Layout; +using Avalonia.Media; using Avalonia.Threading; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -135,6 +138,86 @@ namespace Avalonia.Controls.UnitTests }); } + [Fact] + public void ViewportFraction_Centers_Selected_Item_And_Peeks_Neighbors() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, _) = CreateTarget(items, viewportFraction: 0.8, clientSize: new Size(400, 300)); + + var realized = target.GetRealizedContainers()! + .OfType() + .ToDictionary(x => (string)x.Content!); + + Assert.Equal(2, realized.Count); + Assert.Equal(40d, realized["foo"].Bounds.X, 6); + Assert.Equal(320d, realized["foo"].Bounds.Width, 6); + Assert.Equal(360d, realized["bar"].Bounds.X, 6); + } + + [Fact] + public void ViewportFraction_OneThird_Shows_Three_Full_Items() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz", "qux" }; + var (target, carousel) = CreateTarget(items, viewportFraction: 1d / 3d, clientSize: new Size(300, 120)); + + carousel.SelectedIndex = 1; + Layout(target); + + var realized = target.GetRealizedContainers()! + .OfType() + .ToDictionary(x => (string)x.Content!); + + Assert.Equal(3, realized.Count); + Assert.Equal(0d, realized["foo"].Bounds.X, 6); + Assert.Equal(100d, realized["bar"].Bounds.X, 6); + Assert.Equal(200d, realized["baz"].Bounds.X, 6); + Assert.Equal(100d, realized["bar"].Bounds.Width, 6); + } + + [Fact] + public void Changing_SelectedIndex_Repositions_Fractional_Viewport() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items, viewportFraction: 0.8, clientSize: new Size(400, 300)); + + carousel.SelectedIndex = 1; + Layout(target); + + var realized = target.GetRealizedContainers()! + .OfType() + .ToDictionary(x => (string)x.Content!); + + Assert.Equal(40d, realized["bar"].Bounds.X, 6); + Assert.Equal(-280d, realized["foo"].Bounds.X, 6); + } + + [Fact] + public void Changing_ViewportFraction_Does_Not_Change_Selected_Item() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items, viewportFraction: 0.72, clientSize: new Size(400, 300)); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 2; + Layout(target); + + carousel.ViewportFraction = 1d; + Layout(target); + + var visible = target.Children + .OfType() + .Where(x => x.IsVisible) + .ToList(); + + Assert.Single(visible); + Assert.Equal("baz", visible[0].Content); + Assert.Equal(2, carousel.SelectedIndex); + } + public class Transitions : ScopedTestBase { [Fact] @@ -292,22 +375,89 @@ namespace Avalonia.Controls.UnitTests Assert.True(cancelationToken!.Value.IsCancellationRequested); } + + [Fact] + public void Completed_Transition_Is_Flushed_Before_Starting_Next_Transition() + { + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var items = new Control[] { new Button(), new Canvas(), new Label() }; + var transition = new Mock(); + + transition.Setup(x => x.Start( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var (target, carousel) = CreateTarget(items, transition.Object); + + carousel.SelectedIndex = 1; + Layout(target); + + carousel.SelectedIndex = 2; + Layout(target); + + transition.Verify(x => x.Start( + items[0], + items[1], + true, + It.IsAny()), + Times.Once); + transition.Verify(x => x.Start( + items[1], + items[2], + true, + It.IsAny()), + Times.Once); + + sync.ExecutePostedCallbacks(); + } + + [Fact] + public void Interrupted_Transition_Resets_Current_Page_Before_Starting_Next_Transition() + { + using var app = Start(); + var items = new Control[] { new Button(), new Canvas(), new Label() }; + var transition = new DirtyStateTransition(); + var (target, carousel) = CreateTarget(items, transition); + + carousel.SelectedIndex = 1; + Layout(target); + + carousel.SelectedIndex = 2; + Layout(target); + + Assert.Equal(2, transition.Starts.Count); + Assert.Equal(1d, transition.Starts[1].FromOpacity); + Assert.Null(transition.Starts[1].FromTransform); + } } private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); private static (VirtualizingCarouselPanel, Carousel) CreateTarget( IEnumerable items, - IPageTransition? transition = null) + IPageTransition? transition = null, + double viewportFraction = 1d, + Size? clientSize = null) { + var size = clientSize ?? new Size(400, 300); var carousel = new Carousel { ItemsSource = items, Template = CarouselTemplate(), PageTransition = transition, + ViewportFraction = viewportFraction, + Width = size.Width, + Height = size.Height, }; - var root = new TestRoot(carousel); + var root = new TestRoot(carousel) + { + ClientSize = size, + }; root.LayoutManager.ExecuteInitialLayoutPass(); return ((VirtualizingCarouselPanel)carousel.Presenter!.Panel!, carousel); } @@ -345,5 +495,619 @@ namespace Avalonia.Controls.UnitTests } private static void Layout(Control c) => c.GetLayoutManager()?.ExecuteLayoutPass(); + + private sealed class DirtyStateTransition : IPageTransition + { + public List<(double FromOpacity, ITransform? FromTransform)> Starts { get; } = new(); + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + Starts.Add((from?.Opacity ?? 1d, from?.RenderTransform)); + + if (to is not null) + { + to.Opacity = 0.25; + to.RenderTransform = new TranslateTransform { X = 50 }; + } + + return Task.Delay(Timeout.Infinite, cancellationToken); + } + } + + public class WrapSelectionTests : ScopedTestBase + { + [Fact] + public void Next_Wraps_To_First_Item_When_WrapSelection_Enabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 2; // Last item + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + } + + [Fact] + public void Next_Does_Not_Wrap_When_WrapSelection_Disabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = false; + carousel.SelectedIndex = 2; // Last item + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(2, carousel.SelectedIndex); // Should stay at last item + } + + [Fact] + public void Previous_Wraps_To_Last_Item_When_WrapSelection_Enabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 0; // First item + Layout(target); + + carousel.Previous(); + Layout(target); + + Assert.Equal(2, carousel.SelectedIndex); // Should wrap to last item + } + + [Fact] + public void Previous_Does_Not_Wrap_When_WrapSelection_Disabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = false; + carousel.SelectedIndex = 0; // First item + Layout(target); + + carousel.Previous(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); // Should stay at first item + } + + [Fact] + public void WrapSelection_Works_With_Two_Items() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 1; + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + + carousel.Previous(); + Layout(target); + + Assert.Equal(1, carousel.SelectedIndex); + } + + [Fact] + public void WrapSelection_Does_Not_Apply_To_Single_Item() + { + using var app = Start(); + var items = new[] { "foo" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 0; + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + + carousel.Previous(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + } + } + + public class Gestures : ScopedTestBase + { + [Fact] + public void Swiping_Forward_Realizes_Next_Item() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + var target = panel.Children[1] as Control; + Assert.NotNull(target); + Assert.True(target.IsVisible); + Assert.Equal("bar", ((target as ContentPresenter)?.Content)); + } + + [Fact] + public void Swiping_Backward_At_Start_RubberBands_When_WrapSelection_False() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = false; + + var e = new SwipeGestureEventArgs(1, new Vector(-10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Single(panel.Children); + } + + [Fact] + public void Swiping_Backward_At_Start_Wraps_When_WrapSelection_True() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = true; + + var e = new SwipeGestureEventArgs(1, new Vector(-10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + var target = panel.Children[1] as Control; + Assert.Equal("baz", ((target as ContentPresenter)?.Content)); + } + + [Fact] + public void ViewportFraction_Swiping_Backward_At_Start_Wraps_When_WrapSelection_True() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar", "baz" }; + var (panel, carousel) = CreateTarget(items, viewportFraction: 0.8); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = true; + Layout(panel); + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(-120, 0), default)); + + Assert.True(carousel.IsSwiping); + Assert.Contains(panel.Children.OfType(), x => Equals(x.Content, "baz")); + + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, default)); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(2, carousel.SelectedIndex); + } + + [Fact] + public void Swiping_Forward_At_End_RubberBands_When_WrapSelection_False() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = false; + carousel.SelectedIndex = 1; + + Layout(panel); + Layout(panel); + + Assert.Equal(2, ((IReadOnlyList?)carousel.ItemsSource)?.Count); + Assert.Equal(1, carousel.SelectedIndex); + Assert.False(carousel.WrapSelection, "WrapSelection should be false"); + + var container = Assert.IsType(panel.Children[0]); + Assert.Equal("bar", container.Content); + + var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Single(panel.Children); + } + + [Fact] + public void Swiping_Locks_To_Dominant_Axis() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items, new CrossFade(TimeSpan.FromSeconds(1))); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(10, 2), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + } + + [Fact] + public void Swipe_Completion_Does_Not_Update_With_Same_From_And_To() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new TrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(transition.UpdateCallCount > 0); + Assert.False(transition.SawAliasedUpdate); + Assert.Equal(1d, transition.LastProgress); + Assert.Equal(1, carousel.SelectedIndex); + } + + [Fact] + public void Swipe_Completion_Keeps_Target_Final_Interactive_Visual_State() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new TransformTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(1, carousel.SelectedIndex); + var realized = Assert.Single(panel.Children.OfType(), x => Equals(x.Content, "bar")); + Assert.NotNull(transition.LastTargetTransform); + Assert.Same(transition.LastTargetTransform, realized.RenderTransform); + } + + [Fact] + public void Swipe_Completion_Hides_Outgoing_Page_Before_Resetting_Visual_State() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new OutgoingTransformTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + var outgoing = Assert.Single(panel.Children.OfType(), x => Equals(x.Content, "foo")); + bool? hiddenWhenReset = null; + outgoing.PropertyChanged += (_, args) => + { + if (args.Property == Visual.RenderTransformProperty && + args.GetNewValue() is null) + { + hiddenWhenReset = !outgoing.IsVisible; + } + }; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(hiddenWhenReset); + } + + [Fact] + public void RubberBand_Swipe_Release_Animates_Back_Through_Intermediate_Progress() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new ProgressTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = false; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(-100, 0), default)); + + var releaseStartProgress = transition.Progresses[^1]; + var updatesBeforeRelease = transition.Progresses.Count; + + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, default)); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(0.1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + var postReleaseProgresses = transition.Progresses.Skip(updatesBeforeRelease).ToArray(); + + Assert.Contains(postReleaseProgresses, p => p > 0 && p < releaseStartProgress); + + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(0d, transition.Progresses[^1]); + Assert.Equal(0, carousel.SelectedIndex); + } + + [Fact] + public void ViewportFraction_SelectedIndex_Change_Drives_Progress_Updates() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar", "baz" }; + var transition = new ProgressTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition, viewportFraction: 0.8); + + carousel.SelectedIndex = 1; + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(0.1)); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.NotEmpty(transition.Progresses); + Assert.Contains(transition.Progresses, p => p > 0 && p < 1); + Assert.Equal(1d, transition.Progresses[^1]); + Assert.Equal(1, carousel.SelectedIndex); + } + + private sealed class TrackingInteractiveTransition : IProgressPageTransition + { + public int UpdateCallCount { get; private set; } + public bool SawAliasedUpdate { get; private set; } + public double LastProgress { get; private set; } + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + UpdateCallCount++; + LastProgress = progress; + + if (from is not null && ReferenceEquals(from, to)) + SawAliasedUpdate = true; + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.Opacity = 1; + visual.ZIndex = 0; + visual.Clip = null; + } + } + + private sealed class ProgressTrackingInteractiveTransition : IProgressPageTransition + { + public List Progresses { get; } = new(); + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + Progresses.Add(progress); + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.Opacity = 1; + visual.ZIndex = 0; + visual.Clip = null; + } + } + + private sealed class TransformTrackingInteractiveTransition : IProgressPageTransition + { + public TransformGroup? LastTargetTransform { get; private set; } + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (to is not Control target) + return; + + if (target.RenderTransform is not TransformGroup group) + { + group = new TransformGroup + { + Children = + { + new ScaleTransform(), + new TranslateTransform() + } + }; + target.RenderTransform = group; + } + + var scale = Assert.IsType(group.Children[0]); + var translate = Assert.IsType(group.Children[1]); + scale.ScaleX = scale.ScaleY = 0.9 + (0.1 * progress); + translate.X = 100 * (1 - progress); + LastTargetTransform = group; + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + } + } + + private sealed class OutgoingTransformTrackingInteractiveTransition : IProgressPageTransition + { + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (from is Control source) + source.RenderTransform = new TranslateTransform(100 * progress, 0); + + if (to is Control target) + target.RenderTransform = new TranslateTransform(100 * (1 - progress), 0); + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + } + } + + [Fact] + public void Vertical_Swipe_Forward_Realizes_Next_Item() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var transition = new PageSlide(TimeSpan.FromSeconds(1), PageSlide.SlideAxis.Vertical); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(0, 10), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + var target = panel.Children[1] as ContentPresenter; + Assert.NotNull(target); + Assert.Equal("bar", target.Content); + } + + [Fact] + public void New_Swipe_Interrupts_Active_Completion_Animation() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar", "baz" }; + var transition = new TrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromMilliseconds(50)); + sync.ExecutePostedCallbacks(); + + Assert.Equal(0, carousel.SelectedIndex); + + panel.RaiseEvent(new SwipeGestureEventArgs(2, new Vector(10, 0), default)); + + Assert.True(carousel.IsSwiping); + Assert.Equal(1, carousel.SelectedIndex); + } + + [Fact] + public void Swipe_With_NonInteractive_Transition_Does_Not_Crash() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var transition = new Mock(); + transition.Setup(x => x.Start(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var (panel, carousel) = CreateTarget(items, transition.Object); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + } + } } } diff --git a/tests/Avalonia.RenderTests/Controls/CarouselTests.cs b/tests/Avalonia.RenderTests/Controls/CarouselTests.cs new file mode 100644 index 0000000000..6e5c42d093 --- /dev/null +++ b/tests/Avalonia.RenderTests/Controls/CarouselTests.cs @@ -0,0 +1,127 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Styling; +using Avalonia.Themes.Simple; +using Avalonia.UnitTests; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests +#else +namespace Avalonia.Direct2D1.RenderTests.Controls +#endif +{ + public class CarouselRenderTests : TestBase + { + public CarouselRenderTests() + : base(@"Controls\Carousel") + { + } + + private static Style FontStyle => new Style(x => x.OfType()) + { + Setters = { new Setter(TextBlock.FontFamilyProperty, TestFontFamily) } + }; + + [Fact] + public async Task Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks() + { + var carousel = new Carousel + { + Background = Brushes.Transparent, + ViewportFraction = 0.8, + SelectedIndex = 1, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + ItemsSource = new Control[] + { + CreateCard("One", "#D8574B", "#F7C5BE"), + CreateCard("Two", "#3E7AD9", "#BCD0F7"), + CreateCard("Three", "#3D9B67", "#BEE4CB"), + } + }; + + var target = new Border + { + Width = 520, + Height = 340, + Background = Brushes.White, + Padding = new Thickness(20), + Child = carousel + }; + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(new CursorFactoryStub()); + target.Styles.Add(new SimpleTheme()); + target.Styles.Add(FontStyle); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + private static Control CreateCard(string label, string background, string accent) + { + return new Border + { + Margin = new Thickness(14, 12), + CornerRadius = new CornerRadius(18), + ClipToBounds = true, + Background = Brush.Parse(background), + BorderBrush = Brushes.White, + BorderThickness = new Thickness(2), + Child = new Grid + { + Children = + { + new Border + { + Height = 56, + Background = Brush.Parse(accent), + VerticalAlignment = VerticalAlignment.Top + }, + new Border + { + Width = 88, + Height = 88, + CornerRadius = new CornerRadius(44), + Background = Brushes.White, + Opacity = 0.9, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }, + new Border + { + Background = new SolidColorBrush(Color.Parse("#80000000")), + VerticalAlignment = VerticalAlignment.Bottom, + Padding = new Thickness(12), + Child = new TextBlock + { + Text = label, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + FontWeight = FontWeight.SemiBold + } + } + } + } + }; + } + + private sealed class CursorFactoryStub : ICursorFactory + { + public ICursorImpl GetCursor(StandardCursorType cursorType) => new CursorStub(); + + public ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot) => new CursorStub(); + + private sealed class CursorStub : ICursorImpl + { + public void Dispose() + { + } + } + } + } +} diff --git a/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png b/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..68fe67592588b6e2e502b8468afa735141ad895d GIT binary patch literal 5615 zcmdTocTki0wxK8pNE1XUp{;@iSfq+3WC6`uC@Kg7f(ufliV!d+GzAGI3A-vqAP`uJ zC}7|!5JFg_gqC0skRn70MQR8L2|~#G+_^LN&D{UqyqWu6X1>XM=R4`#k;D?sHjRAp_%P!dc;(R}U zdE!oH{zmL)u6KWIpuefffaJ|XslO*33CYY%xHWwKR_`oE}00T80-65q}5HgYdplPh+%6pmd z2Dtk64tBj|1`76cBz~RjKdS>ib4o+EV~YY8sZgUmp7rsmR8sMNuSmxt9oN76&jzr$ z#I^|k2PZjK7&k(zKI~Y}!X0RXzNiw@D}AV^b^u?LtV60;f=s?@9v&i5Ub*%6H9otAIqQt_eSDLpFgjz8((Fg zkf^Clhhbb97jiVA&v=K7O|3Xdb_r5e=B6sPC0i}W&X}29t<(F7N+9n`!eDX(XpTR>~T9OtI;3NLIv`ZDRBLlWT~X95ob9HJ`^d(9Fd9`_LD6RD!uLr67O^!p$s?^ zQc@hGr7aIiNP>YtilQQ*U;qCB?5;Ixt~Ofl*feUc&DG9V7BoVIoE`dlBh>mYAH>8F zwaD0A;p%eD^!J@J;p1Mzw7Q4?`EdHTC@wz65lQH|zy18}S58nGfzkPOJ}`rTdz@U8 z9c_Nzkv2Nf`fG&wW7?e0pOz}R>_}tJe7s3V;~{Wi`n8fe?jW<9B0LV;BPm{SZDJW4 zF?tIgqPc{*^zjYcV?EE~XZ5dlaEXLjiyh7rlXa3XH}yW>$#@eC#*@0YPC4v%mQDmA zZu^Bt?z}r!K=OU9YyK)E-G;c+&m8_F?;5t0al#_H6MYAH77?Yh2_|4to3%*`U|RyCaA@ zF_R_vPi7|Uxg2Y>7dn8I+aTB_<`4{K0A!)Id{NKM`(D92<7(NoF+T$&el4pxJ~$@C zYy6;aCQol_>&XWsUTjBZD8m0hroNI++aRA7EcI`y3~5UEU47O&7rKPKyi_^?Uf4@zXgH4LA^><>sfor6Y%3X9Vo zx!&AnR5xt6r>GS@2V6wa!n-l*x9(VD@*R}pC-lq!g$D8hZYH%fHm1??oj|3~x+ZF< zYfdzBLu1O{3n=0u**IN_a#zu2^`Yr)Q<;S40ZEF--!Z~{_m;+$Wa8Gm` zYSP!as@EdQaN&u$cDSg>z1QTH({WpslN4ITgL1vS`sbM6Q?ZpJ~*H% z6~Ngq;!J=gNHw8t;htm$G1+2q0I^I$G%xRYc2vITp;R&k&-sSq&uuJnZXVoo!qDl8 zsL1g^+Aa92rBDVc<9S_e{po0#=xNB_lgEKCe>!3Kt4;Yi_p1TUUN0q`egS~Df1rDO zkxiUic(?9e&?KV#lYEg)7_J;MBl|!}X+#@{&#>t|3c(s3h`l9yWfG;$3c$zOC4}p1?(Nw5!RF z2<{r`9I9e6r%JCIYEJEC z4n%lIzFLS3p1J2S^ilm~sB70#Sc1&W&y3VuQmD3vsK}|oaX|f&h~Br!Apq(7t1a!P ze8+9x0<;w|sop*}=0obXAD%=e|MEC1+I$9LGg13bx-XBCPqF32Zud?~pPH7pjn1-c z2j#9wJ(59Y=M{j*51u+AuVwq8u9N}?S?U3dOiw|x{~Mz_|6&w4Vnpg2d7EjF2s&&9 zG0*!KR%T4&=s)G0&UT9q@#x9|#NUti*xTQe`g<+~on_mt9|9)iHrph8};07FhG9AGRa^z8#o9FGn< z$x&E%+$QaG81RbE#7Asdlc%jqp50&eRLCcSlt2G$i#*HS`4d3U1`wvtY@c5P6K5xy zV=Orex0rz4wdFJoJJZW2zjJRUsy)>oL0+}NCcg3L~(vSyZg#^fB5ZwBDVomgM!1n0E(}2EeIW;2Cach7G&`K z9h#iD(?Alq@*@d+@QviMR-j+L?|#&Orn9@qB4D@|>YX2-1(ZF??ssTk=E==K&JXeH zP>$ptvC0Hk!tuMj$=~^%a-aOT;nWzb-7r=zkcfbsh_4Z}y=a(YcA&0K#%va(w(PE2{^qQjm0dB&(j6(I21H^%QorN5TSw4(w+LWRF@%HfubmZ_E^QiXH#j%pA#g(uta9#p~Hpy%avh*1Cp;L0b@Jh>7DbWQA|z`O1)@r z!xNqV=uq`pkn~{uz8^>?UT8x(z?=2zu={(tscvqB#(%m# znesnvhiZ++I36L-5s`4Y;i1gP^i7u85`UaQ3Zt)C-krXWUa^QX2BW`r^V{cIlnVJf*;BZ1v;|ba<;_9N zokT=M4wCfbG_b+dBkHc6WNj*vGl{|QOL?Jq3`Pu&qmj*e*(kiaCx0ttQ#0-U%-uas zto%oy^pQke8X(}pa^6(`?i%&${0hc<>-~^1y6liqfqE(iLpZW5ZOs}&ZZ0yp-T6oN z9`68j>a$Hin@>Hnm6pJ%ZtW<7RP>@bh-kmn0 z9^j1p!yNqGTX;-I=W)Lh`-yPnQX@mUru>DrFgPYpRn<2-&X8l=b5zKx@Ed$Tmc^4~ z4qwHPE+oP>PJdg%vu9a5HtN<9-^;1OEtY^I?JaDxbrcNtQ=;zu6t+t@I+-7N_v@%8 z&5ikGPo*R3(r)7W;Xzg34;>Vme%^Pfq_;a_#O6}%&ZBedU@=ACj_zbzshW+kwmsqB zOlo+Lild%;M_t*sPzTDl=w|83~_L2l2$#~C%NHSoTo70*Ue zw?%1=V0dHjjtkZ#nU^?&+{5!VNLiv{nn3JRp?<*|m{?~Ci;JFQrY$wPC6@tgS= zdhoa}t>{;fem2nCx1S4U5OUmxwuOm^h^^;4o8YkX9|p}v74^d?q4|^JU;GQ z33J2^67zFNNJs`AJ*~@R#YZE2d$*Rk)Jx>zMk&KKkqQ?&Hu=Y_7UG54e0|S;NhmQ) zt8P6}TuRz>@H7H;Xti;*?|}ivjghi_EHvChQ$xkO_AaB9j98q?@(4@C;TF&-UMCN- z8D}^dx^SGGT|0joeReI%7)LRE#tQ_Q`K?lhN1}hDy*n*oRxd0hVBk2RJ^_k2SvQ9U za;IM`<+)^1mhy=bwnnzyhXHCbCv^O+K^S?yEHJQQ<8+sobWMyR9C7`Q`ZY^=Kxksxr+EV0b5Z)@6y4CzRi&6?=*>dLaHnNFGBUq~a>P9^UQq<`Mp#&oHkaa^ zrgGjZjWnN{^SmIHo10qy?W~I?upwH0xw3H>q^0tIWj7>LVTNq`7Z=}M1U9&!3+Ei{ Js%>sP{4aY^_`d)E literal 0 HcmV?d00001 From ff980aeba2d23eb52a26c8d946c6854aad4275fc Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Wed, 18 Mar 2026 14:39:19 +0500 Subject: [PATCH 15/16] Restructure VisualLayerManager (#20905) * refactor: Replace IsPopup with Enable*Layer properties on VisualLayerManager - Remove IsPopup from VisualLayerManager, add granular Enable*Layer properties: EnableAdornerLayer (default true), EnableOverlayLayer (default false), EnablePopupOverlayLayer (internal, default false), EnableTextSelectorLayer (default false) - Add PART_VisualLayerManager template part to TopLevel with protected property - Window and EmbeddableControlRoot override OnApplyTemplate to enable overlay, popup overlay, and text selector layers - OverlayLayer is now wrapped in a Panel with a dedicated AdornerLayer sibling - AdornerLayer.GetAdornerLayer checks for OverlayLayer's dedicated AdornerLayer - Update all 8 XAML templates (both themes) to name PART_VisualLayerManager and remove IsPopup="True" from PopupRoot/OverlayPopupHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add XML doc to VisualLayerManager * Also search for AdornerLayer from TopLevel --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Julien Lebosquain --- api/Avalonia.nupkg.xml | 24 ++++++++ .../Embedding/EmbeddableControlRoot.cs | 7 +++ .../Primitives/AdornerLayer.cs | 28 ++++++++- .../Primitives/OverlayLayer.cs | 5 ++ .../Primitives/VisualLayerManager.cs | 58 +++++++++++++++---- src/Avalonia.Controls/TopLevel.cs | 16 +++++ src/Avalonia.Controls/Window.cs | 7 +++ .../Controls/EmbeddableControlRoot.xaml | 2 +- .../Controls/OverlayPopupHost.xaml | 2 +- .../Controls/PopupRoot.xaml | 2 +- .../Controls/Window.xaml | 2 +- .../Controls/EmbeddableControlRoot.xaml | 2 +- .../Controls/OverlayPopupHost.xaml | 2 +- .../Controls/PopupRoot.xaml | 2 +- .../Controls/Window.xaml | 2 +- .../Primitives/VisualLayerManagerTests.cs | 37 ++++++++++++ 16 files changed, 178 insertions(+), 20 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 8e6173a6cb..63e8234919 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -2269,6 +2269,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.VisualLayerManager.get_IsPopup + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.VisualLayerManager.get_LightDismissOverlayLayer @@ -2287,6 +2293,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.VisualLayerManager.set_IsPopup(System.Boolean) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Screens.ScreenFromWindow(Avalonia.Platform.IWindowBaseImpl) @@ -3871,6 +3883,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.VisualLayerManager.get_IsPopup + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.VisualLayerManager.get_LightDismissOverlayLayer @@ -3889,6 +3907,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.VisualLayerManager.set_IsPopup(System.Boolean) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Screens.ScreenFromWindow(Avalonia.Platform.IWindowBaseImpl) diff --git a/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs b/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs index d4e5488019..59718c3e3f 100644 --- a/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs +++ b/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs @@ -4,6 +4,7 @@ using Avalonia.Automation.Peers; using Avalonia.Controls.Automation; using Avalonia.Controls.Automation.Peers; using Avalonia.Controls.Platform; +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Platform; @@ -54,6 +55,12 @@ namespace Avalonia.Controls.Embedding protected override Type StyleKeyOverride => typeof(EmbeddableControlRoot); + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + EnableVisualLayerManagerLayers(); + } + protected override AutomationPeer OnCreateAutomationPeer() { return new EmbeddableControlRootAutomationPeer(this); diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs index 8d3d97b94f..1c8b24f627 100644 --- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs +++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Specialized; +using System.Linq; using Avalonia.Input.TextInput; using Avalonia.Media; using Avalonia.Reactive; @@ -71,7 +72,32 @@ namespace Avalonia.Controls.Primitives public static AdornerLayer? GetAdornerLayer(Visual visual) { - return visual.FindAncestorOfType()?.AdornerLayer; + // Check if the visual is inside an OverlayLayer with a dedicated AdornerLayer + foreach (var ancestor in visual.GetVisualAncestors()) + { + if (GetDirectAdornerLayer(ancestor) is { } adornerLayer) + return adornerLayer; + } + + if (TopLevel.GetTopLevel(visual) is { } topLevel) + { + foreach (var descendant in topLevel.GetVisualDescendants()) + { + if (GetDirectAdornerLayer(descendant) is { } adornerLayer) + return adornerLayer; + } + } + + return null; + + static AdornerLayer? GetDirectAdornerLayer(Visual visual) + { + if (visual is OverlayLayer { AdornerLayer: { } adornerLayer }) + return adornerLayer; + if (visual is VisualLayerManager vlm) + return vlm.AdornerLayer; + return null; + } } public static bool GetIsClipEnabled(Visual adorner) diff --git a/src/Avalonia.Controls/Primitives/OverlayLayer.cs b/src/Avalonia.Controls/Primitives/OverlayLayer.cs index 3337288a13..a9d9b072f2 100644 --- a/src/Avalonia.Controls/Primitives/OverlayLayer.cs +++ b/src/Avalonia.Controls/Primitives/OverlayLayer.cs @@ -13,6 +13,11 @@ namespace Avalonia.Controls.Primitives public Size AvailableSize { get; private set; } + /// + /// Gets the dedicated adorner layer for this overlay layer. + /// + internal AdornerLayer? AdornerLayer { get; set; } + internal OverlayLayer() { } diff --git a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs index 6630f1e09c..eb912d2cf8 100644 --- a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs +++ b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs @@ -3,6 +3,9 @@ using Avalonia.LogicalTree; namespace Avalonia.Controls.Primitives { + /// + /// A control that manages multiple layers such as adorners, overlays, text selectors, and popups. + /// public sealed class VisualLayerManager : Decorator { private const int AdornerZIndex = int.MaxValue - 100; @@ -13,13 +16,37 @@ namespace Avalonia.Controls.Primitives private ILogicalRoot? _logicalRoot; private readonly List _layers = new(); - - public bool IsPopup { get; set; } - - internal AdornerLayer AdornerLayer + private OverlayLayer? _overlayLayer; + + /// + /// Gets or sets a value indicating whether an is + /// created for this . When enabled, the adorner layer is added to the + /// visual tree, providing a dedicated layer for rendering adorners. + /// + public bool EnableAdornerLayer { get; set; } = true; + + /// + /// Gets or sets a value indicating whether an is + /// created for this . When enabled, the overlay layer is added to the + /// visual tree, providing a dedicated layer for rendering overlay visuals. + /// + public bool EnableOverlayLayer { get; set; } + + internal bool EnablePopupOverlayLayer { get; set; } + + /// + /// Gets or sets a value indicating whether a is + /// created for this . When enabled, the overlay layer is added to the + /// visual tree, providing a dedicated layer for rendering text selection handles. + /// + public bool EnableTextSelectorLayer { get; set; } + + internal AdornerLayer? AdornerLayer { get { + if (!EnableAdornerLayer) + return null; var rv = FindLayer(); if (rv == null) AddLayer(rv = new AdornerLayer(), AdornerZIndex); @@ -31,7 +58,7 @@ namespace Avalonia.Controls.Primitives { get { - if (IsPopup) + if (!EnablePopupOverlayLayer) return null; var rv = FindLayer(); if (rv == null) @@ -44,12 +71,21 @@ namespace Avalonia.Controls.Primitives { get { - if (IsPopup) + if (!EnableOverlayLayer) return null; - var rv = FindLayer(); - if (rv == null) - AddLayer(rv = new OverlayLayer(), OverlayZIndex); - return rv; + if (_overlayLayer == null) + { + _overlayLayer = new OverlayLayer(); + var adorner = new AdornerLayer(); + _overlayLayer.AdornerLayer = adorner; + + var panel = new Panel(); + panel.Children.Add(_overlayLayer); + panel.Children.Add(adorner); + + AddLayer(panel, OverlayZIndex); + } + return _overlayLayer; } } @@ -57,7 +93,7 @@ namespace Avalonia.Controls.Primitives { get { - if (IsPopup) + if (!EnableTextSelectorLayer) return null; var rv = FindLayer(); if (rv == null) diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 8556d03d91..ceb9590564 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -38,6 +38,7 @@ namespace Avalonia.Controls /// tracking the widget's . /// [TemplatePart("PART_TransparencyFallback", typeof(Border))] + [TemplatePart("PART_VisualLayerManager", typeof(VisualLayerManager))] public abstract class TopLevel : ContentControl, ICloseable, IStyleHost, @@ -125,6 +126,7 @@ namespace Avalonia.Controls private Size? _frameSize; private WindowTransparencyLevel _actualTransparencyLevel; private Border? _transparencyFallbackBorder; + private VisualLayerManager? _visualLayerManager; private TargetWeakEventSubscriber? _resourcesChangesSubscriber; private IStorageProvider? _storageProvider; private Screens? _screens; @@ -133,6 +135,18 @@ namespace Avalonia.Controls internal TopLevelHost TopLevelHost => _topLevelHost; internal new PresentationSource PresentationSource => _source; internal IInputRoot InputRoot => _source; + + private protected VisualLayerManager? VisualLayerManager => _visualLayerManager; + + private protected void EnableVisualLayerManagerLayers() + { + if (_visualLayerManager is { } vlm) + { + vlm.EnableOverlayLayer = true; + vlm.EnablePopupOverlayLayer = true; + vlm.EnableTextSelectorLayer = true; + } + } /// /// Initializes static members of the class. @@ -723,6 +737,8 @@ namespace Avalonia.Controls { base.OnApplyTemplate(e); + _visualLayerManager = e.NameScope.Find("PART_VisualLayerManager"); + if (PlatformImpl is null) return; diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index db3ec6a077..e7a4ce953e 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Avalonia.Automation.Peers; using Avalonia.Controls.Chrome; using Avalonia.Controls.Platform; +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; @@ -795,6 +796,12 @@ namespace Avalonia.Controls ShowCore(null, false); } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + EnableVisualLayerManagerLayers(); + } + protected override void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) { if (!IgnoreVisibilityChanges) diff --git a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml index 1fc931db36..2ea83ec6a9 100644 --- a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml @@ -12,7 +12,7 @@ - + - + - + - + - + - + - + - + Date: Wed, 18 Mar 2026 14:13:52 +0100 Subject: [PATCH 16/16] Added more Carousel samples (#20932) * Added all the Carousel samples * Fixes * Updated sample * More changes --- samples/ControlCatalog/MainView.xaml | 6 +- .../Pages/CarouselDemoPage.xaml | 11 + .../Pages/CarouselDemoPage.xaml.cs | 53 ++ .../CarouselCustomizationPage.xaml | 119 ++++ .../CarouselCustomizationPage.xaml.cs | 48 ++ .../CarouselPage/CarouselDataBindingPage.xaml | 60 ++ .../CarouselDataBindingPage.xaml.cs | 95 +++ .../CarouselPage/CarouselGalleryAppPage.xaml | 557 ++++++++++++++++++ .../CarouselGalleryAppPage.xaml.cs | 101 ++++ .../CarouselPage/CarouselGesturesPage.xaml | 93 +++ .../CarouselPage/CarouselGesturesPage.xaml.cs | 59 ++ .../CarouselGettingStartedPage.xaml | 74 +++ .../CarouselGettingStartedPage.xaml.cs | 40 ++ .../CarouselPage/CarouselMultiItemPage.xaml | 140 +++++ .../CarouselMultiItemPage.xaml.cs | 47 ++ .../CarouselPage/CarouselTransitionsPage.xaml | 97 +++ .../CarouselTransitionsPage.xaml.cs | 66 +++ .../CarouselPage/CarouselVerticalPage.xaml | 132 +++++ .../CarouselPage/CarouselVerticalPage.xaml.cs | 39 ++ .../VirtualizingCarouselPanel.cs | 14 +- 20 files changed, 1848 insertions(+), 3 deletions(-) create mode 100644 samples/ControlCatalog/Pages/CarouselDemoPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml create mode 100644 samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml.cs diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index b6249fe17f..2a0de7a114 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -54,8 +54,10 @@ ScrollViewer.VerticalScrollBarVisibility="Disabled"> - - + + diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml new file mode 100644 index 0000000000..df4317fcad --- /dev/null +++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs new file mode 100644 index 0000000000..64753b9fc4 --- /dev/null +++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs @@ -0,0 +1,53 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace ControlCatalog.Pages +{ + public partial class CarouselDemoPage : UserControl + { + private static readonly (string Group, string Title, string Description, Func Factory)[] Demos = + { + // Overview + ("Overview", "Getting Started", + "Basic Carousel with image items and previous/next navigation buttons.", + () => new CarouselGettingStartedPage()), + + // Features + ("Features", "Transitions", + "Configure page transitions: PageSlide, CrossFade, 3D Rotation, or None.", + () => new CarouselTransitionsPage()), + ("Features", "Customization", + "Adjust orientation and transition type to tailor the carousel layout.", + () => new CarouselCustomizationPage()), + ("Features", "Gestures & Keyboard", + "Navigate items via swipe gesture and arrow keys. Toggle each input mode on and off.", + () => new CarouselGesturesPage()), + ("Features", "Vertical Orientation", + "Carousel with Orientation set to Vertical, navigated with Up/Down keys, swipe, or buttons.", + () => new CarouselVerticalPage()), + ("Features", "Multi-Item Peek", + "Adjust ViewportFraction to show multiple items simultaneously with adjacent cards peeking.", + () => new CarouselMultiItemPage()), + ("Features", "Data Binding", + "Bind Carousel to an ObservableCollection and add, remove, or shuffle items at runtime.", + () => new CarouselDataBindingPage()), + + // Showcases + ("Showcases", "Curated Gallery", + "Editorial art gallery app with DrawerPage navigation, hero Carousel with PipsPager dots, and a horizontal peek carousel for collection highlights.", + () => new CarouselGalleryAppPage()), + }; + + public CarouselDemoPage() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private async void OnLoaded(object? sender, RoutedEventArgs e) + { + await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null); + } + } +} diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml new file mode 100644 index 0000000000..add442e7a1 --- /dev/null +++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +