Skip to content
8 changes: 7 additions & 1 deletion src/Controls/src/Core/SwipeView/SwipeItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>Bindable property for <see cref="IsVisible"/>.</summary>
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);

/// <summary>
/// Gets or sets the background color of the swipe item. This is a bindable property.
Expand All @@ -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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Defensive castvar swipeItem = (SwipeItem)bindable; will throw if IsVisibleProperty is ever attached to a non-SwipeItem target (e.g. via styles applying to a derived type that re-uses the BP). The conventional MAUI pattern is if (bindable is SwipeItem swipeItem) swipeItem.Handler?.UpdateValue(...). Low risk because BindableProperty.Create ties the property to typeof(SwipeItem), but the safer pattern is preferred in propertyChanged callbacks throughout the codebase.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Defensive castvar swipeItem = (SwipeItem)bindable; will throw if IsVisibleProperty is ever attached to a non-SwipeItem target (e.g. via styles applying to a derived type that re-uses the BP). The conventional MAUI pattern is if (bindable is SwipeItem swipeItem) swipeItem.Handler?.UpdateValue(...). Low risk because BindableProperty.Create ties the property to typeof(SwipeItem), but the safer pattern is preferred in propertyChanged callbacks throughout the codebase.

}

void Maui.ISwipeItem.OnInvoked()
{
if (Command != null && Command.CanExecute(CommandParameter))
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
108 changes: 108 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue34832.cs
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#if TEST_FAILS_ON_WINDOWS // Issue Link - https://github.com/dotnet/maui/issues/35216

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Missing Windows coverage justification — entire file is wrapped in #if TEST_FAILS_ON_WINDOWS referencing #35216. The fix in this PR only touches iOS/Android handlers (no Windows handler change), so Windows behaviour is untested. If SwipeItem.IsVisible was already broken on Windows (per #35216), please cross-link that issue from the PR description and confirm whether this PR is expected to fix Windows or leave it as-is. The current title says [iOS/Android] so leaving Windows out is intentional, but a code comment near the #if referencing the scope decision would help future reviewers.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Missing Windows coverage justification — entire file is wrapped in #if TEST_FAILS_ON_WINDOWS referencing #35216. The fix in this PR only touches iOS/Android handlers (no Windows handler change), so Windows behaviour is untested. If SwipeItem.IsVisible was already broken on Windows (per #35216), please cross-link that issue from the PR description and confirm whether this PR is expected to fix Windows or leave it as-is. The current title says [iOS/Android] so leaving Windows out is intentional, but a code comment near the #if referencing the scope decision would help future reviewers.

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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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<MauiSwipeView>();
swipeView?.UpdateIsVisibleSwipeItem(view);

handler.PlatformView.Visibility = view.Visibility.ToPlatformVisibility();
}

protected override AView CreatePlatformElement()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMe
{
var swipeView = handler.PlatformView.GetParentOfType<MauiSwipeView>();

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
Expand Down
8 changes: 7 additions & 1 deletion src/Core/src/Platform/Android/MauiSwipeView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,11 @@ void LayoutSwipeItems(List<AView> childs)

foreach (var child in childs)
{
if (i >= items.Count)
{
break;
}

if (child.Visibility == ViewStates.Visible)
{
var item = items[i];
Expand Down Expand Up @@ -655,9 +660,10 @@ void LayoutSwipeItems(List<AView> childs)

child.Layout(l, t, r, b);

i++;
previousWidth += swipeItemWidth;
}

i++;
}
}

Expand Down
18 changes: 12 additions & 6 deletions src/Core/src/Platform/iOS/MauiSwipeView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
SyedAbdulAzeemSF4852 marked this conversation as resolved.

