diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index c295bc49362d..8505094c9eb6 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -14,7 +14,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); + public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(SwipeItem), true, propertyChanged: OnIsVisibleChanged); /// /// Gets or sets the background color of the swipe item. This is a bindable property. @@ -40,6 +40,12 @@ public bool IsVisible Visibility ISwipeItemMenuItem.Visibility => this.IsVisible ? Visibility.Visible : Visibility.Collapsed; + static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) + { + var swipeItem = (SwipeItem)bindable; + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.Visibility)); + } + void Maui.ISwipeItem.OnInvoked() { if (Command != null && Command.CanExecute(CommandParameter)) diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_BecomeVisible.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_BecomeVisible.png new file mode 100644 index 000000000000..43f9ed3adb16 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_BecomeVisible.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_DeleteHidden.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_DeleteHidden.png new file mode 100644 index 000000000000..318dc99a484e Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_DeleteHidden.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_DeleteVisible.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_DeleteVisible.png new file mode 100644 index 000000000000..8959408950e6 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_DeleteVisible.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_InitiallyHidden.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_InitiallyHidden.png new file mode 100644 index 000000000000..92b42ecd2533 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/SwipeOpen_InitiallyHidden.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue34832.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34832.cs new file mode 100644 index 000000000000..1fbc8e8dd37b --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34832.cs @@ -0,0 +1,108 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 34832, "SwipeItem.IsVisible doesn't properly refresh native swipe items when binding value changes dynamically", PlatformAffected.Android | PlatformAffected.iOS)] +public class Issue34832 : ContentPage +{ + readonly Issue34832ViewModel _viewModel = new() { IsDeleteVisible = false }; + SwipeView _swipeView; + + public Issue34832() + { + BindingContext = _viewModel; + + SwipeItem deleteSwipeItem = new SwipeItem + { + Text = "Delete", + BackgroundColor = Colors.Green, + AutomationId = "DeleteSwipeItem" + }; + deleteSwipeItem.SetBinding(SwipeItem.IsVisibleProperty, new Binding(nameof(Issue34832ViewModel.IsDeleteVisible))); + + SwipeItem archiveSwipeItem = new SwipeItem + { + Text = "Archive", + BackgroundColor = Colors.Blue, + AutomationId = "ArchiveSwipeItem" + }; + + _swipeView = new SwipeView + { + AutomationId = "TestSwipeView", + HeightRequest = 60, + LeftItems = new SwipeItems { deleteSwipeItem, archiveSwipeItem }, + Content = new Grid + { + BackgroundColor = Colors.LightGray, + Children = + { + new Label + { + Text = "Swipe left to reveal items", + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + } + } + } + }; + + Button toggleButton = new Button + { + Text = "Toggle Delete Visibility", + AutomationId = "ToggleVisibilityButton" + }; + toggleButton.Clicked += (s, e) => _viewModel.IsDeleteVisible = !_viewModel.IsDeleteVisible; + + Button openSwipeButton = new Button + { + Text = "Open Swipe", + AutomationId = "OpenSwipeButton" + }; + openSwipeButton.Clicked += (s, e) => _swipeView?.Open(OpenSwipeItem.LeftItems); + + Button resetButton = new Button + { + Text = "Reset", + AutomationId = "ResetButton" + }; + resetButton.Clicked += (s, e) => _viewModel.IsDeleteVisible = false; + + Content = new VerticalStackLayout + { + Padding = new Thickness(20), + Spacing = 20, + Children = + { + _swipeView, + toggleButton, + openSwipeButton, + resetButton, + } + }; + } +} + +public class Issue34832ViewModel : INotifyPropertyChanged +{ + bool _isDeleteVisible; + + public bool IsDeleteVisible + { + get => _isDeleteVisible; + set + { + if (_isDeleteVisible != value) + { + _isDeleteVisible = value; + OnPropertyChanged(); + } + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_BecomeVisible.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_BecomeVisible.png new file mode 100644 index 000000000000..8cce5d4ad741 Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_BecomeVisible.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_DeleteHidden.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_DeleteHidden.png new file mode 100644 index 000000000000..782676695ec2 Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_DeleteHidden.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_DeleteVisible.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_DeleteVisible.png new file mode 100644 index 000000000000..713a19f2dfdc Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_DeleteVisible.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_InitiallyHidden.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_InitiallyHidden.png new file mode 100644 index 000000000000..d76d5bd7f7e9 Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/SwipeOpen_InitiallyHidden.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34832.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34832.cs new file mode 100644 index 000000000000..49b0f807aa3e --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34832.cs @@ -0,0 +1,65 @@ +#if TEST_FAILS_ON_WINDOWS // Issue Link - https://github.com/dotnet/maui/issues/35216 +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue34832 : _IssuesUITest +{ + public override string Issue => "SwipeItem.IsVisible doesn't properly refresh native swipe items when binding value changes dynamically"; + + public Issue34832(TestDevice device) : base(device) + { + } + + [Test] + [Order(1)] + [Category(UITestCategories.SwipeView)] + public void SwipeItemInitiallyHiddenBecomesVisibleAfterBindingChanges() + { + Exception? exception = null; + App.WaitForElement("OpenSwipeButton"); + App.Tap("OpenSwipeButton"); + + VerifyScreenshotOrSetException(ref exception, "SwipeOpen_InitiallyHidden"); + + App.Tap("ToggleVisibilityButton"); + + VerifyScreenshotOrSetException(ref exception, "SwipeOpen_BecomeVisible"); + + App.Tap("TestSwipeView"); + App.Tap("ResetButton"); + + if (exception is not null) + { + throw exception; + } + } + + [Test] + [Order(2)] + [Category(UITestCategories.SwipeView)] + public void SwipeItemBecomesHiddenAfterBindingChanges() + { + Exception? exception = null; + App.WaitForElement("ToggleVisibilityButton"); + App.Tap("ToggleVisibilityButton"); + App.Tap("OpenSwipeButton"); + + VerifyScreenshotOrSetException(ref exception, "SwipeOpen_DeleteVisible"); + + App.Tap("ToggleVisibilityButton"); + + VerifyScreenshotOrSetException(ref exception, "SwipeOpen_DeleteHidden"); + + App.Tap("TestSwipeView"); + App.Tap("ResetButton"); + + if (exception is not null) + { + throw exception; + } + } +} +#endif diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_BecomeVisible.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_BecomeVisible.png new file mode 100644 index 000000000000..3bcaf3537dc5 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_BecomeVisible.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_DeleteHidden.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_DeleteHidden.png new file mode 100644 index 000000000000..ea99fabe91ac Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_DeleteHidden.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_DeleteVisible.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_DeleteVisible.png new file mode 100644 index 000000000000..8d33df1339f1 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_DeleteVisible.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_InitiallyHidden.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_InitiallyHidden.png new file mode 100644 index 000000000000..f0b0413da129 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/SwipeOpen_InitiallyHidden.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_BecomeVisible.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_BecomeVisible.png new file mode 100644 index 000000000000..576a3cd96c1f Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_BecomeVisible.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_DeleteHidden.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_DeleteHidden.png new file mode 100644 index 000000000000..73daae20460e Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_DeleteHidden.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_DeleteVisible.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_DeleteVisible.png new file mode 100644 index 000000000000..ab9a7151246f Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_DeleteVisible.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_InitiallyHidden.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_InitiallyHidden.png new file mode 100644 index 000000000000..a2552743e963 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/SwipeOpen_InitiallyHidden.png differ diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index eabc4383037b..c7192b51d799 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -71,10 +71,12 @@ public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMe public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { + // Set visibility before UpdateIsVisibleSwipeItem so LayoutSwipeItems + // reads the correct visibility when recalculating item positions. + handler.PlatformView.Visibility = view.Visibility.ToPlatformVisibility(); + var swipeView = handler.PlatformView.Parent.GetParentOfType(); swipeView?.UpdateIsVisibleSwipeItem(view); - - handler.PlatformView.Visibility = view.Visibility.ToPlatformVisibility(); } protected override AView CreatePlatformElement() diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index 647662e5d91b..0eb9faafd22e 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -88,9 +88,11 @@ public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMe { var swipeView = handler.PlatformView.GetParentOfType(); - swipeView?.UpdateIsVisibleSwipeItem(view); - + // Update the native view's Hidden state BEFORE calling UpdateIsVisibleSwipeItem, + // so LayoutSwipeItems can use the correct Hidden state when repositioning items. handler.PlatformView.UpdateVisibility(view.Visibility); + + swipeView?.UpdateIsVisibleSwipeItem(view); } partial class SwipeItemMenuItemImageSourcePartSetter diff --git a/src/Core/src/Platform/Android/MauiSwipeView.cs b/src/Core/src/Platform/Android/MauiSwipeView.cs index 56137f3cef30..f82f73f99631 100644 --- a/src/Core/src/Platform/Android/MauiSwipeView.cs +++ b/src/Core/src/Platform/Android/MauiSwipeView.cs @@ -610,6 +610,11 @@ void LayoutSwipeItems(List childs) foreach (var child in childs) { + if (i >= items.Count) + { + break; + } + if (child.Visibility == ViewStates.Visible) { var item = items[i]; @@ -655,9 +660,10 @@ void LayoutSwipeItems(List childs) child.Layout(l, t, r, b); - i++; previousWidth += swipeItemWidth; } + + i++; } } diff --git a/src/Core/src/Platform/iOS/MauiSwipeView.cs b/src/Core/src/Platform/iOS/MauiSwipeView.cs index b7f316953625..01e87f888ab5 100644 --- a/src/Core/src/Platform/iOS/MauiSwipeView.cs +++ b/src/Core/src/Platform/iOS/MauiSwipeView.cs @@ -299,7 +299,7 @@ void UpdateSwipeItems() double swipeItemsWidth; if (_swipeDirection == SwipeDirection.Left || _swipeDirection == SwipeDirection.Right) - swipeItemsWidth = items.Count * SwipeViewExtensions.SwipeItemWidth; + swipeItemsWidth = items.Count(GetIsVisible) * SwipeViewExtensions.SwipeItemWidth; else swipeItemsWidth = _contentView.Frame.Width; @@ -312,6 +312,7 @@ void UpdateSwipeItems() foreach (var item in items) { UIView swipeItem = item.ToPlatform(Element.Handler.MauiContext); + swipeItem.Hidden = !GetIsVisible(item); _actionView.AddSubview(swipeItem); _swipeItems.Add(item, swipeItem); } @@ -342,6 +343,11 @@ void LayoutSwipeItems(List childs) foreach (var child in childs) { + if (i >= items.Count) + { + break; + } + if (!child.Hidden) { var item = items[i]; @@ -371,10 +377,10 @@ void LayoutSwipeItems(List childs) UpdateSwipeItemInsets(button); } - i++; previousWidth += swipeItemWidth; } + i++; _swipeItemsRect.Add(child.Frame); } } @@ -626,12 +632,12 @@ void SetFrame() { case SwipeDirection.Left: _contentView.Frame = new CGRect(_originalBounds.X + offset, _originalBounds.Y, _originalBounds.Width, _originalBounds.Height); - actionSize = Element.RightItems.Count * SwipeViewExtensions.SwipeItemWidth; + actionSize = Element.RightItems.Count(GetIsVisible) * SwipeViewExtensions.SwipeItemWidth; _actionView.Frame = new CGRect(actionSize + offset, actionBounds.Y, actionBounds.Width, actionBounds.Height); break; case SwipeDirection.Right: _contentView.Frame = new CGRect(_originalBounds.X + offset, _originalBounds.Y, _originalBounds.Width, _originalBounds.Height); - actionSize = Element.LeftItems.Count * SwipeViewExtensions.SwipeItemWidth; + actionSize = Element.LeftItems.Count(GetIsVisible) * SwipeViewExtensions.SwipeItemWidth; _actionView.Frame = new CGRect(-actionSize + offset, actionBounds.Y, actionBounds.Width, actionBounds.Height); break; case SwipeDirection.Up: @@ -841,12 +847,12 @@ void SwipeToThreshold(bool animated = true) { case SwipeDirection.Left: _contentView.Frame = new CGRect(_originalBounds.X - swipeThreshold, _originalBounds.Y, _originalBounds.Width, _originalBounds.Height); - actionSize = Element.RightItems.Count * SwipeViewExtensions.SwipeItemWidth; + actionSize = Element.RightItems.Count(GetIsVisible) * SwipeViewExtensions.SwipeItemWidth; _actionView.Frame = new CGRect(actionSize - swipeThreshold, actionBounds.Y, actionBounds.Width, actionBounds.Height); break; case SwipeDirection.Right: _contentView.Frame = new CGRect(_originalBounds.X + swipeThreshold, _originalBounds.Y, _originalBounds.Width, _originalBounds.Height); - actionSize = Element.LeftItems.Count * SwipeViewExtensions.SwipeItemWidth; + actionSize = Element.LeftItems.Count(GetIsVisible) * SwipeViewExtensions.SwipeItemWidth; _actionView.Frame = new CGRect(-actionSize + swipeThreshold, actionBounds.Y, actionBounds.Width, actionBounds.Height); break; case SwipeDirection.Up: