Browse Source

Merge branch 'master' into fix/composition-animation

pull/20936/head
Betta_Fish 4 days ago
committed by GitHub
parent
commit
01f7369f01
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml
  2. 8
      samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml
  3. 16
      samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
  4. 11
      samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
  5. 8
      samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
  6. 119
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
  7. 4
      src/Avalonia.Base/Input/DragEventArgs.cs
  8. 2
      src/Avalonia.Base/Input/FocusChangedEventArgs.cs
  9. 2
      src/Avalonia.Base/Input/FocusChangingEventArgs.cs
  10. 6
      src/Avalonia.Base/Input/PointerDeltaEventArgs.cs
  11. 21
      src/Avalonia.Base/Input/PointerEventArgs.cs
  12. 8
      src/Avalonia.Base/Input/PointerWheelEventArgs.cs
  13. 2
      src/Avalonia.Base/Input/TappedEventArgs.cs
  14. 1
      src/Avalonia.Base/Rect.cs
  15. 12
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs
  16. 5
      src/Avalonia.Base/Threading/Dispatcher.Invoke.cs
  17. 126
      src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs
  18. 2
      src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs
  19. 2
      src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs
  20. 52
      src/Avalonia.Controls/Page/DrawerPage.cs
  21. 16
      src/Avalonia.Controls/Page/Page.cs
  22. 2
      src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
  23. 58
      src/Avalonia.Controls/Page/TabbedPage.cs
  24. 2
      src/Avalonia.Controls/ScrollChangedEventArgs.cs
  25. 2
      src/Avalonia.Controls/SelectionChangedEventArgs.cs
  26. 4
      src/Avalonia.Controls/Spinner.cs
  27. 111
      src/Avalonia.Controls/VirtualizingStackPanel.cs
  28. 6
      src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
  29. 108
      src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
  30. 6
      src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml
  31. 106
      src/Avalonia.Themes.Simple/Controls/GroupBox.xaml
  32. 6
      src/Avalonia.Vulkan/VulkanContext.cs
  33. 75
      tests/Avalonia.Base.UnitTests/DispatcherTests.cs
  34. 111
      tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
  35. 89
      tests/Avalonia.Controls.UnitTests/TabControlTests.cs
  36. 108
      tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
  37. 121
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

5
samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml

@ -118,6 +118,11 @@
Header="Customization"
DrawerLength="260"
DrawerHeaderBackground="{DynamicResource SystemControlHighlightAccentBrush}">
<DrawerPage.DrawerIconTemplate>
<DataTemplate DataType="Geometry">
<PathIcon Data="{Binding}" />
</DataTemplate>
</DrawerPage.DrawerIconTemplate>
<DrawerPage.DrawerHeader>
<Border x:Name="DrawerHeaderBorder" Padding="16">
<StackPanel Spacing="4">

8
samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml

@ -52,9 +52,13 @@
</DrawerPage.Resources>
<DrawerPage.DrawerIcon>
<PathIcon Width="22" Height="22"
Data="M12 3C9 6 6 9 6 13C6 17.4 8.7 21 12 22C15.3 21 18 17.4 18 13C18 9 15 6 12 3Z" />
<StreamGeometry>M12 3C9 6 6 9 6 13C6 17.4 8.7 21 12 22C15.3 21 18 17.4 18 13C18 9 15 6 12 3Z</StreamGeometry>
</DrawerPage.DrawerIcon>
<DrawerPage.DrawerIconTemplate>
<DataTemplate DataType="Geometry">
<PathIcon Width="22" Height="22" Data="{Binding}" />
</DataTemplate>
</DrawerPage.DrawerIconTemplate>
<DrawerPage.DrawerHeader>
<StackPanel Background="{StaticResource EcoDrawerBg}" Margin="0,0,0,8">

16
samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs

@ -5,7 +5,6 @@ namespace ControlCatalog.Pages
{
public partial class TabbedPageCustomTabBarPage : UserControl
{
// Fluent UI icon geometries (24x24 viewbox)
private static readonly StreamGeometry HomeGeometry =
StreamGeometry.Parse("M12.9942 2.79444C12.4118 2.30208 11.5882 2.30208 11.0058 2.79444L3.50582 9.39444C3.18607 9.66478 3 10.0634 3 10.4828V20.25C3 20.9404 3.55964 21.5 4.25 21.5H8.25C8.94036 21.5 9.5 20.9404 9.5 20.25V14.75C9.5 14.6119 9.61193 14.5 9.75 14.5H14.25C14.3881 14.5 14.5 14.6119 14.5 14.75V20.25C14.5 20.9404 15.0596 21.5 15.75 21.5H19.75C20.4404 21.5 21 20.9404 21 20.25V10.4828C21 10.0634 20.8139 9.66478 20.4942 9.39444L12.9942 2.79444Z");
private static readonly StreamGeometry WalletGeometry =
@ -25,16 +24,11 @@ namespace ControlCatalog.Pages
private void SetupIcons()
{
SetIcon(HomePage, HomeGeometry);
SetIcon(WalletPage, WalletGeometry);
SetIcon(SendPage, SendGeometry);
SetIcon(ActivityPage, ActivityGeometry);
SetIcon(ProfilePage, ProfileGeometry);
}
private static void SetIcon(ContentPage page, StreamGeometry geometry)
{
page.Icon = geometry;
HomePage.Icon = new PathIcon { Data = HomeGeometry };
WalletPage.Icon = new PathIcon { Data = WalletGeometry };
SendPage.Icon = new PathIcon { Data = SendGeometry };
ActivityPage.Icon = new PathIcon { Data = ActivityGeometry };
ProfilePage.Icon = new PathIcon { Data = ProfileGeometry };
}
}
}

11
samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs

@ -109,14 +109,9 @@ namespace ControlCatalog.Pages
private void OnShowIconsChanged(object? sender, RoutedEventArgs e)
{
bool show = ShowIconsCheck.IsChecked == true;
SetIcon(HomePage, show ? HomeGeometry : null);
SetIcon(SearchPage, show ? SearchGeometry : null);
SetIcon(SettingsPage, show ? SettingsGeometry : null);
}
private static void SetIcon(ContentPage page, StreamGeometry? geometry)
{
page.Icon = geometry;
HomePage.Icon = show ? new PathIcon { Data = HomeGeometry } : null;
SearchPage.Icon = show ? new PathIcon { Data = SearchGeometry } : null;
SettingsPage.Icon = show ? new PathIcon { Data = SettingsGeometry } : null;
}
private void OnTabEnabledChanged(object? sender, RoutedEventArgs e)

8
samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs

@ -28,10 +28,10 @@ namespace ControlCatalog.Pages
private void SetupIcons()
{
FeedPage.Icon = FeedGeometry;
DiscoverPage.Icon = DiscoverGeometry;
AlertsPage.Icon = AlertsGeometry;
ProfilePage.Icon = ProfileGeometry;
FeedPage.Icon = new PathIcon { Data = FeedGeometry };
DiscoverPage.Icon = new PathIcon { Data = DiscoverGeometry };
AlertsPage.Icon = new PathIcon { Data = AlertsGeometry };
ProfilePage.Icon = new PathIcon { Data = ProfileGeometry };
}
private void OnFabClicked(object? sender, RoutedEventArgs e)

119
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs

