From 45b0eb33b86f8298be902ba72483f28cc6a9c0ad Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Fri, 6 Mar 2026 01:29:59 +0500 Subject: [PATCH 01/50] X11IconLoader needs to scale the icon into our hardcoded size (#20817) --- src/Avalonia.X11/X11IconLoader.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.X11/X11IconLoader.cs b/src/Avalonia.X11/X11IconLoader.cs index a45c77e7c0..f0cd6f0192 100644 --- a/src/Avalonia.X11/X11IconLoader.cs +++ b/src/Avalonia.X11/X11IconLoader.cs @@ -41,9 +41,15 @@ namespace Avalonia.X11 _height = Math.Min(bitmap.PixelSize.Height, 128); var pixels = new uint[_width * _height]; - fixed (void* pPixels = pixels) - bitmap.CopyPixels(new LockedFramebuffer((IntPtr)pPixels, new PixelSize(_width, _height), _width * 4, - new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul, null)); + using (var rtb = new RenderTargetBitmap(new PixelSize(128, 128))) + { + 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, + new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul, null)); + } Data = new UIntPtr[_width * _height + 2]; Data[0] = new UIntPtr((uint)_width); From 7520967a110ee30655c1bf2a7eaef29ffd3300c4 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 9 Mar 2026 13:50:53 +0500 Subject: [PATCH 02/50] Streamline drawn decorations management to avoid call ordering problems (#20840) --- .../TopLevelHost.Decorations.cs | 104 ++++++++++-------- src/Avalonia.Controls/Window.cs | 24 ++-- 2 files changed, 67 insertions(+), 61 deletions(-) diff --git a/src/Avalonia.Controls/TopLevelHost.Decorations.cs b/src/Avalonia.Controls/TopLevelHost.Decorations.cs index f9cb20f511..11f3dc3ada 100644 --- a/src/Avalonia.Controls/TopLevelHost.Decorations.cs +++ b/src/Avalonia.Controls/TopLevelHost.Decorations.cs @@ -48,64 +48,76 @@ internal partial class TopLevelHost internal WindowDrawnDecorations? Decorations => _decorations; /// - /// Enables drawn window decorations with the specified parts. - /// Creates the decorations instance, applies the template, and inserts layers into the visual tree. + /// Updates drawn window decorations with the specified parts and window state. + /// When is null, decorations are removed entirely. + /// When non-null (including ), the decoration + /// infrastructure is kept alive and parts/fullscreen state are updated. /// - internal void EnableDecorations(DrawnWindowDecorationParts parts) + internal void UpdateDrawnDecorations(DrawnWindowDecorationParts? parts, WindowState windowState) { + if (parts == null) + { + RemoveDecorations(); + return; + } + + var enabledParts = parts.Value; + if (_decorations != null) { // Layers persist across part changes; pseudo-classes driven by EnabledParts // control visibility of individual decoration elements in the theme. - _decorations.EnabledParts = parts; + _decorations.EnabledParts = enabledParts; if (_resizeGrips != null) - _resizeGrips.IsVisible = parts.HasFlag(DrawnWindowDecorationParts.ResizeGrips); - return; + _resizeGrips.IsVisible = enabledParts.HasFlag(DrawnWindowDecorationParts.ResizeGrips); } - - _decorations = new WindowDrawnDecorations(); - _decorations.EnabledParts = parts; - - // Set up logical parenting - LogicalChildren.Add(_decorations); - - // Create layer wrappers - _underlay = new LayerWrapper() { [AutomationProperties.AutomationIdProperty] = "WindowChromeUnderlay" }; - _overlay = new LayerWrapper() { [AutomationProperties.AutomationIdProperty] = "WindowChromeOverlay" }; - _fullscreenPopover = new LayerWrapper() + else { - IsVisible = false, [AutomationProperties.AutomationIdProperty] = "PopoverWindowChrome" - }; - - // Insert layers: underlay below TopLevel, overlay and popover above - // Visual order: underlay(0), TopLevel(1), overlay(2), fullscreenPopover(3), resizeGrips(4) - VisualChildren.Insert(0, _underlay); - VisualChildren.Add(_overlay); - VisualChildren.Add(_fullscreenPopover); - - // Always create resize grips; visibility is controlled by EnabledParts - _resizeGrips = new ResizeGripLayer(); - _resizeGrips.IsVisible = parts.HasFlag(DrawnWindowDecorationParts.ResizeGrips); - VisualChildren.Add(_resizeGrips); + _decorations = new WindowDrawnDecorations(); + _decorations.EnabledParts = enabledParts; - // Attach to window if available - if (_topLevel is Window window) - _decorations.Attach(window); + // Set up logical parenting + LogicalChildren.Add(_decorations); - // Subscribe to template changes to re-apply and geometry changes for resize grips - _decorations.EffectiveGeometryChanged += OnDecorationsGeometryChanged; - _decorationsSubscriptions = _decorations.GetObservable(WindowDrawnDecorations.TemplateProperty) - .Subscribe(_ => ApplyDecorationsTemplate()); + // Create layer wrappers + _underlay = new LayerWrapper() { [AutomationProperties.AutomationIdProperty] = "WindowChromeUnderlay" }; + _overlay = new LayerWrapper() { [AutomationProperties.AutomationIdProperty] = "WindowChromeOverlay" }; + _fullscreenPopover = new LayerWrapper() + { + IsVisible = false, [AutomationProperties.AutomationIdProperty] = "PopoverWindowChrome" + }; + + // Insert layers: underlay below TopLevel, overlay and popover above + // Visual order: underlay(0), TopLevel(1), overlay(2), fullscreenPopover(3), resizeGrips(4) + VisualChildren.Insert(0, _underlay); + VisualChildren.Add(_overlay); + VisualChildren.Add(_fullscreenPopover); + + // Always create resize grips; visibility is controlled by EnabledParts + _resizeGrips = new ResizeGripLayer(); + _resizeGrips.IsVisible = enabledParts.HasFlag(DrawnWindowDecorationParts.ResizeGrips); + VisualChildren.Add(_resizeGrips); + + // Attach to window if available + if (_topLevel is Window window) + _decorations.Attach(window); + + // Subscribe to template changes to re-apply and geometry changes for resize grips + _decorations.EffectiveGeometryChanged += OnDecorationsGeometryChanged; + _decorationsSubscriptions = _decorations.GetObservable(WindowDrawnDecorations.TemplateProperty) + .Subscribe(_ => ApplyDecorationsTemplate()); + + ApplyDecorationsTemplate(); + InvalidateMeasure(); + } - ApplyDecorationsTemplate(); - InvalidateMeasure(); - _decorationsOverlayPeer?.InvalidateChildren(); + ApplyFullscreenState(windowState == WindowState.FullScreen); } /// - /// Disables drawn window decorations and removes all layers. + /// Removes drawn window decorations and all associated layers. /// - internal void DisableDecorations() + private void RemoveDecorations() { if (_decorations == null) return; @@ -192,15 +204,15 @@ internal partial class TopLevelHost } /// - /// Shows or hides the fullscreen popover based on the window state. - /// Called by Window when window state changes. + /// Applies fullscreen-specific layer visibility: hides overlay/underlay and enables + /// popover hover detection, or restores normal state. /// - internal void SetFullscreenPopoverEnabled(bool enabled) + private void ApplyFullscreenState(bool isFullscreen) { if (_fullscreenPopover == null) return; - if (enabled) + if (isFullscreen) { // In fullscreen mode, hide overlay and underlay, enable popover hover detection if (_overlay != null) diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 043abb0e1b..d92a46a70a 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -626,10 +626,7 @@ namespace Avalonia.Controls StartRendering(); } - // Update fullscreen popover visibility - TopLevelHost.SetFullscreenPopoverEnabled(state == WindowState.FullScreen); - - // Update decoration parts for the new window state + // Update decoration parts and fullscreen popover state for the new window state UpdateDrawnDecorationParts(); } @@ -643,13 +640,11 @@ namespace Avalonia.Controls private void UpdateDrawnDecorations() { - var needsDrawnDecorations = PlatformImpl?.NeedsManagedDecorations ?? false; + var parts = ComputeDecorationParts(); + TopLevelHost.UpdateDrawnDecorations(parts, WindowState); - var parts = needsDrawnDecorations ? ComputeDecorationParts() : DrawnWindowDecorationParts.None; - if (parts != DrawnWindowDecorationParts.None) + if (parts != null) { - TopLevelHost.EnableDecorations(parts); - // Forward ExtendClientAreaTitleBarHeightHint to decoration TitleBarHeight var decorations = TopLevelHost.Decorations; if (decorations != null) @@ -659,10 +654,6 @@ namespace Avalonia.Controls decorations.TitleBarHeightOverride = hint; } } - else - { - TopLevelHost.DisableDecorations(); - } UpdateDrawnDecorationMargins(); } @@ -676,11 +667,14 @@ namespace Avalonia.Controls if (TopLevelHost.Decorations == null) return; - TopLevelHost.EnableDecorations(ComputeDecorationParts()); + TopLevelHost.UpdateDrawnDecorations(ComputeDecorationParts(), WindowState); } - private Chrome.DrawnWindowDecorationParts ComputeDecorationParts() + private Chrome.DrawnWindowDecorationParts? ComputeDecorationParts() { + if (!(PlatformImpl?.NeedsManagedDecorations ?? false)) + return null; + var platformNeeds = PlatformImpl?.RequestedDrawnDecorations ?? PlatformRequestedDrawnDecoration.None; var parts = Chrome.DrawnWindowDecorationParts.None; if (WindowDecorations != WindowDecorations.None) From f3df9b1f30e8ed15a3b3fc5157f0b8c183297219 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 9 Mar 2026 18:41:53 +0900 Subject: [PATCH 03/50] [Metal] Dispose GRBackendRenderTarget and @autoreleasepool for metal objects (#20815) * Add @autoreleasepool to release memory * Pass in and dispose GRBackendRenderTarget * Use Macros --- native/Avalonia.Native/src/OSX/metal.mm | 15 +++++++++------ src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs | 13 +++++++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/metal.mm b/native/Avalonia.Native/src/OSX/metal.mm index 33aa2aeb53..517872b147 100644 --- a/native/Avalonia.Native/src/OSX/metal.mm +++ b/native/Avalonia.Native/src/OSX/metal.mm @@ -87,11 +87,12 @@ public: return (__bridge void*) queue; } - HRESULT ImportIOSurface(void *handle, AvnPixelFormat pixelFormat, IAvnMetalTexture **ppv) override { + HRESULT ImportIOSurface(void *handle, AvnPixelFormat pixelFormat, IAvnMetalTexture **ppv) override { + START_COM_ARP_CALL; auto surf = (IOSurfaceRef)handle; auto width = IOSurfaceGetWidth(surf); auto height = IOSurfaceGetHeight(surf); - + auto desc = [MTLTextureDescriptor new]; if(pixelFormat == kAvnRgba8888) desc.pixelFormat = MTLPixelFormatRGBA8Unorm; @@ -106,13 +107,12 @@ public: desc.mipmapLevelCount = 1; desc.sampleCount = 1; desc.usage = MTLTextureUsageShaderRead | MTLTextureUsageRenderTarget; - + auto texture = [device newTextureWithDescriptor:desc iosurface:surf plane:0]; if(texture == nullptr) return E_FAIL; *ppv = new AvnMetalTexture(texture); return S_OK; - } HRESULT ImportSharedEvent(void *mtlSharedEventInstance, IAvnMTLSharedEvent**ppv) override { @@ -132,11 +132,12 @@ public: HRESULT SignalOrWait(IAvnMTLSharedEvent *ev, uint64_t value, bool wait) { + START_ARP_CALL; if (@available(macOS 12.0, *)) { auto e = dynamic_cast(ev); if(e == nullptr) - return E_FAIL;; + return E_FAIL; auto buf = [queue commandBuffer]; if(wait) [buf encodeWaitForEvent:e->GetEvent() value:value]; @@ -204,6 +205,7 @@ public: ~AvnMetalRenderSession() { + START_ARP_CALL; auto buffer = [_queue commandBuffer]; [buffer presentDrawable: _drawable]; [buffer commit]; @@ -227,6 +229,7 @@ public: } HRESULT BeginDrawing(IAvnMetalRenderingSession **ret) override { + START_COM_ARP_CALL; if([NSThread isMainThread]) { // Flush all existing rendering @@ -289,7 +292,7 @@ class AvnMetalDisplay : public ComSingleObject false; @@ -118,14 +118,17 @@ internal class SkiaMetalGpu : ISkiaGpu private readonly SkiaMetalGpu _gpu; private SKSurface? _surface; private IMetalPlatformSurfaceRenderingSession? _session; + private GRBackendRenderTarget? _backendTarget; - public SkiaMetalRenderSession(SkiaMetalGpu gpu, + public SkiaMetalRenderSession(SkiaMetalGpu gpu, SKSurface surface, - IMetalPlatformSurfaceRenderingSession session) + IMetalPlatformSurfaceRenderingSession session, + GRBackendRenderTarget backendTarget) { _gpu = gpu; _surface = surface; _session = session; + _backendTarget = backendTarget; } public void Dispose() @@ -133,11 +136,13 @@ internal class SkiaMetalGpu : ISkiaGpu _surface?.Canvas.Flush(); _surface?.Flush(); _gpu._context?.Flush(); - + _surface?.Dispose(); _surface = null; _session?.Dispose(); _session = null; + _backendTarget?.Dispose(); + _backendTarget = null; } public GRContext GrContext => _gpu._context!; From d8a0008ef03292ee60407b974cb63727165d7c41 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 9 Mar 2026 21:00:37 +0900 Subject: [PATCH 04/50] [Navigation] Fire Page lifecycle events after transitions complete, not before (#20826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Send Page lifecycle events after page transitions * Cleanup * Moved the push lifecycle calls out of ExecutePushCore --------- Co-authored-by: Javier Suárez Ruiz --- src/Avalonia.Controls/Page/NavigationPage.cs | 86 +++++++-- .../NavigationPageTests.cs | 164 ++++++++++++++++++ 2 files changed, 231 insertions(+), 19 deletions(-) diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index 2fb5235ff7..dd14d71a04 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -43,6 +43,7 @@ namespace Avalonia.Controls private ContentPresenter? _pagePresenter; private ContentPresenter? _pageBackPresenter; private CancellationTokenSource? _currentTransition; + private Task _lastPageTransitionTask = Task.CompletedTask; private CancellationTokenSource? _currentModalTransition; private Border? _navBar; private Border? _navBarShadow; @@ -769,15 +770,13 @@ namespace Avalonia.Controls page.SetInNavigationPage(true); UpdateActivePage(); - - previousPage?.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Push)); - page.SendNavigatedTo(new NavigatedToEventArgs(previousPage, NavigationType.Push)); - Pushed?.Invoke(this, new NavigationEventArgs(page, NavigationType.Push)); } /// - /// Performs the stack mutation and lifecycle events for a pop. The visual transition runs - /// subsequently via . + /// Performs the stack mutation for a pop. The visual transition runs + /// subsequently via . Callers are responsible + /// for firing lifecycle events via + /// after awaiting the page transition where possible. /// private Page? ExecutePopCore() { @@ -810,11 +809,6 @@ namespace Avalonia.Controls { old.Navigation = null; old.SetInNavigationPage(false); - - var newCurrentPage = CurrentPage; - old.SendNavigatedFrom(new NavigatedFromEventArgs(newCurrentPage, NavigationType.Pop)); - newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(old, NavigationType.Pop)); - Popped?.Invoke(this, new NavigationEventArgs(old, NavigationType.Pop)); } return old; @@ -844,6 +838,12 @@ namespace Avalonia.Controls } ExecutePushCore(page, previousPage); + + await AwaitPageTransitionAsync(); + + previousPage?.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Push)); + page.SendNavigatedTo(new NavigatedToEventArgs(previousPage, NavigationType.Push)); + Pushed?.Invoke(this, new NavigationEventArgs(page, NavigationType.Push)); } finally { @@ -886,7 +886,14 @@ namespace Avalonia.Controls return null; } - return ExecutePopCore(); + var old = ExecutePopCore(); + + await AwaitPageTransitionAsync(); + + if (old != null) + SendPopLifecycleEvents(old, NavigationType.Pop); + + return old; } finally { @@ -931,6 +938,7 @@ namespace Avalonia.Controls } bool isIncc = Pages is INotifyCollectionChanged; + var poppedPages = new List(); void TearDownPopped(Page popped) { @@ -939,8 +947,7 @@ namespace Avalonia.Controls LogicalChildren.Remove(poppedLogical); popped.Navigation = null; popped.SetInNavigationPage(false); - popped.SendNavigatedFrom(new NavigatedFromEventArgs(rootPage, NavigationType.PopToRoot)); - Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.PopToRoot)); + poppedPages.Add(popped); } if (Pages is Stack stack) @@ -962,6 +969,14 @@ namespace Avalonia.Controls _isPop = true; UpdateActivePage(); + await AwaitPageTransitionAsync(); + + foreach (var popped in poppedPages) + { + popped.SendNavigatedFrom(new NavigatedFromEventArgs(rootPage, NavigationType.PopToRoot)); + Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.PopToRoot)); + } + var newCurrentPage = CurrentPage; if (newCurrentPage != null) @@ -1013,6 +1028,7 @@ namespace Avalonia.Controls } bool isIncc = Pages is INotifyCollectionChanged; + var poppedPages = new List(); void TearDownPopped(Page popped) { @@ -1021,8 +1037,7 @@ namespace Avalonia.Controls LogicalChildren.Remove(poppedLogical); popped.Navigation = null; popped.SetInNavigationPage(false); - popped.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Pop)); - Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.Pop)); + poppedPages.Add(popped); } if (Pages is Stack stack) @@ -1044,6 +1059,14 @@ namespace Avalonia.Controls _isPop = true; UpdateActivePage(); + await AwaitPageTransitionAsync(); + + foreach (var popped in poppedPages) + { + popped.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Pop)); + Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.Pop)); + } + var newCurrentPage = CurrentPage; if (newCurrentPage != null) { @@ -1356,7 +1379,9 @@ namespace Avalonia.Controls { if (stack.Count > 0 && ReferenceEquals(stack.Peek(), page)) { - ExecutePopCore(); + var old = ExecutePopCore(); + if (old != null) + SendPopLifecycleEvents(old, NavigationType.Pop); PageRemoved?.Invoke(this, new PageRemovedEventArgs(page)); return; } @@ -1387,7 +1412,9 @@ namespace Avalonia.Controls if (idx == list.Count - 1) { - ExecutePopCore(); + var old = ExecutePopCore(); + if (old != null) + SendPopLifecycleEvents(old, NavigationType.Pop); PageRemoved?.Invoke(this, new PageRemovedEventArgs(page)); return; } @@ -1595,12 +1622,14 @@ namespace Avalonia.Controls oldPresenter.ZIndex = 0; } - _ = RunPageTransitionAsync(resolvedTransition, oldPresenter, newPresenter, !isPop, cancel.Token); + _lastPageTransitionTask = RunPageTransitionAsync(resolvedTransition, oldPresenter, newPresenter, !isPop, cancel.Token); (_pagePresenter, _pageBackPresenter) = (newPresenter, oldPresenter); } else { + _lastPageTransitionTask = Task.CompletedTask; + _pagePresenter.Content = page; _pagePresenter.IsVisible = page != null; _pagePresenter.ZIndex = 0; @@ -1686,6 +1715,25 @@ namespace Avalonia.Controls from.Opacity = 1; } + private Task AwaitPageTransitionAsync() + { + var task = _lastPageTransitionTask; + _lastPageTransitionTask = Task.CompletedTask; + return task; + } + + /// + /// Fires lifecycle events after a pop: SendNavigatedFrom on the old page, + /// SendNavigatedTo on the new current page, and raises the Popped event. + /// + private void SendPopLifecycleEvents(Page oldPage, NavigationType navigationType) + { + var newCurrentPage = CurrentPage; + oldPage.SendNavigatedFrom(new NavigatedFromEventArgs(newCurrentPage, navigationType)); + newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(oldPage, navigationType)); + Popped?.Invoke(this, new NavigationEventArgs(oldPage, navigationType)); + } + /// /// Swaps the top of the navigation stack with . /// diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs index 8f3055a2fd..9602256fe8 100644 --- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.UnitTests; @@ -1574,4 +1578,164 @@ public class NavigationPageTests } } + public class LifecycleAfterTransitionTests : ScopedTestBase + { + [Fact] + public async Task PushAsync_LifecycleEvents_FireAfterTransition() + { + var tcs = new TaskCompletionSource(); + var transition = new ControllableTransition(tcs.Task); + var nav = CreateNavigationPage(transition); + + var root = new ContentPage { Header = "Root" }; + await nav.PushAsync(root); + + bool navigatedFromDuringTransition = false; + bool navigatedToDuringTransition = false; + bool pushedDuringTransition = false; + + var second = new ContentPage { Header = "Second" }; + root.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + second.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted; + nav.Pushed += (_, _) => pushedDuringTransition = !tcs.Task.IsCompleted; + + var pushTask = nav.PushAsync(second); + + tcs.SetResult(); + await pushTask; + + Assert.False(navigatedFromDuringTransition); + Assert.False(navigatedToDuringTransition); + Assert.False(pushedDuringTransition); + } + + [Fact] + public async Task PopAsync_LifecycleEvents_FireAfterTransition() + { + var tcs = new TaskCompletionSource(); + var nav = CreateNavigationPage(null); + + var root = new ContentPage { Header = "Root" }; + var top = new ContentPage { Header = "Top" }; + await nav.PushAsync(root); + await nav.PushAsync(top); + + nav.PageTransition = new ControllableTransition(tcs.Task); + + bool navigatedFromDuringTransition = false; + bool navigatedToDuringTransition = false; + bool poppedDuringTransition = false; + + top.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + root.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted; + nav.Popped += (_, _) => poppedDuringTransition = !tcs.Task.IsCompleted; + + var popTask = nav.PopAsync(); + + tcs.SetResult(); + await popTask; + + Assert.False(navigatedFromDuringTransition); + Assert.False(navigatedToDuringTransition); + Assert.False(poppedDuringTransition); + } + + [Fact] + public async Task PopToRootAsync_LifecycleEvents_FireAfterTransition() + { + var tcs = new TaskCompletionSource(); + var nav = CreateNavigationPage(null); + + var root = new ContentPage { Header = "Root" }; + var second = new ContentPage { Header = "Second" }; + var third = new ContentPage { Header = "Third" }; + await nav.PushAsync(root); + await nav.PushAsync(second); + await nav.PushAsync(third); + + nav.PageTransition = new ControllableTransition(tcs.Task); + + bool navigatedFromDuringTransition = false; + bool navigatedToDuringTransition = false; + bool poppedToRootDuringTransition = false; + + second.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + third.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + root.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted; + nav.PoppedToRoot += (_, _) => poppedToRootDuringTransition = !tcs.Task.IsCompleted; + + var popTask = nav.PopToRootAsync(); + + tcs.SetResult(); + await popTask; + + Assert.False(navigatedFromDuringTransition); + Assert.False(navigatedToDuringTransition); + Assert.False(poppedToRootDuringTransition); + } + + private static NavigationPage CreateNavigationPage(IPageTransition? transition) + { + var nav = new NavigationPage + { + PageTransition = transition, + Template = NavigationPageTemplate() + }; + var root = new TestRoot { Child = nav }; + root.LayoutManager.ExecuteInitialLayoutPass(); + return nav; + } + + private static IControlTemplate NavigationPageTemplate() + { + return new FuncControlTemplate((parent, ns) => + { + var contentHost = new Panel + { + Name = "PART_ContentHost", + Children = + { + new ContentPresenter { Name = "PART_PageBackPresenter" }.RegisterInNameScope(ns), + new ContentPresenter { Name = "PART_PagePresenter" }.RegisterInNameScope(ns), + } + }.RegisterInNameScope(ns); + + return new Panel + { + Children = + { + new Border + { + Name = "PART_NavigationBar", + Child = new Button { Name = "PART_BackButton" }.RegisterInNameScope(ns) + }.RegisterInNameScope(ns), + contentHost, + new ContentPresenter { Name = "PART_TopCommandBar" }.RegisterInNameScope(ns), + new ContentPresenter { Name = "PART_ModalBackPresenter" }.RegisterInNameScope(ns), + new ContentPresenter { Name = "PART_ModalPresenter" }.RegisterInNameScope(ns), + } + }; + }); + } + + private class ControllableTransition : IPageTransition + { + private readonly Task _gate; + + public ControllableTransition(Task gate) + { + _gate = gate; + } + + public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (to != null) + to.IsVisible = true; + await _gate; + if (from != null) + from.IsVisible = false; + } + } + } + } From 425346612ce76dd15c21731a057f152ed7fed7ce Mon Sep 17 00:00:00 2001 From: mfkl Date: Mon, 9 Mar 2026 23:41:43 +0700 Subject: [PATCH 05/50] Fix pixel rounding and visual transforms in NativeControlHost (#20786) Ensure that native control bounds are properly rounded when UseLayoutRounding is enabled. This prevents alignment issues with Avalonia's visual tree, especially at non-integer scaling factors. The calculation now uses LayoutHelper.RoundLayoutValue for each edge of the transformed rectangle, matching how Avalonia rounds other layout elements. --- src/Avalonia.Controls/NativeControlHost.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Avalonia.Controls/NativeControlHost.cs b/src/Avalonia.Controls/NativeControlHost.cs index 8328e0fd56..e36bec652c 100644 --- a/src/Avalonia.Controls/NativeControlHost.cs +++ b/src/Avalonia.Controls/NativeControlHost.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Avalonia.Automation.Peers; using Avalonia.Controls.Automation.Peers; using Avalonia.Controls.Platform; +using Avalonia.Layout; using Avalonia.Platform; using Avalonia.Rendering; using Avalonia.Threading; @@ -152,6 +153,18 @@ namespace Avalonia.Controls if (transformToVisual == null) return null; var transformedRect = new Rect(default, bounds.Size).TransformToAABB(transformToVisual.Value); + // Transformed rect should be pixel-rounded if layout rounding is enabled. + // This is important for native controls to align correctly with Avalonia's visual tree. + if (UseLayoutRounding) + { + var scale = LayoutHelper.GetLayoutScale(this); + var left = LayoutHelper.RoundLayoutValue(transformedRect.X, scale); + var top = LayoutHelper.RoundLayoutValue(transformedRect.Y, scale); + var right = LayoutHelper.RoundLayoutValue(transformedRect.Right, scale); + var bottom = LayoutHelper.RoundLayoutValue(transformedRect.Bottom, scale); + transformedRect = new Rect(new Point(left, top), new Point(right, bottom)); + } + return transformedRect; } From f13bfe6c1b34a00c999601b4df288a9becb6b0d7 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Tue, 10 Mar 2026 09:01:04 +0000 Subject: [PATCH 06/50] Find perfect match before nearest in font collection (#20851) * Find perfect match before nearest in font collection * Update API suppressions * Address review --- api/Avalonia.nupkg.xml | 16 +++++- .../Media/Fonts/FontCollectionBase.cs | 45 ++++++++++++----- .../Media/Fonts/SystemFontCollection.cs | 15 ++++-- .../Assets/Inter-Bold.ttf | Bin 0 -> 316100 bytes .../Media/CustomFontCollectionTests.cs | 46 ++++++++++++------ .../Media/FontManagerTests.cs | 24 +++++++++ 6 files changed, 113 insertions(+), 33 deletions(-) create mode 100644 tests/Avalonia.RenderTests/Assets/Inter-Bold.ttf diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 92f41c6606..146d6df001 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -1375,6 +1375,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Media.Fonts.FontCollectionBase.TryGetGlyphTypeface(System.String,Avalonia.Media.Fonts.FontCollectionKey,Avalonia.Media.GlyphTypeface@) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Media.Fonts.IFontCollection.Initialize(Avalonia.Platform.IFontManagerImpl) @@ -2773,6 +2779,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Media.Fonts.FontCollectionBase.TryGetGlyphTypeface(System.String,Avalonia.Media.Fonts.FontCollectionKey,Avalonia.Media.GlyphTypeface@) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Media.Fonts.IFontCollection.Initialize(Avalonia.Platform.IFontManagerImpl) @@ -4969,4 +4981,4 @@ baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll - \ No newline at end of file + diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs index 40176c88ff..6e4283d76e 100644 --- a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs +++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs @@ -168,7 +168,7 @@ namespace Avalonia.Media.Fonts var key = typeface.ToFontCollectionKey(); - return TryGetGlyphTypeface(familyName, key, out glyphTypeface); + return TryGetGlyphTypeface(familyName, key, allowNearestMatch: true, out glyphTypeface); } public virtual bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) @@ -455,25 +455,25 @@ namespace Avalonia.Media.Fonts /// find the best match based on the provided . /// The name of the font family to search for. This parameter is case-insensitive. /// The key representing the desired font collection attributes. + /// Whether to allow a nearest match (as opposed to only an exact match). /// When this method returns, contains the matching if a match is found; otherwise, /// . /// if a matching glyph typeface is found; otherwise, . - protected bool TryGetGlyphTypeface(string familyName, FontCollectionKey key, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) + protected bool TryGetGlyphTypeface( + string familyName, + FontCollectionKey key, + bool allowNearestMatch, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) { glyphTypeface = null; if (_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces)) { - if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && glyphTypeface != null) - { - return true; - } - - if (TryGetNearestMatch(glyphTypefaces, key, out glyphTypeface)) + if (TryGetMatch(glyphTypefaces, key, allowNearestMatch, out glyphTypeface, out var isNearestMatch)) { var matchedKey = glyphTypeface.ToFontCollectionKey(); - if (matchedKey != key) + if (isNearestMatch && matchedKey != key) { if (TryCreateSyntheticGlyphTypeface(glyphTypeface, key.Style, key.Weight, key.Stretch, out var syntheticGlyphTypeface)) { @@ -511,7 +511,7 @@ namespace Avalonia.Media.Fonts { // Exact match found in snapshot. Use the exact family name for lookup if (_glyphTypefaceCache.TryGetValue(snapshot[mid].Name, out var exactGlyphTypefaces) && - TryGetNearestMatch(exactGlyphTypefaces, key, out glyphTypeface)) + TryGetMatch(exactGlyphTypefaces, key, allowNearestMatch, out glyphTypeface, out _)) { return true; } @@ -549,7 +549,7 @@ namespace Avalonia.Media.Fonts } if (_glyphTypefaceCache.TryGetValue(fontFamily.Name, out glyphTypefaces) && - TryGetNearestMatch(glyphTypefaces, key, out glyphTypeface)) + TryGetMatch(glyphTypefaces, key, allowNearestMatch, out glyphTypeface, out _)) { return true; } @@ -559,6 +559,29 @@ namespace Avalonia.Media.Fonts return false; } + private bool TryGetMatch( + IDictionary glyphTypefaces, + FontCollectionKey key, + bool allowNearestMatch, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface, + out bool isNearestMatch) + { + if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && glyphTypeface is not null) + { + isNearestMatch = false; + return true; + } + + if (allowNearestMatch && TryGetNearestMatch(glyphTypefaces, key, out glyphTypeface)) + { + isNearestMatch = true; + return true; + } + + isNearestMatch = false; + return false; + } + /// /// Attempts to retrieve the nearest matching for the specified font key from the /// provided collection of glyph typefaces. diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index 3c81e9890f..1c79127ec3 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -29,14 +29,14 @@ namespace Avalonia.Media.Fonts FontStretch stretch, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) { var typeface = new Typeface(familyName, style, weight, stretch).Normalize(out familyName); + var key = typeface.ToFontCollectionKey(); - if (base.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface)) + // Find an exact match first + if (TryGetGlyphTypeface(familyName, key, allowNearestMatch: false, out glyphTypeface)) { return true; } - var key = typeface.ToFontCollectionKey(); - //Check cache first to avoid unnecessary calls to the font manager if (_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces) && glyphTypefaces.TryGetValue(key, out glyphTypeface)) { @@ -52,6 +52,13 @@ namespace Avalonia.Media.Fonts return false; } + // The font manager didn't return a perfect match either. Find the nearest match ourselves. + if (key != platformTypeface.ToFontCollectionKey() && + TryGetGlyphTypeface(familyName, key, allowNearestMatch: true, out glyphTypeface)) + { + return true; + } + glyphTypeface = GlyphTypeface.TryCreate(platformTypeface); if (glyphTypeface is null) { @@ -77,7 +84,7 @@ namespace Avalonia.Media.Fonts } //Requested glyph typeface should be in cache now - return base.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); + return TryGetGlyphTypeface(familyName, key, allowNearestMatch: false, out glyphTypeface); } public override bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) diff --git a/tests/Avalonia.RenderTests/Assets/Inter-Bold.ttf b/tests/Avalonia.RenderTests/Assets/Inter-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8e82c70d1081e2857ada1b73395d4f42c2e8adc9 GIT binary patch literal 316100 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�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

6=3(=QnPeuLDdti0n3-y(nd#Q;7fbjCmF@fSxxm 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 Date: Wed, 11 Mar 2026 00:23:41 +1100 Subject: [PATCH 07/50] Add automation landmarks to HamburgerMenu (#20857) --- .../HamburgerMenu/HamburgerMenu.xaml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml b/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml index cffecb5b7d..49972bdc64 100644 --- a/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml +++ b/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml @@ -73,7 +73,8 @@ CornerRadius="{TemplateBinding CornerRadius}" TextElement.FontFamily="{TemplateBinding FontFamily}" TextElement.FontSize="{TemplateBinding FontSize}" - TextElement.FontWeight="{TemplateBinding FontWeight}" /> + TextElement.FontWeight="{TemplateBinding FontWeight}" + AutomationProperties.LandmarkType="Main" /> @@ -182,7 +183,9 @@ HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}" VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}"> + HorizontalAlignment="Stretch" + AutomationProperties.ControlTypeOverride="List" + AutomationProperties.LandmarkType="Navigation"> - + + IsChecked="{Binding #PART_NavigationPane.IsPaneOpen, Mode=TwoWay}" + AutomationProperties.ControlTypeOverride="ListItem"> From 0f2760afce49fadefb0767b2cff6504a9ec77b79 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 10 Mar 2026 14:43:20 +0100 Subject: [PATCH 08/50] Fix `TabControl` `DataContext` issues when switching tabs (#20856) * Add failing tests for #18280 and #20845 Tests cover: - TabItem child DataContext binding not resolving (#20845) - DataContext binding not propagating to TabItem children (#20845) - DataContext binding not surviving tab switch round-trip (#20845) - UserControl content losing DataContext on tab switch (#18280) - Content temporarily getting wrong DataContext when switching tabs - Transition not applying new DataContext to old content Co-Authored-By: Claude Opus 4.6 * Fix TabControl DataContext issues (#18280, #20845) Add ContentPresenter.SetContentWithDataContext to atomically set Content and DataContext, preventing the intermediate state where setting Content to a Control clears DataContext and causes the content to briefly inherit the wrong DataContext from higher up the tree. TabControl.UpdateSelectedContent now uses this method, and the DataContext subscription no longer applies the new container's DataContext to the old content during page transitions. Co-Authored-By: Claude Opus 4.6 * Add tests for ContentTemplate with Control content DataContext When a TabItem has a ContentTemplate and its Content is a Control, the ContentPresenter should set DataContext to the content (so the template can bind to the control's properties), not the TabItem's DataContext. Co-Authored-By: Claude Opus 4.6 * Only use SetContentWithDataContext when no ContentTemplate is set When a ContentTemplate is present and content is a Control, the ContentPresenter should set DataContext = content so the template can bind to the control's properties. Only override DataContext with the container's DataContext when there's no template (i.e. the presenter displays the control directly). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../Presenters/ContentPresenter.cs | 49 +- src/Avalonia.Controls/TabControl.cs | 50 +- .../TabControlTests.cs | 432 ++++++++++++++++++ 3 files changed, 509 insertions(+), 22 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index a5079f8344..183882ca50 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -177,6 +177,7 @@ namespace Avalonia.Controls.Presenters private Control? _child; private bool _createdChild; private IRecyclingDataTemplate? _recyclingDataTemplate; + private (bool IsSet, object? Value) _overrideDataContext; private readonly BorderRenderHelper _borderRenderer = new BorderRenderHelper(); ///

@@ -420,6 +421,39 @@ namespace Avalonia.Controls.Presenters /// internal IContentPresenterHost? Host { get; private set; } + /// + /// Sets the and properties atomically, + /// ensuring that the content's DataContext is never temporarily set to an incorrect value. + /// + /// The new content. + /// The DataContext to set on the presenter. + /// + /// When is set to a , the presenter normally + /// clears its to allow the content to inherit it. This method + /// overrides that behavior, setting the to the specified value + /// before updating the child, preventing any intermediate state where the content could + /// inherit an incorrect DataContext from higher up the tree. + /// + internal void SetContentWithDataContext(object? content, object? dataContext) + { + _overrideDataContext = (true, dataContext); + + try + { + SetCurrentValue(ContentProperty, content); + } + finally + { + // If Content didn't change, UpdateChild wasn't called and the + // override wasn't consumed. Apply the DataContext directly. + if (_overrideDataContext.IsSet) + { + _overrideDataContext = default; + DataContext = dataContext; + } + } + } + /// public sealed override void ApplyTemplate() { @@ -484,8 +518,19 @@ namespace Avalonia.Controls.Presenters } } - // Set the DataContext if the data isn't a control. - if (contentTemplate is { } || !(content is Control)) + // Consume the override immediately so any reentrant/cascading calls + // to UpdateChild don't incorrectly apply the stale override. + var overrideDataContext = _overrideDataContext; + _overrideDataContext = default; + + // Set the DataContext: use the caller-provided override if set, + // otherwise set to content when a template is present or content + // isn't a control, or clear for template-less control content. + if (overrideDataContext.IsSet) + { + DataContext = overrideDataContext.Value; + } + else if (contentTemplate is { } || !(content is Control)) { DataContext = content; } diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index f0c624489f..67274247c2 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; @@ -297,8 +296,6 @@ namespace Avalonia.Controls _selectedItemSubscriptions = new CompositeDisposable( container.GetObservable(ContentControl.ContentProperty).Subscribe(content => { - var contentElement = content as StyledElement; - var contentDataContext = contentElement?.DataContext; SelectedContent = content; if (isInitialFire && shouldTransition) @@ -306,11 +303,13 @@ namespace Avalonia.Controls var template = SelectContentTemplate(container.GetValue(ContentControl.ContentTemplateProperty)); SelectedContentTemplate = template; - _contentPresenter2!.Content = content; - _contentPresenter2.ContentTemplate = template; - _contentPresenter2.IsVisible = true; - if (contentElement is not null && contentElement.DataContext != contentDataContext) - _contentPresenter2.DataContext = contentDataContext; + _contentPresenter2!.ContentTemplate = template; + _contentPresenter2!.IsVisible = true; + + if (content is Control && template is null) + _contentPresenter2.SetContentWithDataContext(content, container.DataContext); + else + _contentPresenter2.Content = content; _pendingForward = forward; _shouldAnimate = true; @@ -320,18 +319,11 @@ namespace Avalonia.Controls { if (ContentPart != null) { - ContentPart.Content = content; - // When ContentPart displays a Control, it doesn't set its - // DataContext to that of the Control's. If the content doesn't - // set a DataContext it gets inherited from the TabControl. - // Work around this by setting ContentPart's DataContext to - // the content's original DataContext (inherited from container). - if (contentElement is not null && - contentElement.DataContext != contentDataContext) - { - Debug.Assert(!contentElement.IsSet(DataContextProperty)); - ContentPart.DataContext = contentDataContext; - } + var template = SelectContentTemplate(container.GetValue(ContentControl.ContentTemplateProperty)); + if (content is Control && template is null) + ContentPart.SetContentWithDataContext(content, container.DataContext); + else + ContentPart.Content = content; } } @@ -342,6 +334,24 @@ namespace Avalonia.Controls SelectedContentTemplate = SelectContentTemplate(v); if (ContentPart != null && !_shouldAnimate) ContentPart.ContentTemplate = _selectedContentTemplate; + }), + container.GetObservable(StyledElement.DataContextProperty).Subscribe(dc => + { + // During a transition, ContentPart holds the old tab's content + // and _contentPresenter2 holds the new tab's content. Only update + // the presenter that is showing this container's content. + // Only override DataContext when there's no ContentTemplate; + // with a template, the presenter's DataContext should be the + // content itself (so the template can bind to it). + if (_contentPresenter2 is { IsVisible: true }) + { + if (_contentPresenter2.Content is Control && _contentPresenter2.ContentTemplate is null) + _contentPresenter2.DataContext = dc; + } + else if (ContentPart is { Content: Control } && ContentPart.ContentTemplate is null) + { + ContentPart.DataContext = dc; + } })); IDataTemplate? SelectContentTemplate(IDataTemplate? containerTemplate) => containerTemplate ?? ContentTemplate; diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 62c453a18b..8bad019c2b 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -1046,6 +1046,438 @@ namespace Avalonia.Controls.UnitTests assetLoader: new StandardAssetLoader())); } + [Fact] + public void Switching_Tab_Should_Preserve_DataContext_Binding_On_UserControl_Content() + { + // Issue #18280: When switching tabs, a UserControl inside a TabItem has its + // DataContext set to null, causing two-way bindings on child controls (like + // DataGrid.SelectedItem) to propagate null back to the view model. + // Verify that after switching away and back, the DataContext binding still + // resolves correctly. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new TabDataContextViewModel { SelectedItem = "Item1" }; + + // Create a UserControl with an explicit DataContext binding, + // matching the issue scenario. + var userControl = new UserControl + { + [~UserControl.DataContextProperty] = new Binding("SelectedItem"), + }; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + Content = userControl, + }, + new TabItem + { + Header = "Tab2", + Content = "Other content", + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // Verify initial state + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Item1", userControl.DataContext); + + // Switch to second tab and back + target.SelectedIndex = 1; + target.SelectedIndex = 0; + + // The UserControl's DataContext binding should still resolve correctly. + Assert.Equal("Item1", userControl.DataContext); + + // Verify the binding is still live by changing the source property. + viewModel.SelectedItem = "Item2"; + Assert.Equal("Item2", userControl.DataContext); + } + + [Fact] + public void TabItem_Child_DataContext_Binding_Should_Work() + { + // Issue #20845: When a DataContext binding is placed on the child of a TabItem, + // the DataContext is null. The binding hasn't resolved when the content's + // DataContext is captured in UpdateSelectedContent, so the captured value is null. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + tab1View.Bind(UserControl.DataContextProperty, new Binding("Tab1")); + + // Add a child TextBlock that binds to a property on Tab1ViewModel. + var textBlock = new TextBlock(); + textBlock.Bind(TextBlock.TextProperty, new Binding("Name")); + tab1View.Content = textBlock; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + Content = tab1View, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // The UserControl's DataContext should be the Tab1ViewModel. + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // The TextBlock should display the Name from Tab1ViewModel. + Assert.Equal("Tab 1 message here", textBlock.Text); + } + + [Fact] + public void TabItem_Child_With_DataContext_Binding_Should_Propagate_To_Children() + { + // Issue #20845 (comment): Putting the DataContext binding on the TabItem itself + // is also broken. The child should inherit the TabItem's DataContext. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var textBlock = new TextBlock(); + textBlock.Bind(TextBlock.TextProperty, new Binding("Name")); + var tab1View = new UserControl { Content = textBlock }; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + Content = tab1View, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // The TabItem's DataContext should be the Tab1ViewModel. + var tabItem = (TabItem)target.Items[0]!; + Assert.Same(viewModel.Tab1, tabItem.DataContext); + + // The UserControl should inherit the TabItem's DataContext. + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // The TextBlock should display the Name from Tab1ViewModel. + Assert.Equal("Tab 1 message here", textBlock.Text); + } + + [Fact] + public void Switching_Tabs_Should_Not_Null_Out_DataContext_Bound_Properties() + { + // Issue #20845: DataContext binding should survive tab switches. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + tab1View.Bind(UserControl.DataContextProperty, new Binding("Tab1")); + var textBlock = new TextBlock(); + textBlock.Bind(TextBlock.TextProperty, new Binding("Name")); + tab1View.Content = textBlock; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + Content = tab1View, + }, + new TabItem + { + Header = "Tab2", + Content = "Other content", + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + Assert.Same(viewModel.Tab1, tab1View.DataContext); + Assert.Equal("Tab 1 message here", textBlock.Text); + + // Switch to tab 2 and back + target.SelectedIndex = 1; + target.SelectedIndex = 0; + + // DataContext binding should still be resolved correctly. + Assert.Same(viewModel.Tab1, tab1View.DataContext); + Assert.Equal("Tab 1 message here", textBlock.Text); + } + + [Fact] + public void Content_Should_Not_Temporarily_Get_Wrong_DataContext_When_Switching_Tabs() + { + // When ContentPart.Content is set, ContentPresenter.UpdateChild clears its + // DataContext before we can set it to the container's DataContext. This causes + // the content to briefly inherit TabControl's DataContext instead of TabItem's. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + var tab2View = new UserControl(); + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + Content = tab1View, + }, + new TabItem + { + Header = "Tab2", + [~TabItem.DataContextProperty] = new Binding("Tab2"), + Content = tab2View, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // Track all DataContext values the new content receives during the switch. + var dataContexts = new List(); + tab2View.PropertyChanged += (s, e) => + { + if (e.Property == StyledElement.DataContextProperty) + dataContexts.Add(e.NewValue); + }; + + target.SelectedIndex = 1; + + // tab2View should only have received the correct DataContext (Tab2ViewModel). + // It should NOT have temporarily received the TabControl's DataContext (MainViewModel). + Assert.All(dataContexts, dc => Assert.Same(viewModel.Tab2, dc)); + Assert.Same(viewModel.Tab2, tab2View.DataContext); + } + + [Fact] + public void Transition_Should_Not_Apply_New_DataContext_To_Old_Content() + { + // When a PageTransition is set, the old content stays in ContentPart while the + // new content goes into _contentPresenter2. The DataContext subscription for the + // new container should not update ContentPart's DataContext (which still holds + // the old content). + using var app = Start(); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + var tab2View = new UserControl(); + + var transition = new Mock(); + transition + .Setup(t => t.Start( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var target = new TabControl + { + PageTransition = transition.Object, + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + Content = tab1View, + }, + new TabItem + { + Header = "Tab2", + [~TabItem.DataContextProperty] = new Binding("Tab2"), + Content = tab2View, + }, + }, + }; + + var root = CreateRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // Track all DataContext values the OLD content receives during the transition. + var oldContentDataContexts = new List(); + tab1View.PropertyChanged += (s, e) => + { + if (e.Property == StyledElement.DataContextProperty) + oldContentDataContexts.Add(e.NewValue); + }; + + // Switch tab — triggers transition + target.SelectedIndex = 1; + root.LayoutManager.ExecuteLayoutPass(); + + // The old content (tab1View) should NOT have received Tab2's DataContext. + Assert.DoesNotContain(viewModel.Tab2, oldContentDataContexts); + } + + [Fact] + public void ContentTemplate_With_Control_Content_Should_Set_DataContext_To_Content() + { + // When a TabItem has a ContentTemplate and its Content is a Control, the + // ContentPresenter should set DataContext = content (so the template can bind + // to the control's properties), not the TabItem's DataContext. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + var userControl = new UserControl { Tag = "my-content" }; + + TextBlock? templateChild = null; + var contentTemplate = new FuncDataTemplate((x, _) => + { + templateChild = new TextBlock(); + templateChild.Bind(TextBlock.TextProperty, new Binding("Tag")); + return templateChild; + }); + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + ContentTemplate = contentTemplate, + Content = userControl, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // The ContentPresenter's DataContext should be the content (UserControl), + // not the TabItem's DataContext (Tab1ViewModel), because ContentTemplate is set. + Assert.Same(userControl, target.ContentPart!.DataContext); + Assert.NotNull(templateChild); + Assert.Equal("my-content", templateChild!.Text); + } + + [Fact] + public void ContentTemplate_With_Control_Content_Should_Set_DataContext_To_Content_After_Tab_Switch() + { + // Same as above but verifies the behavior after switching tabs. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + var userControl = new UserControl { Tag = "my-content" }; + + TextBlock? templateChild = null; + var contentTemplate = new FuncDataTemplate((x, _) => + { + templateChild = new TextBlock(); + templateChild.Bind(TextBlock.TextProperty, new Binding("Tag")); + return templateChild; + }); + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + ContentTemplate = contentTemplate, + Content = userControl, + }, + new TabItem + { + Header = "Tab2", + Content = "Other content", + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + Assert.Same(userControl, target.ContentPart!.DataContext); + + // Switch away and back. + target.SelectedIndex = 1; + target.SelectedIndex = 0; + + // DataContext should still be the content, not the TabItem's DataContext. + Assert.Same(userControl, target.ContentPart!.DataContext); + Assert.NotNull(templateChild); + Assert.Equal("my-content", templateChild!.Text); + } + + private class TabDataContextViewModel : NotifyingBase + { + private string? _selectedItem; + + public string? SelectedItem + { + get => _selectedItem; + set => SetField(ref _selectedItem, value); + } + } + + private class MainViewModel + { + public Tab1ViewModel Tab1 { get; set; } = new(); + public Tab2ViewModel Tab2 { get; set; } = new(); + } + + private class Tab1ViewModel + { + public string Name { get; set; } = "Tab 1 message here"; + } + + private class Tab2ViewModel + { + public string Name { get; set; } = "Tab 2 message here"; + } + private class Item { public Item(string value) From 9a21a48aa40ef102fe13a00c6b3c2c893b770ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Tue, 10 Mar 2026 17:47:30 +0100 Subject: [PATCH 09/50] [Feature] Add PipsPager Control (#20660) * Added PipsPager control * Added tests * Added render tests * Added samples * More improvements * More tests * Added more samples * Fix formatting * Updated Automation * Small optimization * More changes * Changes based on feedback * Fix build errors * More changes * Updated samples * Fixes * More changes * Fix build * More changes * More changes * More tests * More constants --- samples/ControlCatalog/MainView.xaml | 3 + .../PipsPager/PipsPagerCarouselPage.xaml | 50 ++ .../PipsPager/PipsPagerCarouselPage.xaml.cs | 11 + .../PipsPager/PipsPagerCustomButtonsPage.xaml | 78 +++ .../PipsPagerCustomButtonsPage.xaml.cs | 11 + .../PipsPager/PipsPagerCustomColorsPage.xaml | 59 ++ .../PipsPagerCustomColorsPage.xaml.cs | 11 + .../PipsPagerCustomTemplatesPage.xaml | 197 ++++++ .../PipsPagerCustomTemplatesPage.xaml.cs | 11 + .../Pages/PipsPager/PipsPagerEventsPage.xaml | 35 + .../PipsPager/PipsPagerEventsPage.xaml.cs | 29 + .../PipsPagerGettingStartedPage.xaml | 46 ++ .../PipsPagerGettingStartedPage.xaml.cs | 11 + .../PipsPagerLargeCollectionPage.xaml | 52 ++ .../PipsPagerLargeCollectionPage.xaml.cs | 11 + .../ControlCatalog/Pages/PipsPagerPage.xaml | 11 + .../Pages/PipsPagerPage.xaml.cs | 47 ++ .../Peers/PipsPagerAutomationPeer.cs | 85 +++ src/Avalonia.Controls/PipsPager/PipsPager.cs | 662 ++++++++++++++++++ .../PipsPagerSelectedIndexChangedEventArgs.cs | 27 + .../PipsPager/PipsPagerTemplateSettings.cs | 30 + .../Controls/FluentControls.xaml | 1 + .../Controls/PipsPager.xaml | 312 +++++++++ .../Controls/PipsPager.xaml | 143 ++++ .../Controls/SimpleControls.xaml | 1 + .../PipsPagerTests.cs | 578 +++++++++++++++ .../Controls/PipsPagerTests.cs | 168 +++++ .../PipsPager/PipsPager_Default.expected.png | Bin 0 -> 1498 bytes .../PipsPager_Preselected_Index.expected.png | Bin 0 -> 1401 bytes 29 files changed, 2680 insertions(+) create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/PipsPagerPage.xaml create mode 100644 samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs create mode 100644 src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs create mode 100644 src/Avalonia.Controls/PipsPager/PipsPager.cs create mode 100644 src/Avalonia.Controls/PipsPager/PipsPagerSelectedIndexChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/PipsPager/PipsPagerTemplateSettings.cs create mode 100644 src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml create mode 100644 src/Avalonia.Themes.Simple/Controls/PipsPager.xaml create mode 100644 tests/Avalonia.Controls.UnitTests/PipsPagerTests.cs create mode 100644 tests/Avalonia.RenderTests/Controls/PipsPagerTests.cs create mode 100644 tests/TestFiles/Skia/Controls/PipsPager/PipsPager_Default.expected.png create mode 100644 tests/TestFiles/Skia/Controls/PipsPager/PipsPager_Preselected_Index.expected.png diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index c8c496b50c..b6249fe17f 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -161,6 +161,9 @@ + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml new file mode 100644 index 0000000000..b75b5c37c2 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs new file mode 100644 index 0000000000..f42bb10ce9 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerCarouselPage : UserControl +{ + public PipsPagerCarouselPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml new file mode 100644 index 0000000000..8b9856424d --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs new file mode 100644 index 0000000000..4fc74995bc --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerCustomButtonsPage : UserControl +{ + public PipsPagerCustomButtonsPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml new file mode 100644 index 0000000000..260536d7ae --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs new file mode 100644 index 0000000000..a9276f11b0 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerCustomColorsPage : UserControl +{ + public PipsPagerCustomColorsPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml new file mode 100644 index 0000000000..fe748b248d --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs new file mode 100644 index 0000000000..cce9e6c5e5 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerCustomTemplatesPage : UserControl +{ + public PipsPagerCustomTemplatesPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml new file mode 100644 index 0000000000..a69c101687 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs new file mode 100644 index 0000000000..d97165397a --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs @@ -0,0 +1,29 @@ +using System.Collections.ObjectModel; +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerEventsPage : UserControl +{ + private readonly ObservableCollection _events = new(); + + public PipsPagerEventsPage() + { + InitializeComponent(); + + EventLog.ItemsSource = _events; + + EventPager.PropertyChanged += (_, e) => + { + if (e.Property != PipsPager.SelectedPageIndexProperty) + return; + + var newIndex = (int)e.NewValue!; + StatusText.Text = $"Selected: {newIndex}"; + _events.Insert(0, $"SelectedPageIndex changed to {newIndex}"); + + if (_events.Count > 20) + _events.RemoveAt(_events.Count - 1); + }; + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml new file mode 100644 index 0000000000..5eead2fb31 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs new file mode 100644 index 0000000000..80a1569f30 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerGettingStartedPage : UserControl +{ + public PipsPagerGettingStartedPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml new file mode 100644 index 0000000000..5cc416d413 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs new file mode 100644 index 0000000000..2dc936b544 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerLargeCollectionPage : UserControl +{ + public PipsPagerLargeCollectionPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPagerPage.xaml b/samples/ControlCatalog/Pages/PipsPagerPage.xaml new file mode 100644 index 0000000000..54112daae0 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs new file mode 100644 index 0000000000..8f27cc61f8 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs @@ -0,0 +1,47 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace ControlCatalog.Pages +{ + public partial class PipsPagerPage : UserControl + { + private static readonly (string Group, string Title, string Description, Func Factory)[] Demos = + { + ("Getting Started", "First Look", + "Default PipsPager with horizontal and vertical orientation, with and without navigation buttons.", + () => new PipsPagerGettingStartedPage()), + + ("Features", "Carousel Integration", + "Bind SelectedPageIndex to a Carousel's SelectedIndex for two-way synchronized page navigation.", + () => new PipsPagerCarouselPage()), + ("Features", "Large Collections", + "Use MaxVisiblePips to limit visible indicators when the page count is large. Pips scroll automatically.", + () => new PipsPagerLargeCollectionPage()), + ("Features", "Events", + "Monitor SelectedPageIndex changes to react to user navigation.", + () => new PipsPagerEventsPage()), + + ("Appearance", "Custom Colors", + "Override pip indicator colors using resource keys for normal, selected, and hover states.", + () => new PipsPagerCustomColorsPage()), + ("Appearance", "Custom Buttons", + "Replace the default chevron navigation buttons with custom styled buttons.", + () => new PipsPagerCustomButtonsPage()), + ("Appearance", "Custom Templates", + "Override pip item templates to create squares, pills, numbers, or any custom shape.", + () => new PipsPagerCustomTemplatesPage()), + }; + + public PipsPagerPage() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private async void OnLoaded(object? sender, RoutedEventArgs e) + { + await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null); + } + } +} diff --git a/src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs new file mode 100644 index 0000000000..b40a9b4159 --- /dev/null +++ b/src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using Avalonia.Automation.Provider; +using Avalonia.Controls; + +namespace Avalonia.Automation.Peers +{ + /// + /// An automation peer for . + /// + public class PipsPagerAutomationPeer : ControlAutomationPeer, ISelectionProvider + { + private ListBox? _pipsList; + + /// + /// Initializes a new instance of the class. + /// + /// The control associated with this peer. + public PipsPagerAutomationPeer(PipsPager owner) : base(owner) + { + owner.SelectedIndexChanged += OnSelectionChanged; + } + + /// + /// Gets the owner as a . + /// + private new PipsPager Owner => (PipsPager)base.Owner; + + /// + public bool CanSelectMultiple => false; + + /// + public bool IsSelectionRequired => true; + + /// + public IReadOnlyList GetSelection() + { + var result = new List(); + var owner = Owner; + + if (owner.SelectedPageIndex >= 0 && owner.SelectedPageIndex < owner.NumberOfPages) + { + _pipsList ??= owner.FindNameScope()?.Find("PART_PipsPagerList"); + + if (_pipsList != null) + { + var container = _pipsList.ContainerFromIndex(owner.SelectedPageIndex); + if (container is Control c) + { + var peer = GetOrCreate(c); + result.Add(peer); + } + } + } + + return result; + } + + /// + protected override AutomationControlType GetAutomationControlTypeCore() + { + return AutomationControlType.List; + } + + /// + protected override string GetClassNameCore() + { + return nameof(PipsPager); + } + + /// + protected override string? GetNameCore() + { + var name = base.GetNameCore(); + return string.IsNullOrWhiteSpace(name) ? "Pips Pager" : name; + } + + private void OnSelectionChanged(object? sender, Controls.PipsPagerSelectedIndexChangedEventArgs e) + { + RaisePropertyChangedEvent( + SelectionPatternIdentifiers.SelectionProperty, + e.OldIndex, + e.NewIndex); + } + } +} diff --git a/src/Avalonia.Controls/PipsPager/PipsPager.cs b/src/Avalonia.Controls/PipsPager/PipsPager.cs new file mode 100644 index 0000000000..b976df4826 --- /dev/null +++ b/src/Avalonia.Controls/PipsPager/PipsPager.cs @@ -0,0 +1,662 @@ +using System; +using System.Threading; +using Avalonia.Threading; +using Avalonia.Controls.Metadata; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Styling; +using System.Collections.Generic; + +namespace Avalonia.Controls +{ + /// + /// Represents a control that lets the user navigate through a paginated collection using a set of pips. + /// + [TemplatePart(PART_PreviousButton, typeof(Button))] + [TemplatePart(PART_NextButton, typeof(Button))] + [TemplatePart(PART_PipsPagerList, typeof(ListBox))] + [PseudoClasses(PC_FirstPage, PC_LastPage, PC_Vertical, PC_Horizontal)] + public class PipsPager : TemplatedControl + { + private const string PART_PreviousButton = "PART_PreviousButton"; + private const string PART_NextButton = "PART_NextButton"; + private const string PART_PipsPagerList = "PART_PipsPagerList"; + + private const string PC_FirstPage = ":first-page"; + private const string PC_LastPage = ":last-page"; + private const string PC_Vertical = ":vertical"; + private const string PC_Horizontal = ":horizontal"; + + private Button? _previousButton; + private Button? _nextButton; + private ListBox? _pipsPagerList; + private bool _scrollPending; + private bool _updatingPagerSize; + private bool _isInitialLoad; + private int _lastSelectedPageIndex; + private CancellationTokenSource? _scrollAnimationCts; + private PipsPagerTemplateSettings _templateSettings = new PipsPagerTemplateSettings(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxVisiblePipsProperty = + AvaloniaProperty.Register(nameof(MaxVisiblePips), 5); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsNextButtonVisibleProperty = + AvaloniaProperty.Register(nameof(IsNextButtonVisible), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty NumberOfPagesProperty = + AvaloniaProperty.Register(nameof(NumberOfPages)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register(nameof(Orientation), Orientation.Horizontal); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsPreviousButtonVisibleProperty = + AvaloniaProperty.Register(nameof(IsPreviousButtonVisible), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SelectedPageIndexProperty = + AvaloniaProperty.Register(nameof(SelectedPageIndex), + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly DirectProperty TemplateSettingsProperty = + AvaloniaProperty.RegisterDirect(nameof(TemplateSettings), + x => x.TemplateSettings); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PreviousButtonStyleProperty = + AvaloniaProperty.Register(nameof(PreviousButtonStyle)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty NextButtonStyleProperty = + AvaloniaProperty.Register(nameof(NextButtonStyle)); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent SelectedIndexChangedEvent = + RoutedEvent.Register(nameof(SelectedIndexChanged), RoutingStrategies.Bubble); + + /// + /// Occurs when the selected index has changed. + /// + public event EventHandler? SelectedIndexChanged + { + add => AddHandler(SelectedIndexChangedEvent, value); + remove => RemoveHandler(SelectedIndexChangedEvent, value); + } + + static PipsPager() + { + SelectedPageIndexProperty.Changed.AddClassHandler((x, e) => x.OnSelectedPageIndexChanged(e)); + NumberOfPagesProperty.Changed.AddClassHandler((x, e) => x.OnNumberOfPagesChanged(e)); + IsPreviousButtonVisibleProperty.Changed.AddClassHandler((x, e) => x.OnIsPreviousButtonVisibleChanged(e)); + IsNextButtonVisibleProperty.Changed.AddClassHandler((x, e) => x.OnIsNextButtonVisibleChanged(e)); + OrientationProperty.Changed.AddClassHandler((x, e) => x.OnOrientationChanged(e)); + MaxVisiblePipsProperty.Changed.AddClassHandler((x, e) => x.OnMaxVisiblePipsChanged(e)); + } + + /// + /// Initializes a new instance of . + /// + public PipsPager() + { + UpdatePseudoClasses(); + } + + /// + /// Gets or sets the maximum number of visible pips. + /// + public int MaxVisiblePips + { + get => GetValue(MaxVisiblePipsProperty); + set => SetValue(MaxVisiblePipsProperty, value); + } + + /// + /// Gets or sets the visibility of the next button. + /// + public bool IsNextButtonVisible + { + get => GetValue(IsNextButtonVisibleProperty); + set => SetValue(IsNextButtonVisibleProperty, value); + } + + /// + /// Gets or sets the number of pages. + /// + public int NumberOfPages + { + get => GetValue(NumberOfPagesProperty); + set => SetValue(NumberOfPagesProperty, value); + } + + /// + /// Gets or sets the orientation of the pips. + /// + public Orientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + /// + /// Gets or sets the visibility of the previous button. + /// + public bool IsPreviousButtonVisible + { + get => GetValue(IsPreviousButtonVisibleProperty); + set => SetValue(IsPreviousButtonVisibleProperty, value); + } + + /// + /// Gets or sets the current selected page index. + /// + public int SelectedPageIndex + { + get => GetValue(SelectedPageIndexProperty); + set => SetValue(SelectedPageIndexProperty, value); + } + + /// + /// Gets the template settings. + /// + public PipsPagerTemplateSettings TemplateSettings + { + get => _templateSettings; + private set => SetAndRaise(TemplateSettingsProperty, ref _templateSettings, value); + } + + /// + /// Gets or sets the style for the previous button. + /// + public ControlTheme? PreviousButtonStyle + { + get => GetValue(PreviousButtonStyleProperty); + set => SetValue(PreviousButtonStyleProperty, value); + } + + /// + /// Gets or sets the style for the next button. + /// + public ControlTheme? NextButtonStyle + { + get => GetValue(NextButtonStyleProperty); + set => SetValue(NextButtonStyleProperty, value); + } + + /// + protected override AutomationPeer OnCreateAutomationPeer() + { + return new PipsPagerAutomationPeer(this); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _scrollAnimationCts?.Cancel(); + _scrollAnimationCts?.Dispose(); + _scrollAnimationCts = null; + _isInitialLoad = true; + + // Unsubscribe from previous button events + if (_previousButton != null) + { + _previousButton.Click -= PreviousButton_Click; + } + + if (_nextButton != null) + { + _nextButton.Click -= NextButton_Click; + } + + // Unsubscribe from previous list events + if (_pipsPagerList != null) + { + _pipsPagerList.SizeChanged -= OnPipsPagerListSizeChanged; + _pipsPagerList.ContainerPrepared -= OnContainerPrepared; + _pipsPagerList.ContainerIndexChanged -= OnContainerIndexChanged; + } + + // Get template parts + _previousButton = e.NameScope.Find + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Avalonia.Themes.Simple/Controls/PipsPager.xaml b/src/Avalonia.Themes.Simple/Controls/PipsPager.xaml new file mode 100644 index 0000000000..388fc0e4d6 --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/PipsPager.xaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml index 11a25dde9d..e5ba9163b5 100644 --- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml +++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml @@ -40,6 +40,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/PipsPagerTests.cs b/tests/Avalonia.Controls.UnitTests/PipsPagerTests.cs new file mode 100644 index 0000000000..ffc0469ce3 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/PipsPagerTests.cs @@ -0,0 +1,578 @@ +using Avalonia.Input; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using System.Linq; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class PipsPagerTests : ScopedTestBase + { + [Fact] + public void NumberOfPages_Should_Update_Pips() + { + var target = new PipsPager(); + + target.NumberOfPages = 5; + + Assert.Equal(5, target.TemplateSettings.Pips.Count); + Assert.Equal(1, target.TemplateSettings.Pips[0]); + Assert.Equal(5, target.TemplateSettings.Pips[4]); + } + + [Fact] + public void Decreasing_NumberOfPages_Should_Update_Pips() + { + var target = new PipsPager(); + target.NumberOfPages = 5; + + target.NumberOfPages = 3; + + Assert.Equal(3, target.TemplateSettings.Pips.Count); + } + + [Fact] + public void Decreasing_NumberOfPages_Should_Update_SelectedPageIndex() + { + var target = new PipsPager(); + target.NumberOfPages = 5; + target.SelectedPageIndex = 4; + + target.NumberOfPages = 3; + + Assert.Equal(2, target.SelectedPageIndex); + } + + [Fact] + public void SelectedPageIndex_Should_Be_Clamped_To_Zero() + { + var target = new PipsPager(); + target.NumberOfPages = 5; + + target.SelectedPageIndex = -1; + + Assert.Equal(0, target.SelectedPageIndex); + } + + [Fact] + public void SelectedPageIndex_Change_Should_Raise_Event() + { + var target = new PipsPager(); + target.NumberOfPages = 5; + var raised = false; + target.SelectedIndexChanged += (s, e) => raised = true; + + target.SelectedPageIndex = 2; + + Assert.True(raised); + } + + [Fact] + public void Next_Button_Should_Increment_Index() + { + using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow); + + var target = new PipsPager + { + NumberOfPages = 5, + SelectedPageIndex = 1, + IsNextButtonVisible = true, + Template = GetTemplate() + }; + + var root = new TestRoot(target); + target.ApplyTemplate(); + + var nextButton = target.GetVisualDescendants().OfType
public class TabStripItem : ListBoxItem { - protected override void OnGotFocus(GotFocusEventArgs e) + protected override void OnGotFocus(FocusChangedEventArgs e) { base.OnGotFocus(e); UpdateSelectionFromEvent(e); diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index e93180fff4..2593c4b475 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -766,7 +766,7 @@ namespace Avalonia.Controls } } - protected override void OnGotFocus(GotFocusEventArgs e) + protected override void OnGotFocus(FocusChangedEventArgs e) { base.OnGotFocus(e); diff --git a/src/Avalonia.Controls/SelectableTextBlock.cs b/src/Avalonia.Controls/SelectableTextBlock.cs index f3532763f6..493c1631d4 100644 --- a/src/Avalonia.Controls/SelectableTextBlock.cs +++ b/src/Avalonia.Controls/SelectableTextBlock.cs @@ -160,14 +160,14 @@ namespace Avalonia.Controls SetCurrentValue(SelectionEndProperty, SelectionStart); } - protected override void OnGotFocus(GotFocusEventArgs e) + protected override void OnGotFocus(FocusChangedEventArgs e) { base.OnGotFocus(e); UpdateCommandStates(); } - protected override void OnLostFocus(RoutedEventArgs e) + protected override void OnLostFocus(FocusChangedEventArgs e) { base.OnLostFocus(e); diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index 67274247c2..cb619a941d 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -237,7 +237,7 @@ namespace Avalonia.Controls public override bool UpdateSelectionFromEvent(Control container, RoutedEventArgs eventArgs) { - if (eventArgs is GotFocusEventArgs { NavigationMethod: not NavigationMethod.Directional }) + if (eventArgs is FocusChangedEventArgs { NavigationMethod: not NavigationMethod.Directional }) { return false; } diff --git a/src/Avalonia.Controls/TabItem.cs b/src/Avalonia.Controls/TabItem.cs index 07f2cd6505..a4829c16ca 100644 --- a/src/Avalonia.Controls/TabItem.cs +++ b/src/Avalonia.Controls/TabItem.cs @@ -111,7 +111,7 @@ namespace Avalonia.Controls e.Handled = true; } - protected override void OnGotFocus(GotFocusEventArgs e) + protected override void OnGotFocus(FocusChangedEventArgs e) { base.OnGotFocus(e); UpdateSelectionFromEvent(e); diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 447a6a41fc..eadb54b58a 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1088,7 +1088,7 @@ namespace Avalonia.Controls CanPaste = !IsReadOnly; } - protected override void OnGotFocus(GotFocusEventArgs e) + protected override void OnGotFocus(FocusChangedEventArgs e) { base.OnGotFocus(e); @@ -1114,7 +1114,7 @@ namespace Avalonia.Controls _presenter?.ShowCaret(); } - protected override void OnLostFocus(RoutedEventArgs e) + protected override void OnLostFocus(FocusChangedEventArgs e) { base.OnLostFocus(e); diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 033be87c7f..d48997f390 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -550,7 +550,7 @@ namespace Avalonia.Controls } /// - protected override void OnGotFocus(GotFocusEventArgs e) + protected override void OnGotFocus(FocusChangedEventArgs e) { if (e.NavigationMethod == NavigationMethod.Directional) { diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Single.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Single.cs index f0f9b820f7..1c2d43e94f 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Single.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Single.cs @@ -35,7 +35,7 @@ namespace Avalonia.Controls.UnitTests Prepare(target); - target.Presenter!.Panel!.Children[0].RaiseEvent(new GotFocusEventArgs + target.Presenter!.Panel!.Children[0].RaiseEvent(new FocusChangedEventArgs(InputElement.GotFocusEvent) { NavigationMethod = NavigationMethod.Tab, }); @@ -57,7 +57,7 @@ namespace Avalonia.Controls.UnitTests AvaloniaLocator.CurrentMutable.Bind().ToConstant(new PlatformHotkeyConfiguration()); Prepare(target); - target.Presenter!.Panel!.Children[0].RaiseEvent(new GotFocusEventArgs + target.Presenter!.Panel!.Children[0].RaiseEvent(new FocusChangedEventArgs(InputElement.GotFocusEvent) { NavigationMethod = NavigationMethod.Directional, KeyModifiers = KeyModifiers.Control From 7d900944555bf63bd08a9ba5721ca04c6bea59b6 Mon Sep 17 00:00:00 2001 From: Melissa Date: Fri, 13 Mar 2026 09:17:45 +0100 Subject: [PATCH 15/50] Made GetMaxSizeFromConstraint a protected method (#20877) --- src/Avalonia.Controls/TextBlock.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index c70d06ae7f..5c798f6a83 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -363,7 +363,10 @@ namespace Avalonia.Controls internal bool HasComplexContent => Inlines != null && Inlines.Count > 0; - private protected Size GetMaxSizeFromConstraint() + /// + /// Gets the maximum available size based on the constraint of the control + /// + protected Size GetMaxSizeFromConstraint() { var maxWidth = double.IsNaN(_constraint.Width) ? 0.0 : _constraint.Width; var maxHeight = double.IsNaN(_constraint.Height) ? 0.0 : _constraint.Height; From 2ffb4d01e00130377d1015d3a52eb4a5978bb741 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 13 Mar 2026 17:24:20 +0900 Subject: [PATCH 16/50] Add /update-api and /api-diff commands (#20887) * Add update-api command * Api diff command * Missed flag * Restrict commands running on fork PRs * Add concurrency * Filter github.event.comment.author_association even before workflow started * Use steps.pr.outputs.sha * Only push api/ changes --- .github/workflows/api-diff.yml | 180 +++++++++++++++++++++++++++++++ .github/workflows/update-api.yml | 123 +++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 .github/workflows/api-diff.yml create mode 100644 .github/workflows/update-api.yml diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml new file mode 100644 index 0000000000..f855380f9e --- /dev/null +++ b/.github/workflows/api-diff.yml @@ -0,0 +1,180 @@ +name: Output API Diff + +on: + issue_comment: + types: [created] + +permissions: {} + +concurrency: + group: api-diff-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + api-diff: + name: Output API Diff + if: >- + github.event.issue.pull_request + && contains(github.event.comment.body, '/api-diff') + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - name: Check maintainer permission + uses: actions/github-script@v7 + with: + script: | + const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const allowed = ['admin', 'maintain', 'write']; + if (!allowed.includes(permLevel.permission)) { + core.setFailed(`User @${context.payload.comment.user.login} does not have write access.`); + } + + - name: Add reaction to acknowledge command + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + + - name: Get PR branch info + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Cannot run /api-diff on fork PRs — would execute untrusted code.'); + return; + } + core.setOutput('ref', pr.head.ref); + core.setOutput('sha', pr.head.sha); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Run OutputApiDiff + run: dotnet run --project ./nukebuild/_build.csproj -- OutputApiDiff + + - name: Post API diff as PR comment + if: always() && steps.pr.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const diffDir = path.join(process.env.GITHUB_WORKSPACE, 'artifacts', 'api-diff', 'markdown'); + const mergedPath = path.join(diffDir, '_diff.md'); + + let body; + if (fs.existsSync(mergedPath)) { + let diff = fs.readFileSync(mergedPath, 'utf8').trim(); + if (!diff || diff.toLowerCase().includes('no changes')) { + body = '### API Diff\n\n✅ No public API changes detected in this PR.'; + } else { + const MAX_COMMENT_LENGTH = 60000; // GitHub comment limit is 65536 + const header = '### API Diff\n\n'; + const footer = '\n\n---\n_Generated by `/api-diff` command._'; + const budget = MAX_COMMENT_LENGTH - header.length - footer.length; + + if (diff.length > budget) { + diff = diff.substring(0, budget) + '\n\n> ⚠️ Output truncated. See the [full workflow run](' + + `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + + ') for complete diff.'; + } + + body = header + diff + footer; + } + } else { + body = '### API Diff\n\n⚠️ No diff output was produced. Check the [workflow run](' + + `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + + ') for details.'; + } + + // Collapse into
if large + if (body.length > 2000) { + const inner = body; + body = '
\n📋 API Diff (click to expand)\n\n' + inner + '\n\n
'; + } + + // Update existing bot comment or create a new one + const marker = ''; + body = marker + '\n' + body; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find(c => c.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Add success reaction + if: success() + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket', + }); + + - name: Report failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1', + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ \`/api-diff\` failed. [See logs](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + }); diff --git a/.github/workflows/update-api.yml b/.github/workflows/update-api.yml new file mode 100644 index 0000000000..611a4ead50 --- /dev/null +++ b/.github/workflows/update-api.yml @@ -0,0 +1,123 @@ +name: Update API Suppressions + +on: + issue_comment: + types: [created] + +permissions: {} + +concurrency: + group: update-api-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + update-api: + name: Update API Suppressions + if: >- + github.event.issue.pull_request + && contains(github.event.comment.body, '/update-api') + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - name: Check maintainer permission + uses: actions/github-script@v7 + with: + script: | + const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const allowed = ['admin', 'maintain', 'write']; + if (!allowed.includes(permLevel.permission)) { + core.setFailed(`User @${context.payload.comment.user.login} does not have write access.`); + } + + - name: Add reaction to acknowledge command + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + + - name: Get PR branch info + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Cannot run /update-api on fork PRs — would execute untrusted code with write permissions.'); + return; + } + core.setOutput('ref', pr.head.ref); + core.setOutput('sha', pr.head.sha); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Run ValidateApiDiff + run: dotnet run --project ./nukebuild/_build.csproj -- ValidateApiDiff --update-api-suppression true + + - name: Commit and push changes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add api/ + if git diff --cached --quiet; then + echo "No API suppression changes to commit." + else + git commit -m "Update API suppressions" + git push origin HEAD:${{ steps.pr.outputs.ref }} + fi + + - name: Add success reaction + if: success() + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket', + }); + + - name: Report failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1', + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ \`/update-api\` failed. [See logs](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + }); From 7bdf54ee355eb811f6bc305fa29aa8d67a956300 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Fri, 13 Mar 2026 08:44:32 +0000 Subject: [PATCH 17/50] Remove Uncompilable code guarded by platform defines (#20861) * remove uncompilable code guarded by platform defines * Remove OperatingSystemEx * remove NativeLibraryEx * remove unused classes * fix unused usings --- .../ControlCatalog/Pages/DialogsPage.xaml.cs | 14 -- src/Avalonia.Base/Avalonia.Base.csproj | 8 +- .../CollectionCompatibilityExtensions.cs | 32 ---- .../Compatibility/NativeLibrary.cs | 122 -------------- .../Compatibility/OperatingSystem.cs | 32 ---- .../Compatibility/StringSyntaxAttribute.cs | 43 ----- .../Reflection/DynamicPluginStreamNode.cs | 2 - .../Reflection/ExpressionTreeIndexerNode.cs | 2 - .../Core/Parsers/ExpressionNodeFactory.cs | 2 - .../Data/Core/Plugins/BindingPlugins.cs | 4 - .../Plugins/ReflectionMethodAccessorPlugin.cs | 4 - src/Avalonia.Base/Input/DataFormat.cs | 14 +- src/Avalonia.Base/Input/KeyGesture.cs | 4 +- src/Avalonia.Base/Layout/LayoutHelper.cs | 5 - .../Media/Fonts/FamilyNameCollection.cs | 4 - src/Avalonia.Base/Media/GlyphRun.cs | 2 - .../TextFormatting/FormattingBufferHelper.cs | 15 -- src/Avalonia.Base/Media/Typeface.cs | 6 +- .../Platform/Internal/UnmanagedBlob.cs | 13 +- .../Platform/StandardRuntimePlatform.cs | 12 +- .../StandardRuntimePlatformServices.cs | 3 - .../Platform/Storage/FileIO/BclLauncher.cs | 11 +- .../Storage/FileIO/BclStorageProvider.cs | 7 +- .../Storage/FileIO/SecurityScopedStream.cs | 8 - .../Storage/FileIO/StorageBookmarkHelper.cs | 12 +- .../Composition/Server/FpsCounter.cs | 4 - .../Composition/Server/FrameTimeGraph.cs | 5 - .../Composition/Transport/BatchStream.cs | 5 +- .../AvaloniaSynchronizationContext.cs | 3 - .../CulturePreservingExecutionContext.cs | 156 ------------------ .../Threading/DispatcherOperation.cs | 8 - .../Threading/NonPumpingSyncContext.cs | 7 - src/Avalonia.Base/Utilities/ArrayBuilder.cs | 4 - .../Utilities/AvaloniaPropertyDictionary.cs | 10 +- src/Avalonia.Base/Utilities/EnumHelper.cs | 34 ---- src/Avalonia.Base/Utilities/Polyfills.cs | 43 ----- .../Utilities/RefCountingSmallDictionary.cs | 20 +-- .../Utilities/SmallDictionary.cs | 3 - src/Avalonia.Base/Utilities/StringSplitter.cs | 4 - .../Helpers/ColorHelper.cs | 7 +- .../TextBoxTextInputMethodClient.cs | 4 - .../Remote/HtmlTransport/HtmlTransport.cs | 5 +- .../AppBuilderDesktopExtensions.cs | 8 +- .../ManagedFileDialogExtensions.cs | 2 - src/Avalonia.Native/AvaloniaNativePlatform.cs | 6 +- src/Avalonia.Native/IAvnMenu.cs | 4 +- src/Avalonia.Native/IAvnMenuItem.cs | 6 +- src/Avalonia.OpenGL/Egl/EglInterface.cs | 11 +- src/Avalonia.OpenGL/OpenGlException.cs | 4 - .../ColorPaletteResourcesCollection.cs | 2 - src/Avalonia.X11/X11Window.cs | 5 - .../Input/EvDev/EvDevDevice.cs | 4 - .../MarkupExtensions/OnPlatformExtension.cs | 16 +- .../ReflectionBindingExtension.cs | 2 - .../XamlIl/Runtime/XamlIlRuntimeHelpers.cs | 11 -- src/Markup/Avalonia.Markup/Data/Binding.cs | 4 +- src/Shared/ModuleInitializer.cs | 2 +- .../Helpers/PixelFormatHelper.cs | 3 +- src/Skia/Avalonia.Skia/SKRoundRectCache.cs | 6 - .../AutomationNode.cs | 14 -- .../Interop/IDockProvider.cs | 5 - .../Interop/IExpandCollapseProvider.cs | 5 - .../Interop/IGridItemProvider.cs | 5 - .../Interop/IGridProvider.cs | 5 - .../Interop/IInvokeProvider.cs | 5 - .../Interop/IMultipleViewProvider.cs | 8 +- .../Interop/IRangeValueProvider.cs | 5 - .../IRawElementProviderAdviseEvents.cs | 9 - .../Interop/IRawElementProviderFragment.cs | 9 - .../IRawElementProviderFragmentRoot.cs | 5 - .../Interop/IRawElementProviderSimple.cs | 7 - .../Interop/IRawElementProviderSimple2.cs | 15 -- .../Interop/IScrollItemProvider.cs | 5 - .../Interop/IScrollProvider.cs | 5 - .../Interop/ISelectionItemProvider.cs | 5 - .../Interop/ISelectionProvider.cs | 7 - .../Interop/ISynchronizedInputProvider.cs | 6 +- .../Interop/ITableItemProvider.cs | 9 - .../Interop/ITableProvider.cs | 10 +- .../Interop/ITextProvider.cs | 9 - .../Interop/ITextRangeProvider.cs | 13 -- .../Interop/IToggleProvider.cs | 5 - .../Interop/ITransformProvider.cs | 5 - .../Interop/IValueProvider.cs | 5 - .../Interop/IWindowProvider.cs | 6 +- .../Interop/UiaCoreProviderApi.cs | 27 --- .../Interop/UiaCoreTypesApi.cs | 25 --- .../InteropAutomationNode.cs | 4 - .../Marshalling/ComVariant.cs | 2 - .../Marshalling/ComVariantMarshaller.cs | 4 +- .../Marshalling/SafeArrayMarshaller.cs | 4 +- .../Marshalling/SafeArrayRef.cs | 3 - .../RootAutomationNode.cs | 8 - .../Avalonia.Win32/OleDataObjectHelper.cs | 4 - .../Avalonia.Win32/WindowImpl.AppWndProc.cs | 17 +- .../Avalonia.Designer.HostApp.csproj | 1 - .../Avalonia.Designer.HostApp/TinyJson.cs | 4 - 97 files changed, 67 insertions(+), 1022 deletions(-) delete mode 100644 src/Avalonia.Base/Compatibility/CollectionCompatibilityExtensions.cs delete mode 100644 src/Avalonia.Base/Compatibility/NativeLibrary.cs delete mode 100644 src/Avalonia.Base/Compatibility/OperatingSystem.cs delete mode 100644 src/Avalonia.Base/Compatibility/StringSyntaxAttribute.cs delete mode 100644 src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs delete mode 100644 src/Avalonia.Base/Utilities/EnumHelper.cs delete mode 100644 src/Avalonia.Base/Utilities/Polyfills.cs diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs index 892e320afc..adcf844552 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs @@ -228,13 +228,8 @@ namespace ControlCatalog.Pages try { // Sync disposal of StreamWriter is not supported on WASM -#if NET6_0_OR_GREATER await using var stream = await file.OpenWriteAsync(); await using var writer = new System.IO.StreamWriter(stream); -#else - using var stream = await file.OpenWriteAsync(); - using var writer = new System.IO.StreamWriter(stream); -#endif await writer.WriteLineAsync(openedFileContent.Text); SetFolder(await file.GetParentAsync()); @@ -265,13 +260,8 @@ namespace ControlCatalog.Pages if (result.File is { } file) { // Sync disposal of StreamWriter is not supported on WASM -#if NET6_0_OR_GREATER await using var stream = await file.OpenWriteAsync(); await using var writer = new System.IO.StreamWriter(stream); -#else - using var stream = await file.OpenWriteAsync(); - using var writer = new System.IO.StreamWriter(stream); -#endif if (result.SelectedFileType == FilePickerFileTypes.Xml) { await writer.WriteLineAsync("Test"); @@ -431,11 +421,7 @@ namespace ControlCatalog.Pages internal static async Task ReadTextFromFile(IStorageFile file, int length) { -#if NET6_0_OR_GREATER await using var stream = await file.OpenReadAsync(); -#else - using var stream = await file.OpenReadAsync(); -#endif using var reader = new System.IO.StreamReader(stream); // 4GB file test, shouldn't load more than 10000 chars into a memory. diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index ffaac716b9..99524857a7 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -16,11 +16,7 @@ - - - - @@ -65,8 +61,6 @@ - + diff --git a/src/Avalonia.Base/Compatibility/CollectionCompatibilityExtensions.cs b/src/Avalonia.Base/Compatibility/CollectionCompatibilityExtensions.cs deleted file mode 100644 index e22288a74d..0000000000 --- a/src/Avalonia.Base/Compatibility/CollectionCompatibilityExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace System; - -#if !NET6_0_OR_GREATER -internal static class CollectionCompatibilityExtensions -{ - public static bool Remove( - this Dictionary o, - TKey key, - [MaybeNullWhen(false)] out TValue value) - where TKey : notnull - { - if (o.TryGetValue(key, out value)) - return o.Remove(key); - return false; - } - - public static bool TryAdd(this Dictionary o, TKey key, TValue value) - where TKey : notnull - { - if (!o.ContainsKey(key)) - { - o.Add(key, value); - return true; - } - - return false; - } -} -#endif diff --git a/src/Avalonia.Base/Compatibility/NativeLibrary.cs b/src/Avalonia.Base/Compatibility/NativeLibrary.cs deleted file mode 100644 index 7627c095bc..0000000000 --- a/src/Avalonia.Base/Compatibility/NativeLibrary.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.ComponentModel; -using System.Reflection; -using System.Runtime.InteropServices; -using Avalonia.Compatibility; -using Avalonia.Platform.Interop; - -namespace Avalonia.Compatibility -{ - internal class NativeLibraryEx - { -#if NET6_0_OR_GREATER - public static IntPtr Load(string dll, Assembly assembly) => NativeLibrary.Load(dll, assembly, null); - public static IntPtr Load(string dll) => NativeLibrary.Load(dll); - public static bool TryGetExport(IntPtr handle, string name, out IntPtr address) => - NativeLibrary.TryGetExport(handle, name, out address); -#else - public static IntPtr Load(string dll, Assembly assembly) => Load(dll); - public static IntPtr Load(string dll) - { - var handle = DlOpen!(dll); - if (handle != IntPtr.Zero) - return handle; - throw new InvalidOperationException("Unable to load " + dll, DlError!()); - } - - public static bool TryGetExport(IntPtr handle, string name, out IntPtr address) - { - try - { - address = DlSym!(handle, name); - return address != default; - } - catch (Exception) - { - address = default; - return false; - } - } - - static NativeLibraryEx() - { - if (OperatingSystemEx.IsWindows()) - { - Win32Imports.Init(); - } - else if (OperatingSystemEx.IsLinux() || OperatingSystemEx.IsMacOS()) - { - var buffer = Marshal.AllocHGlobal(0x1000); - uname(buffer); - var unixName = Marshal.PtrToStringAnsi(buffer); - Marshal.FreeHGlobal(buffer); - if (unixName == "Darwin") - OsXImports.Init(); - else - LinuxImports.Init(); - } - } - - private static Func? DlOpen; - private static Func? DlSym; - private static Func? DlError; - - [DllImport("libc")] - static extern int uname(IntPtr buf); - - static class Win32Imports - { - [DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)] - private static extern IntPtr GetProcAddress(IntPtr hModule, string procName); - - [DllImport("kernel32", EntryPoint = "LoadLibraryW", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern IntPtr LoadLibrary(string lpszLib); - - public static void Init() - { - DlOpen = LoadLibrary; - DlSym = GetProcAddress; - DlError = () => new Win32Exception(Marshal.GetLastWin32Error()); - } - } - - static class LinuxImports - { - [DllImport("libdl.so.2")] - private static extern IntPtr dlopen(string path, int flags); - - [DllImport("libdl.so.2")] - private static extern IntPtr dlsym(IntPtr handle, string symbol); - - [DllImport("libdl.so.2")] - private static extern IntPtr dlerror(); - - public static void Init() - { - DlOpen = s => dlopen(s, 1); - DlSym = dlsym; - DlError = () => new InvalidOperationException(Marshal.PtrToStringAnsi(dlerror())); - } - } - - static class OsXImports - { - [DllImport("/usr/lib/libSystem.dylib")] - private static extern IntPtr dlopen(string path, int flags); - - [DllImport("/usr/lib/libSystem.dylib")] - private static extern IntPtr dlsym(IntPtr handle, string symbol); - - [DllImport("/usr/lib/libSystem.dylib")] - private static extern IntPtr dlerror(); - - public static void Init() - { - DlOpen = s => dlopen(s, 1); - DlSym = dlsym; - DlError = () => new InvalidOperationException(Marshal.PtrToStringAnsi(dlerror())); - } - } -#endif - } -} diff --git a/src/Avalonia.Base/Compatibility/OperatingSystem.cs b/src/Avalonia.Base/Compatibility/OperatingSystem.cs deleted file mode 100644 index ad5fe0246a..0000000000 --- a/src/Avalonia.Base/Compatibility/OperatingSystem.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace Avalonia.Compatibility -{ - internal sealed class OperatingSystemEx - { -#if NET6_0_OR_GREATER - public static bool IsWindows() => OperatingSystem.IsWindows(); - public static bool IsMacOS() => OperatingSystem.IsMacOS(); - public static bool IsMacCatalyst() => OperatingSystem.IsMacCatalyst(); - public static bool IsLinux() => OperatingSystem.IsLinux(); - public static bool IsFreeBSD() => OperatingSystem.IsFreeBSD(); - public static bool IsAndroid() => OperatingSystem.IsAndroid(); - public static bool IsIOS() => OperatingSystem.IsIOS(); - public static bool IsTvOS() => OperatingSystem.IsTvOS(); - public static bool IsBrowser() => OperatingSystem.IsBrowser(); - public static bool IsOSPlatform(string platform) => OperatingSystem.IsOSPlatform(platform); -#else - public static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - public static bool IsMacOS() => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - public static bool IsLinux() => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - public static bool IsFreeBSD() => false; - public static bool IsAndroid() => false; - public static bool IsIOS() => false; - public static bool IsMacCatalyst() => false; - public static bool IsTvOS() => false; - public static bool IsBrowser() => false; - public static bool IsOSPlatform(string platform) => RuntimeInformation.IsOSPlatform(OSPlatform.Create(platform)); -#endif - } -} diff --git a/src/Avalonia.Base/Compatibility/StringSyntaxAttribute.cs b/src/Avalonia.Base/Compatibility/StringSyntaxAttribute.cs deleted file mode 100644 index 2b3585fbe4..0000000000 --- a/src/Avalonia.Base/Compatibility/StringSyntaxAttribute.cs +++ /dev/null @@ -1,43 +0,0 @@ -#pragma warning disable MA0048 // File name must match type name -// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/StringSyntaxAttribute.cs - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -// ReSharper disable once CheckNamespace -namespace System.Diagnostics.CodeAnalysis -{ -#if !NET7_0_OR_GREATER - /// Specifies the syntax used in a string. - [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - internal sealed class StringSyntaxAttribute : Attribute - { - /// Initializes the with the identifier of the syntax used. - /// The syntax identifier. - public StringSyntaxAttribute(string syntax) - { - Syntax = syntax; - Arguments = Array.Empty(); - } - - /// Initializes the with the identifier of the syntax used. - /// The syntax identifier. - /// Optional arguments associated with the specific syntax employed. - public StringSyntaxAttribute(string syntax, params object?[] arguments) - { - Syntax = syntax; - Arguments = arguments; - } - - /// Gets the identifier of the syntax used. - public string Syntax { get; } - - /// Optional arguments associated with the specific syntax employed. - public object?[] Arguments { get; } - - /// The syntax identifier for strings containing XML. - public const string Xml = nameof(Xml); - } -#endif -} diff --git a/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginStreamNode.cs b/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginStreamNode.cs index dd8c0e1a63..198819f1ac 100644 --- a/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginStreamNode.cs +++ b/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginStreamNode.cs @@ -7,9 +7,7 @@ using Avalonia.Reactive; namespace Avalonia.Data.Core.ExpressionNodes.Reflection; [RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] -#endif internal sealed class DynamicPluginStreamNode : ExpressionNode { private IDisposable? _subscription; diff --git a/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/ExpressionTreeIndexerNode.cs b/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/ExpressionTreeIndexerNode.cs index dfb83fb10d..ef8aa34752 100644 --- a/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/ExpressionTreeIndexerNode.cs +++ b/src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/ExpressionTreeIndexerNode.cs @@ -16,9 +16,7 @@ internal sealed class ExpressionTreeIndexerNode : CollectionNodeBase, ISettableN private readonly Delegate _getDelegate; private readonly Delegate _firstArgumentDelegate; -#if NET8_0_OR_GREATER [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] -#endif public ExpressionTreeIndexerNode(IndexExpression expression) { var valueParameter = Expression.Parameter(expression.Type); diff --git a/src/Avalonia.Base/Data/Core/Parsers/ExpressionNodeFactory.cs b/src/Avalonia.Base/Data/Core/Parsers/ExpressionNodeFactory.cs index f7eb2d537d..301e8c0796 100644 --- a/src/Avalonia.Base/Data/Core/Parsers/ExpressionNodeFactory.cs +++ b/src/Avalonia.Base/Data/Core/Parsers/ExpressionNodeFactory.cs @@ -15,9 +15,7 @@ namespace Avalonia.Data.Core.Parsers internal static class ExpressionNodeFactory { [RequiresUnreferencedCode(TrimmingMessages.ReflectionBindingRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER [RequiresDynamicCode(TrimmingMessages.ReflectionBindingRequiresDynamicCodeMessage)] -#endif public static List? CreateFromAst( List astNodes, Func? typeResolver, diff --git a/src/Avalonia.Base/Data/Core/Plugins/BindingPlugins.cs b/src/Avalonia.Base/Data/Core/Plugins/BindingPlugins.cs index 50e137eac1..516dbecc26 100644 --- a/src/Avalonia.Base/Data/Core/Plugins/BindingPlugins.cs +++ b/src/Avalonia.Base/Data/Core/Plugins/BindingPlugins.cs @@ -32,11 +32,7 @@ namespace Avalonia.Data.Core.Plugins { // When building with AOT, don't create ReflectionMethodAccessorPlugin instance. // This branch can be eliminated in compile time with AOT. -#if NET6_0_OR_GREATER if (System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) -#else - if (true) -#endif { s_propertyAccessors.Insert(1, new ReflectionMethodAccessorPlugin()); } diff --git a/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs b/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs index f9a4587ca6..d2e6f23e29 100644 --- a/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs +++ b/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs @@ -7,9 +7,7 @@ using System.Reflection; namespace Avalonia.Data.Core.Plugins { [RequiresUnreferencedCode(TrimmingMessages.PropertyAccessorsRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] -#endif internal class ReflectionMethodAccessorPlugin : IPropertyAccessorPlugin { private readonly Dictionary<(Type, string), MethodInfo?> _methodLookup = @@ -84,9 +82,7 @@ namespace Avalonia.Data.Core.Plugins return found; } -#if NET8_0_OR_GREATER [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] -#endif private sealed class Accessor : PropertyAccessorBase { public Accessor(WeakReference reference, MethodInfo method) diff --git a/src/Avalonia.Base/Input/DataFormat.cs b/src/Avalonia.Base/Input/DataFormat.cs index 7e35bab411..14d1d4a30b 100644 --- a/src/Avalonia.Base/Input/DataFormat.cs +++ b/src/Avalonia.Base/Input/DataFormat.cs @@ -213,19 +213,7 @@ public abstract class DataFormat : IEquatable return true; static bool IsValidChar(char c) - => IsAsciiLetterOrDigit(c) || c == '.' || c == '-'; - - static bool IsAsciiLetterOrDigit(char c) - { -#if NET8_0_OR_GREATER - return char.IsAsciiLetterOrDigit(c); -#else - return c is - (>= '0' and <= '9') or - (>= 'A' and <= 'Z') or - (>= 'a' and <= 'z'); -#endif - } + => char.IsAsciiLetterOrDigit(c) || c == '.' || c == '-'; } /// diff --git a/src/Avalonia.Base/Input/KeyGesture.cs b/src/Avalonia.Base/Input/KeyGesture.cs index 83d99bf7a9..463337ddda 100644 --- a/src/Avalonia.Base/Input/KeyGesture.cs +++ b/src/Avalonia.Base/Input/KeyGesture.cs @@ -167,7 +167,7 @@ namespace Avalonia.Input if (s_keySynonyms.TryGetValue(keyStr.ToLower(CultureInfo.InvariantCulture), out key)) return true; - if (EnumHelper.TryParse(keyStr, true, out key)) + if (Enum.TryParse(keyStr, true, out key)) return true; return false; @@ -187,7 +187,7 @@ namespace Avalonia.Input return KeyModifiers.Meta; } - return EnumHelper.Parse(modifier.ToString(), true); + return Enum.Parse(modifier.ToString(), true); } private static Key ResolveNumPadOperationKey(Key key) diff --git a/src/Avalonia.Base/Layout/LayoutHelper.cs b/src/Avalonia.Base/Layout/LayoutHelper.cs index c50053dc05..fd81ffaa49 100644 --- a/src/Avalonia.Base/Layout/LayoutHelper.cs +++ b/src/Avalonia.Base/Layout/LayoutHelper.cs @@ -263,12 +263,7 @@ namespace Avalonia.Layout // point precision error (e.g. 79.333333333333343) then when it's multiplied by // `dpiScale` and rounded up, it will be rounded up to a value one greater than it // should be. -#if NET6_0_OR_GREATER return Math.Round(value, 8, MidpointRounding.ToZero); -#else - // MidpointRounding.ToZero isn't available in netstandard2.0. - return Math.Truncate(value * 1e8) / 1e8; -#endif } } } diff --git a/src/Avalonia.Base/Media/Fonts/FamilyNameCollection.cs b/src/Avalonia.Base/Media/Fonts/FamilyNameCollection.cs index dabe935b76..ed916bb441 100644 --- a/src/Avalonia.Base/Media/Fonts/FamilyNameCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/FamilyNameCollection.cs @@ -43,11 +43,7 @@ namespace Avalonia.Media.Fonts } private static string[] SplitNames(string names) -#if NET6_0_OR_GREATER => names.Split(',', StringSplitOptions.TrimEntries); -#else - => Array.ConvertAll(names.Split(','), p => p.Trim()); -#endif /// /// Gets the primary family name. diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index 41eed5b747..cccef8f938 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -126,12 +126,10 @@ namespace Avalonia.Media return array.AsSpan(); } -#if NET6_0_OR_GREATER if (list is List concreteList) { return CollectionsMarshal.AsSpan(concreteList); } -#endif array = new ushort[count]; for (var i = 0; i < count; ++i) diff --git a/src/Avalonia.Base/Media/TextFormatting/FormattingBufferHelper.cs b/src/Avalonia.Base/Media/TextFormatting/FormattingBufferHelper.cs index c27903cd55..d8672ffba8 100644 --- a/src/Avalonia.Base/Media/TextFormatting/FormattingBufferHelper.cs +++ b/src/Avalonia.Base/Media/TextFormatting/FormattingBufferHelper.cs @@ -52,11 +52,7 @@ namespace Avalonia.Media.TextFormatting // dictionary is in fact larger than that: it has entries and buckets, but let's only count our data here if (IsBufferTooLarge>(approximateCapacity)) { -#if NET6_0_OR_GREATER dictionary.TrimExcess(); -#else - dictionary = new Dictionary(); -#endif } } @@ -67,18 +63,7 @@ namespace Avalonia.Media.TextFormatting [MethodImpl(MethodImplOptions.AggressiveInlining)] private static uint RoundUpToPowerOf2(uint value) { -#if NET6_0_OR_GREATER return BitOperations.RoundUpToPowerOf2(value); -#else - // Based on https://graphics.stanford.edu/~seander/bithacks.html#RoundUpPowerOf2 - --value; - value |= value >> 1; - value |= value >> 2; - value |= value >> 4; - value |= value >> 8; - value |= value >> 16; - return value + 1; -#endif } } } diff --git a/src/Avalonia.Base/Media/Typeface.cs b/src/Avalonia.Base/Media/Typeface.cs index 1adcac5b75..f745ba2d23 100644 --- a/src/Avalonia.Base/Media/Typeface.cs +++ b/src/Avalonia.Base/Media/Typeface.cs @@ -174,17 +174,17 @@ namespace Avalonia.Media // Try match with font style, weight or stretch and update accordingly. var match = false; - if (EnumHelper.TryParse(token, true, out var newStyle)) + if (Enum.TryParse(token, true, out var newStyle)) { style = newStyle; match = true; } - else if (EnumHelper.TryParse(token, true, out var newWeight)) + else if (Enum.TryParse(token, true, out var newWeight)) { weight = newWeight; match = true; } - else if (EnumHelper.TryParse(token, true, out var newStretch)) + else if (Enum.TryParse(token, true, out var newStretch)) { stretch = newStretch; match = true; diff --git a/src/Avalonia.Base/Platform/Internal/UnmanagedBlob.cs b/src/Avalonia.Base/Platform/Internal/UnmanagedBlob.cs index eeba160a3c..a1296c2ee1 100644 --- a/src/Avalonia.Base/Platform/Internal/UnmanagedBlob.cs +++ b/src/Avalonia.Base/Platform/Internal/UnmanagedBlob.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; -using Avalonia.Compatibility; namespace Avalonia.Platform.Internal; @@ -117,7 +116,7 @@ internal class UnmanagedBlob : IDisposable // Could be replaced with https://github.com/dotnet/runtime/issues/40892 when it will be available. private IntPtr Alloc(int size) { - if (!OperatingSystemEx.IsLinux()) + if (!OperatingSystem.IsLinux()) { return Marshal.AllocHGlobal(size); } @@ -126,12 +125,8 @@ internal class UnmanagedBlob : IDisposable var rv = mmap(IntPtr.Zero, new IntPtr(size), 3, 0x22, -1, IntPtr.Zero); if (rv.ToInt64() == -1 || (ulong)rv.ToInt64() == 0xffffffff) { -#if NET6_0_OR_GREATER var errno = Marshal.GetLastSystemError(); throw new Exception("Unable to allocate memory: " + errno); -#else - throw new Exception("Unable to allocate memory"); -#endif } return rv; } @@ -139,7 +134,7 @@ internal class UnmanagedBlob : IDisposable private void Free(IntPtr ptr, int len) { - if (!OperatingSystemEx.IsLinux()) + if (!OperatingSystem.IsLinux()) { Marshal.FreeHGlobal(ptr); } @@ -147,12 +142,8 @@ internal class UnmanagedBlob : IDisposable { if (munmap(ptr, new IntPtr(len)) == -1) { -#if NET6_0_OR_GREATER var errno = Marshal.GetLastSystemError(); throw new Exception("Unable to free memory: " + errno); -#else - throw new Exception("Unable to free memory"); -#endif } } } diff --git a/src/Avalonia.Base/Platform/StandardRuntimePlatform.cs b/src/Avalonia.Base/Platform/StandardRuntimePlatform.cs index b72e10c831..fc44cbbbd7 100644 --- a/src/Avalonia.Base/Platform/StandardRuntimePlatform.cs +++ b/src/Avalonia.Base/Platform/StandardRuntimePlatform.cs @@ -1,4 +1,4 @@ -using Avalonia.Compatibility; +using System; using Avalonia.Metadata; namespace Avalonia.Platform @@ -8,11 +8,11 @@ namespace Avalonia.Platform { public virtual RuntimePlatformInfo GetRuntimeInfo() => new() { - IsDesktop = OperatingSystemEx.IsWindows() - || OperatingSystemEx.IsMacOS() || OperatingSystemEx.IsMacCatalyst() - || OperatingSystemEx.IsLinux() || OperatingSystemEx.IsFreeBSD(), - IsMobile = OperatingSystemEx.IsAndroid() || (OperatingSystemEx.IsIOS() && !OperatingSystemEx.IsMacCatalyst()), - IsTV = OperatingSystemEx.IsTvOS() + IsDesktop = OperatingSystem.IsWindows() + || OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst() + || OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD(), + IsMobile = OperatingSystem.IsAndroid() || (OperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst()), + IsTV = OperatingSystem.IsTvOS() }; } } diff --git a/src/Avalonia.Base/Platform/StandardRuntimePlatformServices.cs b/src/Avalonia.Base/Platform/StandardRuntimePlatformServices.cs index 70919bc477..666cdf2bed 100644 --- a/src/Avalonia.Base/Platform/StandardRuntimePlatformServices.cs +++ b/src/Avalonia.Base/Platform/StandardRuntimePlatformServices.cs @@ -1,7 +1,4 @@ using System.Reflection; -using Avalonia.Compatibility; -using Avalonia.Platform.Internal; -using Avalonia.Platform.Interop; namespace Avalonia.Platform; diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs index 96f489a222..da4cb01df5 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading.Tasks; -using Avalonia.Compatibility; namespace Avalonia.Platform.Storage.FileIO; @@ -39,7 +38,7 @@ internal class BclLauncher : ILauncher private static bool Exec(string urlOrFile) { - if (OperatingSystemEx.IsLinux()) + if (OperatingSystem.IsLinux()) { // If no associated application/json MimeType is found xdg-open opens return error // but it tries to open it anyway using the console editor (nano, vim, other..) @@ -47,17 +46,17 @@ internal class BclLauncher : ILauncher ShellExecRaw($"xdg-open \\\"{args}\\\"", waitForExit: false); return true; } - else if (OperatingSystemEx.IsWindows() || OperatingSystemEx.IsMacOS()) + else if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) { var info = new ProcessStartInfo { - FileName = OperatingSystemEx.IsWindows() ? urlOrFile : "open", + FileName = OperatingSystem.IsWindows() ? urlOrFile : "open", CreateNoWindow = true, - UseShellExecute = OperatingSystemEx.IsWindows() + UseShellExecute = OperatingSystem.IsWindows() }; // Using the argument list avoids having to escape spaces and other special // characters that are part of valid macos file and folder paths. - if (OperatingSystemEx.IsMacOS()) + if (OperatingSystem.IsMacOS()) info.ArgumentList.Add(urlOrFile); using var process = Process.Start(info); return true; diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs index decb742ed8..a471dba720 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Threading.Tasks; -using Avalonia.Compatibility; using Avalonia.Logging; namespace Avalonia.Platform.Storage.FileIO; @@ -107,13 +106,13 @@ internal abstract class BclStorageProvider : IStorageProvider // Normally we want to avoid platform specific code in the Avalonia.Base assembly. protected static string? GetDownloadsWellKnownFolder() { - if (OperatingSystemEx.IsWindows()) + if (OperatingSystem.IsWindows()) { return Environment.OSVersion.Version.Major < 6 ? null : TryGetWindowsKnownFolder(s_folderDownloads); } - if (OperatingSystemEx.IsLinux()) + if (OperatingSystem.IsLinux()) { var envDir = Environment.GetEnvironmentVariable("XDG_DOWNLOAD_DIR"); if (envDir != null && Directory.Exists(envDir)) @@ -122,7 +121,7 @@ internal abstract class BclStorageProvider : IStorageProvider } } - if (OperatingSystemEx.IsLinux() || OperatingSystemEx.IsMacOS()) + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { return "~/Downloads"; } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs b/src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs index 0e0ffa3b1b..ad2bd9f37a 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs @@ -40,12 +40,10 @@ internal sealed class SecurityScopedStream(FileStream _stream, IDisposable _secu public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _stream.ReadAsync(buffer, offset, count, cancellationToken); -#if NET6_0_OR_GREATER public override int Read(Span buffer) => _stream.Read(buffer); public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => _stream.ReadAsync(buffer, cancellationToken); -#endif public override void Write(byte[] buffer, int offset, int count) => _stream.Write(buffer, offset, count); @@ -53,12 +51,10 @@ internal sealed class SecurityScopedStream(FileStream _stream, IDisposable _secu public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _stream.WriteAsync(buffer, offset, count, cancellationToken); -#if NET6_0_OR_GREATER public override void Write(ReadOnlySpan buffer) => _stream.Write(buffer); public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => _stream.WriteAsync(buffer, cancellationToken); -#endif public override void WriteByte(byte value) => _stream.WriteByte(value); @@ -68,9 +64,7 @@ internal sealed class SecurityScopedStream(FileStream _stream, IDisposable _secu public override void SetLength(long value) => _stream.SetLength(value); -#if NET6_0_OR_GREATER public override void CopyTo(Stream destination, int bufferSize) => _stream.CopyTo(destination, bufferSize); -#endif public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) => _stream.CopyToAsync(destination, bufferSize, cancellationToken); @@ -100,7 +94,6 @@ internal sealed class SecurityScopedStream(FileStream _stream, IDisposable _secu } } -#if NET6_0_OR_GREATER public override async ValueTask DisposeAsync() { try @@ -112,5 +105,4 @@ internal sealed class SecurityScopedStream(FileStream _stream, IDisposable _secu _securityScope.Dispose(); } } -#endif } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs index 78392ec31d..4c43331803 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs @@ -58,11 +58,7 @@ internal static class StorageBookmarkHelper nativeBookmarkBytes.CopyTo(arraySpan.Slice(HeaderLength)); // We must use span overload because ArrayPool might return way too big array. -#if NET6_0_OR_GREATER return Convert.ToBase64String(arraySpan); -#else - return Convert.ToBase64String(arraySpan.ToArray(), Base64FormattingOptions.None); -#endif } finally { @@ -89,7 +85,7 @@ internal static class StorageBookmarkHelper } Span decodedBookmark; -#if NET6_0_OR_GREATER + // Each base64 character represents 6 bits, but to be safe, var arrayPool = ArrayPool.Shared.Rent(HeaderLength + base64bookmark.Length * 6); if (Convert.TryFromBase64Chars(base64bookmark, arrayPool, out int bytesWritten)) @@ -101,9 +97,7 @@ internal static class StorageBookmarkHelper nativeBookmark = null; return DecodeResult.InvalidFormat; } -#else - decodedBookmark = Convert.FromBase64String(base64bookmark).AsSpan(); -#endif + try { if (decodedBookmark.Length < HeaderLength @@ -126,9 +120,7 @@ internal static class StorageBookmarkHelper } finally { -#if NET6_0_OR_GREATER ArrayPool.Shared.Return(arrayPool); -#endif } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs b/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs index c13e0d04ae..81f41e3a42 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs @@ -42,11 +42,7 @@ internal class FpsCounter _lastFpsUpdate = now; } -#if NET6_0_OR_GREATER var fpsLine = string.Create(CultureInfo.InvariantCulture, $"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}"); -#else - var fpsLine = FormattableString.Invariant($"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}"); -#endif var size = _textRenderer.MeasureAsciiText(fpsLine.AsSpan()); diff --git a/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs b/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs index 8e283ba5b1..c5672a1860 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs @@ -104,14 +104,9 @@ internal sealed class FrameTimeGraph var brush = value <= _defaultMaxY ? Brushes.Black : Brushes.Red; -#if NET6_0_OR_GREATER Span buffer = stackalloc char[24]; buffer.TryWrite(CultureInfo.InvariantCulture, $"{label}: {value,5:F2}ms", out var charsWritten); _textRenderer.DrawAsciiText(context, buffer.Slice(0, charsWritten), brush); -#else - var text = FormattableString.Invariant($"{label}: {value,5:F2}ms"); - _textRenderer.DrawAsciiText(context, text.AsSpan(), brush); -#endif } private IStreamGeometryImpl BuildGraphGeometry(double maxY) diff --git a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStream.cs b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStream.cs index 0231c29bb3..1fcadae1c2 100644 --- a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStream.cs +++ b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStream.cs @@ -33,11 +33,8 @@ static unsafe class UnalignedMemoryHelper { public static T ReadUnaligned(byte* src) where T : unmanaged { -#if NET6_0_OR_GREATER Unsafe.SkipInit(out var rv); -#else - T rv; -#endif + UnalignedMemcpy((byte*)&rv, src, Unsafe.SizeOf()); return rv; } diff --git a/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs b/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs index a643445e99..5639963e9e 100644 --- a/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs +++ b/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs @@ -74,9 +74,6 @@ namespace Avalonia.Threading _dispatcher.Send(d, state, Priority); } -#if !NET6_0_OR_GREATER - [PrePrepareMethod] -#endif public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) { if ( diff --git a/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs b/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs deleted file mode 100644 index ec0ebaa4a6..0000000000 --- a/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs +++ /dev/null @@ -1,156 +0,0 @@ -#if NET6_0_OR_GREATER -// In .NET Core, the security context and call context are not supported, however, -// the impersonation context and culture would typically flow with the execution context. -// See: https://learn.microsoft.com/en-us/dotnet/api/system.threading.executioncontext -// -// So we can safely use ExecutionContext without worrying about culture flowing issues. -#else -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Threading; - -namespace Avalonia.Threading; - -/// -/// An ExecutionContext that preserves culture information across async operations. -/// This is a modernized version that removes legacy compatibility switches and -/// includes nullable reference type annotations. -/// -internal sealed class CulturePreservingExecutionContext -{ - private readonly ExecutionContext _context; - private CultureAndContext? _cultureAndContext; - - private CulturePreservingExecutionContext(ExecutionContext context) - { - _context = context; - } - - /// - /// Captures the current ExecutionContext and culture information. - /// - /// A new CulturePreservingExecutionContext instance, or null if no context needs to be captured. - public static CulturePreservingExecutionContext? Capture() - { - // ExecutionContext.SuppressFlow had been called. - // We expect ExecutionContext.Capture() to return null, so match that behavior and return null. - if (ExecutionContext.IsFlowSuppressed()) - { - return null; - } - - var context = ExecutionContext.Capture(); - if (context == null) - return null; - - return new CulturePreservingExecutionContext(context); - } - - /// - /// Runs the specified callback in the captured execution context while preserving culture information. - /// This method is used for .NET Framework and earlier .NET versions. - /// - /// The execution context to run in. - /// The callback to execute. - /// The state to pass to the callback. - public static void Run(CulturePreservingExecutionContext executionContext, ContextCallback callback, object? state) - { - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (callback == null) - return; - - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (executionContext == null) - ThrowNullContext(); - - // Save culture information - we will need this to restore just before - // the callback is actually invoked from CallbackWrapper. - executionContext._cultureAndContext = CultureAndContext.Initialize(callback, state); - - try - { - ExecutionContext.Run( - executionContext._context, - s_callbackWrapperDelegate, - executionContext._cultureAndContext); - } - finally - { - // Restore culture information - it might have been modified during callback execution. - executionContext._cultureAndContext.RestoreCultureInfos(); - } - } - - [DoesNotReturn] - private static void ThrowNullContext() - { - throw new InvalidOperationException("ExecutionContext cannot be null."); - } - - private static readonly ContextCallback s_callbackWrapperDelegate = CallbackWrapper; - - /// - /// Executes the callback and saves culture values immediately afterwards. - /// - /// Contains the actual callback and state. - private static void CallbackWrapper(object? obj) - { - var cultureAndContext = (CultureAndContext)obj!; - - // Restore culture information saved during Run() - cultureAndContext.RestoreCultureInfos(); - - try - { - // Execute the actual callback - cultureAndContext.Callback(cultureAndContext.State); - } - finally - { - // Save any culture changes that might have occurred during callback execution - cultureAndContext.CaptureCultureInfos(); - } - } - - /// - /// Helper class to manage culture information across execution contexts. - /// - private sealed class CultureAndContext - { - public ContextCallback Callback { get; } - public object? State { get; } - - private CultureInfo? _culture; - private CultureInfo? _uiCulture; - - private CultureAndContext(ContextCallback callback, object? state) - { - Callback = callback; - State = state; - CaptureCultureInfos(); - } - - public static CultureAndContext Initialize(ContextCallback callback, object? state) - { - return new CultureAndContext(callback, state); - } - - public void CaptureCultureInfos() - { - _culture = Thread.CurrentThread.CurrentCulture; - _uiCulture = Thread.CurrentThread.CurrentUICulture; - } - - public void RestoreCultureInfos() - { - if (_culture != null) - Thread.CurrentThread.CurrentCulture = _culture; - - if (_uiCulture != null) - Thread.CurrentThread.CurrentUICulture = _uiCulture; - } - } -} -#endif diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs index 3a4513652e..ea48fa31b0 100644 --- a/src/Avalonia.Base/Threading/DispatcherOperation.cs +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -5,11 +5,7 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -#if NET6_0_OR_GREATER using ExecutionContext = System.Threading.ExecutionContext; -#else -using ExecutionContext = Avalonia.Threading.CulturePreservingExecutionContext; -#endif namespace Avalonia.Threading; @@ -277,12 +273,8 @@ public class DispatcherOperation { if (_executionContext is { } executionContext) { -#if NET6_0_OR_GREATER ExecutionContext.Restore(executionContext); InvokeCore(); -#else - ExecutionContext.Run(executionContext, static s => ((DispatcherOperation)s!).InvokeCore(), this); -#endif } else { diff --git a/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs b/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs index 03fc0cc76c..3c12d693eb 100644 --- a/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs +++ b/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs @@ -22,11 +22,7 @@ namespace Avalonia.Threading { if (_inner is null) { -#if NET6_0_OR_GREATER ThreadPool.QueueUserWorkItem(static x => x.d(x.state), (d, state), false); -#else - ThreadPool.QueueUserWorkItem(_ => d(state)); -#endif } else { @@ -46,9 +42,6 @@ namespace Avalonia.Threading } } -#if !NET6_0_OR_GREATER - [PrePrepareMethod] -#endif public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) => _impl.Wait(waitHandles, waitAll, millisecondsTimeout); diff --git a/src/Avalonia.Base/Utilities/ArrayBuilder.cs b/src/Avalonia.Base/Utilities/ArrayBuilder.cs index bbbcc39ecc..c12a4906f3 100644 --- a/src/Avalonia.Base/Utilities/ArrayBuilder.cs +++ b/src/Avalonia.Base/Utilities/ArrayBuilder.cs @@ -136,7 +136,6 @@ namespace Avalonia.Utilities /// public void Clear() { -#if NET6_0_OR_GREATER if (RuntimeHelpers.IsReferenceOrContainsReferences()) { ClearArray(); @@ -145,9 +144,6 @@ namespace Avalonia.Utilities { _size = 0; } -#else - ClearArray(); -#endif } private void ClearArray() diff --git a/src/Avalonia.Base/Utilities/AvaloniaPropertyDictionary.cs b/src/Avalonia.Base/Utilities/AvaloniaPropertyDictionary.cs index ab34e85220..13272c7e7d 100644 --- a/src/Avalonia.Base/Utilities/AvaloniaPropertyDictionary.cs +++ b/src/Avalonia.Base/Utilities/AvaloniaPropertyDictionary.cs @@ -244,12 +244,8 @@ namespace Avalonia.Utilities // hi and lo are never negative: there's no overflow using unsigned math var i = (int)(((uint)hi + (uint)lo) >> 1); -#if NET6_0_OR_GREATER // nuint cast to force zero extend instead of sign extend ref var entry = ref Unsafe.Add(ref entry0, (nuint)i); -#else - ref var entry = ref Unsafe.Add(ref entry0, i); -#endif var entryId = entry.Id; if (entryId == propertyId) @@ -288,12 +284,8 @@ namespace Avalonia.Utilities // hi and lo are never negative: there's no overflow using unsigned math var i = (int)(((uint)hi + (uint)lo) >> 1); -#if NET6_0_OR_GREATER // nuint cast to force zero extend instead of sign extend ref var entry = ref Unsafe.Add(ref entry0, (nuint)i); -#else - ref var entry = ref Unsafe.Add(ref entry0, i); -#endif var entryId = entry.Id; if (entryId == propertyId) @@ -360,7 +352,7 @@ namespace Avalonia.Utilities [MethodImpl(MethodImplOptions.AggressiveInlining)] private ref Entry UnsafeGetEntryRef(int index) { -#if NET6_0_OR_GREATER && !DEBUG +#if !DEBUG // This type is performance critical: in release mode, skip any bound check the JIT compiler couldn't elide. // The index parameter should always be correct when calling this method: no unchecked user input should get here. return ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_entries!), (uint)index); diff --git a/src/Avalonia.Base/Utilities/EnumHelper.cs b/src/Avalonia.Base/Utilities/EnumHelper.cs deleted file mode 100644 index fd9176985e..0000000000 --- a/src/Avalonia.Base/Utilities/EnumHelper.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace Avalonia.Utilities -{ - internal class EnumHelper - { -#if NET6_0_OR_GREATER - public static T Parse(ReadOnlySpan key, bool ignoreCase) where T : struct - { - return Enum.Parse(key, ignoreCase); - } - - public static bool TryParse(ReadOnlySpan key, bool ignoreCase, out T result) where T : struct - { - return Enum.TryParse(key, ignoreCase, out result); - } -#else - public static T Parse(string key, bool ignoreCase) where T : struct - { - return (T)Enum.Parse(typeof(T), key, ignoreCase); - } - - public static bool TryParse(string key, bool ignoreCase, out T result) where T : struct - { - return Enum.TryParse(key, ignoreCase, out result); - } - - public static bool TryParse(ReadOnlySpan key, bool ignoreCase, out T result) where T : struct - { - return Enum.TryParse(key.ToString(), ignoreCase, out result); - } -#endif - } -} diff --git a/src/Avalonia.Base/Utilities/Polyfills.cs b/src/Avalonia.Base/Utilities/Polyfills.cs deleted file mode 100644 index 9ee72ab112..0000000000 --- a/src/Avalonia.Base/Utilities/Polyfills.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -internal static class Polyfills -{ - #if !NET6_0_OR_GREATER - - public static bool TryDequeue(this Queue queue, [MaybeNullWhen(false)]out T item) - { - if (queue.Count == 0) - { - item = default; - return false; - } - - item = queue.Dequeue(); - return true; - } - - #endif -} - -#if !NET7_0_OR_GREATER - -namespace System.Diagnostics.CodeAnalysis -{ - [System.AttributeUsage( - System.AttributeTargets.Method | System.AttributeTargets.Parameter | System.AttributeTargets.Property, - AllowMultiple = false, Inherited = false)] - internal sealed class UnscopedRefAttribute : Attribute - { - } - - struct S - { - int _field; - - // Okay: `field` has the ref-safe-to-escape of `this` which is *calling method* because - // it is a `ref` - [UnscopedRef] ref int Prop1 => ref _field; - } -} -#endif diff --git a/src/Avalonia.Base/Utilities/RefCountingSmallDictionary.cs b/src/Avalonia.Base/Utilities/RefCountingSmallDictionary.cs index 86c9fd7ba1..64838a845f 100644 --- a/src/Avalonia.Base/Utilities/RefCountingSmallDictionary.cs +++ b/src/Avalonia.Base/Utilities/RefCountingSmallDictionary.cs @@ -10,20 +10,13 @@ internal struct RefCountingSmallDictionary : IEnumerable : IEnumerable : IEnumerable> IEnumerable>.GetEnumerator() => GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} \ No newline at end of file +} diff --git a/src/Avalonia.Base/Utilities/SmallDictionary.cs b/src/Avalonia.Base/Utilities/SmallDictionary.cs index bfb3a32bac..99283522af 100644 --- a/src/Avalonia.Base/Utilities/SmallDictionary.cs +++ b/src/Avalonia.Base/Utilities/SmallDictionary.cs @@ -177,7 +177,6 @@ internal struct InlineDictionary : IEnumerable : IEnumerable _cachedKnownColorNames = new Dictionary(); private static readonly object _displayNameCacheMutex = new object(); private static readonly object _knownColorCacheMutex = new object(); - private static readonly KnownColor[] _knownColors = -#if NET6_0_OR_GREATER - Enum.GetValues(); -#else - (KnownColor[])Enum.GetValues(typeof(KnownColor)); -#endif + private static readonly KnownColor[] _knownColors = Enum.GetValues(); /// /// Gets the relative (perceptual) luminance/brightness of the given color. diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 12e8e97640..c61a220990 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -181,11 +181,7 @@ namespace Avalonia.Controls { if (run.Length > 0) { -#if NET6_0_OR_GREATER builder.Append(run.Text.Span); -#else - builder.Append(run.Text.Span.ToArray()); -#endif } } diff --git a/src/Avalonia.DesignerSupport/Remote/HtmlTransport/HtmlTransport.cs b/src/Avalonia.DesignerSupport/Remote/HtmlTransport/HtmlTransport.cs index 869e682940..a27f532292 100644 --- a/src/Avalonia.DesignerSupport/Remote/HtmlTransport/HtmlTransport.cs +++ b/src/Avalonia.DesignerSupport/Remote/HtmlTransport/HtmlTransport.cs @@ -10,7 +10,6 @@ using System.Threading; using System.Threading.Tasks; using Avalonia.Remote.Protocol; using Avalonia.Remote.Protocol.Viewport; -using Avalonia.Utilities; using InputProtocol = Avalonia.Remote.Protocol.Input; namespace Avalonia.DesignerSupport.Remote.HtmlTransport @@ -354,13 +353,13 @@ namespace Avalonia.DesignerSupport.Remote.HtmlTransport ? null : modifiersText .Split(',') - .Select(x => EnumHelper.Parse(x, true)) + .Select(x => Enum.Parse(x, true)) .ToArray(); private static InputProtocol.MouseButton ParseMouseButton(string buttonText) => string.IsNullOrWhiteSpace(buttonText) ? InputProtocol.MouseButton.None - : EnumHelper.Parse(buttonText, true); + : Enum.Parse(buttonText, true); private static double ParseDouble(string text) => double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); diff --git a/src/Avalonia.Desktop/AppBuilderDesktopExtensions.cs b/src/Avalonia.Desktop/AppBuilderDesktopExtensions.cs index 6c85ca40fc..9fd370e0dd 100644 --- a/src/Avalonia.Desktop/AppBuilderDesktopExtensions.cs +++ b/src/Avalonia.Desktop/AppBuilderDesktopExtensions.cs @@ -1,4 +1,4 @@ -using Avalonia.Compatibility; +using System; using Avalonia.Logging; namespace Avalonia @@ -17,17 +17,17 @@ namespace Avalonia // Additionally, by having a hard reference to each assembly, // we verify that the assemblies are in the final .deps.json file // so .NET Core knows where to load the assemblies from. - if (OperatingSystemEx.IsWindows()) + if (OperatingSystem.IsWindows()) { LoadWin32(builder); LoadSkia(builder); } - else if (OperatingSystemEx.IsMacOS()) + else if (OperatingSystem.IsMacOS()) { LoadAvaloniaNative(builder); LoadSkia(builder); } - else if (OperatingSystemEx.IsLinux()) + else if (OperatingSystem.IsLinux()) { LoadX11(builder); LoadSkia(builder); diff --git a/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs b/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs index c8f7d388f5..16995f91a8 100644 --- a/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs +++ b/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs @@ -6,9 +6,7 @@ using Avalonia.Platform.Storage; namespace Avalonia.Dialogs { -#if NET6_0_OR_GREATER [SupportedOSPlatform("windows"), SupportedOSPlatform("macos"), SupportedOSPlatform("linux")] -#endif public static class ManagedFileDialogExtensions { internal class ManagedStorageProviderFactory : IStorageProviderFactory diff --git a/src/Avalonia.Native/AvaloniaNativePlatform.cs b/src/Avalonia.Native/AvaloniaNativePlatform.cs index 40bc2ca71e..34f0ee766e 100644 --- a/src/Avalonia.Native/AvaloniaNativePlatform.cs +++ b/src/Avalonia.Native/AvaloniaNativePlatform.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; -using Avalonia.Compatibility; -using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Platform; using Avalonia.Input; @@ -47,8 +45,8 @@ namespace Avalonia.Native { if (options.AvaloniaNativeLibraryPath != null) { - var lib = NativeLibraryEx.Load(options.AvaloniaNativeLibraryPath); - if (!NativeLibraryEx.TryGetExport(lib, "CreateAvaloniaNative", out var proc)) + var lib = NativeLibrary.Load(options.AvaloniaNativeLibraryPath); + if (!NativeLibrary.TryGetExport(lib, "CreateAvaloniaNative", out var proc)) { throw new InvalidOperationException( "Unable to get \"CreateAvaloniaNative\" export from AvaloniaNativeLibrary library"); diff --git a/src/Avalonia.Native/IAvnMenu.cs b/src/Avalonia.Native/IAvnMenu.cs index b526d91924..05588eb57c 100644 --- a/src/Avalonia.Native/IAvnMenu.cs +++ b/src/Avalonia.Native/IAvnMenu.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using Avalonia.Compatibility; -using Avalonia.Reactive; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -51,7 +49,7 @@ namespace Avalonia.Native.Interop.Impl private void UpdateTitle(string? title) { - if (OperatingSystemEx.IsMacOS()) + if (OperatingSystem.IsMacOS()) { // macOS does not process access key markers, so remove them. title = AccessText.RemoveAccessKeyMarker(title); diff --git a/src/Avalonia.Native/IAvnMenuItem.cs b/src/Avalonia.Native/IAvnMenuItem.cs index f9b3e2d2d6..441b974df2 100644 --- a/src/Avalonia.Native/IAvnMenuItem.cs +++ b/src/Avalonia.Native/IAvnMenuItem.cs @@ -1,11 +1,9 @@ using System; using System.IO; -using Avalonia.Compatibility; -using Avalonia.Reactive; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Media.Imaging; -using Avalonia.Platform.Interop; +using Avalonia.Reactive; namespace Avalonia.Native.Interop { @@ -26,7 +24,7 @@ namespace Avalonia.Native.Interop.Impl private void UpdateTitle(string? title) { - if (OperatingSystemEx.IsMacOS()) + if (OperatingSystem.IsMacOS()) { // macOS does not process access key markers, so remove them. title = AccessText.RemoveAccessKeyMarker(title); diff --git a/src/Avalonia.OpenGL/Egl/EglInterface.cs b/src/Avalonia.OpenGL/Egl/EglInterface.cs index 5c93d2084b..492c646da2 100644 --- a/src/Avalonia.OpenGL/Egl/EglInterface.cs +++ b/src/Avalonia.OpenGL/Egl/EglInterface.cs @@ -1,8 +1,5 @@ using System; using System.Runtime.InteropServices; -using Avalonia.Compatibility; -using Avalonia.Platform; -using Avalonia.Platform.Interop; using Avalonia.SourceGenerator; namespace Avalonia.OpenGL.Egl @@ -25,9 +22,9 @@ namespace Avalonia.OpenGL.Egl static Func Load() { - if(OperatingSystemEx.IsLinux()) + if(OperatingSystem.IsLinux()) return Load("libEGL.so.1"); - if (OperatingSystemEx.IsAndroid()) + if (OperatingSystem.IsAndroid()) return Load("libEGL.so"); throw new PlatformNotSupportedException(); @@ -35,8 +32,8 @@ namespace Avalonia.OpenGL.Egl static Func Load(string library) { - var lib = NativeLibraryEx.Load(library); - return (s) => NativeLibraryEx.TryGetExport(lib, s, out var address) ? address : default; + var lib = NativeLibrary.Load(library); + return (s) => NativeLibrary.TryGetExport(lib, s, out var address) ? address : default; } // ReSharper disable UnassignedGetOnlyAutoProperty diff --git a/src/Avalonia.OpenGL/OpenGlException.cs b/src/Avalonia.OpenGL/OpenGlException.cs index d7a42c4400..616fe0f187 100644 --- a/src/Avalonia.OpenGL/OpenGlException.cs +++ b/src/Avalonia.OpenGL/OpenGlException.cs @@ -37,11 +37,7 @@ namespace Avalonia.OpenGL { try { -#if NET6_0_OR_GREATER var errorName = Enum.GetName(errorCode); -#else - var errorName = Enum.GetName(typeof(T), errorCode); -#endif return new OpenGlException( $"{funcName} failed with error {errorName} (0x{errorCode.ToString("X")})", intErrorCode); } diff --git a/src/Avalonia.Themes.Fluent/ColorPaletteResourcesCollection.cs b/src/Avalonia.Themes.Fluent/ColorPaletteResourcesCollection.cs index 828cb2e899..3b2173e133 100644 --- a/src/Avalonia.Themes.Fluent/ColorPaletteResourcesCollection.cs +++ b/src/Avalonia.Themes.Fluent/ColorPaletteResourcesCollection.cs @@ -130,9 +130,7 @@ internal sealed class ColorPaletteResourcesCollection : ResourceProvider, IDicti } bool IDictionary.TryGetValue(ThemeVariant key, -#if NET6_0_OR_GREATER [MaybeNullWhen(false)] -#endif out ColorPaletteResources value) { return _inner.TryGetValue(key, out value); diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 52456ca1b6..a57e1986ac 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -395,12 +395,7 @@ namespace Avalonia.X11 private static int GetProcessId() { -#if NET6_0_OR_GREATER var pid = Environment.ProcessId; -#else - using var currentProcess = Process.GetCurrentProcess(); - var pid = currentProcess.Id; -#endif return pid; } diff --git a/src/Linux/Avalonia.LinuxFramebuffer/Input/EvDev/EvDevDevice.cs b/src/Linux/Avalonia.LinuxFramebuffer/Input/EvDev/EvDevDevice.cs index 92d8bbf268..85f190a848 100644 --- a/src/Linux/Avalonia.LinuxFramebuffer/Input/EvDev/EvDevDevice.cs +++ b/src/Linux/Avalonia.LinuxFramebuffer/Input/EvDev/EvDevDevice.cs @@ -18,11 +18,7 @@ namespace Avalonia.LinuxFramebuffer.Input.EvDev Fd = fd; _dev = dev; Name = Marshal.PtrToStringAnsi(NativeUnsafeMethods.libevdev_get_name(_dev)); -#if NET6_0_OR_GREATER foreach (EvType type in Enum.GetValues()) -#else - foreach (EvType type in Enum.GetValues(typeof(EvType))) -#endif { if (NativeUnsafeMethods.libevdev_has_event_type(dev, type) != 0) EventTypes.Add(type); diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/OnPlatformExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/OnPlatformExtension.cs index e393fbc7cf..0b1ae4638f 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/OnPlatformExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/OnPlatformExtension.cs @@ -1,4 +1,4 @@ -using Avalonia.Compatibility; +using System; using Avalonia.Metadata; namespace Avalonia.Markup.Xaml.MarkupExtensions; @@ -76,13 +76,13 @@ public abstract class OnPlatformExtensionBase : IAddChild // IsOSPlatform might work better with trimming in the future, so it should be re-visited after .NET 8/9. return option switch { - "WINDOWS" => OperatingSystemEx.IsWindows(), - "OSX" => OperatingSystemEx.IsMacOS(), - "LINUX" => OperatingSystemEx.IsLinux(), - "ANDROID" => OperatingSystemEx.IsAndroid(), - "IOS" => OperatingSystemEx.IsIOS(), - "BROWSER" => OperatingSystemEx.IsBrowser(), - _ => OperatingSystemEx.IsOSPlatform(option) + "WINDOWS" => OperatingSystem.IsWindows(), + "OSX" => OperatingSystem.IsMacOS(), + "LINUX" => OperatingSystem.IsLinux(), + "ANDROID" => OperatingSystem.IsAndroid(), + "IOS" => OperatingSystem.IsIOS(), + "BROWSER" => OperatingSystem.IsBrowser(), + _ => OperatingSystem.IsOSPlatform(option) }; } } diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs index f4b7864185..71d0902c0d 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs @@ -6,9 +6,7 @@ using Avalonia.Data; namespace Avalonia.Markup.Xaml.MarkupExtensions { [RequiresUnreferencedCode(TrimmingMessages.ReflectionBindingRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER [RequiresDynamicCode(TrimmingMessages.ReflectionBindingRequiresDynamicCodeMessage)] -#endif public sealed class ReflectionBindingExtension : ReflectionBinding { /// diff --git a/src/Markup/Avalonia.Markup.Xaml/XamlIl/Runtime/XamlIlRuntimeHelpers.cs b/src/Markup/Avalonia.Markup.Xaml/XamlIl/Runtime/XamlIlRuntimeHelpers.cs index f98a1cc60b..d2f8fe437e 100644 --- a/src/Markup/Avalonia.Markup.Xaml/XamlIl/Runtime/XamlIlRuntimeHelpers.cs +++ b/src/Markup/Avalonia.Markup.Xaml/XamlIl/Runtime/XamlIlRuntimeHelpers.cs @@ -129,22 +129,11 @@ namespace Avalonia.Markup.Xaml.XamlIl.Runtime return false; } -#if NET6_0_OR_GREATER if (!CollectionsMarshal.AsSpan(resourceNodes).SequenceEqual(lastResourceNodes)) { cachedResourceNodes = null; return false; } -#else - for (var i = 0; i < lastResourceNodes.Length; ++i) - { - if (lastResourceNodes[i] != resourceNodes[i]) - { - cachedResourceNodes = null; - return false; - } - } -#endif cachedResourceNodes = lastResourceNodes; return true; diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index 4e624b6479..931c17fd0e 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup/Data/Binding.cs @@ -8,9 +8,7 @@ namespace Avalonia.Data; /// for new code. /// [RequiresUnreferencedCode(TrimmingMessages.ReflectionBindingRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER - [RequiresDynamicCode(TrimmingMessages.ReflectionBindingRequiresDynamicCodeMessage)] -#endif +[RequiresDynamicCode(TrimmingMessages.ReflectionBindingRequiresDynamicCodeMessage)] public class Binding : ReflectionBinding { public Binding() { } diff --git a/src/Shared/ModuleInitializer.cs b/src/Shared/ModuleInitializer.cs index e58b296474..d65fd7ffa4 100644 --- a/src/Shared/ModuleInitializer.cs +++ b/src/Shared/ModuleInitializer.cs @@ -2,7 +2,7 @@ namespace System.Runtime.CompilerServices { #if NETSTANDARD2_0 [AttributeUsage(AttributeTargets.Method)] - internal sealed class ModuleInitializerAttribute : Attribute + internal sealed class ModuleInitializerhAttribute : Attribute { } diff --git a/src/Skia/Avalonia.Skia/Helpers/PixelFormatHelper.cs b/src/Skia/Avalonia.Skia/Helpers/PixelFormatHelper.cs index f1cd39b2a7..21c9f3cdf9 100644 --- a/src/Skia/Avalonia.Skia/Helpers/PixelFormatHelper.cs +++ b/src/Skia/Avalonia.Skia/Helpers/PixelFormatHelper.cs @@ -1,5 +1,4 @@ -using Avalonia.Compatibility; -using Avalonia.Platform; +using Avalonia.Platform; using SkiaSharp; namespace Avalonia.Skia.Helpers diff --git a/src/Skia/Avalonia.Skia/SKRoundRectCache.cs b/src/Skia/Avalonia.Skia/SKRoundRectCache.cs index b84c61303d..7b041fb9f1 100644 --- a/src/Skia/Avalonia.Skia/SKRoundRectCache.cs +++ b/src/Skia/Avalonia.Skia/SKRoundRectCache.cs @@ -91,13 +91,7 @@ namespace Avalonia.Skia base.Clear(); // Clear out the cache of SKPoint arrays. -#if NET6_0_OR_GREATER _radiiCache.Clear(); -#else - while (_radiiCache.TryTake(out var item)) - { - } -#endif } } } diff --git a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs index bcf3e782b4..2ae4294dcb 100644 --- a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs +++ b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs @@ -18,15 +18,8 @@ using UIA = Avalonia.Win32.Automation.Interop; namespace Avalonia.Win32.Automation { -#if NET8_0_OR_GREATER [GeneratedComClass] internal partial class AutomationNode : -#else -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode("Requires .NET COM interop")] -#endif - internal partial class AutomationNode : MarshalByRefObject, -#endif IRawElementProviderSimple, IRawElementProviderSimple2, IRawElementProviderFragment, @@ -202,9 +195,7 @@ namespace Avalonia.Win32.Automation public void SetFocus() => InvokeSync(() => Peer.SetFocus()); -#if NET6_0_OR_GREATER [return: NotNullIfNotNull(nameof(peer))] -#endif public static AutomationNode? GetOrCreate(AutomationPeer? peer) { return peer is null ? null : s_nodes.GetValue(peer, Create); @@ -434,12 +425,7 @@ namespace Avalonia.Win32.Automation private static int GetProcessId() { -#if NET6_0_OR_GREATER return Environment.ProcessId; -#else - using var proccess = Process.GetCurrentProcess(); - return proccess.Id; -#endif } } } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IDockProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IDockProvider.cs index c65e76366a..80509e8aa7 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IDockProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IDockProvider.cs @@ -14,12 +14,7 @@ internal enum DockPosition None } -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("159bc72c-4ad3-485e-9637-d7052edf0146")] internal partial interface IDockProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IExpandCollapseProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IExpandCollapseProvider.cs index ee04a24ce7..7e5cc048de 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IExpandCollapseProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IExpandCollapseProvider.cs @@ -4,12 +4,7 @@ using Avalonia.Automation; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("d847d3a5-cab0-4a98-8c32-ecb45c59ad24")] internal partial interface IExpandCollapseProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IGridItemProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IGridItemProvider.cs index f764427417..541f656a34 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IGridItemProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IGridItemProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("d02541f1-fb81-4d64-ae32-f520f8a6dbd1")] internal partial interface IGridItemProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IGridProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IGridProvider.cs index cfc295fa7d..b3a7cfd47a 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IGridProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IGridProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("b17d6187-0907-464b-a168-0ef17a1572b1")] internal partial interface IGridProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IInvokeProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IInvokeProvider.cs index 7737a1bb74..1ef0cac481 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IInvokeProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IInvokeProvider.cs @@ -7,12 +7,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("54fcb24b-e18e-47a2-b4d3-eccbe77599a2")] internal partial interface IInvokeProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IMultipleViewProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IMultipleViewProvider.cs index dcd0d35e74..c21790acab 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IMultipleViewProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IMultipleViewProvider.cs @@ -5,12 +5,8 @@ using Avalonia.Win32.Automation.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif + [Guid("6278cab1-b556-4a1a-b4e0-418acc523201")] internal partial interface IMultipleViewProvider { @@ -18,8 +14,6 @@ internal partial interface IMultipleViewProvider string GetViewName(int viewId); void SetCurrentView(int viewId); int GetCurrentView(); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif int[] GetSupportedViews(); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRangeValueProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRangeValueProvider.cs index a8f921fa26..089b1d65ad 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRangeValueProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRangeValueProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("36dc7aef-33e6-4691-afe1-2be7274b3d33")] internal partial interface IRangeValueProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderAdviseEvents.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderAdviseEvents.cs index 9d2e16ab94..6f73b73790 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderAdviseEvents.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderAdviseEvents.cs @@ -5,24 +5,15 @@ using Avalonia.Win32.Automation.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("a407b27b-0f6d-4427-9292-473c7bf93258")] internal partial interface IRawElementProviderAdviseEvents { void AdviseEventAdded(int eventId, -#if NET8_0_OR_GREATER [MarshalUsing(typeof(SafeArrayMarshaller))] -#endif int[] properties); void AdviseEventRemoved(int eventId, -#if NET8_0_OR_GREATER [MarshalUsing(typeof(SafeArrayMarshaller))] -#endif int[] properties); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragment.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragment.cs index 0bb56c8b68..7550ecf0df 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragment.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragment.cs @@ -15,24 +15,15 @@ internal enum NavigateDirection LastChild, } -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("f7063da8-8359-439c-9297-bbc5299a7d87")] internal partial interface IRawElementProviderFragment { IRawElementProviderFragment? Navigate(NavigateDirection direction); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif int[]? GetRuntimeId(); Rect GetBoundingRectangle(); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[]? GetEmbeddedFragmentRoots(); void SetFocus(); IRawElementProviderFragmentRoot? GetFragmentRoot(); diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragmentRoot.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragmentRoot.cs index 349e58b7b3..430559665a 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragmentRoot.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderFragmentRoot.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("620ce2a5-ab8f-40a9-86cb-de3c75599b58")] internal partial interface IRawElementProviderFragmentRoot { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs index bf70aa1f40..6f2233d7f6 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs @@ -307,21 +307,14 @@ internal enum UiaLiveSetting Assertive, }; -#if NET8_0_OR_GREATER [GeneratedComInterface] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("d6dd68d1-86fd-4332-8666-9abedea2d24c")] internal partial interface IRawElementProviderSimple { ProviderOptions GetProviderOptions(); [return: MarshalAs(UnmanagedType.Interface)] object? GetPatternProvider(int patternId); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(ComVariantMarshaller))] -#endif object? GetPropertyValue(int propertyId); IRawElementProviderSimple? GetHostRawElementProvider(); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple2.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple2.cs index 7bd48f4e78..ed62c621d4 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple2.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple2.cs @@ -3,24 +3,9 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("a0a839a9-8da1-4a82-806a-8e0d44e79f56")] internal partial interface IRawElementProviderSimple2 : IRawElementProviderSimple { -#if !NET8_0_OR_GREATER - // Hack for the legacy COM interop - // See https://learn.microsoft.com/en-us/dotnet/standard/native-interop/comwrappers-source-generation#derived-interfaces - new ProviderOptions GetProviderOptions(); - [return: MarshalAs(UnmanagedType.Interface)] - new object? GetPatternProvider(int patternId); - [return: MarshalAs(UnmanagedType.Struct)] - new object? GetPropertyValue(int propertyId); - new IRawElementProviderSimple? GetHostRawElementProvider(); -#endif void ShowContextMenu(); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IScrollItemProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IScrollItemProvider.cs index 8e022c988d..20d5690de3 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IScrollItemProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IScrollItemProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("2360c714-4bf1-4b26-ba65-9b21316127eb")] internal partial interface IScrollItemProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IScrollProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IScrollProvider.cs index 1113685592..bda05f540f 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IScrollProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IScrollProvider.cs @@ -4,12 +4,7 @@ using Avalonia.Automation.Provider; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("b38b8077-1fc3-42a5-8cae-d40c2215055a")] internal partial interface IScrollProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionItemProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionItemProvider.cs index a4f4d56e54..d36cb605a5 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionItemProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionItemProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("2acad808-b2d4-452d-a407-91ff1ad167b2")] internal partial interface ISelectionItemProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionProvider.cs index 2a30c97f18..3db1e3a4a4 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ISelectionProvider.cs @@ -5,18 +5,11 @@ using Avalonia.Win32.Automation.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("fb8b03af-3bdf-48d4-bd36-1a65793be168")] internal partial interface ISelectionProvider { -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[] GetSelection(); [return: MarshalAs(UnmanagedType.Bool)] diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ISynchronizedInputProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ISynchronizedInputProvider.cs index 75850461c3..24f8948f12 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ISynchronizedInputProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ISynchronizedInputProvider.cs @@ -13,12 +13,8 @@ internal enum SynchronizedInputType MouseRightButtonUp = 0x10, MouseRightButtonDown = 0x20 } -#if NET8_0_OR_GREATER + [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("29db1a06-02ce-4cf7-9b42-565d4fab20ee")] internal partial interface ISynchronizedInputProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ITableItemProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ITableItemProvider.cs index 75bdf48bb8..33b9d30062 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ITableItemProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ITableItemProvider.cs @@ -5,21 +5,12 @@ using Avalonia.Win32.Automation.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("b9734fa6-771f-4d78-9c90-2517999349cd")] internal partial interface ITableItemProvider { -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[] GetRowHeaderItems(); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[] GetColumnHeaderItems(); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ITableProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ITableProvider.cs index 6acacbdf5d..5f4bc589d6 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ITableProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ITableProvider.cs @@ -12,22 +12,14 @@ internal enum RowOrColumnMajor ColumnMajor, Indeterminate, } -#if NET8_0_OR_GREATER + [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("9c860395-97b3-490a-b52a-858cc22af166")] internal partial interface ITableProvider { -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[] GetRowHeaders(); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[] GetColumnHeaders(); RowOrColumnMajor GetRowOrColumnMajor(); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ITextProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ITextProvider.cs index 63a92ce547..d3c94ecd07 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ITextProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ITextProvider.cs @@ -15,22 +15,13 @@ internal enum SupportedTextSelection Multiple, } -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("3589c92c-63f3-4367-99bb-ada653b77cf2")] internal partial interface ITextProvider { -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif ITextRangeProvider[] GetSelection(); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif ITextRangeProvider[] GetVisibleRanges(); ITextRangeProvider RangeFromChild(IRawElementProviderSimple childElement); diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ITextRangeProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ITextRangeProvider.cs index 18f167a87a..72f43ef8b2 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ITextRangeProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ITextRangeProvider.cs @@ -22,12 +22,7 @@ internal enum TextUnit Document = 6, } -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("5347ad7b-c355-46f8-aff5-909033582f63")] internal partial interface ITextRangeProvider { @@ -42,22 +37,16 @@ internal partial interface ITextRangeProvider void ExpandToEnclosingUnit(TextUnit unit); ITextRangeProvider FindAttribute(int attribute, -#if NET8_0_OR_GREATER [MarshalUsing(typeof(ComVariantMarshaller))] -#endif object value, [MarshalAs(UnmanagedType.Bool)] bool backward); ITextRangeProvider FindText( [MarshalAs(UnmanagedType.BStr)] string text, [MarshalAs(UnmanagedType.Bool)] bool backward, [MarshalAs(UnmanagedType.Bool)] bool ignoreCase); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(ComVariantMarshaller))] -#endif object GetAttributeValue(int attribute); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif double[] GetBoundingRectangles(); IRawElementProviderSimple GetEnclosingElement(); [return: MarshalAs(UnmanagedType.BStr)] @@ -72,8 +61,6 @@ internal partial interface ITextRangeProvider void AddToSelection(); void RemoveFromSelection(); void ScrollIntoView([MarshalAs(UnmanagedType.Bool)] bool alignToTop); -#if NET8_0_OR_GREATER [return: MarshalUsing(typeof(SafeArrayMarshaller))] -#endif IRawElementProviderSimple[] GetChildren(); } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IToggleProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IToggleProvider.cs index 85dd3c0f97..157accebee 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IToggleProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IToggleProvider.cs @@ -4,12 +4,7 @@ using Avalonia.Automation.Provider; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("56d00bd0-c4f4-433c-a836-1a52a57e0892")] internal partial interface IToggleProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/ITransformProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/ITransformProvider.cs index baabaf3664..947a850028 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/ITransformProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/ITransformProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("6829ddc4-4f91-4ffa-b86f-bd3e2987cb4c")] internal partial interface ITransformProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IValueProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IValueProvider.cs index 6d7526c054..b86a33e4cf 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IValueProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IValueProvider.cs @@ -3,12 +3,7 @@ using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Interop; -#if NET8_0_OR_GREATER [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("c7935180-6fb3-4201-b174-7df73adbf64a")] internal partial interface IValueProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IWindowProvider.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IWindowProvider.cs index 65cec9a1f5..7f7a096aac 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IWindowProvider.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IWindowProvider.cs @@ -20,12 +20,8 @@ internal enum WindowInteractionState BlockedByModalWindow, NotResponding } -#if NET8_0_OR_GREATER + [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] -#else -[ComImport()] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -#endif [Guid("987df77b-db06-4d77-8f8a-86a9c3bb90b9")] internal partial interface IWindowProvider { diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreProviderApi.cs b/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreProviderApi.cs index 36e6902846..eb2384fb5b 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreProviderApi.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreProviderApi.cs @@ -66,7 +66,6 @@ namespace Avalonia.Win32.Automation.Interop { public const int UIA_E_ELEMENTNOTENABLED = unchecked((int)0x80040200); -#if NET7_0_OR_GREATER [LibraryImport("UIAutomationCore.dll", StringMarshalling = StringMarshalling.Utf8)] [return: MarshalAs(UnmanagedType.Bool)] public static partial bool UiaClientsAreListening(); @@ -88,31 +87,5 @@ namespace Avalonia.Win32.Automation.Interop [LibraryImport("UIAutomationCore.dll", StringMarshalling = StringMarshalling.Utf8)] public static partial int UiaDisconnectProvider(IRawElementProviderSimple? provider); -#else - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern bool UiaClientsAreListening(); - - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern IntPtr UiaReturnRawElementProvider(IntPtr hwnd, IntPtr wParam, IntPtr lParam, - IRawElementProviderSimple? el); - - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern int UiaHostProviderFromHwnd(IntPtr hwnd, - [MarshalAs(UnmanagedType.Interface)] out IRawElementProviderSimple provider); - - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern int UiaRaiseAutomationEvent(IRawElementProviderSimple? provider, int id); - - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern int UiaRaiseAutomationPropertyChangedEvent(IRawElementProviderSimple? provider, int id, - object? oldValue, object? newValue); - - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern int UiaRaiseStructureChangedEvent(IRawElementProviderSimple? provider, - StructureChangeType structureChangeType, int[]? runtimeId, int runtimeIdLen); - - [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)] - public static extern int UiaDisconnectProvider(IRawElementProviderSimple? provider); -#endif } } diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreTypesApi.cs b/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreTypesApi.cs index a8a83dfd57..d9cc51843e 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreTypesApi.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/UiaCoreTypesApi.cs @@ -24,37 +24,12 @@ namespace Avalonia.Win32.Automation.Interop internal const int UIA_E_NOCLICKABLEPOINT = unchecked((int)0x80040202); internal const int UIA_E_PROXYASSEMBLYNOTLOADED = unchecked((int)0x80040203); - internal static bool IsNetComInteropAvailable - { - get - { -#if NET8_0_OR_GREATER - return true; -#else -#if NET6_0_OR_GREATER - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) - { - return false; - } -#endif - var comConfig = - AppContext.GetData("System.Runtime.InteropServices.BuiltInComInterop.IsSupported") as string; - return comConfig == null || bool.Parse(comConfig); -#endif - } - } - internal static int UiaLookupId(AutomationIdType type, ref Guid guid) { return RawUiaLookupId(type, ref guid); } -#if NET7_0_OR_GREATER [LibraryImport("UIAutomationCore.dll", EntryPoint = "UiaLookupId", StringMarshalling = StringMarshalling.Utf8)] private static partial int RawUiaLookupId(AutomationIdType type, ref Guid guid); -#else - [DllImport("UIAutomationCore.dll", EntryPoint = "UiaLookupId", CharSet = CharSet.Unicode)] - private static extern int RawUiaLookupId(AutomationIdType type, ref Guid guid); -#endif } } diff --git a/src/Windows/Avalonia.Win32.Automation/InteropAutomationNode.cs b/src/Windows/Avalonia.Win32.Automation/InteropAutomationNode.cs index d6f698513c..d97561ba11 100644 --- a/src/Windows/Avalonia.Win32.Automation/InteropAutomationNode.cs +++ b/src/Windows/Avalonia.Win32.Automation/InteropAutomationNode.cs @@ -10,11 +10,7 @@ namespace Avalonia.Win32.Automation; /// /// An automation node which serves as the root of an embedded native control automation tree. /// -#if NET8_0_OR_GREATER [GeneratedComClass] -#elif NET6_0_OR_GREATER - [RequiresUnreferencedCode("Requires .NET COM interop")] -#endif internal partial class InteropAutomationNode : AutomationNode, IRawElementProviderFragmentRoot { private readonly IntPtr _handle; diff --git a/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariant.cs b/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariant.cs index a7fcd6776c..0fa78f300e 100644 --- a/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariant.cs +++ b/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariant.cs @@ -8,7 +8,6 @@ using System.Runtime.InteropServices; namespace Avalonia.Win32.Automation.Marshalling; -#if NET7_0_OR_GREATER // Oversimplified ComVariant implementation based on https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/Marshalling/ComVariant.cs // Available [StructLayout(LayoutKind.Explicit)] @@ -306,4 +305,3 @@ internal struct ComVariant : IDisposable private set => _typeUnion._vt = (ushort)value; } } -#endif diff --git a/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariantMarshaller.cs b/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariantMarshaller.cs index 02ae8eca28..f525e91951 100644 --- a/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariantMarshaller.cs +++ b/src/Windows/Avalonia.Win32.Automation/Marshalling/ComVariantMarshaller.cs @@ -1,5 +1,4 @@ -#if NET7_0_OR_GREATER -global using ComVariantMarshaller = Avalonia.Win32.Automation.Marshalling.ComVariantMarshaller; +global using ComVariantMarshaller = Avalonia.Win32.Automation.Marshalling.ComVariantMarshaller; using System.Runtime.InteropServices.Marshalling; namespace Avalonia.Win32.Automation.Marshalling; @@ -13,4 +12,3 @@ internal static class ComVariantMarshaller public static void Free(ComVariant unmanaged) => unmanaged.Dispose(); } -#endif diff --git a/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayMarshaller.cs b/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayMarshaller.cs index fe7fe54976..ef3af4a3b2 100644 --- a/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayMarshaller.cs +++ b/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayMarshaller.cs @@ -1,5 +1,4 @@ -#if NET7_0_OR_GREATER -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices.Marshalling; @@ -18,4 +17,3 @@ internal static class SafeArrayMarshaller where T : notnull public static void Free(SafeArrayRef unmanaged) => unmanaged.Destroy(); } -#endif diff --git a/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayRef.cs b/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayRef.cs index 14158c4ee3..f27c67e36e 100644 --- a/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayRef.cs +++ b/src/Windows/Avalonia.Win32.Automation/Marshalling/SafeArrayRef.cs @@ -8,13 +8,11 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using Avalonia.Controls.Documents; // ReSharper disable InconsistentNaming namespace Avalonia.Win32.Automation.Marshalling; #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value -#if NET7_0_OR_GREATER internal unsafe partial struct SafeArrayRef { private SAFEARRAY* _ptr; @@ -340,4 +338,3 @@ internal unsafe partial struct SafeArrayRef } } } -#endif diff --git a/src/Windows/Avalonia.Win32.Automation/RootAutomationNode.cs b/src/Windows/Avalonia.Win32.Automation/RootAutomationNode.cs index 3fd59b502e..9cd4ae4850 100644 --- a/src/Windows/Avalonia.Win32.Automation/RootAutomationNode.cs +++ b/src/Windows/Avalonia.Win32.Automation/RootAutomationNode.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Runtime.InteropServices.Marshalling; using Avalonia.Automation.Peers; @@ -9,15 +8,8 @@ using Avalonia.Win32.Automation.Interop; namespace Avalonia.Win32.Automation { -#if NET8_0_OR_GREATER [GeneratedComClass] internal partial class RootAutomationNode : AutomationNode, IRawElementProviderFragmentRoot -#else -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode("Requires .NET COM interop")] -#endif - internal partial class RootAutomationNode : AutomationNode, IRawElementProviderFragmentRoot -#endif { public RootAutomationNode(AutomationPeer peer) : base(peer) diff --git a/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs b/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs index 6104613761..198c6fca07 100644 --- a/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs +++ b/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs @@ -674,11 +674,7 @@ internal static class OleDataObjectHelper { var data = StringBuilderCache.GetStringAndRelease(buffer); var destSpan = new Span((void*)ptr, requiredSize); -#if NET8_0_OR_GREATER MemoryMarshal.Write(destSpan, in dropFiles); -#else - MemoryMarshal.Write(destSpan, ref dropFiles); -#endif fixed (char* sourcePtr = data) { diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index ab86830b73..5295e2c03a 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Avalonia.Automation.Peers; using Avalonia.Controls; @@ -155,10 +153,7 @@ namespace Avalonia.Win32 // The first and foremost thing to do - notify the TopLevel Closed?.Invoke(); - if (UiaCoreTypesApi.IsNetComInteropAvailable) - { - UiaCoreProviderApi.UiaReturnRawElementProvider(_hwnd, IntPtr.Zero, IntPtr.Zero, null); - } + UiaCoreProviderApi.UiaReturnRawElementProvider(_hwnd, IntPtr.Zero, IntPtr.Zero, null); // We need to release IMM context and state to avoid leaks. if (Imm32InputMethod.Current.Hwnd == _hwnd) @@ -224,7 +219,7 @@ namespace Avalonia.Win32 } var requestIcon = (Icons)wParam; - var requestDpi = (uint) lParam; + var requestDpi = (uint)lParam; if (requestDpi == 0) { @@ -937,7 +932,7 @@ namespace Avalonia.Win32 return IntPtr.Zero; } case WindowsMessage.WM_GETOBJECT: - if ((long)lParam == uiaRootObjectId && UiaCoreTypesApi.IsNetComInteropAvailable && _owner?.FocusRoot is Control control) + if ((long)lParam == uiaRootObjectId && _owner?.FocusRoot is Control control) { var peer = ControlAutomationPeer.CreatePeerForElement(control); var node = AutomationNode.GetOrCreate(peer); @@ -946,7 +941,7 @@ namespace Avalonia.Win32 break; case WindowsMessage.WM_WINDOWPOSCHANGED: var winPos = Marshal.PtrToStructure(lParam); - if((winPos.flags & (uint)SetWindowPosFlags.SWP_SHOWWINDOW) != 0) + if ((winPos.flags & (uint)SetWindowPosFlags.SWP_SHOWWINDOW) != 0) { OnShowHideMessage(true); } @@ -973,7 +968,7 @@ namespace Avalonia.Win32 if (message == WindowsMessage.WM_KEYDOWN) { - if(e is RawKeyEventArgs args && args.Key == Key.ImeProcessed) + if (e is RawKeyEventArgs args && args.Key == Key.ImeProcessed) { _ignoreWmChar = true; } @@ -1118,7 +1113,7 @@ namespace Avalonia.Win32 var x = mp.x > 32767 ? mp.x - 65536 : mp.x; var y = mp.y > 32767 ? mp.y - 65536 : mp.y; - if(mp.time <= prevMovePoint.time || mp.time >= movePoint.time) + if (mp.time <= prevMovePoint.time || mp.time >= movePoint.time) continue; s_sortedPoints.Add(new InternalPoint diff --git a/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj b/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj index e4b1d0baf7..f70c46b8c2 100644 --- a/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj +++ b/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj @@ -14,7 +14,6 @@ - diff --git a/src/tools/Avalonia.Designer.HostApp/TinyJson.cs b/src/tools/Avalonia.Designer.HostApp/TinyJson.cs index d48475887c..8c8b8282d7 100644 --- a/src/tools/Avalonia.Designer.HostApp/TinyJson.cs +++ b/src/tools/Avalonia.Designer.HostApp/TinyJson.cs @@ -344,11 +344,7 @@ namespace TinyJson static object ParseObject(Type type, string json) { -#if NET6_0_OR_GREATER object instance = RuntimeHelpers.GetUninitializedObject(type); -#else - object instance = FormatterServices.GetUninitializedObject(type); -#endif //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON List elems = Split(json); From a94621fd799fa863b2b8d385fdb27a19f4dd506e Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 13 Mar 2026 23:16:27 +0900 Subject: [PATCH 18/50] Use Numerge package (#20892) * Remove numerge submodule * Use numerge nuget package --- .gitmodules | 3 --- Directory.Packages.props | 1 + external/Numerge | 1 - nukebuild/_build.csproj | 6 +----- 4 files changed, 2 insertions(+), 9 deletions(-) delete mode 160000 external/Numerge diff --git a/.gitmodules b/.gitmodules index d1463ad26b..2d648aa7ba 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "Numerge"] - path = external/Numerge - url = https://github.com/kekekeks/Numerge.git [submodule "XamlX"] path = external/XamlX url = https://github.com/kekekeks/XamlX.git diff --git a/Directory.Packages.props b/Directory.Packages.props index 58650e1729..9441c68cac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,6 +36,7 @@ + diff --git a/external/Numerge b/external/Numerge deleted file mode 160000 index 5530e1cbe9..0000000000 --- a/external/Numerge +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5530e1cbe9e105ff4ebc9da1f4af3253a8756754 diff --git a/nukebuild/_build.csproj b/nukebuild/_build.csproj index 92f71351c4..c4d20ad4b7 100644 --- a/nukebuild/_build.csproj +++ b/nukebuild/_build.csproj @@ -12,6 +12,7 @@ + @@ -24,11 +25,6 @@ - - - From ae6a085ebcd002f8f4f3a47973dc6217e32261c3 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Fri, 13 Mar 2026 19:27:47 +0500 Subject: [PATCH 19/50] Don't tick with render loop when app is idle (#20873) * Don't tick with render loop when app is idle * Update src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * wip * wip * api diff * fixes * Address review: clear wakeupPending at tick start, guard CarbonEmissionsHack subscriptions - Clear _wakeupPending at start of TimerTick so wakeups already processed by the current tick don't force an unnecessary extra tick - Guard CarbonEmissionsHack against duplicate subscriptions using a private attached property Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix lock-order inversion in Add/Remove vs TimerTick Move Wakeup() and Stop() calls outside the _items lock in Add/Remove to prevent deadlock with TimerTick which acquires _timerLock then _items. Add/Remove are UI-thread-only so the extracted logic remains safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: remove unused usings, guard double Stop(), fix SleepLoop extra frame - Remove unused using directives from IRenderLoopTask.cs - Guard TimerTick Stop() with _running check to prevent double Stop() when Remove() already stopped the timer - SleepLoopRenderTimer: use WaitOne(timeout) instead of Thread.Sleep so Stop() can interrupt the sleep, and recheck _stopped before Tick Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix DisplayLinkTimer foreground handler bypassing render loop state Only resume the display link on WillEnterForeground if the timer was calling Start() to avoid setting _stopped=false when the render loop had the timer stopped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix DisplayLinkTimer thread safety, revert global NU5104 suppression - Stop() now only sets _stopped flag; OnLinkTick() self-pauses the CADisplayLink from the timer thread to avoid thread-affinity issues - Revert NU5104 global suppression in SharedVersion.props Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cap _ticksSinceLastCommit to prevent int overflow Stop incrementing once it reaches CommitGraceTicks to prevent wrapping negative and keeping the render loop awake indefinitely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove Start/Stop from IRenderTimer, merge into Tick setter Timer start/stop is now controlled entirely by setting the Tick property: non-null starts, null stops. This eliminates the explicit Start()/Stop() methods from IRenderTimer, making the API simpler. DefaultRenderLoop controls the timer purely through Tick assignment under its _timerLock. A new _hasItems flag tracks subscriber presence since Tick is now transient (null when idle). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review comments on timer thread safety and guards - ChoreographerTimer: add _frameCallbackActive guard to prevent double PostFrameCallback from both Tick setter and SubscribeView - ServerCompositor: cap _ticksSinceLastCommit at int.MaxValue - SleepLoopRenderTimer: make _tick volatile, remove _stopped recheck (guard moved to DefaultRenderLoop) - DefaultRenderLoop: add _running check at tick start to drop late ticks - ThreadProxyRenderTimer: add lock for internal state manipulation - DisplayLinkTimer: add lock for all internal state manipulation - Re-add NU5104 suppression to SharedVersion.props Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make _hasItems volatile for cross-thread visibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: guard against redundant starts in DefaultRenderTimer, make _tick volatile across all timers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove CarbonEmissionsHack, revert iOS/Android timers to always-ticking - Delete CarbonEmissionsHack class and its XAML reference - Revert DisplayLinkTimer (iOS) to original always-ticking implementation - Revert ChoreographerTimer (Android) to original always-ticking implementation - Add TODO comments for future start/stop on RenderLoop request Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix DirectCompositionConnection WaitOne not respecting process exit cancellation Use WaitHandle.WaitAny with both _wakeEvent and cts.Token.WaitHandle so the loop can exit when ProcessExit fires while the timer is stopped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * timers * XML docs * Cache delegate --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/Avalonia.Headless.XUnit.nupkg.xml | 4 +- api/Avalonia.Headless.nupkg.xml | 4 +- api/Avalonia.Win32.Interoperability.nupkg.xml | 4 +- api/Avalonia.nupkg.xml | 148 +++++++++++++++++- build/SharedVersion.props | 2 +- .../Pages/ClipboardPage.xaml.cs | 5 +- .../Avalonia.Android/AndroidPlatform.cs | 4 +- src/Android/Avalonia.Android/AvaloniaView.cs | 2 +- .../Avalonia.Android/ChoreographerTimer.cs | 60 +++---- .../Rendering/Composition/Compositor.cs | 2 +- .../Server/ServerCompositionTarget.cs | 14 ++ .../Composition/Server/ServerCompositor.cs | 44 ++++-- .../Server/ServerCompositorAnimations.cs | 2 + .../Transport/BatchStreamArrayPool.cs | 75 ++++++--- .../Rendering/DefaultRenderTimer.cs | 44 ++---- src/Avalonia.Base/Rendering/IRenderLoop.cs | 16 +- .../Rendering/IRenderLoopTask.cs | 5 +- src/Avalonia.Base/Rendering/IRenderTimer.cs | 13 +- src/Avalonia.Base/Rendering/RenderLoop.cs | 140 ++++++++++++----- .../Rendering/SleepLoopRenderTimer.cs | 61 ++++---- .../Rendering/ThreadProxyRenderTimer.cs | 65 +++++--- .../Remote/PreviewerWindowingPlatform.cs | 2 +- src/Avalonia.DesignerSupport/Remote/Stubs.cs | 9 +- src/Avalonia.Native/AvaloniaNativePlatform.cs | 2 +- .../AvaloniaNativeRenderTimer.cs | 48 +++--- src/Avalonia.X11/X11Platform.cs | 2 +- .../Rendering/BrowserRenderTimer.cs | 13 +- .../Rendering/BrowserSharedRenderLoop.cs | 2 +- .../AvaloniaHeadlessPlatform.cs | 6 +- .../LinuxFramebufferPlatform.cs | 2 +- .../DirectCompositionConnection.cs | 30 +++- .../Avalonia.Win32/DirectX/DxgiConnection.cs | 31 +++- src/Windows/Avalonia.Win32/Win32Platform.cs | 2 +- .../Composition/WinUiCompositorConnection.cs | 30 +++- src/iOS/Avalonia.iOS/DisplayLinkTimer.cs | 12 +- src/iOS/Avalonia.iOS/Platform.cs | 2 +- .../Composition/CompositionAnimationTests.cs | 2 +- .../Compositor/CompositionTargetUpdate.cs | 6 +- .../Composition/DirectFbCompositionTests.cs | 2 +- .../Avalonia.RenderTests/ManualRenderTimer.cs | 2 +- .../Avalonia.RenderTests/TestRenderHelper.cs | 2 +- .../CompositorTestServices.cs | 7 +- tests/Avalonia.UnitTests/RendererMocks.cs | 2 +- 43 files changed, 632 insertions(+), 298 deletions(-) diff --git a/api/Avalonia.Headless.XUnit.nupkg.xml b/api/Avalonia.Headless.XUnit.nupkg.xml index c87cf909fe..15a56561b9 100644 --- a/api/Avalonia.Headless.XUnit.nupkg.xml +++ b/api/Avalonia.Headless.XUnit.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -73,4 +73,4 @@ baseline/Avalonia.Headless.XUnit/lib/net8.0/Avalonia.Headless.XUnit.dll current/Avalonia.Headless.XUnit/lib/net8.0/Avalonia.Headless.XUnit.dll - + \ No newline at end of file diff --git a/api/Avalonia.Headless.nupkg.xml b/api/Avalonia.Headless.nupkg.xml index 229047057a..435df92d13 100644 --- a/api/Avalonia.Headless.nupkg.xml +++ b/api/Avalonia.Headless.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -37,4 +37,4 @@ baseline/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll current/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll - + \ No newline at end of file diff --git a/api/Avalonia.Win32.Interoperability.nupkg.xml b/api/Avalonia.Win32.Interoperability.nupkg.xml index 3672bb9b99..33fc2ac062 100644 --- a/api/Avalonia.Win32.Interoperability.nupkg.xml +++ b/api/Avalonia.Win32.Interoperability.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -37,4 +37,4 @@ baseline/Avalonia.Win32.Interoperability/lib/net8.0-windows7.0/Avalonia.Win32.Interoperability.dll current/Avalonia.Win32.Interoperability/lib/net8.0-windows7.0/Avalonia.Win32.Interoperability.dll - + \ No newline at end of file diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index b4c8bba386..5fe2e48f60 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -1597,6 +1597,42 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.Start + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.Stop + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.IRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.IRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Rendering.SceneInvalidatedEventArgs.#ctor(Avalonia.Rendering.IRenderRoot,Avalonia.Rect) @@ -1609,6 +1645,30 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Rendering.SleepLoopRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.SleepLoopRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.ThreadProxyRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.ThreadProxyRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Utilities.AvaloniaResourcesIndexReaderWriter.WriteResources(System.IO.Stream,System.Collections.Generic.List{System.ValueTuple{System.String,System.Int32,System.Func{System.IO.Stream}}}) @@ -3031,6 +3091,42 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.Start + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.DefaultRenderTimer.Stop + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.IRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.IRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Rendering.SceneInvalidatedEventArgs.#ctor(Avalonia.Rendering.IRenderRoot,Avalonia.Rect) @@ -3043,6 +3139,30 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Rendering.SleepLoopRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.SleepLoopRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.ThreadProxyRenderTimer.add_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Rendering.ThreadProxyRenderTimer.remove_Tick(System.Action{System.TimeSpan}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Utilities.AvaloniaResourcesIndexReaderWriter.WriteResources(System.IO.Stream,System.Collections.Generic.List{System.ValueTuple{System.String,System.Int32,System.Func{System.IO.Stream}}}) @@ -3979,6 +4099,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0006 + M:Avalonia.Rendering.IRenderTimer.Start + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Rendering.IRenderTimer.Stop + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0006 P:Avalonia.Input.IInputRoot.FocusRoot @@ -4291,6 +4423,18 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0006 + M:Avalonia.Rendering.IRenderTimer.Start + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Rendering.IRenderTimer.Stop + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0006 P:Avalonia.Input.IInputRoot.FocusRoot @@ -5077,4 +5221,4 @@ baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll - + \ No newline at end of file diff --git a/build/SharedVersion.props b/build/SharedVersion.props index 37d14a5647..b8c0dd4d43 100644 --- a/build/SharedVersion.props +++ b/build/SharedVersion.props @@ -8,7 +8,7 @@ https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link https://github.com/AvaloniaUI/Avalonia/ true - $(NoWarn);CS1591 + $(NoWarn);CS1591;NU5104 MIT Icon.png Avalonia is a cross-platform UI framework for .NET providing a flexible styling system and supporting a wide range of Operating Systems such as Windows, Linux, macOS and with experimental support for Android, iOS and WebAssembly. diff --git a/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs b/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs index 2b87ceb7b1..4cdde6b824 100644 --- a/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs @@ -34,7 +34,10 @@ namespace ControlCatalog.Pages { InitializeComponent(); _clipboardLastDataObjectChecker = - new DispatcherTimer(TimeSpan.FromSeconds(0.5), default, CheckLastDataObject); + new DispatcherTimer(TimeSpan.FromSeconds(0.5), default, CheckLastDataObject) + { + IsEnabled = false + }; using var asset = AssetLoader.Open(new Uri("avares://ControlCatalog/Assets/image1.jpg")); _defaultImage = new Bitmap(asset); diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs index 7a3059cb65..460aaec5ca 100644 --- a/src/Android/Avalonia.Android/AndroidPlatform.cs +++ b/src/Android/Avalonia.Android/AndroidPlatform.cs @@ -76,19 +76,21 @@ namespace Avalonia.Android public static AndroidPlatformOptions? Options { get; private set; } internal static Compositor? Compositor { get; private set; } + internal static ChoreographerTimer? Timer { get; private set; } public static void Initialize() { Options = AvaloniaLocator.Current.GetService() ?? new AndroidPlatformOptions(); Dispatcher.InitializeUIThreadDispatcher(new AndroidDispatcherImpl()); + Timer = new ChoreographerTimer(); AvaloniaLocator.CurrentMutable .Bind().ToTransient() .Bind().ToConstant(new WindowingPlatformStub()) .Bind().ToSingleton() .Bind().ToSingleton() .Bind().ToSingleton() - .Bind().ToConstant(new ChoreographerTimer()) + .Bind().ToConstant(RenderLoop.FromTimer(Timer)) .Bind().ToSingleton() .Bind().ToConstant(new KeyGestureFormatInfo(new Dictionary() { })) .Bind().ToConstant(new AndroidActivatableLifetime()); diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index 86b96772ce..d8df486bb3 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -100,7 +100,7 @@ namespace Avalonia.Android return; if (isVisible && _timerSubscription == null) { - if (AvaloniaLocator.Current.GetService() is ChoreographerTimer timer) + if (AndroidPlatform.Timer is { } timer) { _timerSubscription = timer.SubscribeView(this); } diff --git a/src/Android/Avalonia.Android/ChoreographerTimer.cs b/src/Android/Avalonia.Android/ChoreographerTimer.cs index adca9c72ce..9bc8e78a52 100644 --- a/src/Android/Avalonia.Android/ChoreographerTimer.cs +++ b/src/Android/Avalonia.Android/ChoreographerTimer.cs @@ -18,10 +18,9 @@ namespace Avalonia.Android private readonly AutoResetEvent _event = new(false); private readonly GCHandle _timerHandle; private readonly HashSet _views = new(); - private Action? _tick; + private bool _pendingCallback; private long _lastTime; - private int _count; public ChoreographerTimer() { @@ -40,28 +39,13 @@ namespace Avalonia.Android public bool RunsInBackground => true; - public event Action Tick + public Action? Tick { - add + get => _tick; + set { - lock (_lock) - { - _tick += value; - _count++; - - if (_count == 1) - { - PostFrameCallback(_choreographer.Task.Result, GCHandle.ToIntPtr(_timerHandle)); - } - } - } - remove - { - lock (_lock) - { - _tick -= value; - _count--; - } + _tick = value; + PostFrameCallbackIfNeeded(); } } @@ -70,20 +54,14 @@ namespace Avalonia.Android lock (_lock) { _views.Add(view); - - if (_views.Count == 1) - { - PostFrameCallback(_choreographer.Task.Result, GCHandle.ToIntPtr(_timerHandle)); - } + PostFrameCallbackIfNeeded(); } return Disposable.Create( () => { - lock (_lock) - { + lock (_lock) _views.Remove(view); - } } ); } @@ -109,14 +87,28 @@ namespace Avalonia.Android } } + private void PostFrameCallbackIfNeeded() + { + lock (_lock) + { + if(_pendingCallback) + return; + + if (_tick == null || _views.Count == 0) + return; + + _pendingCallback = true; + + PostFrameCallback(_choreographer.Task.Result, GCHandle.ToIntPtr(_timerHandle)); + } + } + private void DoFrameCallback(long frameTimeNanos, IntPtr data) { lock (_lock) { - if (_count > 0 && _views.Count > 0) - { - PostFrameCallback(_choreographer.Task.Result, data); - } + _pendingCallback = false; + PostFrameCallbackIfNeeded(); _lastTime = frameTimeNanos; _event.Set(); } diff --git a/src/Avalonia.Base/Rendering/Composition/Compositor.cs b/src/Avalonia.Base/Rendering/Composition/Compositor.cs index 2398468456..2acda6c57d 100644 --- a/src/Avalonia.Base/Rendering/Composition/Compositor.cs +++ b/src/Avalonia.Base/Rendering/Composition/Compositor.cs @@ -51,7 +51,7 @@ namespace Avalonia.Rendering.Composition /// [PrivateApi] public Compositor(IPlatformGraphics? gpu, bool useUiThreadForSynchronousCommits = false) - : this(RenderLoop.LocatorAutoInstance, gpu, useUiThreadForSynchronousCommits) + : this(AvaloniaLocator.Current.GetRequiredService(), gpu, useUiThreadForSynchronousCommits) { } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs index f8382547b9..81a3c09b35 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs @@ -39,6 +39,11 @@ namespace Avalonia.Rendering.Composition.Server public ICompositionTargetDebugEvents? DebugEvents { get; set; } public int RenderedVisuals { get; set; } public int VisitedVisuals { get; set; } + + /// + /// Returns true if the target is enabled and has pending work but its render target was not ready. + /// + internal bool IsWaitingForReadyRenderTarget { get; private set; } public ServerCompositionTarget(ServerCompositor compositor, Func> surfaces) : base(compositor) @@ -125,6 +130,8 @@ namespace Avalonia.Rendering.Composition.Server public void Render() { + IsWaitingForReadyRenderTarget = false; + if (_disposed) return; @@ -143,11 +150,15 @@ namespace Avalonia.Rendering.Composition.Server try { if (_renderTarget == null && !_compositor.IsReadyToCreateRenderTarget(_surfaces())) + { + IsWaitingForReadyRenderTarget = IsEnabled; return; + } _renderTarget ??= _compositor.CreateRenderTarget(_surfaces()); } catch (RenderTargetNotReadyException) { + IsWaitingForReadyRenderTarget = IsEnabled; return; } catch (RenderTargetCorruptedException) @@ -164,7 +175,10 @@ namespace Avalonia.Rendering.Composition.Server return; if (!_renderTarget.IsReady) + { + IsWaitingForReadyRenderTarget = IsEnabled; return; + } var needLayer = _overlays.RequireLayer // Check if we don't need overlays // Check if render target can be rendered to directly and preserves the previous frame diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositor.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositor.cs index 76e649407f..b8cc5afca2 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositor.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositor.cs @@ -44,6 +44,9 @@ namespace Avalonia.Rendering.Composition.Server public CompositionOptions Options { get; } public ServerCompositorAnimations Animations { get; } public ReadbackIndices Readback { get; } = new(); + + private int _ticksSinceLastCommit; + private const int CommitGraceTicks = 10; public ServerCompositor(IRenderLoop renderLoop, IPlatformGraphics? platformGraphics, CompositionOptions options, @@ -64,6 +67,7 @@ namespace Avalonia.Rendering.Composition.Server { lock (_batches) _batches.Enqueue(batch); + _renderLoop.Wakeup(); } internal void UpdateServerTime() => ServerNow = Clock.Elapsed; @@ -72,6 +76,7 @@ namespace Avalonia.Rendering.Composition.Server readonly List _reusableToNotifyRenderedList = new(); void ApplyPendingBatches() { + bool hadBatches = false; while (true) { CompositionBatch batch; @@ -119,7 +124,13 @@ namespace Avalonia.Rendering.Composition.Server _reusableToNotifyProcessedList.Add(batch); LastBatchId = batch.SequenceId; + hadBatches = true; } + + if (hadBatches) + _ticksSinceLastCommit = 0; + else if (_ticksSinceLastCommit < int.MaxValue) + _ticksSinceLastCommit++; } void ReadServerJobs(BatchStreamReader reader, Queue queue, object endMarker) @@ -171,8 +182,10 @@ namespace Avalonia.Rendering.Composition.Server _reusableToNotifyRenderedList.Clear(); } - public void Render() => Render(true); - public void Render(bool catchExceptions) + bool IRenderLoopTask.Render() => ExecuteRender(true); + public void Render(bool catchExceptions) => ExecuteRender(catchExceptions); + + private bool ExecuteRender(bool catchExceptions) { if (Dispatcher.UIThread.CheckAccess()) { @@ -182,7 +195,7 @@ namespace Avalonia.Rendering.Composition.Server try { using (Dispatcher.UIThread.DisableProcessing()) - RenderReentrancySafe(catchExceptions); + return RenderReentrancySafe(catchExceptions); } finally { @@ -190,10 +203,10 @@ namespace Avalonia.Rendering.Composition.Server } } else - RenderReentrancySafe(catchExceptions); + return RenderReentrancySafe(catchExceptions); } - private void RenderReentrancySafe(bool catchExceptions) + private bool RenderReentrancySafe(bool catchExceptions) { lock (_lock) { @@ -202,7 +215,7 @@ namespace Avalonia.Rendering.Composition.Server try { _safeThread = Thread.CurrentThread; - RenderCore(catchExceptions); + return RenderCore(catchExceptions); } finally { @@ -235,17 +248,16 @@ namespace Avalonia.Rendering.Composition.Server return Stopwatch.GetElapsedTime(compositorGlobalPassesStarted); } - private void RenderCore(bool catchExceptions) + private bool RenderCore(bool catchExceptions) { - UpdateServerTime(); var compositorGlobalPassesElapsed = ExecuteGlobalPasses(); try { - if(!RenderInterface.IsReady) - return; + if (!RenderInterface.IsReady) + return true; RenderInterface.EnsureValidBackendContext(); ExecuteServerJobs(_receivedJobQueue); @@ -263,6 +275,18 @@ namespace Avalonia.Rendering.Composition.Server { Logger.TryGet(LogEventLevel.Error, LogArea.Visual)?.Log(this, "Exception when rendering: {Error}", e); } + + // Request a tick if we have active animations or if there are recent batches + if (Animations.NeedNextTick || _ticksSinceLastCommit < CommitGraceTicks) + return true; + + // Request a tick if we had unready targets in the last tick, to check if they are ready next time + foreach (var target in _activeTargets) + if (target.IsWaitingForReadyRenderTarget) + return true; + + // Otherwise there is no need to waste CPU cycles, tell the timer to pause + return false; } public void AddCompositionTarget(ServerCompositionTarget target) diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositorAnimations.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositorAnimations.cs index 1f2c7dedb8..0e59cd8f03 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositorAnimations.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositorAnimations.cs @@ -30,6 +30,8 @@ internal class ServerCompositorAnimations _dirtyAnimatedObjects.Clear(); } + public bool NeedNextTick => _clockItems.Count > 0; + public void AddDirtyAnimatedObject(ServerObjectAnimations obj) { if (_dirtyAnimatedObjects.Add(obj)) diff --git a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs index 7e1c9e711f..d9003659a1 100644 --- a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs +++ b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; using System.Threading; @@ -13,52 +14,75 @@ namespace Avalonia.Rendering.Composition.Transport; /// internal abstract class BatchStreamPoolBase : IDisposable { + private readonly Action>? _startTimer; readonly Stack _pool = new(); bool _disposed; int _usage; readonly int[] _usageStatistics = new int[10]; int _usageStatisticsSlot; - readonly bool _reclaimImmediately; + private readonly WeakReference> _updateRef; + private readonly Dispatcher? _reclaimOnDispatcher; + private bool _timerIsRunning; + private ulong _currentUpdateTick, _lastActivityTick; public int CurrentUsage => _usage; public int CurrentPool => _pool.Count; public BatchStreamPoolBase(bool needsFinalize, bool reclaimImmediately, Action>? startTimer = null) { + _startTimer = startTimer; if(!needsFinalize) - GC.SuppressFinalize(needsFinalize); + GC.SuppressFinalize(this); - var updateRef = new WeakReference>(this); - if ( - reclaimImmediately - || Dispatcher.FromThread(Thread.CurrentThread) == null) - _reclaimImmediately = true; - else - StartUpdateTimer(startTimer, updateRef); + _updateRef = new WeakReference>(this); + _reclaimOnDispatcher = !reclaimImmediately ? Dispatcher.FromThread(Thread.CurrentThread) : null; + EnsureUpdateTimer(); } + - static void StartUpdateTimer(Action>? startTimer, WeakReference> updateRef) + void EnsureUpdateTimer() { - Func timerProc = () => + if (_timerIsRunning || !NeedsTimer) + return; + + var timerProc = GetTimerProc(_updateRef); + + if (_startTimer != null) + _startTimer(timerProc); + else { - if (updateRef.TryGetTarget(out var target)) + if (_reclaimOnDispatcher != null) { - target.UpdateStatistics(); - return true; + if (_reclaimOnDispatcher.CheckAccess()) + DispatcherTimer.Run(timerProc, TimeSpan.FromSeconds(1)); + else + _reclaimOnDispatcher.Post( + () => DispatcherTimer.Run(timerProc, TimeSpan.FromSeconds(1)), + DispatcherPriority.Normal); } + } + + _timerIsRunning = true; + // Explicit capture + static Func GetTimerProc(WeakReference> updateRef) => () => + { + if (updateRef.TryGetTarget(out var target)) + return target.UpdateTimerTick(); return false; }; - if (startTimer != null) - startTimer(timerProc); - else - DispatcherTimer.Run(timerProc, TimeSpan.FromSeconds(1)); } - private void UpdateStatistics() + [MemberNotNullWhen(true, nameof(_reclaimOnDispatcher))] + private bool NeedsTimer => _reclaimOnDispatcher != null && + _currentUpdateTick - _lastActivityTick < (uint)_usageStatistics.Length * 2 + 1; + private bool ReclaimImmediately => _reclaimOnDispatcher == null; + + private bool UpdateTimerTick() { lock (_pool) { + _currentUpdateTick++; var maximumUsage = _usageStatistics.Max(); var recentlyUsedPooledSlots = maximumUsage - _usage; var keepSlots = Math.Max(recentlyUsedPooledSlots, 10); @@ -67,9 +91,17 @@ internal abstract class BatchStreamPoolBase : IDisposable _usageStatisticsSlot = (_usageStatisticsSlot + 1) % _usageStatistics.Length; _usageStatistics[_usageStatisticsSlot] = 0; + + return _timerIsRunning = NeedsTimer; } } + private void OnActivity() + { + _lastActivityTick = _currentUpdateTick; + EnsureUpdateTimer(); + } + protected abstract T CreateItem(); protected virtual void ClearItem(T item) @@ -90,6 +122,8 @@ internal abstract class BatchStreamPoolBase : IDisposable if (_usageStatistics[_usageStatisticsSlot] < _usage) _usageStatistics[_usageStatisticsSlot] = _usage; + OnActivity(); + if (_pool.Count != 0) return _pool.Pop(); } @@ -103,9 +137,10 @@ internal abstract class BatchStreamPoolBase : IDisposable lock (_pool) { _usage--; - if (!_disposed && !_reclaimImmediately) + if (!_disposed && !ReclaimImmediately) { _pool.Push(item); + OnActivity(); return; } } diff --git a/src/Avalonia.Base/Rendering/DefaultRenderTimer.cs b/src/Avalonia.Base/Rendering/DefaultRenderTimer.cs index 102cc30e87..cc24086305 100644 --- a/src/Avalonia.Base/Rendering/DefaultRenderTimer.cs +++ b/src/Avalonia.Base/Rendering/DefaultRenderTimer.cs @@ -15,8 +15,7 @@ namespace Avalonia.Rendering [PrivateApi] public class DefaultRenderTimer : IRenderTimer { - private int _subscriberCount; - private Action? _tick; + private volatile Action? _tick; private IDisposable? _subscription; /// @@ -36,40 +35,28 @@ namespace Avalonia.Rendering public int FramesPerSecond { get; } /// - public event Action Tick + public Action? Tick { - add + get => _tick; + set { - _tick += value; - - if (_subscriberCount++ == 0) + if (value != null) { - Start(); + _tick = value; + _subscription ??= StartCore(InternalTick); } - } - - remove - { - if (--_subscriberCount == 0) + else { - Stop(); + _subscription?.Dispose(); + _subscription = null; + _tick = null; } - - _tick -= value; } } /// public virtual bool RunsInBackground => true; - /// - /// Starts the timer. - /// - protected void Start() - { - _subscription = StartCore(InternalTick); - } - /// /// Provides the implementation of starting the timer. /// @@ -85,15 +72,6 @@ namespace Avalonia.Rendering return new Timer(_ => tick(TimeSpan.FromMilliseconds(Environment.TickCount)), null, interval, interval); } - /// - /// Stops the timer. - /// - protected void Stop() - { - _subscription?.Dispose(); - _subscription = null; - } - private void InternalTick(TimeSpan tickCount) { _tick?.Invoke(tickCount); diff --git a/src/Avalonia.Base/Rendering/IRenderLoop.cs b/src/Avalonia.Base/Rendering/IRenderLoop.cs index bf2c221b03..e887832ebc 100644 --- a/src/Avalonia.Base/Rendering/IRenderLoop.cs +++ b/src/Avalonia.Base/Rendering/IRenderLoop.cs @@ -9,8 +9,8 @@ namespace Avalonia.Rendering /// The render loop is responsible for advancing the animation timer and updating the scene /// graph for visible windows. /// - [NotClientImplementable] - internal interface IRenderLoop + [PrivateApi] + public interface IRenderLoop { /// /// Adds an update task. @@ -20,17 +20,23 @@ namespace Avalonia.Rendering /// Registered update tasks will be polled on each tick of the render loop after the /// animation timer has been pulsed. /// - void Add(IRenderLoopTask i); + internal void Add(IRenderLoopTask i); /// /// Removes an update task. /// /// The update task. - void Remove(IRenderLoopTask i); + internal void Remove(IRenderLoopTask i); /// /// Indicates if the rendering is done on a non-UI thread. /// - bool RunsInBackground { get; } + internal bool RunsInBackground { get; } + + /// + /// Wakes up the render loop to schedule the next tick. + /// Thread-safe: can be called from any thread. + /// + internal void Wakeup(); } } diff --git a/src/Avalonia.Base/Rendering/IRenderLoopTask.cs b/src/Avalonia.Base/Rendering/IRenderLoopTask.cs index f63855e651..67416cc155 100644 --- a/src/Avalonia.Base/Rendering/IRenderLoopTask.cs +++ b/src/Avalonia.Base/Rendering/IRenderLoopTask.cs @@ -1,10 +1,7 @@ -using System; -using System.Threading.Tasks; - namespace Avalonia.Rendering { internal interface IRenderLoopTask { - void Render(); + bool Render(); } } diff --git a/src/Avalonia.Base/Rendering/IRenderTimer.cs b/src/Avalonia.Base/Rendering/IRenderTimer.cs index 396e84d492..772dcf7656 100644 --- a/src/Avalonia.Base/Rendering/IRenderTimer.cs +++ b/src/Avalonia.Base/Rendering/IRenderTimer.cs @@ -10,16 +10,19 @@ namespace Avalonia.Rendering public interface IRenderTimer { /// - /// Raised when the render timer ticks to signal a new frame should be drawn. + /// Gets or sets the callback to be invoked when the timer ticks. + /// This property can be set from any thread, but it's guaranteed that it's not set concurrently + /// (i. e. render loop always does it under a lock). + /// Setting the value to null suggests the timer to stop ticking, however + /// timer is allowed to produce ticks on the previously set value as long as it stops doing so /// /// - /// This event can be raised on any thread; it is the responsibility of the subscriber to - /// switch execution to the right thread. + /// The callback can be invoked on any thread /// - event Action Tick; + Action? Tick { get; set; } /// - /// Indicates if the timer ticks on a non-UI thread + /// Indicates if the timer ticks on a non-UI thread. /// bool RunsInBackground { get; } } diff --git a/src/Avalonia.Base/Rendering/RenderLoop.cs b/src/Avalonia.Base/Rendering/RenderLoop.cs index 846cce7a23..9af9c54443 100644 --- a/src/Avalonia.Base/Rendering/RenderLoop.cs +++ b/src/Avalonia.Base/Rendering/RenderLoop.cs @@ -2,58 +2,52 @@ using System.Collections.Generic; using System.Threading; using Avalonia.Logging; +using Avalonia.Metadata; using Avalonia.Threading; namespace Avalonia.Rendering { /// - /// The application render loop. + /// Provides factory methods for creating instances. + /// + [PrivateApi] + public static class RenderLoop + { + /// + /// Creates an from an . + /// + public static IRenderLoop FromTimer(IRenderTimer timer) => new DefaultRenderLoop(timer); + } + + /// + /// Default implementation of the application render loop. /// /// /// The render loop is responsible for advancing the animation timer and updating the scene - /// graph for visible windows. + /// graph for visible windows. It owns the sleep/wake state machine: setting + /// to a non-null callback to start the timer and to null to + /// stop it, under a lock so that timer implementations never see concurrent changes. /// - internal class RenderLoop : IRenderLoop + internal class DefaultRenderLoop : IRenderLoop { private readonly List _items = new List(); private readonly List _itemsCopy = new List(); - private IRenderTimer? _timer; + private Action _tick; + private readonly IRenderTimer _timer; + private readonly object _timerLock = new(); private int _inTick; - - public static IRenderLoop LocatorAutoInstance - { - get - { - var loop = AvaloniaLocator.Current.GetService(); - if (loop == null) - { - var timer = AvaloniaLocator.Current.GetRequiredService(); - AvaloniaLocator.CurrentMutable.Bind() - .ToConstant(loop = new RenderLoop(timer)); - } - - return loop; - } - } + private volatile bool _hasItems; + private bool _running; + private bool _wakeupPending; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The render timer. - public RenderLoop(IRenderTimer timer) + public DefaultRenderLoop(IRenderTimer timer) { _timer = timer; - } - - /// - /// Gets the render timer. - /// - protected IRenderTimer Timer - { - get - { - return _timer ??= AvaloniaLocator.Current.GetRequiredService(); - } + _tick = TimerTick; } /// @@ -62,14 +56,17 @@ namespace Avalonia.Rendering _ = i ?? throw new ArgumentNullException(nameof(i)); Dispatcher.UIThread.VerifyAccess(); + bool shouldStart; lock (_items) { _items.Add(i); + shouldStart = _items.Count == 1; + } - if (_items.Count == 1) - { - Timer.Tick += TimerTick; - } + if (shouldStart) + { + _hasItems = true; + Wakeup(); } } @@ -78,19 +75,48 @@ namespace Avalonia.Rendering { _ = i ?? throw new ArgumentNullException(nameof(i)); Dispatcher.UIThread.VerifyAccess(); + + bool shouldStop; lock (_items) { _items.Remove(i); + shouldStop = _items.Count == 0; + } - if (_items.Count == 0) + if (shouldStop) + { + _hasItems = false; + lock (_timerLock) { - Timer.Tick -= TimerTick; + if (_running) + { + _running = false; + _wakeupPending = false; + _timer.Tick = null; + } } } } /// - public bool RunsInBackground => Timer.RunsInBackground; + public bool RunsInBackground => _timer.RunsInBackground; + + /// + public void Wakeup() + { + lock (_timerLock) + { + if (_hasItems && !_running) + { + _running = true; + _timer.Tick = _tick; + } + else + { + _wakeupPending = true; + } + } + } private void TimerTick(TimeSpan time) { @@ -98,21 +124,49 @@ namespace Avalonia.Rendering { try { - + // Consume any pending wakeup — this tick will process its work. + // Only wakeups arriving during task execution will keep the timer running. + // Also drop late ticks that arrive after the timer was stopped. + lock (_timerLock) + { + if (!_running) + return; + _wakeupPending = false; + } + lock (_items) { _itemsCopy.Clear(); _itemsCopy.AddRange(_items); } - + var wantsNextTick = false; for (int i = 0; i < _itemsCopy.Count; i++) { - _itemsCopy[i].Render(); + wantsNextTick |= _itemsCopy[i].Render(); } _itemsCopy.Clear(); + if (!wantsNextTick) + { + lock (_timerLock) + { + if (!_running) + { + // Already stopped by Remove() + } + else if (_wakeupPending) + { + _wakeupPending = false; + } + else + { + _running = false; + _timer.Tick = null; + } + } + } } catch (Exception ex) { diff --git a/src/Avalonia.Base/Rendering/SleepLoopRenderTimer.cs b/src/Avalonia.Base/Rendering/SleepLoopRenderTimer.cs index 3ad4ea94d0..570dc4cb30 100644 --- a/src/Avalonia.Base/Rendering/SleepLoopRenderTimer.cs +++ b/src/Avalonia.Base/Rendering/SleepLoopRenderTimer.cs @@ -8,10 +8,10 @@ namespace Avalonia.Rendering [PrivateApi] public class SleepLoopRenderTimer : IRenderTimer { - private Action? _tick; - private int _count; - private readonly object _lock = new object(); - private bool _running; + private volatile Action? _tick; + private volatile bool _stopped = true; + private bool _threadStarted; + private readonly AutoResetEvent _wakeEvent = new(false); private readonly Stopwatch _st = Stopwatch.StartNew(); private readonly TimeSpan _timeBetweenTicks; @@ -19,28 +19,30 @@ namespace Avalonia.Rendering { _timeBetweenTicks = TimeSpan.FromSeconds(1d / fps); } - - public event Action Tick + + public Action? Tick { - add + get => _tick; + set { - lock (_lock) + if (value != null) { - _tick += value; - _count++; - if (_running) - return; - _running = true; - new Thread(LoopProc) { IsBackground = true }.Start(); + _tick = value; + _stopped = false; + if (!_threadStarted) + { + _threadStarted = true; + new Thread(LoopProc) { IsBackground = true }.Start(); + } + else + { + _wakeEvent.Set(); + } } - - } - remove - { - lock (_lock) + else { - _tick -= value; - _count--; + _stopped = true; + _tick = null; } } } @@ -52,24 +54,17 @@ namespace Avalonia.Rendering var lastTick = _st.Elapsed; while (true) { + if (_stopped) + _wakeEvent.WaitOne(); + var now = _st.Elapsed; var timeTillNextTick = lastTick + _timeBetweenTicks - now; - if (timeTillNextTick.TotalMilliseconds > 1) Thread.Sleep(timeTillNextTick); + if (timeTillNextTick.TotalMilliseconds > 1) + _wakeEvent.WaitOne(timeTillNextTick); lastTick = now = _st.Elapsed; - lock (_lock) - { - if (_count == 0) - { - _running = false; - return; - } - } _tick?.Invoke(now); - } } - - } } diff --git a/src/Avalonia.Base/Rendering/ThreadProxyRenderTimer.cs b/src/Avalonia.Base/Rendering/ThreadProxyRenderTimer.cs index 0f3387cd1a..d15d3a052e 100644 --- a/src/Avalonia.Base/Rendering/ThreadProxyRenderTimer.cs +++ b/src/Avalonia.Base/Rendering/ThreadProxyRenderTimer.cs @@ -12,8 +12,9 @@ public sealed class ThreadProxyRenderTimer : IRenderTimer private readonly Stopwatch _stopwatch; private readonly Thread _timerThread; private readonly AutoResetEvent _autoResetEvent; - private Action? _tick; - private int _subscriberCount; + private readonly object _lock = new(); + private volatile Action? _tick; + private volatile bool _active; private bool _registered; public ThreadProxyRenderTimer(IRenderTimer inner, int maxStackSize = 1 * 1024 * 1024) @@ -24,33 +25,54 @@ public sealed class ThreadProxyRenderTimer : IRenderTimer _timerThread = new Thread(RenderTimerThreadFunc, maxStackSize) { Name = "RenderTimerLoop", IsBackground = true }; } - public event Action Tick + public Action? Tick { - add + get => _tick; + set { - _tick += value; - - if (!_registered) + lock (_lock) { - _registered = true; - _timerThread.Start(); + if (value != null) + { + _tick = value; + _active = true; + EnsureStarted(); + _inner.Tick = InnerTick; + } + else + { + // Don't set _inner.Tick = null here — may be on the wrong thread. + // InnerTick will detect _active=false and clear _inner.Tick on the correct thread. + _active = false; + _tick = null; + } } + } + } - if (_subscriberCount++ == 0) - { - _inner.Tick += InnerTick; - } + public bool RunsInBackground => true; + + private void EnsureStarted() + { + if (!_registered) + { + _registered = true; + _stopwatch.Start(); + _timerThread.Start(); } + } - remove + private void InnerTick(TimeSpan obj) + { + lock (_lock) { - if (--_subscriberCount == 0) + if (!_active) { - _inner.Tick -= InnerTick; + _inner.Tick = null; + return; } - - _tick -= value; } + _autoResetEvent.Set(); } private void RenderTimerThreadFunc() @@ -60,11 +82,4 @@ public sealed class ThreadProxyRenderTimer : IRenderTimer _tick?.Invoke(_stopwatch.Elapsed); } } - - private void InnerTick(TimeSpan obj) - { - _autoResetEvent.Set(); - } - - public bool RunsInBackground => true; } diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs index 43eddb010d..bc94ff4388 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs @@ -55,7 +55,7 @@ namespace Avalonia.DesignerSupport.Remote .Bind().ToSingleton() .Bind().ToConstant(Keyboard) .Bind().ToSingleton() - .Bind().ToConstant(new UiThreadRenderTimer(60)) + .Bind().ToConstant(RenderLoop.FromTimer(new UiThreadRenderTimer(60))) .Bind().ToConstant(instance) .Bind().ToSingleton() .Bind().ToSingleton(); diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index d13d442e09..d9c8e333cb 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -69,16 +69,11 @@ namespace Avalonia.DesignerSupport.Remote private sealed class DummyRenderTimer : IRenderTimer { - public event Action Tick - { - add { } - remove { } - } - + public Action? Tick { get; set; } public bool RunsInBackground => false; } - public Compositor Compositor { get; } = new(new RenderLoop(new DummyRenderTimer()), null); + public Compositor Compositor { get; } = new(RenderLoop.FromTimer(new DummyRenderTimer()), null); public void Dispose() { diff --git a/src/Avalonia.Native/AvaloniaNativePlatform.cs b/src/Avalonia.Native/AvaloniaNativePlatform.cs index 34f0ee766e..825eb254be 100644 --- a/src/Avalonia.Native/AvaloniaNativePlatform.cs +++ b/src/Avalonia.Native/AvaloniaNativePlatform.cs @@ -122,7 +122,7 @@ namespace Avalonia.Native .Bind().ToConstant(this) .Bind().ToConstant(clipboardImpl) .Bind().ToConstant(clipboard) - .Bind().ToConstant(new ThreadProxyRenderTimer(new AvaloniaNativeRenderTimer(_factory.CreatePlatformRenderTimer()))) + .Bind().ToConstant(RenderLoop.FromTimer(new ThreadProxyRenderTimer(new AvaloniaNativeRenderTimer(_factory.CreatePlatformRenderTimer())))) .Bind().ToConstant(new MacOSMountedVolumeInfoProvider()) .Bind().ToConstant(new AvaloniaNativeDragSource(_factory)) .Bind().ToConstant(applicationPlatform) diff --git a/src/Avalonia.Native/AvaloniaNativeRenderTimer.cs b/src/Avalonia.Native/AvaloniaNativeRenderTimer.cs index 625de5d6bc..d484b82ac7 100644 --- a/src/Avalonia.Native/AvaloniaNativeRenderTimer.cs +++ b/src/Avalonia.Native/AvaloniaNativeRenderTimer.cs @@ -9,9 +9,8 @@ internal sealed class AvaloniaNativeRenderTimer : NativeCallbackBase, IRenderTim { private readonly IAvnPlatformRenderTimer _platformRenderTimer; private readonly Stopwatch _stopwatch; - private Action? _tick; - private int _subscriberCount; - private bool registered; + private volatile Action? _tick; + private bool _registered; public AvaloniaNativeRenderTimer(IAvnPlatformRenderTimer platformRenderTimer) { @@ -19,42 +18,41 @@ internal sealed class AvaloniaNativeRenderTimer : NativeCallbackBase, IRenderTim _stopwatch = Stopwatch.StartNew(); } - public event Action Tick + public Action? Tick { - add + get => _tick; + set { - _tick += value; - - if (!registered) + if (value != null) { - registered = true; - var registrationResult = _platformRenderTimer.RegisterTick(this); - if (registrationResult != 0) - { - throw new InvalidOperationException( - $"Avalonia.Native was not able to start the RenderTimer. Native error code is: {registrationResult}"); - } + _tick = value; + EnsureRegistered(); + _platformRenderTimer.Start(); } - - if (_subscriberCount++ == 0) + else { - _platformRenderTimer.Start(); + _platformRenderTimer.Stop(); + _tick = null; } } + } - remove + public bool RunsInBackground => _platformRenderTimer.RunsInBackground().FromComBool(); + + private void EnsureRegistered() + { + if (!_registered) { - if (--_subscriberCount == 0) + _registered = true; + var registrationResult = _platformRenderTimer.RegisterTick(this); + if (registrationResult != 0) { - _platformRenderTimer.Stop(); + throw new InvalidOperationException( + $"Avalonia.Native was not able to start the RenderTimer. Native error code is: {registrationResult}"); } - - _tick -= value; } } - public bool RunsInBackground => _platformRenderTimer.RunsInBackground().FromComBool(); - public void Run() { _tick?.Invoke(_stopwatch.Elapsed); diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index bff986d2d1..566b0d907a 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -87,7 +87,7 @@ namespace Avalonia.X11 : new X11PlatformThreading(this); Dispatcher.InitializeUIThreadDispatcher(DispatcherImpl); AvaloniaLocator.CurrentMutable - .Bind().ToConstant(timer) + .Bind().ToConstant(RenderLoop.FromTimer(timer)) .Bind().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Control)) .Bind().ToConstant(new KeyGestureFormatInfo(new Dictionary() { }, meta: "Super")) .Bind().ToFunc(() => KeyboardDevice) diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs index fea94d0248..de9a167954 100644 --- a/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs @@ -18,19 +18,16 @@ internal class BrowserRenderTimer : IRenderTimer public bool RunsInBackground { get; } - public event Action? Tick + public Action? Tick { - add + set { if (!BrowserWindowingPlatform.IsThreadingEnabled) StartOnThisThread(); - _tick += value; - } - remove - { - _tick -= value; + _tick = value; } + get => _tick; } public void StartOnThisThread() @@ -50,4 +47,4 @@ internal class BrowserRenderTimer : IRenderTimer tick.Invoke(TimeSpan.FromMilliseconds(timestamp)); } } -} +} \ No newline at end of file diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserSharedRenderLoop.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserSharedRenderLoop.cs index 8d454ff582..1d9d1248b8 100644 --- a/src/Browser/Avalonia.Browser/Rendering/BrowserSharedRenderLoop.cs +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserSharedRenderLoop.cs @@ -9,5 +9,5 @@ internal static class BrowserSharedRenderLoop { private static BrowserRenderTimer? s_browserUiRenderTimer; public static BrowserRenderTimer RenderTimer => s_browserUiRenderTimer ??= new BrowserRenderTimer(false); - public static Lazy RenderLoop = new(() => new RenderLoop(RenderTimer), true); + public static Lazy RenderLoop = new(() => Avalonia.Rendering.RenderLoop.FromTimer(RenderTimer), true); } diff --git a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs index b56e686d4b..8e44942d32 100644 --- a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs +++ b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs @@ -15,6 +15,7 @@ namespace Avalonia.Headless public static class AvaloniaHeadlessPlatform { internal static Compositor? Compositor { get; private set; } + private static RenderTimer? s_renderTimer; private class RenderTimer : DefaultRenderTimer { @@ -85,7 +86,7 @@ namespace Avalonia.Headless .Bind().ToSingleton() .Bind().ToSingleton() .Bind().ToConstant(new KeyboardDevice()) - .Bind().ToConstant(new RenderTimer(60)) + .Bind().ToConstant(Rendering.RenderLoop.FromTimer(s_renderTimer = new RenderTimer(60))) .Bind().ToConstant(new HeadlessWindowingPlatform(opts.FrameBufferFormat)) .Bind().ToSingleton() .Bind().ToConstant(new KeyGestureFormatInfo(new Dictionary() { })); @@ -99,9 +100,8 @@ namespace Avalonia.Headless /// Count of frames to be ticked on the timer. public static void ForceRenderTimerTick(int count = 1) { - var timer = AvaloniaLocator.Current.GetService() as RenderTimer; for (var c = 0; c < count; c++) - timer?.ForceTick(); + s_renderTimer?.ForceTick(); } } diff --git a/src/Linux/Avalonia.LinuxFramebuffer/LinuxFramebufferPlatform.cs b/src/Linux/Avalonia.LinuxFramebuffer/LinuxFramebufferPlatform.cs index ee8b85919e..3239957d73 100644 --- a/src/Linux/Avalonia.LinuxFramebuffer/LinuxFramebufferPlatform.cs +++ b/src/Linux/Avalonia.LinuxFramebuffer/LinuxFramebufferPlatform.cs @@ -64,7 +64,7 @@ namespace Avalonia.LinuxFramebuffer Dispatcher.InitializeUIThreadDispatcher(new EpollDispatcherImpl(new ManualRawEventGrouperDispatchQueueDispatcherInputProvider(EventGrouperDispatchQueue))); AvaloniaLocator.CurrentMutable - .Bind().ToConstant(timer) + .Bind().ToConstant(RenderLoop.FromTimer(timer)) .Bind().ToTransient() .Bind().ToConstant(new KeyboardDevice()) .Bind().ToSingleton() diff --git a/src/Windows/Avalonia.Win32/DComposition/DirectCompositionConnection.cs b/src/Windows/Avalonia.Win32/DComposition/DirectCompositionConnection.cs index 6c73fbeb9e..55d49ca30c 100644 --- a/src/Windows/Avalonia.Win32/DComposition/DirectCompositionConnection.cs +++ b/src/Windows/Avalonia.Win32/DComposition/DirectCompositionConnection.cs @@ -21,15 +21,36 @@ internal class DirectCompositionConnection : IRenderTimer, IWindowsSurfaceFactor { private static readonly Guid IID_IDCompositionDesktopDevice = Guid.Parse("5f4633fe-1e08-4cb8-8c75-ce24333f5602"); - public event Action? Tick; + private volatile Action? _tick; public bool RunsInBackground => true; private readonly DirectCompositionShared _shared; + private readonly AutoResetEvent _wakeEvent = new(false); + private volatile bool _stopped = true; public DirectCompositionConnection(DirectCompositionShared shared) { _shared = shared; } + + public Action? Tick + { + get => _tick; + set + { + if (value != null) + { + _tick = value; + _stopped = false; + _wakeEvent.Set(); + } + else + { + _stopped = true; + _tick = null; + } + } + } private static bool TryCreateAndRegisterCore() { @@ -52,7 +73,7 @@ internal class DirectCompositionConnection : IRenderTimer, IWindowsSurfaceFactor } AvaloniaLocator.CurrentMutable.Bind().ToConstant(connect); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(connect); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(RenderLoop.FromTimer(connect)); tcs.SetResult(true); } catch (Exception e) @@ -81,8 +102,11 @@ internal class DirectCompositionConnection : IRenderTimer, IWindowsSurfaceFactor { try { + if (_stopped) + WaitHandle.WaitAny([_wakeEvent, cts.Token.WaitHandle]); + device.WaitForCommitCompletion(); - Tick?.Invoke(_stopwatch.Elapsed); + _tick?.Invoke(_stopwatch.Elapsed); } catch (Exception ex) { diff --git a/src/Windows/Avalonia.Win32/DirectX/DxgiConnection.cs b/src/Windows/Avalonia.Win32/DirectX/DxgiConnection.cs index 678b15e0d7..9ee2f25c86 100644 --- a/src/Windows/Avalonia.Win32/DirectX/DxgiConnection.cs +++ b/src/Windows/Avalonia.Win32/DirectX/DxgiConnection.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Avalonia.Platform; using Avalonia.Platform.Surfaces; @@ -25,8 +26,10 @@ namespace Avalonia.Win32.DirectX public bool RunsInBackground => true; - public event Action? Tick; + private volatile Action? _tick; private readonly object _syncLock; + private readonly AutoResetEvent _wakeEvent = new(false); + private volatile bool _stopped = true; private IDXGIOutput? _output; @@ -37,6 +40,25 @@ namespace Avalonia.Win32.DirectX { _syncLock = syncLock; } + + public Action? Tick + { + get => _tick; + set + { + if (value != null) + { + _tick = value; + _stopped = false; + _wakeEvent.Set(); + } + else + { + _stopped = true; + _tick = null; + } + } + } public static bool TryCreateAndRegister() { @@ -70,6 +92,9 @@ namespace Avalonia.Win32.DirectX { try { + if (_stopped) + _wakeEvent.WaitOne(); + lock (_syncLock) { if (_output is not null) @@ -94,7 +119,7 @@ namespace Avalonia.Win32.DirectX // but theoretically someone could have a weirder setup out there DwmFlush(); } - Tick?.Invoke(_stopwatch.Elapsed); + _tick?.Invoke(_stopwatch.Elapsed); } } catch (Exception ex) @@ -199,7 +224,7 @@ namespace Avalonia.Win32.DirectX var connection = new DxgiConnection(pumpLock); AvaloniaLocator.CurrentMutable.Bind().ToConstant(connection); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(connection); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(RenderLoop.FromTimer(connection)); tcs.SetResult(true); connection.RunLoop(); } diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index f158d539ff..7903a62d8f 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -98,7 +98,7 @@ namespace Avalonia.Win32 .Bind().ToConstant(WindowsKeyboardDevice.Instance) .Bind().ToSingleton() .Bind().ToSingleton() - .Bind().ToConstant(renderTimer) + .Bind().ToConstant(RenderLoop.FromTimer(renderTimer)) .Bind().ToConstant(s_instance) .Bind().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Control) { diff --git a/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositorConnection.cs b/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositorConnection.cs index fa7ff2e7a3..d4cdfcbe42 100644 --- a/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositorConnection.cs +++ b/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositorConnection.cs @@ -17,8 +17,29 @@ namespace Avalonia.Win32.WinRT.Composition; internal class WinUiCompositorConnection : IRenderTimer, Win32.IWindowsSurfaceFactory { private readonly WinUiCompositionShared _shared; - public event Action? Tick; + private readonly AutoResetEvent _wakeEvent = new(false); + private volatile bool _stopped = true; + private volatile Action? _tick; public bool RunsInBackground => true; + + public Action? Tick + { + get => _tick; + set + { + if (value != null) + { + _tick = value; + _stopped = false; + _wakeEvent.Set(); + } + else + { + _stopped = true; + _tick = null; + } + } + } public WinUiCompositorConnection() { @@ -58,7 +79,7 @@ internal class WinUiCompositorConnection : IRenderTimer, Win32.IWindowsSurfaceFa }); connect = new WinUiCompositorConnection(); AvaloniaLocator.CurrentMutable.Bind().ToConstant(connect); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(connect); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(RenderLoop.FromTimer(connect)); tcs.SetResult(true); } @@ -102,8 +123,11 @@ internal class WinUiCompositorConnection : IRenderTimer, Win32.IWindowsSurfaceFa { _currentCommit?.Dispose(); _currentCommit = null; - _parent.Tick?.Invoke(_st.Elapsed); + _parent._tick?.Invoke(_st.Elapsed); + // Always schedule a commit so the current frame's work reaches DWM. ScheduleNextCommit(); + if (_parent._stopped) + _parent._wakeEvent.WaitOne(); } private void ScheduleNextCommit() diff --git a/src/iOS/Avalonia.iOS/DisplayLinkTimer.cs b/src/iOS/Avalonia.iOS/DisplayLinkTimer.cs index 676554811e..e8a313afa8 100644 --- a/src/iOS/Avalonia.iOS/DisplayLinkTimer.cs +++ b/src/iOS/Avalonia.iOS/DisplayLinkTimer.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics; using System.Threading; -using System.Threading.Tasks; using Avalonia.Rendering; using CoreAnimation; using Foundation; @@ -11,7 +10,7 @@ namespace Avalonia.iOS { class DisplayLinkTimer : IRenderTimer { - public event Action? Tick; + private volatile Action? _tick; private Stopwatch _st = Stopwatch.StartNew(); public DisplayLinkTimer() @@ -31,9 +30,16 @@ namespace Avalonia.iOS public bool RunsInBackground => true; + // TODO: start/stop on RenderLoop request + public Action? Tick + { + get => _tick; + set => _tick = value; + } + private void OnLinkTick() { - Tick?.Invoke(_st.Elapsed); + _tick?.Invoke(_st.Elapsed); } } } diff --git a/src/iOS/Avalonia.iOS/Platform.cs b/src/iOS/Avalonia.iOS/Platform.cs index 29633a8609..79926a3836 100644 --- a/src/iOS/Avalonia.iOS/Platform.cs +++ b/src/iOS/Avalonia.iOS/Platform.cs @@ -93,7 +93,7 @@ namespace Avalonia.iOS { Key.PageUp , "⇞" }, { Key.Right , "→" }, { Key.Space , "␣" }, { Key.Tab , "⇥" }, { Key.Up , "↑" } }, ctrl: "⌃", meta: "⌘", shift: "⇧", alt: "⌥")) - .Bind().ToConstant(Timer) + .Bind().ToConstant(RenderLoop.FromTimer(Timer)) .Bind().ToConstant(keyboard); if (appDelegate is not null) diff --git a/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs b/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs index cf6d7a8aee..21ac9c1ae1 100644 --- a/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs @@ -88,7 +88,7 @@ public class CompositionAnimationTests : ScopedTestBase { using var scope = AvaloniaLocator.EnterScope(); var compositor = - new Compositor(new RenderLoop(new CompositorTestServices.ManualRenderTimer()), null); + new Compositor(RenderLoop.FromTimer(new CompositorTestServices.ManualRenderTimer()), null); var target = compositor.CreateSolidColorVisual(); var ani = new ScalarKeyFrameAnimation(compositor); foreach (var frame in data.Frames) diff --git a/tests/Avalonia.Benchmarks/Compositor/CompositionTargetUpdate.cs b/tests/Avalonia.Benchmarks/Compositor/CompositionTargetUpdate.cs index 7451e3c843..06df626857 100644 --- a/tests/Avalonia.Benchmarks/Compositor/CompositionTargetUpdate.cs +++ b/tests/Avalonia.Benchmarks/Compositor/CompositionTargetUpdate.cs @@ -22,7 +22,7 @@ public class CompositionTargetUpdateOnly : IDisposable class Timer : IRenderTimer { - event Action IRenderTimer.Tick { add { } remove { } } + public Action Tick { get; set; } = null!; public bool RunsInBackground => false; } @@ -52,7 +52,7 @@ public class CompositionTargetUpdateOnly : IDisposable { _includeRender = includeRender; _app = UnitTestApplication.Start(TestServices.StyledWindow); - _compositor = new Compositor(new RenderLoop(new Timer()), null, true, new ManualScheduler(), true, + _compositor = new Compositor(RenderLoop.FromTimer(new Timer()), null, true, new ManualScheduler(), true, Dispatcher.UIThread, null); _target = _compositor.CreateCompositionTarget(() => [new NullFramebuffer()]); _target.PixelSize = new PixelSize(1000, 1000); @@ -99,7 +99,7 @@ public class CompositionTargetUpdateOnly : IDisposable { _target.Root.Offset = new Vector3D(_target.Root.Offset.X == 0 ? 1 : 0, 0, 0); _compositor.Commit(); - _compositor.Server.Render(); + _compositor.Server.Render(false); if (!_includeRender) _target.Server.Update(); diff --git a/tests/Avalonia.RenderTests/Composition/DirectFbCompositionTests.cs b/tests/Avalonia.RenderTests/Composition/DirectFbCompositionTests.cs index 588785cf69..5c44809a85 100644 --- a/tests/Avalonia.RenderTests/Composition/DirectFbCompositionTests.cs +++ b/tests/Avalonia.RenderTests/Composition/DirectFbCompositionTests.cs @@ -47,7 +47,7 @@ public class DirectFbCompositionTests : TestBase void Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised(bool advertised) { var timer = new ManualRenderTimer(); - var compositor = new Compositor(new RenderLoop(timer), null, true, + var compositor = new Compositor(RenderLoop.FromTimer(timer), null, true, new DispatcherCompositorScheduler(), true, Dispatcher.UIThread, new CompositionOptions { UseRegionDirtyRectClipping = true diff --git a/tests/Avalonia.RenderTests/ManualRenderTimer.cs b/tests/Avalonia.RenderTests/ManualRenderTimer.cs index 6247691453..1b6e7539a0 100644 --- a/tests/Avalonia.RenderTests/ManualRenderTimer.cs +++ b/tests/Avalonia.RenderTests/ManualRenderTimer.cs @@ -5,7 +5,7 @@ namespace Avalonia.Skia.RenderTests { public class ManualRenderTimer : IRenderTimer { - public event Action? Tick; + public Action? Tick { get; set; } public bool RunsInBackground => false; public void TriggerTick() => Tick?.Invoke(TimeSpan.Zero); } diff --git a/tests/Avalonia.RenderTests/TestRenderHelper.cs b/tests/Avalonia.RenderTests/TestRenderHelper.cs index 68cf05e9b9..87132881f7 100644 --- a/tests/Avalonia.RenderTests/TestRenderHelper.cs +++ b/tests/Avalonia.RenderTests/TestRenderHelper.cs @@ -63,7 +63,7 @@ static class TestRenderHelper { var timer = new ManualRenderTimer(); - var compositor = new Compositor(new RenderLoop(timer), null, true, + var compositor = new Compositor(RenderLoop.FromTimer(timer), null, true, new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); using (var writableBitmap = factory.CreateWriteableBitmap(pixelSize, dpiVector, factory.DefaultPixelFormat, factory.DefaultAlphaFormat)) diff --git a/tests/Avalonia.UnitTests/CompositorTestServices.cs b/tests/Avalonia.UnitTests/CompositorTestServices.cs index e90e1cff0e..cb2a84049c 100644 --- a/tests/Avalonia.UnitTests/CompositorTestServices.cs +++ b/tests/Avalonia.UnitTests/CompositorTestServices.cs @@ -42,9 +42,10 @@ public class CompositorTestServices : IDisposable _app = UnitTestApplication.Start(services); try { - AvaloniaLocator.CurrentMutable.Bind().ToConstant(Timer); + var renderLoop = RenderLoop.FromTimer(Timer); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(renderLoop); - Compositor = new Compositor(new RenderLoop(Timer), null, + Compositor = new Compositor(renderLoop, null, true, new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); var impl = new TopLevelImpl(Compositor, size ?? new Size(1000, 1000)); TopLevel = new EmbeddableControlRoot(impl) @@ -136,7 +137,7 @@ public class CompositorTestServices : IDisposable public class ManualRenderTimer : IRenderTimer { - public event Action? Tick; + public Action? Tick { get; set; } public bool RunsInBackground => false; public void TriggerTick() => Tick?.Invoke(TimeSpan.Zero); } diff --git a/tests/Avalonia.UnitTests/RendererMocks.cs b/tests/Avalonia.UnitTests/RendererMocks.cs index 32d171e147..9b172fe342 100644 --- a/tests/Avalonia.UnitTests/RendererMocks.cs +++ b/tests/Avalonia.UnitTests/RendererMocks.cs @@ -17,7 +17,7 @@ namespace Avalonia.UnitTests } public static Compositor CreateDummyCompositor() => - new(new RenderLoop(new CompositorTestServices.ManualRenderTimer()), null, false, + new(RenderLoop.FromTimer(new CompositorTestServices.ManualRenderTimer()), null, false, new CompositionCommitScheduler(), true, Dispatcher.UIThread); class CompositionCommitScheduler : ICompositorScheduler From 45bf966051797f6fbb8287b0afe363b9a9a884cc Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 14 Mar 2026 00:22:18 +0900 Subject: [PATCH 20/50] Enable recursive submodule checkout in workflow --- .github/workflows/update-api.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/update-api.yml b/.github/workflows/update-api.yml index 611a4ead50..27a0598d3b 100644 --- a/.github/workflows/update-api.yml +++ b/.github/workflows/update-api.yml @@ -71,6 +71,7 @@ jobs: with: ref: ${{ steps.pr.outputs.sha }} token: ${{ secrets.GITHUB_TOKEN }} + submodules: recursive - name: Setup .NET uses: actions/setup-dotnet@v4 From e327dcc54de0e12e74f02458f8ab8615270ee6f2 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 14 Mar 2026 00:22:54 +0900 Subject: [PATCH 21/50] Add recursive submodule checkout to API diff workflow Enable recursive submodule checkout in API diff workflow. --- .github/workflows/api-diff.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml index f855380f9e..e87f3f334f 100644 --- a/.github/workflows/api-diff.yml +++ b/.github/workflows/api-diff.yml @@ -70,6 +70,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ steps.pr.outputs.sha }} + submodules: recursive - name: Setup .NET uses: actions/setup-dotnet@v4 From ada1e72ce8dce25bfdf58fe5f848d7f6652e7b00 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 13 Mar 2026 18:12:19 +0100 Subject: [PATCH 22/50] Hide TextRange. It has no public usage. (#20893) * Hide TextRange. It has no public usage. * Update api diff --- api/Avalonia.Headless.XUnit.nupkg.xml | 4 +- api/Avalonia.Headless.nupkg.xml | 4 +- api/Avalonia.LinuxFramebuffer.nupkg.xml | 4 +- api/Avalonia.Skia.nupkg.xml | 4 +- api/Avalonia.Win32.Interoperability.nupkg.xml | 4 +- api/Avalonia.nupkg.xml | 16 ++++- .../TextFormatting/FormattedTextSource.cs | 66 +++++++++++++++++ .../Media/TextFormatting/TextRange.cs | 70 ------------------- .../TextFormatting/MultiBufferTextSource.cs | 5 +- 9 files changed, 92 insertions(+), 85 deletions(-) delete mode 100644 src/Avalonia.Base/Media/TextFormatting/TextRange.cs diff --git a/api/Avalonia.Headless.XUnit.nupkg.xml b/api/Avalonia.Headless.XUnit.nupkg.xml index 15a56561b9..c87cf909fe 100644 --- a/api/Avalonia.Headless.XUnit.nupkg.xml +++ b/api/Avalonia.Headless.XUnit.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -73,4 +73,4 @@ baseline/Avalonia.Headless.XUnit/lib/net8.0/Avalonia.Headless.XUnit.dll current/Avalonia.Headless.XUnit/lib/net8.0/Avalonia.Headless.XUnit.dll - \ No newline at end of file + diff --git a/api/Avalonia.Headless.nupkg.xml b/api/Avalonia.Headless.nupkg.xml index 435df92d13..229047057a 100644 --- a/api/Avalonia.Headless.nupkg.xml +++ b/api/Avalonia.Headless.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -37,4 +37,4 @@ baseline/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll current/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll - \ No newline at end of file + diff --git a/api/Avalonia.LinuxFramebuffer.nupkg.xml b/api/Avalonia.LinuxFramebuffer.nupkg.xml index 10c927a203..0fa6ef4e03 100644 --- a/api/Avalonia.LinuxFramebuffer.nupkg.xml +++ b/api/Avalonia.LinuxFramebuffer.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -37,4 +37,4 @@ baseline/Avalonia.LinuxFramebuffer/lib/net8.0/Avalonia.LinuxFramebuffer.dll current/Avalonia.LinuxFramebuffer/lib/net8.0/Avalonia.LinuxFramebuffer.dll - \ No newline at end of file + diff --git a/api/Avalonia.Skia.nupkg.xml b/api/Avalonia.Skia.nupkg.xml index c1afe2f966..b73745af8e 100644 --- a/api/Avalonia.Skia.nupkg.xml +++ b/api/Avalonia.Skia.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -169,4 +169,4 @@ baseline/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll current/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll - \ No newline at end of file + diff --git a/api/Avalonia.Win32.Interoperability.nupkg.xml b/api/Avalonia.Win32.Interoperability.nupkg.xml index 33fc2ac062..3672bb9b99 100644 --- a/api/Avalonia.Win32.Interoperability.nupkg.xml +++ b/api/Avalonia.Win32.Interoperability.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -37,4 +37,4 @@ baseline/Avalonia.Win32.Interoperability/lib/net8.0-windows7.0/Avalonia.Win32.Interoperability.dll current/Avalonia.Win32.Interoperability/lib/net8.0-windows7.0/Avalonia.Win32.Interoperability.dll - \ No newline at end of file + diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 5fe2e48f60..44617ccf64 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -163,6 +163,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0001 + T:Avalonia.Media.TextFormatting.TextRange + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0001 T:Avalonia.Platform.IGeometryContext2 @@ -631,6 +637,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0001 + T:Avalonia.Media.TextFormatting.TextRange + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0001 T:Avalonia.Platform.IGeometryContext2 @@ -5221,4 +5233,4 @@ baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll - \ No newline at end of file + diff --git a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs index 1586639fbd..13fc22b3e5 100644 --- a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs +++ b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs @@ -132,5 +132,71 @@ namespace Avalonia.Media.TextFormatting return Math.Min(length, text.Length); } + + /// + /// References a portion of a text buffer. + /// + private readonly record struct TextRange + { + public TextRange(int start, int length) + { + Start = start; + Length = length; + } + + /// + /// Gets the start. + /// + /// + /// The start. + /// + public int Start { get; } + + /// + /// Gets the length. + /// + /// + /// The length. + /// + public int Length { get; } + + /// + /// Gets the end. + /// + /// + /// The end. + /// + public int End => Start + Length - 1; + + /// + /// Returns a specified number of contiguous elements from the start of the slice. + /// + /// The number of elements to return. + /// A that contains the specified number of elements from the start of this slice. + public TextRange Take(int length) + { + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new TextRange(Start, length); + } + + /// + /// Bypasses a specified number of elements in the slice and then returns the remaining elements. + /// + /// The number of elements to skip before returning the remaining elements. + /// A that contains the elements that occur after the specified index in this slice. + public TextRange Skip(int length) + { + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new TextRange(Start + length, Length - length); + } + } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextRange.cs b/src/Avalonia.Base/Media/TextFormatting/TextRange.cs deleted file mode 100644 index e8bab55aff..0000000000 --- a/src/Avalonia.Base/Media/TextFormatting/TextRange.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; - -namespace Avalonia.Media.TextFormatting -{ - /// - /// References a portion of a text buffer. - /// - public readonly record struct TextRange - { - public TextRange(int start, int length) - { - Start = start; - Length = length; - } - - /// - /// Gets the start. - /// - /// - /// The start. - /// - public int Start { get; } - - /// - /// Gets the length. - /// - /// - /// The length. - /// - public int Length { get; } - - /// - /// Gets the end. - /// - /// - /// The end. - /// - public int End => Start + Length - 1; - - /// - /// Returns a specified number of contiguous elements from the start of the slice. - /// - /// The number of elements to return. - /// A that contains the specified number of elements from the start of this slice. - public TextRange Take(int length) - { - if (length > Length) - { - throw new ArgumentOutOfRangeException(nameof(length)); - } - - return new TextRange(Start, length); - } - - /// - /// Bypasses a specified number of elements in the slice and then returns the remaining elements. - /// - /// The number of elements to skip before returning the remaining elements. - /// A that contains the elements that occur after the specified index in this slice. - public TextRange Skip(int length) - { - if (length > Length) - { - throw new ArgumentOutOfRangeException(nameof(length)); - } - - return new TextRange(Start + length, Length - length); - } - } -} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs index 7bde885502..0d2da06a05 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs @@ -1,4 +1,5 @@ -using Avalonia.Media.TextFormatting; +using System; +using Avalonia.Media.TextFormatting; namespace Avalonia.Skia.UnitTests.Media.TextFormatting { @@ -14,8 +15,6 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting _runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" }; } - public static TextRange TextRange => new TextRange(0, 50); - public TextRun? GetTextRun(int textSourceIndex) { if (textSourceIndex >= 50) From 8dcfc7ebec6f27b458315b81dc110a7bb0960510 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 16 Mar 2026 08:27:34 +0000 Subject: [PATCH 23/50] 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 24/50] 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 25/50] 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 26/50] 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 27/50] 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 28/50] 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 29/50] 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 30/50] 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 31/50] 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 32/50] 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 33/50] 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 34/50] 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 35/50] 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 36/50] [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 37/50] 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 38/50] 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z + + + + + + + + + + + + M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z + + + + + + + + + + + + M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml.cs new file mode 100644 index 0000000000..05a4097a46 --- /dev/null +++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class SanctuaryMainPage : UserControl +{ + public SanctuaryMainPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml new file mode 100644 index 0000000000..50864e5e57 --- /dev/null +++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml.cs new file mode 100644 index 0000000000..be91370691 --- /dev/null +++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml.cs @@ -0,0 +1,70 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace ControlCatalog.Pages; + +public partial class SanctuaryShowcasePage : UserControl +{ + public SanctuaryShowcasePage() + { + InitializeComponent(); + } + + private void OnPage1CTA(object? sender, RoutedEventArgs e) + { + DemoCarousel.SelectedIndex = 1; + } + + private void OnPage2CTA(object? sender, RoutedEventArgs e) + { + DemoCarousel.SelectedIndex = 2; + } + + private async void OnPage3CTA(object? sender, RoutedEventArgs e) + { + var nav = this.FindAncestorOfType(); + if (nav == null) + return; + + var carouselWrapper = nav.NavigationStack.LastOrDefault(); + + var headerGrid = new Grid { ColumnDefinitions = new ColumnDefinitions("*, Auto") }; + headerGrid.Children.Add(new TextBlock + { + Text = "Sanctuary", + VerticalAlignment = VerticalAlignment.Center + }); + var closeIcon = Geometry.Parse( + "M4.397 4.397a1 1 0 0 1 1.414 0L12 10.585l6.19-6.188a1 1 0 0 1 1.414 1.414L13.413 12l6.19 6.189a1 1 0 0 1-1.414 1.414L12 13.413l-6.189 6.19a1 1 0 0 1-1.414-1.414L10.585 12 4.397 5.811a1 1 0 0 1 0-1.414z"); + var closeBtn = new Button + { + Content = new PathIcon { Data = closeIcon }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(8, 4), + VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(closeBtn, 1); + headerGrid.Children.Add(closeBtn); + closeBtn.Click += async (_, _) => await nav.PopAsync(null); + + var mainPage = new ContentPage + { + Header = headerGrid, + Content = new SanctuaryMainPage() + }; + NavigationPage.SetHasBackButton(mainPage, false); + + await nav.PushAsync(mainPage); + + if (carouselWrapper != null) + { + nav.RemovePage(carouselWrapper); + } + } +} diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml new file mode 100644 index 0000000000..8dbc44e19b --- /dev/null +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml @@ -0,0 +1,95 @@ + + + M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z + M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z + M18,16.08C17.24,16.08 16.56,16.38 16.04,16.85L8.91,12.7C8.96,12.47 9,12.24 9,12C9,11.76 8.96,11.53 8.91,11.3L15.96,7.19C16.5,7.69 17.21,8 18,8A3,3 0 0,0 21,5A3,3 0 0,0 18,2A3,3 0 0,0 15,5C15,5.24 15.04,5.47 15.09,5.7L8.04,9.81C7.5,9.31 6.79,9 6,9A3,3 0 0,0 3,12A3,3 0 0,0 6,15C6.79,15 7.5,14.69 8.04,14.19L15.16,18.34C15.11,18.55 15.08,18.77 15.08,19C15.08,20.61 16.39,21.91 18,21.91C19.61,21.91 20.92,20.61 20.92,19C20.92,17.39 19.61,16.08 18,16.08Z + M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M16,11V18.1L13.9,16L11.1,18.8L8.3,16L11.1,13.2L9,11.1L16,11Z + M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml.cs new file mode 100644 index 0000000000..0da2eb2ed7 --- /dev/null +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml.cs @@ -0,0 +1,84 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; + +namespace ControlCatalog.Pages +{ + public partial class DrawerPageBreakpointPage : UserControl + { + private bool _isLoaded; + + public DrawerPageBreakpointPage() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + _isLoaded = true; + DemoDrawer.PropertyChanged += OnDrawerPropertyChanged; + UpdateStatus(); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + DemoDrawer.PropertyChanged -= OnDrawerPropertyChanged; + } + + private void OnDrawerPropertyChanged(object? sender, Avalonia.AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == DrawerPage.BoundsProperty) + UpdateStatus(); + } + + private void OnBreakpointChanged(object? sender, RangeBaseValueChangedEventArgs e) + { + if (!_isLoaded) + return; + var value = (int)e.NewValue; + DemoDrawer.DrawerBreakpointLength = value; + BreakpointText.Text = value.ToString(); + UpdateStatus(); + } + + private void OnLayoutChanged(object? sender, SelectionChangedEventArgs e) + { + if (!_isLoaded) + return; + DemoDrawer.DrawerLayoutBehavior = LayoutCombo.SelectedIndex switch + { + 0 => DrawerLayoutBehavior.Split, + 1 => DrawerLayoutBehavior.CompactInline, + 2 => DrawerLayoutBehavior.CompactOverlay, + _ => DrawerLayoutBehavior.Split + }; + UpdateStatus(); + } + + private void OnMenuItemClick(object? sender, RoutedEventArgs e) + { + if (!_isLoaded || sender is not Button button) + return; + var item = button.Tag?.ToString() ?? "Home"; + DetailTitleText.Text = item; + DetailPage.Header = item; + if (DemoDrawer.DrawerLayoutBehavior != DrawerLayoutBehavior.Split) + DemoDrawer.IsOpen = false; + } + + private void UpdateStatus() + { + var isVertical = DemoDrawer.DrawerPlacement == DrawerPlacement.Top || + DemoDrawer.DrawerPlacement == DrawerPlacement.Bottom; + var length = isVertical ? DemoDrawer.Bounds.Height : DemoDrawer.Bounds.Width; + var breakpoint = DemoDrawer.DrawerBreakpointLength; + WidthText.Text = $"{(isVertical ? "Height" : "Width")}: {(int)length} px"; + var isOverlay = breakpoint > 0 && length > 0 && length < breakpoint; + ModeText.Text = isOverlay ? + "Mode: Overlay (below breakpoint)" : + $"Mode: {DemoDrawer.DrawerLayoutBehavior} (above breakpoint)"; + } + } +} diff --git a/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs index b4ec1503bd..d65a43a6ad 100644 --- a/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs @@ -28,6 +28,9 @@ namespace ControlCatalog.Pages // Data ("Data", "Pass Data", "Pass data during navigation via constructor arguments or DataContext.", () => new NavigationPagePassDataPage()), + ("Data", "MVVM Navigation", + "Keep navigation decisions in view models by routing NavigationPage push and pop operations through a small INavigationService.", + () => new NavigationPageMvvmPage()), // Features ("Features", "Attached Methods", diff --git a/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs index 6c4a67a473..beb0b2dccb 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs @@ -59,6 +59,26 @@ public partial class LAvenirAppPage : UserControl _infoPanel.IsVisible = Bounds.Width >= 650; } + void ApplyRootNavigationBarAppearance() + { + if (_navPage == null) + return; + + _navPage.Background = new SolidColorBrush(BgLight); + _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgLight); + _navPage.Resources["NavigationBarForeground"] = new SolidColorBrush(TextDark); + } + + void ApplyDetailNavigationBarAppearance() + { + if (_navPage == null) + return; + + _navPage.Background = new SolidColorBrush(BgDark); + _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgDark); + _navPage.Resources["NavigationBarForeground"] = Brushes.White; + } + TabbedPage BuildMenuTabbedPage() { var tp = new TabbedPage @@ -92,6 +112,7 @@ public partial class LAvenirAppPage : UserControl VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, }; + ApplyRootNavigationBarAppearance(); NavigationPage.SetTopCommandBar(tp, new Button { @@ -119,7 +140,7 @@ public partial class LAvenirAppPage : UserControl Content = menuView, Background = new SolidColorBrush(BgLight), Header = "Menu", - Icon = "M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z", + Icon = Geometry.Parse("M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z"), }; var reservationsPage = new ContentPage @@ -127,7 +148,7 @@ public partial class LAvenirAppPage : UserControl Content = new LAvenirReservationsView(), Background = new SolidColorBrush(BgLight), Header = "Reservations", - Icon = "M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z", + Icon = Geometry.Parse("M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z"), }; var profilePage = new ContentPage @@ -135,7 +156,7 @@ public partial class LAvenirAppPage : UserControl Content = new LAvenirProfileView(), Background = new SolidColorBrush(BgLight), Header = "Profile", - Icon = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", + Icon = Geometry.Parse("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"), }; tp.Pages = new ObservableCollection { menuPage, reservationsPage, profilePage }; @@ -144,7 +165,8 @@ public partial class LAvenirAppPage : UserControl async void PushDishDetail(string name, string price, string description, string imageFile) { - if (_navPage == null) return; + if (_navPage == null) + return; var detail = new ContentPage { @@ -153,22 +175,19 @@ public partial class LAvenirAppPage : UserControl Header = name, }; NavigationPage.SetBottomCommandBar(detail, BuildFloatingBar(price)); - - _navPage.Background = new SolidColorBrush(BgDark); - _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgDark); - _navPage.Resources["NavigationBarForeground"] = Brushes.White; - - detail.NavigatedFrom += (_, _) => + detail.Navigating += args => { - if (_navPage != null) - { - _navPage.Background = new SolidColorBrush(BgLight); - _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgLight); - _navPage.Resources["NavigationBarForeground"] = new SolidColorBrush(TextDark); - } + if (args.NavigationType == NavigationType.Pop) + ApplyRootNavigationBarAppearance(); + + return Task.CompletedTask; }; + ApplyDetailNavigationBarAppearance(); await _navPage.PushAsync(detail); + + if (!ReferenceEquals(_navPage.CurrentPage, detail)) + ApplyRootNavigationBarAppearance(); } Border BuildFloatingBar(string price) diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs index 52e667b0bf..147dbe1f75 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs @@ -8,6 +8,7 @@ namespace ControlCatalog.Pages { public partial class NavigationPageAppearancePage : UserControl { + private bool _initialized; private int _pageCount; private int _backButtonStyle; @@ -19,6 +20,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Appearance", "Change bar properties using the options panel.", 0), null); } diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs index 5a868046f3..01aef5385b 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs @@ -8,6 +8,7 @@ namespace ControlCatalog.Pages { public partial class NavigationPageAttachedMethodsPage : UserControl { + private bool _initialized; private int _pageCount; public NavigationPageAttachedMethodsPage() @@ -18,6 +19,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; await DemoNav.PushAsync(new ContentPage { Header = "Root Page", diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs index 347b8e8010..5dd438750f 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs @@ -8,6 +8,7 @@ namespace ControlCatalog.Pages { public partial class NavigationPageBackButtonPage : UserControl { + private bool _initialized; private int _pushCount; public NavigationPageBackButtonPage() @@ -18,6 +19,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; DemoNav.Pushed += (s, ev) => AddLog($"Pushed: \"{ev.Page?.Header}\""); DemoNav.Popped += (s, ev) => AddLog($"Popped: \"{ev.Page?.Header}\""); diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml index 74d0e58371..904d4310cc 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml @@ -51,6 +51,13 @@ + + + + + + diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs index faa47f6eda..c1c439f6a4 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs @@ -7,6 +7,7 @@ namespace ControlCatalog.Pages { public partial class NavigationPageEventsPage : UserControl { + private bool _initialized; private int _pageCount; public NavigationPageEventsPage() @@ -17,6 +18,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; DemoNav.Pushed += (s, ev) => AddLog($"Pushed → {ev.Page?.Header}"); DemoNav.Popped += (s, ev) => AddLog($"Popped ← {ev.Page?.Header}"); DemoNav.PoppedToRoot += (s, ev) => AddLog("PoppedToRoot"); diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs index 32b2e8927d..f9a6f9aa41 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs @@ -6,6 +6,7 @@ namespace ControlCatalog.Pages { public partial class NavigationPageFirstLookPage : UserControl { + private bool _initialized; private int _pageCount; public NavigationPageFirstLookPage() @@ -16,6 +17,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Home", "Welcome!\nUse the buttons to push and pop pages.", 0), null); UpdateStatus(); } diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs index c18cfebc7e..e185208119 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs @@ -7,6 +7,8 @@ namespace ControlCatalog.Pages { public partial class NavigationPageGesturePage : UserControl { + private bool _initialized; + public NavigationPageGesturePage() { InitializeComponent(); @@ -16,6 +18,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Page 1", "← Drag from the left edge to go back", 0), null); await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Page 2", "← Drag from the left edge to go back", 1), null); await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Page 3", "← Drag from the left edge to go back", 2), null); diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs index 1dc724128b..6a56beabe4 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs @@ -37,6 +37,7 @@ namespace ControlCatalog.Pages ]; private readonly ObservableCollection _filteredItems = new(AllContacts); + private bool _initialized; private string _searchText = ""; public NavigationPageInteractiveHeaderPage() @@ -47,6 +48,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; var headerGrid = new Grid { ColumnDefinitions = new ColumnDefinitions("*, Auto"), diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs index 1dd717234e..81ed6d5c1f 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs @@ -7,6 +7,7 @@ namespace ControlCatalog.Pages { public partial class NavigationPageModalPage : UserControl { + private bool _initialized; private int _modalCount; public NavigationPageModalPage() @@ -17,6 +18,10 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + return; + + _initialized = true; await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Home", "Use Push Modal to show a modal on top.", 0), null); } diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs index ac4e8c985d..2c77798570 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs @@ -20,6 +20,7 @@ namespace ControlCatalog.Pages ]; private int _modalCount; + private bool _initialized; public NavigationPageModalTransitionsPage() { @@ -29,6 +30,13 @@ namespace ControlCatalog.Pages private async void OnLoaded(object? sender, RoutedEventArgs e) { + if (_initialized) + { + UpdateTransition(); + return; + } + + _initialized = true; await DemoNav.PushAsync(new ContentPage { Header = "Modal Transitions", diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmNavigation.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmNavigation.cs new file mode 100644 index 0000000000..c6262ca9f4 --- /dev/null +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmNavigation.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using MiniMvvm; + +namespace ControlCatalog.Pages +{ + internal interface ISampleNavigationService + { + event EventHandler? StateChanged; + + Task NavigateToAsync(ViewModelBase viewModel); + + Task GoBackAsync(); + + Task PopToRootAsync(); + } + + internal interface ISamplePageFactory + { + ContentPage CreatePage(ViewModelBase viewModel); + } + + internal sealed class NavigationStateChangedEventArgs : EventArgs + { + public NavigationStateChangedEventArgs(string currentPageHeader, int navigationDepth, string lastAction) + { + CurrentPageHeader = currentPageHeader; + NavigationDepth = navigationDepth; + LastAction = lastAction; + } + + public string CurrentPageHeader { get; } + + public int NavigationDepth { get; } + + public string LastAction { get; } + } + + internal sealed class SampleNavigationService : ISampleNavigationService + { + private readonly NavigationPage _navigationPage; + private readonly ISamplePageFactory _pageFactory; + + public SampleNavigationService(NavigationPage navigationPage, ISamplePageFactory pageFactory) + { + _navigationPage = navigationPage; + _pageFactory = pageFactory; + + _navigationPage.Pushed += (_, e) => PublishState($"Pushed {e.Page?.Header}"); + _navigationPage.Popped += (_, e) => PublishState($"Popped {e.Page?.Header}"); + _navigationPage.PoppedToRoot += (_, _) => PublishState("Popped to root"); + } + + public event EventHandler? StateChanged; + + public async Task NavigateToAsync(ViewModelBase viewModel) + { + var page = _pageFactory.CreatePage(viewModel); + await _navigationPage.PushAsync(page); + } + + public async Task GoBackAsync() + { + if (_navigationPage.NavigationStack.Count <= 1) + { + PublishState("Already at the root page"); + return; + } + + await _navigationPage.PopAsync(); + } + + public async Task PopToRootAsync() + { + if (_navigationPage.NavigationStack.Count <= 1) + { + PublishState("Already at the root page"); + return; + } + + await _navigationPage.PopToRootAsync(); + } + + private void PublishState(string lastAction) + { + var header = _navigationPage.CurrentPage?.Header?.ToString() ?? "None"; + + StateChanged?.Invoke(this, new NavigationStateChangedEventArgs( + header, + _navigationPage.NavigationStack.Count, + lastAction)); + } + } +} diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml new file mode 100644 index 0000000000..7204b78a4d --- /dev/null +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + +