From 8b19628be32ca32f49aa6690eab006d61dbaac8b Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 31 Jul 2025 18:02:49 +0200 Subject: [PATCH 01/25] Commit missing resource values (#19371) --- .../Accents/FluentControlResources.xaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml index d1b1acf2a2..b11ea00898 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml @@ -102,6 +102,15 @@ + + 4 + 16 + 0,4,0,12 + 1 + + + + #FF681DA8 From 0f233c24dee4bc106e6ac91d341eaca56d1972e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Fri, 1 Aug 2025 08:55:57 +0000 Subject: [PATCH 02/25] Set IsKeyboardFocusWithin to false when control is detached from visual tree (#19369) * add failing IsKeyboardFocusWithin test for popup * Set IsKeyboardWithin to false when detached from visual tree --- src/Avalonia.Base/Input/InputElement.cs | 2 + .../Primitives/PopupTests.cs | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index 0e0b6eab5a..f00166d6b0 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -566,6 +566,8 @@ namespace Avalonia.Input { FocusManager.GetFocusManager(this)?.ClearFocusOnElementRemoved(this, e.Parent); } + + IsKeyboardFocusWithin = false; } /// diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 139c4656a1..1a9ce7c655 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -669,6 +669,51 @@ namespace Avalonia.Controls.UnitTests.Primitives } } + [Fact] + public void Popup_Should_Clear_Keyboard_Focus_From_Children_When_Closed() + { + using (CreateServicesWithFocus()) + { + var winButton = new Button(); + var window = PreparedWindow(new Panel { Children = { winButton }}); + + var border1 = new Border(); + var border2 = new Border(); + var button = new Button(); + border1.Child = border2; + border2.Child = button; + var popup = new Popup + { + PlacementTarget = window, + Child = new StackPanel + { + Children = + { + border1 + } + } + }; + + ((ISetLogicalParent)popup).SetParent(popup.PlacementTarget); + window.Show(); + winButton.Focus(); + popup.Open(); + + button.Focus(); + + var inputRoot = Assert.IsAssignableFrom(popup.Host); + + var focusManager = inputRoot.FocusManager!; + Assert.Same(button, focusManager.GetFocusedElement()); + + border1.Child = null; + + winButton.Focus(); + + Assert.False(border2.IsKeyboardFocusWithin); + } + } + [Fact] public void Closing_Popup_Sets_Focus_On_PlacementTarget() { From 77e9b293884947d2fb1d424cf89c8e7d93eff101 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Fri, 1 Aug 2025 18:17:35 +0900 Subject: [PATCH 03/25] Use VisualBrush for GroupBox (#19372) --- .../Controls/GroupBox.xaml | 23 +++++++++++-------- .../Controls/GroupBox.xaml | 23 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml index cd0610944b..1fee84269d 100644 --- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml @@ -33,16 +33,19 @@ CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> - - - - - - - - - - + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml index 2285128c12..6267e3731c 100644 --- a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml @@ -31,16 +31,19 @@ CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> - - - - - - - - - - + + + + + + + + + From cd05ff88b70d93eece2737a7e40b7cf9a48a405e Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Sat, 2 Aug 2025 15:11:02 +0900 Subject: [PATCH 04/25] [iOS] Implement Save File Picker Support (#19364) * [iOS] Implement Save File Picker Support * Delete folder instead * Use StorageProviderHelpers * Use FromBytes to create blank file --- samples/ControlCatalog.iOS/Info.plist | 3 +- .../Storage/IOSStorageProvider.cs | 70 +++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/samples/ControlCatalog.iOS/Info.plist b/samples/ControlCatalog.iOS/Info.plist index b4c7c07eb6..a1aa23e506 100644 --- a/samples/ControlCatalog.iOS/Info.plist +++ b/samples/ControlCatalog.iOS/Info.plist @@ -16,7 +16,6 @@ 1 2 - 3 UIRequiredDeviceCapabilities @@ -38,5 +37,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + com.apple.security.files.user-selected.read-write + diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs index 4c1bf97c6f..79d88c13b0 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs @@ -26,7 +26,7 @@ internal class IOSStorageProvider : IStorageProvider public bool CanOpen => true; - public bool CanSave => false; + public bool CanSave => true; public bool CanPickFolder => true; @@ -161,10 +161,72 @@ internal class IOSStorageProvider : IStorageProvider return Task.FromResult(new IOSStorageFolder(uri, wellKnownFolder)); } - public Task SaveFilePickerAsync(FilePickerSaveOptions options) + public async Task SaveFilePickerAsync(FilePickerSaveOptions options) { - return Task.FromException( - new PlatformNotSupportedException("Save file picker is not supported by iOS")); + /* + This requires a bit of dialog here... + To save a file, we need to present the user with a document picker + This requires a temp file to be created and used to "export" the file to. + When the user picks the file location and name, UIDocumentPickerViewController + will give back the URI to the real file location, which we can then use + to give back as an IStorageFile. + https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller + Yes, it is weird, but without the temp file it will explode. + */ + + // Create a temporary file to use with the document picker + var tempFileName = StorageProviderHelpers.NameWithExtension( + options.SuggestedFileName ?? "document", + options.DefaultExtension, + options.FileTypeChoices?.FirstOrDefault()); + + var tempDir = NSFileManager.DefaultManager.GetTemporaryDirectory().Append(Guid.NewGuid().ToString(), true); + if (tempDir == null) + { + throw new InvalidOperationException("Failed to get temporary directory for save file picker"); + } + + var isDirectoryCreated = NSFileManager.DefaultManager.CreateDirectory(tempDir, true, null, out var error); + if (!isDirectoryCreated) + { + throw new InvalidOperationException("Failed to create temporary directory for save file picker"); + } + + var tempFileUrl = tempDir.Append(tempFileName, false); + + // Create an empty file at the temp location + NSData.FromBytes(0, 0).Save(tempFileUrl, false); + + UIDocumentPickerViewController documentPicker; + if (OperatingSystem.IsIOSVersionAtLeast(14)) + { + documentPicker = new UIDocumentPickerViewController(new[] { tempFileUrl }, asCopy: true); + } + else + { +#pragma warning disable CA1422 + documentPicker = new UIDocumentPickerViewController(tempFileUrl, UIDocumentPickerMode.ExportToService); +#pragma warning restore CA1422 + } + + using (documentPicker) + { + if (OperatingSystem.IsIOSVersionAtLeast(13)) + { + documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation); + } + + documentPicker.Title = options.Title; + + var tcs = new TaskCompletionSource(); + documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls)); + var urls = await ShowPicker(documentPicker, tcs); + + // Clean up the temporary directory + NSFileManager.DefaultManager.Remove(tempDir, out _); + + return urls.FirstOrDefault() is { } url ? new IOSStorageFile(url) : null; + } } public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) From 74e8ce7efad55c078b1e91bc11bfe21ab78053c8 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 4 Aug 2025 08:16:31 +0000 Subject: [PATCH 05/25] restrict souce of input event sto parent view on android (#19289) --- .../Avalonia.Android/AvaloniaActivity.cs | 5 +- .../Avalonia.Android/AvaloniaView.Input.cs | 67 +++++++++++++++++++ src/Android/Avalonia.Android/AvaloniaView.cs | 21 +----- .../Platform/Input/AndroidInputMethod.cs | 3 - .../Platform/SkiaPlatform/TopLevelImpl.cs | 61 +++-------------- 5 files changed, 82 insertions(+), 75 deletions(-) create mode 100644 src/Android/Avalonia.Android/AvaloniaView.Input.cs diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs index fa3484f058..cf425d279e 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -8,10 +8,10 @@ using Android.OS; using Android.Runtime; using Android.Views; using AndroidX.AppCompat.App; -using Avalonia.Platform; using Avalonia.Android.Platform; using Avalonia.Android.Platform.Storage; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform; namespace Avalonia.Android; @@ -48,6 +48,9 @@ public class AvaloniaActivity : AppCompatActivity, IAvaloniaActivity SetContentView(_view); + // By default, the view isn't focused if the activity is created anew, so we force focus. + _view.RequestFocus(); + _listener = new GlobalLayoutListener(_view); _view.ViewTreeObserver?.AddOnGlobalLayoutListener(_listener); diff --git a/src/Android/Avalonia.Android/AvaloniaView.Input.cs b/src/Android/Avalonia.Android/AvaloniaView.Input.cs new file mode 100644 index 0000000000..c829be56be --- /dev/null +++ b/src/Android/Avalonia.Android/AvaloniaView.Input.cs @@ -0,0 +1,67 @@ +using System; +using Android.Views; +using Android.Views.InputMethods; +using Avalonia.Android.Platform.SkiaPlatform; + +namespace Avalonia.Android +{ + public partial class AvaloniaView : IInitEditorInfo + { + private Func? _initEditorInfo; + + public override IInputConnection OnCreateInputConnection(EditorInfo? outAttrs) + { + return _initEditorInfo?.Invoke(_view, outAttrs!)!; + } + + void IInitEditorInfo.InitEditorInfo(Func init) + { + _initEditorInfo = init; + } + + protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) + { + base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); + _accessHelper.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); + } + + protected override bool DispatchHoverEvent(MotionEvent? e) + { + return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e); + } + + protected override bool DispatchGenericPointerEvent(MotionEvent? e) + { + var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + + var baseResult = callBase && base.DispatchGenericPointerEvent(e); + + return result ?? baseResult; + } + + public override bool DispatchTouchEvent(MotionEvent? e) + { + var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + var baseResult = callBase && base.DispatchTouchEvent(e); + + if(result == true) + { + // Request focus for this view + RequestFocus(); + } + + return result ?? baseResult; + } + + public override bool DispatchKeyEvent(KeyEvent? e) + { + var res = _view.KeyboardHelper.DispatchKeyEvent(e, out var callBase); + if (res == false) + callBase = !_accessHelper.DispatchKeyEvent(e!) && callBase; + + var baseResult = callBase && base.DispatchKeyEvent(e); + + return res ?? baseResult; + } + } +} diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index ced2f11077..665feb2e2b 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -17,7 +17,7 @@ using Avalonia.Rendering; namespace Avalonia.Android { - public class AvaloniaView : FrameLayout + public partial class AvaloniaView : FrameLayout { private EmbeddableControlRoot _root; private readonly ViewImpl _view; @@ -71,24 +71,6 @@ namespace Avalonia.Android _root = null!; } - protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) - { - base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); - _accessHelper.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); - } - - protected override bool DispatchHoverEvent(MotionEvent? e) - { - return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e); - } - - public override bool DispatchKeyEvent(KeyEvent? e) - { - if (!_view.View.DispatchKeyEvent(e)) - return _accessHelper.DispatchKeyEvent(e!) || base.DispatchKeyEvent(e); - return true; - } - [SupportedOSPlatform("android24.0")] public override void OnVisibilityAggregated(bool isVisible) { @@ -149,7 +131,6 @@ namespace Avalonia.Android { public ViewImpl(AvaloniaView avaloniaView) : base(avaloniaView) { - View.Focusable = true; View.FocusChange += ViewImpl_FocusChange; } diff --git a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs index 8003db6607..2e8e145ef8 100644 --- a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs @@ -44,9 +44,6 @@ namespace Avalonia.Android.Platform.Input public AndroidInputMethod(TView host) { - if (host.OnCheckIsTextEditor() == false) - throw new InvalidOperationException("Host should return true from OnCheckIsTextEditor()"); - _host = host; _imm = host.Context?.GetSystemService(Context.InputMethodService).JavaCast() ?? throw new InvalidOperationException("Context.InputMethodService is expected to be not null."); diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 00ae95abaf..142657c8ce 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -5,9 +5,7 @@ using Android.Content; using Android.Graphics; using Android.Graphics.Drawables; using Android.Runtime; -using Android.Text; using Android.Views; -using Android.Views.InputMethods; using AndroidX.AppCompat.App; using Avalonia.Android.Platform.Input; using Avalonia.Android.Platform.Specific; @@ -15,13 +13,11 @@ using Avalonia.Android.Platform.Specific.Helpers; using Avalonia.Android.Platform.Storage; using Avalonia.Controls; using Avalonia.Controls.Platform; -using Avalonia.Controls.Platform.Surfaces; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; using Avalonia.OpenGL.Egl; -using Avalonia.OpenGL.Surfaces; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Rendering.Composition; @@ -34,7 +30,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform { private readonly AndroidKeyboardEventsHelper _keyboardHelper; private readonly AndroidMotionEventsHelper _pointerHelper; - private readonly AndroidInputMethod _textInputMethod; + private readonly AndroidInputMethod _textInputMethod; private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider? _storageProvider; private readonly AndroidSystemNavigationManagerImpl _systemNavigationManager; @@ -42,7 +38,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly ClipboardImpl _clipboard; private readonly AndroidLauncher? _launcher; private readonly AndroidScreens? _screens; - private ViewImpl _view; + private SurfaceViewImpl _view; private WindowTransparencyLevel _transparencyLevel; public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) @@ -52,8 +48,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform throw new ArgumentException("AvaloniaView.Context must not be null"); } - _view = new ViewImpl(avaloniaView.Context, this, placeOnTop); - _textInputMethod = new AndroidInputMethod(_view); + _view = new SurfaceViewImpl(avaloniaView.Context, this, placeOnTop); + _textInputMethod = new AndroidInputMethod(avaloniaView); _keyboardHelper = new AndroidKeyboardEventsHelper(this); _pointerHelper = new AndroidMotionEventsHelper(this); _clipboard = new ClipboardImpl(avaloniaView.Context.GetSystemService(Context.ClipboardService).JavaCast()); @@ -141,13 +137,13 @@ namespace Avalonia.Android.Platform.SkiaPlatform Resized?.Invoke(size, WindowResizeReason.Layout); } - sealed class ViewImpl : InvalidationAwareSurfaceView, IInitEditorInfo + sealed class SurfaceViewImpl : InvalidationAwareSurfaceView { private readonly TopLevelImpl _tl; private Size _oldSize; private double _oldScaling; - public ViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) + public SurfaceViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) { _tl = tl; if (placeOnTop) @@ -176,30 +172,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform base.DispatchDraw(canvas); } - protected override bool DispatchGenericPointerEvent(MotionEvent? e) - { - var result = _tl._pointerHelper.DispatchMotionEvent(e, out var callBase); - var baseResult = callBase && base.DispatchGenericPointerEvent(e); - - return result ?? baseResult; - } - - public override bool DispatchTouchEvent(MotionEvent? e) - { - var result = _tl._pointerHelper.DispatchMotionEvent(e, out var callBase); - var baseResult = callBase && base.DispatchTouchEvent(e); - - return result ?? baseResult; - } - - public override bool DispatchKeyEvent(KeyEvent? e) - { - var res = _tl._keyboardHelper.DispatchKeyEvent(e, out var callBase); - var baseResult = callBase && base.DispatchKeyEvent(e); - - return res ?? baseResult; - } - public override void SurfaceChanged(ISurfaceHolder holder, Format format, int width, int height) { base.SurfaceChanged(holder, format, width, height); @@ -232,23 +204,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform _tl.Compositor.RequestCompositionUpdate(drawingFinished.Run); base.SurfaceRedrawNeededAsync(holder, drawingFinished); } - - public override bool OnCheckIsTextEditor() - { - return true; - } - - private Func? _initEditorInfo; - - public void InitEditorInfo(Func init) - { - _initEditorInfo = init; - } - - public override IInputConnection OnCreateInputConnection(EditorInfo? outAttrs) - { - return _initEditorInfo?.Invoke(_tl, outAttrs!)!; - } } public IPopupImpl? CreatePopup() => null; @@ -291,6 +246,10 @@ namespace Avalonia.Android.Platform.SkiaPlatform PixelSize EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Size => _view.Size; double EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Scaling => _view.Scaling; + internal AndroidKeyboardEventsHelper KeyboardHelper => _keyboardHelper; + + internal AndroidMotionEventsHelper PointerHelper => _pointerHelper; + public void SetTransparencyLevelHint(IReadOnlyList transparencyLevels) { if (_view.Context is not AvaloniaMainActivity activity) From f3b418d435de52f3c938ed58014a3e5dc13809e3 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 4 Aug 2025 14:30:10 +0500 Subject: [PATCH 06/25] Implemented Dispatcher.Yield / Dispatcher.Resume (#19370) * Refactored DispatcherPriorityAwaitable, implemented Dispatcher.Yield * Picked changes from https://github.com/AvaloniaUI/Avalonia/pull/14212 * Format/compile * Update API suppressions --------- Co-authored-by: Yoh Deadfall Co-authored-by: Julien Lebosquain --- api/Avalonia.nupkg.xml | 60 +++++++++ .../Threading/Dispatcher.Invoke.cs | 66 ++++++++++ .../Threading/DispatcherPriorityAwaitable.cs | 122 +++++++++++++++--- .../DispatcherTests.cs | 100 ++++++++++++++ 4 files changed, 332 insertions(+), 16 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 347e6b3b08..8d64cb2a82 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -109,6 +109,42 @@ baseline/netstandard2.0/Avalonia.Base.dll target/netstandard2.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.get_IsCompleted + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.GetAwaiter + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.GetResult + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.OnCompleted(System.Action) + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable`1.GetAwaiter + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable`1.GetResult + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + CP0002 M:Avalonia.Controls.Primitives.IPopupHost.ConfigurePosition(Avalonia.Visual,Avalonia.Controls.PlacementMode,Avalonia.Point,Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor,Avalonia.Controls.Primitives.PopupPositioning.PopupGravity,Avalonia.Controls.Primitives.PopupPositioning.PopupPositionerConstraintAdjustment,System.Nullable{Avalonia.Rect}) @@ -187,6 +223,30 @@ baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + CP0007 + T:Avalonia.Threading.DispatcherPriorityAwaitable + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0007 + T:Avalonia.Threading.DispatcherPriorityAwaitable`1 + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0008 + T:Avalonia.Threading.DispatcherPriorityAwaitable + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0008 + T:Avalonia.Threading.DispatcherPriorityAwaitable`1 + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + CP0009 T:Avalonia.Diagnostics.StyleDiagnostics diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index 324a50e4b4..afee481252 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -672,4 +672,70 @@ public partial class Dispatcher /// public DispatcherPriorityAwaitable AwaitWithPriority(Task task, DispatcherPriority priority) => new(this, task, priority); + + /// + /// Creates an awaitable object that asynchronously resumes execution on the dispatcher. + /// + /// + /// An awaitable object that asynchronously resumes execution on the dispatcher. + /// + /// + /// This method is equivalent to calling the method + /// and passing in . + /// + public DispatcherPriorityAwaitable Resume() => + Resume(DispatcherPriority.Background); + + /// `` + /// Creates an awaitable object that asynchronously resumes execution on the dispatcher. The work that occurs + /// when control returns to the code awaiting the result of this method is scheduled with the specified priority. + /// + /// The priority at which to schedule the continuation. + /// + /// An awaitable object that asynchronously resumes execution on the dispatcher. + /// + public DispatcherPriorityAwaitable Resume(DispatcherPriority priority) + { + DispatcherPriority.Validate(priority, nameof(priority)); + return new(this, null, priority); + } + + /// + /// Creates an awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// An awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// This method is equivalent to calling the method + /// and passing in . + /// + /// + /// The current thread is not the UI thread. + /// + public static DispatcherPriorityAwaitable Yield() => + Yield(DispatcherPriority.Background); + + /// + /// Creates an cawaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. The work that occurs when + /// control returns to the code awaiting the result of this method is scheduled with the specified priority. + /// + /// The priority at which to schedule the continuation. + /// + /// An awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// The current thread is not the UI thread. + /// + public static DispatcherPriorityAwaitable Yield(DispatcherPriority priority) + { + // TODO12: Update to use Dispatcher.CurrentDispatcher once multi-dispatcher support is merged + var current = UIThread; + current.VerifyAccess(); + return UIThread.Resume(priority); + } } diff --git a/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs b/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs index 456e2d7551..ab4fb38b5a 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs @@ -1,40 +1,130 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace Avalonia.Threading; -public class DispatcherPriorityAwaitable : INotifyCompletion +/// +/// A simple awaitable type that will return a DispatcherPriorityAwaiter. +/// +public struct DispatcherPriorityAwaitable { private readonly Dispatcher _dispatcher; - private protected readonly Task Task; + private readonly Task? _task; private readonly DispatcherPriority _priority; - internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) + internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task? task, DispatcherPriority priority) { _dispatcher = dispatcher; - Task = task; + _task = task; _priority = priority; } - - public void OnCompleted(Action continuation) => - Task.ContinueWith(_ => _dispatcher.Post(continuation, _priority)); - public bool IsCompleted => Task.IsCompleted; + public DispatcherPriorityAwaiter GetAwaiter() => new(_dispatcher, _task, _priority); +} + +/// +/// A simple awaiter type that will queue the continuation to a dispatcher at a specific priority. +/// +/// +/// This is returned from DispatcherPriorityAwaitable.GetAwaiter() +/// +public struct DispatcherPriorityAwaiter : INotifyCompletion +{ + private readonly Dispatcher _dispatcher; + private readonly Task? _task; + private readonly DispatcherPriority _priority; + + internal DispatcherPriorityAwaiter(Dispatcher dispatcher, Task? task, DispatcherPriority priority) + { + _dispatcher = dispatcher; + _task = task; + _priority = priority; + } + + public void OnCompleted(Action continuation) + { + if(_task == null || _task.IsCompleted) + _dispatcher.Post(continuation, _priority); + else + { + var self = this; + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + self._dispatcher.Post(continuation, self._priority); + }); + } + } + + /// + /// This always returns false since continuation is requested to be queued to a dispatcher queue + /// + public bool IsCompleted => false; + + public void GetResult() + { + if (_task != null) + _task.GetAwaiter().GetResult(); + } +} + +/// +/// A simple awaitable type that will return a DispatcherPriorityAwaiter<T>. +/// +public struct DispatcherPriorityAwaitable +{ + private readonly Dispatcher _dispatcher; + private readonly Task _task; + private readonly DispatcherPriority _priority; - public void GetResult() => Task.GetAwaiter().GetResult(); + internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) + { + _dispatcher = dispatcher; + _task = task; + _priority = priority; + } - public DispatcherPriorityAwaitable GetAwaiter() => this; + public DispatcherPriorityAwaiter GetAwaiter() => new(_dispatcher, _task, _priority); } -public sealed class DispatcherPriorityAwaitable : DispatcherPriorityAwaitable +/// +/// A simple awaiter type that will queue the continuation to a dispatcher at a specific priority. +/// +/// +/// This is returned from DispatcherPriorityAwaitable<T>.GetAwaiter() +/// +public struct DispatcherPriorityAwaiter : INotifyCompletion { - internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) : base( - dispatcher, task, priority) + private readonly Dispatcher _dispatcher; + private readonly Task _task; + private readonly DispatcherPriority _priority; + + internal DispatcherPriorityAwaiter(Dispatcher dispatcher, Task task, DispatcherPriority priority) { + _dispatcher = dispatcher; + _task = task; + _priority = priority; } - public new T GetResult() => ((Task)Task).GetAwaiter().GetResult(); + public void OnCompleted(Action continuation) + { + if(_task.IsCompleted) + _dispatcher.Post(continuation, _priority); + else + { + var self = this; + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + self._dispatcher.Post(continuation, self._priority); + }); + } + } + + /// + /// This always returns false since continuation is requested to be queued to a dispatcher queue + /// + public bool IsCompleted => false; - public new DispatcherPriorityAwaitable GetAwaiter() => this; -} + public void GetResult() => _task.GetAwaiter().GetResult(); +} \ No newline at end of file diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index a667057708..1884a1ab65 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -505,4 +505,104 @@ public partial class DispatcherTests t.GetAwaiter().GetResult(); } } + + + [Fact] + public async Task DispatcherResumeContinuesOnUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + + await Dispatcher.UIThread.Resume(); + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task DispatcherYieldContinuesOnUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Dispatcher.Yield(); + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task DispatcherYieldThrowsOnNonUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + await Assert.ThrowsAsync(async () => await Dispatcher.Yield()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task AwaitWithPriorityRunsOnUIThread() + { + static async Task Workload() + { + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + + return Thread.CurrentThread.ManagedThreadId; + } + + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + Task taskWithoutResult = Workload(); + + await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); + + Assert.True(Dispatcher.UIThread.CheckAccess()); + Task taskWithResult = Workload(); + + await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); + + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } } From df1816bde5e226b34ff9a660bd49c0786c51c257 Mon Sep 17 00:00:00 2001 From: Alexander Marek Date: Mon, 4 Aug 2025 16:12:02 +0200 Subject: [PATCH 07/25] #18626 - improved scrolling performance in VirtualizingStackPanel.cs by reducing Measure/Arrange calls since they cause heavy GC pressure on constrained devices (Android, iOS) especially with complex item views (#18646) Co-authored-by: alexander.marek Co-authored-by: Steven Kirk Co-authored-by: Julien Lebosquain --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 3 + .../VirtualizingStackPanel.cs | 209 ++- .../VirtualizingStackPanelTests.cs | 1407 +++++++++++++---- 3 files changed, 1340 insertions(+), 279 deletions(-) diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index 7694845009..e3a706bfed 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -20,6 +20,9 @@ + Hosts a collection of ListBoxItem. diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index adeebf97d9..e883bb533b 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; +using Avalonia.Reactive; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -51,6 +52,12 @@ namespace Avalonia.Controls RoutedEvent.Register( nameof(VerticalSnapPointsChanged), RoutingStrategies.Bubble); + /// + /// Defines the property. + /// + public static readonly StyledProperty CacheLengthProperty = + AvaloniaProperty.Register(nameof(CacheLength), 0.0, + validate: v => v is >= 0 and <= 2); private static readonly AttachedProperty RecycleKeyProperty = AvaloniaProperty.RegisterAttached("RecycleKey"); @@ -73,12 +80,24 @@ namespace Avalonia.Controls private int _focusedIndex = -1; private Control? _realizingElement; private int _realizingIndex = -1; + private double _bufferFactor; + + private bool _hasReachedStart = false; + private bool _hasReachedEnd = false; + private Rect _extendedViewport; + + static VirtualizingStackPanel() + { + CacheLengthProperty.Changed.AddClassHandler((x, e) => x.OnCacheLengthChanged(e)); + } public VirtualizingStackPanel() { _recycleElement = RecycleElement; _recycleElementOnItemRemoved = RecycleElementOnItemRemoved; _updateElementIndex = UpdateElementIndex; + + _bufferFactor = Math.Max(0, CacheLength); EffectiveViewportChanged += OnEffectiveViewportChanged; } @@ -131,6 +150,20 @@ namespace Avalonia.Controls set => SetValue(AreVerticalSnapPointsRegularProperty, value); } + /// + /// Gets or sets the CacheLength. + /// + /// The factor determines how much additional space to maintain above and below the viewport. + /// A value of 0.5 means half the viewport size will be buffered on each side (up-down or left-right) + /// This uses more memory as more UI elements are realized, but greatly reduces the number of Measure-Arrange + /// cycles which can cause heavy GC pressure depending on the complexity of the item layouts. + /// + public double CacheLength + { + get => GetValue(CacheLengthProperty); + set => SetValue(CacheLengthProperty, value); + } + /// /// Gets the index of the first realized element, or -1 if no elements are realized. /// @@ -141,6 +174,16 @@ namespace Avalonia.Controls /// public int LastRealizedIndex => _realizedElements?.LastIndex ?? -1; + /// + /// Returns the viewport that contains any visible elements + /// + internal Rect ViewPort => _viewport; + + /// + /// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2) + /// + internal Rect ExtendedViewPort => _extendedViewport; + protected override Size MeasureOverride(Size availableSize) { var items = Items; @@ -217,8 +260,12 @@ namespace Avalonia.Controls var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, sizeU, finalSize.Height) : new Rect(0, u, finalSize.Width, sizeU); + e.Arrange(rect); - _scrollAnchorProvider?.RegisterAnchorCandidate(e); + + if (_viewport.Intersects(rect)) + _scrollAnchorProvider?.RegisterAnchorCandidate(e); + u += orientation == Orientation.Horizontal ? rect.Width : rect.Height; } } @@ -230,6 +277,7 @@ namespace Avalonia.Controls var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, _focusedElement.DesiredSize.Width, finalSize.Height) : new Rect(0, u, finalSize.Width, _focusedElement.DesiredSize.Height); + _focusedElement.Arrange(rect); } @@ -416,6 +464,7 @@ namespace Avalonia.Controls // Create and measure the element to be brought into view. Store it in a field so that // it can be re-used in the layout pass. var scrollToElement = GetOrCreateElement(items, index); + scrollToElement.Measure(Size.Infinity); // Get the expected position of the element and put it in place. @@ -483,7 +532,8 @@ namespace Avalonia.Controls { Debug.Assert(_realizedElements is not null); - var viewport = _viewport; + // Use the extended viewport for calculations + var viewport = _extendedViewport; // Get the viewport in the orientation direction. var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y; @@ -653,7 +703,6 @@ namespace Avalonia.Controls return index * estimatedSize; } - private void RealizeElements( IReadOnlyList items, Size availableSize, @@ -666,6 +715,10 @@ namespace Avalonia.Controls var index = viewport.anchorIndex; var horizontal = Orientation == Orientation.Horizontal; var u = viewport.anchorU; + + // Reset boundary flags + _hasReachedStart = false; + _hasReachedEnd = false; // If the anchor element is at the beginning of, or before, the start of the viewport // then we can recycle all elements before it. @@ -678,8 +731,9 @@ namespace Avalonia.Controls _realizingIndex = index; var e = GetOrCreateElement(items, index); _realizingElement = e; + e.Measure(availableSize); - + var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; @@ -691,7 +745,10 @@ namespace Avalonia.Controls _realizingIndex = -1; _realizingElement = null; } while (u < viewport.viewportUEnd && index < items.Count); - + + // Check if we reached the end of the collection + _hasReachedEnd = index >= items.Count; + // Store the last index and end U position for the desired size calculation. viewport.lastIndex = index - 1; viewport.realizedEndU = u; @@ -706,8 +763,8 @@ namespace Avalonia.Controls while (u > viewport.viewportUStart && index >= 0) { var e = GetOrCreateElement(items, index); + e.Measure(availableSize); - var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; u -= sizeU; @@ -716,6 +773,9 @@ namespace Avalonia.Controls viewport.measuredV = Math.Max(viewport.measuredV, sizeV); --index; } + + // Check if we reached the start of the collection + _hasReachedStart = index < 0; // We can now recycle elements before the first element. _realizedElements.RecycleElementsBefore(index + 1, _recycleElement); @@ -748,7 +808,7 @@ namespace Avalonia.Controls { return _realizedElements?.GetElement(index); } - + private static Control? GetRealizedElement( int index, ref int specialIndex, @@ -891,22 +951,146 @@ namespace Avalonia.Controls ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex); } - + private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) { var vertical = Orientation == Orientation.Vertical; var oldViewportStart = vertical ? _viewport.Top : _viewport.Left; var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left; + var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right; + // Update current viewport _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); _isWaitingForViewportUpdate = false; + // Calculate buffer sizes based on viewport dimensions + var viewportSize = vertical ? _viewport.Height : _viewport.Width; + var bufferSize = viewportSize * _bufferFactor; + + // Calculate extended viewport with relative buffers + var extendedViewportStart = vertical ? + Math.Max(0, _viewport.Top - bufferSize) : + Math.Max(0, _viewport.Left - bufferSize); + + var extendedViewportEnd = vertical ? + Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) : + Math.Min(Bounds.Width, _viewport.Right + bufferSize); + + // special case: + // If we are at the start of the list, append 2 * CacheLength additional items + // If we are at the end of the list, prepend 2 * CacheLength additional items + // - this way we always maintain "2 * CacheLength * element" items. + if (vertical) + { + var spaceAbove = _viewport.Top - bufferSize; + var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize); + + if (spaceAbove < 0 && spaceBelow >= 0) + extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove)); + if (spaceAbove >= 0 && spaceBelow < 0) + extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceBelow)); + } + else + { + var spaceLeft = _viewport.Left - bufferSize; + var spaceRight = Bounds.Width - (_viewport.Right + bufferSize); + + if (spaceLeft < 0 && spaceRight >= 0) + extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft)); + if(spaceLeft >= 0 && spaceRight < 0) + extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight)); + } + + Rect extendedViewPort; + if (vertical) + { + extendedViewPort = new Rect( + _viewport.X, + extendedViewportStart, + _viewport.Width, + extendedViewportEnd - extendedViewportStart); + } + else + { + extendedViewPort = new Rect( + extendedViewportStart, + _viewport.Y, + extendedViewportEnd - extendedViewportStart, + _viewport.Height); + } + + // Determine if we need a new measure var newViewportStart = vertical ? _viewport.Top : _viewport.Left; var newViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var newExtendedViewportStart = vertical ? extendedViewPort.Top : extendedViewPort.Left; + var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right; + var needsMeasure = false; + + + // Case 1: Viewport has changed significantly if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) || !MathUtilities.AreClose(oldViewportEnd, newViewportEnd)) { + // Case 1a: The new viewport exceeds the old extended viewport + if (newViewportStart < oldExtendedViewportStart || + newViewportEnd > oldExtendedViewportEnd) + { + needsMeasure = true; + } + // Case 1b: The extended viewport has changed significantly + else if (!MathUtilities.AreClose(oldExtendedViewportStart, newExtendedViewportStart) || + !MathUtilities.AreClose(oldExtendedViewportEnd, newExtendedViewportEnd)) + { + // Check if we're about to scroll into an area where we don't have realized elements + // This would be the case if we're near the edge of our current extended viewport + var nearingEdge = false; + + if (_realizedElements != null) + { + var firstRealizedElementU = _realizedElements.StartU; + var lastRealizedElementU = _realizedElements.StartU; + + for (var i = 0; i < _realizedElements.Count; i++) + { + lastRealizedElementU += _realizedElements.SizeU[i]; + } + + // If scrolling up/left and nearing the top/left edge of realized elements + if (newViewportStart < oldViewportStart && + newViewportStart - newExtendedViewportStart < bufferSize) + { + // Edge case: We're at item 0 with excess measurement space. + // Skip re-measuring since we're at the list start and it won't change the result. + // This prevents redundant Measure-Arrange cycles when at list beginning. + nearingEdge = !_hasReachedStart; + } + + // If scrolling down/right and nearing the bottom/right edge of realized elements + if (newViewportEnd > oldViewportEnd && + newExtendedViewportEnd - newViewportEnd < bufferSize) + { + // Edge case: We're at the last item with excess measurement space. + // Skip re-measuring since we're at the list end and it won't change the result. + // This prevents redundant Measure-Arrange cycles when at list beginning. + nearingEdge = !_hasReachedEnd; + } + } + else + { + nearingEdge = true; + } + + needsMeasure = nearingEdge; + } + } + + if (needsMeasure) + { + // only store the new "old" extended viewport if we _did_ actually measure + _extendedViewport = extendedViewPort; + InvalidateMeasure(); } } @@ -924,6 +1108,15 @@ namespace Avalonia.Controls } } + private void OnCacheLengthChanged(AvaloniaPropertyChangedEventArgs e) + { + var newValue = e.GetNewValue(); + _bufferFactor = newValue; + + // Force a recalculation of the extended viewport on the next layout pass + InvalidateMeasure(); + } + /// public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) { diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 0b33239687..6c6252d836 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; @@ -24,137 +25,167 @@ namespace Avalonia.Controls.UnitTests public class VirtualizingStackPanelTests : ScopedTestBase { private static FuncDataTemplate CanvasWithHeightTemplate = new((_, _) => - new Canvas + new CanvasCountingMeasureArrangeCalls { Width = 100, [!Layoutable.HeightProperty] = new Binding("Height"), }); private static FuncDataTemplate CanvasWithWidthTemplate = new((_, _) => - new Canvas + new CanvasCountingMeasureArrangeCalls { Height = 100, [!Layoutable.WidthProperty] = new Binding("Width"), }); - [Fact] - public void Creates_Initial_Items() + [Theory] + [InlineData(0d , 10)] + [InlineData(0.5d, 20)] + public void Creates_Initial_Items(double bufferFactor, int expectedCount) { using var app = App(); - var (target, scroll, itemsControl) = CreateTarget(); + var (target, scroll, itemsControl) = CreateTarget(bufferFactor:bufferFactor); Assert.Equal(1000, scroll.Extent.Height); - AssertRealizedItems(target, itemsControl, 0, 10); + AssertRealizedItems(target, itemsControl, 0, expectedCount); } - [Fact] - public void Initializes_Initial_Control_Items() + [Theory] + [InlineData(0d, 10)] + [InlineData(0.5d, 20)] // Buffer factor of 0.5. Since at start there is no room, the 10 additional items are just appended + public void Initializes_Initial_Control_Items(double bufferFactor, int expectedCount) { using var app = App(); var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 }); - var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null, bufferFactor:bufferFactor); Assert.Equal(1000, scroll.Extent.Height); - AssertRealizedControlItems