@ -12,6 +12,7 @@ using Avalonia.Logging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Java.Lang;
using static Android.Provider.DocumentsContract;
using AndroidUri = Android.Net.Uri;
using Exception = System.Exception;
using JavaFile = Java.IO.File;
@ -35,10 +36,10 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
}
internal AndroidUri Uri { get; set; }
protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
public virtual string Name => GetColumnValue(Activity, Uri, DocumentsContract.Document.ColumnDisplayName)
public virtual string Name => GetColumnValue(Activity, Uri, Document.ColumnDisplayName)
?? GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName)
?? Uri.PathSegments?.LastOrDefault()?.Split("/", StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
@ -67,7 +68,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
Activity.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
}
public abstract Task<StorageItemProperties> GetBasicPropertiesAsync();
protected static string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
@ -98,7 +99,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return null;
}
if(_parent != null)
if (_parent != null)
{
return _parent;
}
@ -106,8 +107,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
using var javaFile = new JavaFile(Uri.Path!);
// Java file represents files AND directories. Don't be confused.
if (javaFile.ParentFile is {} parentFile
&& AndroidUri.FromFile(parentFile) is {} androidUri)
if (javaFile.ParentFile is { } parentFile
&& AndroidUri.FromFile(parentFile) is { } androidUri)
{
return new AndroidStorageFolder(Activity, androidUri, false);
}
@ -124,12 +125,12 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return await _activity!.CheckPermission(Manifest.Permission.ReadExternalStorage);
}
public void Dispose()
{
_activity = null;
}
internal AndroidUri? PermissionRoot => _permissionRoot;
public abstract Task DeleteAsync();
@ -138,8 +139,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
public static IStorageItem CreateItem(Activity activity, AndroidUri uri)
{
var mimeType = GetColumnValue(activity, uri, DocumentsContract.Document.ColumnMimeType);
if (mimeType == DocumentsContract.Document.MimeTypeDir)
var mimeType = GetColumnValue(activity, uri, Document.ColumnMimeType);
if (mimeType == Document.MimeTypeDir)
{
return new AndroidStorageFolder(activity, uri, false);
}
@ -160,8 +161,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream";
var treeUri = GetTreeUri().treeUri;
var newFile = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name);
if(newFile == null)
var newFile = CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name);
if (newFile == null)
{
return Task.FromResult<IStorageFile?>(null);
}
@ -172,7 +173,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
public Task<IStorageFolder?> CreateFolderAsync(string name)
{
var treeUri = GetTreeUri().treeUri;
var newFolder = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, DocumentsContract.Document.MimeTypeDir, name);
var newFolder = CreateDocument(Activity.ContentResolver!, treeUri!, Document.MimeTypeDir, name);
if (newFolder == null)
{
return Task.FromResult<IStorageFolder?>(null);
@ -197,24 +198,76 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
await foreach (var file in storageFolder.GetItemsAsync())
{
if(file is AndroidStorageFolder folder)
if (file is AndroidStorageFolder folder)
{
await DeleteContents(folder);
}
else if(file is AndroidStorageFile storageFile)
else if (file is AndroidStorageFile storageFile)
{
await storageFile.DeleteAsync();
}
}
var treeUri = GetTreeUri().treeUri;
DocumentsContract.DeleteDocument(Activity.ContentResolver!, treeUri!);
DeleteDocument(Activity.ContentResolver!, treeUri!);
}
}
public override Task<StorageItemProperties> GetBasicPropertiesAsync()
{
return Task.FromResult(new StorageItemProperties());
DateTimeOffset? dateModified = null;
AndroidUri? queryUri = null;
try
{
try
{
// When Uri is a tree URI, use its document id to build a document URI.
var folderId = GetTreeDocumentId(Uri);
queryUri = BuildDocumentUriUsingTree(Uri, folderId);
}
catch (UnsupportedOperationException)
{
// For non-root items, Uri may already be a document URI; use it directly.
queryUri = Uri;
}
if (queryUri != null)
{
var projection = new[]
{
Document.ColumnLastModified
};
using var cursor = Activity.ContentResolver!.Query(queryUri, projection, null, null, null);
if (cursor?.MoveToFirst() == true)
{
try
{
var columnIndex = cursor.GetColumnIndex(Document.ColumnLastModified);
if (columnIndex != -1)
{
var longValue = cursor.GetLong(columnIndex);
dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
.Log(this, "Directory LastModified metadata reader failed: '{Exception}'", ex);
}
}
}
}
catch (Exception ex)
{
// Data may not be available for this item or the URI may not be in the expected shape.
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
.Log(this, "Directory basic properties metadata unavailable: '{Exception}'", ex);
}
return Task.FromResult(new StorageItemProperties(null, null, dateModified));
}
public async IAsyncEnumerable<IStorageItem> GetItemsAsync()
@ -234,8 +287,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var projection = new[]
{
DocumentsContract.Document.ColumnDocumentId,
DocumentsContract.Document.ColumnMimeType
Document.ColumnDocumentId,
Document.ColumnMimeType
};
if (childrenUri != null)
{
@ -247,8 +300,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var mime = cursor.GetString(1);
var id = cursor.GetString(0);
bool isDirectory = mime == DocumentsContract.Document.MimeTypeDir;
var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id);
bool isDirectory = mime == Document.MimeTypeDir;
var uri = BuildDocumentUriUsingTree(root, id);
if (uri == null)
{
@ -313,9 +366,9 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var projection = new[]
{
DocumentsContract.Document.ColumnDocumentId,
DocumentsContract.Document.ColumnMimeType,
DocumentsContract.Document.ColumnDisplayName
Document.ColumnDocumentId,
Document.ColumnMimeType,
Document.ColumnDisplayName
};
if (childrenUri != null)
@ -332,15 +385,15 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
if (fileName != name)
{
continue;
}
}
bool mineDirectory = mime == DocumentsContract.Document.MimeTypeDir;
bool mineDirectory = mime == Document.MimeTypeDir;
if (isDirectory != mineDirectory)
{
return null;
}
var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id);
var uri = BuildDocumentUriUsingTree(root, id);
if (uri == null)
{
return null;
@ -370,8 +423,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
private (AndroidUri root, AndroidUri? treeUri) GetTreeUri()
{
var root = PermissionRoot ?? Uri;
var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri);
return (root, DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId));
var folderId = root != Uri ? GetDocumentId(Uri) : GetTreeDocumentId(Uri);
return (root, BuildChildDocumentsUriUsingTree(root, folderId));
}
}
@ -419,10 +472,10 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
if (!OperatingSystem.IsAndroidVersionAtLeast(24))
return false;
if (!DocumentsContract.IsDocumentUri(context, uri))
if (!IsDocumentUri(context, uri))
return false;
var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags);
var value = GetColumnValue(context, uri, Document.ColumnFlags);
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt))
{
var flags = (DocumentContractFlags)flagsInt;
@ -530,7 +583,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
if (Activity != null)
{
DocumentsContract.DeleteDocument(Activity.ContentResolver!, Uri);
DeleteDocument(Activity.ContentResolver!, Uri);
}
}
@ -553,7 +606,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
storageFolder.Uri is { } targetParentUri &&
await GetParentAsync() is AndroidStorageFolder parentFolder)
{
movedUri = DocumentsContract.MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri);
movedUri = MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri);
}
}
catch (Exception)

4
src/Avalonia.Base/Input/DragEventArgs.cs

