diff --git a/Directory.Build.targets b/Directory.Build.targets index 77feb5312b4a..6713b61f0524 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -2,6 +2,13 @@ + + + false + + 8.0.148 - 1.8.260508005 - 10.0.26100.4654 - 1.3.2 + 1.8.260529003 + 10.0.26100.8249 + 1.4.0 1.0.3179.45 10.0.0 diff --git a/eng/cake/dotnet.cake b/eng/cake/dotnet.cake index 918774f8fda3..7c2fe8e9a13f 100644 --- a/eng/cake/dotnet.cake +++ b/eng/cake/dotnet.cake @@ -273,6 +273,7 @@ Task("dotnet-test") "**/Resizetizer.UnitTests.csproj", "**/Graphics.Tests.csproj", "**/Compatibility.Core.UnitTests.csproj", + "**/MauiBlazorWebView.UnitTests.csproj", }; var success = true; diff --git a/eng/devices/windows.cake b/eng/devices/windows.cake index e0cc1af82f5c..08af65fc8cb9 100644 --- a/eng/devices/windows.cake +++ b/eng/devices/windows.cake @@ -71,15 +71,43 @@ Task("GenerateMsixCert") .WithCriteria(isPackagedTestRun) .Does(() => { - // We need the key to be in LocalMachine -> TrustedPeople to install the msix signed with the key + // We need the key to be in LocalMachine -> TrustedPeople to install the msix signed with the key. + // Open read-only first so we can detect an existing cert without requiring admin. Only escalate + // to ReadWrite (which requires admin on LocalMachine) when we actually need to create the cert. var localTrustedPeopleStore = new X509Store("TrustedPeople", StoreLocation.LocalMachine); - localTrustedPeopleStore.Open(OpenFlags.ReadWrite); + localTrustedPeopleStore.Open(OpenFlags.ReadOnly); + var expectedSubject = "CN=" + certCN; + certificateThumbprint = localTrustedPeopleStore.Certificates + .Cast() + .FirstOrDefault(c => c.Subject == expectedSubject)?.Thumbprint; + localTrustedPeopleStore.Close(); - // We need to have the key also in CurrentUser -> My so that the msix can be built and signed - // with the key by passing the key's thumbprint to the build - var currentUserMyStore = new X509Store("My", StoreLocation.CurrentUser); - currentUserMyStore.Open(OpenFlags.ReadWrite); - certificateThumbprint = localTrustedPeopleStore.Certificates.FirstOrDefault(c => c.Subject.Contains(certCN))?.Thumbprint; + // If a cert exists, verify it has a usable user-scoped private key in CurrentUser\My. A cert + // installed by an older version of this script may reference a private key in the machine key + // container (C:\ProgramData\Microsoft\Crypto\...), which is unreadable from a non-elevated + // process — signtool would then fail mid-build with an opaque "No certificates were found that + // met all the given criteria". If unusable, remove the stale entries and fall through to the + // creation path below. + if (!string.IsNullOrEmpty(certificateThumbprint) && !IsCurrentUserSigningCertUsable(certificateThumbprint)) + { + Information("Existing cert {0} has no usable user-scoped private key; removing and recreating.", certificateThumbprint); + try + { + RemoveCertByThumbprint(StoreLocation.LocalMachine, "TrustedPeople", certificateThumbprint); + RemoveCertByThumbprint(StoreLocation.CurrentUser, "My", certificateThumbprint); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + throw new Exception( + "Cert " + certificateThumbprint + " exists in LocalMachine\\TrustedPeople but its private key " + + "is not accessible from this non-elevated process, and removing the stale cert also requires " + + "elevation. Please remove the stale entries manually and re-run this task elevated once:\n" + + " Remove-Item Cert:\\LocalMachine\\TrustedPeople\\" + certificateThumbprint + "\n" + + " Remove-Item Cert:\\CurrentUser\\My\\" + certificateThumbprint, + ex); + } + certificateThumbprint = null; + } if (string.IsNullOrEmpty(certificateThumbprint)) { @@ -111,18 +139,110 @@ Task("GenerateMsixCert") cert.FriendlyName = certCN; } - var tmpCert = new X509Certificate2(cert.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); + // Store the private key in the *user* key container (not the machine container) so the + // current non-elevated user can use it to sign. LocalMachine\TrustedPeople only needs the + // cert's public key for sideload trust validation, so a user-scope private key is enough. + // Using MachineKeySet here would put the key in C:\ProgramData\Microsoft\Crypto\... + // which is unreadable from a non-admin process — signtool then fails with "No certificates + // were found that met all the given criteria" even though the cert is visible in the store. + var tmpCert = new X509Certificate2(cert.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.PersistKeySet); certificateThumbprint = tmpCert.Thumbprint; - localTrustedPeopleStore.Add(tmpCert); + + // Writing to LocalMachine\TrustedPeople requires admin. If we don't have it, fail with a + // clear message rather than the raw "Access is denied" from the store. + try + { + localTrustedPeopleStore.Open(OpenFlags.ReadWrite); + localTrustedPeopleStore.Add(tmpCert); + localTrustedPeopleStore.Close(); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + throw new Exception( + "Failed to install signing cert into LocalMachine\\TrustedPeople. " + + "This step requires an elevated (administrator) shell on first run. " + + "After the cert is created once, subsequent runs can be performed without elevation.", + ex); + } + + // CurrentUser\My only needs admin if the process doesn't own the profile, so do it after + // the LocalMachine write succeeded. + var currentUserMyStore = new X509Store("My", StoreLocation.CurrentUser); + currentUserMyStore.Open(OpenFlags.ReadWrite); currentUserMyStore.Add(tmpCert); + currentUserMyStore.Close(); + } + else + { + Information("Reusing existing cert {0} from CurrentUser\\My.", certificateThumbprint); } - localTrustedPeopleStore.Close(); - currentUserMyStore.Close(); - - Information("Cert thumbprint: " + certificateThumbprint ?? "null"); + Information("Cert thumbprint: {0}", certificateThumbprint ?? "null"); }); +// Verifies the cert with the given thumbprint exists in CurrentUser\My and that its private key +// can actually be used for signing. Uses SignData rather than ExportParameters because reading +// public parameters never touches the private key container and would succeed even when the key +// material lives in an inaccessible machine key container. +bool IsCurrentUserSigningCertUsable(string thumbprint) +{ + var store = new X509Store("My", StoreLocation.CurrentUser); + try + { + store.Open(OpenFlags.ReadOnly); + var cert = store.Certificates + .Cast() + .FirstOrDefault(c => c.Thumbprint == thumbprint); + if (cert == null || !cert.HasPrivateKey) + { + return false; + } + try + { + using var key = cert.GetRSAPrivateKey(); + if (key == null) + { + return false; + } + // Exercise the actual signing path; throws CryptographicException when the private key + // material is in a container we can't access. + key.SignData(Array.Empty(), System.Security.Cryptography.HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return true; + } + catch (System.Security.Cryptography.CryptographicException) + { + return false; + } + } + finally + { + store.Close(); + } +} + +// Removes the cert with the given thumbprint from the specified store. Propagates +// CryptographicException so the caller can surface a meaningful error when removal requires +// elevation we don't have. +void RemoveCertByThumbprint(StoreLocation location, string storeName, string thumbprint) +{ + var store = new X509Store(storeName, location); + try + { + store.Open(OpenFlags.ReadWrite); + var cert = store.Certificates + .Cast() + .FirstOrDefault(c => c.Thumbprint == thumbprint); + if (cert != null) + { + store.Remove(cert); + } + } + finally + { + store.Close(); + } +} + Task("buildOnly") .IsDependentOn("GenerateMsixCert") .WithCriteria(!string.IsNullOrEmpty(PROJECT.FullPath)) @@ -341,19 +461,33 @@ Task("testOnly") var cerPath = cerPaths.First(); Information($"Found MSIX, installing: {msixPath}"); + int InstallAppxPackage(FilePath path) { + var absPath = MakeAbsolute(path).FullPath; + Information("Installing MSIX: {0}", absPath); + // $ProgressPreference='SilentlyContinue' suppresses Add-AppxPackage's + // progress output which otherwise chokes cake's stdout pipe. + return StartProcess("powershell", + "-NoProfile -Command \"$ProgressPreference='SilentlyContinue'; Add-AppxPackage -Path '" + absPath + "'; if (-not $?) { exit 1 }\""); + } + // Install dependencies var dependencies = GetFiles(projectDir.FullPath + "/**/AppPackages/**/Dependencies/x64/*.msix"); foreach (var dep in dependencies) { - Information("Installing Dependency MSIX: {0}", dep); try { - StartProcess("powershell", "Add-AppxPackage -Path \"" + MakeAbsolute(dep).FullPath + "\""); - } catch { + var depExit = InstallAppxPackage(dep); + if (depExit != 0) { + Warning($"Failed to install dependency (exit code {depExit}): {dep}"); + } + } catch { Warning($"Failed to install dependency: {dep}"); } } // Install the DeviceTests app - StartProcess("powershell", "Add-AppxPackage -Path \"" + MakeAbsolute(msixPath).FullPath + "\""); + var installExit = InstallAppxPackage(msixPath); + if (installExit != 0) { + throw new Exception($"Failed to install app MSIX (exit code {installExit}): {msixPath}"); + } if (isControlsProjectTestRun) { diff --git a/eng/helix.proj b/eng/helix.proj index 5ba803fef9ba..61ab2be44391 100644 --- a/eng/helix.proj +++ b/eng/helix.proj @@ -36,6 +36,7 @@ + diff --git a/eng/pipelines/ci.yml b/eng/pipelines/ci.yml index bc962be9a192..85e62e098d5e 100644 --- a/eng/pipelines/ci.yml +++ b/eng/pipelines/ci.yml @@ -300,7 +300,7 @@ stages: timeout: 240 testCategory: AOT - - name: mac_runandroid_tests + - name: linux_runandroid_tests ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}: pool: ${{ parameters.AndroidPoolLinux }} ${{ else }}: diff --git a/eng/pipelines/common/ui-tests-steps.yml b/eng/pipelines/common/ui-tests-steps.yml index b5b2a8f79a8a..943768f9c40b 100644 --- a/eng/pipelines/common/ui-tests-steps.yml +++ b/eng/pipelines/common/ui-tests-steps.yml @@ -152,6 +152,11 @@ steps: if ($testFilter) { $command += " --test-filter ""$testFilter""" } + + $deviceType = "${{ parameters.deviceType }}" + if ($deviceType) { + $command += " --skin=""$deviceType""" + } $headless = ${{ parameters.headless }} if ($headless) { diff --git a/eng/pipelines/common/ui-tests.yml b/eng/pipelines/common/ui-tests.yml index eadb114c2799..6101f908cd9a 100644 --- a/eng/pipelines/common/ui-tests.yml +++ b/eng/pipelines/common/ui-tests.yml @@ -5,9 +5,9 @@ parameters: windowsPool: { } windowsBuildPool: { } macosPool: { } - androidApiLevels: [ 30 ] - androidApiLevelsExtended: [ 36 ] # API 36 for Material3 tests with Pixel 3 XL iosVersions: [ 'latest' ] + androidApiLevels: [ 30 ] + androidApiLevelsExtended: [ 36 ] # Separate API level for tests requiring Pixel 3 XL provisionatorChannel: 'latest' defaultiOSVersion: '26.0' timeoutInMinutes: 180 @@ -33,7 +33,10 @@ parameters: - 'Navigation' - 'Page,Performance,Picker,ProgressBar' - 'RadioButton,RefreshView' - - 'SafeAreaEdges,Shadow' + # SafeAreaEdges runs on the common lanes, except on Android where it is excluded and runs + # in a dedicated notch-device (Pixel 3 XL) stage instead. + - 'SafeAreaEdges' + - 'Shadow' - 'ScrollView' - 'SearchBar,Shape,Slider' - 'SoftInput,Stepper,Switch,SwipeView' @@ -150,9 +153,11 @@ stages: - job: android_ui_tests_${{ project.name }}_${{ api }} strategy: matrix: + # SafeAreaEdges is excluded here; it runs on a dedicated Pixel 3 XL (notch) stage on Android ${{ each categoryGroup in parameters.categoryGroupsToTest }}: - ${{ categoryGroup }}: - CATEGORYGROUP: ${{ categoryGroup }} + ${{ if ne(categoryGroup, 'SafeAreaEdges') }}: + ${{ categoryGroup }}: + CATEGORYGROUP: ${{ categoryGroup }} timeoutInMinutes: 240 # how long to run the job before automatically cancelling workspace: clean: all @@ -194,9 +199,11 @@ stages: - job: android_ui_tests_${{ project.name }}_${{ api }} strategy: matrix: + # SafeAreaEdges is excluded here; it runs on a dedicated Pixel 3 XL (notch) stage on Android ${{ each categoryGroup in parameters.categoryGroupsToTest }}: - ${{ categoryGroup }}: - CATEGORYGROUP: ${{ categoryGroup }} + ${{ if ne(categoryGroup, 'SafeAreaEdges') }}: + ${{ categoryGroup }}: + CATEGORYGROUP: ${{ categoryGroup }} timeoutInMinutes: 240 # how long to run the job before automatically cancelling workspace: clean: all @@ -269,6 +276,42 @@ stages: platform: 'Android Material3' artifactName: 'uitest-snapshot-results-android-material3-$(System.StageName)-$(System.JobName)-$(System.JobAttempt)' +# SafeAreaEdges tests on Android API 36 with Pixel 3 XL + - stage: android_ui_tests_safeareaedges + displayName: Android UITests SafeAreaEdges (API 36 Pixel 3 XL) + dependsOn: build_ui_tests + jobs: + - ${{ each project in parameters.projects }}: + - ${{ if ne(project.android, '') }}: + - ${{ each api in parameters.androidApiLevelsExtended }}: + - job: android_ui_tests_safeareaedges_${{ project.name }}_${{ api }} + timeoutInMinutes: 240 # how long to run the job before automatically cancelling + workspace: + clean: all + displayName: ${{ coalesce(project.desc, project.name) }} SafeAreaEdges (API ${{ api }} Pixel 3 XL) + pool: ${{ parameters.androidLinuxPool }} + variables: + REQUIRED_XCODE: $(DEVICETESTS_REQUIRED_XCODE) + APPIUM_HOME: $(System.DefaultWorkingDirectory)/.appium/ + steps: + - template: ui-tests-steps.yml + parameters: + platform: android + version: ${{ api }} + path: ${{ project.android }} + app: ${{ project.app }} + device: android-emulator-64_${{ api }} + deviceType: 'pixel_3_xl' # Pixel 3 XL has notch display for SafeAreaEdges tests + provisionatorChannel: ${{ parameters.provisionatorChannel }} + testFilter: 'SafeAreaEdges' + skipProvisioning: ${{ parameters.skipProvisioning }} + + # Collect and publish Android SafeAreaEdges snapshot diffs + - template: ui-tests-collect-snapshot-diffs.yml + parameters: + platform: 'Android SafeAreaEdges' + artifactName: 'uitest-snapshot-results-android-safeareaedges-$(System.StageName)-$(System.JobName)-$(System.JobAttempt)' + - stage: ios_ui_tests_mono displayName: iOS UITests Mono dependsOn: build_ui_tests diff --git a/src/BlazorWebView/src/Maui/Extensions/UriExtensions.cs b/src/BlazorWebView/src/Maui/Extensions/UriExtensions.cs index 3767e19237c5..7e8b137b2fc8 100644 --- a/src/BlazorWebView/src/Maui/Extensions/UriExtensions.cs +++ b/src/BlazorWebView/src/Maui/Extensions/UriExtensions.cs @@ -1,20 +1,27 @@ using System; using System.IO; -namespace Microsoft.AspNetCore.Components.WebView.Maui +namespace Microsoft.AspNetCore.Components.WebView.Maui; + +internal static class UriExtensions { - internal static class UriExtensions + internal static bool IsBaseOfPage(this Uri baseUri, string? uriString) { - internal static bool IsBaseOfPage(this Uri baseUri, string? uriString) + if (string.IsNullOrWhiteSpace(uriString)) { - if (Path.HasExtension(uriString)) - { - // If the path ends in a file extension, it's not referring to a page. - return false; - } + return false; + } - var uri = new Uri(uriString!); - return baseUri.IsBaseOf(uri); + if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) + { + return false; } + + if (Path.HasExtension(uri.GetComponents(UriComponents.Path, UriFormat.Unescaped))) + { + return false; + } + + return baseUri.IsBaseOf(uri); } } diff --git a/src/BlazorWebView/src/Maui/MauiBlazorWebViewBuilderExtensions.cs b/src/BlazorWebView/src/Maui/MauiBlazorWebViewBuilderExtensions.cs new file mode 100644 index 000000000000..ad9553b07fc5 --- /dev/null +++ b/src/BlazorWebView/src/Maui/MauiBlazorWebViewBuilderExtensions.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Maui; +using Microsoft.Maui.Hosting; + +namespace Microsoft.AspNetCore.Components.WebView.Maui +{ + /// + /// Extension methods for . + /// + public static class MauiBlazorWebViewBuilderExtensions + { + /// + /// Registers a custom handler for , replacing the default + /// registered by + /// . + /// This allows custom platform backends to provide their own BlazorWebView handler + /// while reusing all shared service registrations. + /// + /// + /// Replacement is "last-registration-wins" through the underlying MAUI handler collection. + /// Call this method after AddMauiBlazorWebView() so the custom handler + /// overrides the default registration. If a downstream library calls + /// AddMauiBlazorWebView() again later in the pipeline, that subsequent default + /// registration will silently re-override this custom handler — call this method last, + /// after every other library's MAUI Blazor configuration, when composing multiple sources. + /// + /// The custom handler type to use for . + /// Must have a public parameterless constructor. + /// The . + /// The for chaining. + public static IMauiBlazorWebViewBuilder UsePlatformHandler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( + this IMauiBlazorWebViewBuilder builder) + where THandler : IViewHandler, new() + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.ConfigureMauiHandlers(handlers => + handlers.AddHandler()); + return builder; + } + + /// + /// Registers a custom handler for using a factory method, + /// replacing the default registered by + /// . + /// Use this overload for handlers that lack a public parameterless constructor or that + /// need to pull dependencies from the MAUI handler service container at construction time. + /// + /// + /// The passed to is the MAUI + /// handler factory's service provider, not the application's root . + /// It can resolve services that were registered on the handler collection (via + /// ConfigureMauiHandlers); it cannot resolve arbitrary services from the app's + /// . The same call-ordering rule as + /// applies — call this + /// method after AddMauiBlazorWebView() (and after any later re-invocations from + /// downstream libraries) so the custom handler is the last registration to win. + /// + /// The . + /// A factory function that creates the handler instance. + /// The argument is the MAUI handler factory's service provider + /// (see remarks). + /// The for chaining. + public static IMauiBlazorWebViewBuilder UsePlatformHandler( + this IMauiBlazorWebViewBuilder builder, + Func factory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(factory); + builder.Services.ConfigureMauiHandlers(handlers => + handlers.AddHandler(factory)); + return builder; + } + } +} diff --git a/src/BlazorWebView/src/Maui/Properties/AssemblyInfo.cs b/src/BlazorWebView/src/Maui/Properties/AssemblyInfo.cs index 75fc9e1e0adb..6ad8b3a8aa4b 100644 --- a/src/BlazorWebView/src/Maui/Properties/AssemblyInfo.cs +++ b/src/BlazorWebView/src/Maui/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Internals; +using System.Runtime.CompilerServices; + [assembly: Preserve] [assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/2021/maui", "Microsoft.AspNetCore.Components.WebView.Maui")] +[assembly: InternalsVisibleTo("Microsoft.Maui.MauiBlazorWebView.UnitTests")] diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt index 51e039fc88da..038b7546d70e 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -1,2 +1,5 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! override Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewHandler.ConnectHandler(Android.Webkit.WebView! platformView) -> void diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 7dc5c58110bf..79428f930adf 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 7dc5c58110bf..79428f930adf 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 7dc5c58110bf..79428f930adf 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 7dc5c58110bf..79428f930adf 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt index 7dc5c58110bf..79428f930adf 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! +static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Services.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Services.cs index 9a4c8fd9132a..b29a23d27192 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Services.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Services.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Components.WebView.Maui; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Hosting; using Microsoft.Maui.MauiBlazorWebView.DeviceTests.Components; using WebViewAppShared; using Xunit; @@ -119,4 +120,55 @@ await Assert.ThrowsAsync(async () => }); }); } + + [Fact] + public void UsePlatformHandlerGenericReplacesDefaultBlazorWebViewHandler() + { + // Verifies that the public UsePlatformHandler() extension on IMauiBlazorWebViewBuilder + // actually replaces the default BlazorWebViewHandler registered by AddMauiBlazorWebView() + // for the IBlazorWebView service type. The two HostBuilderHandlerTests in Core verify the + // underlying ConfigureMauiHandlers replacement mechanism with stub types; this test exercises + // the new public API surface end-to-end with the real BlazorWebView/IBlazorWebView types. + var builder = MauiApp.CreateBuilder(); + builder.Services.AddMauiBlazorWebView() + .UsePlatformHandler(); + using var app = builder.Build(); + + var handlersFactory = app.Services.GetRequiredService(); + Assert.Equal(typeof(CustomBlazorWebViewHandlerStub), handlersFactory.GetHandlerType(typeof(BlazorWebView))); + } + + [Fact] + public void UsePlatformHandlerFactoryReplacesDefaultBlazorWebViewHandler() + { + // Companion to UsePlatformHandlerGenericReplacesDefaultBlazorWebViewHandler — verifies the + // factory overload (Func) also replaces the default handler. + // The factory overload registers a different ServiceDescriptor shape (ImplementationFactory + // rather than ImplementationType), so GetHandlerType returns null here; we resolve through + // GetHandler instead and assert the produced instance type. + var builder = MauiApp.CreateBuilder(); + var factoryWasCalled = false; + builder.Services.AddMauiBlazorWebView() + .UsePlatformHandler(_ => + { + factoryWasCalled = true; + return new CustomBlazorWebViewHandlerStub(); + }); + using var app = builder.Build(); + + var handlersFactory = app.Services.GetRequiredService(); + var handler = handlersFactory.GetHandler(typeof(BlazorWebView)); + + Assert.True(factoryWasCalled, "Factory delegate should have been invoked when the handler was resolved."); + Assert.IsType(handler); + } + + private class CustomBlazorWebViewHandlerStub : BlazorWebViewHandler + { + // Marker subclass used only to prove that UsePlatformHandler replaced the default + // BlazorWebViewHandler registration. Inheriting from BlazorWebViewHandler keeps the + // IViewHandler contract honored on every device-test target framework without forcing + // us to reimplement the full handler surface. + public CustomBlazorWebViewHandlerStub() { } + } } diff --git a/src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj b/src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj new file mode 100644 index 000000000000..0f164bfb04ea --- /dev/null +++ b/src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + $(_MauiDotNetTfm) + enable + Microsoft.Maui.MauiBlazorWebView.UnitTests + Microsoft.Maui.MauiBlazorWebView.UnitTests + enable + false + + + + + + + + + + + + + + + + + diff --git a/src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/UriExtensions_Tests.cs b/src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/UriExtensions_Tests.cs new file mode 100644 index 000000000000..33ff4c061911 --- /dev/null +++ b/src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/UriExtensions_Tests.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Components.WebView.Maui; + +namespace Microsoft.Maui.MauiBlazorWebView.UnitTests; + +public class UriExtensions_Tests +{ + private readonly Uri _baseUri = new("https://example.com/"); + + [Theory] + [InlineData("https://example.com/page", true)] + [InlineData("page/subpage", false)] + [InlineData("this is not a uri!", false)] + [InlineData("https://example.com/", true)] + [InlineData("https://example.com/page/", true)] + [InlineData("https://example.com/page?weight=62.5", true)] + [InlineData("https://example.com/page#section.1", true)] + [InlineData("https://example.com/file.txt", false)] + [InlineData("https://example.com/page.json?foo=bar", false)] + [InlineData("ftp://example.com/page", false)] + [InlineData("/relative/path", false)] + [InlineData("", false)] + [InlineData(null, false)] + [InlineData("https://example.com/test", true)] + [InlineData("https://example.com/folder/subfolder/", true)] + [InlineData("https://example.com/folder/file.exe", false)] + [InlineData("https://subdomain.example.com/page", false)] + [InlineData("https://example.com/path/with/.dot/segment", true)] + [InlineData("https://example.com/path/with space", true)] + [InlineData("https://example.com/path/with%20encoded%20space", true)] + [InlineData("https://example.com/page?param=value¶m2=value2", true)] + [InlineData("https://example.com/page.html", false)] + [InlineData("HTTPS://EXAMPLE.COM/PAGE", true)] + [InlineData("https://subdomain.example.com/", false)] + [InlineData("https://subdomain.example.com/page/", false)] + [InlineData("https://subdomain.example.com/page?weight=62.5", false)] + [InlineData("https://subdomain.example.com/page#section.1", false)] + [InlineData("https://subdomain.example.com/file.txt", false)] + [InlineData("https://subdomain.example.com/page.json?foo=bar", false)] + [InlineData("ftp://subdomain.example.com/page", false)] + [InlineData("https://subdomain.example.com/test", false)] + [InlineData("https://subdomain.example.com/folder/subfolder/", false)] + [InlineData("https://subdomain.example.com/folder/file.exe", false)] + [InlineData("https://subdomain.example.com/path/with/.dot/segment", false)] + [InlineData("https://subdomain.example.com/path/with space", false)] + [InlineData("https://subdomain.example.com/path/with%20encoded%20space", false)] + [InlineData("https://subdomain.example.com/page?param=value¶m2=value2", false)] + [InlineData("https://subdomain.example.com/page.html", false)] + [InlineData("HTTPS://SUBDOMAIN.EXAMPLE.COM/PAGE", false)] + public void IsBaseOfPage_HandlesVariousUris(string? uriString, bool expected) + { + var result = _baseUri.IsBaseOfPage(uriString); + Assert.Equal(expected, result); + } + + // Regression test for https://github.com/dotnet/maui/issues/25689 + // A URL with a dot in the query parameter (e.g. ?weight=62.5) must not be + // treated as a file-extension path and must be allowed to fall back to the host page. + [Fact] + public void IsBaseOfPage_DoesNotTreatDotInQueryAsExtension() + { + var baseUri = new Uri("https://example.com"); + var urlWithDotInQuery = "https://example.com/customer?weight=62.5"; + Assert.True(baseUri.IsBaseOfPage(urlWithDotInQuery)); + } + + // Regression tests using the actual BlazorWebView platform app origins (https://github.com/dotnet/maui/issues/25689). + // Android/Windows use https://0.0.0.1/ and iOS/MacCatalyst use app://0.0.0.1/ as the host origin, + // so the original ?weight=62.5 bug must be verified against those real origins, not just a generic host. + [Theory] + [InlineData("https://0.0.0.1/", "https://0.0.0.1/customer?weight=62.5", true)] // Android/Windows: dot in query + [InlineData("https://0.0.0.1/", "https://0.0.0.1/customer#section.1", true)] // Android/Windows: dot in fragment + [InlineData("https://0.0.0.1/", "https://0.0.0.1/customer.json", false)] // Android/Windows: real file extension + [InlineData("app://0.0.0.1/", "app://0.0.0.1/customer?weight=62.5", true)] // iOS/MacCatalyst: dot in query + [InlineData("app://0.0.0.1/", "app://0.0.0.1/customer#section.1", true)] // iOS/MacCatalyst: dot in fragment + [InlineData("app://0.0.0.1/", "app://0.0.0.1/customer.json", false)] // iOS/MacCatalyst: real file extension + public void IsBaseOfPage_HandlesPlatformAppOrigins(string baseUri, string uriString, bool expected) + { + var result = new Uri(baseUri).IsBaseOfPage(uriString); + Assert.Equal(expected, result); + } +} diff --git a/src/Controls/samples/Controls.Sample/Pages/Others/TwoPaneViewPage.xaml b/src/Controls/samples/Controls.Sample/Pages/Others/TwoPaneViewPage.xaml index 3b47f7235ff3..b9634358bb5f 100644 --- a/src/Controls/samples/Controls.Sample/Pages/Others/TwoPaneViewPage.xaml +++ b/src/Controls/samples/Controls.Sample/Pages/Others/TwoPaneViewPage.xaml @@ -16,9 +16,8 @@ WideModeConfiguration="{Binding Source={x:Reference WideModeConfiguration}, Path=SelectedItem}" PanePriority="{Binding Source={x:Reference PanePriority}, Path=SelectedItem, Mode=TwoWay}" > - - + + pathProperties) + static bool TryParsePath(ILContext context, string path, TypeReference tSourceRef, IXmlLineInfo lineInfo, ModuleDefinition module, out IList<(PropertyDefinition property, TypeReference propDeclTypeRef, string indexArg)> pathProperties, bool suppressPropertyNotFoundError = false) { pathProperties = null; @@ -691,7 +847,17 @@ static bool TryParsePath(ILContext context, string path, TypeReference tSourceRe var property = previousPartTypeRef.GetProperty(context.Cache, pd => pd.Name == p && pd.GetMethod != null && pd.GetMethod.IsPublic && !pd.GetMethod.IsStatic, out var propDeclTypeRef); if (property is null) { - context.LoggingHelper.LogWarningOrError(BindingPropertyNotFound, context.XamlFilePath, lineInfo.LineNumber, lineInfo.LinePosition, 0, 0, p, previousPartTypeRef); + // When the source type was inferred from RelativeSource AncestorType (rather + // than an explicit x:DataType), a missing property doesn't necessarily mean the + // binding is invalid - the actual runtime ancestor may be a subtype that declares + // the property. Silently fall back to a regular (reflection-based) Binding instead + // of failing the build, matching the pre-existing behavior for RelativeSource + // bindings without an explicit x:DataType and the SourceGen inflator's equivalent + // fallback in KnownMarkups.ProvideValueForBindingExtension. + if (!suppressPropertyNotFoundError) + { + context.LoggingHelper.LogWarningOrError(BindingPropertyNotFound, context.XamlFilePath, lineInfo.LineNumber, lineInfo.LinePosition, 0, 0, p, previousPartTypeRef); + } return false; } diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.Before.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.Before.targets index 17b91db2f42b..9ed5125af575 100644 --- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.Before.targets +++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.Before.targets @@ -28,6 +28,14 @@ $([MSBuild]::EnsureTrailingSlash('$(TizenProjectFolder)')) + @@ -36,6 +44,36 @@ + + + + + + %(MauiPlatformSpecificFolder.TargetPlatformIdentifier) + + + + $(AndroidProjectFolder)AndroidManifest.xml $(AndroidProjectFolder)Resources @@ -68,4 +106,4 @@ $(TizenProjectFolder)shared - \ No newline at end of file + diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.targets index 6f4069e6d36d..d0f3d1aaef40 100644 --- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.targets +++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.SingleProject.targets @@ -35,6 +35,64 @@ + + + + <_MauiPlatformSpecificCompileItems + Include="$([MSBuild]::EnsureTrailingSlash('%(MauiPlatformSpecificFolder.Identity)'))**/*$(DefaultLanguageSourceExtension)" + Condition=" '%(MauiPlatformSpecificFolder.TargetPlatformIdentifiers)' == '' or ('$(TargetPlatformIdentifier)' != '' and $([System.Text.RegularExpressions.Regex]::Replace(';%(MauiPlatformSpecificFolder.TargetPlatformIdentifiers);', '\s+', '').ToLowerInvariant().Contains(';$(TargetPlatformIdentifier.ToLowerInvariant());')) )" /> + + + + + + + + false + + + + - - at the top of this file always marks + every $(PlatformsProjectFolder)/** file as ExcludeFromCurrentConfiguration=true, + so this batches into a single iteration; do NOT "simplify" away the + Condition without first re-verifying that contract still holds, or + files outside the active TPI may leak into the build. + --> + <_MauiPlatformCompileToRemove Condition=" '%(Compile.ExcludeFromCurrentConfiguration)' == 'true' " - Remove="$(PlatformsProjectFolder)**/*$(DefaultLanguageSourceExtension)" /> + Include="$(PlatformsProjectFolder)**/*$(DefaultLanguageSourceExtension)" + Exclude="@(_MauiPlatformSpecificCompileItems)" /> + <_MauiXamlToRemove diff --git a/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs b/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs index 0498db4a2c80..a309ea345192 100644 --- a/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs +++ b/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls @@ -16,7 +17,7 @@ namespace Microsoft.Maui.Controls public partial class ActivityIndicator : View, IColorElement, IElementConfiguration, IActivityIndicator { /// Bindable property for . - public static readonly BindableProperty IsRunningProperty = BindableProperty.Create(nameof(IsRunning), typeof(bool), typeof(ActivityIndicator), default(bool)); + public static readonly BindableProperty IsRunningProperty = BindableProperty.Create(nameof(IsRunning), typeof(bool), typeof(ActivityIndicator), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty ColorProperty = ColorElement.ColorProperty; @@ -48,7 +49,7 @@ public Color Color public bool IsRunning { get { return (bool)GetValue(IsRunningProperty); } - set { SetValue(IsRunningProperty, value); } + set { SetValue(IsRunningProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/AdaptiveTrigger.cs b/src/Controls/src/Core/AdaptiveTrigger.cs index 2981776866c4..06be21709e66 100644 --- a/src/Controls/src/Core/AdaptiveTrigger.cs +++ b/src/Controls/src/Core/AdaptiveTrigger.cs @@ -8,7 +8,9 @@ namespace Microsoft.Maui.Controls /// public sealed class AdaptiveTrigger : StateTriggerBase { - VisualElement? _visualElement; + // Weak reference so the trigger does not prevent the VisualElement from being + // garbage-collected if SendDetached() is somehow missed. + WeakReference? _visualElement; Window? _window; /// @@ -69,9 +71,10 @@ void AttachEvents() { DetachEvents(); - _visualElement = VisualState?.VisualStateGroup?.VisualElement; + var element = VisualState?.VisualStateGroup?.VisualElement; + _visualElement = element is not null ? new WeakReference(element) : null; - _window = _visualElement?.Window; + _window = element?.Window; if (_window is not null) { _window.SizeChanged += OnWindowSizeChanged; diff --git a/src/Controls/src/Core/AnimationExtensions.cs b/src/Controls/src/Core/AnimationExtensions.cs index be24d3dc042b..bdef89f81ff3 100644 --- a/src/Controls/src/Core/AnimationExtensions.cs +++ b/src/Controls/src/Core/AnimationExtensions.cs @@ -28,6 +28,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using Microsoft.Maui.Animations; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Dispatching; @@ -64,7 +65,7 @@ static AnimationExtensions() public static int Add(this IAnimationManager animationManager, Action step) { - var id = s_currentTweener++; + var id = Interlocked.Increment(ref s_currentTweener); var animation = new Animation { Name = $"{id}", @@ -84,7 +85,7 @@ public static int Add(this IAnimationManager animationManager, Action st public static int Insert(this IAnimationManager animationManager, Func step) { - var id = s_currentTweener++; + var id = Interlocked.Increment(ref s_currentTweener); Animation animation = null; animation = new TweenerAnimation(step) { diff --git a/src/Controls/src/Core/AppLinkEntry.cs b/src/Controls/src/Core/AppLinkEntry.cs index ee0eee07284f..4b1a7be34aa2 100644 --- a/src/Controls/src/Core/AppLinkEntry.cs +++ b/src/Controls/src/Core/AppLinkEntry.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -28,7 +29,7 @@ public AppLinkEntry() public static readonly BindableProperty AppLinkUriProperty = BindableProperty.Create(nameof(AppLinkUri), typeof(Uri), typeof(AppLinkEntry), null); /// Bindable property for . - public static readonly BindableProperty IsLinkActiveProperty = BindableProperty.Create(nameof(IsLinkActive), typeof(bool), typeof(AppLinkEntry), false); + public static readonly BindableProperty IsLinkActiveProperty = BindableProperty.Create(nameof(IsLinkActive), typeof(bool), typeof(AppLinkEntry), BooleanBoxes.FalseBox); /// Gets or sets an application-specific URI that uniquely describes content within an app. This is a bindable property. public Uri AppLinkUri @@ -49,7 +50,7 @@ public string Description public bool IsLinkActive { get { return (bool)GetValue(IsLinkActiveProperty); } - set { SetValue(IsLinkActiveProperty, value); } + set { SetValue(IsLinkActiveProperty, BooleanBoxes.Box(value)); } } /// Gets a dictionary of application-specific key-value pairs. diff --git a/src/Controls/src/Core/BindableObject.cs b/src/Controls/src/Core/BindableObject.cs index 0e7d7d65c219..8b15eeee5c49 100644 --- a/src/Controls/src/Core/BindableObject.cs +++ b/src/Controls/src/Core/BindableObject.cs @@ -779,20 +779,15 @@ BindablePropertyContext CreateContext(BindableProperty property) [MethodImpl(MethodImplOptions.AggressiveInlining)] BindablePropertyContext GetOrCreateContext(BindableProperty property) { -#if NETSTANDARD - var context = GetContext(property); - if (context is null) - { - context = CreateContext(property); - _properties.Add(property.InternalId, context); - } -#else - ref var context = ref CollectionsMarshal.GetValueRefOrAddDefault(_properties, property.InternalId, out var exists); - if (!exists) - { - context = CreateContext(property); - } -#endif + if (_properties.TryGetValue(property.InternalId, out var context)) + return context; + + // Do not use CollectionsMarshal.GetValueRefOrAddDefault: CreateContext invokes + // DefaultValueCreator, which is arbitrary user code and may mutate other + // BindableProperties, resizing _properties and invalidating the returned ref. + // See dotnet/maui#36744. + context = CreateContext(property); + _properties[property.InternalId] = context; return context; } diff --git a/src/Controls/src/Core/Cells/Cell.cs b/src/Controls/src/Core/Cells/Cell.cs index 683c8603ece8..ae98ac6b1be9 100644 --- a/src/Controls/src/Core/Cells/Cell.cs +++ b/src/Controls/src/Core/Cells/Cell.cs @@ -16,7 +16,7 @@ public abstract class Cell : Element, ICellController, IFlowDirectionController, /// The default height of cells. public const int DefaultCellHeight = 40; /// Bindable property for . - public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(Cell), true, propertyChanged: OnIsEnabledPropertyChanged); + public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(Cell), BooleanBoxes.TrueBox, propertyChanged: OnIsEnabledPropertyChanged); ObservableCollection _contextActions; List _currentContextActions; @@ -130,7 +130,7 @@ public double Height public bool IsEnabled { get { return (bool)GetValue(IsEnabledProperty); } - set { SetValue(IsEnabledProperty, value); } + set { SetValue(IsEnabledProperty, BooleanBoxes.Box(value)); } } /// Gets the height of the rendered cell on the device. diff --git a/src/Controls/src/Core/Cells/SwitchCell.cs b/src/Controls/src/Core/Cells/SwitchCell.cs index 0e2845b43871..14fe18f11e74 100644 --- a/src/Controls/src/Core/Cells/SwitchCell.cs +++ b/src/Controls/src/Core/Cells/SwitchCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls @@ -9,7 +10,7 @@ namespace Microsoft.Maui.Controls public class SwitchCell : Cell { /// Bindable property for . - public static readonly BindableProperty OnProperty = BindableProperty.Create(nameof(On), typeof(bool), typeof(SwitchCell), false, propertyChanged: (obj, oldValue, newValue) => + public static readonly BindableProperty OnProperty = BindableProperty.Create(nameof(On), typeof(bool), typeof(SwitchCell), BooleanBoxes.FalseBox, propertyChanged: (obj, oldValue, newValue) => { var switchCell = (SwitchCell)obj; switchCell.OnChanged?.Invoke(obj, new ToggledEventArgs((bool)newValue)); @@ -32,7 +33,7 @@ public Color OnColor public bool On { get { return (bool)GetValue(OnProperty); } - set { SetValue(OnProperty, value); } + set { SetValue(OnProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the text displayed next to the switch. This is a bindable property. diff --git a/src/Controls/src/Core/CheckBox/CheckBox.cs b/src/Controls/src/Core/CheckBox/CheckBox.cs index 03423defc58c..936a78d49a89 100644 --- a/src/Controls/src/Core/CheckBox/CheckBox.cs +++ b/src/Controls/src/Core/CheckBox/CheckBox.cs @@ -29,7 +29,7 @@ public partial class CheckBox : View, IElementConfiguration, IBorderEl /// Bindable property for . This is a bindable property. public static readonly BindableProperty IsCheckedProperty = - BindableProperty.Create(nameof(IsChecked), typeof(bool), typeof(CheckBox), false, + BindableProperty.Create(nameof(IsChecked), typeof(bool), typeof(CheckBox), BooleanBoxes.FalseBox, propertyChanged: (bindable, oldValue, newValue) => { if (bindable is not CheckBox checkBox) @@ -98,7 +98,7 @@ public Color Color public bool IsChecked { get => (bool)GetValue(IsCheckedProperty); - set => SetValue(IsCheckedProperty, value); + set => SetValue(IsCheckedProperty, BooleanBoxes.Box(value)); } protected internal override void ChangeVisualState() @@ -175,7 +175,7 @@ void ICommandElement.CanExecuteChanged(object sender, EventArgs e) => bool ICheckBox.IsChecked { get => IsChecked; - set => SetValue(IsCheckedProperty, value, SetterSpecificity.FromHandler); + set => SetValue(IsCheckedProperty, BooleanBoxes.Box(value), SetterSpecificity.FromHandler); } ICommand ICommandElement.Command => Command; diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/Android/ListViewAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/Android/ListViewAdapter.cs index baa49ffeb4f6..d753834f7d0f 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/Android/ListViewAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/Android/ListViewAdapter.cs @@ -26,7 +26,7 @@ internal class ListViewAdapter : CellAdapter static int s_dividerHorizontalDarkId = int.MinValue; #pragma warning disable CS0618 // Type or member is obsolete - internal static readonly BindableProperty IsSelectedProperty = BindableProperty.CreateAttached("IsSelected", typeof(bool), typeof(Cell), false); + internal static readonly BindableProperty IsSelectedProperty = BindableProperty.CreateAttached("IsSelected", typeof(bool), typeof(Cell), BooleanBoxes.FalseBox); #pragma warning restore CS0618 // Type or member is obsolete readonly Context _context; @@ -722,7 +722,7 @@ void Select(int index, AView view) Cell previousCell; #pragma warning restore CS0618 // Type or member is obsolete if (_selectedCell.TryGetTarget(out previousCell)) - previousCell.SetValue(IsSelectedProperty, false); + previousCell.SetValue(IsSelectedProperty, BooleanBoxes.FalseBox); } _lastSelected = view; @@ -733,7 +733,7 @@ void Select(int index, AView view) #pragma warning disable CS0618 // Type or member is obsolete Cell cell = GetCellForPosition(index); #pragma warning restore CS0618 // Type or member is obsolete - cell.SetValue(IsSelectedProperty, true); + cell.SetValue(IsSelectedProperty, BooleanBoxes.TrueBox); #pragma warning disable CS0618 // Type or member is obsolete _selectedCell = new WeakReference(cell); #pragma warning restore CS0618 // Type or member is obsolete diff --git a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs index b175e0ca86c7..a868d1c4c880 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs @@ -1548,7 +1548,21 @@ public override void ViewWillAppear(bool animated) var isTranslucent = false; if (_navigation.TryGetTarget(out n)) isTranslucent = n.NavigationBar.Translucent; - EdgesForExtendedLayout = isTranslucent ? UIRectEdge.All : UIRectEdge.None; + + var edges = isTranslucent ? UIRectEdge.All : UIRectEdge.None; + + // On iOS/MacCatalyst 26+, the tab bar renders as a floating glass overlay. + // Extend behind it when inside a visible UITabBarController so content + // isn't clipped at the old tab bar boundary. + if ((OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + && TabBarController is { } tbc + && !tbc.TabBar.Hidden + && tbc.TabBar.Translucent) + { + edges |= UIRectEdge.Bottom; + } + + EdgesForExtendedLayout = edges; base.ViewWillAppear(animated); } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs index 16acb52466c7..a554df454aed 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs @@ -274,16 +274,19 @@ public Element Element set { if (_element == value) + { return; + } - if (View.Parent is BaseShellItem bsi) - bsi.RemoveLogicalChild(View); + if (View.Parent is BaseShellItem bsiParent) + bsiParent.RemoveLogicalChild(View); else _shell.RemoveLogicalChild(View); - if (_element != null && _element is BaseShellItem) + if (_element != null) { _element.PropertyChanged -= OnElementPropertyChanged; + ((IElementDefinition)View)?.RemoveResourcesChangedListener(OnElementResourcesChanged); } _element = value; @@ -299,6 +302,7 @@ public Element Element _shell.AddLogicalChild(View); _element.PropertyChanged += OnElementPropertyChanged; + ((IElementDefinition)View)?.AddResourcesChangedListener(OnElementResourcesChanged); UpdateVisualState(); } } @@ -306,9 +310,9 @@ public Element Element void UpdateVisualState() { - if (Element is BaseShellItem baseShellItem && baseShellItem != null) + if (Element is BaseShellItem baseShellItem) { - View.IsItemSelected = baseShellItem.IsChecked; + VisualStateManager.GoToState(View, baseShellItem.IsChecked ? "Selected" : "Normal", force: true); } } @@ -318,6 +322,11 @@ void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChang UpdateVisualState(); } + void OnElementResourcesChanged(object sender, ResourcesChangedEventArgs e) + { + UpdateVisualState(); + } + void OnClicked(object sender, EventArgs e) { if (Element == null) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRenderer.cs index c243bacdc684..106f5c7a9aa7 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRenderer.cs @@ -83,7 +83,7 @@ void OnDrawerStateChanged(object sender, DrawerStateChangedEventArgs e) void OnDrawerOpened(object sender, DrawerOpenedEventArgs e) { - Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, true); + Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, BooleanBoxes.TrueBox); } void OnDrawerSlide(object sender, DrawerSlideEventArgs e) @@ -95,7 +95,7 @@ void OnDrawerSlide(object sender, DrawerSlideEventArgs e) void OnDrawerClosed(object sender, DrawerClosedEventArgs e) { - Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, false); + Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, BooleanBoxes.FalseBox); } #endregion IDrawerListener @@ -319,7 +319,7 @@ protected virtual void UpdateDrawerLockMode(FlyoutBehavior behavior) { case FlyoutBehavior.Disabled: CloseDrawers(); - Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, false); + Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, BooleanBoxes.FalseBox); _currentLockMode = LockModeLockedClosed; SetDrawerLockMode(_currentLockMode); _content.SetPadding(0, _content.PaddingTop, _content.PaddingRight, _content.PaddingBottom); @@ -332,7 +332,7 @@ protected virtual void UpdateDrawerLockMode(FlyoutBehavior behavior) break; case FlyoutBehavior.Locked: - Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, true); + Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, BooleanBoxes.TrueBox); _currentLockMode = LockModeLockedOpen; SetDrawerLockMode(_currentLockMode); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs index 853c69aff3e4..8988cf205412 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs @@ -54,8 +54,6 @@ public class ShellFlyoutTemplatedContentRenderer : Java.Lang.Object, IShellFlyou protected IShellContext ShellContext => _shellContext; protected AView FooterView => _footerView?.PlatformView; protected AView View => _rootView; - ShellFlyoutWindowInsetListener _shellFlyoutListener; - public ShellFlyoutTemplatedContentRenderer(IShellContext shellContext) { @@ -88,44 +86,6 @@ void OnFlyoutStateChanging(object sender, AndroidX.DrawerLayout.Widget.DrawerLay // - Do not extend; add new logic to the forthcoming implementation instead. internal class ShellFlyoutWindowInsetListener : MauiWindowInsetListener { - private WeakReference _bgImageRef; - private WeakReference _flyoutViewRef; - private WeakReference _footerViewRef; - - public AView FlyoutView - { - get - { - if (_flyoutViewRef != null && _flyoutViewRef.TryGetTarget(out var flyoutView)) - return flyoutView; - - return null; - } - set - { - _flyoutViewRef = new WeakReference(value); - } - } - public AView FooterView - { - get - { - if (_footerViewRef != null && _footerViewRef.TryGetTarget(out var footerView)) - return footerView; - - return null; - } - set - { - _footerViewRef = new WeakReference(value); - } - } - - public ShellFlyoutWindowInsetListener(ImageView bgImage) - { - _bgImageRef = new WeakReference(bgImage); - } - public override WindowInsetsCompat OnApplyWindowInsets(AView v, WindowInsetsCompat insets) { if (insets == null || v == null) @@ -133,42 +93,38 @@ public override WindowInsetsCompat OnApplyWindowInsets(AView v, WindowInsetsComp if (v is CoordinatorLayout) { - // The flyout overlaps the status bar so we don't really care about insetting it + // Apply all system bar and display-cutout insets as padding so flyout + // content (including the footer) stays within the safe area on all edges. var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + var leftInset = Math.Max(systemBars?.Left ?? 0, displayCutout?.Left ?? 0); var topInset = Math.Max(systemBars?.Top ?? 0, displayCutout?.Top ?? 0); + var rightInset = Math.Max(systemBars?.Right ?? 0, displayCutout?.Right ?? 0); var bottomInset = Math.Max(systemBars?.Bottom ?? 0, displayCutout?.Bottom ?? 0); - var appbarLayout = v.FindDescendantView((v) => true); - int flyoutViewBottomInset = 0; - - if (FooterView is not null) + // Only apply bottom padding if the view's bottom actually extends into + // the bottom safe area zone. If the view is fully above the safe area + // boundary, there is no overlap and no padding is needed. + if (bottomInset > 0 && v.Height > 0) { - v.SetPadding(0, 0, 0, bottomInset); - flyoutViewBottomInset = 0; - } - else - { - flyoutViewBottomInset = bottomInset; - v.SetPadding(0, 0, 0, 0); - } - - if (appbarLayout.MeasuredHeight > 0) - { - FlyoutView?.SetPadding(0, 0, 0, flyoutViewBottomInset); - appbarLayout?.SetPadding(0, topInset, 0, 0); - } - else - { - FlyoutView?.SetPadding(0, topInset, 0, flyoutViewBottomInset); - appbarLayout?.SetPadding(0, 0, 0, 0); - } - - if (_bgImageRef != null && _bgImageRef.TryGetTarget(out var bgImage) && bgImage != null) - { - bgImage.SetPadding(0, topInset, 0, bottomInset); + var location = new int[2]; + v.GetLocationOnScreen(location); + var viewBottom = location[1] + v.Height; + + var windowManager = v.Context?.GetSystemService(Context.WindowService) as IWindowManager; + if (windowManager?.DefaultDisplay is not null) + { + var realMetrics = new global::Android.Util.DisplayMetrics(); + windowManager.DefaultDisplay.GetRealMetrics(realMetrics); + var screenHeight = realMetrics.HeightPixels; + + // View does not reach the bottom safe area zone — no bottom padding needed + if (viewBottom <= screenHeight - bottomInset) + bottomInset = 0; + } } + v.SetPadding(leftInset, topInset, rightInset, bottomInset); return WindowInsetsCompat.Consumed; } @@ -208,8 +164,7 @@ protected virtual void LoadView(IShellContext shellContext) LayoutParameters = new LP(coordinator.LayoutParameters) }; - _shellFlyoutListener = new ShellFlyoutWindowInsetListener(_bgImage); - MauiWindowInsetListener.SetupViewWithLocalListener(coordinator, _shellFlyoutListener); + MauiWindowInsetListener.SetupViewWithLocalListener(coordinator, new ShellFlyoutWindowInsetListener()); UpdateFlyoutHeaderBehavior(); _shellContext.Shell.PropertyChanged += OnShellPropertyChanged; @@ -305,7 +260,6 @@ protected virtual void UpdateFlyoutContent() } _flyoutContentView = CreateFlyoutContent(_rootView); - _shellFlyoutListener.FlyoutView = _flyoutContentView; if (_flyoutContentView == null) return; @@ -422,7 +376,6 @@ protected virtual void UpdateFlyoutFooter() var oldFooterView = _footerView; _rootView.RemoveView(_footerView); _footerView = null; - _shellFlyoutListener.FooterView = null; oldFooterView.View = null; } @@ -442,8 +395,6 @@ protected virtual void UpdateFlyoutFooter() MatchWidth = true }; - _shellFlyoutListener.FooterView = _footerView; - var footerViewLP = new CoordinatorLayout.LayoutParams(0, 0) { Gravity = (int)(GravityFlags.Bottom | GravityFlags.End) @@ -468,7 +419,9 @@ void UpdateFooterLayout() void UpdateFooterLayout(CoordinatorLayout.LayoutParams cl) { - cl.Width = MeasureSpecMode.Exactly.MakeMeasureSpec(_flyoutWidth); + // Use MatchParent so the footer width is bounded by the parent's content + // area, which already has left/right safe-area insets applied as padding. + cl.Width = LP.MatchParent; cl.Height = MeasureSpecMode.Unspecified.MakeMeasureSpec(0); } @@ -577,6 +530,12 @@ void OnFlyoutViewLayoutChanging() _flyoutHeight = View.MeasuredHeight; _flyoutWidth = View.MeasuredWidth; + // Re-request insets so OnApplyWindowInsets re-evaluates whether + // the bottom padding is still needed at the new flyout size. + // Without this, the padding set during the first inset dispatch + // (which may have been at full-screen height) would persist even + // after FlyoutHeight is changed to a smaller value. + ViewCompat.RequestApplyInsets(_rootView); // We wait to instantiate the flyout footer until we know the WxH of the flyout container if (_footerView == null) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellItemRendererBase.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellItemRendererBase.cs index 63cce3dfdcfa..744a92309ef6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellItemRendererBase.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellItemRendererBase.cs @@ -406,25 +406,27 @@ void RemoveAllButCurrent(Fragment skip) void RemoveAllPushedPages(ShellSection shellSection, bool keepCurrent) { - if (shellSection.Stack.Count <= 1 || (keepCurrent && shellSection.Stack.Count == 2)) - return; - - var t = ChildFragmentManager.BeginTransactionEx(); + FragmentTransaction t = null; foreach (var kvp in _fragmentMap.ToList()) { if (kvp.Key.Parent != shellSection) + { continue; + } _fragmentMap.Remove(kvp.Key); if (keepCurrent && kvp.Value.Fragment == _currentFragment) + { continue; + } + t ??= ChildFragmentManager.BeginTransactionEx(); t.RemoveEx(kvp.Value.Fragment); } - t.CommitAllowingStateLossEx(); + t?.CommitAllowingStateLossEx(); } void RemoveFragment(Fragment fragment) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchView.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchView.cs index 6bd4d96049eb..6973ad76d81a 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchView.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchView.cs @@ -184,7 +184,7 @@ protected virtual void LoadView(SearchHandler searchHandler) int padding = (int)context.ToPixels(8); - _searchButton = CreateImageButton(context, searchHandler, SearchHandler.QueryIconProperty, Resource.Drawable.abc_ic_search_api_material, padding, 0, "SearchIcon"); + _searchButton = CreateImageButton(context, searchHandler, SearchHandler.QueryIconProperty, Resource.Drawable.abc_ic_search_api_material, padding, 0, "SearchIcon", searchHandler.TextColor?.ToPlatform()); lp = new LinearLayout.LayoutParams(0, LP.MatchParent) { @@ -211,8 +211,8 @@ protected virtual void LoadView(SearchHandler searchHandler) // A note on accessibility. The _textBlocks hint is what android defaults to reading in the screen // reader. Therefore, we do not need to set something else. - _clearButton = CreateImageButton(context, searchHandler, SearchHandler.ClearIconProperty, Resource.Drawable.abc_ic_clear_material, 0, padding, nameof(SearchHandler.ClearIcon)); - _clearPlaceholderButton = CreateImageButton(context, searchHandler, SearchHandler.ClearPlaceholderIconProperty, -1, 0, padding, nameof(SearchHandler.ClearPlaceholderIcon)); + _clearButton = CreateImageButton(context, searchHandler, SearchHandler.ClearIconProperty, Resource.Drawable.abc_ic_clear_material, 0, padding, nameof(SearchHandler.ClearIcon), searchHandler.CancelButtonColor?.ToPlatform()); + _clearPlaceholderButton = CreateImageButton(context, searchHandler, SearchHandler.ClearPlaceholderIconProperty, -1, 0, padding, nameof(SearchHandler.ClearPlaceholderIcon), searchHandler.TextColor?.ToPlatform()); linearLayout.AddView(_searchButton); linearLayout.AddView(_textBlock); @@ -236,10 +236,72 @@ protected virtual void LoadView(SearchHandler searchHandler) protected virtual void OnSearchHandlerPropertyChanged(object sender, PropertyChangedEventArgs e) { + if (SearchHandler is null || _textBlock is null) + { + return; + } + if (e.PropertyName == SearchHandler.IsSearchEnabledProperty.PropertyName) { _textBlock.Enabled = SearchHandler.IsSearchEnabled; } + else if (e.PropertyName == SearchHandler.QueryIconProperty.PropertyName) + { + ApplyImageSource(_searchButton, SearchHandler.QueryIcon, Resource.Drawable.abc_ic_search_api_material, SearchHandler.TextColor?.ToPlatform()); + } + else if (e.PropertyName == SearchHandler.ClearIconProperty.PropertyName) + { + ApplyImageSource(_clearButton, SearchHandler.ClearIcon, Resource.Drawable.abc_ic_clear_material, SearchHandler.CancelButtonColor?.ToPlatform()); + } + else if (e.PropertyName == SearchHandler.ClearPlaceholderIconProperty.PropertyName) + { + ApplyImageSource(_clearPlaceholderButton, SearchHandler.ClearPlaceholderIcon, -1, SearchHandler.TextColor?.ToPlatform()); + } + else if (e.PropertyName == SearchHandler.ClearPlaceholderEnabledProperty.PropertyName) + { + UpdateClearButtonState(); + } + } + + void ApplyImageSource(AImageButton button, ImageSource image, int defaultImage, AColor? tint = null) + { + if (button is null) + { + return; + } + + void ApplyTint() + { + if (tint.HasValue) + { + button.Drawable?.Mutate(); + button.Drawable?.SetColorFilter(tint.Value, FilterMode.SrcIn); + } + } + + if (image is not null) + { + AutomationPropertiesProvider.SetContentDescription(button, image, null, null); + image.LoadImage(MauiContext, (r) => + { + if (_disposed) + { + return; + } + + button.SetImageDrawable(r?.Value); + ApplyTint(); + }); + } + else if (defaultImage > 0 && ContextCompat.GetDrawable(Context, defaultImage) is Drawable defaultDrawable) + { + button.SetImageDrawable(defaultDrawable); + ApplyTint(); + } + else + { + button.SetImageDrawable(null); + } } protected override async void OnAttachedToWindow() @@ -308,7 +370,7 @@ protected virtual void OnSearchButtonClicked(object sender, EventArgs e) { } - AImageButton CreateImageButton(Context context, BindableObject bindable, BindableProperty property, int defaultImage, int leftMargin, int rightMargin, string tag) + AImageButton CreateImageButton(Context context, BindableObject bindable, BindableProperty property, int defaultImage, int leftMargin, int rightMargin, string tag, AColor? tint = null) { var result = new AImageButton(context); result.Tag = tag; @@ -316,23 +378,7 @@ AImageButton CreateImageButton(Context context, BindableObject bindable, Bindabl result.Focusable = false; result.SetScaleType(ImageView.ScaleType.FitCenter); - if (bindable.GetValue(property) is ImageSource image) - { - AutomationPropertiesProvider.SetContentDescription(result, image, null, null); - - image.LoadImage(MauiContext, (r) => - { - result.SetImageDrawable(r?.Value); - }); - } - else if (defaultImage > 0 && ContextCompat.GetDrawable(Context, defaultImage) is Drawable defaultDrawable) - { - result.SetImageDrawable(defaultDrawable); - } - else - { - result.SetImageDrawable(null); - } + ApplyImageSource(result, bindable.GetValue(property) as ImageSource, defaultImage, tint); var lp = new LinearLayout.LayoutParams((int)Context.ToPixels(22), LP.MatchParent) { diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 272ba2b684a1..e0b7bd8671bf 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -307,6 +307,7 @@ void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) else if (e.Is(Shell.ForegroundColorProperty)) { UpdateLeftBarButtonItem(); + UpdateToolbarItemsTintColors(); } } @@ -339,6 +340,7 @@ protected virtual void OnPagePropertyChanged(object sender, PropertyChangedEvent else if (e.PropertyName == Shell.ForegroundColorProperty.PropertyName) { UpdateLeftBarButtonItem(); + UpdateToolbarItemsTintColors(); } } @@ -669,13 +671,19 @@ protected virtual void UpdateTitleView(Context context, AToolbar toolbar, View t _toolbar.Handler?.UpdateValue(nameof(Toolbar.TitleView)); } + Color GetSearchHandlerTintColor(Page page) + { + var foregroundColor = page is not null ? Shell.GetForegroundColor(page) : null; + return TintColor ?? foregroundColor ?? Shell.GetForegroundColor(_shell); + } + private void UpdateToolbarItemsTintColors(AToolbar toolbar) { var menu = toolbar.Menu; if (menu.FindItem(_placeholderMenuItemId) is IMenuItem item) { using (var icon = item.Icon) - icon.SetColorFilter(TintColor.ToPlatform(Colors.White), FilterMode.SrcAtop); + icon.SetColorFilter(GetSearchHandlerTintColor(Page).ToPlatform(Colors.White), FilterMode.SrcAtop); } } @@ -719,7 +727,7 @@ protected virtual void UpdateToolbarItems(AToolbar toolbar, Page page) item.SetEnabled(SearchHandler.IsSearchEnabled); item.SetIcon(Resource.Drawable.abc_ic_search_api_material); using (var icon = item.Icon) - icon.SetColorFilter(TintColor.ToPlatform(Colors.White), FilterMode.SrcAtop); + icon.SetColorFilter(GetSearchHandlerTintColor(page).ToPlatform(Colors.White), FilterMode.SrcAtop); item.SetShowAsAction(ShowAsAction.IfRoom | ShowAsAction.CollapseActionView); if (_searchView.View.Parent is not null) @@ -794,7 +802,7 @@ void OnSearchViewAttachedToWindow(object sender, AView.ViewAttachedToWindowEvent // we want the newly added button which will need layout if (child.IsLayoutRequested) { - button.SetColorFilter(TintColor.ToPlatform(Colors.White), PorterDuff.Mode.SrcAtop); + button.SetColorFilter(GetSearchHandlerTintColor(Page).ToPlatform(Colors.White), PorterDuff.Mode.SrcAtop); } button.Dispose(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs index 201a6018f6b2..b14be1c5fd18 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs @@ -310,31 +310,39 @@ void LayoutHeader(CGRect parentFrame) void LayoutContent(CGRect parentBounds, nfloat footerHeight) { - double contentYOffset = 0; + var safeAreaInsets = UIApplication.SharedApplication.GetSafeAreaInsetsForWindow(); - if (ShouldHonorSafeArea(HeaderView?.View) || - (HeaderView is null && ShouldHonorSafeArea(Content))) + // Honor ISafeAreaView.IgnoreSafeArea and explicit margins (same as LayoutHeader) + nfloat safeAreaTop = 0; + if (ShouldHonorSafeArea(HeaderView?.View) || (HeaderView is null && ShouldHonorSafeArea(Content))) { - // We add the safe area if margin is not explicitly set. This matches the header behavior. - contentYOffset += (float)UIApplication.SharedApplication.GetSafeAreaInsetsForWindow().Top; + safeAreaTop = safeAreaInsets.Top; } + nfloat safeAreaBottom = safeAreaInsets.Bottom; + + var contentY = parentBounds.Y + safeAreaTop; + var contentHeight = parentBounds.Height - safeAreaTop - safeAreaBottom - footerHeight; if (HeaderView is not null) { if (ScrollView is null) { - // The margin is already managed by MAUI's layout system, so we don't need to add it here and we just offset the content by the header's height. - contentYOffset += HeaderView.Frame.Height; + // The margin is already managed by MAUI's layout system, so we don't need to add it here + // and we just offset the content by the header's height. + contentY += HeaderView.Frame.Height; + contentHeight -= HeaderView.Frame.Height; } else { // For ScrollView, we need to consider the margin, but we should not consider the header height, since it should overlap with the scroll view. // The content inset is already managed by SetHeaderContentInset. - contentYOffset += HeaderView.View.Margin.VerticalThickness; + var marginOffset = (nfloat)HeaderView.View.Margin.VerticalThickness; + contentY += marginOffset; + contentHeight -= marginOffset; } } - var contentFrame = new Rect(parentBounds.X, contentYOffset, parentBounds.Width, parentBounds.Height - contentYOffset - footerHeight); + var contentFrame = new Rect(parentBounds.X, contentY, parentBounds.Width, contentHeight); if (Content is null) { ContentView.Frame = contentFrame.AsCGRect(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs index 938c2d38b648..c8a0235ebb18 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs @@ -7,6 +7,7 @@ using System.Windows.Input; using CoreGraphics; using Foundation; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics.Platform; using UIKit; @@ -200,12 +201,20 @@ void OnToolbarPropertyChanged(object? sender, PropertyChangedEventArgs e) protected virtual void UpdateTitle() { - if (!ToolbarReady() || NavigationItem is null || _context?.Shell?.Toolbar is null) + + if (NavigationItem is null) + { + return; + } + + if (ToolbarReady() && _context?.Shell?.Toolbar is not null) { + NavigationItem.Title = _context.Shell.Toolbar.Title; return; } - NavigationItem.Title = _context.Shell.Toolbar.Title; + // Update back-stack pages so iOS back button/history menu reflects title changes + NavigationItem.Title = Page?.Title; } @@ -371,6 +380,29 @@ TitleViewContainer CreateTitleViewContainer(View titleView) return new TitleViewContainer(titleView); } + /// + /// Re-applies the navigation bar frame to the current TitleView container. + /// On iOS 26+ the TitleView container uses autoresizing masks with an explicitly set frame + /// (see ), so the frame is not automatically recomputed + /// when the navigation bar resizes during rotation or window size changes. This explicitly + /// resizes the container to match the navigation bar's new dimensions. + /// + internal void UpdateTitleViewFrameForOrientation() + { + if (NavigationItem?.TitleView is not TitleViewContainer titleViewContainer) + { + return; + } + + var navigationBarFrame = ViewController?.NavigationController?.NavigationBar.Frame; + if (navigationBarFrame.HasValue) + { + titleViewContainer.Frame = new CGRect(0, 0, navigationBarFrame.Value.Width, navigationBarFrame.Value.Height); + titleViewContainer.Height = navigationBarFrame.Value.Height; + titleViewContainer.LayoutIfNeeded(); + } + } + void OnTitleViewParentSet(object? sender, EventArgs e) { if (sender is Element element) @@ -588,7 +620,7 @@ void UpdateLeftToolbarItems() { NavigationItem.LeftBarButtonItem = new UIBarButtonItem(icon, UIBarButtonItemStyle.Plain, (s, e) => LeftBarButtonItemHandler(ViewController, IsRootPage)) { Enabled = enabled }; - + // For iOS 26+, explicitly set the tint color on the bar button item // because the navigation bar's tint color is not automatically inherited if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) @@ -696,7 +728,7 @@ void LeftBarButtonItemHandler(UIViewController controller, bool isRootPage) } else if (_flyoutBehavior == FlyoutBehavior.Flyout) { - _context?.Shell?.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, true); + _context?.Shell?.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, BooleanBoxes.TrueBox); } } @@ -916,6 +948,18 @@ protected virtual void OnSearchHandlerPropertyChanged(object sender, PropertyCha { UpdateAutomationId(); } + else if (e.PropertyName == SearchHandler.QueryIconProperty.PropertyName) + { + UpdateSearchBarIcon(_searchController.SearchBar, _searchHandler.QueryIcon, UISearchBarIcon.Search); + } + else if (e.PropertyName == SearchHandler.ClearIconProperty.PropertyName) + { + UpdateSearchBarIcon(_searchController.SearchBar, _searchHandler.ClearIcon, UISearchBarIcon.Clear); + } + else if (e.PropertyName == SearchHandler.ClearPlaceholderIconProperty.PropertyName) + { + UpdateSearchBarIcon(_searchController.SearchBar, _searchHandler.ClearPlaceholderIcon, UISearchBarIcon.Bookmark); + } } void UpdateAutomationId() @@ -1147,10 +1191,59 @@ void SetSearchBarIcon(UISearchBar searchBar, ImageSource source, UISearchBarIcon searchBar.SetImageforSearchBarIcon(newResult, icon, UIControlState.Normal); searchBar.SetImageforSearchBarIcon(newResult, icon, UIControlState.Highlighted); searchBar.SetImageforSearchBarIcon(newResult, icon, UIControlState.Selected); + + // iOS caches the clear button image once it has been shown. After the button + // has appeared (user typed text), SetImageforSearchBarIcon alone won't refresh + // it. Directly update the button subview so dynamic changes are reflected. + if (icon is UISearchBarIcon.Clear) + { + UpdateClearButtonImage(searchBar, newResult); + } } }); } + // Directly updates the clear button (X) inside UISearchBar's UITextField subview. + // This is required because iOS does not re-apply SetImageforSearchBarIcon to a + // clear button that is already visible on screen. + // + // NOTE: "searchField" and "clearButton" are private UIKit KVC keys. Apple does not + // expose these as public API. They have been stable across iOS versions and are a + // well-established pattern in Xamarin/MAUI, but could break in a future OS release. + static void UpdateClearButtonImage(UISearchBar searchBar, UIImage? image) + { + if (searchBar.ValueForKey(new NSString("searchField")) is UITextField textField && + textField.ValueForKey(new NSString("clearButton")) is UIButton clearButton) + { + clearButton.SetImage(image, UIControlState.Normal); + clearButton.SetImage(image, UIControlState.Highlighted); + } + } + + void UpdateSearchBarIcon(UISearchBar searchBar, ImageSource? source, UISearchBarIcon icon) + { + if (source is not null) + { + SetSearchBarIcon(searchBar, source, icon); + } + else + { + // Reset to default system icon by clearing the custom image + searchBar.SetImageforSearchBarIcon(null, icon, UIControlState.Normal); + searchBar.SetImageforSearchBarIcon(null, icon, UIControlState.Highlighted); + searchBar.SetImageforSearchBarIcon(null, icon, UIControlState.Selected); + + if (icon is UISearchBarIcon.Clear) + { + // UIKit caches the clear button image once it is on-screen, so + // SetImageforSearchBarIcon(null, ...) alone will not update the visible + // button. Restore the system default SF Symbol so the button shows the + // standard 'X' instead of becoming imageless. + UpdateClearButtonImage(searchBar, UIImage.GetSystemImage("multiply.circle.fill")); + } + } + } + void OnPageLoaded(object? sender, EventArgs e) { if (sender is Page page) @@ -1268,6 +1361,11 @@ void OnKeyboardWillHide(object? sender, UIKeyboardEventArgs e) } var currentFrame = ViewController.View.Frame; + + // Skip frame adjustment for transparent Shell nav bar where Y=0 is intentional (content extends behind bar). + if (navBar.Translucent && currentFrame.Y == 0) + return; + var navBarBottom = navBar.Frame.Bottom; if (currentFrame.Y == 0 && navBarBottom > 0 && diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index 6c3d707a2fd9..405fb7f21df6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Input; +using CoreGraphics; using Foundation; using Microsoft.Maui.Controls.Handlers.Compatibility; using Microsoft.Maui.Controls.Internals; @@ -303,6 +304,41 @@ public override void DidMoveToParentViewController(UIViewController parent) base.DidMoveToParentViewController(parent); } + public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTransitionCoordinator coordinator) + { + base.ViewWillTransitionToSize(toSize, coordinator); + + // On iOS 26+ the TitleView container uses autoresizing masks with an explicitly set frame, + // so it does not automatically resize when the navigation bar changes width during rotation. + // Re-apply the frame for the pushed pages' TitleViews alongside the transition. + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + coordinator.AnimateAlongsideTransition(_ => + { + foreach (var tracker in _trackers.Values) + { + (tracker as ShellPageRendererTracker)?.UpdateTitleViewFrameForOrientation(); + } + }, null); + } + } + + public override void TraitCollectionDidChange(UITraitCollection previousTraitCollection) + { + base.TraitCollectionDidChange(previousTraitCollection); + if (previousTraitCollection?.VerticalSizeClass != TraitCollection.VerticalSizeClass || + previousTraitCollection?.HorizontalSizeClass != TraitCollection.HorizontalSizeClass) + { + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + foreach (var tracker in _trackers.Values) + { + (tracker as ShellPageRendererTracker)?.UpdateTitleViewFrameForOrientation(); + } + } + } + } + public override void ViewDidLoad() { if (_disposed) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index b94597c34dc0..c2ae5836ea14 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -76,6 +76,30 @@ public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTr { base.ViewWillTransitionToSize(toSize, coordinator); _isRotating = true; + + // On iOS 26+ the TitleView container uses autoresizing masks with an explicitly set frame, + // so it does not automatically resize when the navigation bar changes width during rotation. + // Re-apply the frame alongside the transition so the TitleView fills the navigation bar. + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + coordinator.AnimateAlongsideTransition(_ => + { + (_tracker as ShellPageRendererTracker)?.UpdateTitleViewFrameForOrientation(); + }, null); + } + } + + public override void TraitCollectionDidChange(UITraitCollection previousTraitCollection) + { + base.TraitCollectionDidChange(previousTraitCollection); + if (previousTraitCollection?.VerticalSizeClass != TraitCollection.VerticalSizeClass || + previousTraitCollection?.HorizontalSizeClass != TraitCollection.HorizontalSizeClass) + { + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + (_tracker as ShellPageRendererTracker)?.UpdateTitleViewFrameForOrientation(); + } + } } public override void ViewDidLoad() diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs index b827357f96ee..2c6e0c3784e0 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs @@ -1,6 +1,7 @@ #nullable disable using System; using Foundation; +using Microsoft.Maui.Controls.Internals; using ObjCRuntime; using UIKit; @@ -10,6 +11,7 @@ public class UIContainerCell : UITableViewCell { IPlatformViewHandler _renderer; object _bindingContext; + IElementDefinition _viewResource; internal Action ViewMeasureInvalidated { get; set; } internal NSIndexPath IndexPath { get; set; } @@ -18,6 +20,8 @@ public class UIContainerCell : UITableViewCell internal UIContainerCell(string cellId, View view, Shell shell, object context) : base(UITableViewCellStyle.Default, cellId) { View = view; + _viewResource = view as IElementDefinition; + _viewResource?.AddResourcesChangedListener(OnResourcesChanged); View.MeasureInvalidated += MeasureInvalidated; SelectionStyle = UITableViewCellSelectionStyle.None; @@ -75,6 +79,8 @@ internal void Disconnect(Shell shell = null, bool keepRenderer = false) { ViewMeasureInvalidated = null; View.MeasureInvalidated -= MeasureInvalidated; + _viewResource?.RemoveResourcesChangedListener(OnResourcesChanged); + _viewResource = null; if (_bindingContext != null && _bindingContext is BaseShellItem baseShell) baseShell.PropertyChanged -= OnElementPropertyChanged; @@ -101,7 +107,9 @@ public object BindingContext set { if (value == _bindingContext) + { return; + } if (_bindingContext != null && _bindingContext is BaseShellItem baseShell) baseShell.PropertyChanged -= OnElementPropertyChanged; @@ -129,18 +137,21 @@ public override void LayoutSubviews() void UpdateVisualState() { - if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) + if (BindingContext is BaseShellItem bsi) { - View.IsItemSelected = baseShellItem.IsChecked; + VisualStateManager.GoToState(View, bsi.IsChecked ? "Selected" : "Normal", force: true); } } void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName == BaseShellItem.IsCheckedProperty.PropertyName) - { UpdateVisualState(); - } + } + + void OnResourcesChanged(object sender, ResourcesChangedEventArgs e) + { + UpdateVisualState(); } } } \ No newline at end of file diff --git a/src/Controls/src/Core/CompressedLayout.cs b/src/Controls/src/Core/CompressedLayout.cs index b49e33e444e3..eb6e03e82183 100644 --- a/src/Controls/src/Core/CompressedLayout.cs +++ b/src/Controls/src/Core/CompressedLayout.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls @@ -27,7 +28,7 @@ public static bool GetIsHeadless(BindableObject bindable) /// The new layout compression value. to enable layout compression [Obsolete("CompressedLayout does not provide meaningful functionality and may be removed in a future release. Please remove usage of this API.")] public static void SetIsHeadless(BindableObject bindable, bool value) - => bindable.SetValue(IsHeadlessProperty, value); + => bindable.SetValue(IsHeadlessProperty, BooleanBoxes.Box(value)); static void OnIsHeadlessPropertyChanged(BindableObject bindable, object oldValue, object newValue) { diff --git a/src/Controls/src/Core/ContentPage/ContentPage.cs b/src/Controls/src/Core/ContentPage/ContentPage.cs index c34fa417c3d3..b0dd5083cd28 100644 --- a/src/Controls/src/Core/ContentPage/ContentPage.cs +++ b/src/Controls/src/Core/ContentPage/ContentPage.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Microsoft.Maui.HotReload; using Microsoft.Maui.Layouts; @@ -27,7 +28,7 @@ public View Content /// Bindable property for . public static readonly BindableProperty HideSoftInputOnTappedProperty - = BindableProperty.Create(nameof(HideSoftInputOnTapped), typeof(bool), typeof(ContentPage), false); + = BindableProperty.Create(nameof(HideSoftInputOnTapped), typeof(bool), typeof(ContentPage), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty SafeAreaEdgesProperty = SafeAreaElement.SafeAreaEdgesProperty; @@ -38,7 +39,7 @@ public static readonly BindableProperty HideSoftInputOnTappedProperty public bool HideSoftInputOnTapped { get { return (bool)GetValue(HideSoftInputOnTappedProperty); } - set { SetValue(HideSoftInputOnTappedProperty, value); } + set { SetValue(HideSoftInputOnTappedProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/DragAndDrop/DragGestureRecognizer.cs b/src/Controls/src/Core/DragAndDrop/DragGestureRecognizer.cs index 77d25490a2e9..cc259ed4ae46 100644 --- a/src/Controls/src/Core/DragAndDrop/DragGestureRecognizer.cs +++ b/src/Controls/src/Core/DragAndDrop/DragGestureRecognizer.cs @@ -1,6 +1,7 @@ using System; -using System.Windows.Input; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; +using System.Windows.Input; namespace Microsoft.Maui.Controls { @@ -11,7 +12,7 @@ namespace Microsoft.Maui.Controls public class DragGestureRecognizer : GestureRecognizer { /// Bindable property for . - public static readonly BindableProperty CanDragProperty = BindableProperty.Create(nameof(CanDrag), typeof(bool), typeof(DragGestureRecognizer), true); + public static readonly BindableProperty CanDragProperty = BindableProperty.Create(nameof(CanDrag), typeof(bool), typeof(DragGestureRecognizer), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty DropCompletedCommandProperty = BindableProperty.Create(nameof(DropCompletedCommand), typeof(ICommand), typeof(DragGestureRecognizer), null); @@ -52,7 +53,7 @@ public DragGestureRecognizer() public bool CanDrag { get { return (bool)GetValue(CanDragProperty); } - set { SetValue(CanDragProperty, value); } + set { SetValue(CanDragProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/DragAndDrop/DropGestureRecognizer.cs b/src/Controls/src/Core/DragAndDrop/DropGestureRecognizer.cs index 8a926e5aaf2e..fab205b483d5 100644 --- a/src/Controls/src/Core/DragAndDrop/DropGestureRecognizer.cs +++ b/src/Controls/src/Core/DragAndDrop/DropGestureRecognizer.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading.Tasks; using System.Windows.Input; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -14,7 +15,7 @@ namespace Microsoft.Maui.Controls public class DropGestureRecognizer : GestureRecognizer { /// Bindable property for . - public static readonly BindableProperty AllowDropProperty = BindableProperty.Create(nameof(AllowDrop), typeof(bool), typeof(DropGestureRecognizer), true); + public static readonly BindableProperty AllowDropProperty = BindableProperty.Create(nameof(AllowDrop), typeof(bool), typeof(DropGestureRecognizer), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty DragOverCommandProperty = BindableProperty.Create(nameof(DragOverCommand), typeof(ICommand), typeof(DropGestureRecognizer), null); @@ -29,7 +30,7 @@ public class DropGestureRecognizer : GestureRecognizer public static readonly BindableProperty DragLeaveCommandParameterProperty = BindableProperty.Create(nameof(DragLeaveCommandParameter), typeof(object), typeof(DropGestureRecognizer), null); /// Bindable property for . - public static readonly BindableProperty DropCommandProperty = BindableProperty.Create(nameof(DropCommand), typeof(ICommand), typeof(DragGestureRecognizer), null); + public static readonly BindableProperty DropCommandProperty = BindableProperty.Create(nameof(DropCommand), typeof(ICommand), typeof(DropGestureRecognizer), null); /// Bindable property for . public static readonly BindableProperty DropCommandParameterProperty = BindableProperty.Create(nameof(DropCommandParameter), typeof(object), typeof(DropGestureRecognizer), null); @@ -62,7 +63,7 @@ public DropGestureRecognizer() public bool AllowDrop { get { return (bool)GetValue(AllowDropProperty); } - set { SetValue(AllowDropProperty, value); } + set { SetValue(AllowDropProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/Element/Element.Windows.cs b/src/Controls/src/Core/Element/Element.Windows.cs index aa249666f61e..d8891277bd55 100644 --- a/src/Controls/src/Core/Element/Element.Windows.cs +++ b/src/Controls/src/Core/Element/Element.Windows.cs @@ -2,6 +2,9 @@ using System; using System.Collections.Generic; using System.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; +using NativeAutomationProperties = Microsoft.UI.Xaml.Automation.AutomationProperties; namespace Microsoft.Maui.Controls { @@ -12,8 +15,28 @@ public static void MapAutomationPropertiesIsInAccessibleTree(IElementHandler han if (handler.IsConnectingHandler() && element.GetValue(AutomationProperties.IsInAccessibleTreeProperty) is null) return; - Platform.AccessibilityExtensions.SetAutomationPropertiesAccessibilityView( - handler.PlatformView as Microsoft.UI.Xaml.FrameworkElement, element); + if (handler.PlatformView is not FrameworkElement platformView) + return; + + var isInAccessibleTree = (bool?)element.GetValue(AutomationProperties.IsInAccessibleTreeProperty); + if (isInAccessibleTree == true) + { + platformView.SetValue(NativeAutomationProperties.AccessibilityViewProperty, AccessibilityView.Content); + } + else if (isInAccessibleTree == false) + { + platformView.SetValue(NativeAutomationProperties.AccessibilityViewProperty, AccessibilityView.Raw); + } + else + { + // Only clear if this mapper itself previously set the value (Content or Raw). + // Preserve any AccessibilityView set externally by platform code or a custom handler. + var current = platformView.ReadLocalValue(NativeAutomationProperties.AccessibilityViewProperty); + if (current is AccessibilityView.Content or AccessibilityView.Raw) + { + platformView.ClearValue(NativeAutomationProperties.AccessibilityViewProperty); + } + } } public static void MapAutomationPropertiesLabeledBy(IElementHandler handler, Element element) diff --git a/src/Controls/src/Core/Entry/Entry.cs b/src/Controls/src/Core/Entry/Entry.cs index 9681fd5b8ec7..7485174dc87a 100644 --- a/src/Controls/src/Core/Entry/Entry.cs +++ b/src/Controls/src/Core/Entry/Entry.cs @@ -35,7 +35,7 @@ public partial class Entry : InputView, ITextAlignmentElement, IEntryController, /// /// Backing store for the property. /// - public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(Entry), default(bool)); + public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(Entry), BooleanBoxes.FalseBox); /// public new static readonly BindableProperty TextProperty = InputView.TextProperty; @@ -122,7 +122,7 @@ public TextAlignment VerticalTextAlignment public bool IsPassword { get { return (bool)GetValue(IsPasswordProperty); } - set { SetValue(IsPasswordProperty, value); } + set { SetValue(IsPasswordProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs b/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs index 1637a8f9dc00..64328208ade4 100644 --- a/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs +++ b/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs @@ -15,6 +15,7 @@ public partial class FlyoutPage #endif #if WINDOWS FlyoutViewHandler.Mapper.ReplaceMapping(nameof(PlatformConfiguration.WindowsSpecific.FlyoutPage.CollapseStyleProperty), MapCollapseStyle); + FlyoutViewHandler.Mapper.ReplaceMapping(nameof(PlatformConfiguration.WindowsSpecific.FlyoutPage.CollapsedPaneWidthProperty), MapCollapsedPaneWidth); #endif } @@ -39,6 +40,7 @@ internal static void MapPrefersPrefersStatusBarHiddenProperty(IFlyoutViewHandler internal static void MapCollapseStyle(IFlyoutViewHandler handler, IFlyoutView view) { var flyoutLayoutBehavior = (view as FlyoutPage)?.FlyoutLayoutBehavior; + if (view is BindableObject bindable && handler.PlatformView is Microsoft.Maui.Platform.RootNavigationView navigationView && flyoutLayoutBehavior is FlyoutLayoutBehavior.Popover) { var collapseStyle = PlatformConfiguration.WindowsSpecific.FlyoutPage.GetCollapseStyle(bindable); @@ -56,6 +58,16 @@ internal static void MapCollapseStyle(IFlyoutViewHandler handler, IFlyoutView vi } } } + + internal static void MapCollapsedPaneWidth(IFlyoutViewHandler handler, IFlyoutView view) + { + if (view is BindableObject bindable && handler.PlatformView is Microsoft.Maui.Platform.RootNavigationView navigationView) + { + var collapsedPaneWidth = PlatformConfiguration.WindowsSpecific.FlyoutPage.GetCollapsedPaneWidth(bindable); + navigationView.CompactPaneLength = collapsedPaneWidth; + } + } + #endif } -} +} \ No newline at end of file diff --git a/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs b/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs index a9ce94192b13..dd6c7c666f5e 100644 --- a/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs +++ b/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs @@ -17,10 +17,10 @@ namespace Microsoft.Maui.Controls public partial class FlyoutPage : Page, IFlyoutPageController, IElementConfiguration, IFlyoutView { /// Bindable property for . - public static readonly BindableProperty IsGestureEnabledProperty = BindableProperty.Create(nameof(IsGestureEnabled), typeof(bool), typeof(FlyoutPage), true); + public static readonly BindableProperty IsGestureEnabledProperty = BindableProperty.Create(nameof(IsGestureEnabled), typeof(bool), typeof(FlyoutPage), BooleanBoxes.TrueBox); /// Bindable property for . - public static readonly BindableProperty IsPresentedProperty = BindableProperty.Create(nameof(IsPresented), typeof(bool), typeof(FlyoutPage), default(bool), + public static readonly BindableProperty IsPresentedProperty = BindableProperty.Create(nameof(IsPresented), typeof(bool), typeof(FlyoutPage), BooleanBoxes.FalseBox, propertyChanged: OnIsPresentedPropertyChanged, propertyChanging: OnIsPresentedPropertyChanging, defaultValueCreator: GetDefaultValue); /// Bindable property for . @@ -95,14 +95,14 @@ public Page Detail public bool IsGestureEnabled { get { return (bool)GetValue(IsGestureEnabledProperty); } - set { SetValue(IsGestureEnabledProperty, value); } + set { SetValue(IsGestureEnabledProperty, BooleanBoxes.Box(value)); } } /// Gets or sets a value that indicates whether the flyout is presented. This is a bindable property. public bool IsPresented { get { return (bool)GetValue(IsPresentedProperty); } - set { SetValue(IsPresentedProperty, value); } + set { SetValue(IsPresentedProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the flyout page that is used to present a menu or navigation options. @@ -282,7 +282,7 @@ internal static void UpdateFlyoutLayoutBehavior(FlyoutPage page) { if (page is IFlyoutPageController fpc && fpc.ShouldShowSplitMode) { - page.SetValue(IsPresentedProperty, true); + page.SetValue(IsPresentedProperty, BooleanBoxes.TrueBox); if (page.FlyoutLayoutBehavior != FlyoutLayoutBehavior.Default) fpc.CanChangeIsPresented = false; } diff --git a/src/Controls/src/Core/FontImageSource.cs b/src/Controls/src/Core/FontImageSource.cs index 9dc302b8aca0..43a140b68578 100644 --- a/src/Controls/src/Core/FontImageSource.cs +++ b/src/Controls/src/Core/FontImageSource.cs @@ -1,4 +1,5 @@ #nullable disable +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls @@ -57,13 +58,13 @@ public double Size /// Bindable property for . public static readonly BindableProperty FontAutoScalingEnabledProperty = - BindableProperty.Create(nameof(FontAutoScalingEnabled), typeof(bool), typeof(FontImageSource), false, + BindableProperty.Create(nameof(FontAutoScalingEnabled), typeof(bool), typeof(FontImageSource), BooleanBoxes.FalseBox, propertyChanged: (b, o, n) => ((FontImageSource)b).OnSourceChanged()); public bool FontAutoScalingEnabled { get => (bool)GetValue(FontAutoScalingEnabledProperty); - set => SetValue(FontAutoScalingEnabledProperty, value); + set => SetValue(FontAutoScalingEnabledProperty, BooleanBoxes.Box(value)); } } } diff --git a/src/Controls/src/Core/Frame/Frame.cs b/src/Controls/src/Core/Frame/Frame.cs index a15e72d8bbe1..6abbb588f6dc 100644 --- a/src/Controls/src/Core/Frame/Frame.cs +++ b/src/Controls/src/Core/Frame/Frame.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Microsoft.Maui.Layouts; @@ -14,7 +15,7 @@ public partial class Frame : ContentView, IElementConfiguration, IPadding public static readonly BindableProperty BorderColorProperty = BorderElement.BorderColorProperty; /// Bindable property for . - public static readonly BindableProperty HasShadowProperty = BindableProperty.Create(nameof(HasShadow), typeof(bool), typeof(Frame), true); + public static readonly BindableProperty HasShadowProperty = BindableProperty.Create(nameof(HasShadow), typeof(bool), typeof(Frame), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(Frame), -1.0f, @@ -38,7 +39,7 @@ Thickness IPaddingElement.PaddingDefaultValueCreator() public bool HasShadow { get { return (bool)GetValue(HasShadowProperty); } - set { SetValue(HasShadowProperty, value); } + set { SetValue(HasShadowProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the border color for the frame. This is a bindable property. diff --git a/src/Controls/src/Core/Handlers/Items/Android/Adapters/ItemsViewAdapter.cs b/src/Controls/src/Core/Handlers/Items/Android/Adapters/ItemsViewAdapter.cs index 43408e333924..ea9a7811d92b 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/Adapters/ItemsViewAdapter.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/Adapters/ItemsViewAdapter.cs @@ -152,6 +152,14 @@ protected virtual void BindTemplatedItemViewHolder(TemplatedItemViewHolder templ templatedItemViewHolder.Bind(context, ItemsView); } + /// + /// Clears any cached item size used by the MeasureFirstItem sizing strategy. + /// Called when the RecyclerView's size changes (e.g., after an orientation change). + /// + internal virtual void ClearMeasureCache() + { + } + void UpdateUsingItemTemplate() { _usingItemTemplate = ItemsView.ItemTemplate != null; diff --git a/src/Controls/src/Core/Handlers/Items/Android/Adapters/StructuredItemsViewAdapter.cs b/src/Controls/src/Core/Handlers/Items/Android/Adapters/StructuredItemsViewAdapter.cs index 42c8055d9a73..109c2681cff6 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/Adapters/StructuredItemsViewAdapter.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/Adapters/StructuredItemsViewAdapter.cs @@ -169,5 +169,10 @@ void SetStaticSize(Size size) { _size = size; } + + internal override void ClearMeasureCache() + { + _size = null; + } } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/CarouselViewLoopManager.cs b/src/Controls/src/Core/Handlers/Items/Android/CarouselViewLoopManager.cs index f50027661c7a..55ea7a00fca6 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/CarouselViewLoopManager.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/CarouselViewLoopManager.cs @@ -46,34 +46,59 @@ public int GetGoToIndex(RecyclerView recyclerView, int carouselPosition, int new { if (!(recyclerView.GetLayoutManager() is LinearLayoutManager linearLayoutManager)) return -1; - if (_itemsSource is null) + + if (_itemsSource is null || _itemsSource.Count == 0) return -1; - var currentCarouselPosition = carouselPosition; var itemSourceCount = _itemsSource.Count; - var diffToStart = currentCarouselPosition + (itemSourceCount - newPosition); - var diffToEnd = itemSourceCount - currentCarouselPosition + newPosition; + if (newPosition < 0 || newPosition >= itemSourceCount) + { + return -1; + } + var centerView = recyclerView.GetCenteredView(); if (centerView == null) return -1; var centerPosition = linearLayoutManager.GetPosition(centerView); - var increment = currentCarouselPosition - newPosition; - var incrementAbs = System.Math.Abs(increment); - - int goToPosition; - if (diffToStart < incrementAbs) - goToPosition = centerPosition - diffToStart; - else if (diffToEnd < incrementAbs) - goToPosition = centerPosition + diffToEnd; - else - goToPosition = centerPosition - increment; - - return goToPosition; + var adapterCount = recyclerView.GetAdapter()?.ItemCount ?? 0; + + return GetNearestAdapterPosition(centerPosition, newPosition, itemSourceCount, adapterCount); } public void SetItemsSource(IItemsViewSource itemsSource) => _itemsSource = itemsSource; + + static int GetNearestAdapterPosition(int currentAdapterPosition, int targetItemIndex, int itemCount, int adapterCount) + { + if (currentAdapterPosition < 0 || adapterCount <= 0 || itemCount <= 0) + { + return -1; + } + + var currentCycleStart = currentAdapterPosition - (currentAdapterPosition % itemCount); + var bestPosition = -1; + var bestDistance = int.MaxValue; + + for (var cycleOffset = -1; cycleOffset <= 1; cycleOffset++) + { + var candidate = currentCycleStart + targetItemIndex + (cycleOffset * itemCount); + + if (candidate < 0 || candidate >= adapterCount) + { + continue; + } + + var distance = System.Math.Abs(candidate - currentAdapterPosition); + if (distance < bestDistance) + { + bestPosition = candidate; + bestDistance = distance; + } + } + + return bestPosition; + } } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/GridLayoutSpanSizeLookup.cs b/src/Controls/src/Core/Handlers/Items/Android/GridLayoutSpanSizeLookup.cs index 90ed595e41f4..dd769fe734bc 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/GridLayoutSpanSizeLookup.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/GridLayoutSpanSizeLookup.cs @@ -12,6 +12,9 @@ public GridLayoutSpanSizeLookup(GridItemsLayout gridItemsLayout, RecyclerView re { _gridItemsLayout = gridItemsLayout; _recyclerView = recyclerView; + + SpanIndexCacheEnabled = true; + SpanGroupIndexCacheEnabled = true; } public override int GetSpanSize(int position) diff --git a/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs b/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs index 580ea95549ba..80124edd744f 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs @@ -79,6 +79,17 @@ internal void HandleItemSizingStrategy(Action reportMeasure, Size? size) _pixelSize = size; } + /// + /// Invalidates the cached size so the next measure pass will re-measure the content. + /// Called when the parent RecyclerView's size changes (e.g., after orientation change). + /// + internal void InvalidateCachedSize() + { + _pixelSize = null; + _previousPixelWidth = -1; + _previousPixelHeight = -1; + } + protected override void OnLayout(bool changed, int l, int t, int r, int b) { if (Content == null) diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs index d0dca45c42b2..96d6d8b2862a 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs @@ -20,7 +20,11 @@ public class MauiCarouselRecyclerView : MauiRecyclerView _oldViews; CarouselViewOnGlobalLayoutListener _carouselViewLayoutListener; @@ -30,20 +34,91 @@ public MauiCarouselRecyclerView(Context context, Func getItemsLayo { _oldViews = new List(); _carouselViewLoopManager = new CarouselViewLoopManager(); + _touchSlop = ViewConfiguration.Get(context).ScaledTouchSlop; } + // Gets or sets a value indicating whether swipe gestures are enabled for the carousel. public bool IsSwipeEnabled { get; set; } public override bool OnInterceptTouchEvent(MotionEvent ev) { + // If ItemsView is disabled, defer to the base implementation to intercept all touch events and prevent interactions + if (ItemsView?.IsEnabled == false) + { + return base.OnInterceptTouchEvent(ev); + } + if (!IsSwipeEnabled) { return false; } + switch (ev.Action) + { + case MotionEventActions.Down: + _initialTouchX = ev.GetX(); + _initialTouchY = ev.GetY(); + _directionLocked = false; + _delegatingToChild = false; + break; + + case MotionEventActions.Move: + // Once a gesture has been delegated to a nested child, keep delegating for the + // rest of the gesture. This prevents a later ambiguous move - or the child + // reaching its scroll boundary - from letting the carousel hijack the swipe and + // transition to the next item. + if (_delegatingToChild) + { + return false; + } + + if (!_directionLocked) + { + float deltaX = ev.GetX() - _initialTouchX; + float deltaY = ev.GetY() - _initialTouchY; + + // Lock the gesture direction the first time movement exceeds touch slop. + if (Math.Abs(deltaX) > _touchSlop || Math.Abs(deltaY) > _touchSlop) + { + _directionLocked = true; + + if (IsOffAxisGesture(deltaX, deltaY)) + { + // Perpendicular gesture (e.g. a vertical swipe on a horizontal carousel): + // it belongs to nested scrollable content, never the carousel. + _delegatingToChild = true; + return false; + } + } + } + break; + + case MotionEventActions.Cancel: + case MotionEventActions.Up: + // Reset gesture state at the end of the gesture + // to prevent old values from being used if we don't get a Down event + _initialTouchX = 0; + _initialTouchY = 0; + _directionLocked = false; + _delegatingToChild = false; + break; + } + return base.OnInterceptTouchEvent(ev); } + // Determines whether the gesture's dominant axis is the opposite of the carousel's scroll + // orientation (e.g. a vertical swipe on a horizontal carousel). Off-axis gestures belong to + // nested scrollable content, so the carousel must not intercept them. + bool IsOffAxisGesture(float deltaX, float deltaY) + { + float absDeltaX = Math.Abs(deltaX); + float absDeltaY = Math.Abs(deltaY); + bool isVerticalGesture = absDeltaY > absDeltaX; + + return IsHorizontal ? isVerticalGesture : !isVerticalGesture; + } + protected virtual bool IsHorizontal => (Carousel?.ItemsLayout)?.Orientation == ItemsLayoutOrientation.Horizontal; protected override int DetermineTargetPosition(ScrollToRequestEventArgs args) @@ -263,6 +338,10 @@ void CollectionItemsSourceChanged(object sender, System.Collections.Specialized. if (Carousel.Loop) { UpdateAdapter(); + // Sync the loop manager's source so GetGoToIndex uses the correct item count + // after the adapter is rebuilt. Without this, _itemsSource stays stale and + // GetNearestAdapterPosition produces wrong results + _carouselViewLoopManager.SetItemsSource(ItemsViewAdapter.ItemsSource); ScrollToPosition(carouselPosition); } } @@ -320,9 +399,16 @@ void CollectionItemsSourceChanged(object sender, System.Collections.Specialized. UpdateItemDecoration(); } - UpdateVisualStates(); + if (Carousel.Loop) + { + UpdateLoopCentering(count); + } + else + { + ScrollToPosition(carouselPosition); + } - ScrollToPosition(carouselPosition); + UpdateVisualStates(); } } finally @@ -430,8 +516,14 @@ void UpdateInitialPosition() SetCurrentItem(_oldPosition); - var index = Carousel.Loop ? LoopedPosition(itemCount) + _oldPosition : _oldPosition; - ScrollHelper.JumpScrollToPosition(index, Microsoft.Maui.Controls.ScrollToPosition.Center); + if (Carousel.Loop) + { + UpdateLoopCentering(itemCount); + } + else + { + ScrollHelper.JumpScrollToPosition(_oldPosition, Microsoft.Maui.Controls.ScrollToPosition.Center); + } _gotoPosition = -1; } @@ -446,6 +538,20 @@ int LoopedPosition(int itemCount) return loopScale - (loopScale % itemCount); } + void UpdateLoopCentering(int itemCount) + { + if (ItemsViewAdapter is null || itemCount == 0) + { + return; + } + + var currentPosition = Carousel.Position; + + // Calculate the proper looped index for centering + var index = LoopedPosition(itemCount) + currentPosition; + ScrollHelper.JumpScrollToPosition(index, Microsoft.Maui.Controls.ScrollToPosition.Center); + } + void UpdatePositionFromVisibilityChanges() { if (_isVisible != ItemsView.IsVisible) @@ -696,6 +802,34 @@ void ClearLayoutListener() _carouselViewLayoutListener = null; } + // https://github.com/dotnet/maui/issues/13323 + // CarouselView is a full-page pager; child-initiated rectangle scroll requests + // (e.g. EditText cursor positioning) must not scroll the carousel. + public override bool RequestChildRectangleOnScreen( + global::Android.Views.View child, + global::Android.Graphics.Rect rect, + bool immediate) + { + return false; + } + + // https://github.com/dotnet/maui/issues/13323 + // base.RequestChildFocus preserves normal focus propagation, but it may + // start a focus-driven scroll from an otherwise idle CarouselView. + public override void RequestChildFocus( + global::Android.Views.View child, + global::Android.Views.View focused) + { + var wasIdleBeforeFocus = ScrollState == RecyclerView.ScrollStateIdle; + + base.RequestChildFocus(child, focused); + + if (wasIdleBeforeFocus && ScrollState != RecyclerView.ScrollStateIdle) + { + StopScroll(); + } + } + protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) { // If the height or width are unbounded and the user is set to diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs index 2f75ab79d05e..94ca9b58efe2 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs @@ -444,10 +444,9 @@ public virtual void UpdateItemsSource() UpdateAdapter(); // Set up any properties which require observing data changes in the adapter - UpdateItemsUpdatingScrollMode(); - UpdateEmptyView(); AddOrUpdateScrollListener(); + UpdateItemsUpdatingScrollMode(); UpdateSnapBehavior(); } @@ -456,6 +455,15 @@ protected virtual void UpdateItemsUpdatingScrollMode() if (ItemsViewAdapter == null || ItemsView == null) return; + if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepScrollOffset) + { + ScrollHelper.AddScrollListener(); + } + else + { + ScrollHelper.RemoveScrollListener(); + } + if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepItemsInView) { // Keeping the current items in view is the default, so we don't need to watch for data changes @@ -662,10 +670,8 @@ protected virtual void LayoutPropertyChanged(object sender, PropertyChangedEvent public override bool OnTouchEvent(MotionEvent e) { - // If ItemsView is disabled, don't handle touch events. - // But only when the ItemsView itself is explicitly disabled, not when it inherits - // IsEnabled=false from a parent (e.g. RefreshView.IsEnabled=false propagating down). - if (ItemsView?.IsEnabled == false && !ItemsView.IsExplicitlyEnabled) + // If ItemsView is disabled, don't handle touch events + if (ItemsView?.IsEnabled == false) { return false; } @@ -677,7 +683,7 @@ public override bool OnTouchEvent(MotionEvent e) public override bool DispatchTouchEvent(MotionEvent e) { - if (ItemsView?.IsEnabled == false && !ItemsView.IsExplicitlyEnabled) + if (ItemsView?.IsEnabled == false) { return base.DispatchTouchEvent(e); } @@ -692,10 +698,8 @@ public override bool DispatchTouchEvent(MotionEvent e) public override bool OnInterceptTouchEvent(MotionEvent e) { - // If ItemsView is disabled, intercept all touch events to prevent interactions. - // But only when the ItemsView itself is explicitly disabled, not when it inherits - // IsEnabled=false from a parent (e.g. RefreshView.IsEnabled=false propagating down). - if (ItemsView?.IsEnabled == false && !ItemsView.IsExplicitlyEnabled) + // If ItemsView is disabled, intercept all touch events to prevent interactions + if (ItemsView?.IsEnabled == false) { return true; } @@ -717,6 +721,35 @@ protected override void OnLayout(bool changed, int l, int t, int r, int b) _scrollHelper?.AdjustScroll(); } + protected override void OnSizeChanged(int w, int h, int oldw, int oldh) + { + base.OnSizeChanged(w, h, oldw, oldh); + + // When the RecyclerView's size changes (e.g., after an orientation change while + // the CollectionView was not visible), we need to invalidate all visible item views + // to ensure they are re-measured with the new dimensions. + if (oldw > 0 && oldh > 0 && (Math.Abs(w - oldw) > 1 || Math.Abs(h - oldh) > 1)) + { + InvalidateItemMeasures(); + } + } + + void InvalidateItemMeasures() + { + // Clear the adapter's static size cache (used by MeasureFirstItem strategy) + ItemsViewAdapter?.ClearMeasureCache(); + + // Force all visible children to invalidate their cached sizes and re-measure + for (int i = 0; i < ChildCount; i++) + { + if (GetChildAt(i) is ItemContentView itemContentView) + { + itemContentView.InvalidateCachedSize(); + itemContentView.ForceLayout(); + } + } + } + protected override void Dispose(bool disposing) { if (disposing) @@ -739,17 +772,21 @@ protected override void Dispose(bool disposing) class ParentScrollGestureDispatcher : IDisposable { readonly MauiRecyclerView _owner; + readonly DescendantDisallowInterceptListener _disallowInterceptListener; readonly int[] _targetLocation = new int[2]; MotionEvent _downEvent; AView _parentScrollTarget; float _touchStartX; float _touchStartY; int? _scaledTouchSlop; + bool _descendantDisallowedIntercept; GestureOwner _gestureOwner; public ParentScrollGestureDispatcher(MauiRecyclerView owner) { _owner = owner; + _disallowInterceptListener = new DescendantDisallowInterceptListener(this); + _owner.AddOnItemTouchListener(_disallowInterceptListener); } public bool TryDispatchToParent(MotionEvent e, Func dispatchToRecyclerView, out bool handled) @@ -797,9 +834,19 @@ public bool TryDispatchToParent(MotionEvent e, Func dispatchT public void Dispose() { + _owner.RemoveOnItemTouchListener(_disallowInterceptListener); + _disallowInterceptListener.Dispose(); Reset(); } + public void RequestDisallowInterceptTouchEvent(bool disallowIntercept) + { + if (_gestureOwner != GestureOwner.Parent) + { + _descendantDisallowedIntercept = disallowIntercept; + } + } + void TrackDown(MotionEvent e) { Reset(); @@ -854,6 +901,14 @@ bool TryStartForwardingToParent(MotionEvent e, Func dispatchT return false; } + var recyclerViewHandled = dispatchToRecyclerView(e); + + if (_descendantDisallowedIntercept) + { + handled = recyclerViewHandled; + return true; + } + _parentScrollTarget = target; _gestureOwner = GestureOwner.Parent; _owner.Parent?.RequestDisallowInterceptTouchEvent(false); @@ -942,6 +997,7 @@ void Reset() { _owner.Parent?.RequestDisallowInterceptTouchEvent(false); _parentScrollTarget = null; + _descendantDisallowedIntercept = false; _gestureOwner = GestureOwner.Undecided; if (_downEvent is not null) @@ -962,6 +1018,30 @@ enum GestureOwner RecyclerView, Parent } + + sealed class DescendantDisallowInterceptListener : Java.Lang.Object, RecyclerView.IOnItemTouchListener + { + readonly ParentScrollGestureDispatcher _dispatcher; + + public DescendantDisallowInterceptListener(ParentScrollGestureDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public bool OnInterceptTouchEvent(RecyclerView rv, MotionEvent e) + { + return false; + } + + public void OnTouchEvent(RecyclerView rv, MotionEvent e) + { + } + + public void OnRequestDisallowInterceptTouchEvent(bool disallowIntercept) + { + _dispatcher.RequestDisallowInterceptTouchEvent(disallowIntercept); + } + } } internal void UpdateEmptyViewVisibility() @@ -1068,8 +1148,8 @@ void RemoveScrollListener() if (RecyclerViewScrollListener == null) return; + RemoveOnScrollListener(RecyclerViewScrollListener); RecyclerViewScrollListener.Dispose(); - ClearOnScrollListeners(); RecyclerViewScrollListener = null; } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/ScrollHelper.cs b/src/Controls/src/Core/Handlers/Items/Android/ScrollHelper.cs index 9c20d6dd6ffd..6961491ce28a 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/ScrollHelper.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/ScrollHelper.cs @@ -12,7 +12,7 @@ internal class ScrollHelper : RecyclerView.OnScrollListener bool _undoNextScrollAdjustment; bool _maintainingScrollOffsets; - + bool _isAtScrollOrigin = true; int _lastScrollX; int _lastScrollY; int _lastDeltaX; @@ -26,13 +26,6 @@ public ScrollHelper(RecyclerView recyclerView) // Used by the renderer to maintain scroll offset when using ItemsUpdatingScrollMode KeepScrollOffset public void UndoNextScrollAdjustment() { - // Don't start tracking the scroll offsets until we really need to - if (!_maintainingScrollOffsets) - { - _maintainingScrollOffsets = true; - _recyclerView.AddOnScrollListener(this); - } - _undoNextScrollAdjustment = true; _lastScrollX = _recyclerView.ComputeHorizontalScrollOffset(); @@ -212,12 +205,20 @@ void TrackOffsets() // offset to shift; since the ItemsUpdatingScrollMode is set to KeepScrollOffset; we need to undo // that shift and stay where we were before the item was added - _undoNextScrollAdjustment = false; - _recyclerView.ScrollBy(-_lastDeltaX, -_lastDeltaY); + if (_isAtScrollOrigin) + { + _recyclerView.ScrollBy(-_lastDeltaX, -_lastDeltaY); + } + _undoNextScrollAdjustment = false; _lastDeltaX = 0; _lastDeltaY = 0; } + else + { + _isAtScrollOrigin = newXOffset == 0 + && newYOffset == 0; + } } public override void OnScrolled(RecyclerView recyclerView, int dx, int dy) @@ -225,5 +226,25 @@ public override void OnScrolled(RecyclerView recyclerView, int dx, int dy) base.OnScrolled(recyclerView, dx, dy); TrackOffsets(); } + + internal void AddScrollListener() + { + // Set up scroll listener to track the scroll offsets when we're using KeepScrollOffset. + if (!_maintainingScrollOffsets) + { + _maintainingScrollOffsets = true; + _recyclerView.AddOnScrollListener(this); + } + } + + internal void RemoveScrollListener() + { + // Remove the scroll listener when we're done and no longer need to track the offsets. + if (_maintainingScrollOffsets) + { + _maintainingScrollOffsets = false; + _recyclerView.RemoveOnScrollListener(this); + } + } } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/SpacingItemDecoration.cs b/src/Controls/src/Core/Handlers/Items/Android/SpacingItemDecoration.cs index ca2525e79658..7abaeb2345a9 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/SpacingItemDecoration.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/SpacingItemDecoration.cs @@ -13,8 +13,6 @@ public class SpacingItemDecoration : RecyclerView.ItemDecoration public int VerticalOffset { get; } - int _span = 1; - ItemsLayoutOrientation _orientation; public SpacingItemDecoration(Context context, IItemsLayout itemsLayout) @@ -39,7 +37,6 @@ public SpacingItemDecoration(Context context, IItemsLayout itemsLayout) case GridItemsLayout gridItemsLayout: horizontalOffset = gridItemsLayout.HorizontalItemSpacing / 2.0; verticalOffset = gridItemsLayout.VerticalItemSpacing / 2.0; - _span = gridItemsLayout.Span; _orientation = gridItemsLayout.Orientation; break; case LinearItemsLayout listItemsLayout: @@ -84,10 +81,24 @@ public override void GetItemOffsets(ARect outRect, AView view, RecyclerView pare outRect.Top = VerticalOffset; // Remove spacing on the outer edges so spacing only appears between items. - // A linear layout is effectively span=1, so the same math works for both. - int rowCol = _span <= 1 ? position : position / _span; - int totalRowsCols = _span <= 1 ? itemCount : (itemCount + _span - 1) / _span; - int lastRowCol = totalRowsCols - 1; + int rowCol; + int lastRowCol; + + if (parent.GetLayoutManager() is GridLayoutManager gridLayoutManager) + { + // Use SpanSizeLookup instead of position/spanCount so full-span items + // (group headers, footers, etc.) are accounted for when determining rows. + var spanSizeLookup = gridLayoutManager.GetSpanSizeLookup(); + int spanCount = gridLayoutManager.SpanCount; + rowCol = spanSizeLookup.GetSpanGroupIndex(position, spanCount); + lastRowCol = spanSizeLookup.GetSpanGroupIndex(itemCount - 1, spanCount); + } + else + { + // Linear layout: each item occupies exactly one row/column. + rowCol = position; + lastRowCol = itemCount - 1; + } if (_orientation == ItemsLayoutOrientation.Vertical) { diff --git a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs index c45e9c315ba2..efb525b27f8a 100644 --- a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs @@ -30,6 +30,7 @@ public partial class CarouselViewHandler : ItemsViewHandler bool _isCarouselViewReady; bool _isInternalPositionUpdate; int _gotoPosition = -1; + bool _isCollectionChanged; NotifyCollectionChangedEventHandler _collectionChanged; readonly WeakNotifyCollectionChangedProxy _proxy = new(); @@ -549,6 +550,11 @@ void CarouselScrolled(object sender, ItemsViewScrolledEventArgs e) } var position = e.CenterItemIndex; + if (_isCollectionChanged && ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepScrollOffset) + { + position = ItemsView.Position; + _isCollectionChanged = false; + } if (position == -1) { @@ -588,7 +594,7 @@ void OnCollectionItemsSourceChanged(object sender, NotifyCollectionChangedEventA { // Set flag to disable animation during collection changes _isInternalPositionUpdate = true; - + try { var carouselPosition = ItemsView.Position; @@ -614,6 +620,12 @@ void OnCollectionItemsSourceChanged(object sender, NotifyCollectionChangedEventA && currentItemPosition != -1) { carouselPosition = currentItemPosition; + _isCollectionChanged = true; + } + + if (e.Action == NotifyCollectionChangedAction.Remove) + { + _isCollectionChanged = true; } if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepLastItemInView) diff --git a/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs b/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs index 5a6c863c66f3..75a479fb7b80 100644 --- a/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs +++ b/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs @@ -100,6 +100,10 @@ protected virtual void UpdateLayout() internal static void MapIsEnabled(ItemsViewHandler handler, ItemsView itemsView) { (handler.Controller as SelectableItemsViewController)?.UpdateSelectionMode(); + + // Funnel through the base handler's IsEnabled mapping so UserInteractionEnabled + // stays correctly derived from both IsEnabled and InputTransparent. + ViewHandler.MapIsEnabled(handler, itemsView); } protected virtual void ScrollToRequested(object sender, ScrollToRequestEventArgs args) diff --git a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs index e1b75635e944..9675b93cdcc2 100644 --- a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs +++ b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs @@ -77,11 +77,11 @@ public override void UpdateViewState(NView view, ViewHolderState state) { case ViewHolderState.Focused: VisualStateManager.GoToState(formsView, VisualStateManager.CommonStates.Focused); - formsView.SetValue(VisualElement.IsFocusedPropertyKey, true); + formsView.SetValue(VisualElement.IsFocusedPropertyKey, BooleanBoxes.TrueBox); break; case ViewHolderState.Normal: formsView.IsItemSelected = false; - formsView.SetValue(VisualElement.IsFocusedPropertyKey, false); + formsView.SetValue(VisualElement.IsFocusedPropertyKey, BooleanBoxes.FalseBox); break; case ViewHolderState.Selected: if (IsSelectable) diff --git a/src/Controls/src/Core/Handlers/Items/iOS/SelectableItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/SelectableItemsViewController.cs index 95881a777273..46a8508a8d7e 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/SelectableItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/SelectableItemsViewController.cs @@ -25,7 +25,7 @@ protected override UICollectionViewDelegateFlowLayout CreateDelegator() // _Only_ called if the user initiates the selection change; will not be called for programmatic selection public override void ItemSelected(UICollectionView collectionView, NSIndexPath indexPath) { - if (ItemsView?.ItemsSource is null || !ItemsView.IsExplicitlyEnabled) + if (ItemsView?.ItemsSource is null || !ItemsView.IsEnabled) { return; } @@ -35,7 +35,7 @@ public override void ItemSelected(UICollectionView collectionView, NSIndexPath i // _Only_ called if the user initiates the selection change; will not be called for programmatic selection public override void ItemDeselected(UICollectionView collectionView, NSIndexPath indexPath) { - if (ItemsView?.ItemsSource is null || !ItemsView.IsExplicitlyEnabled) + if (ItemsView?.ItemsSource is null || !ItemsView.IsEnabled) { return; } @@ -185,7 +185,7 @@ internal void UpdatePlatformSelection() internal void UpdateSelectionMode() { var mode = ItemsView.SelectionMode; - var isEnabled = ItemsView.IsExplicitlyEnabled; + var isEnabled = ItemsView.IsEnabled; switch (mode) { diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.iOS.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.iOS.cs index 7ed8b7eea894..f59e73dd6057 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.iOS.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.iOS.cs @@ -227,11 +227,14 @@ public static void MapItemSizingStrategy(CollectionViewHandler2 handler, Structu handler.UpdateLayout(); } + Dictionary _layoutPropertyCache = new(); IItemsLayout _subscribedItemsLayout; protected override void DisconnectHandler(UIView platformView) { base.DisconnectHandler(platformView); + _layoutPropertyCache?.Clear(); + _layoutPropertyCache = null; UpdateItemsLayoutSubscription(null); } @@ -251,12 +254,20 @@ internal void UpdateItemsLayoutSubscription(IItemsLayout newLayout) if (_subscribedItemsLayout is not null) { + // Reinitialize the cache if it was cleared by DisconnectHandler + _layoutPropertyCache ??= new Dictionary(); _subscribedItemsLayout.PropertyChanged += OnItemsLayoutPropertyChanged; } } void OnItemsLayoutPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs args) { + if (_subscribedItemsLayout == null) + { + return; + } + + // Handle all layout-affecting property changes with caching if (args.PropertyName == nameof(ItemsLayout.SnapPointsAlignment) || args.PropertyName == nameof(ItemsLayout.SnapPointsType) || args.PropertyName == nameof(GridItemsLayout.VerticalItemSpacing) || @@ -264,8 +275,34 @@ void OnItemsLayoutPropertyChanged(object sender, System.ComponentModel.PropertyC args.PropertyName == nameof(GridItemsLayout.Span) || args.PropertyName == nameof(LinearItemsLayout.ItemSpacing)) { - UpdateLayout(); + // Get the current value of the changed property + object newValue = GetPropertyValue(_subscribedItemsLayout, args.PropertyName); + + // Check if value actually changed by comparing with cached value + if (!_layoutPropertyCache.TryGetValue(args.PropertyName, out var cachedValue) || + !Equals(cachedValue, newValue)) + { + // Update cache and trigger layout update + _layoutPropertyCache[args.PropertyName] = newValue; + UpdateLayout(); + } } } + + object GetPropertyValue(IItemsLayout itemsLayout, string propertyName) + { + return propertyName switch + { + nameof(GridItemsLayout.Span) when itemsLayout is GridItemsLayout grid => grid.Span, + nameof(GridItemsLayout.HorizontalItemSpacing) when itemsLayout is GridItemsLayout grid => grid.HorizontalItemSpacing, + nameof(GridItemsLayout.VerticalItemSpacing) when itemsLayout is GridItemsLayout grid => grid.VerticalItemSpacing, + nameof(LinearItemsLayout.ItemSpacing) when itemsLayout is LinearItemsLayout linear => linear.ItemSpacing, + nameof(ItemsLayout.SnapPointsAlignment) when itemsLayout is GridItemsLayout grid => grid.SnapPointsAlignment, + nameof(ItemsLayout.SnapPointsAlignment) when itemsLayout is LinearItemsLayout linear => linear.SnapPointsAlignment, + nameof(ItemsLayout.SnapPointsType) when itemsLayout is GridItemsLayout grid => grid.SnapPointsType, + nameof(ItemsLayout.SnapPointsType) when itemsLayout is LinearItemsLayout linear => linear.SnapPointsType, + _ => null + }; + } } } diff --git a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs index 931f39d210f9..73f1f4170dab 100644 --- a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs +++ b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs @@ -125,6 +125,10 @@ public static void MapIsVisible(ItemsViewHandler2 handler, ItemsView internal static void MapIsEnabled(ItemsViewHandler2 handler, ItemsView itemsView) { (handler.Controller as SelectableItemsViewController2)?.UpdateSelectionMode(); + + // Funnel through the base handler's IsEnabled mapping so UserInteractionEnabled + // stays correctly derived from both IsEnabled and InputTransparent. + ViewHandler.MapIsEnabled(handler, itemsView); } public static void MapItemsUpdatingScrollMode(ItemsViewHandler2 handler, ItemsView itemsView) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index d97a09dec9d5..6c57d169ee53 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -19,6 +19,7 @@ public class CarouselViewController2 : ItemsViewController2 bool _isInternalCollectionUpdate = false; int _section = 0; bool _wasDetachedFromWindow = false; + int _gotoPosition = -1; CarouselViewLoopManager _carouselViewLoopManager; CancellationTokenSource _scrollDebounce; NSObject _orientationObserver; @@ -125,6 +126,8 @@ public override void UpdateItemsSource() { UnsubscribeCollectionItemsSourceChanged(ItemsSource); _isUpdating = true; + // Pending scroll target belongs to the old source; clear it. + _gotoPosition = -1; base.UpdateItemsSource(); //we don't need to Subscribe because base calls CreateItemsViewSource _carouselViewLoopManager?.SetItemsSource(LoopItemsSource); @@ -133,6 +136,10 @@ public override void UpdateItemsSource() { carousel.SetValueFromRenderer(CarouselView.CurrentItemProperty, null); carousel.SetValueFromRenderer(CarouselView.PositionProperty, 0); + // The Position=0 reset above indirectly sets _gotoPosition via + // UpdateFromPosition -> ScrollToPosition(0, oldPos, ...); clear it so later + // programmatic Position/CurrentItem changes aren't suppressed. + _gotoPosition = -1; } _isUpdating = false; } @@ -214,6 +221,8 @@ void TearDown(CarouselView carouselView) _isUpdating = false; _isRotating = false; _isInternalCollectionUpdate = false; + // Don't let a pending scroll target survive re-attach. + _gotoPosition = -1; } internal void UpdateScrollingConstraints() @@ -340,7 +349,7 @@ void CollectionViewUpdated(object sender, NotifyCollectionChangedEventArgs e) return; } - //_gotoPosition = -1; + _gotoPosition = -1; // We need to update the position while modifying the collection. targetPosition = GetTargetPosition(); @@ -365,6 +374,8 @@ void CollectionViewUpdated(object sender, NotifyCollectionChangedEventArgs e) _isUpdating = false; ScrollToPosition(targetPosition, targetPosition, false, true); + // The forced scroll above sets _gotoPosition but fires no callback when already at the target; clear it so a later user-initiated scroll isn't suppressed. + _gotoPosition = -1; } int GetPositionWhenAddingItems(int carouselPosition, int currentItemPosition) @@ -471,6 +482,11 @@ internal void UpdateLoop() CollectionView.ReloadData(); ScrollToPosition(carouselPosition, carouselPosition, false, true); + + // Symmetric to CollectionViewUpdated: this forced re-center may leave + // _gotoPosition stuck (no-op scroll, or dropped while another scroll was + // in-flight), so clear it to avoid blocking future programmatic scrolls. + _gotoPosition = -1; } void UpdateScrollBarVisibility() @@ -498,7 +514,7 @@ void ScrollToPosition(int goToPosition, int carouselPosition, bool animate, bool return; } - if (goToPosition != carouselPosition || forceScroll) + if (_gotoPosition == -1 && (goToPosition != carouselPosition || forceScroll)) { UICollectionViewScrollPosition uICollectionViewScrollPosition = IsHorizontal ? UICollectionViewScrollPosition.CenteredHorizontally : UICollectionViewScrollPosition.CenteredVertically; var goToIndexPath = GetScrollToIndexPath(goToPosition); @@ -508,6 +524,7 @@ void ScrollToPosition(int goToPosition, int carouselPosition, bool animate, bool return; } + _gotoPosition = goToPosition; CollectionView.ScrollToItem(goToIndexPath, uICollectionViewScrollPosition, animate); } } @@ -535,6 +552,19 @@ internal void SetPosition(int position) return; } + if (_gotoPosition != -1) + { + if (position == _gotoPosition) + { + _gotoPosition = -1; + } + else + { + // Suppress intermediate positions while scrolling to target + return; + } + } + ItemsView.SetValueFromRenderer(CarouselView.PositionProperty, position); SetCurrentItem(position); UpdateVisualStates(); @@ -578,6 +608,11 @@ internal void UpdateFromCurrentItem() return; } + if (currentItemIndex.Row == _gotoPosition) + { + _gotoPosition = -1; + } + ScrollToPosition(currentItemIndex.Row, carousel.Position, carousel.AnimateCurrentItemChanges); UpdateVisualStates(); diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs index 16177e594ff4..a43bb3880c91 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs @@ -232,12 +232,33 @@ void InvalidateLayoutIfItemsMeasureChanged() if (invalidatedIndexPaths is not null) { + var indexPathsArray = invalidatedIndexPaths.ToArray(); + + // Workaround for layout issue observed on iPadOS 18+ with UICollectionViewCompositionalLayout + // where self-sizing cells can cause scroll position jumps during invalidation + if (ShouldApplyCellReConfiguration()) + { + // Wrap in PerformWithoutAnimation to prevent ReconfigureItems from animating + // the scroll position adjustment that would otherwise occur during the layout pass. + UIView.PerformWithoutAnimation(() => + { + // Use ReconfigureItems (iOS 15+) which is designed for size changes + // without full cell recreation - more efficient than ReloadItems + collectionView.ReconfigureItems(indexPathsArray); + }); + } + var layoutInvalidationContext = new UICollectionViewLayoutInvalidationContext(); - layoutInvalidationContext.InvalidateItems(invalidatedIndexPaths.ToArray()); + layoutInvalidationContext.InvalidateItems(indexPathsArray); collectionView.CollectionViewLayout.InvalidateLayout(layoutInvalidationContext); } } + static bool ShouldApplyCellReConfiguration() + { + return OperatingSystem.IsIOSVersionAtLeast(15); + } + private void MovedToWindow(object sender, EventArgs e) { if (CollectionView?.Window != null) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs index 3f93eb7561df..5b4f7e855caf 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs @@ -211,9 +211,13 @@ static UICollectionViewLayout CreateGridLayout(UICollectionViewScrollDirection s : NSCollectionLayoutGroup.CreateVertical(groupSize, item, columns); if (scrollDirection == UICollectionViewScrollDirection.Vertical) + { group.InterItemSpacing = NSCollectionLayoutSpacing.CreateFixed(new NFloat(horizontalItemSpacing)); + } else + { group.InterItemSpacing = NSCollectionLayoutSpacing.CreateFixed(new NFloat(verticalItemSpacing)); + } // Create our section layout var section = NSCollectionLayoutSection.Create(group: group); @@ -221,9 +225,13 @@ static UICollectionViewLayout CreateGridLayout(UICollectionViewScrollDirection s section.ContentInsetsReference = UIContentInsetsReference.None; if (scrollDirection == UICollectionViewScrollDirection.Vertical) + { section.InterGroupSpacing = new NFloat(verticalItemSpacing); + } else + { section.InterGroupSpacing = new NFloat(horizontalItemSpacing); + } section.BoundarySupplementaryItems = CreateSupplementaryItems( @@ -540,6 +548,7 @@ class CustomUICollectionViewCompositionalLayout : UICollectionViewCompositionalL ItemsLayout? _itemsLayout; LayoutGroupingInfo? _groupingInfo; LayoutHeaderFooterInfo? _headerFooterInfo; + CGSize _currentSize; public CustomUICollectionViewCompositionalLayout(LayoutSnapInfo snapInfo, LayoutGroupingInfo? groupingInfo, LayoutHeaderFooterInfo? headerFooterInfo, UICollectionViewCompositionalLayoutSectionProvider sectionProvider, UICollectionViewCompositionalLayoutConfiguration configuration, ItemsLayout? itemsLayout) : base(sectionProvider, configuration) { @@ -592,6 +601,20 @@ void ForceScrollToLastItem(UICollectionView collectionView) } } + public override bool ShouldInvalidateLayoutForBoundsChange(CGRect newBounds) + { + // If the size hasn't changed, use the base implementation + if (newBounds.Size.IsCloseTo(_currentSize)) + { + return base.ShouldInvalidateLayoutForBoundsChange(newBounds); + } + + // Size has changed (e.g., rotation), so we need to invalidate the layout + // to ensure cells are properly measured and displayed + _currentSize = newBounds.Size; + return true; + } + public override CGPoint TargetContentOffset(CGPoint proposedContentOffset, CGPoint scrollingVelocity) { var snapPointsType = _snapInfo.SnapType; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/SelectableItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/SelectableItemsViewController2.cs index d8bf2ab69cd1..3f594d7c0b00 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/SelectableItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/SelectableItemsViewController2.cs @@ -26,7 +26,7 @@ protected override UICollectionViewDelegateFlowLayout CreateDelegator() // _Only_ called if the user initiates the selection change; will not be called for programmatic selection public override void ItemSelected(UICollectionView collectionView, NSIndexPath indexPath) { - if (ItemsView?.ItemsSource is null || !ItemsView.IsExplicitlyEnabled) + if (ItemsView?.ItemsSource is null || !ItemsView.IsEnabled) { return; } @@ -36,7 +36,7 @@ public override void ItemSelected(UICollectionView collectionView, NSIndexPath i // _Only_ called if the user initiates the selection change; will not be called for programmatic selection public override void ItemDeselected(UICollectionView collectionView, NSIndexPath indexPath) { - if (ItemsView?.ItemsSource is null || !ItemsView.IsExplicitlyEnabled) + if (ItemsView?.ItemsSource is null || !ItemsView.IsEnabled) { return; } @@ -186,7 +186,7 @@ internal void UpdatePlatformSelection() internal void UpdateSelectionMode() { var mode = ItemsView.SelectionMode; - var isEnabled = ItemsView.IsExplicitlyEnabled; + var isEnabled = ItemsView.IsEnabled; switch (mode) { diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index 6d9379ead92d..1951b81f34db 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -116,9 +116,22 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin if (cachedSize != CGSize.Empty) { _measuredSize = cachedSize.ToSize(); - // Even when we have a cached measurement, we still need to call Measure - // to update the virtual view's internal state and bookkeeping - virtualView.Measure(constraints.Width, _measuredSize.Height); + // For MeasureFirstItem, non-first cells reuse the cached first-item size. + // Mirroring CV1 (Items/TemplatedCell.cs): when ConstrainedSize is set, CV1 + // calls NO virtualView.Measure() from PreferredLayoutAttributesFittingAttributes. + // We do the same — only call Measure when the layout constraints actually change + // (fresh cell: _cachedConstraints==default, or rotation/resize: new width/height). + // Checking the full Size (both width and height) handles both orientations: + // - Vertical list: constraints=(w, ∞) — width change drives the check. + // - Horizontal list: constraints=(∞, h) — height change drives the check. + // Recycled cells at the same constraints skip Measure entirely, eliminating + // the per-scroll MeasureOverride invocation that PR #29496 introduced. + // The Measure return value is intentionally discarded; _measuredSize comes + // from the cache, not from this call. + if (_cachedConstraints != constraints) + { + virtualView.Measure(constraints.Width, constraints.Height); + } } else { diff --git a/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Windows.cs b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Windows.cs index fe2242915904..0686d710efa2 100644 --- a/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Windows.cs @@ -204,8 +204,28 @@ private void OnNavigationTabChanged(NavigationView sender, NavigationViewSelecti if (currentItem?.Title != shellContent.Title && currentItem != shellContent.Parent) { (parentShell.CurrentItem as IShellItemController)?.ProposeSection(shellContent); + parentShell.CurrentItem = shellContent; + } + else if (shellContent.Parent is ShellSection existingSection && existingSection.CurrentItem != shellContent && existingSection.IsVisibleSection) + { + // Fire OnNavigatingFrom before CurrentItem changes, so it captures the correct outgoing page. + parentShell.NavigationManager.ProposeNavigationOutsideGotoAsync( + ShellNavigationSource.ShellContentChanged, + parentShell.CurrentItem, + existingSection, + shellContent, + existingSection.Stack, + canCancel: false, + isAnimated: true); + + // Set ShellSection.CurrentItem directly (using SetValueFromRenderer) to avoid + // re-triggering the mistimed-navigation bug via CreateFromShellContent. + existingSection.SetValueFromRenderer(ShellSection.CurrentItemProperty, shellContent); + } + else + { + parentShell.CurrentItem = shellContent; } - parentShell.CurrentItem = shellContent; } } @@ -344,7 +364,7 @@ void UpdateSearchHandler() autoSuggestBox.UpdateSearchHandlerBackground(_currentSearchHandler); autoSuggestBox.UpdateSearchHandlerVerticalTextAlignment(_currentSearchHandler); autoSuggestBox.UpdateSearchHandlerHorizontalTextAlignment(_currentSearchHandler); - + _currentSearchHandler.PropertyChanged += OnCurrentSearchHandlerPropertyChanged; autoSuggestBox.Visibility = _currentSearchHandler.SearchBoxVisibility == SearchBoxVisibility.Hidden ? Microsoft.UI.Xaml.Visibility.Collapsed : Microsoft.UI.Xaml.Visibility.Visible; @@ -543,6 +563,12 @@ void OnCurrentSearchHandlerPropertyChanged(object? sender, PropertyChangedEventA case nameof(SearchHandler.VerticalTextAlignment): autoSuggestBox.UpdateSearchHandlerVerticalTextAlignment(_currentSearchHandler); break; + case nameof(SearchHandler.QueryIcon): + UpdateQueryIcon(); + break; + // TODO: ClearIcon and ClearPlaceholderIcon are not supported on Windows + // (AutoSuggestBox has no built-in clear/placeholder icon API). + // Tracked in: https://github.com/dotnet/maui/issues/28619 } } diff --git a/src/Controls/src/Core/Handlers/Shell/Tizen/ShellContentItemView.cs b/src/Controls/src/Core/Handlers/Shell/Tizen/ShellContentItemView.cs index 9141ef4ca5fe..bca023fa734d 100644 --- a/src/Controls/src/Core/Handlers/Shell/Tizen/ShellContentItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Tizen/ShellContentItemView.cs @@ -8,7 +8,7 @@ namespace Microsoft.Maui.Controls.Platform class ShellContentItemView : Frame #pragma warning restore CS0618 // Type or member is obsolete { - static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(ShellContentItemView), false, propertyChanged: (b, o, n) => ((ShellContentItemView)b).UpdateViewColors()); + static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(ShellContentItemView), BooleanBoxes.FalseBox, propertyChanged: (b, o, n) => ((ShellContentItemView)b).UpdateViewColors()); internal static readonly BindableProperty SelectedTextColorProperty = BindableProperty.Create(nameof(SelectedTextColor), typeof(GColor), typeof(ShellContentItemView), null, propertyChanged: (b, o, n) => ((ShellContentItemView)b).UpdateViewColors()); internal static readonly BindableProperty SelectedBarColorProperty = BindableProperty.Create(nameof(SelectedBarColor), typeof(GColor), typeof(ShellContentItemView), null, propertyChanged: (b, o, n) => ((ShellContentItemView)b).UpdateViewColors()); internal static readonly BindableProperty UnselectedColorProperty = BindableProperty.Create(nameof(UnselectedColor), typeof(GColor), typeof(ShellContentItemView), null, propertyChanged: (b, o, n) => ((ShellContentItemView)b).UpdateViewColors()); @@ -19,7 +19,7 @@ class ShellContentItemView : Frame public bool IsSelected { get => (bool)GetValue(SelectedStateProperty); - set => SetValue(SelectedStateProperty, value); + set => SetValue(SelectedStateProperty, BooleanBoxes.Box(value)); } diff --git a/src/Controls/src/Core/Handlers/Shell/Tizen/ShellFlyoutItemView.cs b/src/Controls/src/Core/Handlers/Shell/Tizen/ShellFlyoutItemView.cs index cb068265dfd4..35765a09d399 100644 --- a/src/Controls/src/Core/Handlers/Shell/Tizen/ShellFlyoutItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Tizen/ShellFlyoutItemView.cs @@ -7,14 +7,14 @@ namespace Microsoft.Maui.Controls.Platform class ShellFlyoutItemView : Frame #pragma warning restore CS0618 // Type or member is obsolete { - static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(ShellFlyoutItemView), false, propertyChanged: (b, o, n) => ((ShellFlyoutItemView)b).UpdateSelectedState()); + static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(ShellFlyoutItemView), BooleanBoxes.FalseBox, propertyChanged: (b, o, n) => ((ShellFlyoutItemView)b).UpdateSelectedState()); Grid _grid; public bool IsSelected { get => (bool)GetValue(SelectedStateProperty); - set => SetValue(SelectedStateProperty, value); + set => SetValue(SelectedStateProperty, BooleanBoxes.Box(value)); } #pragma warning disable CS8618 diff --git a/src/Controls/src/Core/Handlers/Shell/Tizen/ShellSectionItemView.cs b/src/Controls/src/Core/Handlers/Shell/Tizen/ShellSectionItemView.cs index ec33c2b5d160..354b15824201 100644 --- a/src/Controls/src/Core/Handlers/Shell/Tizen/ShellSectionItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Tizen/ShellSectionItemView.cs @@ -9,7 +9,7 @@ namespace Microsoft.Maui.Controls.Platform class ShellSectionItemView : Frame #pragma warning restore CS0618 // Type or member is obsolete { - static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(ShellSectionItemView), false, propertyChanged: (b, o, n) => ((ShellSectionItemView)b).UpdateViewColors()); + static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(ShellSectionItemView), BooleanBoxes.FalseBox, propertyChanged: (b, o, n) => ((ShellSectionItemView)b).UpdateViewColors()); internal static readonly BindableProperty SelectedColorProperty = BindableProperty.Create(nameof(SelectedColor), typeof(GColor), typeof(ShellSectionItemView), null, propertyChanged: (b, o, n) => ((ShellSectionItemView)b).UpdateViewColors()); internal static readonly BindableProperty UnselectedColorProperty = BindableProperty.Create(nameof(UnselectedColor), typeof(GColor), typeof(ShellSectionItemView), null, propertyChanged: (b, o, n) => ((ShellSectionItemView)b).UpdateViewColors()); @@ -20,7 +20,7 @@ class ShellSectionItemView : Frame public bool IsSelected { get => (bool)GetValue(SelectedStateProperty); - set => SetValue(SelectedStateProperty, value); + set => SetValue(SelectedStateProperty, BooleanBoxes.Box(value)); } public GColor SelectedColor diff --git a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs index 57146e2f9c62..2aaf13c4c7b6 100644 --- a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs @@ -1,5 +1,6 @@ #nullable disable using System.ComponentModel; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -47,6 +48,9 @@ void OnDataContextChanged(Microsoft.UI.Xaml.FrameworkElement sender, Microsoft.U else _shell?.RemoveLogicalChild(_content); + // Remove resource listener from previous content + if (_content is IElementDefinition contentDef) + contentDef.RemoveResourcesChangedListener(OnResourcesChanged); _content.Cleanup(); _content.BindingContext = null; _content.Parent = null; @@ -79,7 +83,9 @@ void OnDataContextChanged(Microsoft.UI.Xaml.FrameworkElement sender, Microsoft.U else _shell.AddLogicalChild(_content); - + // Listen for resource changes to re-apply visual state when DynamicResources change at runtime + if (_content is IElementDefinition contentDef) + contentDef.AddResourcesChangedListener(OnResourcesChanged); var platformView = _content.ToPlatform(_shell.Handler.MauiContext); Content = platformView; @@ -142,12 +148,17 @@ void ShellElementPropertyChanged(object sender, PropertyChangedEventArgs e) void UpdateVisualState() { - if (_content?.BindingContext is BaseShellItem baseShellItem && baseShellItem != null) + if (_content?.BindingContext is BaseShellItem baseShellItem) { - _content.IsItemSelected = baseShellItem.IsChecked; + VisualStateManager.GoToState(_content, baseShellItem.IsChecked ? "Selected" : "Normal", force: true); } } + void OnResourcesChanged(object sender, ResourcesChangedEventArgs e) + { + UpdateVisualState(); + } + static void IsSelectedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((ShellFlyoutItemView)d).UpdateVisualState(); diff --git a/src/Controls/src/Core/IndicatorView/IndicatorView.cs b/src/Controls/src/Core/IndicatorView/IndicatorView.cs index 961d99bb11d3..b70df42c2bc9 100644 --- a/src/Controls/src/Core/IndicatorView/IndicatorView.cs +++ b/src/Controls/src/Core/IndicatorView/IndicatorView.cs @@ -43,7 +43,7 @@ public partial class IndicatorView : TemplatedView, ITemplatedIndicatorView => UpdateIndicatorLayout((IndicatorView)bindable, newValue)); /// Bindable property for . - public static readonly BindableProperty HideSingleProperty = BindableProperty.Create(nameof(HideSingle), typeof(bool), typeof(IndicatorView), true); + public static readonly BindableProperty HideSingleProperty = BindableProperty.Create(nameof(HideSingle), typeof(bool), typeof(IndicatorView), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty IndicatorColorProperty = BindableProperty.Create(nameof(IndicatorColor), typeof(Color), typeof(IndicatorView), Colors.LightGrey); @@ -155,7 +155,7 @@ public DataTemplate IndicatorTemplate public bool HideSingle { get => (bool)GetValue(HideSingleProperty); - set => SetValue(HideSingleProperty, value); + set => SetValue(HideSingleProperty, BooleanBoxes.Box(value)); } /// diff --git a/src/Controls/src/Core/InputView/InputView.cs b/src/Controls/src/Core/InputView/InputView.cs index 51cd1ff9c711..e311bcb44658 100644 --- a/src/Controls/src/Core/InputView/InputView.cs +++ b/src/Controls/src/Core/InputView/InputView.cs @@ -22,16 +22,16 @@ public partial class InputView : View, IPlaceholderElement, ITextElement, ITextI coerceValue: (o, v) => (Keyboard)v ?? Keyboard.Default); /// Bindable property for . - public static readonly BindableProperty IsSpellCheckEnabledProperty = BindableProperty.Create(nameof(IsSpellCheckEnabled), typeof(bool), typeof(InputView), true); + public static readonly BindableProperty IsSpellCheckEnabledProperty = BindableProperty.Create(nameof(IsSpellCheckEnabled), typeof(bool), typeof(InputView), BooleanBoxes.TrueBox); /// Bindable property for . - public static readonly BindableProperty IsTextPredictionEnabledProperty = BindableProperty.Create(nameof(IsTextPredictionEnabled), typeof(bool), typeof(InputView), true); + public static readonly BindableProperty IsTextPredictionEnabledProperty = BindableProperty.Create(nameof(IsTextPredictionEnabled), typeof(bool), typeof(InputView), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty MaxLengthProperty = BindableProperty.Create(nameof(MaxLength), typeof(int), typeof(InputView), int.MaxValue); /// Bindable property for . - public static readonly BindableProperty IsReadOnlyProperty = BindableProperty.Create(nameof(IsReadOnly), typeof(bool), typeof(InputView), false); + public static readonly BindableProperty IsReadOnlyProperty = BindableProperty.Create(nameof(IsReadOnly), typeof(bool), typeof(InputView), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty PlaceholderProperty = PlaceholderElement.PlaceholderProperty; @@ -121,7 +121,7 @@ public Keyboard Keyboard public bool IsSpellCheckEnabled { get => (bool)GetValue(IsSpellCheckEnabledProperty); - set => SetValue(IsSpellCheckEnabledProperty, value); + set => SetValue(IsSpellCheckEnabledProperty, BooleanBoxes.Box(value)); } /// Gets or sets a value that controls whether text prediction and automatic text correction are enabled. @@ -130,7 +130,7 @@ public bool IsSpellCheckEnabled public bool IsTextPredictionEnabled { get => (bool)GetValue(IsTextPredictionEnabledProperty); - set => SetValue(IsTextPredictionEnabledProperty, value); + set => SetValue(IsTextPredictionEnabledProperty, BooleanBoxes.Box(value)); } /// Gets or sets a value indicating whether the user can edit text in this input view. This is a bindable property. @@ -138,7 +138,7 @@ public bool IsTextPredictionEnabled public bool IsReadOnly { get => (bool)GetValue(IsReadOnlyProperty); - set => SetValue(IsReadOnlyProperty, value); + set => SetValue(IsReadOnlyProperty, BooleanBoxes.Box(value)); } /// Gets or sets the placeholder text shown when the input view is empty. This is a bindable property. @@ -271,7 +271,7 @@ public double FontSize public bool FontAutoScalingEnabled { get => (bool)GetValue(FontAutoScalingEnabledProperty); - set => SetValue(FontAutoScalingEnabledProperty, value); + set => SetValue(FontAutoScalingEnabledProperty, BooleanBoxes.Box(value)); } double IFontElement.FontSizeDefaultValueCreator() => diff --git a/src/Controls/src/Core/Interactivity/MultiCondition.cs b/src/Controls/src/Core/Interactivity/MultiCondition.cs index 6a32090813b3..7075526c8989 100644 --- a/src/Controls/src/Core/Interactivity/MultiCondition.cs +++ b/src/Controls/src/Core/Interactivity/MultiCondition.cs @@ -1,5 +1,6 @@ #nullable disable using System.Collections.Generic; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -9,7 +10,7 @@ internal sealed class MultiCondition : Condition public MultiCondition() { - _aggregatedStateProperty = BindableProperty.CreateAttached("AggregatedState", typeof(bool), typeof(MultiCondition), false, propertyChanged: OnAggregatedStatePropertyChanged); + _aggregatedStateProperty = BindableProperty.CreateAttached("AggregatedState", typeof(bool), typeof(MultiCondition), BooleanBoxes.FalseBox, propertyChanged: OnAggregatedStatePropertyChanged); Conditions = new TriggerBase.SealedList(); } diff --git a/src/Controls/src/Core/Interactivity/PropertyCondition.cs b/src/Controls/src/Core/Interactivity/PropertyCondition.cs index ab459d04749b..d386bc788313 100644 --- a/src/Controls/src/Core/Interactivity/PropertyCondition.cs +++ b/src/Controls/src/Core/Interactivity/PropertyCondition.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Reflection; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Xaml; namespace Microsoft.Maui.Controls @@ -21,7 +22,7 @@ public sealed class PropertyCondition : Condition /// public PropertyCondition() { - _stateProperty = BindableProperty.CreateAttached("State", typeof(bool), typeof(PropertyCondition), false, propertyChanged: OnStatePropertyChanged); + _stateProperty = BindableProperty.CreateAttached("State", typeof(bool), typeof(PropertyCondition), BooleanBoxes.FalseBox, propertyChanged: OnStatePropertyChanged); } /// diff --git a/src/Controls/src/Core/Internals/BooleanBoxes.cs b/src/Controls/src/Core/Internals/BooleanBoxes.cs new file mode 100644 index 000000000000..8c69be39721d --- /dev/null +++ b/src/Controls/src/Core/Internals/BooleanBoxes.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Maui.Controls.Internals; + +internal static class BooleanBoxes +{ + internal static readonly object TrueBox = true; + internal static readonly object FalseBox = false; + + internal static object Box(bool value) => + value ? TrueBox : FalseBox; + + [return: NotNullIfNotNull(nameof(value))] + internal static object? Box(bool? value) => + value switch + { + true => TrueBox, + false => FalseBox, + null => null, + }; +} diff --git a/src/Controls/src/Core/Internals/WeakEventProxy.cs b/src/Controls/src/Core/Internals/WeakEventProxy.cs index 9a793d096b4c..28030b777420 100644 --- a/src/Controls/src/Core/Internals/WeakEventProxy.cs +++ b/src/Controls/src/Core/Internals/WeakEventProxy.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Specialized; using System.ComponentModel; +using Microsoft.Maui.Controls.Shapes; // NOTE: warning disabled for netstandard projects #pragma warning disable 0436 @@ -199,4 +200,62 @@ public override void Unsubscribe() base.Unsubscribe(); } } + + /// + /// A "proxy" class for subscribing Geometry and PathGeometry invalidation via WeakReference. + /// General usage is to store this in a member variable and call Subscribe()/Unsubscribe() appropriately. + /// Your class should have a finalizer that calls Unsubscribe() to prevent WeakGeometryChangedProxy objects from leaking. + /// + class WeakGeometryChangedProxy : WeakEventProxy + { + public WeakGeometryChangedProxy() { } + + public WeakGeometryChangedProxy(Geometry source, EventHandler handler) + { + Subscribe(source, handler); + } + + void OnGeometryChanged(object? sender, EventArgs e) + { + if (TryGetHandler(out var handler)) + { + handler(sender, e); + } + else + { + Unsubscribe(); + } + } + + public override void Subscribe(Geometry source, EventHandler handler) + { + if (TryGetSource(out var s)) + { + s.PropertyChanged -= OnGeometryChanged; + + if (s is PathGeometry oldPathGeometry) + oldPathGeometry.InvalidatePathGeometryRequested -= OnGeometryChanged; + } + + source.PropertyChanged += OnGeometryChanged; + + if (source is PathGeometry pathGeometry) + pathGeometry.InvalidatePathGeometryRequested += OnGeometryChanged; + + base.Subscribe(source, handler); + } + + public override void Unsubscribe() + { + if (TryGetSource(out var s)) + { + s.PropertyChanged -= OnGeometryChanged; + + if (s is PathGeometry pathGeometry) + pathGeometry.InvalidatePathGeometryRequested -= OnGeometryChanged; + } + + base.Unsubscribe(); + } + } } diff --git a/src/Controls/src/Core/Items/CarouselView.cs b/src/Controls/src/Core/Items/CarouselView.cs index 2039736d9304..e2f3d64eb7c8 100644 --- a/src/Controls/src/Core/Items/CarouselView.cs +++ b/src/Controls/src/Core/Items/CarouselView.cs @@ -47,7 +47,7 @@ public class CarouselView : ItemsView public const string DefaultItemVisualState = "DefaultItem"; /// Bindable property for . - public static readonly BindableProperty LoopProperty = BindableProperty.Create(nameof(Loop), typeof(bool), typeof(CarouselView), true, BindingMode.OneTime); + public static readonly BindableProperty LoopProperty = BindableProperty.Create(nameof(Loop), typeof(bool), typeof(CarouselView), BooleanBoxes.TrueBox, BindingMode.OneTime); /// /// Gets or sets a value indicating whether the carousel loops back to the first item after reaching the last item. @@ -59,7 +59,7 @@ public class CarouselView : ItemsView public bool Loop { get { return (bool)GetValue(LoopProperty); } - set { SetValue(LoopProperty, value); } + set { SetValue(LoopProperty, BooleanBoxes.Box(value)); } } /// Bindable property for . @@ -111,7 +111,7 @@ public Thickness PeekAreaInsets /// Bindable property for . public static readonly BindableProperty IsBounceEnabledProperty = - BindableProperty.Create(nameof(IsBounceEnabled), typeof(bool), typeof(CarouselView), true); + BindableProperty.Create(nameof(IsBounceEnabled), typeof(bool), typeof(CarouselView), BooleanBoxes.TrueBox); /// /// Gets or sets a value indicating whether bounce effects are enabled when scrolling reaches the end of the carousel. @@ -124,12 +124,12 @@ public Thickness PeekAreaInsets public bool IsBounceEnabled { get { return (bool)GetValue(IsBounceEnabledProperty); } - set { SetValue(IsBounceEnabledProperty, value); } + set { SetValue(IsBounceEnabledProperty, BooleanBoxes.Box(value)); } } /// Bindable property for . public static readonly BindableProperty IsSwipeEnabledProperty = - BindableProperty.Create(nameof(IsSwipeEnabled), typeof(bool), typeof(CarouselView), true); + BindableProperty.Create(nameof(IsSwipeEnabled), typeof(bool), typeof(CarouselView), BooleanBoxes.TrueBox); /// /// Gets or sets a value indicating whether swipe gestures are enabled for navigation. @@ -142,12 +142,12 @@ public bool IsBounceEnabled public bool IsSwipeEnabled { get { return (bool)GetValue(IsSwipeEnabledProperty); } - set { SetValue(IsSwipeEnabledProperty, value); } + set { SetValue(IsSwipeEnabledProperty, BooleanBoxes.Box(value)); } } /// Bindable property for . public static readonly BindableProperty IsScrollAnimatedProperty = - BindableProperty.Create(nameof(IsScrollAnimated), typeof(bool), typeof(CarouselView), true); + BindableProperty.Create(nameof(IsScrollAnimated), typeof(bool), typeof(CarouselView), BooleanBoxes.TrueBox); /// /// Gets or sets a value indicating whether scrolling between items is animated. @@ -160,7 +160,7 @@ public bool IsSwipeEnabled public bool IsScrollAnimated { get { return (bool)GetValue(IsScrollAnimatedProperty); } - set { SetValue(IsScrollAnimatedProperty, value); } + set { SetValue(IsScrollAnimatedProperty, BooleanBoxes.Box(value)); } } /// Bindable property for . diff --git a/src/Controls/src/Core/Items/GroupableItemsView.cs b/src/Controls/src/Core/Items/GroupableItemsView.cs index 941b4052069a..d96fa6a7d227 100644 --- a/src/Controls/src/Core/Items/GroupableItemsView.cs +++ b/src/Controls/src/Core/Items/GroupableItemsView.cs @@ -1,4 +1,5 @@ #nullable disable +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { /// @@ -13,7 +14,7 @@ public class GroupableItemsView : SelectableItemsView { /// Bindable property for . public static readonly BindableProperty IsGroupedProperty = - BindableProperty.Create(nameof(IsGrouped), typeof(bool), typeof(GroupableItemsView), false); + BindableProperty.Create(nameof(IsGrouped), typeof(bool), typeof(GroupableItemsView), BooleanBoxes.FalseBox); /// /// Gets or sets a value indicating whether items should be displayed in groups. @@ -26,7 +27,7 @@ public class GroupableItemsView : SelectableItemsView public bool IsGrouped { get => (bool)GetValue(IsGroupedProperty); - set => SetValue(IsGroupedProperty, value); + set => SetValue(IsGroupedProperty, BooleanBoxes.Box(value)); } /// Bindable property for . diff --git a/src/Controls/src/Core/Items/ReorderableItemsView.cs b/src/Controls/src/Core/Items/ReorderableItemsView.cs index 7f3c63454cd2..a263427f4d04 100644 --- a/src/Controls/src/Core/Items/ReorderableItemsView.cs +++ b/src/Controls/src/Core/Items/ReorderableItemsView.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -24,7 +25,7 @@ public class ReorderableItemsView : GroupableItemsView public event EventHandler ReorderCompleted; /// Bindable property for . - public static readonly BindableProperty CanMixGroupsProperty = BindableProperty.Create(nameof(CanMixGroups), typeof(bool), typeof(ReorderableItemsView), false); + public static readonly BindableProperty CanMixGroupsProperty = BindableProperty.Create(nameof(CanMixGroups), typeof(bool), typeof(ReorderableItemsView), BooleanBoxes.FalseBox); /// /// Gets or sets a value indicating whether items from different groups can be mixed together during reordering. @@ -38,11 +39,11 @@ public class ReorderableItemsView : GroupableItemsView public bool CanMixGroups { get { return (bool)GetValue(CanMixGroupsProperty); } - set { SetValue(CanMixGroupsProperty, value); } + set { SetValue(CanMixGroupsProperty, BooleanBoxes.Box(value)); } } /// Bindable property for . - public static readonly BindableProperty CanReorderItemsProperty = BindableProperty.Create(nameof(CanReorderItems), typeof(bool), typeof(ReorderableItemsView), false); + public static readonly BindableProperty CanReorderItemsProperty = BindableProperty.Create(nameof(CanReorderItems), typeof(bool), typeof(ReorderableItemsView), BooleanBoxes.FalseBox); /// /// Gets or sets a value indicating whether items in the collection can be reordered by the user. @@ -56,7 +57,7 @@ public bool CanMixGroups public bool CanReorderItems { get { return (bool)GetValue(CanReorderItemsProperty); } - set { SetValue(CanReorderItemsProperty, value); } + set { SetValue(CanReorderItemsProperty, BooleanBoxes.Box(value)); } } [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/src/Controls/src/Core/Layout/Layout.cs b/src/Controls/src/Core/Layout/Layout.cs index 7f7d63261256..df6c45237cdf 100644 --- a/src/Controls/src/Core/Layout/Layout.cs +++ b/src/Controls/src/Core/Layout/Layout.cs @@ -6,6 +6,7 @@ using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Xaml.Diagnostics; using Microsoft.Maui.Graphics; using Microsoft.Maui.Layouts; @@ -86,7 +87,7 @@ public IView this[int index] /// Bindable property for . public static readonly BindableProperty IsClippedToBoundsProperty = - BindableProperty.Create(nameof(IsClippedToBounds), typeof(bool), typeof(Layout), false, + BindableProperty.Create(nameof(IsClippedToBounds), typeof(bool), typeof(Layout), BooleanBoxes.FalseBox, propertyChanged: IsClippedToBoundsPropertyChanged); /// @@ -96,7 +97,7 @@ public IView this[int index] public bool IsClippedToBounds { get => (bool)GetValue(IsClippedToBoundsProperty); - set => SetValue(IsClippedToBoundsProperty, value); + set => SetValue(IsClippedToBoundsProperty, BooleanBoxes.Box(value)); } static void IsClippedToBoundsPropertyChanged(BindableObject bindableObject, object oldValue, object newValue) @@ -399,7 +400,7 @@ public Graphics.Size CrossPlatformArrange(Graphics.Rect bounds) public bool CascadeInputTransparent { get => (bool)GetValue(CascadeInputTransparentProperty); - set => SetValue(CascadeInputTransparentProperty, value); + set => SetValue(CascadeInputTransparentProperty, BooleanBoxes.Box(value)); } private protected override string GetDebuggerDisplay() diff --git a/src/Controls/src/Core/ListView/ListView.cs b/src/Controls/src/Core/ListView/ListView.cs index f2b076d5802d..9521cc50dc75 100644 --- a/src/Controls/src/Core/ListView/ListView.cs +++ b/src/Controls/src/Core/ListView/ListView.cs @@ -26,10 +26,10 @@ public class ListView : ItemsView, IListViewController, IElementConfigurat IReadOnlyList IVisualTreeElement.GetVisualChildren() => _visualChildren; /// Bindable property for . - public static readonly BindableProperty IsPullToRefreshEnabledProperty = BindableProperty.Create(nameof(IsPullToRefreshEnabled), typeof(bool), typeof(ListView), false); + public static readonly BindableProperty IsPullToRefreshEnabledProperty = BindableProperty.Create(nameof(IsPullToRefreshEnabled), typeof(bool), typeof(ListView), BooleanBoxes.FalseBox); /// Bindable property for . - public static readonly BindableProperty IsRefreshingProperty = BindableProperty.Create(nameof(IsRefreshing), typeof(bool), typeof(ListView), false, BindingMode.TwoWay); + public static readonly BindableProperty IsRefreshingProperty = BindableProperty.Create(nameof(IsRefreshing), typeof(bool), typeof(ListView), BooleanBoxes.FalseBox, BindingMode.TwoWay); /// Bindable property for . public static readonly BindableProperty RefreshCommandProperty = BindableProperty.Create(nameof(RefreshCommand), typeof(ICommand), typeof(ListView), null, propertyChanged: OnRefreshCommandChanged); @@ -56,7 +56,7 @@ public class ListView : ItemsView, IListViewController, IElementConfigurat public static readonly BindableProperty SelectionModeProperty = BindableProperty.Create(nameof(SelectionMode), typeof(ListViewSelectionMode), typeof(ListView), ListViewSelectionMode.Single); /// Bindable property for . - public static readonly BindableProperty HasUnevenRowsProperty = BindableProperty.Create(nameof(HasUnevenRows), typeof(bool), typeof(ListView), false); + public static readonly BindableProperty HasUnevenRowsProperty = BindableProperty.Create(nameof(HasUnevenRows), typeof(bool), typeof(ListView), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty RowHeightProperty = BindableProperty.Create(nameof(RowHeight), typeof(int), typeof(ListView), -1); @@ -66,7 +66,7 @@ public class ListView : ItemsView, IListViewController, IElementConfigurat propertyChanged: OnGroupHeaderTemplateChanged); /// Bindable property for . - public static readonly BindableProperty IsGroupingEnabledProperty = BindableProperty.Create(nameof(IsGroupingEnabled), typeof(bool), typeof(ListView), false); + public static readonly BindableProperty IsGroupingEnabledProperty = BindableProperty.Create(nameof(IsGroupingEnabled), typeof(bool), typeof(ListView), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty SeparatorVisibilityProperty = BindableProperty.Create(nameof(SeparatorVisibility), typeof(SeparatorVisibility), typeof(ListView), SeparatorVisibility.Default); @@ -209,7 +209,7 @@ public BindingBase GroupShortNameBinding public bool HasUnevenRows { get { return (bool)GetValue(HasUnevenRowsProperty); } - set { SetValue(HasUnevenRowsProperty, value); } + set { SetValue(HasUnevenRowsProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the string, binding, or view that will be displayed at the top of the list view. This is a bindable property. @@ -230,21 +230,21 @@ public DataTemplate HeaderTemplate public bool IsGroupingEnabled { get { return (bool)GetValue(IsGroupingEnabledProperty); } - set { SetValue(IsGroupingEnabledProperty, value); } + set { SetValue(IsGroupingEnabledProperty, BooleanBoxes.Box(value)); } } /// Gets or sets a value that tells whether the user can swipe down to cause the application to refresh. This is a bindable property. public bool IsPullToRefreshEnabled { get { return (bool)GetValue(IsPullToRefreshEnabledProperty); } - set { SetValue(IsPullToRefreshEnabledProperty, value); } + set { SetValue(IsPullToRefreshEnabledProperty, BooleanBoxes.Box(value)); } } /// Gets or sets a value that tells whether the list view is currently refreshing. This is a bindable property. public bool IsRefreshing { get { return (bool)GetValue(IsRefreshingProperty); } - set { SetValue(IsRefreshingProperty, value); } + set { SetValue(IsRefreshingProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the command that is run when the list view enters the refreshing state. This is a bindable property. @@ -385,7 +385,7 @@ public void BeginRefresh() if (!RefreshAllowed) return; - SetValue(IsRefreshingProperty, true, SetterSpecificity.FromHandler); + SetValue(IsRefreshingProperty, BooleanBoxes.TrueBox, SetterSpecificity.FromHandler); OnRefreshing(EventArgs.Empty); ICommand command = RefreshCommand; @@ -395,7 +395,7 @@ public void BeginRefresh() /// Exits the refreshing state by setting the property to . public void EndRefresh() { - SetValue(IsRefreshingProperty, false, SetterSpecificity.FromHandler); + SetValue(IsRefreshingProperty, BooleanBoxes.FalseBox, SetterSpecificity.FromHandler); } public event EventHandler ItemAppearing; diff --git a/src/Controls/src/Core/Menu/MenuBar.cs b/src/Controls/src/Core/Menu/MenuBar.cs index efea6758ea8e..d589c6dea833 100644 --- a/src/Controls/src/Core/Menu/MenuBar.cs +++ b/src/Controls/src/Core/Menu/MenuBar.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -10,12 +11,12 @@ public partial class MenuBar : Element, IMenuBar { /// Bindable property for . public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create(nameof(IsEnabled), typeof(bool), - typeof(MenuBar), true); + typeof(MenuBar), BooleanBoxes.TrueBox); public bool IsEnabled { get { return (bool)GetValue(IsEnabledProperty); } - set { SetValue(IsEnabledProperty, value); } + set { SetValue(IsEnabledProperty, BooleanBoxes.Box(value)); } } readonly ObservableCollection _menus = new ObservableCollection(); diff --git a/src/Controls/src/Core/Menu/MenuBarItem.cs b/src/Controls/src/Core/Menu/MenuBarItem.cs index 79fbf10b92a6..33f3396ed31d 100644 --- a/src/Controls/src/Core/Menu/MenuBarItem.cs +++ b/src/Controls/src/Core/Menu/MenuBarItem.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -14,7 +15,7 @@ public partial class MenuBarItem : BaseMenuItem, IMenuBarItem /// Bindable property for . public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create(nameof(IsEnabled), typeof(bool), - typeof(MenuBarItem), true); + typeof(MenuBarItem), BooleanBoxes.TrueBox); static readonly BindableProperty PriorityProperty = BindableProperty.Create(nameof(Priority), typeof(int), typeof(ToolbarItem), 0); public MenuBarItem() @@ -31,7 +32,7 @@ public int Priority public bool IsEnabled { get { return (bool)GetValue(IsEnabledProperty); } - set { SetValue(IsEnabledProperty, value); } + set { SetValue(IsEnabledProperty, BooleanBoxes.Box(value)); } } public string Text diff --git a/src/Controls/src/Core/Menu/MenuItem.cs b/src/Controls/src/Core/Menu/MenuItem.cs index 77b338b319d4..db968e547be0 100644 --- a/src/Controls/src/Core/Menu/MenuItem.cs +++ b/src/Controls/src/Core/Menu/MenuItem.cs @@ -34,7 +34,7 @@ static MenuItem() } /// Bindable property for . - public static readonly BindableProperty IsDestructiveProperty = BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(MenuItem), false); + public static readonly BindableProperty IsDestructiveProperty = BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(MenuItem), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty IconImageSourceProperty = BindableProperty.Create(nameof(IconImageSource), typeof(ImageSource), typeof(MenuItem), default(ImageSource), @@ -47,7 +47,7 @@ static MenuItem() /// Bindable property for . public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create( - nameof(IsEnabled), typeof(bool), typeof(MenuItem), true, + nameof(IsEnabled), typeof(bool), typeof(MenuItem), BooleanBoxes.TrueBox, propertyChanged: OnIsEnabledPropertyChanged, coerceValue: CoerceIsEnabledProperty); /// Bindable property for . @@ -86,7 +86,7 @@ public ImageSource IconImageSource public bool IsDestructive { get => (bool)GetValue(IsDestructiveProperty); - set => SetValue(IsDestructiveProperty, value); + set => SetValue(IsDestructiveProperty, BooleanBoxes.Box(value)); } /// The text of the menu item. This is a bindable property. @@ -100,7 +100,7 @@ public string Text public bool IsEnabled { get => (bool)GetValue(IsEnabledProperty); - set => SetValue(IsEnabledProperty, value); + set => SetValue(IsEnabledProperty, BooleanBoxes.Box(value)); } public event EventHandler Clicked; @@ -122,7 +122,7 @@ static object CoerceIsEnabledProperty(BindableObject bindable, object value) { if (bindable is not MenuItem menuItem) { - return false; + return BooleanBoxes.FalseBox; } menuItem._isEnabledExplicit = (bool)value; @@ -130,23 +130,23 @@ static object CoerceIsEnabledProperty(BindableObject bindable, object value) if (!menuItem._isEnabledExplicit) { // No need to check GetCanExecute or the Parent's state - return false; + return BooleanBoxes.FalseBox; } var canExecute = CommandElement.GetCanExecute(menuItem, CommandProperty); if (!canExecute) { - return false; + return BooleanBoxes.FalseBox; } // IsEnabled is not explicitly set to false, and the command can be // executed. The only thing left to verify is Parent.IsEnabled if (menuItem.Parent is MenuItem parentMenuItem && !parentMenuItem.IsEnabled) { - return false; + return BooleanBoxes.FalseBox; } - return true; + return BooleanBoxes.TrueBox; } IImageSource IImageSourcePart.Source => this.IconImageSource; diff --git a/src/Controls/src/Core/NavigationPage/NavigationPage.Legacy.cs b/src/Controls/src/Core/NavigationPage/NavigationPage.Legacy.cs index cbdb009baea8..1e4f319a2f15 100644 --- a/src/Controls/src/Core/NavigationPage/NavigationPage.Legacy.cs +++ b/src/Controls/src/Core/NavigationPage/NavigationPage.Legacy.cs @@ -25,7 +25,8 @@ async Task PopAsyncInner( var page = (Page)InternalChildren.Last(); var previousPage = CurrentPage; - SendNavigating(NavigationType.Pop, previousPage); + var destinationPage = (Page)InternalChildren[InternalChildren.Count - 2]; + SendNavigating(NavigationType.Pop, previousPage, destinationPage); var removedPage = await RemoveAsyncInner(page, animated, fast); SendNavigated(previousPage, NavigationType.Pop); return removedPage; @@ -153,7 +154,7 @@ async Task PopToRootAsyncInner(bool animated) return; var previousPage = CurrentPage; - SendNavigating(NavigationType.PopToRoot, previousPage); + SendNavigating(NavigationType.PopToRoot, previousPage, RootPage); FireDisappearing(CurrentPage); FireAppearing((Page)InternalChildren[0]); @@ -187,8 +188,8 @@ async Task PushAsyncInner(Page page, bool animated) var previousPage = CurrentPage; var navigationType = DetermineNavigationType(); - - SendNavigating(navigationType, previousPage); + + SendNavigating(navigationType, previousPage, page); FireDisappearing(CurrentPage); FireAppearing(page); @@ -203,8 +204,8 @@ async Task PushAsyncInner(Page page, bool animated) if (args.Task != null) await args.Task; - } - + } + SendNavigated(previousPage, navigationType); Pushed?.Invoke(this, args); } diff --git a/src/Controls/src/Core/NavigationPage/NavigationPage.cs b/src/Controls/src/Core/NavigationPage/NavigationPage.cs index 8d36d5a9dacf..7bd83ea18c2a 100644 --- a/src/Controls/src/Core/NavigationPage/NavigationPage.cs +++ b/src/Controls/src/Core/NavigationPage/NavigationPage.cs @@ -19,10 +19,10 @@ public partial class NavigationPage : Page, IPageContainer, IBarElement, I /// Bindable property for attached property HasNavigationBar. public static readonly BindableProperty HasNavigationBarProperty = - BindableProperty.CreateAttached("HasNavigationBar", typeof(bool), typeof(Page), true); + BindableProperty.CreateAttached("HasNavigationBar", typeof(bool), typeof(Page), BooleanBoxes.TrueBox); /// Bindable property for attached property HasBackButton. - public static readonly BindableProperty HasBackButtonProperty = BindableProperty.CreateAttached("HasBackButton", typeof(bool), typeof(NavigationPage), true); + public static readonly BindableProperty HasBackButtonProperty = BindableProperty.CreateAttached("HasBackButton", typeof(bool), typeof(NavigationPage), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty BarBackgroundColorProperty = BarElement.BarBackgroundColorProperty; @@ -362,7 +362,7 @@ public static void SetHasBackButton(Page page, bool value) { if (page == null) throw new ArgumentNullException(nameof(page)); - page.SetValue(HasBackButtonProperty, value); + page.SetValue(HasBackButtonProperty, BooleanBoxes.Box(value)); } /// Sets a value that indicates whether or not this element has a navigation bar. @@ -370,7 +370,7 @@ public static void SetHasBackButton(Page page, bool value) /// The value to set. public static void SetHasNavigationBar(BindableObject page, bool value) { - page.SetValue(HasNavigationBarProperty, value); + page.SetValue(HasNavigationBarProperty, BooleanBoxes.Box(value)); } /// The bindable parameter. @@ -415,9 +415,12 @@ void SendNavigated(Page previousPage, NavigationType navigationType) CurrentPage.SendNavigatedTo(new NavigatedToEventArgs(previousPage, navigationType)); } - void SendNavigating(NavigationType navigationType, Page navigatingFrom = null) + void SendNavigating(NavigationType navigationType, Page navigatingFrom, Page destinationPage) { - (navigatingFrom ?? CurrentPage)?.SendNavigatingFrom(new NavigatingFromEventArgs(CurrentPage, navigationType)); + var fromPage = navigatingFrom ?? CurrentPage; + var toPage = destinationPage; + + fromPage?.SendNavigatingFrom(new NavigatingFromEventArgs(toPage, navigationType)); } @@ -839,7 +842,7 @@ protected async override Task OnPopAsync(bool animated) await Owner.SendHandlerUpdateAsync(animated, () => { - Owner.SendNavigating(NavigationType.Pop, currentPage); + Owner.SendNavigating(NavigationType.Pop, currentPage, newCurrentPage); Owner.FireDisappearing(currentPage); Owner.RemoveFromInnerChildren(currentPage); Owner.CurrentPage = newCurrentPage; @@ -873,7 +876,7 @@ protected override Task OnPopToRootAsync(bool animated) return Owner.SendHandlerUpdateAsync(animated, () => { - Owner.SendNavigating(NavigationType.PopToRoot, previousPage); + Owner.SendNavigating(NavigationType.PopToRoot, previousPage, newPage); Owner.FireDisappearing(previousPage); var lastIndex = NavigationStack.Count - 1; while (lastIndex > 0) @@ -910,7 +913,7 @@ protected override Task OnPushAsync(Page root, bool animated) Owner.NavigationType = navigationType; // Move the SendNavigating here so that it's fired prior to the stack being modified // This ensures consistent event ordering across all platforms (iOS, Catalyst, Android, Windows) - Owner.SendNavigating(navigationType, previousPage); + Owner.SendNavigating(navigationType, previousPage, root); Owner.PushPage(root); }, () => diff --git a/src/Controls/src/Core/Page/Page.cs b/src/Controls/src/Core/Page/Page.cs index ea46e5b0060d..aea36bdbfd5b 100644 --- a/src/Controls/src/Core/Page/Page.cs +++ b/src/Controls/src/Core/Page/Page.cs @@ -50,14 +50,14 @@ public partial class Page : VisualElement, ILayout, IPageController, IElementCon /// For internal use only. This API can be changed or removed without notice at any time. public const string ActionSheetSignalName = "Microsoft.Maui.Controls.ShowActionSheet"; - internal static readonly BindableProperty IgnoresContainerAreaProperty = BindableProperty.Create(nameof(IgnoresContainerArea), typeof(bool), typeof(Page), false); + internal static readonly BindableProperty IgnoresContainerAreaProperty = BindableProperty.Create(nameof(IgnoresContainerArea), typeof(bool), typeof(Page), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty BackgroundImageSourceProperty = BindableProperty.Create(nameof(BackgroundImageSource), typeof(ImageSource), typeof(Page), default(ImageSource)); /// Bindable property for . [Obsolete("Page.IsBusy has been deprecated and will be removed in .NET 11")] - public static readonly BindableProperty IsBusyProperty = BindableProperty.Create(nameof(IsBusy), typeof(bool), typeof(Page), false, propertyChanged: (bo, o, n) => ((Page)bo).OnPageBusyChanged()); + public static readonly BindableProperty IsBusyProperty = BindableProperty.Create(nameof(IsBusy), typeof(bool), typeof(Page), BooleanBoxes.FalseBox, propertyChanged: (bo, o, n) => ((Page)bo).OnPageBusyChanged()); /// Bindable property for . public static readonly BindableProperty PaddingProperty = PaddingElement.PaddingProperty; @@ -129,7 +129,7 @@ public ImageSource IconImageSource public bool IsBusy { get { return (bool)GetValue(IsBusyProperty); } - set { SetValue(IsBusyProperty, value); } + set { SetValue(IsBusyProperty, BooleanBoxes.Box(value)); } } /// @@ -197,7 +197,7 @@ public Rect ContainerArea public bool IgnoresContainerArea { get { return (bool)GetValue(IgnoresContainerAreaProperty); } - set { SetValue(IgnoresContainerAreaProperty, value); } + set { SetValue(IgnoresContainerAreaProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/Picker/Picker.cs b/src/Controls/src/Core/Picker/Picker.cs index ce6897e8e05f..8b03b6e464c3 100644 --- a/src/Controls/src/Core/Picker/Picker.cs +++ b/src/Controls/src/Core/Picker/Picker.cs @@ -333,16 +333,14 @@ static void OnItemsSourceChanged(BindableObject bindable, object oldValue, objec void OnItemsSourceChanged(IList oldValue, IList newValue) { - var oldObservable = oldValue as INotifyCollectionChanged; - if (oldObservable != null) - oldObservable.CollectionChanged -= CollectionChanged; - - var newObservable = newValue as INotifyCollectionChanged; - if (newObservable != null) + if (ReferenceEquals(oldValue, _subscribedItemsSourceCollection)) { - newObservable.CollectionChanged += CollectionChanged; + UnsubscribeFromItemsSourceCollection(); } + // Always subscribe — OnHandlerChanged will unsubscribe when Handler is detached + SubscribeToItemsSourceCollection(newValue as INotifyCollectionChanged); + if (newValue != null) { ((LockableObservableListWrapper)Items).IsLocked = true; @@ -357,6 +355,7 @@ void OnItemsSourceChanged(IList oldValue, IList newValue) } readonly Queue _pendingIsOpenActions = new Queue(); + INotifyCollectionChanged _subscribedItemsSourceCollection; void OnIsOpenPropertyChanged(bool oldValue, bool newValue) { @@ -372,8 +371,24 @@ void OnIsOpenPropertyChanged(bool oldValue, bool newValue) protected override void OnHandlerChanged() { + if (Handler is null) + { + UnsubscribeFromItemsSourceCollection(); + } + base.OnHandlerChanged(); + if (Handler is not null) + { + SubscribeToItemsSourceCollection(ItemsSource as INotifyCollectionChanged); + + // Keep display items in sync if this Picker is detached and later reattached. + if (ItemsSource is not null) + { + ResetItems(); + } + } + // Process any pending actions when handler becomes available while (_pendingIsOpenActions.Count > 0 && Handler != null) { @@ -393,6 +408,29 @@ void HandleIsOpenChanged() picker.Closed?.Invoke(picker, PickerClosedEventArgs.Empty); } + void SubscribeToItemsSourceCollection(INotifyCollectionChanged collection) + { + if (collection is null || ReferenceEquals(collection, _subscribedItemsSourceCollection)) + { + return; + } + + UnsubscribeFromItemsSourceCollection(); + _subscribedItemsSourceCollection = collection; + _subscribedItemsSourceCollection.CollectionChanged += CollectionChanged; + } + + void UnsubscribeFromItemsSourceCollection() + { + if (_subscribedItemsSourceCollection is null) + { + return; + } + + _subscribedItemsSourceCollection.CollectionChanged -= CollectionChanged; + _subscribedItemsSourceCollection = null; + } + void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -404,7 +442,7 @@ void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) RemoveItems(e); break; default: //Move, Replace, Reset - ResetItems(); + ResyncItemsAndReconcileSelection(); break; } @@ -415,6 +453,7 @@ void AddItems(NotifyCollectionChangedEventArgs e) { int insertIndex = e.NewStartingIndex < 0 ? Items.Count : e.NewStartingIndex; int index = insertIndex; + foreach (object newItem in e.NewItems) ((LockableObservableListWrapper)Items).InternalInsert(index++, GetDisplayMember(newItem)); @@ -422,9 +461,9 @@ void AddItems(NotifyCollectionChangedEventArgs e) return; index = GetSelectedIndex(); + if (insertIndex <= index) { - // When an item is inserted before the current selection, the selected item changes because the selected index is not properly updated. ClampSelectedIndex(index); } } @@ -432,18 +471,15 @@ void AddItems(NotifyCollectionChangedEventArgs e) void RemoveItems(NotifyCollectionChangedEventArgs e) { int removeStart; - // Items are removed in reverse order, so index starts at the index of the last item to remove int index; if (e.OldStartingIndex < Items.Count) { - // Remove e.OldItems.Count items starting at e.OldStartingIndex removeStart = e.OldStartingIndex; index = e.OldStartingIndex + e.OldItems.Count - 1; } else { - // Remove e.OldItems.Count items at the end when e.OldStartingIndex is past the end of the Items collection removeStart = Items.Count - e.OldItems.Count; index = Items.Count - 1; } @@ -451,21 +487,50 @@ void RemoveItems(NotifyCollectionChangedEventArgs e) foreach (object _ in e.OldItems) ((LockableObservableListWrapper)Items).InternalRemoveAt(index--); - index = GetSelectedIndex(); - if (removeStart <= index) + if (SelectedItem is not null) { - ClampSelectedIndex(index); + ClampSelectedIndex(ItemsSource?.IndexOf(SelectedItem) ?? -1); + } + else + { + index = GetSelectedIndex(); + + if (removeStart <= index) + { + ClampSelectedIndex(index); + } + } + } + + void ResyncItemsAndReconcileSelection() + { + if (ItemsSource == null) + return; + + ((LockableObservableListWrapper)Items).InternalClear(); + + foreach (object item in ItemsSource) + ((LockableObservableListWrapper)Items).InternalAdd(GetDisplayMember(item)); + + Handler?.UpdateValue(nameof(IPicker.Items)); + + if (SelectedItem is not null) + { + ClampSelectedIndex(ItemsSource.IndexOf(SelectedItem)); + } + else + { + ClampSelectedIndex(SelectedIndex); } } int GetSelectedIndex() { if (SelectedItem is null) - { return SelectedIndex; - } int newIndex = ItemsSource?.IndexOf(SelectedItem) ?? Items?.IndexOf(SelectedItem) ?? -1; + return newIndex >= 0 ? newIndex : SelectedIndex; } @@ -473,9 +538,12 @@ void ResetItems() { if (ItemsSource == null) return; + ((LockableObservableListWrapper)Items).InternalClear(); + foreach (object item in ItemsSource) ((LockableObservableListWrapper)Items).InternalAdd(GetDisplayMember(item)); + Handler?.UpdateValue(nameof(IPicker.Items)); if (!TryApplyPendingSelectedIndex(forceClamp: true)) @@ -496,7 +564,6 @@ static void OnSelectedItemChanged(BindableObject bindable, object oldValue, obje var picker = (Picker)bindable; picker.UpdateSelectedIndex(newValue); } - void ClampSelectedIndex(int selectedIndex) { var oldIndex = selectedIndex; diff --git a/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs index f60a47dc86e3..32e054060ba8 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs @@ -126,6 +126,13 @@ public static void RecalculateSpanPositions(this TextView textView, Label elemen if (layout == null) return; + // Fix for https://github.com/dotnet/maui/issues/35755: skip spans in the + // ellipsized tail to avoid IndexOutOfBoundsException. + var lastLayoutLine = layout.LineCount - 1; + if (lastLayoutLine < 0) + return; + var layoutEndOffset = layout.GetLineEnd(lastLayoutLine); + int next = 0; int count = 0; @@ -163,8 +170,16 @@ public static void RecalculateSpanPositions(this TextView textView, Label elemen var spanStartOffset = spannableString.GetSpanStart(startSpan); var spanEndOffset = spannableString.GetSpanEnd(endSpan); + // Safe for TailTruncation only: both offsets share the same string prefix. + if (spanStartOffset >= layoutEndOffset) + continue; + var spanStartLine = layout.GetLineForOffset(spanStartOffset); - var spanEndLine = layout.GetLineForOffset(spanEndOffset); + var spanEndLine = layout.GetLineForOffset(System.Math.Min(spanEndOffset, layoutEndOffset - 1)); + + // OEM guard: some Layout subclasses don't cap GetLineForOffset at lineCount-1. + // Not dead code — see https://github.com/dotnet/maui/issues/35755 + spanEndLine = System.Math.Min(spanEndLine, lastLayoutLine); // Go through all lines that are affected by the span and calculate a rectangle for each List spanRectangles = new List(); diff --git a/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs b/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs index 98170f751ced..7db911369caf 100644 --- a/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs +++ b/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs @@ -63,7 +63,7 @@ public InnerGestureListener( bool HasAnyGestures() { - return (_panGestureHandler?.HasAnyGestures() ?? false) || (_tapGestureHandler?.HasAnyGestures() ?? false) || (_swipeGestureHandler?.HasAnyGestures() ?? false); + return (_panGestureHandler?.HasAnyGestures() ?? false) || (_tapGestureHandler?.HasAnyGestures() ?? false) || (_swipeGestureHandler?.HasAnyGestures() ?? false) || (_dragAndDropGestureHandler?.HasAnyDragGestures() ?? false); } // This is needed because GestureRecognizer callbacks can be delayed several hundred milliseconds diff --git a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs index ca6aa5055e53..cea32ae45127 100644 --- a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs +++ b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs @@ -168,7 +168,7 @@ public virtual void SetElement(TabbedPage tabbedPage) var layoutInflater = Element.Handler.MauiContext.GetLayoutInflater(); _tabLayout = new TabLayout(_context.Context) { - TabMode = TabLayout.ModeAuto, + TabMode = TabLayout.ModeFixed, TabGravity = TabLayout.GravityFill, LayoutParameters = new AppBarLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent) }; diff --git a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs index bdf750546762..d5a4e5a9714e 100644 --- a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs +++ b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs @@ -202,7 +202,7 @@ static void ProcessRecognizerHandlerTap( if (platformRecognizer == null) { if (virtualView == element) - return new Point((int)originPoint.X, (int)originPoint.Y); + return new Point(originPoint.X, originPoint.Y); var targetViewScreenLocation = virtualView.GetLocationOnScreen(); @@ -237,7 +237,7 @@ static void ProcessRecognizerHandlerTap( if (result == null) return null; - return new Point((int)result.Value.X, (int)result.Value.Y); + return new Point(result.Value.X, result.Value.Y); } protected virtual List? GetPlatformRecognizer(IGestureRecognizer recognizer) diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/AppCompat/Application.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/AppCompat/Application.cs index 57a9a1b12b24..9d7e510351a3 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/AppCompat/Application.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/AppCompat/Application.cs @@ -1,13 +1,14 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific.AppCompat { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.Application; /// AppCompat application instance on Android. public static class Application { /// Bindable property for . - public static readonly BindableProperty SendDisappearingEventOnPauseProperty = BindableProperty.Create(nameof(SendDisappearingEventOnPause), typeof(bool), typeof(Application), true); + public static readonly BindableProperty SendDisappearingEventOnPauseProperty = BindableProperty.Create(nameof(SendDisappearingEventOnPause), typeof(bool), typeof(Application), BooleanBoxes.TrueBox); /// Returns a Boolean value that controls whether the disappearing event is sent when the application is paused. /// The platform specific element on which to perform the operation. @@ -22,7 +23,7 @@ public static bool GetSendDisappearingEventOnPause(BindableObject element) /// The new property value to assign. public static void SetSendDisappearingEventOnPause(BindableObject element, bool value) { - element.SetValue(SendDisappearingEventOnPauseProperty, value); + element.SetValue(SendDisappearingEventOnPauseProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that controls whether the disappearing event is sent when the application is paused. @@ -43,7 +44,7 @@ public static IPlatformElementConfiguration SendDisappear } /// Bindable property for . - public static readonly BindableProperty SendAppearingEventOnResumeProperty = BindableProperty.Create(nameof(SendAppearingEventOnResume), typeof(bool), typeof(Application), true); + public static readonly BindableProperty SendAppearingEventOnResumeProperty = BindableProperty.Create(nameof(SendAppearingEventOnResume), typeof(bool), typeof(Application), BooleanBoxes.TrueBox); /// Returns a Boolean value that controls whether the appearing event is sent when the application resumes. /// The platform specific element on which to perform the operation. @@ -58,7 +59,7 @@ public static bool GetSendAppearingEventOnResume(BindableObject element) /// The new property value to assign. public static void SetSendAppearingEventOnResume(BindableObject element, bool value) { - element.SetValue(SendAppearingEventOnResumeProperty, value); + element.SetValue(SendAppearingEventOnResumeProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that controls whether the appearing event is sent when the application resumes. @@ -79,7 +80,7 @@ public static IPlatformElementConfiguration SendAppearing } /// Bindable property for . - public static readonly BindableProperty ShouldPreserveKeyboardOnResumeProperty = BindableProperty.Create(nameof(ShouldPreserveKeyboardOnResume), typeof(bool), typeof(Application), false); + public static readonly BindableProperty ShouldPreserveKeyboardOnResumeProperty = BindableProperty.Create(nameof(ShouldPreserveKeyboardOnResume), typeof(bool), typeof(Application), BooleanBoxes.FalseBox); /// Returns a Boolean value that controls whether the keyboard state should be preserved when the application resumes. /// The platform specific element on which to perform the operation. @@ -94,7 +95,7 @@ public static bool GetShouldPreserveKeyboardOnResume(BindableObject element) /// The new property value to assign. public static void SetShouldPreserveKeyboardOnResume(BindableObject element, bool value) { - element.SetValue(ShouldPreserveKeyboardOnResumeProperty, value); + element.SetValue(ShouldPreserveKeyboardOnResumeProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that controls whether the keyboard state should be preserved when the application resumes. diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/Button.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/Button.cs index 3270f60be551..040f8e996bb8 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/Button.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/Button.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { + using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using FormsElement = Maui.Controls.Button; @@ -24,7 +25,7 @@ public static bool GetUseDefaultPadding(BindableObject element) /// to use default padding; otherwise, . public static void SetUseDefaultPadding(BindableObject element, bool value) { - element.SetValue(UseDefaultPaddingProperty, value); + element.SetValue(UseDefaultPaddingProperty, BooleanBoxes.Box(value)); } /// Returns if the button will use the default padding. Otherwise, returns . @@ -63,7 +64,7 @@ public static bool GetUseDefaultShadow(BindableObject element) /// to use default shadow; otherwise, . public static void SetUseDefaultShadow(BindableObject element, bool value) { - element.SetValue(UseDefaultShadowProperty, value); + element.SetValue(UseDefaultShadowProperty, BooleanBoxes.Box(value)); } /// Returns if the button will use the default shadow. Otherwise, returns . diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ImageButton.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ImageButton.cs index 0f7fa34b1148..3b2ca9ddfe57 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ImageButton.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ImageButton.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { + using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using FormsImageButton = Maui.Controls.ImageButton; @@ -24,7 +25,7 @@ public static bool GetIsShadowEnabled(BindableObject element) /// to enable shadow; otherwise, . public static void SetIsShadowEnabled(BindableObject element, bool value) { - element.SetValue(IsShadowEnabledProperty, value); + element.SetValue(IsShadowEnabledProperty, BooleanBoxes.Box(value)); } /// Gets whether the shadow effect is enabled on Android. diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ListView.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ListView.cs index 6bdf9543176c..c34460882e60 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ListView.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ListView.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { using System; + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.ListView; /// The list view instance that Microsoft.Maui.Controls created on the Android platform. @@ -27,7 +28,7 @@ public static bool GetIsFastScrollEnabled(BindableObject element) [Obsolete("With the deprecation of ListView, this property is obsolete. Please use CollectionView instead.")] public static void SetIsFastScrollEnabled(BindableObject element, bool value) { - element.SetValue(IsFastScrollEnabledProperty, value); + element.SetValue(IsFastScrollEnabledProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that tells whether fast scrolling is enabled. diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/TabbedPage.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/TabbedPage.cs index 29a541dcb331..503e3f547b8d 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/TabbedPage.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/TabbedPage.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { using System; + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.TabbedPage; /// The tabbed page instance that Microsoft.Maui.Controls created on the Android platform. @@ -25,7 +26,7 @@ public static bool GetIsSwipePagingEnabled(BindableObject element) /// to enable swipe paging; otherwise, . public static void SetIsSwipePagingEnabled(BindableObject element, bool value) { - element.SetValue(IsSwipePagingEnabledProperty, value); + element.SetValue(IsSwipePagingEnabledProperty, BooleanBoxes.Box(value)); } /// Gets a Boolean value that controls whether swipe paging is enabled. @@ -81,7 +82,7 @@ public static bool GetIsSmoothScrollEnabled(BindableObject element) /// to enable smooth scroll; otherwise, . public static void SetIsSmoothScrollEnabled(BindableObject element, bool value) { - element.SetValue(IsSmoothScrollEnabledProperty, value); + element.SetValue(IsSmoothScrollEnabledProperty, BooleanBoxes.Box(value)); } /// Gets whether smooth scrolling is enabled for this. diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ViewCell.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ViewCell.cs index 3c2b3911f43d..e3e6f97e44dd 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ViewCell.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/ViewCell.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { using System; + using Microsoft.Maui.Controls.Internals; using FormsCell = Maui.Controls.Cell; /// Android-specific context actions behavior for ViewCell in ListView. @@ -37,7 +38,7 @@ public static bool GetIsContextActionsLegacyModeEnabled(BindableObject element) [Obsolete("With the deprecation of ListView, this class is obsolete. Please use CollectionView instead.")] public static void SetIsContextActionsLegacyModeEnabled(BindableObject element, bool value) { - element.SetValue(IsContextActionsLegacyModeEnabledProperty, value); + element.SetValue(IsContextActionsLegacyModeEnabledProperty, BooleanBoxes.Box(value)); } /// Gets whether the legacy context actions mode is enabled on Android. diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/VisualElement.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/VisualElement.cs index 14cb10ddc032..884d49e19808 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/VisualElement.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/VisualElement.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.VisualElement; /// @@ -64,7 +65,7 @@ public static IPlatformElementConfiguration SetElevation( /// Bindable property for attached property IsLegacyColorModeEnabled. public static readonly BindableProperty IsLegacyColorModeEnabledProperty = BindableProperty.CreateAttached("IsLegacyColorModeEnabled", typeof(bool), - typeof(FormsElement), true); + typeof(FormsElement), BooleanBoxes.TrueBox); /// /// Gets whether or not the legacy color mode for this element is enabled. @@ -83,7 +84,7 @@ public static bool GetIsLegacyColorModeEnabled(BindableObject element) /// to enable legacy color mode. Otherwise, . public static void SetIsLegacyColorModeEnabled(BindableObject element, bool value) { - element.SetValue(IsLegacyColorModeEnabledProperty, value); + element.SetValue(IsLegacyColorModeEnabledProperty, BooleanBoxes.Box(value)); } /// @@ -105,7 +106,7 @@ public static bool GetIsLegacyColorModeEnabled(this IPlatformElementConfiguratio public static IPlatformElementConfiguration SetIsLegacyColorModeEnabled( this IPlatformElementConfiguration config, bool value) { - config.Element.SetValue(IsLegacyColorModeEnabledProperty, value); + config.Element.SetValue(IsLegacyColorModeEnabledProperty, BooleanBoxes.Box(value)); return config; } diff --git a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/WebView.cs b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/WebView.cs index 9177872b5e8f..f7a11b7e1b98 100644 --- a/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/WebView.cs +++ b/src/Controls/src/Core/PlatformConfiguration/AndroidSpecific/WebView.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.WebView; /// Enumerates web view behaviors when handling mixed content. @@ -67,7 +68,7 @@ public static bool GetEnableZoomControls(FormsElement element) /// to enable zoom controls; otherwise, . public static void SetEnableZoomControls(FormsElement element, bool value) { - element.SetValue(EnableZoomControlsProperty, value); + element.SetValue(EnableZoomControlsProperty, BooleanBoxes.Box(value)); } /// The platform configuration for the element on which to perform the operation. @@ -106,7 +107,7 @@ public static bool GetDisplayZoomControls(FormsElement element) /// to display zoom controls; otherwise, . public static void SetDisplayZoomControls(FormsElement element, bool value) { - element.SetValue(DisplayZoomControlsProperty, value); + element.SetValue(DisplayZoomControlsProperty, BooleanBoxes.Box(value)); } /// The platform configuration for the element on which to perform the operation. @@ -154,7 +155,7 @@ public static bool GetJavaScriptEnabled(FormsElement element) /// The boolean value indicating whether JavaScript should be enabled. public static void SetJavaScriptEnabled(FormsElement element, bool value) { - element.SetValue(JavaScriptEnabledProperty, value); + element.SetValue(JavaScriptEnabledProperty, BooleanBoxes.Box(value)); } /// diff --git a/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/Application.cs b/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/Application.cs index 073fafe78fa0..036e1bec94a0 100644 --- a/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/Application.cs +++ b/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/Application.cs @@ -3,6 +3,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.TizenSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.Application; /// Provides Tizen-specific platform configuration for application-level features. @@ -24,7 +25,7 @@ public static bool GetUseBezelInteraction(BindableObject element) /// to enable bezel interaction; otherwise, . public static void SetUseBezelInteraction(BindableObject element, bool value) { - element.SetValue(UseBezelInteractionProperty, value); + element.SetValue(UseBezelInteractionProperty, BooleanBoxes.Box(value)); } /// Gets the value that indicates whether bezel interaction is enabled. diff --git a/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/NavigationPage.cs b/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/NavigationPage.cs index 37df0daa4d91..b957c07a1dad 100644 --- a/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/NavigationPage.cs +++ b/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/NavigationPage.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.TizenSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.NavigationPage; /// Provides access to the bread crumb bar for navigation pages on the Tizen platform. @@ -9,7 +10,7 @@ public static class NavigationPage #region HasBreadCrumbsBar /// Bindable property for attached property HasBreadCrumbsBar. public static readonly BindableProperty HasBreadCrumbsBarProperty - = BindableProperty.CreateAttached("HasBreadCrumbsBar", typeof(bool), typeof(FormsElement), false); + = BindableProperty.CreateAttached("HasBreadCrumbsBar", typeof(bool), typeof(FormsElement), BooleanBoxes.FalseBox); /// Returns a Boolean value that tells whether the navigation page has a bread crumb bar. /// The navigation page on the Tizen platform whose font weight icon to get. @@ -24,7 +25,7 @@ public static bool GetHasBreadCrumbsBar(BindableObject element) /// to show a bread crumb bar; otherwise, . public static void SetHasBreadCrumbsBar(BindableObject element, bool value) { - element.SetValue(HasBreadCrumbsBarProperty, value); + element.SetValue(HasBreadCrumbsBarProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that tells whether the navigation page has a bread crumb bar. diff --git a/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/VisualElement.cs b/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/VisualElement.cs index 5a29365a2667..f216701ff361 100644 --- a/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/VisualElement.cs +++ b/src/Controls/src/Core/PlatformConfiguration/TizenSpecific/VisualElement.cs @@ -6,6 +6,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.TizenSpecific { using static Microsoft.Maui.ApplicationModel.Permissions; + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.VisualElement; /// @@ -103,7 +104,7 @@ public static IPlatformElementConfiguration SetStyle(this I /// The new focus participation value. public static void SetFocusAllowed(BindableObject element, bool value) { - element.SetValue(IsFocusAllowedProperty, value); + element.SetValue(IsFocusAllowedProperty, BooleanBoxes.Box(value)); } /// diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/FlyoutPage.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/FlyoutPage.cs index e98831e97a8f..e35549df414a 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/FlyoutPage.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/FlyoutPage.cs @@ -75,7 +75,7 @@ public static IPlatformElementConfiguration UsePartialCol /// Bindable property for attached property CollapsedPaneWidth. public static readonly BindableProperty CollapsedPaneWidthProperty = BindableProperty.CreateAttached("CollapsedPaneWidth", typeof(double), - typeof(FlyoutPage), 48d, validateValue: (bindable, value) => (double)value >= 0); + typeof(FlyoutPage), 48d, validateValue: (bindable, value) => (double)value >= 0, propertyChanged : OnCollapsedPaneWidthChanged); /// Gets the width of the collapsed flyout pane on Windows. /// The element to get the collapsed pane width from. @@ -85,6 +85,14 @@ public static double GetCollapsedPaneWidth(BindableObject element) return (double)element.GetValue(CollapsedPaneWidthProperty); } + static void OnCollapsedPaneWidthChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is Microsoft.Maui.Controls.FlyoutPage flyoutPage && flyoutPage.Handler is not null) + { + flyoutPage.Handler.UpdateValue(nameof(CollapsedPaneWidthProperty)); + } + } + /// Sets the width of the collapsed flyout pane on Windows. /// The element to set the collapsed pane width on. /// The collapsed pane width in device-independent units. diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/InputView.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/InputView.cs index e87df2baff6d..12f5184d92f3 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/InputView.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/InputView.cs @@ -3,6 +3,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.InputView; /// Provides access to reading order detection on the Windows platform. @@ -16,7 +17,7 @@ public static class InputView /// to detect reading order from content. public static void SetDetectReadingOrderFromContent(BindableObject element, bool value) { - element.SetValue(DetectReadingOrderFromContentProperty, value); + element.SetValue(DetectReadingOrderFromContentProperty, BooleanBoxes.Box(value)); } /// Gets whether reading order (LTR/RTL) is detected from content on Windows. @@ -42,7 +43,7 @@ public static bool GetDetectReadingOrderFromContent(BindableObject element) public static IPlatformElementConfiguration SetDetectReadingOrderFromContent( this IPlatformElementConfiguration config, bool value) { - config.Element.SetValue(DetectReadingOrderFromContentProperty, value); + config.Element.SetValue(DetectReadingOrderFromContentProperty, BooleanBoxes.Box(value)); return config; } } diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Label.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Label.cs index 5ef9a8b8bf19..1ec22ea1edbc 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Label.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Label.cs @@ -3,6 +3,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.Label; /// Provides access to reading order detection on the Windows platform. @@ -16,7 +17,7 @@ public static class Label /// to detect reading order from content. public static void SetDetectReadingOrderFromContent(BindableObject element, bool value) { - element.SetValue(DetectReadingOrderFromContentProperty, value); + element.SetValue(DetectReadingOrderFromContentProperty, BooleanBoxes.Box(value)); } /// Gets whether reading order (LTR/RTL) is detected from label content on Windows. @@ -42,7 +43,7 @@ public static bool GetDetectReadingOrderFromContent(BindableObject element) public static IPlatformElementConfiguration SetDetectReadingOrderFromContent( this IPlatformElementConfiguration config, bool value) { - config.Element.SetValue(DetectReadingOrderFromContentProperty, value); + config.Element.SetValue(DetectReadingOrderFromContentProperty, BooleanBoxes.Box(value)); return config; } } diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Page.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Page.cs index dd29459aadb4..860c7ccc70f6 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Page.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/Page.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific { @@ -75,7 +76,7 @@ public static IPlatformElementConfiguration SetToolbarPla /// public static readonly BindableProperty ToolbarDynamicOverflowEnabledProperty = BindableProperty.CreateAttached("ToolbarDynamicOverflowEnabled", typeof(bool), - typeof(FormsElement), true); + typeof(FormsElement), BooleanBoxes.TrueBox); /// /// Gets a value that indicates whether toolbar items automatically move to the overflow menu when space is limited. @@ -94,7 +95,7 @@ public static bool GetToolbarDynamicOverflowEnabled(BindableObject element) /// A value that indicates whether toolbar items automatically move to the overflow menu when space is limited public static void SetToolbarDynamicOverflowEnabled(BindableObject element, bool value) { - element.SetValue(ToolbarDynamicOverflowEnabledProperty, value); + element.SetValue(ToolbarDynamicOverflowEnabledProperty, BooleanBoxes.Box(value)); } /// @@ -116,7 +117,7 @@ public static bool GetToolbarDynamicOverflowEnabled(this IPlatformElementConfigu public static IPlatformElementConfiguration SetToolbarDynamicOverflowEnabled( this IPlatformElementConfiguration config, bool value) { - config.Element.SetValue(ToolbarDynamicOverflowEnabledProperty, value); + config.Element.SetValue(ToolbarDynamicOverflowEnabledProperty, BooleanBoxes.Box(value)); return config; } diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/SearchBar.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/SearchBar.cs index 5bf84048da84..02ded3e0cd01 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/SearchBar.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/SearchBar.cs @@ -5,6 +5,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.SearchBar; /// Provides control over the spellchecker on search bars. @@ -19,7 +20,7 @@ public static class SearchBar /// to enable spell checking. public static void SetIsSpellCheckEnabled(BindableObject element, bool value) { - element.SetValue(IsSpellCheckEnabledProperty, value); + element.SetValue(IsSpellCheckEnabledProperty, BooleanBoxes.Box(value)); } /// Gets whether spell checking is enabled for the search bar on Windows. diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/TabbedPage.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/TabbedPage.cs index 2a919ddce020..6fd03b9f3a2e 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/TabbedPage.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/TabbedPage.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific @@ -13,7 +14,7 @@ public static class TabbedPage { /// Bindable property for attached property HeaderIconsEnabled. public static readonly BindableProperty HeaderIconsEnabledProperty = - BindableProperty.Create(nameof(HeaderIconsEnabledProperty), typeof(bool), typeof(TabbedPage), true); + BindableProperty.Create(nameof(HeaderIconsEnabledProperty), typeof(bool), typeof(TabbedPage), BooleanBoxes.TrueBox); /// Bindable property for attached property HeaderIconsSize. public static readonly BindableProperty HeaderIconsSizeProperty = @@ -24,7 +25,7 @@ public static class TabbedPage /// to enable header icons. public static void SetHeaderIconsEnabled(BindableObject element, bool value) { - element.SetValue(HeaderIconsEnabledProperty, value); + element.SetValue(HeaderIconsEnabledProperty, BooleanBoxes.Box(value)); } /// Gets whether tab header icons are displayed on Windows. diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/VisualElement.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/VisualElement.cs index 3f45972efd40..fd4cfc316e02 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/VisualElement.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/VisualElement.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.VisualElement; /// Provides access to platform-specific features of visual elements on the Windows platform. @@ -156,7 +157,7 @@ public static IPlatformElementConfiguration SetAccessKeyV /// Bindable property for attached property IsLegacyColorModeEnabled. public static readonly BindableProperty IsLegacyColorModeEnabledProperty = BindableProperty.CreateAttached("IsLegacyColorModeEnabled", typeof(bool), - typeof(FormsElement), true); + typeof(FormsElement), BooleanBoxes.TrueBox); /// Gets whether legacy color mode is enabled on Windows. /// The element to query. @@ -171,7 +172,7 @@ public static bool GetIsLegacyColorModeEnabled(BindableObject element) /// to enable legacy color mode. public static void SetIsLegacyColorModeEnabled(BindableObject element, bool value) { - element.SetValue(IsLegacyColorModeEnabledProperty, value); + element.SetValue(IsLegacyColorModeEnabledProperty, BooleanBoxes.Box(value)); } /// Gets whether legacy color mode is enabled on Windows. @@ -189,7 +190,7 @@ public static bool GetIsLegacyColorModeEnabled(this IPlatformElementConfiguratio public static IPlatformElementConfiguration SetIsLegacyColorModeEnabled( this IPlatformElementConfiguration config, bool value) { - config.Element.SetValue(IsLegacyColorModeEnabledProperty, value); + config.Element.SetValue(IsLegacyColorModeEnabledProperty, BooleanBoxes.Box(value)); return config; } diff --git a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/WebView.cs b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/WebView.cs index 68cfc0bb5791..530311558422 100644 --- a/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/WebView.cs +++ b/src/Controls/src/Core/PlatformConfiguration/WindowsSpecific/WebView.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.WebView; /// Controls whether JavaScript alerts are enabled for a web view. @@ -23,7 +24,7 @@ public static bool GetIsJavaScriptAlertEnabled(BindableObject element) /// to enable JavaScript alerts. public static void SetIsJavaScriptAlertEnabled(BindableObject element, bool value) { - element.SetValue(IsJavaScriptAlertEnabledProperty, value); + element.SetValue(IsJavaScriptAlertEnabledProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that tells whether the web view allows JavaScript alerts. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Application.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Application.cs index f850fb20700a..8d80b8c3f32d 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Application.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Application.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.Application; /// Provides control over simultaneous recognition for pan gesture recognizers. @@ -23,7 +24,7 @@ public static bool GetPanGestureRecognizerShouldRecognizeSimultaneously(Bindable /// to enable simultaneous recognition; otherwise, . public static void SetPanGestureRecognizerShouldRecognizeSimultaneously(BindableObject element, bool value) { - element.SetValue(PanGestureRecognizerShouldRecognizeSimultaneouslyProperty, value); + element.SetValue(PanGestureRecognizerShouldRecognizeSimultaneouslyProperty, BooleanBoxes.Box(value)); } /// Gets whether pan gesture recognizers can recognize gestures simultaneously with other gesture recognizers. @@ -62,7 +63,7 @@ public static bool GetHandleControlUpdatesOnMainThread(BindableObject element) /// to handle updates on the main thread; otherwise, . public static void SetHandleControlUpdatesOnMainThread(BindableObject element, bool value) { - element.SetValue(HandleControlUpdatesOnMainThreadProperty, value); + element.SetValue(HandleControlUpdatesOnMainThreadProperty, BooleanBoxes.Box(value)); } /// Gets whether control property updates are processed on the main thread on iOS. @@ -101,7 +102,7 @@ public static bool GetEnableAccessibilityScalingForNamedFontSizes(BindableObject /// to enable accessibility scaling; otherwise, . public static void SetEnableAccessibilityScalingForNamedFontSizes(BindableObject element, bool value) { - element.SetValue(EnableAccessibilityScalingForNamedFontSizesProperty, value); + element.SetValue(EnableAccessibilityScalingForNamedFontSizesProperty, BooleanBoxes.Box(value)); } /// Gets whether named font sizes respond to iOS Dynamic Type accessibility settings. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Entry.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Entry.cs index ffb62d9e3abd..f33653180115 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Entry.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Entry.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific @@ -30,7 +31,7 @@ public static bool GetAdjustsFontSizeToFitWidth(BindableObject element) /// to enable auto font size adjustment; otherwise, . public static void SetAdjustsFontSizeToFitWidth(BindableObject element, bool value) { - element.SetValue(AdjustsFontSizeToFitWidthProperty, value); + element.SetValue(AdjustsFontSizeToFitWidthProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that tells whether the entry control automatically adjusts the font size. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/FlyoutPage.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/FlyoutPage.cs index 33b77644ab3f..a2a34abf60cb 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/FlyoutPage.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/FlyoutPage.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.FlyoutPage; /// Provides iOS-specific configuration for FlyoutPage shadow effects. @@ -24,7 +25,7 @@ public static bool GetApplyShadow(BindableObject element) /// to apply shadow; otherwise, . public static void SetApplyShadow(BindableObject element, bool value) { - element.SetValue(ApplyShadowProperty, value); + element.SetValue(ApplyShadowProperty, BooleanBoxes.Box(value)); } /// Sets whether a drop shadow is applied to the detail page when the flyout is revealed on iOS. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ListView.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ListView.cs index d6b4380f0298..31ed43d83c2c 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ListView.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ListView.cs @@ -2,6 +2,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { using System; + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.ListView; /// Provides access to the separator style for list views on the iOS platform. [Obsolete("With the deprecation of ListView, this class is obsolete. Please use CollectionView instead.")] @@ -109,7 +110,7 @@ public static IPlatformElementConfiguration SetGroupHeaderSty /// Bindable property for . [Obsolete("With the deprecation of ListView, this property is obsolete. Please use CollectionView instead.")] - public static readonly BindableProperty RowAnimationsEnabledProperty = BindableProperty.Create(nameof(RowAnimationsEnabled), typeof(bool), typeof(ListView), true); + public static readonly BindableProperty RowAnimationsEnabledProperty = BindableProperty.Create(nameof(RowAnimationsEnabled), typeof(bool), typeof(ListView), BooleanBoxes.TrueBox); /// The element parameter. [Obsolete("With the deprecation of ListView, this property is obsolete. Please use CollectionView instead.")] @@ -124,7 +125,7 @@ public static bool GetRowAnimationsEnabled(BindableObject element) [Obsolete("With the deprecation of ListView, this property is obsolete. Please use CollectionView instead.")] public static void SetRowAnimationsEnabled(BindableObject element, bool value) { - element.SetValue(RowAnimationsEnabledProperty, value); + element.SetValue(RowAnimationsEnabledProperty, BooleanBoxes.Box(value)); } /// Sets whether row animations are enabled for the ListView on iOS. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/NavigationPage.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/NavigationPage.cs index d646736085ed..97d694bac876 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/NavigationPage.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/NavigationPage.cs @@ -3,6 +3,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { using System; + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.NavigationPage; /// The navigation page instance that Microsoft.Maui.Controls created on the iOS platform. @@ -31,7 +32,7 @@ public static bool GetIsNavigationBarTranslucent(BindableObject element) [Obsolete("IsNavigationBarTranslucent is deprecated. The Translucent will be enabled by default by setting the BarBackgroundColor to a transparent color.")] public static void SetIsNavigationBarTranslucent(BindableObject element, bool value) { - element.SetValue(IsNavigationBarTranslucentProperty, value); + element.SetValue(IsNavigationBarTranslucentProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that tells whether the navigation bar on the platform-specific navigation page is translucent. @@ -119,7 +120,7 @@ public static IPlatformElementConfiguration SetStatusBarTextC #region PrefersLargeTitles /// Bindable property for . - public static readonly BindableProperty PrefersLargeTitlesProperty = BindableProperty.Create(nameof(PrefersLargeTitles), typeof(bool), typeof(Page), false); + public static readonly BindableProperty PrefersLargeTitlesProperty = BindableProperty.Create(nameof(PrefersLargeTitles), typeof(bool), typeof(Page), BooleanBoxes.FalseBox); /// Returns the large title preference of . /// The element whose large title preference to get. @@ -134,7 +135,7 @@ public static bool GetPrefersLargeTitles(BindableObject element) /// to prefer large titles; otherwise, . public static void SetPrefersLargeTitles(BindableObject element, bool value) { - element.SetValue(PrefersLargeTitlesProperty, value); + element.SetValue(PrefersLargeTitlesProperty, BooleanBoxes.Box(value)); } /// Sets whether iOS 11+ large titles are displayed in the navigation bar. @@ -158,7 +159,7 @@ public static bool PrefersLargeTitles(this IPlatformElementConfigurationBindable property for . - public static readonly BindableProperty HideNavigationBarSeparatorProperty = BindableProperty.Create(nameof(HideNavigationBarSeparator), typeof(bool), typeof(Page), false); + public static readonly BindableProperty HideNavigationBarSeparatorProperty = BindableProperty.Create(nameof(HideNavigationBarSeparator), typeof(bool), typeof(Page), BooleanBoxes.FalseBox); /// Returns if the separator is hidden. Otherwise, returns . /// The element for which to return whether the navigation bar separator is hidden. @@ -173,7 +174,7 @@ public static bool GetHideNavigationBarSeparator(BindableObject element) /// to hide the separator; otherwise, . public static void SetHideNavigationBarSeparator(BindableObject element, bool value) { - element.SetValue(HideNavigationBarSeparatorProperty, value); + element.SetValue(HideNavigationBarSeparatorProperty, BooleanBoxes.Box(value)); } /// Sets whether to hide the navigation bar separator line on iOS. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Page.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Page.cs index e0fa4a23a2eb..1fff2d6de9fd 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Page.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Page.cs @@ -1,5 +1,6 @@ #nullable disable using System.ComponentModel; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { @@ -149,7 +150,7 @@ public static bool GetUseSafeArea(BindableObject element) [System.Obsolete("Use SafeAreaEdges attached property instead for per-edge safe area control.")] public static void SetUseSafeArea(BindableObject element, bool value) { - element.SetValue(UseSafeAreaProperty, value); + element.SetValue(UseSafeAreaProperty, BooleanBoxes.Box(value)); } /// @@ -418,7 +419,7 @@ static void SetModalPopoverRect(BindableObject element, System.Drawing.Rectangle /// Bindable property for . public static readonly BindableProperty PrefersHomeIndicatorAutoHiddenProperty = - BindableProperty.Create(nameof(PrefersHomeIndicatorAutoHidden), typeof(bool), typeof(Page), false); + BindableProperty.Create(nameof(PrefersHomeIndicatorAutoHidden), typeof(bool), typeof(Page), BooleanBoxes.FalseBox); /// /// Gets a value that indicates whether the visual indicator should hide upon returning to the home screen. @@ -437,7 +438,7 @@ public static bool GetPrefersHomeIndicatorAutoHidden(BindableObject element) /// if hide the home indicator; otherwise, . public static void SetPrefersHomeIndicatorAutoHidden(BindableObject element, bool value) { - element.SetValue(PrefersHomeIndicatorAutoHiddenProperty, value); + element.SetValue(PrefersHomeIndicatorAutoHiddenProperty, BooleanBoxes.Box(value)); } /// diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ScrollView.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ScrollView.cs index 1541aec56719..1810f2acaa91 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ScrollView.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/ScrollView.cs @@ -1,13 +1,14 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.ScrollView; /// The scroll view instance that Microsoft.Maui.Controls created on the iOS platform. public static class ScrollView { /// Bindable property for . - public static readonly BindableProperty ShouldDelayContentTouchesProperty = BindableProperty.Create(nameof(ShouldDelayContentTouches), typeof(bool), typeof(ScrollView), true); + public static readonly BindableProperty ShouldDelayContentTouchesProperty = BindableProperty.Create(nameof(ShouldDelayContentTouches), typeof(bool), typeof(ScrollView), BooleanBoxes.TrueBox); /// Returns a Boolean value that tells whether iOS will wait to determine if a touch is intended as a scroll, or scroll immediately. /// The platform specific element on which to perform the operation. @@ -22,7 +23,7 @@ public static bool GetShouldDelayContentTouches(BindableObject element) /// to delay; for immediate touch response. public static void SetShouldDelayContentTouches(BindableObject element, bool value) { - element.SetValue(ShouldDelayContentTouchesProperty, value); + element.SetValue(ShouldDelayContentTouchesProperty, BooleanBoxes.Box(value)); } /// Returns a Boolean value that tells whether iOS will wait to determine if a touch is intended as a scroll, or scroll immediately. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Slider.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Slider.cs index b4701425ba2f..8fd5cd689f5b 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Slider.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/Slider.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { + using Microsoft.Maui.Controls.Internals; using FormsElement = Maui.Controls.Slider; /// Platform-specific functionality for sliders the iOS platform. @@ -22,7 +23,7 @@ public static bool GetUpdateOnTap(BindableObject element) /// to update on tap; otherwise, . public static void SetUpdateOnTap(BindableObject element, bool value) { - element.SetValue(UpdateOnTapProperty, value); + element.SetValue(UpdateOnTapProperty, BooleanBoxes.Box(value)); } /// Gets whether the slider value updates when the user taps on the track on iOS. diff --git a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/VisualElement.cs b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/VisualElement.cs index 6fa329ef1515..01fb61ca04ca 100644 --- a/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/VisualElement.cs +++ b/src/Controls/src/Core/PlatformConfiguration/iOSSpecific/VisualElement.cs @@ -3,6 +3,7 @@ namespace Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific { using System; + using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using FormsElement = Maui.Controls.VisualElement; @@ -69,7 +70,7 @@ public ShadowEffect() : base("Microsoft.Maui.Controls.ShadowEffect") { } /// Bindable property for attached property IsShadowEnabled. public static readonly BindableProperty IsShadowEnabledProperty = BindableProperty.Create("IsShadowEnabled", typeof(bool), - typeof(VisualElement), false, propertyChanged: OnIsShadowEnabledChanged); + typeof(VisualElement), BooleanBoxes.FalseBox, propertyChanged: OnIsShadowEnabledChanged); static void OnIsShadowEnabledChanged(BindableObject bindable, object oldValue, object newValue) { @@ -109,7 +110,7 @@ public static bool GetIsShadowEnabled(BindableObject element) /// to enable the shadow. Otherwise, . public static void SetIsShadowEnabled(BindableObject element, bool value) { - element.SetValue(IsShadowEnabledProperty, value); + element.SetValue(IsShadowEnabledProperty, BooleanBoxes.Box(value)); } /// @@ -329,7 +330,7 @@ public static IPlatformElementConfiguration SetShadowOpacity( /// Bindable property for attached property IsLegacyColorModeEnabled. public static readonly BindableProperty IsLegacyColorModeEnabledProperty = BindableProperty.CreateAttached("IsLegacyColorModeEnabled", typeof(bool), - typeof(FormsElement), true); + typeof(FormsElement), BooleanBoxes.TrueBox); /// /// Returns whether or not the legacy color mode is enabled. @@ -348,7 +349,7 @@ public static bool GetIsLegacyColorModeEnabled(BindableObject element) /// to enable legacy color mode. Otherwise, . public static void SetIsLegacyColorModeEnabled(BindableObject element, bool value) { - element.SetValue(IsLegacyColorModeEnabledProperty, value); + element.SetValue(IsLegacyColorModeEnabledProperty, BooleanBoxes.Box(value)); } /// @@ -370,14 +371,14 @@ public static bool GetIsLegacyColorModeEnabled(this IPlatformElementConfiguratio public static IPlatformElementConfiguration SetIsLegacyColorModeEnabled( this IPlatformElementConfiguration config, bool value) { - config.Element.SetValue(IsLegacyColorModeEnabledProperty, value); + config.Element.SetValue(IsLegacyColorModeEnabledProperty, BooleanBoxes.Box(value)); return config; } #endregion /// Bindable property for . - public static readonly BindableProperty CanBecomeFirstResponderProperty = BindableProperty.Create(nameof(CanBecomeFirstResponder), typeof(bool), typeof(VisualElement), false); + public static readonly BindableProperty CanBecomeFirstResponderProperty = BindableProperty.Create(nameof(CanBecomeFirstResponder), typeof(bool), typeof(VisualElement), BooleanBoxes.FalseBox); /// /// Gets whether this element can become the first responder to touch events, rather than the page containing the element. @@ -396,7 +397,7 @@ public static bool GetCanBecomeFirstResponder(BindableObject element) /// to set this element as the first responder. Otherwise, . public static void SetCanBecomeFirstResponder(BindableObject element, bool value) { - element.SetValue(CanBecomeFirstResponderProperty, value); + element.SetValue(CanBecomeFirstResponderProperty, BooleanBoxes.Box(value)); } /// diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index dddd92d96824..95729b316ff0 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -9,6 +9,8 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void ~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultTitleColor.get -> Microsoft.Maui.Graphics.Color ~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultUnselectedColor.get -> Microsoft.Maui.Graphics.Color override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void +~override Microsoft.Maui.Controls.Handlers.Items.MauiCarouselRecyclerView.RequestChildFocus(Android.Views.View child, Android.Views.View focused) -> void +~override Microsoft.Maui.Controls.Handlers.Items.MauiCarouselRecyclerView.RequestChildRectangleOnScreen(Android.Views.View child, Android.Graphics.Rect rect, bool immediate) -> bool ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.DispatchTouchEvent(Android.Views.MotionEvent e) -> bool ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnInterceptTouchEvent(Android.Views.MotionEvent e) -> bool ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnTouchEvent(Android.Views.MotionEvent e) -> bool @@ -20,5 +22,6 @@ override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnVisibilityChanged(Android.Views.View changedView, Android.Views.ViewStates visibility) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnHiddenChanged(bool hidden) -> void ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void +override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnSizeChanged(int w, int h, int oldw, int oldh) -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 25c1b293624b..40fdb52b6f69 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -7,7 +7,6 @@ override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2< ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.DidMoveToParentViewController(UIKit.UIViewController parent) -> void -*REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint @@ -15,3 +14,5 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void +~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 25c1b293624b..40fdb52b6f69 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -7,7 +7,6 @@ override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2< ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.DidMoveToParentViewController(UIKit.UIViewController parent) -> void -*REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint @@ -15,3 +14,5 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void +~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void diff --git a/src/Controls/src/Core/RadioButton/RadioButton.cs b/src/Controls/src/Core/RadioButton/RadioButton.cs index 9a21f28fd29c..fccaaf3004b6 100644 --- a/src/Controls/src/Core/RadioButton/RadioButton.cs +++ b/src/Controls/src/Core/RadioButton/RadioButton.cs @@ -89,7 +89,7 @@ public partial class RadioButton : TemplatedView, IElementConfigurationBindable property for . This is a bindable property. public static readonly BindableProperty IsCheckedProperty = BindableProperty.Create( - nameof(IsChecked), typeof(bool), typeof(RadioButton), false, + nameof(IsChecked), typeof(bool), typeof(RadioButton), BooleanBoxes.FalseBox, propertyChanged: (b, o, n) => ((RadioButton)b).OnIsCheckedPropertyChanged((bool)n), defaultBindingMode: BindingMode.TwoWay); @@ -165,7 +165,7 @@ public object Value public bool IsChecked { get { return (bool)GetValue(IsCheckedProperty); } - set { SetValue(IsCheckedProperty, value); } + set { SetValue(IsCheckedProperty, BooleanBoxes.Box(value)); } } /// @@ -254,7 +254,7 @@ public double FontSize public bool FontAutoScalingEnabled { get => (bool)GetValue(FontAutoScalingEnabledProperty); - set => SetValue(FontAutoScalingEnabledProperty, value); + set => SetValue(FontAutoScalingEnabledProperty, BooleanBoxes.Box(value)); } /// @@ -472,7 +472,7 @@ void SelectRadioButton(object sender, EventArgs e) { if (IsEnabled) { - SetValue(IsCheckedProperty, true, specificity: SetterSpecificity.FromHandler); + SetValue(IsCheckedProperty, BooleanBoxes.TrueBox, specificity: SetterSpecificity.FromHandler); } } @@ -752,7 +752,7 @@ object IContentView.Content bool IRadioButton.IsChecked { get => IsChecked; - set => SetValue(IsCheckedProperty, value, SetterSpecificity.FromHandler); + set => SetValue(IsCheckedProperty, BooleanBoxes.Box(value), SetterSpecificity.FromHandler); } private protected override string GetDebuggerDisplay() diff --git a/src/Controls/src/Core/RefreshView/RefreshView.cs b/src/Controls/src/Core/RefreshView/RefreshView.cs index 509cec88c683..caccf5fd1bfb 100644 --- a/src/Controls/src/Core/RefreshView/RefreshView.cs +++ b/src/Controls/src/Core/RefreshView/RefreshView.cs @@ -34,7 +34,7 @@ public RefreshView() /// Bindable property for . public static readonly BindableProperty IsRefreshingProperty = - BindableProperty.Create(nameof(IsRefreshing), typeof(bool), typeof(RefreshView), false, BindingMode.TwoWay, coerceValue: OnIsRefreshingPropertyCoerced, propertyChanged: OnIsRefreshingPropertyChanged); + BindableProperty.Create(nameof(IsRefreshing), typeof(bool), typeof(RefreshView), BooleanBoxes.FalseBox, BindingMode.TwoWay, coerceValue: OnIsRefreshingPropertyCoerced, propertyChanged: OnIsRefreshingPropertyChanged); static void OnIsRefreshingPropertyChanged(BindableObject bindable, object oldValue, object newValue) { @@ -72,7 +72,7 @@ static object OnIsRefreshingPropertyCoerced(BindableObject bindable, object valu public bool IsRefreshing { get { return (bool)GetValue(IsRefreshingProperty); } - set { SetValue(IsRefreshingProperty, value); } + set { SetValue(IsRefreshingProperty, BooleanBoxes.Box(value)); } } /// Bindable property for . @@ -123,7 +123,7 @@ public Color RefreshColor /// Bindable property for . public static readonly BindableProperty IsRefreshEnabledProperty = - BindableProperty.Create(nameof(IsRefreshEnabled), typeof(bool), typeof(RefreshView), true, + BindableProperty.Create(nameof(IsRefreshEnabled), typeof(bool), typeof(RefreshView), BooleanBoxes.TrueBox, propertyChanged: OnIsRefreshEnabledPropertyChanged, coerceValue: CoerceIsRefreshEnabledProperty); bool _isRefreshEnabledExplicit = (bool)IsRefreshEnabledProperty.DefaultValue; @@ -157,7 +157,7 @@ static void OnIsRefreshEnabledPropertyChanged(BindableObject bindable, object ol public bool IsRefreshEnabled { get { return (bool)GetValue(IsRefreshEnabledProperty); } - set { SetValue(IsRefreshEnabledProperty, value); } + set { SetValue(IsRefreshEnabledProperty, BooleanBoxes.Box(value)); } } /// @@ -173,7 +173,7 @@ public IPlatformElementConfiguration On() where T : IConfigPl object ICommandElement.CommandParameter => CommandParameter; protected override bool IsEnabledCore => base.IsEnabledCore; - + void ICommandElement.CanExecuteChanged(object sender, EventArgs e) { if (IsRefreshing) @@ -201,7 +201,7 @@ protected override void OnPropertyChanged([CallerMemberName] string propertyName bool IRefreshView.IsRefreshing { get => IsRefreshing; - set { SetValue(IsRefreshingProperty, value, SetterSpecificity.FromHandler); } + set { SetValue(IsRefreshingProperty, BooleanBoxes.Box(value), SetterSpecificity.FromHandler); } } private protected override string GetDebuggerDisplay() diff --git a/src/Controls/src/Core/Shapes/ArcSegment.cs b/src/Controls/src/Core/Shapes/ArcSegment.cs index 8e48f810ac7e..ce0698750c7c 100644 --- a/src/Controls/src/Core/Shapes/ArcSegment.cs +++ b/src/Controls/src/Core/Shapes/ArcSegment.cs @@ -1,4 +1,5 @@ #nullable disable +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics.Converters; @@ -52,7 +53,7 @@ public ArcSegment(Point point, Size size, double rotationAngle, SweepDirection s /// Bindable property for . public static readonly BindableProperty IsLargeArcProperty = - BindableProperty.Create(nameof(IsLargeArc), typeof(bool), typeof(ArcSegment), false); + BindableProperty.Create(nameof(IsLargeArc), typeof(bool), typeof(ArcSegment), BooleanBoxes.FalseBox); /// /// Gets or sets the endpoint of the arc. This is a bindable property. @@ -96,7 +97,7 @@ public SweepDirection SweepDirection /// public bool IsLargeArc { - set { SetValue(IsLargeArcProperty, value); } + set { SetValue(IsLargeArcProperty, BooleanBoxes.Box(value)); } get { return (bool)GetValue(IsLargeArcProperty); } } } diff --git a/src/Controls/src/Core/Shapes/GeometryGroup.cs b/src/Controls/src/Core/Shapes/GeometryGroup.cs index 3da73936f54f..bcfaa8469004 100644 --- a/src/Controls/src/Core/Shapes/GeometryGroup.cs +++ b/src/Controls/src/Core/Shapes/GeometryGroup.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; @@ -11,6 +12,8 @@ namespace Microsoft.Maui.Controls.Shapes [ContentProperty("Children")] public class GeometryGroup : Geometry { + readonly Dictionary _subscriptionRefCounts = new(); + /// Bindable property for . public static readonly BindableProperty ChildrenProperty = BindableProperty.Create(nameof(Children), typeof(GeometryCollection), typeof(GeometryGroup), null, @@ -55,52 +58,146 @@ public FillRule FillRule void UpdateChildren(GeometryCollection oldCollection, GeometryCollection newCollection) { - if (oldCollection != null) - { - oldCollection.CollectionChanged -= OnChildrenCollectionChanged; + DetachCollection(oldCollection); + AttachCollection(newCollection); + + Invalidate(); + } - foreach (var oldChildren in oldCollection) - { - oldChildren.PropertyChanged -= OnChildrenPropertyChanged; - } + void AttachCollection(GeometryCollection collection) + { + if (collection == null) + return; + + collection.CollectionChanged += OnChildrenCollectionChanged; + + foreach (var geometry in collection) + { + SubscribeToGeometry(geometry); } + } - if (newCollection == null) + void DetachCollection(GeometryCollection collection) + { + if (collection == null) return; - newCollection.CollectionChanged += OnChildrenCollectionChanged; + collection.CollectionChanged -= OnChildrenCollectionChanged; - foreach (var newChildren in newCollection) + foreach (var geometry in collection) { - newChildren.PropertyChanged += OnChildrenPropertyChanged; + UnsubscribeFromGeometry(geometry); } } void OnChildrenCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - if (e.OldItems != null) + switch (e.Action) { - foreach (var oldItem in e.OldItems) - { - if (!(oldItem is Geometry oldGeometry)) - continue; + case NotifyCollectionChangedAction.Add: + if (e.NewItems != null) + { + foreach (Geometry geometry in e.NewItems) + { + SubscribeToGeometry(geometry); + } + } + break; + + case NotifyCollectionChangedAction.Remove: + if (e.OldItems != null) + { + foreach (Geometry geometry in e.OldItems) + { + UnsubscribeFromGeometry(geometry); + } + } + break; + + case NotifyCollectionChangedAction.Replace: + if (e.OldItems != null) + { + foreach (Geometry geometry in e.OldItems) + { + UnsubscribeFromGeometry(geometry); + } + } + + if (e.NewItems != null) + { + foreach (Geometry geometry in e.NewItems) + { + SubscribeToGeometry(geometry); + } + } + break; + + case NotifyCollectionChangedAction.Move: + // No subscription changes required. + break; + + case NotifyCollectionChangedAction.Reset: + ResubscribeCollection(sender as GeometryCollection); + break; + } + + Invalidate(); + } - oldGeometry.PropertyChanged -= OnChildrenPropertyChanged; - } + void ResubscribeCollection(GeometryCollection collection) + { + UnsubscribeFromAllChildren(); + + if (collection == null) + return; + + foreach (var geometry in collection) + { + SubscribeToGeometry(geometry); } + } - if (e.NewItems != null) + void SubscribeToGeometry(Geometry geometry) + { + if (geometry == null) + return; + + if (_subscriptionRefCounts.TryGetValue(geometry, out var count)) { - foreach (var newItem in e.NewItems) - { - if (!(newItem is Geometry newGeometry)) - continue; + _subscriptionRefCounts[geometry] = count + 1; + return; + } - newGeometry.PropertyChanged += OnChildrenPropertyChanged; - } + _subscriptionRefCounts[geometry] = 1; + geometry.PropertyChanged += OnChildrenPropertyChanged; + } + + void UnsubscribeFromGeometry(Geometry geometry) + { + if (geometry == null) + return; + + if (!_subscriptionRefCounts.TryGetValue(geometry, out var count)) + return; + + if (count > 1) + { + _subscriptionRefCounts[geometry] = count - 1; + return; } - Invalidate(); + _subscriptionRefCounts.Remove(geometry); + geometry.PropertyChanged -= OnChildrenPropertyChanged; + } + + void UnsubscribeFromAllChildren() + { + foreach (var geometry in _subscriptionRefCounts.Keys) + { + geometry.PropertyChanged -= OnChildrenPropertyChanged; + } + + _subscriptionRefCounts.Clear(); } void OnChildrenPropertyChanged(object sender, PropertyChangedEventArgs e) diff --git a/src/Controls/src/Core/Shapes/Path.cs b/src/Controls/src/Core/Shapes/Path.cs index 264c48d2875e..44b6f4763775 100644 --- a/src/Controls/src/Core/Shapes/Path.cs +++ b/src/Controls/src/Core/Shapes/Path.cs @@ -11,6 +11,11 @@ namespace Microsoft.Maui.Controls.Shapes /// public sealed partial class Path : Shape, IShape { + WeakGeometryChangedProxy _dataProxy; + EventHandler _dataChanged; + WeakNotifyPropertyChangedProxy _transformProxy; + PropertyChangedEventHandler _transformChanged; + /// /// Initializes a new instance of the class. /// @@ -18,6 +23,12 @@ public Path() : base() { } + ~Path() + { + _dataProxy?.Unsubscribe(); + _transformProxy?.Unsubscribe(); + } + public Path(Geometry data) : this() { Data = data; @@ -26,12 +37,30 @@ public Path(Geometry data) : this() /// Bindable property for . public static readonly BindableProperty DataProperty = BindableProperty.Create(nameof(Data), typeof(Geometry), typeof(Path), null, - propertyChanged: OnGeometryPropertyChanged); + propertyChanging: (bindable, oldValue, newValue) => + { + if (oldValue != null) + (bindable as Path)?.StopNotifyingDataChanges(); + }, + propertyChanged: (bindable, oldValue, newValue) => + { + if (newValue != null) + (bindable as Path)?.NotifyDataChanges(); + }); /// Bindable property for . public static readonly BindableProperty RenderTransformProperty = BindableProperty.Create(nameof(RenderTransform), typeof(Transform), typeof(Path), null, - propertyChanged: OnTransformPropertyChanged); + propertyChanging: (bindable, oldValue, newValue) => + { + if (oldValue != null) + (bindable as Path)?.StopNotifyingTransformChanges(); + }, + propertyChanged: (bindable, oldValue, newValue) => + { + if (newValue != null) + (bindable as Path)?.NotifyTransformChanges(); + }); /// /// Gets or sets the that specifies the shape to be drawn. This is a bindable property. @@ -52,46 +81,38 @@ public Transform RenderTransform get { return (Transform)GetValue(RenderTransformProperty); } } - static void OnGeometryPropertyChanged(BindableObject bindable, object oldValue, object newValue) + void NotifyDataChanges() { - if (oldValue != null) - { - (oldValue as Geometry).PropertyChanged -= (bindable as Path).OnGeometryPropertyChanged; - - if (oldValue is PathGeometry pathGeometry) - pathGeometry.InvalidatePathGeometryRequested -= (bindable as Path).OnInvalidatePathGeometryRequested; - } + var data = Data; - if (newValue != null) + if (data != null) { - (newValue as Geometry).PropertyChanged += (bindable as Path).OnGeometryPropertyChanged; - - if (newValue is PathGeometry pathGeometry) - pathGeometry.InvalidatePathGeometryRequested += (bindable as Path).OnInvalidatePathGeometryRequested; + _dataChanged ??= (sender, e) => OnPropertyChanged(nameof(Data)); + _dataProxy ??= new WeakGeometryChangedProxy(); + _dataProxy.Subscribe(data, _dataChanged); } } - static void OnTransformPropertyChanged(BindableObject bindable, object oldValue, object newValue) + void StopNotifyingDataChanges() { - if (oldValue != null) - { - (oldValue as Transform).PropertyChanged -= (bindable as Path).OnTransformPropertyChanged; - } - - if (newValue != null) - { - (newValue as Transform).PropertyChanged += (bindable as Path).OnTransformPropertyChanged; - } + _dataProxy?.Unsubscribe(); } - void OnGeometryPropertyChanged(object sender, PropertyChangedEventArgs args) + void NotifyTransformChanges() { - OnPropertyChanged(nameof(Data)); + var renderTransform = RenderTransform; + + if (renderTransform != null) + { + _transformChanged ??= OnTransformPropertyChanged; + _transformProxy ??= new WeakNotifyPropertyChangedProxy(); + _transformProxy.Subscribe(renderTransform, _transformChanged); + } } - void OnInvalidatePathGeometryRequested(object sender, EventArgs e) + void StopNotifyingTransformChanges() { - OnPropertyChanged(nameof(Data)); + _transformProxy?.Unsubscribe(); } void OnTransformPropertyChanged(object sender, PropertyChangedEventArgs args) diff --git a/src/Controls/src/Core/Shapes/PathFigure.cs b/src/Controls/src/Core/Shapes/PathFigure.cs index 9c71d665d218..622e5aa204db 100644 --- a/src/Controls/src/Core/Shapes/PathFigure.cs +++ b/src/Controls/src/Core/Shapes/PathFigure.cs @@ -1,7 +1,9 @@ #nullable disable using System; using System.Collections.Specialized; +using System.Collections.Generic; using System.ComponentModel; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls.Shapes @@ -12,6 +14,8 @@ namespace Microsoft.Maui.Controls.Shapes [ContentProperty("Segments")] public sealed class PathFigure : BindableObject, IAnimatable { + readonly List _subscribedSegments = new(); + /// /// Initializes a new instance of the class. /// @@ -36,11 +40,11 @@ static void OnPathSegmentCollectionChanged(BindableObject bindable, object oldVa /// Bindable property for . public static readonly BindableProperty IsClosedProperty = - BindableProperty.Create(nameof(IsClosed), typeof(bool), typeof(PathFigure), false); + BindableProperty.Create(nameof(IsClosed), typeof(bool), typeof(PathFigure), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty IsFilledProperty = - BindableProperty.Create(nameof(IsFilled), typeof(bool), typeof(PathFigure), true); + BindableProperty.Create(nameof(IsFilled), typeof(bool), typeof(PathFigure), BooleanBoxes.TrueBox); /// /// Gets or sets the collection of path segments that define this figure. This is a bindable property. @@ -65,7 +69,7 @@ public Point StartPoint /// public bool IsClosed { - set { SetValue(IsClosedProperty, value); } + set { SetValue(IsClosedProperty, BooleanBoxes.Box(value)); } get { return (bool)GetValue(IsClosedProperty); } } @@ -74,7 +78,7 @@ public bool IsClosed /// public bool IsFilled { - set { SetValue(IsFilledProperty, value); } + set { SetValue(IsFilledProperty, BooleanBoxes.Box(value)); } get { return (bool)GetValue(IsFilledProperty); } } @@ -94,15 +98,9 @@ public void BatchCommit() void UpdatePathSegmentCollection(PathSegmentCollection oldCollection, PathSegmentCollection newCollection) { - if (oldCollection != null) - { - oldCollection.CollectionChanged -= OnPathSegmentCollectionChanged; + oldCollection?.CollectionChanged -= OnPathSegmentCollectionChanged; - foreach (var oldPathSegment in oldCollection) - { - oldPathSegment.PropertyChanged -= OnPathSegmentPropertyChanged; - } - } + UnsubscribeFromAllPathSegmentPropertyChanged(); if (newCollection == null) return; @@ -111,12 +109,31 @@ void UpdatePathSegmentCollection(PathSegmentCollection oldCollection, PathSegmen foreach (var newPathSegment in newCollection) { - newPathSegment.PropertyChanged += OnPathSegmentPropertyChanged; + SubscribeToPathSegmentPropertyChanged(newPathSegment); } } void OnPathSegmentCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { + if (e.Action == NotifyCollectionChangedAction.Reset) + { + for (int i = _subscribedSegments.Count - 1; i >= 0; i--) + { + UnsubscribeFromPathSegmentPropertyChanged(_subscribedSegments[i]); + } + + if (sender is PathSegmentCollection pathSegmentCollection) + { + foreach (var pathSegment in pathSegmentCollection) + { + SubscribeToPathSegmentPropertyChanged(pathSegment); + } + } + + Invalidate(); + return; + } + if (e.OldItems != null) { foreach (var oldItem in e.OldItems) @@ -124,7 +141,7 @@ void OnPathSegmentCollectionChanged(object sender, NotifyCollectionChangedEventA if (!(oldItem is PathSegment oldPathSegment)) continue; - oldPathSegment.PropertyChanged -= OnPathSegmentPropertyChanged; + UnsubscribeFromPathSegmentPropertyChanged(oldPathSegment); } } @@ -135,13 +152,43 @@ void OnPathSegmentCollectionChanged(object sender, NotifyCollectionChangedEventA if (!(newItem is PathSegment newPathSegment)) continue; - newPathSegment.PropertyChanged += OnPathSegmentPropertyChanged; + SubscribeToPathSegmentPropertyChanged(newPathSegment); } } Invalidate(); } + void SubscribeToPathSegmentPropertyChanged(PathSegment pathSegment) + { + if (_subscribedSegments.Contains(pathSegment)) + { + return; + } + + pathSegment.PropertyChanged += OnPathSegmentPropertyChanged; + _subscribedSegments.Add(pathSegment); + } + + void UnsubscribeFromPathSegmentPropertyChanged(PathSegment pathSegment) + { + if (!_subscribedSegments.Contains(pathSegment)) + { + return; + } + + pathSegment.PropertyChanged -= OnPathSegmentPropertyChanged; + _subscribedSegments.Remove(pathSegment); + } + + void UnsubscribeFromAllPathSegmentPropertyChanged() + { + for (int i = _subscribedSegments.Count - 1; i >= 0; i--) + { + UnsubscribeFromPathSegmentPropertyChanged(_subscribedSegments[i]); + } + } + void OnPathSegmentPropertyChanged(object sender, PropertyChangedEventArgs e) { Invalidate(); diff --git a/src/Controls/src/Core/Shapes/PathGeometry.cs b/src/Controls/src/Core/Shapes/PathGeometry.cs index 721026f4a69f..0da288758e14 100644 --- a/src/Controls/src/Core/Shapes/PathGeometry.cs +++ b/src/Controls/src/Core/Shapes/PathGeometry.cs @@ -14,6 +14,11 @@ namespace Microsoft.Maui.Controls.Shapes [ContentProperty("Figures")] public sealed class PathGeometry : Geometry { + // Tracks figures whose PropertyChanged and InvalidatePathSegmentRequested events are + // subscribed so we can unsubscribe them even when the collection is cleared (Reset + // action does not populate OldItems). + readonly List _subscribedFigures = new List(); + /// /// Initializes a new instance of the class. /// @@ -198,17 +203,36 @@ void AddPolyQuad(PathF path, PolyQuadraticBezierSegment polyQuadraticBezierSegme } } + void SubscribeFigure(PathFigure figure) + { + figure.PropertyChanged += OnPathFigurePropertyChanged; + figure.InvalidatePathSegmentRequested += OnInvalidatePathSegmentRequested; + _subscribedFigures.Add(figure); + } + + void UnsubscribeFigure(PathFigure figure) + { + figure.PropertyChanged -= OnPathFigurePropertyChanged; + figure.InvalidatePathSegmentRequested -= OnInvalidatePathSegmentRequested; + _subscribedFigures.Remove(figure); + } + + void UnsubscribeAllFigures() + { + foreach (var figure in _subscribedFigures) + { + figure.PropertyChanged -= OnPathFigurePropertyChanged; + figure.InvalidatePathSegmentRequested -= OnInvalidatePathSegmentRequested; + } + _subscribedFigures.Clear(); + } + void UpdatePathFigureCollection(PathFigureCollection oldCollection, PathFigureCollection newCollection) { if (oldCollection != null) { oldCollection.CollectionChanged -= OnPathFigureCollectionChanged; - - foreach (var oldPathFigure in oldCollection) - { - oldPathFigure.PropertyChanged -= OnPathFigurePropertyChanged; - oldPathFigure.InvalidatePathSegmentRequested -= OnInvalidatePathSegmentRequested; - } + UnsubscribeAllFigures(); } if (newCollection == null) @@ -218,34 +242,37 @@ void UpdatePathFigureCollection(PathFigureCollection oldCollection, PathFigureCo foreach (var newPathFigure in newCollection) { - newPathFigure.PropertyChanged += OnPathFigurePropertyChanged; - newPathFigure.InvalidatePathSegmentRequested += OnInvalidatePathSegmentRequested; + SubscribeFigure(newPathFigure); } } void OnPathFigureCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - if (e.OldItems != null) + if (e.OldItems != null && e.Action != NotifyCollectionChangedAction.Move) { foreach (var oldItem in e.OldItems) { - if (!(oldItem is PathFigure oldPathFigure)) - continue; - - oldPathFigure.PropertyChanged -= OnPathFigurePropertyChanged; - oldPathFigure.InvalidatePathSegmentRequested -= OnInvalidatePathSegmentRequested; + if (oldItem is PathFigure oldPathFigure) + { + UnsubscribeFigure(oldPathFigure); + } } } + if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Clear() raises Reset with OldItems = null; unsubscribe all tracked figures + // to prevent the cleared figures from retaining this PathGeometry alive. + UnsubscribeAllFigures(); + } - if (e.NewItems != null) + if (e.NewItems != null && e.Action != NotifyCollectionChangedAction.Move) { foreach (var newItem in e.NewItems) { - if (!(newItem is PathFigure newPathFigure)) - continue; - - newPathFigure.PropertyChanged += OnPathFigurePropertyChanged; - newPathFigure.InvalidatePathSegmentRequested += OnInvalidatePathSegmentRequested; + if (newItem is PathFigure newPathFigure) + { + SubscribeFigure(newPathFigure); + } } } diff --git a/src/Controls/src/Core/Shapes/TransformGroup.cs b/src/Controls/src/Core/Shapes/TransformGroup.cs index 992da66f05a4..1d348ece6eaf 100644 --- a/src/Controls/src/Core/Shapes/TransformGroup.cs +++ b/src/Controls/src/Core/Shapes/TransformGroup.cs @@ -1,4 +1,5 @@ #nullable disable +using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; @@ -10,10 +11,11 @@ namespace Microsoft.Maui.Controls.Shapes [ContentProperty("Children")] public sealed class TransformGroup : Transform { + readonly Dictionary _subscribedTransforms = new(); + /// Bindable property for . public static readonly BindableProperty ChildrenProperty = - BindableProperty.Create(nameof(Children), typeof(TransformCollection), typeof(TransformGroup), null, - propertyChanged: OnTransformGroupChanged); + BindableProperty.Create(nameof(Children), typeof(TransformCollection), typeof(TransformGroup), null, propertyChanged: OnChildrenChanged); /// /// Initializes a new instance of the class. @@ -32,38 +34,125 @@ public TransformCollection Children get { return (TransformCollection)GetValue(ChildrenProperty); } } - static void OnTransformGroupChanged(BindableObject bindable, object oldValue, object newValue) + static void OnChildrenChanged(BindableObject bindable, object oldValue, object newValue) + { + var transformGroup = (TransformGroup)bindable; + transformGroup.UpdateChildren( + oldValue as TransformCollection, + newValue as TransformCollection); + } + + void UpdateChildren(TransformCollection oldCollection, TransformCollection newCollection) { - if (oldValue != null) + DetachCollection(oldCollection); + AttachCollection(newCollection); + + UpdateTransformMatrix(); + } + + void AttachCollection(TransformCollection collection) + { + if (collection is null) + { + return; + } + + collection.CollectionChanged += OnChildrenCollectionChanged; + + foreach (var transform in collection) { - (oldValue as TransformCollection).CollectionChanged -= (bindable as TransformGroup).OnChildrenCollectionChanged; + SubscribeToTransformPropertyChanged(transform); } + } - if (newValue != null) + void DetachCollection(TransformCollection collection) + { + if (collection is null) { - (newValue as TransformCollection).CollectionChanged += (bindable as TransformGroup).OnChildrenCollectionChanged; + return; } - (bindable as TransformGroup).UpdateTransformMatrix(); + collection.CollectionChanged -= OnChildrenCollectionChanged; + + ClearAllTransformSubscriptions(); } void OnChildrenCollectionChanged(object sender, NotifyCollectionChangedEventArgs args) { - if (args.NewItems != null) - foreach (INotifyPropertyChanged item in args.NewItems) + if (args.Action == NotifyCollectionChangedAction.Reset) + { + ClearAllTransformSubscriptions(); + + if (sender is TransformCollection collection) + { + foreach (INotifyPropertyChanged item in collection) + { + SubscribeToTransformPropertyChanged(item); + } + } + } + else + { + if (args.OldItems is not null) { - item.PropertyChanged += OnTransformPropertyChanged; + foreach (INotifyPropertyChanged item in args.OldItems) + { + UnsubscribeFromTransformPropertyChanged(item); + } } - if (args.OldItems != null) - foreach (INotifyPropertyChanged item in args.OldItems) + if (args.NewItems is not null) { - item.PropertyChanged -= OnTransformPropertyChanged; + foreach (INotifyPropertyChanged item in args.NewItems) + { + SubscribeToTransformPropertyChanged(item); + } } + } UpdateTransformMatrix(); } + void SubscribeToTransformPropertyChanged(INotifyPropertyChanged item) + { + if (_subscribedTransforms.TryGetValue(item, out int count)) + { + _subscribedTransforms[item] = count + 1; + return; + } + + item.PropertyChanged += OnTransformPropertyChanged; + _subscribedTransforms[item] = 1; + } + + void UnsubscribeFromTransformPropertyChanged(INotifyPropertyChanged item) + { + if (!_subscribedTransforms.TryGetValue(item, out int count)) + { + return; + } + + if (count > 1) + { + _subscribedTransforms[item] = count - 1; + return; + } + + item.PropertyChanged -= OnTransformPropertyChanged; + _subscribedTransforms.Remove(item); + } + + // Unsubscribes all tracked transforms from PropertyChanged and clears the dictionary. + void ClearAllTransformSubscriptions() + { + foreach (var item in _subscribedTransforms) + { + item.Key.PropertyChanged -= OnTransformPropertyChanged; + } + + _subscribedTransforms.Clear(); + } + void OnTransformPropertyChanged(object sender, PropertyChangedEventArgs args) { UpdateTransformMatrix(); diff --git a/src/Controls/src/Core/Shell/BackButtonBehavior.cs b/src/Controls/src/Core/Shell/BackButtonBehavior.cs index 5e9b062fc730..67b037ec99b7 100644 --- a/src/Controls/src/Core/Shell/BackButtonBehavior.cs +++ b/src/Controls/src/Core/Shell/BackButtonBehavior.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Windows.Input; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -25,11 +26,11 @@ public class BackButtonBehavior : BindableObject /// Bindable property for . public static readonly BindableProperty IsEnabledProperty = - BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(BackButtonBehavior), true, BindingMode.OneWay); + BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(BackButtonBehavior), BooleanBoxes.TrueBox, BindingMode.OneWay); /// Bindable property for . public static readonly BindableProperty IsVisibleProperty = - BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(BackButtonBehavior), true, BindingMode.OneWay); + BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(BackButtonBehavior), BooleanBoxes.TrueBox, BindingMode.OneWay); /// Bindable property for . public static readonly BindableProperty TextOverrideProperty = @@ -86,7 +87,7 @@ public bool IsEnabled public bool IsVisible { get { return (bool)GetValue(IsVisibleProperty); } - set { SetValue(IsVisibleProperty, value); } + set { SetValue(IsVisibleProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/Shell/BaseShellItem.cs b/src/Controls/src/Core/Shell/BaseShellItem.cs index 566bf0406a77..44d29f3b4ece 100644 --- a/src/Controls/src/Core/Shell/BaseShellItem.cs +++ b/src/Controls/src/Core/Shell/BaseShellItem.cs @@ -49,7 +49,7 @@ public class BaseShellItem : NavigableElement, IPropertyPropagationController, I /// Bindable property for . public static readonly BindableProperty IsEnabledProperty = - BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(BaseShellItem), true, BindingMode.OneWay); + BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(BaseShellItem), BooleanBoxes.TrueBox, BindingMode.OneWay); /// Bindable property for . public static readonly BindableProperty TitleProperty = @@ -57,11 +57,11 @@ public class BaseShellItem : NavigableElement, IPropertyPropagationController, I /// Bindable property for . public static readonly BindableProperty IsVisibleProperty = - BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(BaseShellItem), true); + BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(BaseShellItem), BooleanBoxes.TrueBox); /// Bindable property for . public static readonly BindableProperty FlyoutItemIsVisibleProperty = - BindableProperty.Create(nameof(FlyoutItemIsVisible), typeof(bool), typeof(BaseShellItem), true, propertyChanged: OnFlyoutItemIsVisibleChanged); + BindableProperty.Create(nameof(FlyoutItemIsVisible), typeof(bool), typeof(BaseShellItem), BooleanBoxes.TrueBox, propertyChanged: OnFlyoutItemIsVisibleChanged); public BaseShellItem() { @@ -106,7 +106,7 @@ public ImageSource Icon public bool IsEnabled { get { return (bool)GetValue(IsEnabledProperty); } - set { SetValue(IsEnabledProperty, value); } + set { SetValue(IsEnabledProperty, BooleanBoxes.Box(value)); } } /// @@ -137,7 +137,7 @@ public string Title public bool IsVisible { get => (bool)GetValue(IsVisibleProperty); - set => SetValue(IsVisibleProperty, value); + set => SetValue(IsVisibleProperty, BooleanBoxes.Box(value)); } /// @@ -146,7 +146,7 @@ public bool IsVisible public bool FlyoutItemIsVisible { get => (bool)GetValue(FlyoutItemIsVisibleProperty); - set => SetValue(FlyoutItemIsVisibleProperty, value); + set => SetValue(FlyoutItemIsVisibleProperty, BooleanBoxes.Box(value)); } diff --git a/src/Controls/src/Core/Shell/SearchHandler.cs b/src/Controls/src/Core/Shell/SearchHandler.cs index 1bf1f5059a78..5bf0d6cbcd78 100644 --- a/src/Controls/src/Core/Shell/SearchHandler.cs +++ b/src/Controls/src/Core/Shell/SearchHandler.cs @@ -62,7 +62,7 @@ static void OnIsFocusedPropertyChanged(BindableObject bindable, object oldvalue, [EditorBrowsable(EditorBrowsableState.Never)] public void SetIsFocused(bool value) { - SetValue(IsFocusedPropertyKey, value, specificity: SetterSpecificity.FromHandler); + SetValue(IsFocusedPropertyKey, BooleanBoxes.Box(value), specificity: SetterSpecificity.FromHandler); } [EditorBrowsable(EditorBrowsableState.Never)] public event EventHandler FocusChangeRequested; @@ -237,7 +237,7 @@ public double FontSize public bool FontAutoScalingEnabled { get => (bool)GetValue(FontAutoScalingEnabledProperty); - set => SetValue(FontAutoScalingEnabledProperty, value); + set => SetValue(FontAutoScalingEnabledProperty, BooleanBoxes.Box(value)); } void IFontElement.OnFontFamilyChanged(string oldValue, string newValue) @@ -357,7 +357,7 @@ void ISearchHandlerController.QueryConfirmed() /// Bindable property for . public static readonly BindableProperty ClearPlaceholderEnabledProperty = - BindableProperty.Create(nameof(ClearPlaceholderEnabled), typeof(bool), typeof(SearchHandler), false); + BindableProperty.Create(nameof(ClearPlaceholderEnabled), typeof(bool), typeof(SearchHandler), BooleanBoxes.FalseBox); /// Bindable property for . public static readonly BindableProperty ClearPlaceholderHelpTextProperty = @@ -392,7 +392,7 @@ void ISearchHandlerController.QueryConfirmed() /// Bindable property for . public static readonly BindableProperty IsSearchEnabledProperty = - BindableProperty.Create(nameof(IsSearchEnabled), typeof(bool), typeof(SearchHandler), true, BindingMode.OneWay); + BindableProperty.Create(nameof(IsSearchEnabled), typeof(bool), typeof(SearchHandler), BooleanBoxes.TrueBox, BindingMode.OneWay); /// Bindable property for . public static readonly BindableProperty ItemsSourceProperty = @@ -435,7 +435,7 @@ void ISearchHandlerController.QueryConfirmed() /// Bindable property for . public static readonly BindableProperty ShowsResultsProperty = - BindableProperty.Create(nameof(ShowsResults), typeof(bool), typeof(SearchHandler), false, BindingMode.OneTime); + BindableProperty.Create(nameof(ShowsResults), typeof(bool), typeof(SearchHandler), BooleanBoxes.FalseBox, BindingMode.OneTime); private ListProxy _listProxy; @@ -484,7 +484,7 @@ public object ClearPlaceholderCommandParameter public bool ClearPlaceholderEnabled { get { return (bool)GetValue(ClearPlaceholderEnabledProperty); } - set { SetValue(ClearPlaceholderEnabledProperty, value); } + set { SetValue(ClearPlaceholderEnabledProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the accessibility help text for the clear placeholder icon. This is a bindable property. @@ -534,7 +534,7 @@ public string DisplayMemberName public bool IsSearchEnabled { get { return (bool)GetValue(IsSearchEnabledProperty); } - set { SetValue(IsSearchEnabledProperty, value); } + set { SetValue(IsSearchEnabledProperty, BooleanBoxes.Box(value)); } } /// Gets or sets the collection of items to display as search suggestions. This is a bindable property. @@ -593,12 +593,12 @@ public SearchBoxVisibility SearchBoxVisibility public bool ShowsResults { get { return (bool)GetValue(ShowsResultsProperty); } - set { SetValue(ShowsResultsProperty, value); } + set { SetValue(ShowsResultsProperty, BooleanBoxes.Box(value)); } } - bool ClearPlaceholderEnabledCore { set => SetValue(ClearPlaceholderEnabledProperty, value); } + bool ClearPlaceholderEnabledCore { set => SetValue(ClearPlaceholderEnabledProperty, BooleanBoxes.Box(value)); } - bool IsSearchEnabledCore { set => SetValue(IsSearchEnabledProperty, value); } + bool IsSearchEnabledCore { set => SetValue(IsSearchEnabledProperty, BooleanBoxes.Box(value)); } protected virtual void OnClearPlaceholderClicked() { diff --git a/src/Controls/src/Core/Shell/Shell.cs b/src/Controls/src/Core/Shell/Shell.cs index 175860c8ea8b..31e3b16f6b0d 100644 --- a/src/Controls/src/Core/Shell/Shell.cs +++ b/src/Controls/src/Core/Shell/Shell.cs @@ -68,13 +68,13 @@ static void OnBackButonBehaviorPropertyChanged(BindableObject bindable, object o /// Manages if the navigation bar is visible when a page is presented. /// public static readonly BindableProperty NavBarIsVisibleProperty = - BindableProperty.CreateAttached("NavBarIsVisible", typeof(bool), typeof(Shell), true, propertyChanged: OnNavBarIsVisibleChanged); + BindableProperty.CreateAttached("NavBarIsVisible", typeof(bool), typeof(Shell), BooleanBoxes.TrueBox, propertyChanged: OnNavBarIsVisibleChanged); /// /// Determines if the navigation bar visibility change should be animated. /// public static readonly BindableProperty NavBarVisibilityAnimationEnabledProperty = - BindableProperty.CreateAttached("NavBarVisibilityAnimationEnabled", typeof(bool), typeof(Shell), true); + BindableProperty.CreateAttached("NavBarVisibilityAnimationEnabled", typeof(bool), typeof(Shell), BooleanBoxes.TrueBox); private static void OnNavBarIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) { @@ -90,7 +90,7 @@ private static void OnNavBarIsVisibleChanged(BindableObject bindable, object old /// Controls whether the navigation bar has a shadow. /// public static readonly BindableProperty NavBarHasShadowProperty = - BindableProperty.CreateAttached("NavBarHasShadow", typeof(bool), typeof(Shell), default(bool), + BindableProperty.CreateAttached("NavBarHasShadow", typeof(bool), typeof(Shell), BooleanBoxes.FalseBox, defaultValueCreator: (b) => DeviceInfo.Platform == DevicePlatform.Android); /// @@ -113,7 +113,7 @@ static void OnSearchHandlerPropertyChanged(BindableObject bindable, object oldVa /// Flyout items are visible in the flyout by default. /// public static readonly BindableProperty FlyoutItemIsVisibleProperty = - BindableProperty.CreateAttached("FlyoutItemIsVisible", typeof(bool), typeof(Shell), true, propertyChanged: OnFlyoutItemIsVisibleChanged); + BindableProperty.CreateAttached("FlyoutItemIsVisible", typeof(bool), typeof(Shell), BooleanBoxes.TrueBox, propertyChanged: OnFlyoutItemIsVisibleChanged); public static bool GetFlyoutItemIsVisible(BindableObject obj) => (bool)obj.GetValue(FlyoutItemIsVisibleProperty); /// @@ -122,7 +122,7 @@ static void OnSearchHandlerPropertyChanged(BindableObject bindable, object oldVa /// /// The object that sets the visibility of flyout items. /// to set the flyout item as visible; otherwise, . - public static void SetFlyoutItemIsVisible(BindableObject obj, bool isVisible) => obj.SetValue(FlyoutItemIsVisibleProperty, isVisible); + public static void SetFlyoutItemIsVisible(BindableObject obj, bool isVisible) => obj.SetValue(FlyoutItemIsVisibleProperty, BooleanBoxes.Box(isVisible)); static void OnFlyoutItemIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) { @@ -142,7 +142,7 @@ static void OnFlyoutItemIsVisibleChanged(BindableObject bindable, object oldValu /// The tab bar and tabs are visible in applications by default. /// public static readonly BindableProperty TabBarIsVisibleProperty = - BindableProperty.CreateAttached("TabBarIsVisible", typeof(bool), typeof(Shell), true); + BindableProperty.CreateAttached("TabBarIsVisible", typeof(bool), typeof(Shell), BooleanBoxes.TrueBox); /// /// Enables any to be displayed in the navigation bar. @@ -317,7 +317,7 @@ internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject /// /// The object that modifies the navigation bar visibility. /// to set the navigation bar as visible; otherwise, . - public static void SetNavBarIsVisible(BindableObject obj, bool value) => obj.SetValue(NavBarIsVisibleProperty, value); + public static void SetNavBarIsVisible(BindableObject obj, bool value) => obj.SetValue(NavBarIsVisibleProperty, BooleanBoxes.Box(value)); /// /// Gets a value indicating whether the navigation bar visibility change is animated for the given . @@ -332,7 +332,7 @@ internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject /// /// The object that modifies the animation setting. /// to enable animation; otherwise, . - public static void SetNavBarVisibilityAnimationEnabled(BindableObject obj, bool value) => obj.SetValue(NavBarVisibilityAnimationEnabledProperty, value); + public static void SetNavBarVisibilityAnimationEnabled(BindableObject obj, bool value) => obj.SetValue(NavBarVisibilityAnimationEnabledProperty, BooleanBoxes.Box(value)); /// @@ -348,7 +348,7 @@ internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject /// /// The object that modifies if the navigation bar has a shadow. /// Manages if the navigation bar has a shadow. - public static void SetNavBarHasShadow(BindableObject obj, bool value) => obj.SetValue(NavBarHasShadowProperty, value); + public static void SetNavBarHasShadow(BindableObject obj, bool value) => obj.SetValue(NavBarHasShadowProperty, BooleanBoxes.Box(value)); /// /// Gets the integrated search functionality. @@ -381,7 +381,7 @@ internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject /// /// The object that modifies the tabs visibility. /// to set the tab bar as visible; otherwise, . - public static void SetTabBarIsVisible(BindableObject obj, bool value) => obj.SetValue(TabBarIsVisibleProperty, value); + public static void SetTabBarIsVisible(BindableObject obj, bool value) => obj.SetValue(TabBarIsVisibleProperty, BooleanBoxes.Box(value)); /// /// Gets any to be displayed in the navigation bar when the given is active. @@ -397,6 +397,27 @@ internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject /// The View to be displayed in the navigation bar. public static void SetTitleView(BindableObject obj, View value) => obj.SetValue(TitleViewProperty, value); + // Determines whether the Shell's Title was set by the user (explicit code, style, or a binding) + // as opposed to being mirrored from the current page by the renderer (FromHandler) or never set + // at all (DefaultValue). This lets ShellToolbar mirror the page title into Shell.Title for + // TitleView bindings without clobbering a title the user set intentionally. + internal bool IsTitleSetByUser() + { + if (GetIsBound(TitleProperty)) + return true; + + var context = GetContext(TitleProperty); + if (context is null) + return false; + + var specificity = context.Values.GetSpecificity(); + return specificity != SetterSpecificity.DefaultValue && specificity != SetterSpecificity.FromHandler; + } + + // Returns the Title only when it was set by the user. Used by the native window title + // fallback so that a renderer-mirrored page title never leaks into the platform chrome. + internal string GetUserSetTitle() => IsTitleSetByUser() ? Title : null; + static void OnFlyoutBehaviorChanged(BindableObject bindable, object oldValue, object newValue) { var element = (Element)bindable; @@ -898,7 +919,7 @@ Task OnFlyoutItemSelectedAsync(Element element, bool platformInitiated) shellContent = shellContent ?? shellSection?.CurrentItem; if (platformInitiated && FlyoutIsPresented && GetEffectiveFlyoutBehavior() != FlyoutBehavior.Locked) - SetValueFromRenderer(FlyoutIsPresentedProperty, false); + SetValueFromRenderer(FlyoutIsPresentedProperty, BooleanBoxes.FalseBox); if (shellSection == null) shellItem.PropertyChanged += OnShellItemPropertyChanged; @@ -1235,7 +1256,7 @@ public Task GoToAsync(ShellNavigationState state, bool animate, ShellNavigationQ /// The flyout can be programmatically opened and closed by setting the FlyoutIsPresented property to a boolean value that indicates whether the flyout is currently open. /// public static readonly BindableProperty FlyoutIsPresentedProperty = - BindableProperty.Create(nameof(FlyoutIsPresented), typeof(bool), typeof(Shell), false, BindingMode.TwoWay); + BindableProperty.Create(nameof(FlyoutIsPresented), typeof(bool), typeof(Shell), BooleanBoxes.FalseBox, BindingMode.TwoWay); /// Bindable property for . public static readonly BindableProperty ItemsProperty = ItemsPropertyKey.BindableProperty; @@ -1570,7 +1591,7 @@ public DataTemplate FlyoutFooterTemplate public bool FlyoutIsPresented { get => (bool)GetValue(FlyoutIsPresentedProperty); - set => SetValue(FlyoutIsPresentedProperty, value); + set => SetValue(FlyoutIsPresentedProperty, BooleanBoxes.Box(value)); } /// Gets the collection of objects in the Shell. This is a bindable property. @@ -1796,8 +1817,8 @@ void SendNavigating(ShellNavigatingEventArgs args) // correctly reflects the destination page at that point. _previousPage = CurrentPage; } - - // Unsubscribe Loaded handler if navigating away before page loads to prevent memory leaks. + + // Unsubscribe Loaded handler if navigating away before page loads to prevent memory leaks. if (CurrentPage != null && !CurrentPage.IsLoadedFired) { CurrentPage.Loaded -= OnCurrentPageLoaded; diff --git a/src/Controls/src/Core/Shell/ShellSection.cs b/src/Controls/src/Core/Shell/ShellSection.cs index 549164c2a1fb..809cfde080d2 100644 --- a/src/Controls/src/Core/Shell/ShellSection.cs +++ b/src/Controls/src/Core/Shell/ShellSection.cs @@ -1032,6 +1032,24 @@ static void OnCurrentItemChanged(BindableObject bindable, object oldValue, objec { var shellSection = (ShellSection)bindable; + var isFromHandler = + shellSection.GetContext(CurrentItemProperty)?.Values.GetSpecificity() == SetterSpecificity.FromHandler; + + if (!isFromHandler && + newValue is ShellContent newContent && + shellSection.Parent?.Parent is Shell parentShell && + shellSection.IsVisibleSection) + { + parentShell.NavigationManager.ProposeNavigationOutsideGotoAsync( + ShellNavigationSource.ShellContentChanged, + parentShell.CurrentItem, + shellSection, + newContent, + shellSection.Stack, + canCancel: false, + isAnimated: true); + } + if (oldValue is ShellContent oldShellItem) oldShellItem.SendDisappearing(); @@ -1041,9 +1059,7 @@ static void OnCurrentItemChanged(BindableObject bindable, object oldValue, objec shellSection.PresentedPageAppearing(); if (shellSection.Parent?.Parent is IShellController shell && shellSection.IsVisibleSection) - { shell.UpdateCurrentState(ShellNavigationSource.ShellContentChanged); - } shellSection.SendStructureChanged(); diff --git a/src/Controls/src/Core/ShellToolbar.cs b/src/Controls/src/Core/ShellToolbar.cs index d74cbf82a16d..3c76c6f856eb 100644 --- a/src/Controls/src/Core/ShellToolbar.cs +++ b/src/Controls/src/Core/ShellToolbar.cs @@ -169,27 +169,36 @@ internal void UpdateTitle() Shell.TitleViewProperty, Shell.GetTitleView(_shell)); - if (TitleView != null) - { - Title = String.Empty; - return; - } + var title = GetCurrentTitle(); + + // Mirror the current page's title into Shell.Title so that bindings inside a custom + // TitleView (e.g. {Binding Title, Source={x:Reference shell}}) can resolve to it. + // Use SetValueFromRenderer (FromHandler specificity) so the value is recognized as + // renderer-generated and does not leak into the native window title (see Window.cs), + // and only when the user hasn't explicitly set Shell.Title themselves. + if (!_shell.IsTitleSetByUser()) + _shell.SetValueFromRenderer(Page.TitleProperty, title); + + // The native nav-bar title must be empty when a custom TitleView is present, + // otherwise it should reflect the current page title. + Title = TitleView != null ? String.Empty : title; + } + string GetCurrentTitle() + { Page? currentPage = _shell.GetCurrentShellPage(); if (currentPage?.IsSet(Page.TitleProperty) == true) { - Title = currentPage.Title ?? String.Empty; + return currentPage.Title ?? String.Empty; } // We only want to use the ShellContent as a title if no pages have been // Pushed onto the stack else if (_shell.Navigation?.NavigationStack?.Count <= 1) { - Title = _shell.CurrentContent?.Title ?? String.Empty; - } - else - { - Title = String.Empty; + return _shell.CurrentContent?.Title ?? String.Empty; } + + return String.Empty; } } } diff --git a/src/Controls/src/Core/StateTrigger.cs b/src/Controls/src/Core/StateTrigger.cs index 239f2a1dfaa6..aba72ff65b21 100644 --- a/src/Controls/src/Core/StateTrigger.cs +++ b/src/Controls/src/Core/StateTrigger.cs @@ -1,4 +1,5 @@ #nullable disable +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { /// @@ -12,12 +13,12 @@ public sealed class StateTrigger : StateTriggerBase public new bool IsActive { get => (bool)GetValue(IsActiveProperty); - set => SetValue(IsActiveProperty, value); + set => SetValue(IsActiveProperty, BooleanBoxes.Box(value)); } /// Bindable property for . public static readonly BindableProperty IsActiveProperty = - BindableProperty.Create(nameof(IsActive), typeof(bool), typeof(StateTrigger), default(bool), + BindableProperty.Create(nameof(IsActive), typeof(bool), typeof(StateTrigger), BooleanBoxes.FalseBox, propertyChanged: OnIsActiveChanged); static void OnIsActiveChanged(BindableObject bindable, object oldvalue, object newvalue) diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 8505094c9eb6..9280923edb47 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls @@ -14,7 +15,7 @@ public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemM public static readonly BindableProperty BackgroundColorProperty = BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(SwipeItem), null); /// Bindable property for . - public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(SwipeItem), true, propertyChanged: OnIsVisibleChanged); + public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(SwipeItem), BooleanBoxes.TrueBox, propertyChanged: OnIsVisibleChanged); /// /// Gets or sets the background color of the swipe item. This is a bindable property. @@ -31,7 +32,7 @@ public Color BackgroundColor public bool IsVisible { get { return (bool)GetValue(IsVisibleProperty); } - set { SetValue(IsVisibleProperty, value); } + set { SetValue(IsVisibleProperty, BooleanBoxes.Box(value)); } } public event EventHandler Invoked; diff --git a/src/Controls/src/Core/Switch/Switch.cs b/src/Controls/src/Core/Switch/Switch.cs index b988bdc84aa6..916e945a143e 100644 --- a/src/Controls/src/Core/Switch/Switch.cs +++ b/src/Controls/src/Core/Switch/Switch.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; using System.Runtime.CompilerServices; +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; namespace Microsoft.Maui.Controls @@ -29,7 +30,7 @@ public partial class Switch : View, IElementConfiguration, ISwitch public const string SwitchOffVisualState = "Off"; /// Bindable property for . This is a bindable property. - public static readonly BindableProperty IsToggledProperty = BindableProperty.Create(nameof(IsToggled), typeof(bool), typeof(Switch), false, propertyChanged: (bindable, oldValue, newValue) => + public static readonly BindableProperty IsToggledProperty = BindableProperty.Create(nameof(IsToggled), typeof(bool), typeof(Switch), BooleanBoxes.FalseBox, propertyChanged: (bindable, oldValue, newValue) => { ((Switch)bindable).Toggled?.Invoke(bindable, new ToggledEventArgs((bool)newValue)); ((Switch)bindable).ChangeVisualState(); @@ -106,7 +107,7 @@ public Switch() public bool IsToggled { get { return (bool)GetValue(IsToggledProperty); } - set { SetValue(IsToggledProperty, value); } + set { SetValue(IsToggledProperty, BooleanBoxes.Box(value)); } } protected internal override void ChangeVisualState() @@ -156,7 +157,7 @@ Color ISwitch.TrackColor bool ISwitch.IsOn { get => IsToggled; - set => SetValue(IsToggledProperty, value, SetterSpecificity.FromHandler); + set => SetValue(IsToggledProperty, BooleanBoxes.Box(value), SetterSpecificity.FromHandler); } private protected override string GetDebuggerDisplay() diff --git a/src/Controls/src/Core/TabbedPage/TabbedPage.Tizen.cs b/src/Controls/src/Core/TabbedPage/TabbedPage.Tizen.cs index 11cf62260843..2b22f191c78e 100644 --- a/src/Controls/src/Core/TabbedPage/TabbedPage.Tizen.cs +++ b/src/Controls/src/Core/TabbedPage/TabbedPage.Tizen.cs @@ -164,7 +164,7 @@ static DataTemplate GetTemplate(TabbedPage page) class TabbedItem : Frame #pragma warning restore CS0618 // Type or member is obsolete { - static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(TabbedItem), false, propertyChanged: (b, o, n) => ((TabbedItem)b).UpdateSelectedState()); + static readonly BindableProperty SelectedStateProperty = BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(TabbedItem), BooleanBoxes.FalseBox, propertyChanged: (b, o, n) => ((TabbedItem)b).UpdateSelectedState()); static readonly BindableProperty SelectedTabColorProperty = BindableProperty.Create(nameof(SelectedTabColor), typeof(GColor), typeof(TabbedItem), default(Color), propertyChanged: (b, o, n) => ((TabbedItem)b).UpdateSelectedState()); static readonly BindableProperty UnselectedTabColorProperty = BindableProperty.Create(nameof(UnselectedTabColor), typeof(GColor), typeof(TabbedItem), default(Color), propertyChanged: (b, o, n) => ((TabbedItem)b).UpdateSelectedState()); @@ -174,7 +174,7 @@ class TabbedItem : Frame public bool IsSelected { get => (bool)GetValue(SelectedStateProperty); - set => SetValue(SelectedStateProperty, value); + set => SetValue(SelectedStateProperty, BooleanBoxes.Box(value)); } public GColor SelectedTabColor diff --git a/src/Controls/src/Core/TableView/TableView.cs b/src/Controls/src/Core/TableView/TableView.cs index b13cf3880148..a5ee334b70f3 100644 --- a/src/Controls/src/Core/TableView/TableView.cs +++ b/src/Controls/src/Core/TableView/TableView.cs @@ -22,7 +22,7 @@ public class TableView : View, ITableViewController, IElementConfigurationBindable property for . - public static readonly BindableProperty HasUnevenRowsProperty = BindableProperty.Create(nameof(HasUnevenRows), typeof(bool), typeof(TableView), false); + public static readonly BindableProperty HasUnevenRowsProperty = BindableProperty.Create(nameof(HasUnevenRows), typeof(bool), typeof(TableView), BooleanBoxes.FalseBox); readonly Lazy> _platformConfigurationRegistry; @@ -57,7 +57,7 @@ public TableView(TableRoot root) public bool HasUnevenRows { get { return (bool)GetValue(HasUnevenRowsProperty); } - set { SetValue(HasUnevenRowsProperty, value); } + set { SetValue(HasUnevenRowsProperty, BooleanBoxes.Box(value)); } } /// diff --git a/src/Controls/src/Core/TemplatedItemsList.cs b/src/Controls/src/Core/TemplatedItemsList.cs index 86f288710ebd..ee63ab85cda8 100644 --- a/src/Controls/src/Core/TemplatedItemsList.cs +++ b/src/Controls/src/Core/TemplatedItemsList.cs @@ -36,7 +36,7 @@ public sealed class TemplatedItemsList OperatingSystem.IsMacCatalystVersionAtLeast(26) ? MacCatalystMarginLiquidGlass : MacCatalystMargin; + + bool IsMacCatalystFullScreen() + { + if (OperatingSystem.IsMacCatalystVersionAtLeast(16) + && Window?.Handler?.PlatformView is UIKit.UIWindow uiwindow) + { + return uiwindow.WindowScene?.FullScreen ?? false; + } + + return false; + } + + void ApplyMacCatalystMargin() + { + if (!_isDefaultControlTemplate) + { + return; + } + + if (_templateRoot is not Grid contentGrid) + { + return; + } + + if (IsMacCatalystFullScreen()) + { + contentGrid.Margin = new Thickness(0); + return; + } + + contentGrid.Margin = FlowDirection == FlowDirection.RightToLeft + ? new Thickness(0, 0, GetMacCatalystLeadingMargin(), 0) + : new Thickness(GetMacCatalystLeadingMargin(), 0, 0, 0); + } #endif // Margin space (150px) required for Windows title bar system buttons @@ -313,21 +347,40 @@ public Color ForegroundColor static ControlTemplate? _defaultTemplate; View? _templateRoot; +#if MACCATALYST + bool _isDefaultControlTemplate; +#endif public TitleBar() { PassthroughElements = new List(); PropertyChanged += TitleBar_PropertyChanged; +#if MACCATALYST + SizeChanged += OnSizeChanged; +#endif + if (ControlTemplate is null) { ControlTemplate = DefaultTemplate; } } +#if MACCATALYST + void OnSizeChanged(object? sender, EventArgs e) + { + ApplyMacCatalystMargin(); + } +#endif + internal void Cleanup() { PropertyChanged -= TitleBar_PropertyChanged; + +#if MACCATALYST + SizeChanged -= OnSizeChanged; +#endif + if (Window is not null) { Window.Activated -= Window_Activated; @@ -358,6 +411,11 @@ void UpdateFlowDirectionState() : TitleBarLTRState; ApplyVisibleState(flowDirectionState); + +#if MACCATALYST + ApplyMacCatalystMargin(); +#endif + } internal void ApplyVisibleState(string stateGroup) @@ -391,6 +449,10 @@ protected override void OnApplyTemplate() _templateRoot = controlTemplate?.TemplateRoot as View; +#if MACCATALYST + _isDefaultControlTemplate = ReferenceEquals(ControlTemplate, DefaultTemplate); +#endif + if (controlTemplate?.GetTemplateChild(TitleBarLeading) is IView leadingContent) { PassthroughElements.Add(leadingContent); @@ -428,7 +490,7 @@ static View BuildDefaultTemplate() var contentGrid = new Grid() { #if MACCATALYST - Margin = new Thickness(GetMacCatalystLeadingMargin(), 0, 0, 0), + Margin = new Thickness(0), #endif HorizontalOptions = LayoutOptions.Fill, ColumnDefinitions = @@ -630,30 +692,30 @@ static View BuildDefaultTemplate() // Left-to-Right state (default) var ltrState = new VisualState() { Name = TitleBarLTRState }; + +#if !MACCATALYST ltrState.Setters.Add(new Setter() { Property = MarginProperty, TargetName = TemplateRootName, -#if MACCATALYST - Value = new Thickness(GetMacCatalystLeadingMargin(), 0, 0, 0) // System buttons on left in macOS -#else Value = new Thickness(0, 0, WindowsMargin, 0) // System buttons on right in Windows -#endif }); +#endif + flowDirectionGroup.States.Add(ltrState); // Right-to-Left state var rtlState = new VisualState() { Name = TitleBarRTLState }; + +#if !MACCATALYST rtlState.Setters.Add(new Setter() { Property = MarginProperty, TargetName = TemplateRootName, -#if MACCATALYST - Value = new Thickness(0, 0, GetMacCatalystLeadingMargin(), 0) // System buttons on right in macOS RTL -#else Value = new Thickness(WindowsMargin, 0, 0, 0) // System buttons on left in Windows RTL -#endif }); +#endif + flowDirectionGroup.States.Add(rtlState); visualStateGroups.Add(flowDirectionGroup); diff --git a/src/Controls/src/Core/UriImageSource.cs b/src/Controls/src/Core/UriImageSource.cs index 4889f907d74c..8f4130182b7b 100644 --- a/src/Controls/src/Core/UriImageSource.cs +++ b/src/Controls/src/Core/UriImageSource.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Maui.Controls.Internals; namespace Microsoft.Maui.Controls { @@ -24,7 +25,7 @@ public sealed partial class UriImageSource : ImageSource, IStreamImageSource /// Bindable property for . public static readonly BindableProperty CachingEnabledProperty = BindableProperty.Create( - nameof(CachingEnabled), typeof(bool), typeof(UriImageSource), true); + nameof(CachingEnabled), typeof(bool), typeof(UriImageSource), BooleanBoxes.TrueBox); /// Gets a value indicating whether this image source is empty. public override bool IsEmpty => Uri == null; @@ -40,7 +41,7 @@ public TimeSpan CacheValidity public bool CachingEnabled { get => (bool)GetValue(CachingEnabledProperty); - set => SetValue(CachingEnabledProperty, value); + set => SetValue(CachingEnabledProperty, BooleanBoxes.Box(value)); } /// Gets or sets the URI of the image to load. This is a bindable property. diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index f0455bb57bb5..ec9393ffb942 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -34,20 +34,14 @@ public partial class VisualElement : NavigableElement, IAnimatable, IVisualEleme /// Bindable property for . public static readonly BindableProperty InputTransparentProperty = BindableProperty.Create( - nameof(InputTransparent), typeof(bool), typeof(VisualElement), default(bool), + nameof(InputTransparent), typeof(bool), typeof(VisualElement), BooleanBoxes.FalseBox, propertyChanged: OnInputTransparentPropertyChanged, coerceValue: CoerceInputTransparentProperty); bool _isEnabledExplicit = (bool)IsEnabledProperty.DefaultValue; - /// - /// Gets the explicit value of set directly on this element, - /// before coercion by which factors in parent state. - /// - internal bool IsExplicitlyEnabled => _isEnabledExplicit; - /// Bindable property for . public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create(nameof(IsEnabled), typeof(bool), - typeof(VisualElement), true, propertyChanged: OnIsEnabledPropertyChanged, coerceValue: CoerceIsEnabledProperty); + typeof(VisualElement), BooleanBoxes.TrueBox, propertyChanged: OnIsEnabledPropertyChanged, coerceValue: CoerceIsEnabledProperty); static readonly BindablePropertyKey XPropertyKey = BindableProperty.CreateReadOnly(nameof(X), typeof(double), typeof(VisualElement), default(double)); @@ -276,7 +270,7 @@ static void OnTransformChanged(BindableObject bindable, object oldValue, object propertyChanged: (b, o, n) => { (((VisualElement)b).AnchorX, ((VisualElement)b).AnchorY) = (Point)n; }); /// Bindable property for . - public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(VisualElement), true, + public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(VisualElement), BooleanBoxes.TrueBox, propertyChanged: (bindable, oldvalue, newvalue) => ((VisualElement)bindable).OnIsVisibleChanged((bool)oldvalue, (bool)newvalue)); /// Bindable property for . @@ -642,7 +636,7 @@ public double HeightRequest public bool InputTransparent { get { return (bool)GetValue(InputTransparentProperty); } - set { SetValue(InputTransparentProperty, value); } + set { SetValue(InputTransparentProperty, BooleanBoxes.Box(value)); } } /// @@ -655,7 +649,7 @@ public bool InputTransparent public bool IsEnabled { get { return (bool)GetValue(IsEnabledProperty); } - set { SetValue(IsEnabledProperty, value); } + set { SetValue(IsEnabledProperty, BooleanBoxes.Box(value)); } } /// @@ -728,7 +722,7 @@ private protected bool InputTransparentCore public bool IsVisible { get { return (bool)GetValue(IsVisibleProperty); } - set { SetValue(IsVisibleProperty, value); } + set { SetValue(IsVisibleProperty, BooleanBoxes.Box(value)); } } /// @@ -1769,10 +1763,10 @@ static object CoerceIsEnabledProperty(BindableObject bindable, object value) if (bindable is VisualElement visualElement) { visualElement._isEnabledExplicit = (bool)value; - return visualElement.IsEnabledCore; + return BooleanBoxes.Box(visualElement.IsEnabledCore); } - return false; + return BooleanBoxes.FalseBox; } static void OnIsEnabledPropertyChanged(BindableObject bindable, object oldValue, object newValue) @@ -1792,10 +1786,10 @@ static object CoerceInputTransparentProperty(BindableObject bindable, object val if (bindable is VisualElement visualElement) { visualElement._inputTransparentExplicit = (bool)value; - return visualElement.InputTransparentCore; + return BooleanBoxes.Box(visualElement.InputTransparentCore); } - return false; + return BooleanBoxes.FalseBox; } static void OnInputTransparentPropertyChanged(BindableObject bindable, object oldValue, object newValue) @@ -2105,7 +2099,7 @@ protected virtual Size MeasureOverride(double widthConstraint, double heightCons bool IView.IsFocused { get => (bool)GetValue(IsFocusedProperty); - set => SetValue(IsFocusedPropertyKey, value, SetterSpecificity.FromHandler); + set => SetValue(IsFocusedPropertyKey, BooleanBoxes.Box(value), SetterSpecificity.FromHandler); } /// diff --git a/src/Controls/src/Core/VisualStateManager.cs b/src/Controls/src/Core/VisualStateManager.cs index 9682fa096314..31c5aa486ce9 100644 --- a/src/Controls/src/Core/VisualStateManager.cs +++ b/src/Controls/src/Core/VisualStateManager.cs @@ -38,6 +38,15 @@ static void VisualStateGroupsPropertyChanged(BindableObject bindable, object old foreach (var group in oldVisualStateGroupList) { + // Detach triggers first so OnDetached() unsubscribes window events before visual cleanup. + foreach (var visualState in group.States) + { + foreach (var trigger in visualState.StateTriggers) + { + trigger.SendDetached(); + } + } + if (group.CurrentState is { } state) { // Only promote system-driven states (Disabled, Focused, etc.) to full VSM priority. @@ -62,6 +71,14 @@ static void VisualStateGroupsPropertyChanged(BindableObject bindable, object old visualElement.ChangeVisualState(); UpdateStateTriggers(visualElement); + + // Attach state triggers from the incoming groups if the element is already in a Window. + // Normally triggers are attached via VisualElement.InvalidateStateTriggers(true) when the + // element joins a Window, but that event has already fired before this replacement occurs. + if (newValue != null && visualElement.Window != null) + { + visualElement.InvalidateStateTriggers(true); + } } /// @@ -87,6 +104,9 @@ public static void SetVisualStateGroups(VisualElement visualElement, VisualState /// The name of the visual state to transition to. /// if the transition was successful; otherwise, . public static bool GoToState(VisualElement visualElement, string name) + => GoToState(visualElement, name, force: false); + + internal static bool GoToState(VisualElement visualElement, string name, bool force) { var context = visualElement.GetContext(VisualStateGroupsProperty); if (context is null) @@ -108,12 +128,6 @@ public static bool GoToState(VisualElement visualElement, string name) foreach (VisualStateGroup group in groups) { - if (group.CurrentState?.Name == name) - { - // We're already in the target state; nothing else to do - return true; - } - // See if this group contains the new state var target = group.GetState(name); if (target == null) @@ -121,6 +135,12 @@ public static bool GoToState(VisualElement visualElement, string name) continue; } + if (group.CurrentState?.Name == name && !force) + { + // We're already in the target state; nothing else to do + return true; + } + // If we've got a new state to transition to, unapply the setters from the current state if (group.CurrentState != null) { diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index e961caffd4a2..55d90e5025a4 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -131,7 +131,7 @@ public bool IsActivated private set => SetValue(IsActivatedPropertyKey, value); } - string? ITitledElement.Title => Title ?? (Page as Shell)?.Title; + string? ITitledElement.Title => Title ?? (Page as Shell)?.GetUserSetTitle(); public Page? Page { diff --git a/src/Controls/src/SourceGen/KnownMarkups.cs b/src/Controls/src/SourceGen/KnownMarkups.cs index e14831785316..902da5f74489 100644 --- a/src/Controls/src/SourceGen/KnownMarkups.cs +++ b/src/Controls/src/SourceGen/KnownMarkups.cs @@ -340,17 +340,26 @@ private static bool ProvideValueForBindingExtension(ElementNode markupNode, Inde { returnType = context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.BindingBase")!; ITypeSymbol? dataTypeSymbol = null; - - // When Source is explicitly set (RelativeSource or x:Reference), x:DataType does not describe - // the actual source — skip compilation and fall back to runtime binding. + + // When Source is explicitly set, inherited x:DataType usually does not describe the actual source. + // RelativeSource with AncestorType is the exception: AncestorType defines the source type. + // RelativeSource Self should never compile to TypedBinding because the source is the view itself. bool hasExplicitSource = HasExplicitBindingSource(markupNode); - + bool xDataTypeOnBindingNode = markupNode.Properties.ContainsKey(XmlName.xDataType); + bool isRelativeSourceWithoutAncestorType = IsRelativeSourceWithoutAncestorType(markupNode, context); + context.Variables.TryGetValue(markupNode, out ILocalValue? extVariable); - - if ( !hasExplicitSource - && extVariable is not null) + + if (!isRelativeSourceWithoutAncestorType && extVariable is not null) { - TryGetXDataType(markupNode, context, out dataTypeSymbol); + if (!hasExplicitSource || xDataTypeOnBindingNode) + { + TryGetXDataType(markupNode, context, out dataTypeSymbol); + } + else if (TryGetRelativeSourceAncestorType(markupNode, context, out var ancestorType)) + { + dataTypeSymbol = ancestorType; + } if (dataTypeSymbol is not null) { @@ -629,6 +638,97 @@ static bool IsBindingContextBinding(ElementNode node) && propertyName.LocalName == "BindingContext"; } + static bool IsRelativeSourceWithoutAncestorType(ElementNode bindingNode, SourceGenContext context) + { + if (!TryGetRelativeSourceNode(bindingNode, out var relativeSourceNode)) + { + return false; + } + + return !TryGetRelativeSourceAncestorType(bindingNode, context, out _); + } + + static bool TryGetRelativeSourceAncestorType(ElementNode bindingNode, SourceGenContext context, out ITypeSymbol? ancestorType) + { + ancestorType = null; + + if (!TryGetRelativeSourceNode(bindingNode, out var relativeSourceNode)) + { + return false; + } + + if (!relativeSourceNode.Properties.TryGetValue(new XmlName("", "AncestorType"), out INode? ancestorTypeNode) + && !relativeSourceNode.Properties.TryGetValue(new XmlName(null, "AncestorType"), out ancestorTypeNode) + && !relativeSourceNode.Properties.TryGetValue(new XmlName(XamlParser.MauiUri, "AncestorType"), out ancestorTypeNode)) + { + return false; + } + + if (ancestorTypeNode is ElementNode typeExtNode) + { + if (context.Types.TryGetValue(typeExtNode, out var resolvedType)) + { + ancestorType = resolvedType; + return true; + } + + if (!typeExtNode.Properties.TryGetValue(new XmlName("", "TypeName"), out INode? typeNameNode) + && !typeExtNode.Properties.TryGetValue(new XmlName(null, "TypeName"), out typeNameNode) + && !typeExtNode.Properties.TryGetValue(new XmlName(XamlParser.MauiUri, "TypeName"), out typeNameNode) + && typeExtNode.CollectionItems.Count == 1) + { + typeNameNode = typeExtNode.CollectionItems[0]; + } + + if (typeNameNode is ValueNode { Value: string typeName } && !IsNullOrEmpty(typeName)) + { + XmlType xmlType = TypeArgumentsParser.ParseSingle(typeName, typeExtNode.NamespaceResolver, typeExtNode as IXmlLineInfo); + if (xmlType.TryResolveTypeSymbol(null, context.Compilation, context.XmlnsCache, context.TypeCache, out var resolvedAncestorType) + && resolvedAncestorType is not null) + { + ancestorType = resolvedAncestorType; + context.Types[typeExtNode] = resolvedAncestorType; + return true; + } + } + + return false; + } + + if (ancestorTypeNode is ValueNode { Value: string directTypeName } && !IsNullOrEmpty(directTypeName)) + { + XmlType xmlType = TypeArgumentsParser.ParseSingle(directTypeName, bindingNode.NamespaceResolver, bindingNode as IXmlLineInfo); + if (xmlType.TryResolveTypeSymbol(null, context.Compilation, context.XmlnsCache, context.TypeCache, out var resolvedAncestorType) + && resolvedAncestorType is not null) + { + ancestorType = resolvedAncestorType; + return true; + } + } + + return false; + } + + static bool TryGetRelativeSourceNode(ElementNode bindingNode, out ElementNode relativeSourceNode) + { + relativeSourceNode = null!; + + if ((!bindingNode.Properties.TryGetValue(new XmlName("", "Source"), out INode? sourceNode) + && !bindingNode.Properties.TryGetValue(new XmlName(null, "Source"), out sourceNode)) + || sourceNode is not ElementNode sourceElementNode) + { + return false; + } + + if (sourceElementNode.XmlType.Name is not "RelativeSourceExtension" and not "RelativeSource") + { + return false; + } + + relativeSourceNode = sourceElementNode; + return true; + } + // Checks if the binding has a Source property set to RelativeSource or x:Reference. // When Source is explicitly set, x:DataType does not describe the actual binding source, // so we should NOT compile the binding using x:DataType. diff --git a/src/Controls/src/Xaml/MarkupExtensions/BindingExtension.cs b/src/Controls/src/Xaml/MarkupExtensions/BindingExtension.cs index 5d86ae0daa12..010bdf873be7 100644 --- a/src/Controls/src/Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Controls/src/Xaml/MarkupExtensions/BindingExtension.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Controls.Xaml.Internals; namespace Microsoft.Maui.Controls.Xaml { @@ -83,29 +84,42 @@ BindingBase IMarkupExtension.ProvideValue(IServiceProvider serviceP BindingBase CreateBinding() { Type bindingXDataType = null; + IXamlDataTypeProvider dataTypeProvider = null; if (serviceProvider is not null && (serviceProvider.GetService(typeof(IXamlTypeResolver)) is IXamlTypeResolver typeResolver) - && (serviceProvider.GetService(typeof(IXamlDataTypeProvider)) is IXamlDataTypeProvider dataTypeProvider) - && dataTypeProvider.BindingDataType != null) + && (serviceProvider.GetService(typeof(IXamlDataTypeProvider)) is IXamlDataTypeProvider dtProvider) + && dtProvider.BindingDataType != null) { + dataTypeProvider = dtProvider; typeResolver.TryResolve(dataTypeProvider.BindingDataType, out bindingXDataType); } + // Runtime inflation still creates a string-path Binding. This path is intentionally + // not trim-safe for AOT (it is reflection-based and carries trim warnings). + // SourceGen/XamlC TypedBinding generation is the trim-safe path. return new Binding(Path, Mode, Converter, ConverterParameter, StringFormat, Source) { UpdateSourceEventName = UpdateSourceEventName, FallbackValue = FallbackValue, TargetNullValue = TargetNullValue, - // When Source is set to a concrete element reference (e.g. x:Reference), the - // DataType from IXamlDataTypeProvider reflects the DataTemplate item type, not - // the explicit source type. Assigning that mismatched DataType causes - // BindingExpression.Apply to null-out the binding source when - // IsXamlCBindingWithSourceCompilationEnabled is true (.NET 10 default for - // AOT/trimmed builds). See https://github.com/dotnet/maui/issues/33291. + // When Source is set to any explicit source, the DataType from + // IXamlDataTypeProvider may reflect the DataTemplate item type (inherited from an + // ancestor DataTemplate) rather than the explicit source type. Assigning that + // mismatched DataType causes BindingExpression.Apply to null-out the binding + // source when IsXamlCBindingWithSourceCompilationEnabled is true (.NET 10 default + // for AOT/trimmed builds). // - // RelativeBindingSource is excluded: the developer likely set x:DataType on - // the binding to describe the expected type of the resolved ancestor, and - // that validation should be preserved. - DataType = (Source is null || Source is RelativeBindingSource) ? bindingXDataType : null, + // - Source=null → DataType applies (BindingContext IS described by x:DataType) + // - Source={x:Reference} → DataType must be null (see https://github.com/dotnet/maui/issues/33291) + // - Source={RelativeSource AncestorType=...} + // • x:DataType was set *directly* on the Binding itself: the developer explicitly + // typed the binding source, so preserve DataType for the type-mismatch check. + // • x:DataType was *inherited* from a DataTemplate ancestor: the type describes + // the template item, not the ancestor — set DataType to null to avoid false + // mismatch failures (see https://github.com/dotnet/maui/issues/35564). + DataType = Source is null || (Source is RelativeBindingSource + && dataTypeProvider is IXamlDataTypeProviderWithBindingNodeInfo { IsDataTypeOnBindingNode: true }) + ? bindingXDataType + : null, }; } } diff --git a/src/Controls/src/Xaml/XamlServiceProvider.cs b/src/Controls/src/Xaml/XamlServiceProvider.cs index c2aa755c0178..43ca1607971c 100644 --- a/src/Controls/src/Xaml/XamlServiceProvider.cs +++ b/src/Controls/src/Xaml/XamlServiceProvider.cs @@ -320,7 +320,18 @@ public string LookupNamespace(string prefix) public void Add(string prefix, string ns) => namespaces.Add(prefix, ns); } - public class XamlDataTypeProvider : IXamlDataTypeProvider + /// + /// Extended internal interface for implementations + /// that can report whether the x:DataType was declared directly on the binding node + /// (as opposed to being inherited from an ancestor such as a DataTemplate). + /// This avoids a concrete cast to in consumers. + /// + internal interface IXamlDataTypeProviderWithBindingNodeInfo : IXamlDataTypeProvider + { + bool IsDataTypeOnBindingNode { get; } + } + + public class XamlDataTypeProvider : IXamlDataTypeProviderWithBindingNodeInfo { public XamlDataTypeProvider(string dataType) => this.dataType = dataType; @@ -369,12 +380,16 @@ static bool DoesNotInheritDataType(ElementNode node, HydrationContext context) INode dataTypeNode = null; ElementNode n = node as ElementNode; + var firstNode = n; // Special handling for BindingContext={Binding ...} // The order of checks is: // - x:DataType on the binding itself // - SKIP looking for x:DataType on the parent // - continue looking for x:DataType on the parent's parent... + // Note: skipNode = GetParent(node), so skipNode CANNOT equal firstNode (= node). + // The first loop iteration always checks the binding node itself for x:DataType, + // regardless of whether it is a BindingContext binding. ElementNode skipNode = null; if (IsBindingContextBinding(node)) { @@ -396,9 +411,21 @@ static bool DoesNotInheritDataType(ElementNode node, HydrationContext context) } if (dataTypeNode is ValueNode valueNode) this.dataType = valueNode.Value as string; + // Track whether x:DataType was found directly on the binding node, not inherited from + // an ancestor (e.g. a DataTemplate). This lets BindingExtension correctly skip the + // DataType for RelativeSource bindings whose DataType is only the DataTemplate item type. + IsDataTypeOnBindingNode = dataTypeNode != null && n == firstNode; } string dataType; string IXamlDataTypeProvider.BindingDataType => dataType; + bool IXamlDataTypeProviderWithBindingNodeInfo.IsDataTypeOnBindingNode => IsDataTypeOnBindingNode; internal HydrationContext Context { get; } + + /// + /// Gets whether the x:DataType was found directly on the binding node itself + /// (as opposed to being inherited from an ancestor element such as a DataTemplate). + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal bool IsDataTypeOnBindingNode { get; } } } diff --git a/src/Controls/tests/Core.UnitTests/AdaptiveTriggerTests.cs b/src/Controls/tests/Core.UnitTests/AdaptiveTriggerTests.cs index 5c280b94d386..847db2f5cbee 100644 --- a/src/Controls/tests/Core.UnitTests/AdaptiveTriggerTests.cs +++ b/src/Controls/tests/Core.UnitTests/AdaptiveTriggerTests.cs @@ -9,6 +9,62 @@ namespace Microsoft.Maui.Controls.Core.UnitTests public class AdaptiveTriggerTests : BaseTestFixture { + // Regression tests for https://github.com/dotnet/maui/issues/36032 + // AdaptiveTrigger leaks VisualElement when VisualStateGroups are replaced after attach + + [Fact] + public void OldAdaptiveTriggerDetachedWhenVSGsReplacedAfterAttach() + { + // Arrange: set up a label with an AdaptiveTrigger, attach it to a Window + var label = new Label(); + var oldTrigger = new AdaptiveTrigger { MinWindowWidth = 300 }; + var newTrigger = new AdaptiveTrigger { MinWindowWidth = 500 }; + + VisualStateManager.SetVisualStateGroups(label, new VisualStateGroupList + { + new VisualStateGroup + { + States = + { + new VisualState + { + Name = "Large", + StateTriggers = { oldTrigger }, + Setters = { new Setter { Property = Label.BackgroundProperty, Value = new SolidColorBrush(Colors.Green) } } + }, + } + } + }); + + var page = new ContentPage { Content = label }; + _ = new Window { Page = page }; + + // Old trigger should be attached after element joins a Window + Assert.True(oldTrigger.IsAttached); + + // Act: replace the VisualStateGroups while the element is already attached to a Window + VisualStateManager.SetVisualStateGroups(label, new VisualStateGroupList + { + new VisualStateGroup + { + States = + { + new VisualState + { + Name = "ExtraLarge", + StateTriggers = { newTrigger }, + Setters = { new Setter { Property = Label.BackgroundProperty, Value = new SolidColorBrush(Colors.Blue) } } + }, + } + } + }); + + // Assert: old trigger must be detached (unsubscribed from Window.SizeChanged) + Assert.False(oldTrigger.IsAttached, "Old AdaptiveTrigger should be detached after VSGs are replaced"); + // Assert: new trigger should now be attached + Assert.True(newTrigger.IsAttached, "New AdaptiveTrigger should be attached after VSGs are replaced"); + } + [Fact] public void ResizingWindowPageActivatesTrigger() { diff --git a/src/Controls/tests/Core.UnitTests/AnimationExtensionsThreadSafetyTests.cs b/src/Controls/tests/Core.UnitTests/AnimationExtensionsThreadSafetyTests.cs new file mode 100644 index 000000000000..2e2f37e05148 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/AnimationExtensionsThreadSafetyTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Maui.Animations; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class AnimationExtensionsThreadSafetyTests + { + [Fact] + public void Add_ConcurrentCalls_ProducesUniqueIds() + { + // Exercises the REAL AnimationExtensions.Add() from multiple threads. + // If the fix is reverted (s_currentTweener++ instead of Interlocked.Increment), + // this test will fail with duplicate IDs under contention. + var manager = new NoOpAnimationManager(); + const int threadCount = 50; + const int callsPerThread = 200; + var ids = new ConcurrentBag(); + + var tasks = new Task[threadCount]; + for (int i = 0; i < threadCount; i++) + { + tasks[i] = Task.Run(() => + { + for (int j = 0; j < callsPerThread; j++) + { + int id = manager.Add(_ => { }); + ids.Add(id); + } + }); + } + + Task.WaitAll(tasks); + + int expectedCount = threadCount * callsPerThread; + var uniqueIds = new HashSet(ids); + + Assert.Equal(expectedCount, ids.Count); + Assert.Equal(expectedCount, uniqueIds.Count); + } + + [Fact] + public void Insert_ConcurrentCalls_ProducesUniqueIds() + { + // Same as above but exercises AnimationExtensions.Insert(). + var manager = new NoOpAnimationManager(); + const int threadCount = 50; + const int callsPerThread = 200; + var ids = new ConcurrentBag(); + + var tasks = new Task[threadCount]; + for (int i = 0; i < threadCount; i++) + { + tasks[i] = Task.Run(() => + { + for (int j = 0; j < callsPerThread; j++) + { + int id = manager.Insert(_ => true); + ids.Add(id); + } + }); + } + + Task.WaitAll(tasks); + + int expectedCount = threadCount * callsPerThread; + var uniqueIds = new HashSet(ids); + + Assert.Equal(expectedCount, ids.Count); + Assert.Equal(expectedCount, uniqueIds.Count); + } + + /// + /// Minimal no-op IAnimationManager so AnimationExtensions.Add/Insert can run + /// without requiring a platform ticker (which would block or crash off-thread). + /// + sealed class NoOpAnimationManager : IAnimationManager + { + public ITicker Ticker => null!; + public double SpeedModifier { get; set; } = 1; + public bool AutoStartTicker { get; set; } + public void Add(Microsoft.Maui.Animations.Animation animation) { } + public void Remove(Microsoft.Maui.Animations.Animation animation) { } + } + } +} diff --git a/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs b/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs index f1f9316cf5e7..fe15306eb41f 100644 --- a/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs @@ -1717,5 +1717,60 @@ public void SpecificityOfHandlers() Assert.Equal("manual", bindable.GetValue(prop)); } + // Regression test for https://github.com/dotnet/maui/issues/36744 + [Fact] + public void DefaultValueCreatorThatMutatesOtherPropertiesDoesNotCorruptPropertyStore() + { + var mock = new MockBindable36744(); + + var triggerValue = mock.GetValue(MockBindable36744.TriggerProperty); + Assert.NotNull(triggerValue); + Assert.Same(triggerValue, mock.GetValue(MockBindable36744.TriggerProperty)); + + var exception = Record.Exception(() => mock.BindingContext = new object()); + Assert.Null(exception); + } + } + + internal class MockBindable36744 : BindableObject + { + public static readonly BindableProperty P0 = BindableProperty.Create("P0", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P1 = BindableProperty.Create("P1", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P2 = BindableProperty.Create("P2", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P3 = BindableProperty.Create("P3", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P4 = BindableProperty.Create("P4", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P5 = BindableProperty.Create("P5", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P6 = BindableProperty.Create("P6", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P7 = BindableProperty.Create("P7", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P8 = BindableProperty.Create("P8", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P9 = BindableProperty.Create("P9", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P10 = BindableProperty.Create("P10", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P11 = BindableProperty.Create("P11", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P12 = BindableProperty.Create("P12", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P13 = BindableProperty.Create("P13", typeof(int), typeof(MockBindable36744), 0); + public static readonly BindableProperty P14 = BindableProperty.Create("P14", typeof(int), typeof(MockBindable36744), 0); + + public static readonly BindableProperty TriggerProperty = BindableProperty.Create( + "Trigger", typeof(object), typeof(MockBindable36744), null, + defaultValueCreator: b => + { + var mb = (MockBindable36744)b; + mb.SetValue(P0, 1); + mb.SetValue(P1, 2); + mb.SetValue(P2, 3); + mb.SetValue(P3, 4); + mb.SetValue(P4, 5); + mb.SetValue(P5, 6); + mb.SetValue(P6, 7); + mb.SetValue(P7, 8); + mb.SetValue(P8, 9); + mb.SetValue(P9, 10); + mb.SetValue(P10, 11); + mb.SetValue(P11, 12); + mb.SetValue(P12, 13); + mb.SetValue(P13, 14); + mb.SetValue(P14, 15); + return new object(); + }); } } diff --git a/src/Controls/tests/Core.UnitTests/BooleanBoxesTests.cs b/src/Controls/tests/Core.UnitTests/BooleanBoxesTests.cs new file mode 100644 index 000000000000..39b78233d6b9 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/BooleanBoxesTests.cs @@ -0,0 +1,206 @@ +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Controls.Shapes; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests; + +public class BooleanBoxesTests : BaseTestFixture +{ + [Fact] + public void TrueBoxIsBoxedTrue() + { + Assert.Equal(true, BooleanBoxes.TrueBox); + Assert.IsType(BooleanBoxes.TrueBox); + } + + [Fact] + public void FalseBoxIsBoxedFalse() + { + Assert.Equal(false, BooleanBoxes.FalseBox); + Assert.IsType(BooleanBoxes.FalseBox); + } + + [Fact] + public void TrueBoxAndFalseBoxAreDifferentInstances() + { + Assert.NotSame(BooleanBoxes.TrueBox, BooleanBoxes.FalseBox); + } + + [Fact] + public void BoxTrueReturnsTrueBox() + { + var result = BooleanBoxes.Box(true); + Assert.Same(BooleanBoxes.TrueBox, result); + } + + [Fact] + public void BoxFalseReturnsFalseBox() + { + var result = BooleanBoxes.Box(false); + Assert.Same(BooleanBoxes.FalseBox, result); + } + + [Fact] + public void BoxNullableTrueReturnsTrueBox() + { + bool? value = true; + var result = BooleanBoxes.Box(value); + Assert.Same(BooleanBoxes.TrueBox, result); + } + + [Fact] + public void BoxNullableFalseReturnsFalseBox() + { + bool? value = false; + var result = BooleanBoxes.Box(value); + Assert.Same(BooleanBoxes.FalseBox, result); + } + + [Fact] + public void BoxNullableNullReturnsNull() + { + bool? value = null; + var result = BooleanBoxes.Box(value); + Assert.Null(result); + } + + [Fact] + public void BoxReturnsSameReferenceOnRepeatedCalls() + { + Assert.Same(BooleanBoxes.Box(true), BooleanBoxes.Box(true)); + Assert.Same(BooleanBoxes.Box(false), BooleanBoxes.Box(false)); + } +} + +/// +/// Regression tests that verify production BindableProperty default values and setters +/// store the shared cached boxes rather than freshly-allocated boxed booleans. +/// If any of these fail, a SetValue call site or BindableProperty default has regressed +/// to boxing instead of using BooleanBoxes. +/// +public class BooleanBoxesProductionTests : BaseTestFixture +{ + // --- DefaultValue regression tests --- + + [Fact] + public void ActivityIndicator_IsRunningProperty_DefaultValueIsFalseBox() + => Assert.Same(BooleanBoxes.FalseBox, ActivityIndicator.IsRunningProperty.DefaultValue); + + [Fact] + public void VisualElement_IsVisibleProperty_DefaultValueIsTrueBox() + => Assert.Same(BooleanBoxes.TrueBox, VisualElement.IsVisibleProperty.DefaultValue); + + [Fact] + public void VisualElement_IsEnabledProperty_DefaultValueIsTrueBox() + => Assert.Same(BooleanBoxes.TrueBox, VisualElement.IsEnabledProperty.DefaultValue); + + [Fact] + public void VisualElement_InputTransparentProperty_DefaultValueIsFalseBox() + => Assert.Same(BooleanBoxes.FalseBox, VisualElement.InputTransparentProperty.DefaultValue); + + [Fact] + public void CheckBox_IsCheckedProperty_DefaultValueIsFalseBox() + => Assert.Same(BooleanBoxes.FalseBox, CheckBox.IsCheckedProperty.DefaultValue); + + [Fact] + public void Switch_IsToggledProperty_DefaultValueIsFalseBox() + => Assert.Same(BooleanBoxes.FalseBox, Switch.IsToggledProperty.DefaultValue); + + // --- Setter regression tests: GetValue returns the cached box, not a new allocation --- + + [Fact] + public void ActivityIndicator_IsRunning_SetterStoresCachedBox() + { + var indicator = new ActivityIndicator(); + + indicator.IsRunning = true; + Assert.Same(BooleanBoxes.TrueBox, indicator.GetValue(ActivityIndicator.IsRunningProperty)); + + indicator.IsRunning = false; + Assert.Same(BooleanBoxes.FalseBox, indicator.GetValue(ActivityIndicator.IsRunningProperty)); + } + + [Fact] + public void VisualElement_IsVisible_SetterStoresCachedBox() + { + var view = new ContentView(); + + view.IsVisible = false; + Assert.Same(BooleanBoxes.FalseBox, view.GetValue(VisualElement.IsVisibleProperty)); + + view.IsVisible = true; + Assert.Same(BooleanBoxes.TrueBox, view.GetValue(VisualElement.IsVisibleProperty)); + } + + [Fact] + public void VisualElement_IsEnabled_SetterStoresCachedBox() + { + var view = new ContentView(); + + view.IsEnabled = false; + Assert.Same(BooleanBoxes.FalseBox, view.GetValue(VisualElement.IsEnabledProperty)); + + view.IsEnabled = true; + Assert.Same(BooleanBoxes.TrueBox, view.GetValue(VisualElement.IsEnabledProperty)); + } + + [Fact] + public void VisualElement_InputTransparent_SetterStoresCachedBox() + { + var view = new ContentView(); + + view.InputTransparent = true; + Assert.Same(BooleanBoxes.TrueBox, view.GetValue(VisualElement.InputTransparentProperty)); + + view.InputTransparent = false; + Assert.Same(BooleanBoxes.FalseBox, view.GetValue(VisualElement.InputTransparentProperty)); + } + + [Fact] + public void CheckBox_IsChecked_SetterStoresCachedBox() + { + var checkBox = new CheckBox(); + + checkBox.IsChecked = true; + Assert.Same(BooleanBoxes.TrueBox, checkBox.GetValue(CheckBox.IsCheckedProperty)); + + checkBox.IsChecked = false; + Assert.Same(BooleanBoxes.FalseBox, checkBox.GetValue(CheckBox.IsCheckedProperty)); + } + + [Fact] + public void Switch_IsToggled_SetterStoresCachedBox() + { + var sw = new Switch(); + + sw.IsToggled = true; + Assert.Same(BooleanBoxes.TrueBox, sw.GetValue(Switch.IsToggledProperty)); + + sw.IsToggled = false; + Assert.Same(BooleanBoxes.FalseBox, sw.GetValue(Switch.IsToggledProperty)); + } + + [Fact] + public void RefreshView_IsRefreshing_SetterStoresCachedBox() + { + var refreshView = new RefreshView(); + + refreshView.IsRefreshing = true; + Assert.Same(BooleanBoxes.TrueBox, refreshView.GetValue(RefreshView.IsRefreshingProperty)); + + refreshView.IsRefreshing = false; + Assert.Same(BooleanBoxes.FalseBox, refreshView.GetValue(RefreshView.IsRefreshingProperty)); + } + + [Fact] + public void Entry_IsPassword_SetterStoresCachedBox() + { + var entry = new Entry(); + + entry.IsPassword = true; + Assert.Same(BooleanBoxes.TrueBox, entry.GetValue(Entry.IsPasswordProperty)); + + entry.IsPassword = false; + Assert.Same(BooleanBoxes.FalseBox, entry.GetValue(Entry.IsPasswordProperty)); + } +} \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/GeometryGroupTests.cs b/src/Controls/tests/Core.UnitTests/GeometryGroupTests.cs new file mode 100644 index 000000000000..4207842fcae3 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/GeometryGroupTests.cs @@ -0,0 +1,35 @@ +using Microsoft.Maui.Controls.Shapes; +using Rect = Microsoft.Maui.Graphics.Rect; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests; + +public class GeometryGroupTests : BaseTestFixture +{ + [Fact] + public void ClearUnsubscribesPreviousChildrenFromPropertyChanged() + { + var group = new GeometryGroup(); + var oldChild = new RectangleGeometry(new Rect(0, 0, 10, 10)); + var newChild = new RectangleGeometry(new Rect(0, 0, 5, 5)); + var invalidations = 0; + + group.InvalidateGeometryRequested += (_, _) => invalidations++; + + group.Children.Add(oldChild); + invalidations = 0; + + group.Children.Clear(); + invalidations = 0; + + // If Reset handling does not unsubscribe old items, this mutation incorrectly invalidates the group. + oldChild.Rect = new Rect(1, 1, 11, 11); + Assert.Equal(0, invalidations); + + group.Children.Add(newChild); + invalidations = 0; + + newChild.Rect = new Rect(2, 2, 6, 6); + Assert.Equal(1, invalidations); + } +} \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/PathSegmentTests.cs b/src/Controls/tests/Core.UnitTests/PathSegmentTests.cs index 99d756f789a2..9d1e71168cfe 100644 --- a/src/Controls/tests/Core.UnitTests/PathSegmentTests.cs +++ b/src/Controls/tests/Core.UnitTests/PathSegmentTests.cs @@ -128,5 +128,23 @@ public void TestQuadraticBezierSegmentConstructor() Assert.Equal(100, quadraticBezierSegment2.Point2.X); Assert.Equal(100, quadraticBezierSegment2.Point2.Y); } + + [Fact] + public void ClearingSegmentsDetachesPropertyChangedFromRemovedSegments() + { + var pathFigure = new PathFigure(); + var sharedSegment = new LineSegment(); + var invalidationCount = 0; + + pathFigure.InvalidatePathSegmentRequested += (_, __) => invalidationCount++; + pathFigure.Segments.Add(sharedSegment); + + pathFigure.Segments.Clear(); + var invalidationCountAfterClear = invalidationCount; + + sharedSegment.Point = new Point(10, 10); + + Assert.Equal(invalidationCountAfterClear, invalidationCount); + } } } \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/PickerTests.cs b/src/Controls/tests/Core.UnitTests/PickerTests.cs index 82d7593ed846..6d2236581ef4 100644 --- a/src/Controls/tests/Core.UnitTests/PickerTests.cs +++ b/src/Controls/tests/Core.UnitTests/PickerTests.cs @@ -659,8 +659,9 @@ public void TestItemsSourceCollectionChangedRemoveAtEndSelected(int removeCount) items.RemoveRange(4 - removeCount, removeCount); Assert.Equal(4 - removeCount, picker.Items.Count); - Assert.Equal(items.Count - 1, picker.SelectedIndex); - Assert.Equal(items[^1], picker.SelectedItem); + // When the selected item is removed, selection should be cleared + Assert.Equal(-1, picker.SelectedIndex); + Assert.Null(picker.SelectedItem); } [Fact] @@ -1040,5 +1041,128 @@ public void PickerPreservesSelectedItemAfterInsertingItemBeforeSelection() Assert.Equal("Dog", picker.SelectedItem); Assert.Equal(2, picker.SelectedIndex); } + + // https://github.com/dotnet/maui/issues/33307 + [Fact] + public void PickerClearsSelectionWhenSelectedItemIsRemovedFromItemsSource() + { + // Arrange + var items = new ObservableCollection { "A", "B", "C" }; + var picker = new Picker + { + ItemsSource = items, + SelectedItem = "B" + }; + + Assert.Equal("B", picker.SelectedItem); + Assert.Equal(1, picker.SelectedIndex); + + // Act: Remove the selected item + items.Remove("B"); + + // Assert: Selection should be cleared + Assert.Equal(-1, picker.SelectedIndex); + Assert.Null(picker.SelectedItem); + } + + // https://github.com/dotnet/maui/issues/33307 + [Fact] + public void PickerRetainsSelectionWhenUnselectedItemIsRemovedFromItemsSource() + { + // Arrange + var items = new ObservableCollection { "A", "B", "C" }; + var picker = new Picker + { + ItemsSource = items, + SelectedItem = "C" + }; + + Assert.Equal("C", picker.SelectedItem); + Assert.Equal(2, picker.SelectedIndex); + + // Act: Remove an item that is not selected + items.Remove("A"); + + // Assert: SelectedItem should still be "C", index adjusted + Assert.Equal("C", picker.SelectedItem); + Assert.Equal(1, picker.SelectedIndex); + } + + // https://github.com/dotnet/maui/issues/33307 + [Fact] + public void PickerRetainsSelectionWhenItemsAreInsertedBeforeAndAfterSelection() + { + // Arrange + var items = new ObservableCollection { "X", "Y", "Z" }; + var picker = new Picker + { + ItemsSource = items, + SelectedItem = "Y" + }; + + Assert.Equal("Y", picker.SelectedItem); + Assert.Equal(1, picker.SelectedIndex); + + // Act: Insert an item before the selected item + items.Insert(0, "W"); + + // Assert: SelectedItem should still be "Y", index shifted + Assert.Equal("Y", picker.SelectedItem); + Assert.Equal(2, picker.SelectedIndex); + + // Act: Insert an item after the selected item + items.Insert(4, "V"); + + // Assert: SelectedItem and index remain unchanged + Assert.Equal("Y", picker.SelectedItem); + Assert.Equal(2, picker.SelectedIndex); + } + + // https://github.com/dotnet/maui/issues/33307 + [Fact] + public void PickerRetainsSelectionWhenDuplicateSelectedItemIsRemoved() + { + // Arrange + var items = new ObservableCollection { "A", "B", "B" }; + + var picker = new Picker + { + ItemsSource = items, + SelectedItem = "B" + }; + + Assert.Equal("B", picker.SelectedItem); + Assert.Equal(1, picker.SelectedIndex); + + // Act: Remove the first matching selected item + items.RemoveAt(1); + + // Assert: Selection should remain because another equal item still exists + Assert.Equal("B", picker.SelectedItem); + Assert.Equal(1, picker.SelectedIndex); + } + + // https://github.com/dotnet/maui/issues/33307 + [Fact] + public void PickerRetainsSelectionWhenSelectedItemIsMoved() + { + // Arrange + var items = new ObservableCollection { "A", "B", "C" }; + var picker = new Picker + { + ItemsSource = items, + SelectedItem = "B" + }; + + Assert.Equal("B", picker.SelectedItem); + Assert.Equal(1, picker.SelectedIndex); + + // Act: Move the selected item + items.Move(1, 2); + + // Assert: SelectedItem should still be "B", index updated + Assert.Equal("B", picker.SelectedItem); + Assert.Equal(2, picker.SelectedIndex); + } } } diff --git a/src/Controls/tests/Core.UnitTests/Shapes/PathGeometryTests.cs b/src/Controls/tests/Core.UnitTests/Shapes/PathGeometryTests.cs new file mode 100644 index 000000000000..56be3d73c16f --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/Shapes/PathGeometryTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.Maui.Controls.Shapes; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests.Shapes; + +public class PathGeometryTests : BaseTestFixture +{ + /// + /// Figures.Clear() must unsubscribe the cleared PathFigure from the PathGeometry, + /// otherwise the figure retains the geometry alive via its PropertyChanged delegate. + /// + [Fact] + public void FiguresClear_UnsubscribesFigurePropertyChangedHandler() + { + var geometry = new PathGeometry(); + var sharedFigure = new PathFigure { StartPoint = new Point(0, 0) }; + geometry.Figures.Add(sharedFigure); + + int invalidateCount = 0; + geometry.InvalidatePathGeometryRequested += (s, e) => invalidateCount++; + + // Sanity-check: mutating the figure before Clear should trigger invalidation. + sharedFigure.StartPoint = new Point(10, 10); + Assert.Equal(1, invalidateCount); + + // Act - Clear() fires CollectionChanged (Reset), which itself calls Invalidate() once. + geometry.Figures.Clear(); + int countAfterClear = invalidateCount; + + // After Clear, mutating the figure must NOT trigger any further invalidation on the geometry. + sharedFigure.StartPoint = new Point(20, 20); + Assert.Equal(countAfterClear, invalidateCount); + } + + /// + /// Figures.Clear() must unsubscribe the cleared PathFigure's segment-invalidation event + /// from the PathGeometry. + /// + [Fact] + public void FiguresClear_UnsubscribesFigureSegmentInvalidateHandler() + { + var geometry = new PathGeometry(); + var sharedFigure = new PathFigure { StartPoint = new Point(0, 0) }; + geometry.Figures.Add(sharedFigure); + + int invalidateCount = 0; + geometry.InvalidatePathGeometryRequested += (s, e) => invalidateCount++; + + // Sanity-check: adding a segment before Clear should trigger invalidation. + sharedFigure.Segments.Add(new LineSegment { Point = new Point(100, 100) }); + Assert.Equal(1, invalidateCount); + + // Act - Clear() fires CollectionChanged (Reset), which itself calls Invalidate() once. + geometry.Figures.Clear(); + int countAfterClear = invalidateCount; + + // After Clear, adding segments to the cleared figure must NOT trigger any further invalidation. + sharedFigure.Segments.Add(new LineSegment { Point = new Point(200, 200) }); + Assert.Equal(countAfterClear, invalidateCount); + } + + /// + /// After Figures.Clear(), the PathGeometry must be eligible for garbage collection + /// even when the cleared PathFigure is still alive (shared/rooted elsewhere). + /// + [Fact] + public void FiguresClear_AllowsPathGeometryToBeGarbageCollected() + { + var sharedFigure = new PathFigure { StartPoint = new Point(0, 0) }; + var weakRef = CreateGeometryAndClear(sharedFigure); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // If the bug is present, sharedFigure still holds the geometry alive via + // its PropertyChanged delegate chain, so TryGetTarget would return true. + Assert.False(weakRef.TryGetTarget(out _), + "PathGeometry was retained by the cleared PathFigure (event-handler leak in Figures.Clear())."); + } + + // Factored out so the JIT cannot inline the PathGeometry local onto the caller's frame. + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CreateGeometryAndClear(PathFigure figure) + { + var geometry = new PathGeometry(); + geometry.Figures.Add(figure); + geometry.Figures.Clear(); + return new WeakReference(geometry); + } +} diff --git a/src/Controls/tests/Core.UnitTests/Shapes/PathSharedResourcesMemoryLeakTests.cs b/src/Controls/tests/Core.UnitTests/Shapes/PathSharedResourcesMemoryLeakTests.cs new file mode 100644 index 000000000000..7ef29c2166b4 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/Shapes/PathSharedResourcesMemoryLeakTests.cs @@ -0,0 +1,115 @@ +using System; +using Microsoft.Maui.Controls.Shapes; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests.Shapes; + +public class PathSharedResourcesMemoryLeakTests : BaseTestFixture +{ + // NOTE: Path creation MUST be in a separate method. + // If created inline in the test method, the JIT keeps local variables alive for the + // entire method scope in debug mode — making the path uncollectable regardless of the fix. + // This is the standard .NET pattern for writing reliable GC/leak unit tests. + + static WeakReference CreatePathWithGeometry(PathGeometry geometry) + { + var path = new Path { Data = geometry }; + return new WeakReference(path); + } + + static WeakReference CreatePathWithTransform(ScaleTransform transform) + { + var path = new Path { RenderTransform = transform }; + return new WeakReference(path); + } + + static WeakReference CreatePathWithBoth(PathGeometry geometry, ScaleTransform transform) + { + var path = new Path { Data = geometry, RenderTransform = transform }; + return new WeakReference(path); + } + + [Fact] + public void SharedPathGeometry_DoesNotKeepPathAlive() + { + var sharedGeometry = new PathGeometry + { + Figures = new PathFigureCollection + { + new PathFigure + { + StartPoint = new Point(0, 0), + Segments = new PathSegmentCollection + { + new LineSegment { Point = new Point(100, 100) } + } + } + } + }; + + var pathRef = CreatePathWithGeometry(sharedGeometry); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(pathRef.IsAlive, + "Path was not collected. The shared PathGeometry is holding a strong reference to " + + "Path via its PropertyChanged event handler. Use WeakGeometryChangedProxy to fix."); + + // Ensures sharedGeometry is a live GC root during the Collect calls above. + GC.KeepAlive(sharedGeometry); + } + + [Fact] + public void SharedRenderTransform_DoesNotKeepPathAlive() + { + var sharedTransform = new ScaleTransform(1.5, 1.5) { CenterX = 50, CenterY = 50 }; + + var pathRef = CreatePathWithTransform(sharedTransform); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(pathRef.IsAlive, + "Path was not collected. The shared ScaleTransform is holding a strong reference to " + + "Path via its PropertyChanged event handler. Use WeakNotifyPropertyChangedProxy to fix."); + + GC.KeepAlive(sharedTransform); + } + + [Fact] + public void SharedGeometryAndTransform_DoNotKeepPathAlive() + { + var sharedGeometry = new PathGeometry + { + Figures = new PathFigureCollection + { + new PathFigure + { + StartPoint = new Point(0, 0), + Segments = new PathSegmentCollection + { + new LineSegment { Point = new Point(50, 50) } + } + } + } + }; + var sharedTransform = new ScaleTransform(2.0, 2.0) { CenterX = 25, CenterY = 25 }; + + var pathRef = CreatePathWithBoth(sharedGeometry, sharedTransform); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(pathRef.IsAlive, + "Path was not collected. Shared PathGeometry and/or ScaleTransform are holding " + + "strong references to Path via event subscriptions. Use weak proxies to fix."); + + GC.KeepAlive(sharedGeometry); + GC.KeepAlive(sharedTransform); + } +} diff --git a/src/Controls/tests/Core.UnitTests/Shapes/TransformGroupTests.cs b/src/Controls/tests/Core.UnitTests/Shapes/TransformGroupTests.cs new file mode 100644 index 000000000000..1454597ff147 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/Shapes/TransformGroupTests.cs @@ -0,0 +1,87 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.Maui.Controls.Shapes; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests.Shapes +{ + public class TransformGroupTests : BaseTestFixture + { + [Fact] + public async Task ReplacingChildrenUnsubscribesFromOldChildPropertyChanged() + { + var sharedTransform = new ScaleTransform { ScaleX = 1.0, ScaleY = 1.0 }; + WeakReference weakGroup; + + { + var group = new TransformGroup(); + group.Children.Add(sharedTransform); + group.Children = new TransformCollection(); + weakGroup = new WeakReference(group); + } + + Assert.False(await weakGroup.WaitForCollect(), + "TransformGroup should be collected after Children replacement. " + + "Shared child transform is keeping it alive via stale PropertyChanged subscription."); + } + + [Fact] + public void ReplacingChildrenSubscribesToNewChildPropertyChanged() + { + var group = new TransformGroup(); + var newCollection = new TransformCollection(); + var childTransform = new ScaleTransform { ScaleX = 1.0, ScaleY = 1.0 }; + newCollection.Add(childTransform); + + group.Children = newCollection; + + var matrixBefore = group.Value; + childTransform.ScaleX = 2.0; + var matrixAfter = group.Value; + + Assert.NotEqual(matrixBefore, matrixAfter); + } + + [Fact] + public async Task SharedTransformDoesNotRetainMultipleGroups() + { + var sharedTransform = new ScaleTransform { ScaleX = 1.0, ScaleY = 1.0 }; + var weakRefs = new WeakReference[10]; + + { + for (int i = 0; i < 10; i++) + { + var group = new TransformGroup(); + group.Children.Add(sharedTransform); + group.Children = new TransformCollection(); + weakRefs[i] = new WeakReference(group); + } + } + + for (int i = 0; i < weakRefs.Length; i++) + { + Assert.False(await weakRefs[i].WaitForCollect(), + $"TransformGroup #{i} should be collected. Shared transform is retaining it."); + } + } + + [Fact] + public async Task ClearingChildrenUnsubscribesAllTransforms() + { + var sharedTransform = new ScaleTransform { ScaleX = 1.0, ScaleY = 1.0 }; + WeakReference weakGroup; + + { + var group = new TransformGroup(); + group.Children.Add(sharedTransform); + group.Children.Clear(); + weakGroup = new WeakReference(group); + } + + Assert.False(await weakGroup.WaitForCollect(), + "TransformGroup should be collected after Children.Clear(). " + + "Shared child transform is keeping it alive via stale PropertyChanged subscription."); + } + } +} \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs b/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs index d18d47cb440a..7c5913aa0154 100644 --- a/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs +++ b/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs @@ -270,6 +270,95 @@ public async Task TitleAndTitleViewAreMutuallyExclusive() Assert.Equal("Test Title", toolbar.Title); } + [Fact] + public void ShellTitleReflectsCurrentPageTitleForTitleViewBindings() + { + var contentPage = new ContentPage() { Title = "Test Title" }; + var label = new Label(); + var titleView = new VerticalStackLayout() + { + Children = + { + label + } + }; + + TestShell testShell = new TestShell(contentPage); + _ = new Window() + { + Page = testShell + }; + + label.SetBinding(Label.TextProperty, new Binding(nameof(Shell.Title), source: testShell)); + Shell.SetTitleView(contentPage, titleView); + + Assert.Empty(testShell.Toolbar.Title); + Assert.Equal("Test Title", testShell.Title); + Assert.Equal("Test Title", label.Text); + + contentPage.Title = "Updated Test Title"; + + Assert.Equal("Updated Test Title", testShell.Title); + Assert.Equal("Updated Test Title", label.Text); + } + + // Regression test for https://github.com/dotnet/maui/issues/36562 + // PR #35800 made ShellToolbar mirror the current page's title into Shell.Title (via + // SetValueFromRenderer) so TitleView bindings like {Binding Title, Source={x:Reference Shell}} + // resolve correctly. That mirrored value leaked into the native window title + // (ITitledElement.Title), which previously stayed empty in this scenario, breaking Shell UI + // tests around title/flyout layout on macOS/Windows. + [Fact] + public void WindowNativeTitleDoesNotLeakToolbarMirroredPageTitle() + { + var contentPage = new ContentPage() { Title = "Test Title" }; + var titleView = new VerticalStackLayout(); + + TestShell testShell = new TestShell(contentPage); + var window = new Window() + { + Page = testShell + }; + + Shell.SetTitleView(contentPage, titleView); + + // Shell.Title is mirrored from the page for TitleView binding purposes... + Assert.Equal("Test Title", testShell.Title); + + // ...but that mirrored value must NOT leak into the native window title fallback. + Assert.Null(((ITitledElement)window).Title); + + contentPage.Title = "Updated Test Title"; + Assert.Equal("Updated Test Title", testShell.Title); + Assert.Null(((ITitledElement)window).Title); + } + + [Fact] + public void ShellTitleBindingIsNotOverwrittenByCurrentPageTitle() + { + var contentPage = new ContentPage() { Title = "Page Title" }; + var titleView = new VerticalStackLayout(); + var viewModel = new TestShellViewModel() { Text = "App Title" }; + + TestShell testShell = new TestShell(contentPage); + _ = new Window() + { + Page = testShell + }; + + testShell.SetBinding(Shell.TitleProperty, new Binding(nameof(TestShellViewModel.Text), BindingMode.TwoWay, source: viewModel)); + Shell.SetTitleView(contentPage, titleView); + + Assert.Empty(testShell.Toolbar.Title); + Assert.Equal("App Title", testShell.Title); + Assert.Equal("App Title", viewModel.Text); + + contentPage.Title = "Updated Page Title"; + + Assert.Equal("App Title", testShell.Title); + Assert.Equal("App Title", viewModel.Text); + } + [Fact] public void ContentPageColorsPropagateToShellToolbar() { diff --git a/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.Android.cs index 4e3e7eae604b..8f4910d439fa 100644 --- a/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.Android.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Threading.Tasks; +using Android.Views; using Android.Widget; using AndroidX.RecyclerView.Widget; using Microsoft.Maui.Controls; @@ -55,6 +56,60 @@ await CreateHandlerAndAddToWindow(carouselView, async (hand }); } + [Fact(DisplayName = "Vertical Drag On Horizontal CarouselView Is Not Intercepted")] + public async Task VerticalDragOnHorizontalCarouselIsNotIntercepted() + { + SetupBuilder(); + + var data = new ObservableCollection { "Item 1", "Item 2", "Item 3" }; + + var template = new DataTemplate(() => new Grid { new Label() }); + + var carouselView = new CarouselView + { + ItemTemplate = template, + ItemsSource = data, + IsSwipeEnabled = true, + }; + + await CreateHandlerAndAddToWindow(carouselView, async (handler) => + { + var recyclerView = handler.PlatformView; + await recyclerView.WaitForLayoutOrNonZeroSize(); + + // A vertical-dominant drag with a horizontal component large enough that the base + // RecyclerView would otherwise treat it as a horizontal page swipe. The fix must + // detect the off-axis gesture and delegate it to nested scrollable content, so + // OnInterceptTouchEvent returns false. Without the fix, the carousel intercepts it. + bool intercepted = SimulateDragIntercept(recyclerView, deltaX: 80, deltaY: 400); + + Assert.False(intercepted); + }); + } + + // Dispatches a synthetic down/move/up gesture to the RecyclerView's touch-interception + // pipeline and reports whether the move was intercepted by the carousel. + static bool SimulateDragIntercept(RecyclerView recyclerView, float deltaX, float deltaY) + { + const float startX = 200f; + const float startY = 200f; + long downTime = global::Android.OS.SystemClock.UptimeMillis(); + + var down = MotionEvent.Obtain(downTime, downTime, MotionEventActions.Down, startX, startY, 0); + recyclerView.OnInterceptTouchEvent(down); + down.Recycle(); + + var move = MotionEvent.Obtain(downTime, downTime + 16, MotionEventActions.Move, startX + deltaX, startY + deltaY, 0); + bool intercepted = recyclerView.OnInterceptTouchEvent(move); + move.Recycle(); + + var up = MotionEvent.Obtain(downTime, downTime + 32, MotionEventActions.Up, startX + deltaX, startY + deltaY, 0); + recyclerView.OnInterceptTouchEvent(up); + up.Recycle(); + + return intercepted; + } + RecyclerView GetPlatformCarouselView(CarouselViewHandler carouselViewHandler) => carouselViewHandler.PlatformView; diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs index 184886264590..121780ba9650 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs @@ -568,5 +568,154 @@ private static UIScrollView FindInternalScrollView(UICollectionView collectionVi } return null; } + + // Regression test for https://github.com/dotnet/maui/issues/36010 + // CollectionViewHandler2 must not throw NullReferenceException when a + // GridItemsLayout property changes after the handler has been disconnected + // and then reconnected (the cached-workspace / native-host restore pattern). + [Theory(DisplayName = "CollectionViewHandler2 Does Not Crash After Disconnect-Restore-PropertyChange")] + [InlineData(nameof(GridItemsLayout.Span))] + [InlineData(nameof(GridItemsLayout.HorizontalItemSpacing))] + [InlineData(nameof(GridItemsLayout.VerticalItemSpacing))] + [InlineData(nameof(ItemsLayout.SnapPointsType))] + [InlineData(nameof(ItemsLayout.SnapPointsAlignment))] + [Category(TestCategory.CollectionView)] + public async Task CollectionViewHandler2DoesNotCrashAfterDisconnectRestorePropertyChange(string propertyName) + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); + + var itemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical) + { + HorizontalItemSpacing = 8, + VerticalItemSpacing = 8 + }; + + var collectionView = new CollectionView + { + HeightRequest = 300, + WidthRequest = 300, + ItemsLayout = itemsLayout, + ItemsSource = Enumerable.Range(1, 12).Select(i => $"Item {i}").ToList(), + ItemTemplate = new DataTemplate(() => + { + var label = new Label(); + label.SetBinding(Label.TextProperty, "."); + return label; + }) + }; + + await CreateHandlerAndAddToWindow(collectionView, async handler => + { + await Task.Delay(200); + + // Step 1: Shelve — disconnect the handler (sets _layoutPropertyCache = null) + var mauiContext = handler.MauiContext; + ((IElementHandler)handler).DisconnectHandler(); + + await Task.Delay(50); + + // Step 2: Restore — re-attach the same handler instance + ((IElementHandler)handler).SetMauiContext(mauiContext); + ((IElementHandler)handler).SetVirtualView(collectionView); + + await Task.Delay(50); + + // Step 3: Change a GridItemsLayout property — must NOT throw NullReferenceException. + // Before the fix, _layoutPropertyCache was null here and TryGetValue crashed. + var exception = await Record.ExceptionAsync(async () => + { + await InvokeOnMainThreadAsync(() => + { + switch (propertyName) + { + case nameof(GridItemsLayout.Span): + itemsLayout.Span = 4; + break; + case nameof(GridItemsLayout.HorizontalItemSpacing): + itemsLayout.HorizontalItemSpacing = 16; + break; + case nameof(GridItemsLayout.VerticalItemSpacing): + itemsLayout.VerticalItemSpacing = 16; + break; + case nameof(ItemsLayout.SnapPointsType): + itemsLayout.SnapPointsType = SnapPointsType.MandatorySingle; + break; + case nameof(ItemsLayout.SnapPointsAlignment): + itemsLayout.SnapPointsAlignment = SnapPointsAlignment.Center; + break; + } + }); + }); + + Assert.Null(exception); + }); + } + + // Regression test for https://github.com/dotnet/maui/issues/36010 (LinearItemsLayout path) + // ItemSpacing change on a LinearItemsLayout must also survive disconnect+restore. + [Fact(DisplayName = "CollectionViewHandler2 Does Not Crash After Disconnect-Restore-LinearItemSpacingChange")] + [Category(TestCategory.CollectionView)] + public async Task CollectionViewHandler2DoesNotCrashAfterDisconnectRestoreLinearItemSpacingChange() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); + + var itemsLayout = new LinearItemsLayout(ItemsLayoutOrientation.Vertical) + { + ItemSpacing = 4 + }; + + var collectionView = new CollectionView + { + HeightRequest = 300, + WidthRequest = 300, + ItemsLayout = itemsLayout, + ItemsSource = Enumerable.Range(1, 12).Select(i => $"Item {i}").ToList(), + ItemTemplate = new DataTemplate(() => + { + var label = new Label(); + label.SetBinding(Label.TextProperty, "."); + return label; + }) + }; + + await CreateHandlerAndAddToWindow(collectionView, async handler => + { + await Task.Delay(200); + + var mauiContext = handler.MauiContext; + ((IElementHandler)handler).DisconnectHandler(); + + await Task.Delay(50); + + ((IElementHandler)handler).SetMauiContext(mauiContext); + ((IElementHandler)handler).SetVirtualView(collectionView); + + await Task.Delay(50); + + var exception = await Record.ExceptionAsync(async () => + { + await InvokeOnMainThreadAsync(() => + { + itemsLayout.ItemSpacing = 20; + }); + }); + + Assert.Null(exception); + }); + } } } diff --git a/src/Controls/tests/DeviceTests/Elements/ContextFlyout/ContextFlyoutTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/ContextFlyout/ContextFlyoutTests.Windows.cs index 4b592cbddb6f..edfb40ec29d8 100644 --- a/src/Controls/tests/DeviceTests/Elements/ContextFlyout/ContextFlyoutTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/ContextFlyout/ContextFlyoutTests.Windows.cs @@ -10,6 +10,96 @@ namespace Microsoft.Maui.DeviceTests { public partial class ContextFlyoutTests : ControlsHandlerTestBase { + [Fact(DisplayName = "MenuFlyoutItem MapSource sets ShowAsMonochrome to false for FileImageSource")] + public async Task MenuFlyoutItemMapSourceSetsShowAsMonochromeToFalse() + { + SetupBuilder(); + + await InvokeOnMainThreadAsync(() => + { + MenuFlyoutItem menuFlyoutItem = new MenuFlyoutItem() + { + Text = "TestItem", + IconImageSource = new FileImageSource { File = "red.png" } + }; + + var handler = CreateHandler(menuFlyoutItem); + var platformItem = handler.PlatformView; + + Assert.NotNull(platformItem.Icon); + var bitmapIcon = Assert.IsType(platformItem.Icon); + Assert.False(bitmapIcon.ShowAsMonochrome); + }); + } + + [Fact(DisplayName = "MenuFlyoutSubItem MapSource sets ShowAsMonochrome to false for FileImageSource")] + public async Task MenuFlyoutSubItemMapSourceSetsShowAsMonochromeToFalse() + { + SetupBuilder(); + + await InvokeOnMainThreadAsync(() => + { + MenuFlyoutSubItem menuFlyoutSubItem = new MenuFlyoutSubItem() + { + Text = "SubMenu", + IconImageSource = new FileImageSource { File = "red.png" } + }; + menuFlyoutSubItem.Add(new MenuFlyoutItem() { Text = "ChildItem" }); + + var handler = CreateHandler(menuFlyoutSubItem); + var platformItem = handler.PlatformView; + + Assert.NotNull(platformItem.Icon); + var bitmapIcon = Assert.IsType(platformItem.Icon); + Assert.False(bitmapIcon.ShowAsMonochrome); + }); + } + + [Fact(DisplayName = "MenuFlyoutItem MapSource sets ShowAsMonochrome to false for UriImageSource")] + public async Task MenuFlyoutItemMapSourceSetsShowAsMonochromeToFalseForUriImageSource() + { + SetupBuilder(); + + await InvokeOnMainThreadAsync(() => + { + MenuFlyoutItem menuFlyoutItem = new MenuFlyoutItem() + { + Text = "TestItem", + IconImageSource = new UriImageSource { Uri = new System.Uri("https://raw.githubusercontent.com/dotnet/maui/main/src/Compatibility/ControlGallery/src/Android/Resources/drawable/coffee.png") } + }; + + var handler = CreateHandler(menuFlyoutItem); + var platformItem = handler.PlatformView; + + Assert.NotNull(platformItem.Icon); + var bitmapIcon = Assert.IsType(platformItem.Icon); + Assert.False(bitmapIcon.ShowAsMonochrome); + }); + } + + [Fact(DisplayName = "MenuFlyoutSubItem MapSource sets ShowAsMonochrome to false for UriImageSource")] + public async Task MenuFlyoutSubItemMapSourceSetsShowAsMonochromeToFalseForUriImageSource() + { + SetupBuilder(); + + await InvokeOnMainThreadAsync(() => + { + MenuFlyoutSubItem menuFlyoutSubItem = new MenuFlyoutSubItem() + { + Text = "SubMenu", + IconImageSource = new UriImageSource { Uri = new System.Uri("https://raw.githubusercontent.com/dotnet/maui/main/src/Compatibility/ControlGallery/src/Android/Resources/drawable/coffee.png") } + }; + menuFlyoutSubItem.Add(new MenuFlyoutItem() { Text = "ChildItem" }); + + var handler = CreateHandler(menuFlyoutSubItem); + var platformItem = handler.PlatformView; + + Assert.NotNull(platformItem.Icon); + var bitmapIcon = Assert.IsType(platformItem.Icon); + Assert.False(bitmapIcon.ShowAsMonochrome); + }); + } + [Fact(DisplayName = "Context flyout creates expected WinUI elements")] public async Task ContextFlyoutCreatesExpectedWinUIElements() { diff --git a/src/Controls/tests/DeviceTests/Elements/Entry/EntryTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Entry/EntryTests.Android.cs index e124e6b4a66c..d1110c4ce3ad 100644 --- a/src/Controls/tests/DeviceTests/Elements/Entry/EntryTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Entry/EntryTests.Android.cs @@ -207,5 +207,79 @@ await InvokeOnMainThreadAsync(() => AssertTranslationMatches(nativeView, entry.TranslationX, entry.TranslationY); }); } + + [Fact] + [Category(TestCategory.Entry)] + public async Task KeyboardPasswordDoesNotForcePasswordVisibilityWhenIsPasswordFalse() + { + var entry = new Entry + { + Keyboard = Keyboard.Password, + IsPassword = false, + Text = "Password" + }; + + var handler = await CreateHandlerAsync(entry); + var platformEntry = GetPlatformControl(handler); + + await InvokeOnMainThreadAsync(() => + { + Assert.False(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.TextVariationPassword)); + Assert.False(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.NumberVariationPassword)); + }); + } + + [Fact] + [Category(TestCategory.Entry)] + public async Task KeyboardPasswordRespectsIsPasswordToggle() + { + var entry = new Entry + { + Keyboard = Keyboard.Password, + IsPassword = false, + Text = "Password" + }; + + var handler = await CreateHandlerAsync(entry); + var platformEntry = GetPlatformControl(handler); + + await InvokeOnMainThreadAsync(() => + { + Assert.False(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.TextVariationPassword)); + + entry.IsPassword = true; + handler.UpdateValue(nameof(IEntry.IsPassword)); + + Assert.True(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.TextVariationPassword)); + + entry.IsPassword = false; + handler.UpdateValue(nameof(IEntry.IsPassword)); + + Assert.False(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.TextVariationPassword)); + Assert.False(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.NumberVariationPassword)); + }); + } + + [Fact] + [Category(TestCategory.Entry)] + public async Task KeyboardUrlPreservesUrlInputTypeWhenIsPasswordFalse() + { + var entry = new Entry + { + Keyboard = Keyboard.Url, + IsPassword = false, + Text = "https://dot.net" + }; + + var handler = await CreateHandlerAsync(entry); + var platformEntry = GetPlatformControl(handler); + + await InvokeOnMainThreadAsync(() => + { + Assert.True(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.ClassText)); + Assert.True(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.TextVariationUri)); + Assert.False(platformEntry.InputType.HasFlag(global::Android.Text.InputTypes.TextVariationPassword)); + }); + } } } diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SendRawMessage.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SendRawMessage.cs index 39eef22edc96..87be00dbae8f 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SendRawMessage.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SendRawMessage.cs @@ -39,4 +39,43 @@ public Task LoadsHtmlAndSendReceiveRawMessage() => Assert.True(passed, $"Waited for raw message response but it never arrived or didn't match (last message: {lastRawMessage})"); }); + + // Regression: ensure raw messages containing characters that cannot appear in HTTP header + // values (CR/LF/NUL) and characters that vary in header byte-set handling (non-ASCII unicode, + // the '%' encoding sentinel) survive the Android fetch transport. The JS side URL-encodes + // raw messages and HybridWebViewHandler.MessageReceived decodes them. + [Theory] + [InlineData("with\nnewline")] + [InlineData("with\r\ncarriage")] + [InlineData("with\0nul")] + [InlineData("100% complete")] + [InlineData("café with é and emoji 😀")] + public Task SendRawMessageRoundTripsSpecialCharacters(string testMessage) => + RunTest(async (hybridWebView) => + { + var lastRawMessage = ""; + + hybridWebView.RawMessageReceived += (s, e) => + { + lastRawMessage = e.Message; + }; + + hybridWebView.SendRawMessage(testMessage); + + var expected = "You said: " + testMessage; + var passed = false; + + for (var i = 0; i < 10; i++) + { + if (lastRawMessage == expected) + { + passed = true; + break; + } + + await Task.Delay(1000); + } + + Assert.True(passed, $"Raw message did not round-trip. Expected: [{expected}] Got: [{lastRawMessage}]"); + }); } diff --git a/src/Controls/tests/DeviceTests/Elements/Layout/LayoutTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/Layout/LayoutTests.Windows.cs index 21395ceaf2b3..6a2c70d75f7e 100644 --- a/src/Controls/tests/DeviceTests/Elements/Layout/LayoutTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/Layout/LayoutTests.Windows.cs @@ -28,7 +28,6 @@ void ValidateInputTransparentOnPlatformView(IView view) Assert.Equal(view.InputTransparent, !handler.PlatformView.IsHitTestVisible); } } - void SetupLayoutBuilder() { EnsureHandlerCreated(builder => @@ -59,8 +58,8 @@ await AttachAndRun(grid, (LayoutHandler handler) => }); } - [Fact(DisplayName = "LayoutPanel AutomationPeer control type is Pane")] - public async Task LayoutPanelAutomationPeerControlTypeIsPane() + [Fact(DisplayName = "LayoutPanel AutomationPeer default control type is Custom with lowercase class name as localized type")] + public async Task LayoutPanelAutomationPeerDefaultControlTypeIsCustom() { SetupLayoutBuilder(); @@ -69,7 +68,10 @@ public async Task LayoutPanelAutomationPeerControlTypeIsPane() await AttachAndRun(grid, (LayoutHandler handler) => { var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); - Assert.Equal(AutomationControlType.Pane, peer.GetAutomationControlType()); + Assert.Equal(AutomationControlType.Custom, peer.GetAutomationControlType()); + // UIA spec requires Custom elements to have a non-empty LocalizedControlType. + // For anonymous layouts, we return the lowercase cross-platform type name. + Assert.Equal("grid", peer.GetLocalizedControlType()); }); } @@ -104,10 +106,10 @@ await AttachAndRun(root, (LayoutHandler handler) => }); } - [Theory(DisplayName = "LayoutPanel with AutomationId is exposed in automation tree")] + [Theory(DisplayName = "LayoutPanel AutomationId is exposed via the automation peer")] [InlineData(true)] [InlineData(false)] - public async Task LayoutPanelWithAutomationIdIsExposedInTree(bool hasAutomationId) + public async Task LayoutPanelAutomationIdIsExposedViaPeer(bool hasAutomationId) { SetupLayoutBuilder(); @@ -118,7 +120,8 @@ public async Task LayoutPanelWithAutomationIdIsExposedInTree(bool hasAutomationI await AttachAndRun(grid, (LayoutHandler handler) => { var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); - Assert.Equal(hasAutomationId, peer.IsContentElement()); + var expected = hasAutomationId ? "TestGrid" : string.Empty; + Assert.Equal(expected, peer.GetAutomationId()); }); } @@ -136,28 +139,158 @@ await AttachAndRun(grid, (LayoutHandler handler) => }); } - [Fact(DisplayName = "LayoutPanel IsControlElement and IsContentElement update when AutomationId is set at runtime")] - public async Task LayoutPanelAutomationPeerUpdatesWhenAutomationIdChangesAtRuntime() + [Fact(DisplayName = "LayoutPanel without automation signals is excluded from Control and Content views")] + public async Task LayoutPanelWithoutAutomationSignalsIsExcludedFromControlAndContentViews() + { + SetupLayoutBuilder(); + + var grid = new Grid(); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.False(peer.IsControlElement()); + Assert.False(peer.IsContentElement()); + }); + } + + [Fact(DisplayName = "LayoutPanel with AutomationId is included in Control view only")] + public async Task LayoutPanelWithAutomationIdIsIncludedInControlViewOnly() + { + SetupLayoutBuilder(); + + var grid = new Grid { AutomationId = "TestGrid" }; + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + + Assert.Equal("TestGrid", peer.GetAutomationId()); + Assert.True(peer.IsControlElement()); + Assert.False(peer.IsContentElement()); + Assert.Equal(AutomationControlType.Custom, peer.GetAutomationControlType()); + // UIA spec requires Custom elements to have a non-empty LocalizedControlType. + Assert.Equal("grid", peer.GetLocalizedControlType()); + }); + } + + [Fact(DisplayName = "LayoutPanel opts into Control view when AutomationProperties.IsInAccessibleTree is true")] + public async Task LayoutPanelOptsIntoControlViewViaIsInAccessibleTree() + { + SetupLayoutBuilder(); + + var grid = new Grid(); + AutomationProperties.SetIsInAccessibleTree(grid, true); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.True(peer.IsControlElement()); + Assert.True(peer.IsContentElement()); + Assert.Equal(AutomationControlType.Pane, peer.GetAutomationControlType()); + }); + } + + [Fact(DisplayName = "LayoutPanel opts into Control view when SemanticProperties.Description is set")] + public async Task LayoutPanelOptsIntoControlViewWhenDescriptionIsSet() + { + SetupLayoutBuilder(); + + var grid = new Grid(); + SemanticProperties.SetDescription(grid, "Welcome card"); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.True(peer.IsControlElement()); + Assert.Equal(AutomationControlType.Pane, peer.GetAutomationControlType()); + }); + } + + [Fact(DisplayName = "LayoutPanel opts into Control view when SemanticProperties.Hint is set")] + public async Task LayoutPanelOptsIntoControlViewWhenHintIsSet() { SetupLayoutBuilder(); var grid = new Grid(); + SemanticProperties.SetHint(grid, "Contains welcome card actions"); await AttachAndRun(grid, (LayoutHandler handler) => { var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.True(peer.IsControlElement()); + Assert.Equal(AutomationControlType.Pane, peer.GetAutomationControlType()); + }); + } - // Initially no AutomationId — should NOT be exposed + [Fact(DisplayName = "LayoutPanel explicit accessible-tree opt out overrides AutomationId and SemanticProperties.Description")] + public async Task LayoutPanelAccessibleTreeOptOutOverridesAutomationIdAndDescription() + { + SetupLayoutBuilder(); + + var grid = new Grid { AutomationId = "TestGrid" }; + SemanticProperties.SetDescription(grid, "Welcome card"); + AutomationProperties.SetIsInAccessibleTree(grid, false); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.Equal("TestGrid", peer.GetAutomationId()); + Assert.False(peer.IsControlElement()); Assert.False(peer.IsContentElement()); + }); + } + + [Fact(DisplayName = "LayoutPanel ignores whitespace-only SemanticProperties.Description")] + public async Task LayoutPanelDoesNotOptIntoControlViewWhenDescriptionIsWhitespace() + { + SetupLayoutBuilder(); + + var grid = new Grid(); + SemanticProperties.SetDescription(grid, " "); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.False(peer.IsControlElement()); + }); + } - // Set AutomationId at runtime — should now be exposed. + [Fact(DisplayName = "LayoutPanel ignores whitespace-only SemanticProperties.Hint")] + public async Task LayoutPanelDoesNotOptIntoControlViewWhenHintIsWhitespace() + { + SetupLayoutBuilder(); + + var grid = new Grid(); + SemanticProperties.SetHint(grid, " "); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + Assert.False(peer.IsControlElement()); + }); + } + + [Fact(DisplayName = "LayoutPanel AutomationId is exposed via the peer when set at runtime")] + public async Task LayoutPanelAutomationPeerUpdatesWhenAutomationIdChangesAtRuntime() + { + SetupLayoutBuilder(); + + var grid = new Grid(); + + await AttachAndRun(grid, (LayoutHandler handler) => + { + var peer = FrameworkElementAutomationPeer.CreatePeerForElement(handler.PlatformView); + + // Initially no AutomationId. + Assert.Equal(string.Empty, peer.GetAutomationId()); + + // Set AutomationId at runtime -- peer should reflect the new value. // Note: MAUI AutomationId is write-once (Element.cs enforces this), - // so we can only test the transition from unset → set, not set → cleared. + // so we can only test the transition from unset -> set, not set -> cleared. grid.AutomationId = "DynamicGrid"; - Assert.True(peer.IsContentElement()); + Assert.Equal("DynamicGrid", peer.GetAutomationId()); }); } } } - - diff --git a/src/Controls/tests/DeviceTests/Elements/Picker/PickerTests.cs b/src/Controls/tests/DeviceTests/Elements/Picker/PickerTests.cs index 073518ec2c5d..e3e13bce467b 100644 --- a/src/Controls/tests/DeviceTests/Elements/Picker/PickerTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Picker/PickerTests.cs @@ -33,7 +33,9 @@ public async Task ItemsUpdateWithCollectionChanges() Assert.Equal("1", await GetPlatformControlText(handler.PlatformView)); await InvokeOnMainThreadAsync(() => items.Remove("1")); - Assert.Equal("2", await GetPlatformControlText(handler.PlatformView)); + // When the selected item is removed, selection should be cleared + Assert.Equal(-1, picker.SelectedIndex); + Assert.Null(picker.SelectedItem); } [Fact] diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs index 76180c67934b..3bb56569f5db 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs @@ -172,7 +172,9 @@ await RunShellTest(shell => // validate footer position #if IOS - AssertionExtensions.CloseEnough(footerFrame.Y + GetSafeArea(handler.ToPlatform()).Bottom, headerFrame.Height + contentFrame.Height + GetSafeArea(handler.ToPlatform()).Top); + // With safeAreaBottom subtracted from content height (PR #33335), the footer's Y position + // equals exactly the sum of what's above it (safeAreaTop + headerHeight + contentHeight). + AssertionExtensions.CloseEnough(footerFrame.Y, headerFrame.Height + contentFrame.Height + GetSafeArea(handler.ToPlatform()).Top); #else // On android the we pad the top of the header frame by the safe area because how layout works // so that is already included in the headerFrame Height @@ -253,11 +255,14 @@ await RunShellTest(shell => // validate footer position var expectedFooterY = expectedContentY + contentMargin.Bottom + contentFrame.Height; AssertionExtensions.CloseEnough(0, footerFrame.X, message: "Footer X"); - AssertionExtensions.CloseEnough(expectedFooterY, footerFrame.Y + GetSafeArea(handler.ToPlatform()).Bottom, epsilon: 0.6, message: "Footer Y"); + // With safeAreaBottom subtracted from content height (PR #33335), footerFrame.Y equals + // expectedFooterY directly — no safeAreaBottom adjustment needed here. + AssertionExtensions.CloseEnough(expectedFooterY, footerFrame.Y, epsilon: 0.6, message: "Footer Y"); AssertionExtensions.CloseEnough(flyoutFrame.Width, footerFrame.Width, message: "Footer Width"); //All three views should measure to the height of the flyout - AssertionExtensions.CloseEnough(expectedFooterY + footerFrame.Height, flyoutFrame.Height, epsilon: 0.5, message: "Total Height"); + // The flyout height = content area + footer height + safeAreaBottom below the footer. + AssertionExtensions.CloseEnough(expectedFooterY + footerFrame.Height + GetSafeArea(handler.ToPlatform()).Bottom, flyoutFrame.Height, epsilon: 0.5, message: "Total Height"); }); } #endif diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs index 626e527b0260..2b58bf52e00f 100644 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs @@ -156,6 +156,110 @@ await CreateHandlerAndAddToWindow(mainPage, async (handler) => }); } + [Fact] + public async Task WindowsBoundsWhenMaximized() + { + SetupBuilder(); + var mainPage = new NavigationPage(new ContentPage()); + + await CreateHandlerAndAddToWindow(mainPage, async (handler) => + { + var appWindowPlatform = handler.PlatformView.GetAppWindow(); + Assert.NotNull(appWindowPlatform?.Presenter); + var presenter = Assert.IsType(appWindowPlatform.Presenter); + + // maximize window + presenter.Maximize(); + var appWindow = handler.PlatformView.GetWindow(); + Assert.NotNull(appWindow); + + // Compute work-area reference values before polling so the same values + // are used for both the wait predicate and the final assertions. + // Compare against the monitor's work area. This correctly handles negative + // coordinates when the window is on a monitor positioned left of or above + // the primary display, and catches regressions beyond a simple > 0 check. + var displayArea = DisplayArea.GetFromWindowId(appWindowPlatform.Id, DisplayAreaFallback.Nearest); + var workArea = displayArea.WorkArea; + var density = handler.PlatformView.GetDisplayDensity(); + + // Wait until the MAUI frame reflects the maximized work-area bounds. + // Waiting only for Height > 0 is insufficient: that condition is already true + // before Maximize() is called, so on slow machines the assertions below would + // execute against the pre-maximized frame and become flaky. + await AssertEventually(() => + Math.Abs(appWindow.Width - workArea.Width / density) < 2 && + Math.Abs(appWindow.Height - workArea.Height / density) < 2); + + Assert.True(Math.Abs(appWindow.X - workArea.X / density) < 2, + $"X should be near work area X ({workArea.X / density:F2}) but was {appWindow.X}"); + Assert.True(Math.Abs(appWindow.Y - workArea.Y / density) < 2, + $"Y should be near work area Y ({workArea.Y / density:F2}) but was {appWindow.Y}"); + Assert.True(Math.Abs(appWindow.Width - workArea.Width / density) < 2, + $"Width should match work area width ({workArea.Width / density:F2}) but was {appWindow.Width}"); + Assert.True(Math.Abs(appWindow.Height - workArea.Height / density) < 2, + $"Height should match work area height ({workArea.Height / density:F2}) but was {appWindow.Height}"); + }); + } + + [Fact] + public async Task WindowsYAndHeightCorrectWhenClosingMaximizedWindow() + { + SetupBuilder(); + var mainPage = new NavigationPage(new ContentPage()); + + double destroyingY = double.NaN; + double destroyingHeight = double.NaN; + double expectedY = double.NaN; + double expectedHeight = double.NaN; + + await CreateHandlerAndAddToWindow(mainPage, async (handler) => + { + var window = handler.VirtualView as Window; + Assert.NotNull(window); + + var appWindowPlatform = handler.PlatformView.GetAppWindow(); + Assert.NotNull(appWindowPlatform?.Presenter); + var presenter = Assert.IsType(appWindowPlatform.Presenter); + + // Capture the frame values at destroy time so we can verify them after cleanup + window.Destroying += (s, e) => + { + destroyingY = window.Y; + destroyingHeight = window.Height; + }; + + // Capture the expected work-area bounds before waiting for the maximized frame, + // so the same reference values are used for both the wait predicate and the + // post-cleanup assertions. + var displayArea = DisplayArea.GetFromWindowId(appWindowPlatform.Id, DisplayAreaFallback.Nearest); + var workArea = displayArea.WorkArea; + var density = handler.PlatformView.GetDisplayDensity(); + expectedY = workArea.Y / density; + expectedHeight = workArea.Height / density; + + // Maximize the window and wait until the MAUI frame reflects the maximized bounds. + // Waiting only for Height > 0 is insufficient: that condition is already true + // before Maximize() is called, so on slow machines the captured values at + // Destroying time could come from the pre-maximized frame. + // Do not assert window.Y == 0: on monitors positioned above the primary display + // Y is legitimately negative. + presenter.Maximize(); + await AssertEventually(() => + Math.Abs(window.Height - expectedHeight) < 2 && + Math.Abs(window.Y - expectedY) < 2); + }); + + // The window is destroyed during CreateHandlerAndAddToWindow cleanup. + // Assert that the bounds reported at Destroying time match the monitor work area, + // using the same < 2 tolerance as WindowsBoundsWhenMaximized. This catches + // regressions where Y or Height is off by ~8 pixels when closing a maximized window. + Assert.False(double.IsNaN(destroyingHeight), "Window.Destroying event was not raised"); + Assert.True(Math.Abs(destroyingY - expectedY) < 2, + $"Y should be near work area Y ({expectedY:F2}) when closing a maximized window, but was {destroyingY}"); + Assert.True(Math.Abs(destroyingHeight - expectedHeight) < 2, + $"Height should match work area height ({expectedHeight:F2}) when closing a maximized window, but was {destroyingHeight}"); + } + [Fact] public async Task ToggleFullscreenTitleBarWorks() { diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/LayoutShouldBeCorrectOnFirstNavigation.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/LayoutShouldBeCorrectOnFirstNavigation.png new file mode 100644 index 000000000000..752c49443dcc Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/LayoutShouldBeCorrectOnFirstNavigation.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithFontSize.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithFontSize.png index 34bf729a7dfe..43042fd8045f 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithFontSize.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithFontSize.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithShadow.png index 992300cdb23e..4ec6f9c2166e 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyEditorPlaceholderWithShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeDiabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeDiabled.png index 6adf11c37e61..a8da65143620 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeDiabled.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeDiabled.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeTextChanges.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeTextChanges.png index ce29ba8bb959..b3098b7db258 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeTextChanges.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorPlaceholderWithAutoSizeTextChanges.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorTextWhenAutoSizeTextChangesSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorTextWhenAutoSizeTextChangesSet.png index 2609d3b550b7..675113a4b146 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorTextWhenAutoSizeTextChangesSet.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3Editor_VerifyzEditorTextWhenAutoSizeTextChangesSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetContentAndTextTransform.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetContentAndTextTransform.png new file mode 100644 index 000000000000..e82018f57175 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetContentAndTextTransform.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetFontAutoScalingEnabled_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetFontAutoScalingEnabled_VerifyVisualState.png new file mode 100644 index 000000000000..264c5da1ed26 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetFontAutoScalingEnabled_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetFontFamilyAndTextTransform_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetFontFamilyAndTextTransform_VerifyVisualState.png new file mode 100644 index 000000000000..a725d1075fcf Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/Material3RadioButton_SetFontFamilyAndTextTransform_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/RadioButton_Checking_Initial_Configuration_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/RadioButton_Checking_Initial_Configuration_VerifyVisualState.png deleted file mode 100644 index 61d7c4c3aa56..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/RadioButton_Checking_Initial_Configuration_VerifyVisualState.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png index 01d6bc86a950..aba1cb5de17c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png index e629eb10d734..8b75a0bd4d13 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_Shell.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_Shell.png index aec4f2822f57..358eea01fc2d 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_Shell.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/ToolbarExtendsAllTheWayLeftAndRight_Shell.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentRendering.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentRendering.png new file mode 100644 index 000000000000..f8acf30e9476 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentRendering.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentTemplateRendering.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentTemplateRendering.png new file mode 100644 index 000000000000..544a67c76b3f Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentTemplateRendering.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentTemplateWithHeaderFooter.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentTemplateWithHeaderFooter.png new file mode 100644 index 000000000000..55a1e59bbb10 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentTemplateWithHeaderFooter.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentWithHeaderFooter.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentWithHeaderFooter.png new file mode 100644 index 000000000000..b8f42e605f57 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyCustomFlyoutContentWithHeaderFooter.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyDefaultFlyoutItemsRendering.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyDefaultFlyoutItemsRendering.png new file mode 100644 index 000000000000..76d1f86350c7 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyDefaultFlyoutItemsRendering.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlingnedHorizontally.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlignedHorizontally.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlingnedHorizontally.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlignedHorizontally.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlingnedVertically.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlignedVertically.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlingnedVertically.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAlignedVertically.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorTextWhenAutoSizeDisabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAutoSizeDisabled.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorTextWhenAutoSizeDisabled.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAutoSizeDisabled.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorTextWhenAutoSizeTextChangesSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAutoSizeTextChangesSet.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorTextWhenAutoSizeTextChangesSet.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorTextWhenAutoSizeTextChangesSet.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditor_WithShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorWithShadow.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditor_WithShadow.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyEditorWithShadow.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyFlyoutWithHeaderFooter.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyFlyoutWithHeaderFooter.png new file mode 100644 index 000000000000..749e4036a097 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyFlyoutWithHeaderFooter.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorPlaceholderWithAutoSizeDiabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorPlaceholderWithAutoSizeDiabled.png deleted file mode 100644 index 8aaf9be76931..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorPlaceholderWithAutoSizeDiabled.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorPlaceholderWithAutoSizeTextChanges.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorPlaceholderWithAutoSizeTextChanges.png deleted file mode 100644 index 7b4e28e059de..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android-notch-36/VerifyzEditorPlaceholderWithAutoSizeTextChanges.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ArabicStringShouldBeLeftToRight.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ArabicStringShouldBeLeftToRight.png index b0f346d6ec29..c9dfed57ee1d 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ArabicStringShouldBeLeftToRight.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ArabicStringShouldBeLeftToRight.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Bottom_SwipeItems.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Bottom_SwipeItems.png index 52b35ece9f41..ecfb611f6614 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Bottom_SwipeItems.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Bottom_SwipeItems.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ClearPlaceholderIconShouldHideWhenDisabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ClearPlaceholderIconShouldHideWhenDisabled.png new file mode 100644 index 000000000000..3c0448980062 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ClearPlaceholderIconShouldHideWhenDisabled.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DefaultSelectedTabTextColorShouldApplyProperly.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DefaultSelectedTabTextColorShouldApplyProperly.png index 33e288f6d1a7..bd9bad4e2b1f 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DefaultSelectedTabTextColorShouldApplyProperly.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DefaultSelectedTabTextColorShouldApplyProperly.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawStringShouldDrawText.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawStringShouldDrawText.png index 9e0a6601bda0..f8bf7cb41b91 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawStringShouldDrawText.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawStringShouldDrawText.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawTextWithinBounds.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawTextWithinBounds.png index 18c28051e939..ce1207f73409 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawTextWithinBounds.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DrawTextWithinBounds.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DynamicFontImageSourceColorShouldApplyOnTabIcon.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DynamicFontImageSourceColorShouldApplyOnTabIcon.png index 7fc101135cf1..68adcadb0cb9 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DynamicFontImageSourceColorShouldApplyOnTabIcon.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DynamicFontImageSourceColorShouldApplyOnTabIcon.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Ellipse_DashArray_DashOffset_Thickness.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Ellipse_DashArray_DashOffset_Thickness.png index 373b66f44e00..cb287267e173 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Ellipse_DashArray_DashOffset_Thickness.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Ellipse_DashArray_DashOffset_Thickness.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FlyoutSelectedStateReflectsUpdatedDynamicResource.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FlyoutSelectedStateReflectsUpdatedDynamicResource.png new file mode 100644 index 000000000000..61744e94ca4c Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FlyoutSelectedStateReflectsUpdatedDynamicResource.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FontImageSourceColorShouldApplyOnTabIcon.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FontImageSourceColorShouldApplyOnTabIcon.png index 8d197c081910..df4993b6e3eb 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FontImageSourceColorShouldApplyOnTabIcon.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FontImageSourceColorShouldApplyOnTabIcon.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GradientInTabBarShouldChange.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GradientInTabBarShouldChange.png index 49d62c7c9c11..c31f4bf41033 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GradientInTabBarShouldChange.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GradientInTabBarShouldChange.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GraphicsViewShouldNotWrapText.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GraphicsViewShouldNotWrapText.png index 1b1e837f8057..00b569cd9309 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GraphicsViewShouldNotWrapText.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GraphicsViewShouldNotWrapText.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GroupedCollectionViewGridLayoutRendersCorrectly.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GroupedCollectionViewGridLayoutRendersCorrectly.png new file mode 100644 index 000000000000..06a76bc22132 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/GroupedCollectionViewGridLayoutRendersCorrectly.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HEICImageShouldNotRenderUpsideDown.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HEICImageShouldNotRenderUpsideDown.png index 9de5ee79e8fb..a4a158abc9fe 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HEICImageShouldNotRenderUpsideDown.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HEICImageShouldNotRenderUpsideDown.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HeaderFooterGridWorks.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HeaderFooterGridWorks.png index 5735d443afe2..9c19c35a41a8 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HeaderFooterGridWorks.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/HeaderFooterGridWorks.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/IndicatorViewCircleShape.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/IndicatorViewCircleShape.png new file mode 100644 index 000000000000..a6d1f0f81dab Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/IndicatorViewCircleShape.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/IndicatorViewSquareShape.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/IndicatorViewSquareShape.png new file mode 100644 index 000000000000..f02e8006266e Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/IndicatorViewSquareShape.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue1323Test.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue1323Test.png index 23f107f0c962..1a4fd8fb5ce1 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue1323Test.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue1323Test.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ItemImageSourceShouldBeVisible.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ItemImageSourceShouldBeVisible.png index 34fe4a3ec89b..e2b874a14b94 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ItemImageSourceShouldBeVisible.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ItemImageSourceShouldBeVisible.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LayoutShouldBeCorrectOnFirstNavigation.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LayoutShouldBeCorrectOnFirstNavigation.png deleted file mode 100644 index 94c9b3f38387..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LayoutShouldBeCorrectOnFirstNavigation.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Left_SwipeItems.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Left_SwipeItems.png index c3368de74b39..4f3d6cf24f97 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Left_SwipeItems.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Left_SwipeItems.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_Checking_Initial_Configuration_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_Checking_Initial_Configuration_VerifyVisualState.png deleted file mode 100644 index 7cf311715f76..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_Checking_Initial_Configuration_VerifyVisualState.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetContentAndTextTransform.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetContentAndTextTransform.png new file mode 100644 index 000000000000..f5b80b7b0abd Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetContentAndTextTransform.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetFontAutoScalingEnabled_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetFontAutoScalingEnabled_VerifyVisualState.png new file mode 100644 index 000000000000..5e8bf7999285 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetFontAutoScalingEnabled_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetFontFamilyAndTextTransform_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetFontFamilyAndTextTransform_VerifyVisualState.png new file mode 100644 index 000000000000..a6f7f2acde5d Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/RadioButton_SetFontFamilyAndTextTransform_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Rectangle_DashArray_DashOffset_Thickness.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Rectangle_DashArray_DashOffset_Thickness.png index 84f4af1c8f90..f648271a4cb8 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Rectangle_DashArray_DashOffset_Thickness.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Rectangle_DashArray_DashOffset_Thickness.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Right_SwipeItems.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Right_SwipeItems.png index c382aa51c100..d14a3ecd8f54 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Right_SwipeItems.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Right_SwipeItems.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchBarClearButtonShouldBeVisibleWithText.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchBarClearButtonShouldBeVisibleWithText.png new file mode 100644 index 000000000000..b6ef2e77f979 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchBarClearButtonShouldBeVisibleWithText.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchBarClearButtonShouldDisappearAfterClearingInput.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchBarClearButtonShouldDisappearAfterClearingInput.png new file mode 100644 index 000000000000..6e9c3287db97 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchBarClearButtonShouldDisappearAfterClearingInput.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerClearIconUpdatesAtRuntime.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerClearIconUpdatesAtRuntime.png new file mode 100644 index 000000000000..4f47e06a0255 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerClearIconUpdatesAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerClearPlaceholderIconUpdatesAtRuntime.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerClearPlaceholderIconUpdatesAtRuntime.png new file mode 100644 index 000000000000..48ace269f70e Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerClearPlaceholderIconUpdatesAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerQueryIconUpdatesAtRuntime.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerQueryIconUpdatesAtRuntime.png new file mode 100644 index 000000000000..beb98a3b2e7f Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerQueryIconUpdatesAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerResetAllRestoresDefaultIcons.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerResetAllRestoresDefaultIcons.png new file mode 100644 index 000000000000..ce6cff55ef69 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SearchHandlerResetAllRestoresDefaultIcons.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SelectedTabIconShouldChangeColor.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SelectedTabIconShouldChangeColor.png index 00ded2a142d1..b92ad0b9fd7b 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SelectedTabIconShouldChangeColor.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SelectedTabIconShouldChangeColor.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Shadow_SetRadius_Zero.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Shadow_SetRadius_Zero.png index f343be885cba..dfd2e585c5c0 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Shadow_SetRadius_Zero.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Shadow_SetRadius_Zero.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldFlyoutTextWrapsInLandscape.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldFlyoutTextWrapsInLandscape.png index 2b4abe0ef731..a1cda73a43ae 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldFlyoutTextWrapsInLandscape.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldFlyoutTextWrapsInLandscape.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeItemFontAndSvgIconsRenderCorrectly.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeItemFontAndSvgIconsRenderCorrectly.png deleted file mode 100644 index 7a2fa268e4c1..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeItemFontAndSvgIconsRenderCorrectly.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarIconsShouldAutoscaleTabbedPage.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarIconsShouldAutoscaleTabbedPage.png index 7d22301104bf..6f855b8adfc5 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarIconsShouldAutoscaleTabbedPage.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarIconsShouldAutoscaleTabbedPage.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabTitlesShouldNotBeTruncated.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabTitlesShouldNotBeTruncated.png deleted file mode 100644 index b46ee8173827..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabTitlesShouldNotBeTruncated.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageBackButtonUpdated.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageBackButtonUpdated.png index b27f882d09d4..8bf3ada1fd5a 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageBackButtonUpdated.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageBackButtonUpdated.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingBackToLeftToRight.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingBackToLeftToRight.png index f7b01c077ee9..3c51307eb43c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingBackToLeftToRight.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingBackToLeftToRight.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingToLeftToRight.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingToLeftToRight.png index 6df32aaea917..e887687ea041 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingToLeftToRight.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_AfterChangingToLeftToRight.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_DefaultRightToLeftLayout.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_DefaultRightToLeftLayout.png index cfaca114947f..cde3b6965cb4 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_DefaultRightToLeftLayout.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageFlowDirection_DefaultRightToLeftLayout.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageUnselectedBarTextColorConsistency.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageUnselectedBarTextColorConsistency.png index 5fd4e12dc3ee..da4cfee651ba 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageUnselectedBarTextColorConsistency.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPageUnselectedBarTextColorConsistency.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_And_BarTextColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_And_BarTextColor_Verify.png index 40bdcf2e8738..e9c987973fbc 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_And_BarTextColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_And_BarTextColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Gradient_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Gradient_Verify.png index de0610fcaac9..07a1408e0597 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Gradient_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Gradient_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Solid_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Solid_Verify.png index ebea7ba6fff4..1bee5f3e58b1 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Solid_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_Solid_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_SelectedTabColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_SelectedTabColor_Verify.png index 430d5efa24d0..59debb70aaaf 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_SelectedTabColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_SelectedTabColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_UnselectedTabColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_UnselectedTabColor_Verify.png index 2e47e101f3d5..eb31c9b7c2aa 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_UnselectedTabColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarBackground_With_UnselectedTabColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_SelectedTabColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_SelectedTabColor_Verify.png index 991c2ef29331..c004025f2998 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_SelectedTabColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_SelectedTabColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_UnselectedTabColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_UnselectedTabColor_Verify.png index 3c2313e1642e..070420b52cd5 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_UnselectedTabColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_And_UnselectedTabColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_Verify.png index 5fe88832cc6e..1c648e209035 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_BarTextColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_FlowDirection_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_FlowDirection_Verify.png index 69ce574c5cc7..a6cf5348af91 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_FlowDirection_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_FlowDirection_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_IconImageSource_Change_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_IconImageSource_Change_Verify.png index 2711650414f1..db0f19aaddfd 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_IconImageSource_Change_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_IconImageSource_Change_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_1.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_1.png index 7d0c3fe82071..161f7e316cde 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_1.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_1.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_2.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_2.png index 6030f1146fb0..7f3b300e721f 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_2.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyFunctionalState_2.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyVisualState.png index 22d75e14a410..0035a0e71c35 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyVisualState.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_InitialState_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_And_SelectedItems_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_And_SelectedItems_Verify.png index 156ba1eaf442..7782831a2cca 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_And_SelectedItems_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_And_SelectedItems_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_Verify.png index e468a93b5a02..26e2fffa274c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemSource_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemTemplate_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemTemplate_Verify.png index 3b565b5b0115..5aa74e4e619c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemTemplate_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_ItemTemplate_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_1.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_1.png index 7e6c1cebaa2f..e66fc9a8142f 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_1.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_1.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_2.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_2.png index fa2ed9773e97..32e8a08ee829 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_2.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedAndUnselectedTabColor_Verify_2.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedItems_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedItems_Verify.png index b46012fbd7ea..a0def41c1ee5 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedItems_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedItems_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedTabColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedTabColor_Verify.png index 48ebe8af34e6..a3317de354b5 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedTabColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_SelectedTabColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_UnselectedTabColor_Verify.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_UnselectedTabColor_Verify.png index 223af03b243f..683f709e3ecb 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_UnselectedTabColor_Verify.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabbedPage_UnselectedTabColor_Verify.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TappingDescendantInsideShadowedBorderShouldUpdateBackgroundColor.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TappingDescendantInsideShadowedBorderShouldUpdateBackgroundColor.png new file mode 100644 index 000000000000..c445c9d310db Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TappingDescendantInsideShadowedBorderShouldUpdateBackgroundColor.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png deleted file mode 100644 index 381ef9c919bf..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_FlyoutPage.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png deleted file mode 100644 index 4f0a26bcbd5e..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_NavigationPage.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_Shell.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_Shell.png deleted file mode 100644 index 010b6b918aaf..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ToolbarExtendsAllTheWayLeftAndRight_Shell.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Top_SwipeItems.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Top_SwipeItems.png index 547762be3efc..16c0413cf8c2 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Top_SwipeItems.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Top_SwipeItems.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/UpdatedSelectionIndicatorProperly.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/UpdatedSelectionIndicatorProperly.png index 8ece97af2da3..997b7db84f28 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/UpdatedSelectionIndicatorProperly.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/UpdatedSelectionIndicatorProperly.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndAnchorYShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndAnchorYShadow.png index a3682451192f..3527378f002f 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndAnchorYShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndAnchorYShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndShadow.png index b876fa82ae89..1b2467aff14b 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorXAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorYAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorYAndShadow.png index 64730eb007f0..8f58c1184ee2 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorYAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyAnchorYAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyBorderWithNullStrokeDashArray.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyBorderWithNullStrokeDashArray.png new file mode 100644 index 000000000000..16e208c7b943 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyBorderWithNullStrokeDashArray.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyBorderWithStrokeDashArrayValue.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyBorderWithStrokeDashArrayValue.png new file mode 100644 index 000000000000..ee2641a4a9c2 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyBorderWithStrokeDashArrayValue.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselScrollsToEndItemAfterReset.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselScrollsToEndItemAfterReset.png new file mode 100644 index 000000000000..c2d33f7c3b3a Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselScrollsToEndItemAfterReset.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselViewKeepScrollOffsetAdd.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselViewKeepScrollOffsetAdd.png new file mode 100644 index 000000000000..4c909a70eb22 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselViewKeepScrollOffsetAdd.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithButtonSwipeItem.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithButtonSwipeItem.png index 66fd53bc0224..136204db8265 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithButtonSwipeItem.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithButtonSwipeItem.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithIconImageSwipeItem.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithIconImageSwipeItem.png index 921c0b8b6acf..f32931081790 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithIconImageSwipeItem.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewContentWithIconImageSwipeItem.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewTextShouldAppearAfterRotatingTheDevice.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewTextShouldAppearAfterRotatingTheDevice.png new file mode 100644 index 000000000000..e0ca9a3a7b88 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCollectionViewTextShouldAppearAfterRotatingTheDevice.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyDefaultScrollToRequested.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyDefaultScrollToRequested.png deleted file mode 100644 index 707712729705..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyDefaultScrollToRequested.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorResetToNone.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorResetToNone.png new file mode 100644 index 000000000000..6b6239e2503e Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorResetToNone.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorWithPlaceholder.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorWithPlaceholder.png new file mode 100644 index 000000000000..2d011b695096 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorWithPlaceholder.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorWithTextColor.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorWithTextColor.png new file mode 100644 index 000000000000..e08888a46cae Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorBackgroundColorWithTextColor.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderColorSetDefaultValue.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderColorSetDefaultValue.png new file mode 100644 index 000000000000..3a5623719578 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderColorSetDefaultValue.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderTextSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderTextSet.png index 7dbc94d651bc..e1087778009c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderTextSet.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorControlWhenPlaceholderTextSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderTextWhenFontAttributesBoldAndItalicSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderTextWhenFontAttributesBoldAndItalicSet.png new file mode 100644 index 000000000000..e0aeb2d5a0fb Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderTextWhenFontAttributesBoldAndItalicSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWhenFlowDirectionSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWhenFlowDirectionSet.png index 8759da465c6e..da930bace048 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWhenFlowDirectionSet.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWhenFlowDirectionSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeDiabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeDiabled.png deleted file mode 100644 index 4c7421229c5f..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeDiabled.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeDisabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeDisabled.png new file mode 100644 index 000000000000..3e860ad12b33 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeDisabled.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeTextChanges.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeTextChanges.png index 6e26bc873415..591fcc0e30d3 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeTextChanges.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithAutoSizeTextChanges.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithCharacterSpacing.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithCharacterSpacing.png index 219464e4a3b6..6bd08522772d 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithCharacterSpacing.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithCharacterSpacing.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontAttributes.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontAttributes.png index 79998a3471c3..9d2a7aab4f76 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontAttributes.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontAttributes.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontFamily.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontFamily.png index 64875718b242..e676579bf9c8 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontFamily.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontFamily.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontSize.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontSize.png index f37d9b1588eb..a11a5a1016ab 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontSize.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithFontSize.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithHorizontalAlignment.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithHorizontalAlignment.png index 2d96159f3ed3..1371e9355da2 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithHorizontalAlignment.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithHorizontalAlignment.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithShadow.png index 7f1f87266c2d..5329e401f0b9 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithVerticalAlignment.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithVerticalAlignment.png index ecfb09836616..32609ccfc831 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithVerticalAlignment.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorPlaceholderWithVerticalAlignment.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextColorSetDefaultValue.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextColorSetDefaultValue.png new file mode 100644 index 000000000000..645225a900e6 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextColorSetDefaultValue.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlingnedHorizontally.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlignedHorizontally.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlingnedHorizontally.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlignedHorizontally.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlingnedVertically.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlignedVertically.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlingnedVertically.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAlignedVertically.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeDisabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeDisabled.png index 8833964ed802..97bf90e51dec 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeDisabled.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeDisabled.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSet.png index 1a01c7ecdfb8..c1034199abce 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSet.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithHeightRequest.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithHeightRequest.png new file mode 100644 index 000000000000..d99de38f21f2 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithHeightRequest.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithShortShrinkText_LongText.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithShortShrinkText_LongText.png new file mode 100644 index 000000000000..c383356689f0 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithShortShrinkText_LongText.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithShortShrinkText_ShortText.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithShortShrinkText_ShortText.png new file mode 100644 index 000000000000..182bf4eaba46 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenAutoSizeTextChangesSetWithShortShrinkText_ShortText.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenFontAttributesBoldAndItalicSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenFontAttributesBoldAndItalicSet.png new file mode 100644 index 000000000000..5ef8f9e96bf8 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorTextWhenFontAttributesBoldAndItalicSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenBackgroundColorSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenBackgroundColorSet.png new file mode 100644 index 000000000000..a6bf131d328f Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenBackgroundColorSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenHeightAndWidthRequestSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenHeightAndWidthRequestSet.png new file mode 100644 index 000000000000..c68cf99ef5c9 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenHeightAndWidthRequestSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenHeightRequestSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenHeightRequestSet.png new file mode 100644 index 000000000000..18b21c27e897 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenHeightRequestSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacityResetToDefault.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacityResetToDefault.png new file mode 100644 index 000000000000..e62959362613 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacityResetToDefault.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacitySet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacitySet.png new file mode 100644 index 000000000000..7b677f1212a8 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacitySet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacitySetToZero.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacitySetToZero.png new file mode 100644 index 000000000000..7a870d11750f Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenOpacitySetToZero.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenWidthRequestSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenWidthRequestSet.png new file mode 100644 index 000000000000..bd6b58a9e78a Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWhenWidthRequestSet.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditor_WithShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWithShadow.png similarity index 100% rename from src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditor_WithShadow.png rename to src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorWithShadow.png diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyHorizontalScrollViewPositionAtRuntime.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyHorizontalScrollViewPositionAtRuntime.png new file mode 100644 index 000000000000..b282972948a2 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyHorizontalScrollViewPositionAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyOriginalTabbedPageDoesNotHaveMultipleTabsSelected.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyOriginalTabbedPageDoesNotHaveMultipleTabsSelected.png index bf0e11686539..027429f78fe6 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyOriginalTabbedPageDoesNotHaveMultipleTabsSelected.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyOriginalTabbedPageDoesNotHaveMultipleTabsSelected.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationAndShadow.png index 06a376c16872..d8bf7bf634d1 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationXAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationXAndShadow.png index b0ffe3660d95..6200ff4ef0ec 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationXAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationXAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationYAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationYAndShadow.png index 6515ad128f2c..803dbfc6af3c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationYAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyRotationYAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleAndShadow.png index a62e5ba26fce..7d9301253817 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleXAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleXAndShadow.png index dfe10c160d3d..d0b454111184 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleXAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleXAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleYAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleYAndShadow.png index d636edd17b45..6d29a3f6636b 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleYAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScaleYAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScrollViewDirection.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScrollViewDirection.png new file mode 100644 index 000000000000..2d6bc8cba8a0 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScrollViewDirection.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScrollViewHeightWhenRemoveChildAtRuntime.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScrollViewHeightWhenRemoveChildAtRuntime.png new file mode 100644 index 000000000000..b1b2149eb9be Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyScrollViewHeightWhenRemoveChildAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_BackgroundImageWithHeaderAndFooter.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_BackgroundImageWithHeaderAndFooter.png index fa9c4316069a..c1f75c474817 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_BackgroundImageWithHeaderAndFooter.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_BackgroundImageWithHeaderAndFooter.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_HeightAndWidthWithBackgroundImage.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_HeightAndWidthWithBackgroundImage.png index f042d3fb0f27..4e0c93f3912c 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_HeightAndWidthWithBackgroundImage.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_HeightAndWidthWithBackgroundImage.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewApperance.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewApperance.png index fcf143a8fbf1..3666aef19235 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewApperance.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewApperance.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithButtonSwipeItemsBackgroundColor.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithButtonSwipeItemsBackgroundColor.png index e68f354c18e9..582b6266672d 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithButtonSwipeItemsBackgroundColor.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithButtonSwipeItemsBackgroundColor.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithCollectionViewContentAndThreshold.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithCollectionViewContentAndThreshold.png index 694e85fdc8c1..44dda8c7e877 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithCollectionViewContentAndThreshold.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithCollectionViewContentAndThreshold.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithImageContentAndThreshold.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithImageContentAndThreshold.png index 5785837ec37c..95f4b8c39d74 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithImageContentAndThreshold.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithImageContentAndThreshold.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithLabelContentAndThreshold.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithLabelContentAndThreshold.png index b8da96542465..a2fcf542c194 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithLabelContentAndThreshold.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifySwipeViewWithLabelContentAndThreshold.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTabbedPageDoesNotHaveMultipleTabsSelected.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTabbedPageDoesNotHaveMultipleTabsSelected.png index 4501617a128a..d5d81847a9f1 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTabbedPageDoesNotHaveMultipleTabsSelected.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTabbedPageDoesNotHaveMultipleTabsSelected.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationXAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationXAndShadow.png index 3f20bcc7d8de..7c50c70bae9d 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationXAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationXAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationYAndShadow.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationYAndShadow.png index 3a647a175b6c..acb7849baa49 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationYAndShadow.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyTranslationYAndShadow.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorPlaceholderWithAutoSizeDiabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorPlaceholderWithAutoSizeDiabled.png deleted file mode 100644 index 8dc50e1f7355..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorPlaceholderWithAutoSizeDiabled.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorPlaceholderWithAutoSizeTextChanges.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorPlaceholderWithAutoSizeTextChanges.png deleted file mode 100644 index 1a271017ec08..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorPlaceholderWithAutoSizeTextChanges.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorTextWhenAutoSizeDisabled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorTextWhenAutoSizeDisabled.png deleted file mode 100644 index c6b1e25b2b67..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorTextWhenAutoSizeDisabled.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorTextWhenAutoSizeTextChangesSet.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorTextWhenAutoSizeTextChangesSet.png deleted file mode 100644 index 78d417c60c8e..000000000000 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyzEditorTextWhenAutoSizeTextChangesSet.png and /dev/null differ diff --git a/src/Controls/tests/TestCases.HostApp/CoreViews/CorePageView.cs b/src/Controls/tests/TestCases.HostApp/CoreViews/CorePageView.cs index 9c7ee2c791ac..3c676c527012 100644 --- a/src/Controls/tests/TestCases.HostApp/CoreViews/CorePageView.cs +++ b/src/Controls/tests/TestCases.HostApp/CoreViews/CorePageView.cs @@ -130,7 +130,8 @@ public override string ToString() new GalleryPageFactory(() => new ShellFeaturePage(), "Shell Feature Matrix"), new GalleryPageFactory(() => new BrushesControlPage(), "Brushes Feature Matrix"), new GalleryPageFactory(() => new BindableLayoutControlPage(), "BindableLayout Feature Matrix"), - new GalleryPageFactory(() => new VisualTransformControlPage(), "VisualTransform Feature Matrix"), + new GalleryPageFactory(() => new VisualTransformControlPage(), "VisualTransform Feature Matrix"), + new GalleryPageFactory(() => new MenuBarItemControlPage(), "MenuBarItem Feature Matrix"), new GalleryPageFactory(() => new SafeAreaFeaturePage(), "SafeArea Feature Matrix"), }; diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Button/ButtonOptionsPage.xaml b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Button/ButtonOptionsPage.xaml index dba6f7dcb1ac..8895f406a173 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Button/ButtonOptionsPage.xaml +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Button/ButtonOptionsPage.xaml @@ -10,11 +10,11 @@ Clicked="ApplyButton_Clicked" AutomationId="Apply"/> - + - + - \ No newline at end of file + + diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml.cs b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml.cs index 01f8139ba12a..b4ac87223b09 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml.cs +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml.cs @@ -28,17 +28,11 @@ public EditorControlMainPage(EditorViewModel viewModel) private async void NavigateToOptionsPage_Clicked(object sender, EventArgs e) { - BindingContext = _viewModel = new EditorViewModel(); - _viewModel.Text = "Test Editor"; - _viewModel.Placeholder = "Enter text here"; - _viewModel.VerticalTextAlignment = TextAlignment.End; - _viewModel.CursorPosition = 0; - _viewModel.SelectionLength = 0; - _viewModel.HeightRequest = -1; + _viewModel.Reset(); await Navigation.PushAsync(new EditorOptionsPage(_viewModel)); } - private void CursorPositionButton_Clicked(object sender, EventArgs e) + private void CursorPositionEntry_TextChanged(object sender, TextChangedEventArgs e) { if (int.TryParse(CursorPositionEntry.Text, out int cursorPosition)) { @@ -46,7 +40,7 @@ private void CursorPositionButton_Clicked(object sender, EventArgs e) } } - private void SelectionLength_Clicked(object sender, EventArgs e) + private void SelectionLengthEntry_TextChanged(object sender, TextChangedEventArgs e) { if (int.TryParse(SelectionLengthEntry.Text, out int selectionLength)) { @@ -129,4 +123,5 @@ private void OnLabelTapped(object sender, EventArgs e) { EditorControl.Unfocus(); } -} \ No newline at end of file +} + diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorOptionsPage.xaml b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorOptionsPage.xaml index f81302ea3111..75363e1ad6e5 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorOptionsPage.xaml +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorOptionsPage.xaml @@ -8,16 +8,16 @@ RowDefinitions="Auto, Auto, Auto, Auto, Auto, Auto, Auto, Auto" ColumnSpacing="8"> + Grid.Row="1">