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