From 10d9bdb09b22a19b2ca4bfad7b3f14a8ffd2ac1f Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Sat, 20 Jul 2024 00:01:02 +0100 Subject: [PATCH] [windows] Fix Updating the initial position (#23709) [Windows] Fix CollectionView DisconnectHandler SelectionMode Crash (#23726) * Reorder events to prevent disconnect issues * Add test --------- Co-authored-by: Mike Corsaro Use UIView.Window instead of the global window (#23693) [Windows] Subscribe pointer events only when needed (#23515) Fix iOS log exports (#23334) * Fix ios Device Logging * - fix up device test logging a bit more * Update ui-tests-steps.yml * Update ui-tests-steps.yml * Update ios.cake * Update ui-tests-steps.yml * Update maui-templates.yml * Update maui-templates.yml remove code from oldFragment add new shiny DialogFragment refactoring code to find and dismiss DialogFragment code cleanup delete ModalContainer to use only ModalFragment handle animation and add a map between page and dialogFragment We've back button enabled! After dismissing several demons summoned using obscure Android APIs, I was able to deal with the BackButtonPressed event add modal animations as anim.xml files using cleanup remowork PopModalPlatformAsync to work with dialogFragment remove tag final adjustments on DialogFragment change the ShowNow for Show to fix the issue Wait for animation to complete change local functions order fix build create window hooks for android (like iOS) clean up ModalFragment fields change Dictionary to ConditionalWeakTable clean up event animation refactor on Null notation remove comments - adjust back button - different back button code style remove unused prop. fix DontPushModalPagesWhenWindowIsDeactivated DeviceTest completes the task return back the way how modalManager handles android modals normilize animation duration Co-authored-by: Shane Neuville remove focusability code change how fragments are looked-up code style --- eng/devices/ios.cake | 50 +- eng/pipelines/common/device-tests-steps.yml | 14 +- eng/pipelines/common/device-tests.yml | 2 +- eng/pipelines/common/maui-templates.yml | 15 +- eng/pipelines/common/ui-tests-steps.yml | 8 +- .../Items/CarouselViewHandler.Windows.cs | 20 +- .../SelectableItemsViewHandler.Windows.cs | 2 +- .../GesturePlatformManager.Windows.cs | 40 +- .../GesturePlatformManager.iOS.cs | 7 +- .../ModalNavigationManager.Android.cs | 513 ++++++++---------- .../ModalNavigationManager.cs | 3 +- .../CollectionViewTests.Windows.cs | 48 +- .../anim/nav_modal_default_enter_anim.xml | 7 + .../anim/nav_modal_default_exit_anim.xml | 7 + 14 files changed, 384 insertions(+), 352 deletions(-) create mode 100644 src/Core/src/Platform/Android/Resources/anim/nav_modal_default_enter_anim.xml create mode 100644 src/Core/src/Platform/Android/Resources/anim/nav_modal_default_exit_anim.xml diff --git a/eng/devices/ios.cake b/eng/devices/ios.cake index 9e9871fcc5ae..e65fa2e09ee5 100644 --- a/eng/devices/ios.cake +++ b/eng/devices/ios.cake @@ -44,7 +44,7 @@ var dotnetToolPath = GetDotnetToolPath(); Setup(context => { LogSetupInfo(dotnetToolPath); - PerformCleanupIfNeeded(deviceCleanupEnabled); + PerformCleanupIfNeeded(deviceCleanupEnabled, false); // Device or simulator setup if (testDevice.Contains("device")) @@ -58,7 +58,7 @@ Setup(context => } }); -Teardown(context => PerformCleanupIfNeeded(deviceCleanupEnabled)); +Teardown(context => PerformCleanupIfNeeded(deviceCleanupEnabled, true)); Task("Cleanup"); @@ -307,15 +307,59 @@ void ExecuteCGLegacyUITests(string project, string appProject, string device, st // Helper methods -void PerformCleanupIfNeeded(bool cleanupEnabled) +void PerformCleanupIfNeeded(bool cleanupEnabled, bool createDeviceLogs) { if (cleanupEnabled) { + var logDirectory = GetLogDirectory(); Information("Cleaning up..."); Information("Deleting XHarness simulator if exists..."); var sims = ListAppleSimulators().Where(s => s.Name.Contains("XHarness")).ToArray(); foreach (var sim in sims) { + if(createDeviceLogs) + { + try + { + var homeDirectory = Environment.GetEnvironmentVariable("HOME"); + Information("Diagnostics Reports"); + StartProcess("zip", new ProcessSettings { + Arguments = new ProcessArgumentBuilder() + .Append("-9r") + .AppendQuoted($"{logDirectory}/DiagnosticReports_{sim.UDID}.zip") + .AppendQuoted($"{homeDirectory}/Library/Logs/DiagnosticReports/"), + RedirectStandardOutput = false + }); + + Information("CoreSimulator"); + StartProcess("zip", new ProcessSettings { + Arguments = new ProcessArgumentBuilder() + .Append("-9r") + .AppendQuoted($"{logDirectory}/CoreSimulator_{sim.UDID}.zip") + .AppendQuoted($"{homeDirectory}/Library/Logs/CoreSimulator/{sim.UDID}"), + RedirectStandardOutput = false + }); + + StartProcess("xcrun", $"simctl spawn {sim.UDID} log collect --output {homeDirectory}/{sim.UDID}_log.logarchive"); + + StartProcess("zip", new ProcessSettings { + Arguments = new ProcessArgumentBuilder() + .Append("-9r") + .AppendQuoted($"{logDirectory}/{sim.UDID}_log.logarchive.zip") + .AppendQuoted($"{homeDirectory}/{sim.UDID}_log.logarchive"), + RedirectStandardOutput = false + }); + + var screenshotPath = $"{testResultsPath}/{sim.UDID}_screenshot.png"; + StartProcess("xcrun", $"simctl io {sim.UDID} screenshot {screenshotPath}"); + } + catch(Exception ex) + { + Information($"Failed to collect logs for simulator {sim.Name} ({sim.UDID}): {ex.Message}"); + Information($"Command Executed: simctl spawn {sim.UDID} log collect --output {logDirectory}/{sim.UDID}_log.logarchive"); + } + } + Information($"Deleting XHarness simulator {sim.Name} ({sim.UDID})..."); StartProcess("xcrun", $"simctl shutdown {sim.UDID}"); ExecuteWithRetries(() => StartProcess("xcrun", $"simctl delete {sim.UDID}"), 3); diff --git a/eng/pipelines/common/device-tests-steps.yml b/eng/pipelines/common/device-tests-steps.yml index f597e308e244..a9ac50cdeecb 100644 --- a/eng/pipelines/common/device-tests-steps.yml +++ b/eng/pipelines/common/device-tests-steps.yml @@ -79,14 +79,6 @@ steps: Write-Host "##vso[task.setvariable variable=Platform.Name]${platformName}" displayName: 'Set Platform.Name' - - ${{ if eq(parameters.platform, 'ios')}}: - - bash: | - if [ -f "$HOME/Library/Logs/CoreSimulator/*" ]; then rm -r $HOME/Library/Logs/CoreSimulator/*; fi - if [ -f "$HOME/Library/Logs/DiagnosticReports/*" ]; then rm -r $HOME/Library/Logs/DiagnosticReports/*; fi - displayName: Delete Old Simulator Logs - condition: always() - continueOnError: true - - ${{ if eq(parameters.platform, 'windows')}}: - pwsh: | $errorPath = "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" @@ -113,10 +105,8 @@ steps: - ${{ if eq(parameters.platform, 'ios')}}: - bash: | - suffix=$(date +%Y%m%d%H%M%S) - zip -9r "$(LogDirectory)/CoreSimulatorLog_${suffix}.zip" "$HOME/Library/Logs/CoreSimulator/" - zip -9r "$(LogDirectory)/DiagnosticReports_${suffix}.zip" "$HOME/Library/Logs/DiagnosticReports/" - displayName: Zip Simulator Logs + pwsh ./build.ps1 --target=Cleanup -Script eng/devices/${{ parameters.platform }}.cake ---results="$(TestResultsDirectory)" ${{ parameters.cakeArgs }} + displayName: Cleanup and Create Simulator Logs if Test Run Failed To condition: always() continueOnError: true diff --git a/eng/pipelines/common/device-tests.yml b/eng/pipelines/common/device-tests.yml index b9c562368761..f4a6b8b18eee 100644 --- a/eng/pipelines/common/device-tests.yml +++ b/eng/pipelines/common/device-tests.yml @@ -72,7 +72,7 @@ stages: clean: all displayName: "iOS tests" pool: ${{ parameters.iosPool }} - timeoutInMinutes: 140 + timeoutInMinutes: 45 strategy: matrix: # create all the variables used for the matrix diff --git a/eng/pipelines/common/maui-templates.yml b/eng/pipelines/common/maui-templates.yml index 29d0e06fd6ee..645f1154fa52 100644 --- a/eng/pipelines/common/maui-templates.yml +++ b/eng/pipelines/common/maui-templates.yml @@ -170,14 +170,6 @@ jobs: DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token) PRIVATE_BUILD: $(PrivateBuild) - - ${{ if eq(RunPlatform.testName, 'RunOniOS') }}: - - bash: | - if [ -f "$HOME/Library/Logs/CoreSimulator/*" ]; then rm -r $HOME/Library/Logs/CoreSimulator/*; fi - if [ -f "$HOME/Library/Logs/DiagnosticReports/*" ]; then rm -r $HOME/Library/Logs/DiagnosticReports/*; fi - displayName: Delete Old Simulator Logs - condition: always() - continueOnError: true - # - script: dotnet tool update Microsoft.DotNet.XHarness.CLI --add-source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json --version "9.0.0-prerelease*" -g # displayName: install xharness @@ -200,11 +192,8 @@ jobs: IOS_TEST_DEVICE: ios-simulator-64_17.2 - ${{ if eq(RunPlatform.testName, 'RunOniOS') }}: - - bash: | - suffix=$(date +%Y%m%d%H%M%S) - zip -9r "$(LogDirectory)/CoreSimulatorLog_${suffix}.zip" "$HOME/Library/Logs/CoreSimulator/" - zip -9r "$(LogDirectory)/DiagnosticReports_${suffix}.zip" "$HOME/Library/Logs/DiagnosticReports/" - displayName: Zip Simulator Logs + - pwsh: ./build.ps1 --target=Cleanup -Script eng/devices/ios.cake ---results="$(TestResultsDirectory)" ${{ parameters.cakeArgs }} + displayName: Cleanup and Create Simulator Logs if Test Run Failed To condition: always() continueOnError: true diff --git a/eng/pipelines/common/ui-tests-steps.yml b/eng/pipelines/common/ui-tests-steps.yml index 7835b347a9ca..86ab2bd5c7c3 100644 --- a/eng/pipelines/common/ui-tests-steps.yml +++ b/eng/pipelines/common/ui-tests-steps.yml @@ -99,7 +99,7 @@ steps: $command += " --test-filter ""$testFilter""" } - Invoke-Expression $command + Invoke-Expression $command displayName: $(Agent.JobName) ${{ if ne(parameters.platform, 'android')}}: retryCountOnTaskFailure: 1 @@ -107,10 +107,8 @@ steps: APPIUM_HOME: $(APPIUM_HOME) - bash: | - suffix=$(date +%Y%m%d%H%M%S) - zip -9r "$(LogDirectory)/CoreSimulatorLog_${suffix}.zip" "$HOME/Library/Logs/CoreSimulator/" - zip -9r "$(LogDirectory)/DiagnosticReports_${suffix}.zip" "$HOME/Library/Logs/DiagnosticReports/" - displayName: Zip Simulator Logs + pwsh ./build.ps1 --target=Cleanup -Script eng/devices/${{ parameters.platform }}.cake ---results="$(TestResultsDirectory)" ${{ parameters.cakeArgs }} + displayName: Cleanup and Create Simulator Logs if Test Run Failed To condition: ${{ eq(parameters.platform, 'ios') }} continueOnError: true diff --git a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs index 18b0141565c6..b0a9c259d054 100644 --- a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.Linq; using Microsoft.Maui.Controls.Platform; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -187,7 +188,13 @@ public static void MapCurrentItem(CarouselViewHandler handler, CarouselView caro public static void MapPosition(CarouselViewHandler handler, CarouselView carouselView) { - handler.UpdatePosition(); + // If the initial position hasn't been set, we have a UpdateInitialPosition call on CarouselViewHandler + // that will handle this so we want to skip this mapper call. We need to wait for the LIstView to be ready + if (handler.InitialPositionSet) + { + handler.UpdatePosition(); + } + } public static void MapIsBounceEnabled(CarouselViewHandler handler, CarouselView carouselView) @@ -210,6 +217,9 @@ public static void MapLoop(CarouselViewHandler handler, CarouselView carouselVie handler.UpdateLoop(); } + internal bool InitialPositionSet { get; private set; } + + void UpdateIsBounceEnabled() { if (_scrollViewer != null) @@ -337,7 +347,7 @@ int GetItemPositionInCarousel(object item) return -1; } - void UpdateCarouselViewInitialPosition() + void UpdateInitialPosition() { if (ListViewBase == null) { @@ -348,7 +358,7 @@ void UpdateCarouselViewInitialPosition() { if (Element.Loop) { - var item = ListViewBase.Items[0]; + var item = ItemsView.CurrentItem ?? ListViewBase.Items.FirstOrDefault(); _loopableCollectionView.CenterMode = true; ListViewBase.ScrollIntoView(item); _loopableCollectionView.CenterMode = false; @@ -358,6 +368,8 @@ void UpdateCarouselViewInitialPosition() UpdateCurrentItem(); else UpdatePosition(); + + InitialPositionSet = true; } } @@ -563,7 +575,7 @@ void InitialSetup() UpdateItemsSource(); UpdateSnapPointsType(); UpdateSnapPointsAlignment(); - UpdateCarouselViewInitialPosition(); + UpdateInitialPosition(); } void InvalidateItemSize() diff --git a/src/Controls/src/Core/Handlers/Items/SelectableItemsViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/SelectableItemsViewHandler.Windows.cs index 988e5334560e..55d91f386781 100644 --- a/src/Controls/src/Core/Handlers/Items/SelectableItemsViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/SelectableItemsViewHandler.Windows.cs @@ -49,8 +49,8 @@ protected override void DisconnectHandler(ListViewBase platformView) if (oldListViewBase != null) { - oldListViewBase.ClearValue(ListViewBase.SelectionModeProperty); oldListViewBase.SelectionChanged -= PlatformSelectionChanged; + oldListViewBase.ClearValue(ListViewBase.SelectionModeProperty); } if (ItemsView != null) diff --git a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.Windows.cs b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.Windows.cs index ba6d8a98f69c..f905812c66bf 100644 --- a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.Windows.cs +++ b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.Windows.cs @@ -730,15 +730,7 @@ void PinchComplete(bool success) void UpdateDragAndDropGestureRecognizers() { - if (_container is null) - { - return; - } - - var view = Element as View; - IList? gestures = view?.GestureRecognizers; - - if (gestures is null) + if (_container is null || Element is not View view || view.GestureRecognizers is not IList gestures) { return; } @@ -812,16 +804,17 @@ void UpdatingGestureRecognizers() } } - _subscriptionFlags |= SubscriptionFlags.ContainerPgrPointerEventsSubscribed; - _container.PointerEntered += OnPgrPointerEntered; - _container.PointerExited += OnPgrPointerExited; - _container.PointerMoved += OnPgrPointerMoved; - _container.PointerPressed += OnPgrPointerPressed; - _container.PointerReleased += OnPgrPointerReleased; + bool hasPointerGesture = ElementGestureRecognizers.HasAnyGesturesFor(); + + if (hasPointerGesture) + { + SubscribePointerEvents(_container); + } bool hasSwipeGesture = gestures.HasAnyGesturesFor(); bool hasPinchGesture = gestures.HasAnyGesturesFor(); bool hasPanGesture = gestures.HasAnyGesturesFor(); + if (!hasSwipeGesture && !hasPinchGesture && !hasPanGesture) { return; @@ -840,6 +833,12 @@ void UpdatingGestureRecognizers() return; } + // Pan, pinch, and swipe gestures need pointer events if not subscribed yet. + if (!hasPointerGesture) + { + SubscribePointerEvents(_container); + } + _subscriptionFlags |= SubscriptionFlags.ContainerManipulationAndPointerEventsSubscribed; _container.ManipulationMode = ManipulationModes.Scale | ManipulationModes.TranslateX | ManipulationModes.TranslateY; _container.ManipulationDelta += OnManipulationDelta; @@ -848,6 +847,17 @@ void UpdatingGestureRecognizers() _container.PointerCanceled += OnPointerCanceled; } + void SubscribePointerEvents(FrameworkElement container) + { + _subscriptionFlags |= SubscriptionFlags.ContainerPgrPointerEventsSubscribed; + + container.PointerEntered += OnPgrPointerEntered; + container.PointerExited += OnPgrPointerExited; + container.PointerMoved += OnPgrPointerMoved; + container.PointerPressed += OnPgrPointerPressed; + container.PointerReleased += OnPgrPointerReleased; + } + void HandleTapped(object sender, TappedRoutedEventArgs tappedRoutedEventArgs) { tappedRoutedEventArgs.Handled = true; diff --git a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs index 128878f5e566..701af0d1e5b9 100644 --- a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs +++ b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs @@ -275,11 +275,11 @@ static void ProcessRecognizerHandlerTap( if (weakRecognizer.Target is IPinchGestureController pinchGestureRecognizer && weakEventTracker.Target is GesturePlatformManager eventTracker && eventTracker._handler?.VirtualView is View view && - UIApplication.SharedApplication.GetKeyWindow() is UIWindow window) + eventTracker.PlatformView is {} platformView) { var oldScale = eventTracker._previousScale; var originPoint = r.LocationInView(null); - originPoint = window.ConvertPointToView(originPoint, eventTracker.PlatformView); + originPoint = platformView.Window.ConvertPointToView(originPoint, platformView); var scaledPoint = new Point(originPoint.X / view.Width, originPoint.Y / view.Height); @@ -412,8 +412,7 @@ UISwipeGestureRecognizer CreateSwipeRecognizer(SwipeDirection direction, Action< { if (weakRecognizer.Target is PointerGestureRecognizer pointerGestureRecognizer && weakEventTracker.Target is GesturePlatformManager eventTracker && - eventTracker._handler?.VirtualView is View view && - eventTracker._handler?.MauiContext?.GetPlatformWindow() is UIWindow window) + eventTracker._handler?.VirtualView is View view) { var originPoint = pointerGesture.LocationInView(eventTracker?.PlatformView); var platformPointerArgs = new PlatformPointerEventArgs(pointerGesture.View, pointerGesture); diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs index 687a16e2080e..32177ea38a9b 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -1,18 +1,21 @@ using System; -using System.ComponentModel; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Android.Content; +using Android.Graphics.Drawables; using Android.OS; +using Android.Runtime; using Android.Views; +using System.Runtime.CompilerServices; using Android.Views.Animations; -using AndroidX.Activity; -using AndroidX.AppCompat.App; -using AndroidX.AppCompat.Widget; -using AndroidX.Core.View; using AndroidX.Fragment.App; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Platform; +using AAnimation = Android.Views.Animations.Animation; +using AColor = Android.Graphics.Color; using AView = Android.Views.View; +using AndroidX.AppCompat.App; +using Microsoft.Maui.LifecycleEvents; +using AndroidX.Activity; namespace Microsoft.Maui.Controls.Platform { @@ -21,11 +24,57 @@ internal partial class ModalNavigationManager ViewGroup? _modalParentView; bool _navAnimationInProgress; internal const string CloseContextActionsSignalName = "Xamarin.CloseContextActions"; + AAnimation? _dismissAnimation; + bool _platformActivated; + + readonly Stack _modals = []; partial void InitializePlatform() { _window.Activated += (_, _) => SyncModalStackWhenPlatformIsReady(); - _window.Resumed += (_, _) => SyncModalStackWhenPlatformIsReady(); + _window.HandlerChanging += OnPlatformWindowHandlerChanging; + _window.PropertyChanging += OnWindowPropertyChanging; + } + + void OnWindowPropertyChanging(object sender, PropertyChangingEventArgs e) + { + if (e.PropertyName != Window.PageProperty.PropertyName) + { + return; + } + + var handler = _currentPage?.Handler; + var windowP = _window.Page; + if (CurrentPage is not null && + _window.Page != CurrentPage) + { + ClearModalPages(xplat: true, platform: true); + + var fragmentManager = WindowMauiContext.GetFragmentManager(); + + foreach (var dialogFragmentId in _modals) + { + var dialogFragment = (ModalFragment?)fragmentManager.FindFragmentByTag(dialogFragmentId); + dialogFragment?.Dismiss(); + } + _modals.Clear(); + } + } + + void OnPlatformWindowHandlerChanging(object? sender, HandlerChangingEventArgs e) + { + _platformActivated = _window.IsActivated; + } + + void OnWindowsActivated(object? sender, EventArgs e) + { + if (_platformActivated) + { + return; + } + + _platformActivated = true; + SyncModalStackWhenPlatformIsReady(); } // This is only here for the device tests to use. @@ -68,45 +117,46 @@ Task PopModalPlatformAsync(bool animated) Page modal = CurrentPlatformModalPage; _platformModalPages.Remove(modal); + var fragmentManager = WindowMauiContext.GetFragmentManager(); + + var dialogFragmentId = _modals.Pop(); + var dialogFragment = (ModalFragment?)fragmentManager.FindFragmentByTag(dialogFragmentId); + + // If for the dialog is null what we want to do? + if (dialogFragment is null) + { + return Task.FromResult(modal); + } + var source = new TaskCompletionSource(); - if (modal.Handler is IPlatformViewHandler modalHandler) + if (animated && dialogFragment.View is not null) { - ModalContainer? modalContainer = null; - for (int i = 0; i <= GetModalParentView().ChildCount; i++) - { - if (GetModalParentView().GetChildAt(i) is ModalContainer mc && - mc.Modal == modal) - { - modalContainer = mc; - } - } + _dismissAnimation ??= AnimationUtils.LoadAnimation(WindowMauiContext.Context, Resource.Animation.nav_modal_default_exit_anim)!; - _ = modalContainer ?? throw new InvalidOperationException("Parent is not Modal Container"); + _dismissAnimation.AnimationEnd += OnAnimationEnded; - if (animated) - { - modalContainer - .Animate()?.TranslationY(GetModalParentView().Height)? - .SetInterpolator(new AccelerateInterpolator(1))?.SetDuration(300)?.SetListener(new GenericAnimatorListener - { - OnEnd = a => - { - modalContainer.Destroy(); - source.TrySetResult(modal); - modalContainer = null; - } - }); - } - else - { - modalContainer.Destroy(); - source.TrySetResult(modal); - } + dialogFragment.View.StartAnimation(_dismissAnimation); + } + else + { + source.TrySetResult(modal); } - RestoreFocusability(GetCurrentRootView()); return source.Task; + + void OnAnimationEnded(object? sender, AAnimation.AnimationEndEventArgs e) + { + if (sender is not AAnimation animation) + { + return; + } + + animation.AnimationEnd -= OnAnimationEnded; + dialogFragment.Dismiss(); + source.TrySetResult(modal); + _dismissAnimation = null; + } } // The CurrentPage doesn't represent the root of the platform hierarchy. @@ -124,13 +174,9 @@ async Task PushModalPlatformAsync(Page modal, bool animated) { var viewToHide = GetCurrentRootView(); - RemoveFocusability(viewToHide); - _platformModalPages.Add(modal); - Task presentModal = PresentModal(modal, animated); - - await presentModal; + await PresentModal(modal, animated); // The state of things might have changed after the modal view was pushed if (IsModalReady) @@ -140,327 +186,210 @@ async Task PushModalPlatformAsync(Page modal, bool animated) } } - Task PresentModal(Page modal, bool animated) + async Task PresentModal(Page modal, bool animated) { + TaskCompletionSource animationCompletionSource = new(); + var parentView = GetModalParentView(); - var modalContainer = new ModalContainer(WindowMauiContext, modal, parentView); - var source = new TaskCompletionSource(); - NavAnimationInProgress = true; + var dialogFragment = new ModalFragment(WindowMauiContext, modal) + { + Cancelable = false, + IsAnimated = animated + }; + + var fragmentManager = WindowMauiContext.GetFragmentManager(); + var dialogFragmentId = AView.GenerateViewId().ToString(); + _modals.Push(dialogFragmentId); + dialogFragment.Show(fragmentManager, dialogFragmentId); + if (animated) { - modalContainer.TranslationY = GetModalParentView().Height; - modalContainer?.Animate()?.TranslationY(0)?.SetInterpolator(new DecelerateInterpolator(1))?.SetDuration(300)?.SetListener(new GenericAnimatorListener - { - OnEnd = a => - { - source.TrySetResult(false); - modalContainer = null; - }, - OnCancel = a => - { - source.TrySetResult(true); - modalContainer = null; - } - }); + NavAnimationInProgress = true; + dialogFragment!.AnimationEnded += OnAnimationEnded; + + await animationCompletionSource.Task; } else { - source.TrySetResult(true); + NavAnimationInProgress = false; + animationCompletionSource.TrySetResult(true); } - return source.Task.ContinueWith(task => NavAnimationInProgress = false); - } - - void RestoreFocusability(AView platformView) - { - platformView.ImportantForAccessibility = ImportantForAccessibility.Auto; - - if (OperatingSystem.IsAndroidVersionAtLeast(26)) - platformView.SetFocusable(ViewFocusability.FocusableAuto); - - if (platformView is ViewGroup vg) - vg.DescendantFocusability = DescendantFocusability.BeforeDescendants; - } - - void RemoveFocusability(AView platformView) - { - platformView.ImportantForAccessibility = ImportantForAccessibility.NoHideDescendants; - - if (OperatingSystem.IsAndroidVersionAtLeast(26)) - platformView.SetFocusable(ViewFocusability.NotFocusable); - - // Without setting this the keyboard will still navigate to components behind the modal page - if (platformView is ViewGroup vg) - vg.DescendantFocusability = DescendantFocusability.BlockDescendants; + void OnAnimationEnded(object? sender, EventArgs e) + { + dialogFragment!.AnimationEnded -= OnAnimationEnded; + NavAnimationInProgress = false; + animationCompletionSource.SetResult(true); + } } - sealed class ModalContainer : ViewGroup + internal class ModalFragment : DialogFragment { - AView _backgroundView; - IMauiContext? _windowMauiContext; - public Page? Modal { get; private set; } - ModalFragment _modalFragment; - FragmentManager? _fragmentManager; - NavigationRootManager? NavigationRootManager => _modalFragment.NavigationRootManager; - int _currentRootViewHeight = 0; - int _currentRootViewWidth = 0; - GenericGlobalLayoutListener? _rootViewLayoutListener; - AView? _rootView; - - AView GetWindowRootView() => - _windowMauiContext - ?.GetNavigationRootManager() - ?.RootView ?? - throw new InvalidOperationException("Current Root View cannot be null"); - - public ModalContainer( - IMauiContext windowMauiContext, - Page modal, - ViewGroup parentView) - : base(windowMauiContext?.Context ?? throw new ArgumentNullException($"{nameof(windowMauiContext.Context)}")) - { - _windowMauiContext = windowMauiContext; - Modal = modal; - _backgroundView = new AView(_windowMauiContext.Context); - UpdateBackgroundColor(); - AddView(_backgroundView); - - Id = AView.GenerateViewId(); - - _modalFragment = new ModalFragment(_windowMauiContext, modal); - _fragmentManager = _windowMauiContext.GetFragmentManager(); + Page _modal; + IMauiContext _mauiWindowContext; + NavigationRootManager? _navigationRootManager; + static readonly ColorDrawable TransparentColorDrawable = new(AColor.Transparent); - parentView.AddView(this); + public event EventHandler? AnimationEnded; - _fragmentManager - .BeginTransaction() - .Add(this.Id, _modalFragment) - .Commit(); - } - protected override void OnAttachedToWindow() - { - base.OnAttachedToWindow(); - UpdateMargin(); - UpdateRootView(GetWindowRootView()); - } + public bool IsAnimated { get; internal set; } - protected override void OnDetachedFromWindow() + public ModalFragment(IMauiContext mauiContext, Page modal) { - base.OnDetachedFromWindow(); - UpdateRootView(null); + _modal = modal; + _mauiWindowContext = mauiContext; } - void UpdateRootView(AView? rootView) + sealed class CustomComponentDialog : ComponentDialog { - if (_rootView.IsAlive()) + public CustomComponentDialog(Context context, int themeResId) : base(context, themeResId) { - _rootView.LayoutChange -= OnRootViewLayoutChanged; - _rootView = null; + this.OnBackPressedDispatcher.AddCallback(new CallBack(true, this)); } - if (rootView.IsAlive()) + sealed class CallBack : OnBackPressedCallback { - rootView.LayoutChange += OnRootViewLayoutChanged; - _rootView = rootView; - _currentRootViewHeight = _rootView.MeasuredHeight; - _currentRootViewWidth = _rootView.MeasuredWidth; - } - } + WeakReference _customComponentDialog; - // If the RootView changes sizes that means we also need to change sizes - // This will typically happen when the user is opening the soft keyboard - // which sometimes causes the available window size to change - void OnRootViewLayoutChanged(object? sender, LayoutChangeEventArgs e) - { - if (Modal is null || sender is not AView view) - return; - - var modalStack = Modal?.Navigation?.ModalStack; - if (modalStack is null || - modalStack.Count == 0 || - modalStack[modalStack.Count - 1] != Modal) - { - return; - } - - if ((_currentRootViewHeight != view.MeasuredHeight || _currentRootViewWidth != view.MeasuredWidth) - && this.ViewTreeObserver is not null) - { - // When the keyboard closes Android calls layout but doesn't call remeasure. - // MY guess is that this is due to the modal not being part of the FitSystemWindowView - // The modal is added to the decor view so its dimensions don't get updated. - // So, here we are waiting for the layout pass to finish and then we remeasure the modal - // - // For .NET 8 we'll convert this all over to using a DialogFragment - // which means we can delete most of the awkward code here - _currentRootViewHeight = view.MeasuredHeight; - _currentRootViewWidth = view.MeasuredWidth; - if (!this.IsInLayout) + public CallBack(bool enabled, CustomComponentDialog customComponentDialog) : base(enabled) { - this.InvalidateMeasure(Modal); - return; + _customComponentDialog = new(customComponentDialog); } - _rootViewLayoutListener ??= new GenericGlobalLayoutListener((listener, view) => + public override void HandleOnBackPressed() { - if (view is not null && !this.IsInLayout) + if (!_customComponentDialog.TryGetTarget(out var customComponentDialog) || + customComponentDialog.Context.GetActivity() is not global::Android.App.Activity activity) { - listener.Invalidate(); - _rootViewLayoutListener = null; - this.InvalidateMeasure(Modal); + return; } - }, this); - } - } - - void UpdateMargin() - { - // This sets up the modal container to be offset from the top of window the same - // amount as the view it's covering. This will make it so the - // ModalContainer takes into account the StatusBar or lack thereof - var decorView = Context?.GetActivity()?.Window?.DecorView; - if (decorView is not null && this.LayoutParameters is ViewGroup.MarginLayoutParams mlp) - { - var windowInsets = ViewCompat.GetRootWindowInsets(decorView); - if (windowInsets is not null) - { - var barInsets = windowInsets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + Window? window = activity.GetWindow() as Window; + EventHandler? eventHandler = null; + eventHandler = OnPopCanceled; + if (window is not null) + { + window.PopCanceled += eventHandler; + } - if (mlp.TopMargin != barInsets.Top) - mlp.TopMargin = barInsets.Top; + var preventBackPropagation = false; - if (mlp.LeftMargin != barInsets.Left) - mlp.LeftMargin = barInsets.Left; + try + { + IPlatformApplication.Current?.Services?.InvokeLifecycleEvents(del => + { + preventBackPropagation = del(activity) || preventBackPropagation; + }); + } + finally + { + if (window is not null && eventHandler is not null) + { + window.PopCanceled -= eventHandler; + } + } - if (mlp.RightMargin != barInsets.Right) - mlp.RightMargin = barInsets.Right; + if (!preventBackPropagation) + { + customComponentDialog.OnBackPressedDispatcher.OnBackPressed(); + } - if (mlp.BottomMargin != barInsets.Bottom) - mlp.BottomMargin = barInsets.Bottom; + eventHandler = null; + void OnPopCanceled(object? sender, EventArgs e) + { + preventBackPropagation = true; + if (window is not null && eventHandler is not null) + { + window.PopCanceled -= eventHandler; + } + } } } } - public override bool OnTouchEvent(MotionEvent? e) + public override global::Android.App.Dialog OnCreateDialog(Bundle? savedInstanceState) { - // Don't let touch events pass through to the view being covered up - return true; - } - - protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) - { - if (Context is null || NavigationRootManager?.RootView is null) - { - SetMeasuredDimension(0, 0); - return; - } + var dialog = new CustomComponentDialog(RequireContext(), Theme); - UpdateMargin(); - var rootView = GetWindowRootView(); + if (dialog is null || dialog.Window is null) + throw new InvalidOperationException($"{dialog} or {dialog?.Window} is null, and it's invalid"); - widthMeasureSpec = MeasureSpecMode.Exactly.MakeMeasureSpec(rootView.MeasuredWidth); - heightMeasureSpec = MeasureSpecMode.Exactly.MakeMeasureSpec(rootView.MeasuredHeight); - NavigationRootManager - .RootView - .Measure(widthMeasureSpec, heightMeasureSpec); + dialog.Window.SetBackgroundDrawable(TransparentColorDrawable); - SetMeasuredDimension(rootView.MeasuredWidth, rootView.MeasuredHeight); + return dialog; } - protected override void OnLayout(bool changed, int l, int t, int r, int b) + public override AView OnCreateView(LayoutInflater inflater, ViewGroup? container, Bundle? savedInstanceState) { - if (Context is null || NavigationRootManager?.RootView is null) - return; + var modalContext = _mauiWindowContext + .MakeScoped(layoutInflater: inflater, fragmentManager: ChildFragmentManager, registerNewNavigationRoot: true); - NavigationRootManager - .RootView - .Layout(0, 0, r - l, b - t); + _navigationRootManager = modalContext.GetNavigationRootManager(); + _navigationRootManager.Connect(_modal, modalContext); - _backgroundView.Layout(0, 0, r - l, b - t); + return _navigationRootManager?.RootView ?? + throw new InvalidOperationException("Root view not initialized"); } - void OnModalPagePropertyChanged(object? sender, PropertyChangedEventArgs e) + public override void OnCreate(Bundle? savedInstanceState) { - if (e.PropertyName == Page.BackgroundColorProperty.PropertyName) - UpdateBackgroundColor(); + base.OnCreate(savedInstanceState); + SetStyle(DialogFragment.StyleNormal, Resource.Style.Maui_MainTheme_NoActionBar); } - void UpdateBackgroundColor() + public override void OnViewCreated(AView view, Bundle? savedInstanceState) { - if (Modal is null) - return; - - Color modalBkgndColor = Modal.BackgroundColor; - if (modalBkgndColor is null) - _backgroundView.SetWindowBackground(); - else - _backgroundView.SetBackgroundColor(modalBkgndColor.ToPlatform()); + base.OnViewCreated(view, savedInstanceState); } - public void Destroy() + public override void OnStart() { - if (Modal is null || _windowMauiContext is null || _fragmentManager is null || !_fragmentManager.IsAlive() || _fragmentManager.IsDestroyed) + base.OnStart(); + + var dialog = Dialog; + + if (dialog is null || dialog.Window is null || View is null) return; - if (Modal.Toolbar?.Handler is not null) - Modal.Toolbar.Handler = null; + int width = ViewGroup.LayoutParams.MatchParent; + int height = ViewGroup.LayoutParams.MatchParent; + dialog.Window.SetLayout(width, height); - Modal.Handler = null; + if (IsAnimated) + { + var animation = AnimationUtils.LoadAnimation(_mauiWindowContext.Context, Resource.Animation.nav_modal_default_enter_anim)!; + View.StartAnimation(animation); - UpdateRootView(null); - _rootViewLayoutListener?.Invalidate(); - _rootViewLayoutListener = null; + animation.AnimationEnd += OnAnimationEnded; + } - if (_windowMauiContext.Context is not null) + void OnAnimationEnded(object? sender, AAnimation.AnimationEndEventArgs e) { - _fragmentManager.RunOrWaitForResume(_windowMauiContext.Context, fm => + if (sender is not AAnimation animation) { - fm - .BeginTransaction() - .Remove(_modalFragment) - .SetReorderingAllowed(true) - .Commit(); - }); - } + return; + } - Modal = null; - _windowMauiContext = null; - _fragmentManager = null; - this.RemoveFromParent(); + animation.AnimationEnd -= OnAnimationEnded; + AnimationEnded?.Invoke(this, EventArgs.Empty); + } } - class ModalFragment : Fragment + public override void OnDismiss(IDialogInterface dialog) { - readonly Page _modal; - readonly IMauiContext _mauiWindowContext; - NavigationRootManager? _navigationRootManager; - - public NavigationRootManager? NavigationRootManager - { - get => _navigationRootManager; - private set => _navigationRootManager = value; - } - - public ModalFragment(IMauiContext mauiContext, Page modal) + if (_modal.Toolbar?.Handler is not null) { - _modal = modal; - _mauiWindowContext = mauiContext; + _modal.Toolbar.Handler = null; } - public override AView OnCreateView(LayoutInflater inflater, ViewGroup? container, Bundle? savedInstanceState) - { - var modalContext = _mauiWindowContext - .MakeScoped(layoutInflater: inflater, fragmentManager: ChildFragmentManager, registerNewNavigationRoot: true); - - _navigationRootManager = modalContext.GetNavigationRootManager(); - _navigationRootManager.Connect(_modal, modalContext); - - return _navigationRootManager?.RootView ?? - throw new InvalidOperationException("Root view not initialized"); - } + _modal.Handler = null; + _modal = null!; + _mauiWindowContext = null!; + _navigationRootManager?.Disconnect(); + _navigationRootManager = null; + base.OnDismiss(dialog); } } } diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs index 04067f4eace0..c4e5da8cef5a 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs @@ -397,7 +397,8 @@ async Task SyncModalStackWhenPlatformIsReadyAsync() windowPage.OnLoaded(() => OnCurrentPlatformPageLoaded(windowPage, EventArgs.Empty)); } #endif - else if (!CurrentPlatformPage.IsLoadedOnPlatform() && + + if (!CurrentPlatformPage.IsLoadedOnPlatform() && CurrentPlatformPage.Handler is not null) { var currentPlatformPage = CurrentPlatformPage; diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs index 7475ce81c340..bbb673174f92 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs @@ -1,4 +1,5 @@ -using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Microsoft.Maui.Controls; @@ -58,6 +59,51 @@ await CreateHandlerAndAddToWindow(layout, (handler) => }); } + [Fact(DisplayName = "CollectionView Disconnects Correctly with MultiSelection")] + public async Task CollectionViewHandlerDisconnectsWithMultiSelect() + { + SetupBuilder(); + + ObservableCollection data = new ObservableCollection() + { + "Item 1", + "Item 2", + "Item 3" + }; + + var collectionView = new CollectionView() + { + ItemTemplate = new Controls.DataTemplate(() => + { + return new VerticalStackLayout() + { + new Label() + }; + }), + SelectionMode = SelectionMode.Multiple, + ItemsSource = data + }; + + var layout = new VerticalStackLayout() + { + collectionView + }; + + await CreateHandlerAndAddToWindow(layout, (handler) => + { + collectionView.SelectedItems.Add(data[0]); + collectionView.SelectedItems.Add(data[2]); + + // Validate that no exceptions are thrown + var collectionViewHandler = (IElementHandler)collectionView.Handler; + collectionViewHandler.DisconnectHandler(); + + ((IElementHandler)handler).DisconnectHandler(); + + return Task.CompletedTask; + }); + } + [Fact] public async Task ValidateItemContainerDefaultHeight() { diff --git a/src/Core/src/Platform/Android/Resources/anim/nav_modal_default_enter_anim.xml b/src/Core/src/Platform/Android/Resources/anim/nav_modal_default_enter_anim.xml new file mode 100644 index 000000000000..f860b9bef1a9 --- /dev/null +++ b/src/Core/src/Platform/Android/Resources/anim/nav_modal_default_enter_anim.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/Core/src/Platform/Android/Resources/anim/nav_modal_default_exit_anim.xml b/src/Core/src/Platform/Android/Resources/anim/nav_modal_default_exit_anim.xml new file mode 100644 index 000000000000..db0f0d64bf89 --- /dev/null +++ b/src/Core/src/Platform/Android/Resources/anim/nav_modal_default_exit_anim.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file