@ -1,6 +1,5 @@
using System;
using Avalonia.Interactivity;
using Avalonia.Metadata;
namespace Avalonia.Input
{
@ -25,9 +24,8 @@ namespace Avalonia.Input
return _target.TranslatePoint(_targetLocation, relativeTo) ?? new Point(0, 0);
}
[Unstable("This constructor might be removed in 12.0. For unit testing, consider using DragDrop.DoDragDrop or IHeadlessWindow.DragDrop.")]
public DragEventArgs(
RoutedEvent<DragEventArgs> routedEvent,
RoutedEvent<DragEventArgs>? routedEvent,
IDataTransfer dataTransfer,
Interactive target,
Point targetLocation,

2
src/Avalonia.Base/Input/FocusChangedEventArgs.cs

@ -11,7 +11,7 @@ namespace Avalonia.Input
/// Initializes a new instance of <see cref="FocusChangedEventArgs"/>.
/// </summary>
/// <param name="routedEvent">The routed event associated with these event args.</param>
public FocusChangedEventArgs(RoutedEvent routedEvent)
public FocusChangedEventArgs(RoutedEvent? routedEvent)
: base(routedEvent)
{
}

2
src/Avalonia.Base/Input/FocusChangingEventArgs.cs

@ -12,7 +12,7 @@ namespace Avalonia.Input
/// <summary>
/// Provides data for focus changing.
/// </summary>
internal FocusChangingEventArgs(RoutedEvent routedEvent) : base(routedEvent)
public FocusChangingEventArgs(RoutedEvent? routedEvent) : base(routedEvent)
{
}

6
src/Avalonia.Base/Input/PointerDeltaEventArgs.cs

@ -1,7 +1,4 @@
using System;
using Avalonia.Interactivity;
using Avalonia.Metadata;
using Avalonia.VisualTree;
namespace Avalonia.Input
{
@ -9,8 +6,7 @@ namespace Avalonia.Input
{
public Vector Delta { get; }
[Unstable("This constructor might be removed in 12.0.")]
public PointerDeltaEventArgs(RoutedEvent routedEvent, object? source,
public PointerDeltaEventArgs(RoutedEvent? routedEvent, object? source,
IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp,
PointerPointProperties properties, KeyModifiers modifiers, Vector delta)
: base(routedEvent, source, pointer, rootVisual, rootVisualPosition,

21
src/Avalonia.Base/Input/PointerEventArgs.cs

@ -14,8 +14,7 @@ namespace Avalonia.Input
private readonly PointerPointProperties _properties;
private readonly Lazy<IReadOnlyList<RawPointerPoint>?>? _previousPoints;
[Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")]
public PointerEventArgs(RoutedEvent routedEvent,
public PointerEventArgs(RoutedEvent? routedEvent,
object? source,
IPointer pointer,
Visual? rootVisual, Point rootVisualPosition,
@ -32,8 +31,9 @@ namespace Avalonia.Input
Timestamp = timestamp;
KeyModifiers = modifiers;
}
internal PointerEventArgs(RoutedEvent routedEvent,
[PrivateApi]
public PointerEventArgs(RoutedEvent? routedEvent,
object? source,
IPointer pointer,
Visual? rootVisual, Point rootVisualPosition,
@ -41,9 +41,7 @@ namespace Avalonia.Input
PointerPointProperties properties,
KeyModifiers modifiers,
Lazy<IReadOnlyList<RawPointerPoint>?>? previousPoints)
#pragma warning disable CS0618
: this(routedEvent, source, pointer, rootVisual, rootVisualPosition, timestamp, properties, modifiers)
#pragma warning restore CS0618
{
_previousPoints = previousPoints;
}
@ -160,9 +158,8 @@ namespace Avalonia.Input
public class PointerPressedEventArgs : PointerEventArgs
{
[Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")]
public PointerPressedEventArgs(
object source,
object? source,
IPointer pointer,
Visual rootVisual, Point rootVisualPosition,
ulong timestamp,
@ -180,9 +177,8 @@ namespace Avalonia.Input
public class PointerReleasedEventArgs : PointerEventArgs
{
[Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")]
public PointerReleasedEventArgs(
object source, IPointer pointer,
object? source, IPointer pointer,
Visual rootVisual, Point rootVisualPosition, ulong timestamp,
PointerPointProperties properties, KeyModifiers modifiers,
MouseButton initialPressMouseButton)
@ -202,8 +198,7 @@ namespace Avalonia.Input
{
public IPointer Pointer { get; }
[Unstable("This constructor might be removed in 12.0. If you need to remove capture, use stable methods on the IPointer instance.")]
public PointerCaptureLostEventArgs(object source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent)
public PointerCaptureLostEventArgs(object? source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent)
{
Pointer = pointer;
Source = source;
@ -216,7 +211,7 @@ namespace Avalonia.Input
public CaptureSource CaptureSource { get; }
public IInputElement? NewValue { get; }
internal PointerCaptureChangingEventArgs(object source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent)
internal PointerCaptureChangingEventArgs(object? source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent)
{
Pointer = pointer;
Source = source;

8
src/Avalonia.Base/Input/PointerWheelEventArgs.cs

@ -1,16 +1,10 @@
using System;
using Avalonia.Interactivity;
using Avalonia.Metadata;
using Avalonia.VisualTree;
namespace Avalonia.Input
{
public class PointerWheelEventArgs : PointerEventArgs
{
public Vector Delta { get; }
[Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow.MouseWheel.")]
public PointerWheelEventArgs(object source, IPointer pointer, Visual rootVisual,
public PointerWheelEventArgs(object? source, IPointer pointer, Visual rootVisual,
Point rootVisualPosition, ulong timestamp,
PointerPointProperties properties, KeyModifiers modifiers, Vector delta)
: base(InputElement.PointerWheelChangedEvent, source, pointer, rootVisual, rootVisualPosition,

2
src/Avalonia.Base/Input/TappedEventArgs.cs

@ -6,7 +6,7 @@ namespace Avalonia.Input
{
private readonly PointerEventArgs lastPointerEventArgs;
public TappedEventArgs(RoutedEvent routedEvent, PointerEventArgs lastPointerEventArgs)
public TappedEventArgs(RoutedEvent? routedEvent, PointerEventArgs lastPointerEventArgs)
: base(routedEvent)
{
this.lastPointerEventArgs = lastPointerEventArgs;

1
src/Avalonia.Base/Rect.cs

@ -1,7 +1,6 @@
using System;
using System.Globalization;
using System.Numerics;
using Avalonia.Animation.Animators;
using Avalonia.Utilities;
namespace Avalonia

12
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs

@ -149,12 +149,16 @@ namespace Avalonia.Rendering.Composition.Server
try
{
if (_renderTarget == null && !_compositor.IsReadyToCreateRenderTarget(_surfaces()))
if (_renderTarget == null)
{
IsWaitingForReadyRenderTarget = IsEnabled;
return;
if (!_compositor.IsReadyToCreateRenderTarget(_surfaces()))
{
IsWaitingForReadyRenderTarget = IsEnabled;
return;
}
_renderTarget = _compositor.CreateRenderTarget(_surfaces());
}
_renderTarget ??= _compositor.CreateRenderTarget(_surfaces());
}
catch (RenderTargetNotReadyException)
{

5
src/Avalonia.Base/Threading/Dispatcher.Invoke.cs

@ -737,9 +737,6 @@ public partial class Dispatcher
/// </exception>
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);
return CurrentDispatcher.Resume(priority);
}
}

126
src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs

@ -0,0 +1,126 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Controls.Shapes;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace Avalonia.Controls.Converters
{
// Ported from https://github.com/dotnet/wpf/blob/main/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/BorderGapMaskConverter.cs
/// <summary>
/// Converter that generates the visual brush for <see cref="GroupBox"/>
/// </summary>
public class BorderGapMaskConverter : IMultiValueConverter
{
/// <summary>
/// Convert a value.
/// </summary>
/// <param name="values">values as produced by source binding</param>
/// <param name="targetType">target type</param>
/// <param name="parameter">converter parameter</param>
/// <param name="culture">culture information</param>
/// <returns>
/// Converted value.
/// Visual Brush that is used as the opacity mask for the Border
/// in the style for GroupBox.
/// </returns>
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
//
// Parameter Validation
//
if (parameter == null ||
values == null ||
values.Count != 3 ||
values[0] is not double ||
values[1] is not double ||
values[2] is not double)
{
return AvaloniaProperty.UnsetValue;
}
if (parameter is not double && parameter is not string)
{
return AvaloniaProperty.UnsetValue;
}
//
// Conversion
//
double? headerWidth = (double?)values[0];
double? borderWidth = (double?)values[1];
double? borderHeight = (double?)values[2];
// Doesn't make sense to have a Grid
// with 0 as width or height
if (borderWidth == 0
|| borderHeight == 0)
{
return null;
}
// Width of the line to the left of the header
// to be used to set the width of the first column of the Grid
double lineWidth;
if (parameter is string)
{
lineWidth = Double.Parse(((string)parameter), NumberFormatInfo.InvariantInfo);
}
else
{
lineWidth = (double)parameter;
}
Grid grid = new Grid
{
Width = borderWidth ?? 0,
Height = borderHeight ?? 0
};
ColumnDefinition colDef1 = new ColumnDefinition();
ColumnDefinition colDef2 = new ColumnDefinition();
ColumnDefinition colDef3 = new ColumnDefinition();
colDef1.Width = new GridLength(lineWidth);
colDef2.Width = new GridLength(headerWidth ?? 0);
colDef3.Width = new GridLength(1, GridUnitType.Star);
grid.ColumnDefinitions.Add(colDef1);
grid.ColumnDefinitions.Add(colDef2);
grid.ColumnDefinitions.Add(colDef3);
RowDefinition rowDef1 = new RowDefinition();
RowDefinition rowDef2 = new RowDefinition();
rowDef1.Height = new GridLength((borderHeight ?? 0) / 2);
rowDef2.Height = new GridLength(1, GridUnitType.Star);
grid.RowDefinitions.Add(rowDef1);
grid.RowDefinitions.Add(rowDef2);
Rectangle rectColumn1 = new Rectangle();
Rectangle rectColumn2 = new Rectangle();
Rectangle rectColumn3 = new Rectangle();
rectColumn1.Fill = Brushes.Black;
rectColumn2.Fill = Brushes.Black;
rectColumn3.Fill = Brushes.Black;
Grid.SetRowSpan(rectColumn1, 2);
Grid.SetRow(rectColumn1, 0);
Grid.SetColumn(rectColumn1, 0);
Grid.SetRow(rectColumn2, 1);
Grid.SetColumn(rectColumn2, 1);
Grid.SetRowSpan(rectColumn3, 2);
Grid.SetRow(rectColumn3, 0);
Grid.SetColumn(rectColumn3, 2);
grid.Children.Add(rectColumn1);
grid.Children.Add(rectColumn2);
grid.Children.Add(rectColumn3);
return (new VisualBrush(grid));
}
}
}

2
src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs

@ -4,7 +4,7 @@ namespace Avalonia.Controls
{
public class NumericUpDownValueChangedEventArgs : RoutedEventArgs
{
public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent)
public NumericUpDownValueChangedEventArgs(RoutedEvent? routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent)
{
OldValue = oldValue;
NewValue = newValue;

2
src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs

@ -11,7 +11,7 @@ namespace Avalonia.Controls
/// Initializes a new instance of the <see cref="DrawerClosingEventArgs"/> class.
/// </summary>
/// <param name="routedEvent">The routed event associated with these event data.</param>
public DrawerClosingEventArgs(RoutedEvent routedEvent) : base(routedEvent) { }
public DrawerClosingEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { }
/// <summary>
/// Gets or sets a value indicating whether the closing should be cancelled.

52
src/Avalonia.Controls/Page/DrawerPage.cs

@ -29,9 +29,6 @@ namespace Avalonia.Controls
[TemplatePart("PART_PaneButton", typeof(ToggleButton))]
[TemplatePart("PART_CompactPaneToggle", typeof(ToggleButton))]
[TemplatePart("PART_Backdrop", typeof(Border))]
[TemplatePart("PART_CompactPaneIconPresenter", typeof(ContentPresenter))]
[TemplatePart("PART_PaneIconPresenter", typeof(ContentPresenter))]
[TemplatePart("PART_BottomPaneIconPresenter", typeof(ContentPresenter))]
[PseudoClasses(":placement-right", ":placement-top", ":placement-bottom", ":detail-is-navpage")]
public class DrawerPage : Page
{
@ -133,6 +130,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty<object?> DrawerIconProperty =
AvaloniaProperty.Register<DrawerPage, object?>(nameof(DrawerIcon));
/// <summary>
/// Defines the <see cref="DrawerIconTemplate"/> property.
/// </summary>
public static readonly StyledProperty<IDataTemplate?> DrawerIconTemplateProperty =
AvaloniaProperty.Register<DrawerPage, IDataTemplate?>(nameof(DrawerIconTemplate));
private static readonly DefaultPageDataTemplate s_defaultPageDataTemplate = new DefaultPageDataTemplate();
/// <summary>
@ -206,9 +209,6 @@ namespace Avalonia.Controls
private ContentPresenter? _drawerPresenter;
private ContentPresenter? _drawerHeaderPresenter;
private ContentPresenter? _drawerFooterPresenter;
private ContentPresenter? _compactPaneIconPresenter;
private ContentPresenter? _paneIconPresenter;
private ContentPresenter? _bottomPaneIconPresenter;
private SplitView? _splitView;
private Border? _topBar;
private ToggleButton? _paneButton;
@ -427,6 +427,15 @@ namespace Avalonia.Controls
set => SetValue(DrawerIconProperty, value);
}
/// <summary>
/// Gets or sets the data template used to display the drawer icon.
/// </summary>
public IDataTemplate? DrawerIconTemplate
{
get => GetValue(DrawerIconTemplateProperty);
set => SetValue(DrawerIconTemplateProperty, value);
}
/// <summary>
/// Gets or sets the data template used to display <see cref="Drawer"/> content.
/// </summary>
@ -536,16 +545,11 @@ namespace Avalonia.Controls
_drawerPresenter = e.NameScope.Find<ContentPresenter>("PART_DrawerPresenter");
_drawerHeaderPresenter = e.NameScope.Find<ContentPresenter>("PART_DrawerHeader");
_drawerFooterPresenter = e.NameScope.Find<ContentPresenter>("PART_DrawerFooter");
_compactPaneIconPresenter = e.NameScope.Find<ContentPresenter>("PART_CompactPaneIconPresenter");
_paneIconPresenter = e.NameScope.Find<ContentPresenter>("PART_PaneIconPresenter");
_bottomPaneIconPresenter = e.NameScope.Find<ContentPresenter>("PART_BottomPaneIconPresenter");
_splitView = e.NameScope.Find<SplitView>("PART_SplitView");
_topBar = e.NameScope.Find<Border>("PART_TopBar");
_paneButton = e.NameScope.Find<ToggleButton>("PART_PaneButton");
_backdrop = e.NameScope.Find<Border>("PART_Backdrop");
UpdateIconPresenters();
if (_backdrop != null)
{
if (IsAttachedToVisualTree)
@ -568,11 +572,7 @@ namespace Avalonia.Controls
{
base.OnPropertyChanged(change);
if (change.Property == DrawerIconProperty)
{
UpdateIconPresenters();
}
else if (change.Property == DrawerProperty || change.Property == ContentProperty)
if (change.Property == DrawerProperty || change.Property == ContentProperty)
{
if (change.OldValue is ILogical oldLogical)
LogicalChildren.Remove(oldLogical);
@ -1006,26 +1006,6 @@ namespace Avalonia.Controls
e.Handled = true;
}
private void UpdateIconPresenters()
{
if (_compactPaneIconPresenter != null)
_compactPaneIconPresenter.Content = CreateIconContent(DrawerIcon);
if (_paneIconPresenter != null)
_paneIconPresenter.Content = CreateIconContent(DrawerIcon);
if (_bottomPaneIconPresenter != null)
_bottomPaneIconPresenter.Content = CreateIconContent(DrawerIcon);
}
internal static object? CreateIconContent(object? icon) => icon switch
{
ITemplate<Control> template => template.Build(),
Geometry g => new PathIcon { Data = g },
PathIcon pi => new PathIcon { Data = pi.Data },
DrawingImage { Drawing: GeometryDrawing { Geometry: { } gd } } => new PathIcon { Data = gd },
IImage image => new Image { Source = image },
_ => null
};
private void ApplyDrawerBackground()
{
if (_splitView == null)

16
src/Avalonia.Controls/Page/Page.cs

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using Avalonia.Automation;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
namespace Avalonia.Controls
@ -31,6 +32,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty<object?> IconProperty =
AvaloniaProperty.Register<Page, object?>(nameof(Icon));
/// <summary>
/// Defines the <see cref="IconTemplate"/> property.
/// </summary>
public static readonly StyledProperty<IDataTemplate?> IconTemplateProperty =
AvaloniaProperty.Register<Page, IDataTemplate?>(nameof(IconTemplate));
/// <summary>
/// Defines the <see cref="CurrentPage"/> property.
/// </summary>
@ -94,6 +101,15 @@ namespace Avalonia.Controls
set => SetValue(IconProperty, value);
}
/// <summary>
/// Gets or sets the data template used to display the icon.
/// </summary>
public IDataTemplate? IconTemplate
{
get => GetValue(IconTemplateProperty);
set => SetValue(IconTemplateProperty, value);
}
/// <summary>
/// Gets or sets the safe-area padding applied to this page's content.
/// </summary>

2
src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs

@ -13,7 +13,7 @@ namespace Avalonia.Controls
/// <param name="routedEvent">The routed event associated with this event args instance.</param>
/// <param name="previousPage">The page that was selected before the change, or <see langword="null"/> if no page was selected.</param>
/// <param name="currentPage">The page that is now selected, or <see langword="null"/> if selection was cleared.</param>
public PageSelectionChangedEventArgs(RoutedEvent routedEvent, Page? previousPage, Page? currentPage)
public PageSelectionChangedEventArgs(RoutedEvent? routedEvent, Page? previousPage, Page? currentPage)
: base(routedEvent)
{
PreviousPage = previousPage;

58
src/Avalonia.Controls/Page/TabbedPage.cs

@ -6,13 +6,10 @@ using Avalonia.Automation.Peers;
using Avalonia.Collections;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Threading;
namespace Avalonia.Controls
@ -323,7 +320,8 @@ namespace Avalonia.Controls
tabItem.IsEnabled = GetIsTabEnabled(page);
tabItem.Header = page.Header;
tabItem.Icon = CreateIconContent(page.Icon);
tabItem.Icon = page.Icon;
tabItem.IconTemplate = page.IconTemplate;
if (e.Index == (_tabControl?.SelectedIndex ?? -1))
UpdateActivePage();
@ -351,7 +349,8 @@ namespace Avalonia.Controls
tabItem.IsEnabled = GetIsTabEnabled(page);
tabItem.Header = page.Header;
tabItem.Icon = CreateIconContent(page.Icon);
tabItem.Icon = page.Icon;
tabItem.IconTemplate = page.IconTemplate;
}
UpdateActivePage();
@ -365,7 +364,12 @@ namespace Avalonia.Controls
if (e.Property == Page.IconProperty)
{
if (_pageContainerMap.TryGetValue(page, out var tabItem))
tabItem.Icon = CreateIconContent(page.Icon);
tabItem.Icon = page.Icon;
}
else if (e.Property == Page.IconTemplateProperty)
{
if (_pageContainerMap.TryGetValue(page, out var tabItem))
tabItem.IconTemplate = page.IconTemplate;
}
else if (e.Property == Page.HeaderProperty)
{
@ -378,44 +382,6 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Creates a visual control from a page icon value.
/// </summary>
internal static Control? CreateIconContent(object? icon)
{
if (icon is ITemplate<Control> template)
return template.Build();
Geometry? geometry = icon switch
{
Geometry g => g,
PathIcon pi => pi.Data,
DrawingImage { Drawing: GeometryDrawing { Geometry: { } gd } } => gd,
_ => null
};
if (geometry != null)
{
var path = new Path
{
Data = geometry,
Stretch = Stretch.Uniform,
HorizontalAlignment = HorizontalAlignment.Center,
};
path.Bind(
Path.FillProperty,
path.GetObservable(Documents.TextElement.ForegroundProperty));
return path;
}
if (icon is IImage image)
return new Image { Source = image };
return null;
}
private int FindNearestEnabledTab(int disabledIndex)
{
int count = GetTabCount();
@ -687,7 +653,7 @@ namespace Avalonia.Controls
var placement = ResolveTabPlacement();
bool isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom;
bool isRtl = FlowDirection == FlowDirection.RightToLeft;
bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft;
int delta = (e.SwipeDirection, isHorizontal, isRtl) switch
{
@ -721,7 +687,7 @@ namespace Avalonia.Controls
var resolved = ResolveTabPlacement();
bool isHorizontal = resolved == TabPlacement.Top || resolved == TabPlacement.Bottom;
bool isRtl = FlowDirection == FlowDirection.RightToLeft;
bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft;
bool next = isHorizontal ? (isRtl ? e.Key == Key.Left : e.Key == Key.Right) : e.Key == Key.Down;
bool prev = isHorizontal ? (isRtl ? e.Key == Key.Right : e.Key == Key.Left) : e.Key == Key.Up;

2
src/Avalonia.Controls/ScrollChangedEventArgs.cs

@ -16,7 +16,7 @@ namespace Avalonia.Controls
}
public ScrollChangedEventArgs(
RoutedEvent routedEvent,
RoutedEvent? routedEvent,
Vector extentDelta,
Vector offsetDelta,
Vector viewportDelta)

2
src/Avalonia.Controls/SelectionChangedEventArgs.cs

@ -15,7 +15,7 @@ namespace Avalonia.Controls
/// <param name="routedEvent">The event being raised.</param>
/// <param name="removedItems">The items removed from the selection.</param>
/// <param name="addedItems">The items added to the selection.</param>
public SelectionChangedEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems)
public SelectionChangedEventArgs(RoutedEvent? routedEvent, IList removedItems, IList addedItems)
: base(routedEvent)
{
RemovedItems = removedItems;

4
src/Avalonia.Controls/Spinner.cs

@ -66,7 +66,7 @@ namespace Avalonia.Controls
Direction = direction;
}
public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction)
public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction)
: base(routedEvent)
{
Direction = direction;
@ -78,7 +78,7 @@ namespace Avalonia.Controls
UsingMouseWheel = usingMouseWheel;
}
public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction, bool usingMouseWheel)
public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction, bool usingMouseWheel)
: base(routedEvent)
{
Direction = direction;

111
src/Avalonia.Controls/VirtualizingStackPanel.cs

@ -84,7 +84,8 @@ namespace Avalonia.Controls
private bool _hasReachedStart = false;
private bool _hasReachedEnd = false;
private Rect _extendedViewport;
private Rect _lastMeasuredExtendedViewport;
private Rect _lastKnownExtendedViewport;
static VirtualizingStackPanel()
{
@ -182,7 +183,7 @@ namespace Avalonia.Controls
/// <summary>
/// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2)
/// </summary>
internal Rect ExtendedViewPort => _extendedViewport;
internal Rect LastMeasuredExtendedViewPort => _lastMeasuredExtendedViewport;
protected override Size MeasureOverride(Size availableSize)
{
@ -692,7 +693,7 @@ namespace Avalonia.Controls
Debug.Assert(_realizedElements is not null);
// Use the extended viewport for calculations
var viewport = _extendedViewport;
var viewport = _lastMeasuredExtendedViewport;
// Get the viewport in the orientation direction.
var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
@ -1165,40 +1166,25 @@ namespace Avalonia.Controls
ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex);
}
private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize)
{
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) :
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) :
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.
// - 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)
@ -1208,30 +1194,48 @@ namespace Avalonia.Controls
{
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)
if (spaceLeft >= 0 && spaceRight < 0)
extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight));
}
Rect extendedViewPort;
if (vertical)
{
extendedViewPort = new Rect(
_viewport.X,
return new Rect(
_viewport.X,
extendedViewportStart,
_viewport.Width,
extendedViewportEnd - extendedViewportStart);
}
else
{
extendedViewPort = new Rect(
return new Rect(
extendedViewportStart,
_viewport.Y,
extendedViewportEnd - extendedViewportStart,
_viewport.Height);
}
}
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 ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left;
var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.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;
var extendedViewPort = CalculateExtendedViewport(vertical, viewportSize, bufferSize);
// Determine if we need a new measure
var newViewportStart = vertical ? _viewport.Top : _viewport.Left;
@ -1240,14 +1244,13 @@ namespace Avalonia.Controls
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 ||
if (newViewportStart < oldExtendedViewportStart ||
newViewportEnd > oldExtendedViewportEnd)
{
needsMeasure = true;
@ -1259,19 +1262,19 @@ namespace Avalonia.Controls
// 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 &&
if (newViewportStart < oldViewportStart &&
newViewportStart - newExtendedViewportStart < bufferSize)
{
// Edge case: We're at item 0 with excess measurement space.
@ -1279,9 +1282,9 @@ namespace Avalonia.Controls
// 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 &&
if (newViewportEnd > oldViewportEnd &&
newExtendedViewportEnd - newViewportEnd < bufferSize)
{
// Edge case: We're at the last item with excess measurement space.
@ -1294,16 +1297,34 @@ namespace Avalonia.Controls
{
nearingEdge = true;
}
needsMeasure = nearingEdge;
}
}
// Supplementary check: detect viewport growth after a previous shrink.
// The main comparison (Cases 1a/1b) uses _extendedViewport which only updates
// on measure. When the viewport shrinks (e.g. ComboBox popup during filtering),
// _extendedViewport stays stale-large, masking subsequent growth. Compare against
// _lastKnownExtendedViewport (always updated) to catch this case.
if (!needsMeasure)
{
var lastKnownStart = vertical ? _lastKnownExtendedViewport.Top : _lastKnownExtendedViewport.Left;
var lastKnownEnd = vertical ? _lastKnownExtendedViewport.Bottom : _lastKnownExtendedViewport.Right;
if (newViewportStart < lastKnownStart || newViewportEnd > lastKnownEnd)
{
needsMeasure = true;
}
}
_lastKnownExtendedViewport = extendedViewPort;
if (needsMeasure)
{
// only store the new "old" extended viewport if we _did_ actually measure
_extendedViewport = extendedViewPort;
// Only update the measure viewport when triggering a measure. This keeps the
// wider realization range available for externally-triggered measures (e.g. from
// OnItemsChanged), ensuring enough items are realized.
_lastMeasuredExtendedViewport = extendedViewPort;
InvalidateMeasure();
}
}

6
src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml

@ -47,6 +47,8 @@
VerticalAlignment="Center"/>
<ContentPresenter
Name="PART_CompactPaneIconPresenter"
Content="{TemplateBinding DrawerIcon}"
ContentTemplate="{TemplateBinding DrawerIconTemplate}"
IsVisible="{Binding DrawerIcon, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
@ -101,6 +103,8 @@
VerticalAlignment="Center"/>
<ContentPresenter
Name="PART_PaneIconPresenter"
Content="{TemplateBinding DrawerIcon}"
ContentTemplate="{TemplateBinding DrawerIconTemplate}"
IsVisible="{Binding DrawerIcon, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
@ -146,6 +150,8 @@
VerticalAlignment="Center"/>
<ContentPresenter
Name="PART_BottomPaneIconPresenter"
Content="{TemplateBinding DrawerIcon}"
ContentTemplate="{TemplateBinding DrawerIconTemplate}"
IsVisible="{Binding DrawerIcon, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>

108
src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml

@ -1,11 +1,13 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:Avalonia.Controls.Converters;assembly=Avalonia.Controls"
x:ClassModifier="internal">
<Design.PreviewWith>
<GroupBox Padding="10" Margin="10">
<TextBlock Text="Hello World" />
</GroupBox>
</Design.PreviewWith>
<converters:BorderGapMaskConverter x:Key="BorderGapMaskConverter" />
<ControlTheme x:Key="{x:Type GroupBox}"
TargetType="GroupBox">
@ -18,62 +20,60 @@
<ControlTemplate>
<Grid x:Name="RootGrid" UseLayoutRounding="true" ColumnDefinitions="6,Auto,*,6" RowDefinitions="Auto,Auto,*,6">
<!-- Separate Border for the background because if the background is set in the Border with the Header the opacity mask will be applied to the background as well. -->
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.Column="0"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="Transparent"
Background="{TemplateBinding Background}"/>
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.Column="0"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="Transparent"
Background="{TemplateBinding Background}"/>
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}">
<Border.OpacityMask>
<VisualBrush>
<VisualBrush.Visual>
<Canvas Background="Transparent">
<Rectangle Fill="Transparent"
Width="{Binding #Header.Bounds.Width}"
Height="{Binding #Header.Bounds.Height}"
Canvas.Left="{Binding #Header.Bounds.X}"
Canvas.Top="{Binding #Header.Bounds.Y}"/>
</Canvas>
</VisualBrush.Visual>
</VisualBrush>
</Border.OpacityMask>
</Border>
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}">
<Border.OpacityMask>
<MultiBinding Converter="{StaticResource BorderGapMaskConverter}"
ConverterParameter="6">
<Binding ElementName="Header"
Path="Bounds.Width"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="Bounds.Width"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="Bounds.Height"/>
</MultiBinding>
</Border.OpacityMask>
</Border>
<!-- ContentPresenter for the header -->
<Border x:Name="Header"
Padding="3,0,3,0"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1">
<ContentPresenter Name="PART_HeaderPresenter"
Content="{TemplateBinding Header}"
FontSize="{DynamicResource GroupBoxHeaderFontSize}"
Foreground="{DynamicResource GroupBoxHeaderForeground}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}" />
</Border>
<!-- Primary content for GroupBox -->
<ContentPresenter Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Name="PART_ContentPresenter"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
<!-- ContentPresenter for the header -->
<Border x:Name="Header"
Padding="3,0,3,0"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1">
<ContentPresenter Name="PART_HeaderPresenter"
Content="{TemplateBinding Header}"
FontSize="{DynamicResource GroupBoxHeaderFontSize}"
Foreground="{DynamicResource GroupBoxHeaderForeground}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}"
Margin="{TemplateBinding Padding}" />
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}" />
</Border>
<!-- Primary content for GroupBox -->
<ContentPresenter Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Name="PART_ContentPresenter"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}"
Margin="{TemplateBinding Padding}" />
</Grid>
</ControlTemplate>
</Setter>
@ -84,7 +84,7 @@
<Style Selector="^ /template/ ContentPresenter#PART_HeaderPresenter">
<Setter Property="RecognizesAccessKey" Value="True" />
</Style>
</Style>
</ControlTheme>
</ResourceDictionary>

6
src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml

@ -46,6 +46,8 @@
VerticalAlignment="Center"/>
<ContentPresenter
Name="PART_CompactPaneIconPresenter"
Content="{TemplateBinding DrawerIcon}"
ContentTemplate="{TemplateBinding DrawerIconTemplate}"
IsVisible="{Binding DrawerIcon, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
@ -92,6 +94,8 @@
VerticalAlignment="Center"/>
<ContentPresenter
Name="PART_PaneIconPresenter"
Content="{TemplateBinding DrawerIcon}"
ContentTemplate="{TemplateBinding DrawerIconTemplate}"
IsVisible="{Binding DrawerIcon, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
@ -129,6 +133,8 @@
VerticalAlignment="Center"/>
<ContentPresenter
Name="PART_BottomPaneIconPresenter"
Content="{TemplateBinding DrawerIcon}"
ContentTemplate="{TemplateBinding DrawerIconTemplate}"
IsVisible="{Binding DrawerIcon, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>

106
src/Avalonia.Themes.Simple/Controls/GroupBox.xaml

@ -1,11 +1,13 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:Avalonia.Controls.Converters;assembly=Avalonia.Controls"
x:ClassModifier="internal">
<Design.PreviewWith>
<GroupBox Padding="10" Margin="10">
<TextBlock Text="Hello World" />
</GroupBox>
</Design.PreviewWith>
<converters:BorderGapMaskConverter x:Key="BorderGapMaskConverter" />
<ControlTheme x:Key="{x:Type GroupBox}"
TargetType="GroupBox">
@ -16,60 +18,58 @@
<ControlTemplate>
<Grid x:Name="RootGrid" UseLayoutRounding="true" ColumnDefinitions="6,Auto,*,6" RowDefinitions="Auto,Auto,*,6">
<!-- Separate Border for the background because if the background is set in the Border with the Header the opacity mask will be applied to the background as well. -->
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.Column="0"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="Transparent"
Background="{TemplateBinding Background}"/>
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.Column="0"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="Transparent"
Background="{TemplateBinding Background}"/>
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}">
<Border.OpacityMask>
<VisualBrush>
<VisualBrush.Visual>
<Canvas Background="Transparent">
<Rectangle Fill="Transparent"
Width="{Binding #Header.Bounds.Width}"
Height="{Binding #Header.Bounds.Height}"
Canvas.Left="{Binding #Header.Bounds.X}"
Canvas.Top="{Binding #Header.Bounds.Y}"/>
</Canvas>
</VisualBrush.Visual>
</VisualBrush>
</Border.OpacityMask>
</Border>
<Border Grid.Row="1"
Grid.RowSpan="3"
Grid.ColumnSpan="4"
CornerRadius="{TemplateBinding CornerRadius}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}">
<Border.OpacityMask>
<MultiBinding Converter="{StaticResource BorderGapMaskConverter}"
ConverterParameter="6">
<Binding ElementName="Header"
Path="Bounds.Width"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="Bounds.Width"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="Bounds.Height"/>
</MultiBinding>
</Border.OpacityMask>
</Border>
<!-- ContentPresenter for the header -->
<Border x:Name="Header"
Padding="3,0,3,0"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1">
<ContentPresenter Name="PART_HeaderPresenter"
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}" />
</Border>
<!-- Primary content for GroupBox -->
<ContentPresenter Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Name="PART_ContentPresenter"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
<!-- ContentPresenter for the header -->
<Border x:Name="Header"
Padding="3,0,3,0"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1">
<ContentPresenter Name="PART_HeaderPresenter"
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}"
Margin="{TemplateBinding Padding}" />
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}" />
</Border>
<!-- Primary content for GroupBox -->
<ContentPresenter Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Name="PART_ContentPresenter"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}"
Margin="{TemplateBinding Padding}" />
</Grid>
</ControlTemplate>
</Setter>
@ -80,6 +80,6 @@
<Style Selector="^ /template/ ContentPresenter#PART_HeaderPresenter">
<Setter Property="RecognizesAccessKey" Value="True" />
</Style>
</ControlTheme>
</Style>
</ControlTheme>
</ResourceDictionary>

6
src/Avalonia.Vulkan/VulkanContext.cs

@ -41,6 +41,10 @@ internal class VulkanContext : IVulkanPlatformGraphicsContext
{
if (featureType == typeof(IVulkanContextExternalObjectsFeature))
return _externalObjectsFeature;
if (featureType == typeof(IVulkanKhrSurfacePlatformSurfaceFactory))
return _surfaceFactory;
return null;
}
@ -71,4 +75,4 @@ internal class VulkanContext : IVulkanPlatformGraphicsContext
throw new VulkanException("Unable to find a suitable platform surface");
}
}
}

75
tests/Avalonia.Base.UnitTests/DispatcherTests.cs

@ -512,76 +512,59 @@ public partial class DispatcherTests
[Fact]
public async Task DispatcherResumeContinuesOnUIThread()
public async Task DispatcherResumeContinuesOnCurrentThread()
{
using var services = new DispatcherServices(new SimpleControlledDispatcherImpl());
var tokenSource = new CancellationTokenSource();
var workload = Dispatcher.UIThread.InvokeAsync(
var dispatcher = Dispatcher.CurrentDispatcher;
var workload = dispatcher.InvokeAsync(
async () =>
{
Assert.True(Dispatcher.UIThread.CheckAccess());
Assert.True(dispatcher.CheckAccess());
await Task.Delay(1).ConfigureAwait(false);
Assert.False(Dispatcher.UIThread.CheckAccess());
Assert.False(dispatcher.CheckAccess());
await Dispatcher.UIThread.Resume();
Assert.True(Dispatcher.UIThread.CheckAccess());
await dispatcher.Resume();
Assert.True(dispatcher.CheckAccess());
tokenSource.Cancel();
});
Dispatcher.UIThread.MainLoop(tokenSource.Token);
dispatcher.MainLoop(tokenSource.Token);
}
[Fact]
public async Task DispatcherYieldContinuesOnUIThread()
public async Task DispatcherYieldContinuesOnCurrentThread()
{
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 dispatcher = Dispatcher.CurrentDispatcher;
var tokenSource = new CancellationTokenSource();
var workload = Dispatcher.UIThread.InvokeAsync(
var workload = dispatcher.InvokeAsync(
async () =>
{
Assert.True(Dispatcher.UIThread.CheckAccess());
Assert.True(dispatcher.CheckAccess());
await Task.Delay(1).ConfigureAwait(false);
Assert.False(Dispatcher.UIThread.CheckAccess());
await Assert.ThrowsAsync<InvalidOperationException>(async () => await Dispatcher.Yield());
await Dispatcher.Yield();
Assert.True(dispatcher.CheckAccess());
tokenSource.Cancel();
});
Dispatcher.UIThread.MainLoop(tokenSource.Token);
dispatcher.MainLoop(tokenSource.Token);
}
[Fact]
public async Task AwaitWithPriorityRunsOnUIThread()
public async Task AwaitWithPriorityRunsOnCurrentThread()
{
static async Task<int> Workload()
static async Task<int> Workload(Dispatcher dispatcher)
{
await Task.Delay(1).ConfigureAwait(false);
Assert.False(Dispatcher.UIThread.CheckAccess());
Assert.False(dispatcher.CheckAccess());
return Thread.CurrentThread.ManagedThreadId;
}
@ -589,25 +572,27 @@ public partial class DispatcherTests
using var services = new DispatcherServices(new SimpleControlledDispatcherImpl());
var tokenSource = new CancellationTokenSource();
var workload = Dispatcher.UIThread.InvokeAsync(
var dispatcher = Dispatcher.CurrentDispatcher;
var workload = dispatcher.InvokeAsync(
async () =>
{
Assert.True(Dispatcher.UIThread.CheckAccess());
Task taskWithoutResult = Workload();
Assert.True(dispatcher.CheckAccess());
Task taskWithoutResult = Workload(dispatcher);
await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default);
await dispatcher.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default);
Assert.True(Dispatcher.UIThread.CheckAccess());
Task<int> taskWithResult = Workload();
Assert.True(dispatcher.CheckAccess());
Task<int> taskWithResult = Workload(dispatcher);
await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default);
await dispatcher.AwaitWithPriority(taskWithResult, DispatcherPriority.Default);
Assert.True(Dispatcher.UIThread.CheckAccess());
Assert.True(dispatcher.CheckAccess());
tokenSource.Cancel();
});
Dispatcher.UIThread.MainLoop(tokenSource.Token);
dispatcher.MainLoop(tokenSource.Token);
}
private class AsyncLocalTestClass

111
tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs

@ -1160,113 +1160,26 @@ public class DrawerPageTests
public class IconTests : ScopedTestBase
{
[Fact]
public void Geometry_ReturnsPathIcon()
public void DrawerIconTemplate_RoundTrips()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var result = DrawerPage.CreateIconContent(geometry);
Assert.IsType<PathIcon>(result);
Assert.Same(geometry, ((PathIcon)result!).Data);
}
[Fact]
public void PathIcon_ReturnsPathIcon()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var pathIcon = new PathIcon { Data = geometry };
var result = DrawerPage.CreateIconContent(pathIcon);
Assert.IsType<PathIcon>(result);
Assert.Same(geometry, ((PathIcon)result!).Data);
}
[Fact]
public void DrawingImage_WithGeometryDrawing_ReturnsPathIcon()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var drawing = new GeometryDrawing { Geometry = geometry };
var drawingImage = new DrawingImage(drawing);
var result = DrawerPage.CreateIconContent(drawingImage);
Assert.IsType<PathIcon>(result);
Assert.Same(geometry, ((PathIcon)result!).Data);
}
[Fact]
public void Image_ReturnsImage()
{
var image = new TestImage();
var result = DrawerPage.CreateIconContent(image);
Assert.IsType<Image>(result);
Assert.Same(image, ((Image)result!).Source);
}
private sealed class TestImage : IImage
{
public Size Size => new Size(1, 1);
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { }
}
[Fact]
public void EmptyString_ReturnsNull()
{
var result = DrawerPage.CreateIconContent("");
Assert.Null(result);
}
[Fact]
public void NullString_ReturnsNull()
{
var result = DrawerPage.CreateIconContent((string?)null);
Assert.Null(result);
}
[Fact]
public void Null_ReturnsNull()
{
var result = DrawerPage.CreateIconContent(null);
Assert.Null(result);
}
[Fact]
public void Template_BuildsControl()
{
var template = new FuncTemplate<Control>(() => new Border());
var result = DrawerPage.CreateIconContent(template);
Assert.IsType<Border>(result);
}
[Fact]
public void Template_BuildsSeparateInstances()
{
var template = new FuncTemplate<Control>(() => new Border());
var first = DrawerPage.CreateIconContent(template);
var second = DrawerPage.CreateIconContent(template);
Assert.NotSame(first, second);
var template = new FuncDataTemplate<object>((_, _) => new PathIcon());
var dp = new DrawerPage { DrawerIconTemplate = template };
Assert.Same(template, dp.DrawerIconTemplate);
}
[Fact]
public void NonEmptyString_ReturnsNull()
{
var result = DrawerPage.CreateIconContent("M10 20v-6h4v6");
Assert.Null(result);
}
[Fact]
public void UnsupportedType_ReturnsNull()
{
var result = DrawerPage.CreateIconContent(42);
Assert.Null(result);
}
[Fact]
public void ChangingDrawerIcon_AfterTemplateApplied_UpdatesPresenters()
public void DrawerIcon_With_Geometry_Does_Not_Throw()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var dp = new DrawerPage { DrawerIcon = new PathIcon { Data = geometry } };
var dp = new DrawerPage
{
DrawerIcon = geometry,
DrawerIconTemplate = new FuncDataTemplate<object>((_, _) => new PathIcon()),
};
var root = new TestRoot { Child = dp };
var geometry2 = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) };
dp.DrawerIcon = new PathIcon { Data = geometry2 };
Assert.Same(geometry2, dp.DrawerIcon is PathIcon pi ? pi.Data : null);
dp.DrawerIcon = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) };
Assert.NotNull(dp.DrawerIcon);
}
}