Expand All @@ -312,6 +312,7 @@ void UpdateSwipeItems()
foreach (var item in items)
{
UIView swipeItem = item.ToPlatform(Element.Handler.MauiContext);
swipeItem.Hidden = !GetIsVisible(item);
Comment thread
SyedAbdulAzeemSF4852 marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Inconsistency with MapVisibilityswipeItem.Hidden = !GetIsVisible(item) only flips the Hidden flag, but SwipeItem.IsVisible == false maps to Visibility.Collapsed (see ISwipeItemMenuItem.Visibility getter). MapVisibility later calls platformView.UpdateVisibility(view.Visibility) which, for Collapsed, additionally calls platformView.Collapse() to add a zero-size auto-layout constraint. Setting Hidden directly here skips that path so the constraint state is asymmetric between the initial setup and subsequent toggles. In practice the manual Frame= assignments in LayoutSwipeItems override layout, so this is benign — but consider replacing line 315 with swipeItem.UpdateVisibility((item as IView)?.Visibility ?? ((item as ISwipeItemMenuItem)?.Visibility ?? Visibility.Visible)) (or extracting a local GetVisibility(item) helper alongside GetIsVisible) so the two code paths agree.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Inconsistency with MapVisibilityswipeItem.Hidden = !GetIsVisible(item) only flips the Hidden flag, but SwipeItem.IsVisible == false maps to Visibility.Collapsed. MapVisibility later calls platformView.UpdateVisibility(view.Visibility) which, for Collapsed, additionally calls platformView.Collapse() to add a zero-size auto-layout constraint. Setting Hidden directly here skips that path so the constraint state is asymmetric between the initial setup and subsequent toggles. Consider replacing line 315 with swipeItem.UpdateVisibility(((ISwipeItemMenuItem)item).Visibility) (or extracting a GetVisibility(item) helper alongside GetIsVisible) so the two code paths agree.

_actionView.AddSubview(swipeItem);
_swipeItems.Add(item, swipeItem);
}
Expand Down Expand Up @@ -342,6 +343,11 @@ void LayoutSwipeItems(List<UIView> childs)

foreach (var child in childs)
{
if (i >= items.Count)
{
break;
}

if (!child.Hidden)
{
var item = items[i];
Expand Down Expand Up @@ -371,10 +377,10 @@ void LayoutSwipeItems(List<UIView> childs)
UpdateSwipeItemInsets(button);
}

i++;
previousWidth += swipeItemWidth;
}

i++;
_swipeItemsRect.Add(child.Frame);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Stale frame entries in _swipeItemsRect — moving i++ outside the if (!child.Hidden) block means _swipeItemsRect[i] now corresponds 1:1 with swipeItems[i] (good — fixes the indexing bug), but for hidden children we skip the child.Frame = assignment yet still Add(child.Frame). The added rect is whatever the child was last laid out at (could be (0,0,0,0) if it was hidden from the start, or a stale visible frame from a prior pass). Today this is masked because ProcessTouchSwipeItems (line 1042) guards with GetIsVisible(swipeItem) before hit-testing — but a future caller iterating _swipeItemsRect without that guard would get phantom hits. Recommend _swipeItemsRect.Add(child.Hidden ? CGRect.Empty : child.Frame); to make the invariant explicit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Stale frame entries in _swipeItemsRect — moving i++ outside the if (!child.Hidden) block now correctly pairs _swipeItemsRect[i] with swipeItems[i], but for hidden children we skip the child.Frame = assignment yet still Add(child.Frame). The added rect is whatever the child was last laid out at (could be (0,0,0,0) if it was hidden from the start, or a stale visible frame from a prior pass). Today this is masked because ProcessTouchSwipeItems (line 1042) guards with GetIsVisible(swipeItem) before hit-testing — but a future caller iterating _swipeItemsRect without that guard would get phantom hits. Recommend _swipeItemsRect.Add(child.Hidden ? CGRect.Empty : child.Frame); to make the invariant explicit.

}
}
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Performance — LINQ on drag hot pathitems.Count(GetIsVisible) allocates an enumerator and walks the collection on every pan-gesture tick (this method is called from ProcessSwipingInteractions, hit hundreds of times during a single drag). Lines 635, 640, 850, 855 each call it; line 302 calls it once at setup. Per the Performance-Critical Path dimension, no LINQ on scroll/drag paths. Cache the visible count once at the top of ProcessSwipingInteractions / SwipeToThreshold (e.g. count once via a for loop and reuse), or invalidate-and-cache _visibleItemCount whenever UpdateIsVisibleSwipeItem runs. Item count is small in practice but the pattern is worth fixing now that it's a hot-path predicate.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Performance — LINQ on drag hot pathitems.Count(GetIsVisible) allocates an enumerator and walks the collection on every pan-gesture tick (this method is called from ProcessSwipingInteractions, hit hundreds of times during a single drag). Lines 635, 640, 850, 855 each call it; line 302 calls it once at setup. Cache the visible count once at the top of ProcessSwipingInteractions / SwipeToThreshold (e.g. count once via a for loop and reuse), or invalidate-and-cache _visibleItemCount whenever UpdateIsVisibleSwipeItem runs. Item count is small in practice but the pattern is worth fixing now that it's a hot-path predicate.

_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:
Expand Down Expand 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:
Expand Down
Loading