diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue28968.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue28968.cs new file mode 100644 index 000000000000..091b75ff98e9 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue28968.cs @@ -0,0 +1,69 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 28968, "[iOS] ActivityIndicator IsRunning ignores IsVisible when set to true", PlatformAffected.iOS)] +public class Issue28968 : ContentPage +{ + public Issue28968() + { + var activityIndicator = new ActivityIndicator + { + IsVisible = false, + AutomationId = "MauiActivityIndicator" + }; + + var statusLabel = new Label + { + Text = "Waiting", + AutomationId = "StatusLabel" + }; + + var setRunningButton = new Button + { + Text = "Set IsRunning = true", + AutomationId = "SetRunningButton", + Command = new Command(() => + { + activityIndicator.IsRunning = true; + + // Check the native platform hidden state after a delay to allow + // Draw/LayoutSubviews to execute on iOS + Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(500), () => + { + bool nativeHidden = IsNativeViewHidden(activityIndicator); + statusLabel.Text = nativeHidden ? "HIDDEN" : "VISIBLE"; + }); + }) + }; + + Content = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + Children = + { + activityIndicator, + setRunningButton, + statusLabel + } + }; + } + + static bool IsNativeViewHidden(ActivityIndicator indicator) + { + var handler = indicator.Handler; + if (handler?.PlatformView is null) + return true; + +#if IOS || MACCATALYST + if (handler.PlatformView is UIKit.UIView nativeView) + return nativeView.Hidden; +#elif ANDROID + if (handler.PlatformView is Android.Views.View nativeView) + return nativeView.Visibility != Android.Views.ViewStates.Visible; +#elif WINDOWS + if (handler.PlatformView is Microsoft.UI.Xaml.UIElement nativeView) + return nativeView.Visibility != Microsoft.UI.Xaml.Visibility.Visible; +#endif + return true; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28968.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28968.cs new file mode 100644 index 000000000000..7787454abdea --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28968.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Tests.Issues; + +public class Issue28968 : _IssuesUITest +{ + public Issue28968(TestDevice device) : base(device) + { + } + + public override string Issue => "[iOS] ActivityIndicator IsRunning ignores IsVisible when set to true"; + + [Test] + [Category(UITestCategories.ActivityIndicator)] + public void ActivityIndicatorIsRunningDoesNotOverrideIsVisible() + { + App.WaitForElement("SetRunningButton"); + App.Tap("SetRunningButton"); + + // Wait for the status label to update after the delayed check + var statusText = App.WaitForElement("StatusLabel").GetText(); + + // Retry a few times since the dispatcher delay in the host app + // means the label won't update immediately + int retries = 10; + while (statusText == "Waiting" && retries-- > 0) + { + Thread.Sleep(200); + statusText = App.WaitForElement("StatusLabel").GetText(); + } + + Assert.That(statusText, Is.EqualTo("HIDDEN"), + "ActivityIndicator should remain hidden when IsVisible=false, even after IsRunning is set to true"); + } +} diff --git a/src/Core/src/Handlers/ActivityIndicator/ActivityIndicatorHandler.cs b/src/Core/src/Handlers/ActivityIndicator/ActivityIndicatorHandler.cs index 57033052b059..10e9986a6fee 100644 --- a/src/Core/src/Handlers/ActivityIndicator/ActivityIndicatorHandler.cs +++ b/src/Core/src/Handlers/ActivityIndicator/ActivityIndicatorHandler.cs @@ -22,8 +22,8 @@ public partial class ActivityIndicatorHandler : IActivityIndicatorHandler { [nameof(IActivityIndicator.Color)] = MapColor, [nameof(IActivityIndicator.IsRunning)] = MapIsRunning, -#if __ANDROID__ - // Android does not have the concept of IsRunning, so we are leveraging the Visibility +#if __ANDROID__ || __IOS__ || MACCATALYST + // Android/iOS do not respect both properties independently, so we handle Visibility explicitly [nameof(IActivityIndicator.Visibility)] = MapIsRunning, #endif #if WINDOWS @@ -91,4 +91,4 @@ public ActivityIndicatorHandler(IPropertyMapper? mapper, CommandMapper? commandM public static partial void MapBackground(IActivityIndicatorHandler handler, IActivityIndicator activityIndicator); #endif } -} \ No newline at end of file +} diff --git a/src/Core/src/Platform/iOS/ActivityIndicatorExtensions.cs b/src/Core/src/Platform/iOS/ActivityIndicatorExtensions.cs index 9e8936dad16d..b00902d937a9 100644 --- a/src/Core/src/Platform/iOS/ActivityIndicatorExtensions.cs +++ b/src/Core/src/Platform/iOS/ActivityIndicatorExtensions.cs @@ -6,13 +6,22 @@ public static class ActivityIndicatorExtensions { public static void UpdateIsRunning(this UIActivityIndicatorView activityIndicatorView, IActivityIndicator activityIndicator) { - if (activityIndicator.IsRunning) + // Only show and animate if both IsRunning AND Visibility == Visible + if (activityIndicator.IsRunning && activityIndicator.Visibility == Visibility.Visible) + { + activityIndicatorView.Hidden = false; activityIndicatorView.StartAnimating(); + } else - activityIndicatorView.StopAnimating(); + { + if (activityIndicatorView.IsAnimating) + activityIndicatorView.StopAnimating(); + + activityIndicatorView.Hidden = activityIndicator.Visibility != Visibility.Visible; + } } public static void UpdateColor(this UIActivityIndicatorView activityIndicatorView, IActivityIndicator activityIndicator) => activityIndicatorView.Color = activityIndicator.Color?.ToPlatform(); } -} \ No newline at end of file +} diff --git a/src/Core/src/Platform/iOS/MauiActivityIndicator.cs b/src/Core/src/Platform/iOS/MauiActivityIndicator.cs index 7fc1d8cd1c0c..14aa212e7fa1 100644 --- a/src/Core/src/Platform/iOS/MauiActivityIndicator.cs +++ b/src/Core/src/Platform/iOS/MauiActivityIndicator.cs @@ -10,7 +10,10 @@ public class MauiActivityIndicator : UIActivityIndicatorView, IUIViewLifeCycleEv { readonly WeakReference? _virtualView; - bool IsRunning => _virtualView is not null && _virtualView.TryGetTarget(out var a) ? a.IsRunning : false; + bool IsRunningAndVisible => _virtualView is not null && + _virtualView.TryGetTarget(out var a) && + a.IsRunning && + a.Visibility == Visibility.Visible; public MauiActivityIndicator(CGRect rect, IActivityIndicator? virtualView) : base(rect) { @@ -22,7 +25,7 @@ public override void Draw(CGRect rect) { base.Draw(rect); - if (IsRunning) + if (IsRunningAndVisible) StartAnimating(); else StopAnimating(); @@ -32,7 +35,7 @@ public override void LayoutSubviews() { base.LayoutSubviews(); - if (IsRunning) + if (IsRunningAndVisible) StartAnimating(); else StopAnimating(); @@ -57,4 +60,4 @@ public override void MovedToWindow() _movedToWindow?.Invoke(this, EventArgs.Empty); } } -} \ No newline at end of file +} diff --git a/src/Core/tests/DeviceTests/Handlers/ActivityIndicator/ActivityIndicatorHandlerTests.cs b/src/Core/tests/DeviceTests/Handlers/ActivityIndicator/ActivityIndicatorHandlerTests.cs index 13f73d557398..5579857dc74d 100644 --- a/src/Core/tests/DeviceTests/Handlers/ActivityIndicator/ActivityIndicatorHandlerTests.cs +++ b/src/Core/tests/DeviceTests/Handlers/ActivityIndicator/ActivityIndicatorHandlerTests.cs @@ -7,6 +7,85 @@ namespace Microsoft.Maui.DeviceTests [Category(TestCategory.ActivityIndicator)] public partial class ActivityIndicatorHandlerTests : CoreHandlerTestBase { +#if !WINDOWS // On Windows, the platform control will return IsActive as true even when the control is not visible. + [Theory(DisplayName = "IsRunning Should Respect Visibility At Init")] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task IsRunningShouldRespectVisibilityAtInit(bool isRunning, bool isVisible) + { + var activityIndicator = new ActivityIndicatorStub + { + IsRunning = isRunning, + Visibility = isVisible ? Visibility.Visible : Visibility.Hidden + }; + + bool isAnimating = false; + + await InvokeOnMainThreadAsync(() => + { + var handler = CreateHandler(activityIndicator); + isAnimating = GetNativeIsRunning(handler); + }); + + if (isVisible && isRunning) + Assert.True(isAnimating); + else + Assert.False(isAnimating); + } + + [Fact(DisplayName = "Setting IsRunning After Init Should Respect Hidden Visibility")] + public async Task SettingIsRunningAfterInitShouldRespectHiddenVisibility() + { + var activityIndicator = new ActivityIndicatorStub + { + IsRunning = false, + Visibility = Visibility.Hidden + }; + + bool isAnimating = false; + + await InvokeOnMainThreadAsync(() => + { + var handler = CreateHandler(activityIndicator); + + // Simulate runtime: set IsRunning=true while Visibility=Hidden + activityIndicator.IsRunning = true; + handler.UpdateValue(nameof(IActivityIndicator.IsRunning)); + + isAnimating = GetNativeIsRunning(handler); + }); + + Assert.False(isAnimating, "ActivityIndicator should not animate when Visibility is Hidden"); + } + + [Fact(DisplayName = "Setting IsRunning After Init Should Respect Collapsed Visibility")] + public async Task SettingIsRunningAfterInitShouldRespectCollapsedVisibility() + { + var activityIndicator = new ActivityIndicatorStub + { + IsRunning = false, + Visibility = Visibility.Collapsed + }; + + bool isAnimating = false; + + await InvokeOnMainThreadAsync(() => + { + var handler = CreateHandler(activityIndicator); + + // Simulate runtime: set IsRunning=true while Visibility=Collapsed + activityIndicator.IsRunning = true; + handler.UpdateValue(nameof(IActivityIndicator.IsRunning)); + + isAnimating = GetNativeIsRunning(handler); + }); + + Assert.False(isAnimating, "ActivityIndicator should not animate when Visibility is Collapsed"); + } +#endif + [Theory(DisplayName = "IsRunning Initializes Correctly")] [InlineData(true)] [InlineData(false)]