89
tests/Avalonia.Controls.UnitTests/TabControlTests.cs

@ -955,6 +955,30 @@ namespace Avalonia.Controls.UnitTests
}.RegisterInNameScope(scope));
}
private static IControlTemplate TabItemWithIconTemplate()
{
return new FuncControlTemplate<TabItem>((parent, scope) =>
new StackPanel
{
Children =
{
new ContentPresenter
{
Name = "PART_IconPresenter",
[~ContentPresenter.ContentProperty] = new TemplateBinding(TabItem.IconProperty),
[~ContentPresenter.ContentTemplateProperty] = new TemplateBinding(TabItem.IconTemplateProperty),
}.RegisterInNameScope(scope),
new ContentPresenter
{
Name = "PART_ContentPresenter",
[~ContentPresenter.ContentProperty] = new TemplateBinding(TabItem.HeaderProperty),
[~ContentPresenter.ContentTemplateProperty] = new TemplateBinding(TabItem.HeaderTemplateProperty),
RecognizesAccessKey = true,
}.RegisterInNameScope(scope),
}
});
}
private static ControlTheme CreateTabControlControlTheme()
{
return new ControlTheme(typeof(TabControl))
@ -1495,35 +1519,72 @@ namespace Avalonia.Controls.UnitTests
}
[Fact]
public void TabItem_Icon_DefaultIsNull()
public void TabItem_IconTemplate_Creates_Content_From_NonControl_Icon()
{
var tabItem = new TabItem();
Assert.Null(tabItem.Icon);
var tabItem = new TabItem
{
Icon = "home",
IconTemplate = new FuncDataTemplate<object>((val, _) =>
new TextBlock { Text = (string)val }),
Template = TabItemWithIconTemplate(),
};
var root = new TestRoot { Child = tabItem };
tabItem.ApplyTemplate();
tabItem.Presenter!.UpdateChild();
var iconPresenter = tabItem.GetTemplateChildren().OfType<ContentPresenter>().First(x => x.Name == "PART_IconPresenter");
Assert.NotNull(iconPresenter);
Assert.Equal("home", iconPresenter!.Content);
Assert.NotNull(iconPresenter.ContentTemplate);
iconPresenter.UpdateChild();
var textBlock = iconPresenter.Child as TextBlock;
Assert.NotNull(textBlock);
Assert.Equal("home", textBlock!.Text);
}
[Fact]
public void TabItem_Icon_RoundTrips()
public void TabItem_Icon_Without_Template_Renders_Control_Directly()
{
var tabItem = new TabItem();
var icon = new Avalonia.Controls.Shapes.Path
{
Data = new Avalonia.Media.EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }
};
tabItem.Icon = icon;
Assert.Same(icon, tabItem.Icon);
var tabItem = new TabItem
{
Icon = icon,
Template = TabItemWithIconTemplate(),
};
var root = new TestRoot { Child = tabItem };
tabItem.ApplyTemplate();
tabItem.Presenter!.UpdateChild();
var iconPresenter = tabItem.GetTemplateChildren().OfType<ContentPresenter>().First(x => x.Name == "PART_IconPresenter");
Assert.NotNull(iconPresenter);
Assert.Same(icon, iconPresenter!.Content);
Assert.Null(iconPresenter.ContentTemplate);
}
[Fact]
public void TabItem_Icon_CanBeSetToNull()
public void TabItem_Icon_Change_Updates_Presenter_Content()
{
var tabItem = new TabItem();
var icon = new Avalonia.Controls.Shapes.Path
var tabItem = new TabItem
{
Data = new Avalonia.Media.EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }
Icon = "first",
Template = TabItemWithIconTemplate(),
};
tabItem.Icon = icon;
tabItem.Icon = null;
Assert.Null(tabItem.Icon);
var root = new TestRoot { Child = tabItem };
tabItem.ApplyTemplate();
tabItem.Presenter!.UpdateChild();
var iconPresenter = tabItem.GetTemplateChildren().OfType<ContentPresenter>().First(x => x.Name == "PART_IconPresenter");
Assert.Equal("first", iconPresenter!.Content);
tabItem.Icon = "second";
Assert.Equal("second", iconPresenter.Content);
}
[Fact]

108
tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs

@ -913,111 +913,53 @@ public class TabbedPageTests
}
}
public class IconTests : ScopedTestBase
public class PageIconTemplateTests : ScopedTestBase
{
[Fact]
public void Geometry_ReturnsPath()
public void Page_Icon_AcceptsControlValue()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var result = TabbedPage.CreateIconContent(geometry);
Assert.IsType<Path>(result);
Assert.Same(geometry, ((Path)result!).Data);
var icon = new PathIcon { Data = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) } };
var page = new ContentPage { Icon = icon };
Assert.Same(icon, page.Icon);
}
[Fact]
public void PathIcon_ReturnsPath()
public void Page_Icon_AcceptsNonControlValue()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var pathIcon = new PathIcon { Data = geometry };
var result = TabbedPage.CreateIconContent(pathIcon);
Assert.IsType<Path>(result);
Assert.Same(geometry, ((Path)result!).Data);
}
[Fact]
public void EmptyString_ReturnsNull()
{
var result = TabbedPage.CreateIconContent("");
Assert.Null(result);
}
[Fact]
public void NullString_ReturnsNull()
{
var result = TabbedPage.CreateIconContent((string?)null);
Assert.Null(result);
var page = new ContentPage { Icon = geometry };
Assert.Same(geometry, page.Icon);
}
[Fact]
public void Null_ReturnsNull()
public void Page_IconTemplate_RoundTrips()
{
var result = TabbedPage.CreateIconContent(null);
Assert.Null(result);
var template = new FuncDataTemplate<object>((_, _) => new Border());
var page = new ContentPage { IconTemplate = template };
Assert.Same(template, page.IconTemplate);
}
[Fact]
public void DrawingImage_WithGeometryDrawing_ReturnsPath()
public void DrawerPage_DrawerIconTemplate_RoundTrips()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var drawing = new GeometryDrawing { Geometry = geometry };
var drawingImage = new DrawingImage(drawing);
var result = TabbedPage.CreateIconContent(drawingImage);
Assert.IsType<Path>(result);
Assert.Same(geometry, ((Path)result!).Data);
var template = new FuncDataTemplate<object>((_, _) => new Border());
var dp = new DrawerPage { DrawerIconTemplate = template };
Assert.Same(template, dp.DrawerIconTemplate);
}
[Fact]
public void Path_HasStretchUniform()
public void DrawerPage_DrawerIcon_With_Geometry_Does_Not_Throw()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var result = TabbedPage.CreateIconContent(geometry);
Assert.Equal(Stretch.Uniform, ((Path)result!).Stretch);
}
[Fact]
public void Image_ReturnsImage()
{
var image = new TestImage();
var result = TabbedPage.CreateIconContent(image);
Assert.IsType<Image>(result);
Assert.Same(image, ((Image)result!).Source);
}
private sealed class TestImage : IImage
{
public Size Size => new Size(1, 1);
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { }
}
[Fact]
public void Template_BuildsControl()
{
var template = new FuncTemplate<Control>(() => new Border());
var result = TabbedPage.CreateIconContent(template);
Assert.IsType<Border>(result);
}
[Fact]
public void Template_BuildsSeparateInstances()
{
var template = new FuncTemplate<Control>(() => new Border());
var first = TabbedPage.CreateIconContent(template);
var second = TabbedPage.CreateIconContent(template);
Assert.NotSame(first, second);
}
[Fact]
public void NonEmptyString_ReturnsNull()
{
var result = TabbedPage.CreateIconContent("M10 20v-6h4v6");
Assert.Null(result);
}
var dp = new DrawerPage
{
DrawerIcon = geometry,
DrawerIconTemplate = new FuncDataTemplate<object>((_, _) => new PathIcon()),
};
var root = new TestRoot { Child = dp };
[Fact]
public void UnsupportedType_ReturnsNull()
{
var result = TabbedPage.CreateIconContent(42);
Assert.Null(result);
dp.DrawerIcon = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) };
Assert.NotNull(dp.DrawerIcon);
}
}

121
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@ -632,6 +632,69 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Vector(0, 0), scroll.Offset);
}
[Fact]
public void Shrinking_Viewport_Then_Growing_Back_Triggers_Remeasure()
{
// Regression test for stale _extendedViewport comparison in OnEffectiveViewportChanged.
//
// When the viewport shrinks (e.g., ComboBox popup shrinks during filtering),
// OnEffectiveViewportChanged doesn't trigger a measure (needsMeasure=false because
// the smaller viewport is within the old extended viewport). The _extendedViewport
// comparison baseline is NOT updated. When the viewport later grows back,
// OnEffectiveViewportChanged compares against the stale large _extendedViewport,
// concludes "no significant change", and skips the measure. This prevents item
// realization when the only measure trigger is OnEffectiveViewportChanged.
//
// The fix uses a separate _lastKnownExtendedViewport that is always updated,
// so the comparison correctly detects viewport growth after a shrink.
//
// Key: ScrollContentPresenter passes infinite height for vertical scroll, so
// the panel's MeasureOverride is NOT called from the layout cascade when only
// the root size changes. OnEffectiveViewportChanged is the sole measure trigger.
using var app = App();
var items = Enumerable.Range(0, 20).Select(x => $"Item {x}");
var (target, scroll, itemsControl) =
CreateUnrootedTarget<ItemsControl, VirtualizingStackPanelCountingMeasureArrange>(
items: items, bufferFactor: 0);
var root = CreateRoot(itemsControl, new Size(100, 100));
root.LayoutManager.ExecuteInitialLayoutPass();
// Initial state: viewport 0-100, 10 items visible, _extendedViewport = (0,0,100,100)
AssertRealizedItems(target, itemsControl, 0, 10);
// Shrink viewport (simulates popup shrinking when items are filtered).
// Panel MeasureOverride is NOT called (ScrollContentPresenter passes infinite height).
// OnEffectiveViewportChanged fires with small viewport but needsMeasure=false
// because the small viewport is within the old _extendedViewport.
root.ClientSize = new Size(100, 10);
root.InvalidateMeasure();
Layout(target);
// Reset counters after shrink
target.ResetMeasureArrangeCounters();
// Grow viewport back (simulates popup growing when filter is removed).
// Panel MeasureOverride is NOT called from layout cascade (same infinite constraint).
// OnEffectiveViewportChanged is the ONLY path to trigger a remeasure.
root.ClientSize = new Size(100, 100);
root.InvalidateMeasure();
Layout(target);
// Without fix: OnEffectiveViewportChanged compares new viewport (0-100) against
// stale _extendedViewport (0-100, never updated during shrink). Sees no change.
// needsMeasure=false. No remeasure triggered. Measure count = 0.
//
// With fix: compares against _lastKnownExtendedViewport (0-10, updated during
// shrink). Detects that viewport grew past it (100 > 10). needsMeasure=true.
// InvalidateMeasure called. Measure count >= 1.
Assert.True(target.Measured >= 1,
"Panel should be re-measured when viewport grows back after a previous shrink. " +
"OnEffectiveViewportChanged must detect viewport growth by comparing against " +
"the last known extended viewport, not the stale _extendedViewport.");
}
[Theory]
[InlineData(0d, 10, "4,9")]
[InlineData(0.5d, 20, "4,9,14,19")]
@ -1655,8 +1718,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(0, target.ViewPort.Top);
Assert.Equal(100, target.ViewPort.Bottom);
Assert.Equal(0, target.ExtendedViewPort.Top);
Assert.Equal(200, target.ExtendedViewPort.Bottom);
Assert.Equal(0, target.LastMeasuredExtendedViewPort.Top);
Assert.Equal(200, target.LastMeasuredExtendedViewPort.Bottom);
}
[Fact]
@ -1680,8 +1743,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(900, target.ViewPort.Top);
Assert.Equal(1000, target.ViewPort.Bottom);
Assert.Equal(800, target.ExtendedViewPort.Top);
Assert.Equal(1000, target.ExtendedViewPort.Bottom);
Assert.Equal(800, target.LastMeasuredExtendedViewPort.Top);
Assert.Equal(1000, target.LastMeasuredExtendedViewPort.Bottom);
}
[Fact]
@ -1705,8 +1768,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(500, target.ViewPort.Top);
Assert.Equal(600, target.ViewPort.Bottom);
Assert.Equal(450, target.ExtendedViewPort.Top);
Assert.Equal(650, target.ExtendedViewPort.Bottom);
Assert.Equal(450, target.LastMeasuredExtendedViewPort.Top);
Assert.Equal(650, target.LastMeasuredExtendedViewPort.Bottom);
}
[Fact]
@ -1729,8 +1792,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(0, target.ViewPort.Left);
Assert.Equal(100, target.ViewPort.Right);
Assert.Equal(0, target.ExtendedViewPort.Left);
Assert.Equal(200, target.ExtendedViewPort.Right);
Assert.Equal(0, target.LastMeasuredExtendedViewPort.Left);
Assert.Equal(200, target.LastMeasuredExtendedViewPort.Right);
}
[Fact]
@ -1754,8 +1817,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(900, target.ViewPort.Left);
Assert.Equal(1000, target.ViewPort.Right);
Assert.Equal(800, target.ExtendedViewPort.Left);
Assert.Equal(1000, target.ExtendedViewPort.Right);
Assert.Equal(800, target.LastMeasuredExtendedViewPort.Left);
Assert.Equal(1000, target.LastMeasuredExtendedViewPort.Right);
}
[Fact]
@ -1780,8 +1843,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(500, target.ViewPort.Left);
Assert.Equal(600, target.ViewPort.Right);
Assert.Equal(450, target.ExtendedViewPort.Left);
Assert.Equal(650, target.ExtendedViewPort.Right);
Assert.Equal(450, target.LastMeasuredExtendedViewPort.Left);
Assert.Equal(650, target.LastMeasuredExtendedViewPort.Right);
}
[Fact]
@ -1806,11 +1869,15 @@ namespace Avalonia.Controls.UnitTests
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.LastRealizedIndex < 20)
{
scroll.Offset = new Vector(0, scroll.Offset.Y + 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -1862,11 +1929,15 @@ namespace Avalonia.Controls.UnitTests
var initialFirstRealizedIndex = target.FirstRealizedIndex;
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex >= 15)
{
scroll.Offset = new Vector(0, scroll.Offset.Y - 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -1918,11 +1989,15 @@ namespace Avalonia.Controls.UnitTests
var initialLastRealizedIndex = target.LastRealizedIndex;
var count = 0;
// Scroll down until we reached the very last item
while (target.LastRealizedIndex < 99)
{
scroll.Offset = new Vector(0, scroll.Offset.Y + 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -1972,11 +2047,15 @@ namespace Avalonia.Controls.UnitTests
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex > 0)
{
scroll.Offset = new Vector(0, scroll.Offset.Y - 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2022,12 +2101,15 @@ namespace Avalonia.Controls.UnitTests
// shows 20 items, each is 10 high.
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.LastRealizedIndex < 20)
{
scroll.Offset = new Vector(scroll.Offset.X + 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2079,12 +2161,15 @@ namespace Avalonia.Controls.UnitTests
// until then no measure-arrange call should happen
var initialFirstRealizedIndex = target.FirstRealizedIndex;
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex >= 15)
{
scroll.Offset = new Vector(scroll.Offset.X - 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2137,11 +2222,15 @@ namespace Avalonia.Controls.UnitTests
var initialLastRealizedIndex = target.LastRealizedIndex;
var count = 0;
// Scroll down until we reached the very last item
while (target.LastRealizedIndex < 99)
{
scroll.Offset = new Vector(scroll.Offset.X + 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2192,11 +2281,15 @@ namespace Avalonia.Controls.UnitTests
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex > 0)
{
scroll.Offset = new Vector(scroll.Offset.X - 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert

Loading…
Cancel
Save