diff --git a/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs index 41de494f9209..f0aade51d71a 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Microsoft.Maui.Controls.Internals; @@ -15,16 +16,21 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility { public class PhoneFlyoutPageRenderer : UIViewController, IPlatformViewHandler { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The click-off view is owned by the renderer and disposed in Dispose.")] UIView _clickOffView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The detail child controller is owned by the renderer while its containers are packed and emptied in Dispose.")] UIViewController _detailController; WeakReference _element; bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout child controller is owned by the renderer while its containers are packed and emptied in Dispose.")] UIViewController _flyoutController; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The pan gesture recognizer is removed from the view and disposed in Dispose.")] UIPanGestureRecognizer _panGesture; bool _presented; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The tap gesture recognizer is removed from the click-off view and disposed in Dispose.")] UIGestureRecognizer _tapGesture; bool _applyShadow; @@ -34,6 +40,7 @@ public class PhoneFlyoutPageRenderer : UIViewController, IPlatformViewHandler Page Page => Element as Page; IFlyoutPageController FlyoutPageController => FlyoutPage; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext _mauiContext; IMauiContext MauiContext => _mauiContext; @@ -58,6 +65,7 @@ public bool FlyoutOverlapsDetailsInPopoverMode bool IsRTL => (Element as IVisualElementController)?.EffectiveFlowDirection.IsRightToLeft() == true; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper); public static CommandMapper CommandMapper = new CommandMapper(ViewHandler.ViewCommandMapper); ViewHandlerDelegator _viewHandlerWrapper; @@ -77,6 +85,7 @@ bool Presented public VisualElement Element => _viewHandlerWrapper.Element ?? _element?.GetTargetOrDefault(); + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler ElementChanged; public Size GetDesiredSize(double widthConstraint, double heightConstraint) @@ -89,6 +98,7 @@ public UIView NativeView get { return View; } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Element SizeChanged subscription is removed in Dispose.")] public void SetElement(VisualElement element) { @@ -163,6 +173,7 @@ void SetInitialPresented() UpdateLeftBarButton(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The FlyoutPage PropertyChanged subscription is removed in Dispose.")] public override void ViewDidLoad() { base.ViewDidLoad(); @@ -242,6 +253,8 @@ protected override void Dispose(bool disposing) { Element.SizeChanged -= PageOnSizeChanged; Element.PropertyChanged -= HandlePropertyChanged; + if (Element is FlyoutPage flyoutPage) + flyoutPage.Flyout.PropertyChanged -= HandleFlyoutPropertyChanged; if (_tapGesture != null) { @@ -603,6 +616,7 @@ void UpdateBackground() }); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Flyout page PropertyChanged subscription is removed when containers update and in Dispose.")] void UpdateFlyoutPageContainers() { ((FlyoutPage)Element).Flyout.PropertyChanged -= HandleFlyoutPropertyChanged; diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs index bac7e104ccc9..49f58e7d26c2 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Compatibility; using ObjCRuntime; using UIKit; @@ -13,6 +14,7 @@ public class CellTableViewCell : UITableViewCell, INativeElementView WeakReference _cell; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Callback is cleared in Dispose(bool) before the cell is released.")] public Action PropertyChanged; bool _disposed; @@ -46,6 +48,7 @@ public Cell Cell } else { + PropertyChanged = null; _cell = null; } } @@ -119,6 +122,8 @@ protected override void Dispose(bool disposing) if (disposing) { + PropertyChanged = null; + #pragma warning disable CS0618 // Type or member is obsolete if (Cell is Cell cell) { diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs index b93b530fe618..a97601f0419e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; @@ -27,8 +28,11 @@ internal sealed class ContextActionsCell : UITableViewCell, INativeElementView #pragma warning disable CS0618 // Type or member is obsolete Cell _cell; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Button is owned for the context-actions cell lifetime and disposed in Dispose(bool).")] UIButton _moreButton; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Scroll view is owned for the context-actions cell lifetime and disposed in Dispose(bool).")] UIScrollView _scroller; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view reference is cleared in Dispose(bool) when the cell is released.")] UITableView _tableView; bool _isDiposed; @@ -55,6 +59,7 @@ public ContextActionsCell(string templateId) : base(UITableViewCellStyle.Default { } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Content cell is replaced during updates and retained after disposal because UIKit can issue late layout and reuse callbacks.")] public UITableViewCell ContentCell { get; private set; } public bool IsOpen => ScrollDelegate.IsOpen; @@ -131,6 +136,7 @@ public override void RemoveFromSuperview() } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Cell and context-action subscriptions are removed in Update and Dispose(bool).")] public void Update(UITableView tableView, Cell cell, UITableViewCell nativeCell) #pragma warning restore CS0618 // Type or member is obsolete { @@ -297,6 +303,7 @@ protected override void Dispose(bool disposing) if (_cell != null) { + _cell.PropertyChanged -= OnCellPropertyChanged; if (_cell.HasContextActions) ((INotifyCollectionChanged)_cell.ContextActions).CollectionChanged -= OnContextItemsChanged; _cell = null; @@ -520,6 +527,7 @@ void ReloadRowCore() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Button handlers are attached to buttons owned by this cell and the buttons are disposed in Update and Dispose(bool).")] UIView SetupButtons(nfloat width, nfloat height) { MenuItem destructive = null; @@ -641,6 +649,7 @@ internal static void SetupSelection(UITableView table) private sealed class SelectGestureRecognizer : UITapGestureRecognizer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Gesture recognizer stores only the last touched index path while attached to the table view.")] NSIndexPath _lastPath; public SelectGestureRecognizer() : base(Tapped) diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs index 1b03f18de43f..74c3456c473b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using ObjCRuntime; using UIKit; using NSAction = System.Action; @@ -42,14 +43,19 @@ public override void LayoutSubviews() internal sealed class ContextScrollViewDelegate : UIScrollViewDelegate { readonly nfloat _finalButtonSize; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Background view reference is cleared in Dispose(bool) or after RestoreHighlight reinserts it.")] UIView _backgroundView; List _buttons; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Tap recognizer is removed and disposed by ClearCloserRecognizer or Dispose(bool).")] UITapGestureRecognizer _closer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Container view reference is cleared in Dispose(bool) when the delegate is released.")] UIView _container; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Global close recognizer is removed from the table and disposed by ClearCloserRecognizer or Dispose(bool).")] Controls.Compatibility.Platform.iOS.GlobalCloseContextGestureRecognizer _globalCloser; bool _isDisposed; static WeakReference s_scrollViewBeingScrolled; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view reference is cleared in Dispose(bool) and when the global close recognizer is removed.")] UITableView _table; public ContextScrollViewDelegate(UIView container, List buttons, bool isOpen) @@ -70,6 +76,7 @@ public ContextScrollViewDelegate(UIView container, List buttons, bool public nfloat ButtonsWidth { get; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Callback is cleared in Dispose(bool) when the delegate is released.")] public Action ClosedCallback { get; set; } public bool IsOpen { get; private set; } @@ -216,6 +223,20 @@ protected override void Dispose(bool disposing) { ClosedCallback = null; + if (_closer != null) + { + _closer.View?.RemoveGestureRecognizer(_closer); + _closer.Dispose(); + _closer = null; + } + + if (_globalCloser != null) + { + _table?.RemoveGestureRecognizer(_globalCloser); + _globalCloser.Dispose(); + _globalCloser = null; + } + s_scrollViewBeingScrolled = null; _table = null; _backgroundView = null; @@ -232,11 +253,14 @@ void ClearCloserRecognizer(ContextActionsCell cell) if (_globalCloser == null || _globalCloser.State == UIGestureRecognizerState.Cancelled) return; - cell?.ContentCell?.RemoveGestureRecognizer(_closer); - _closer.Dispose(); - _closer = null; + if (_closer is not null) + { + cell?.ContentCell?.RemoveGestureRecognizer(_closer); + _closer.Dispose(); + _closer = null; + } - _table.RemoveGestureRecognizer(_globalCloser); + _table?.RemoveGestureRecognizer(_globalCloser); _table = null; _globalCloser.Dispose(); _globalCloser = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs index 3e0f4dd8eef3..d6a02b035752 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.ComponentModel; using System.Runtime.Versioning; using Foundation; @@ -196,8 +197,10 @@ public EntryCellTableViewCell(string cellName) : base(UITableViewCellStyle.Value ContentView.AddSubview(TextField); } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Text field is owned as a UIKit subview for the entry-cell lifetime.")] public UITextField TextField { get; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) and renderer subscriptions are removed when cells are reused.")] public event EventHandler KeyboardDoneButtonPressed; public override void LayoutSubviews() @@ -219,8 +222,22 @@ public override void LayoutSubviews() TextField.VerticalAlignment = UIControlContentVerticalAlignment.Center; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) and renderer subscriptions are removed when cells are reused.")] public event EventHandler TextFieldTextChanged; + protected override void Dispose(bool disposing) + { + if (disposing) + { + TextField.EditingChanged -= TextFieldOnEditingChanged; + TextField.ShouldReturn = null; + KeyboardDoneButtonPressed = null; + TextFieldTextChanged = null; + } + + base.Dispose(disposing); + } + static bool OnShouldReturn(UITextField view) { var realCell = GetRealCell(view); diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs index 268fdb757c75..4103beb4646a 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; @@ -40,14 +41,19 @@ public class ListViewRenderer : ViewRenderer const int DefaultRowHeight = 44; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Background view is owned for the renderer lifetime and disposed in CleanUpResources.")] UIView _backgroundUIView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Data source is owned for the renderer lifetime and disposed in CleanUpResources.")] ListViewDataSource _dataSource; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Header handler is owned for the renderer lifetime and cleared in CleanUpResources.")] IPlatformViewHandler _headerRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Footer handler is owned for the renderer lifetime and cleared in CleanUpResources.")] IPlatformViewHandler _footerRenderer; RectangleF _previousFrame; ScrollToRequestedEventArgs _requestedScroll; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view controller is owned for the renderer lifetime and disposed in CleanUpResources.")] FormsUITableViewController _tableViewController; #pragma warning disable CS0618 // Type or member is obsolete ListView ListView => Element; @@ -201,6 +207,7 @@ void CleanUpResources() Control?.TableFooterView?.Dispose(); } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListView and templated-items subscriptions are removed for the old element and in CleanUpResources.")] protected override void OnElementChanged(ElementChangedEventArgs e) #pragma warning restore CS0618 // Type or member is obsolete { @@ -485,6 +492,7 @@ void OnScrollToRequested(object sender, ScrollToRequestedEventArgs e) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Footer MeasureInvalidated subscription is removed when the footer changes and in CleanUpResources.")] void UpdateFooter() { var footer = ListView.FooterElement; @@ -524,6 +532,7 @@ void UpdateFooter() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Header MeasureInvalidated subscription is removed when the header changes and in CleanUpResources.")] void UpdateHeader() { var header = ListView.HeaderElement; @@ -838,6 +847,7 @@ void UpdateHorizontalScrollBarVisibility() internal sealed class UnevenListViewDataSource : ListViewDataSource { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Prototype handler is disconnected and cleared in ClearPrototype from Dispose(bool).")] IPlatformViewHandler _prototype; bool _disposed; #pragma warning disable CS0618 // Type or member is obsolete @@ -1068,6 +1078,7 @@ public ListViewDataSource(ListViewDataSource source) } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListView ItemSelected subscription is removed in ListViewDataSource.Dispose(bool).")] public ListViewDataSource(ListView list, FormsUITableViewController uiTableViewController) #pragma warning restore CS0618 // Type or member is obsolete { @@ -1541,6 +1552,7 @@ void UpdateShortNameListener() WatchShortNameCollection(list.IsGroupingEnabled); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ShortNames CollectionChanged subscription is removed by WatchShortNameCollection(false) in Dispose(bool).")] void WatchShortNameCollection(bool watch) { if (!_list.TryGetTarget(out var list)) @@ -1630,6 +1642,7 @@ public HeaderWrapperView(string reuseIdentifier) : base((NSString)reuseIdentifie { } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Header/footer wrapper owns the table view cell as a UIKit subview for the wrapper lifetime.")] UITableViewCell _tableViewCell; #pragma warning disable CS0618 // Type or member is obsolete @@ -1658,6 +1671,7 @@ internal sealed class FormsUITableViewController : UITableViewController #pragma warning disable CS0618 // Type or member is obsolete readonly WeakReference _list; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Refresh control is owned by the table controller and disposed in Dispose(bool).")] UIRefreshControl _refresh; bool _refreshAdded; @@ -1667,6 +1681,7 @@ internal sealed class FormsUITableViewController : UITableViewController bool _isStartRefreshingPending; #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Refresh control ValueChanged subscription is removed in FormsUITableViewController.Dispose(bool).")] public FormsUITableViewController(ListView element, bool usingLargeTitles) : base(element.OnThisPlatform().GetGroupHeaderStyle() == GroupHeaderStyle.Plain ? UITableViewStyle.Plain diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs index ee32039a79e5..e4ffa73c0704 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.ComponentModel; using Microsoft.Maui.Controls.Compatibility; using Microsoft.Maui.Controls.Internals; @@ -183,6 +184,7 @@ IPlatformViewHandler GetNewRenderer() } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ViewCell PropertyChanged and MeasureInvalidated subscriptions are removed when the cell changes and in Dispose(bool).")] void UpdateCell(ViewCell cell) #pragma warning restore CS0618 // Type or member is obsolete { diff --git a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs index a868d1c4c880..1ade9407d60d 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using CoreGraphics; @@ -33,7 +34,9 @@ public class NavigationRenderer : UINavigationController, INavigationViewHandler bool _appeared; bool _ignorePopCall; FlyoutPage _parentFlyoutPage; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The controllers-to-remove array is transient navigation state cleared after UIKit removal completes.")] UIViewController[] _removeControllers; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The secondary toolbar is owned by the renderer and disposed in Dispose.")] UIToolbar _secondaryToolbar; bool _hasNavigationBar; UIImage _defaultNavBarShadowImage; @@ -41,8 +44,10 @@ public class NavigationRenderer : UINavigationController, INavigationViewHandler Brush _currentBarBackgroundBrush; Color _currentBarBackgroundColor; bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext _mauiContext; IMauiContext MauiContext => _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { [PlatformConfiguration.iOSSpecific.NavigationPage.PrefersLargeTitlesProperty.PropertyName] = NavigationPage.MapPrefersLargeTitles, @@ -77,6 +82,7 @@ Page Current public VisualElement Element { get => _viewHandlerWrapper.Element ?? _element?.GetTargetOrDefault(); } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler ElementChanged; #pragma warning disable CS0618 // Type or member is obsolete @@ -203,6 +209,7 @@ public override void ViewWillLayoutSubviews() (Element as IView).Arrange(View.Bounds.ToRectangle()); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "NavigationPage request and PropertyChanged subscriptions added in ViewDidLoad are removed in Dispose.")] public override void ViewDidLoad() { base.ViewDidLoad(); @@ -251,6 +258,7 @@ public override void ViewDidLoad() class GestureDelegate : UIGestureRecognizerDelegate { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The should-pop callback is owned by the gesture delegate while InteractivePopGestureRecognizer.Delegate is set and released in NavigationRenderer.Dispose.")] readonly Func _shouldPop; public GestureDelegate(Func shouldPop) @@ -810,6 +818,7 @@ void OnBarBackgroundChanged(object sender, EventArgs e) RefreshBarBackground(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Gradient brush InvalidateGradientBrushRequested is removed before replacing the brush and in Dispose.")] void UpdateBarBackground() { if (_currentBarBackgroundBrush is GradientBrush oldGradientBrush) @@ -1400,6 +1409,7 @@ public ParentingViewController(NavigationRenderer navigation) _navigation = new WeakReference(navigation); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Child PropertyChanged subscriptions are removed when Child changes and in Disconnect.")] public Page Child { get => _child?.GetTargetOrDefault(); @@ -1430,6 +1440,7 @@ public Page Child } } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Appearing is a legacy ParentingViewController event consumed by the navigation renderer lifecycle.")] public event EventHandler Appearing; [System.Runtime.Versioning.UnsupportedOSPlatform("ios8.0")] @@ -1441,6 +1452,7 @@ public override void DidRotate(UIInterfaceOrientation fromInterfaceOrientation) View.SetNeedsLayout(); } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Disappearing is a legacy ParentingViewController event consumed by the navigation renderer lifecycle.")] public event EventHandler Disappearing; public override void ViewDidAppear(bool animated) @@ -1516,6 +1528,7 @@ public override void ViewDidLayoutSubviews() UpdateFrames(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Toolbar tracker CollectionChanged is removed in Disconnect.")] public override void ViewDidLoad() { base.ViewDidLoad(); @@ -2024,6 +2037,7 @@ void CleanToolbarItems() _trackedToolbarItems.Clear(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ToolbarItem PropertyChanged subscriptions are removed by CleanToolbarItems before replacement and in Disconnect.")] void UpdateToolbarItems() { // Unsubscribe from previous toolbar item property changes @@ -2278,6 +2292,7 @@ protected internal MauiControlsNavigationBar(NativeHandle handle) : base(handle) } public RectangleF BackButtonFrameSize { get; private set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The navigation bar label is a cached native subview reference updated or cleared during LayoutSubviews.")] public UILabel NavBarLabel { get; private set; } public override void LayoutSubviews() @@ -2315,8 +2330,11 @@ public override void LayoutSubviews() class Container : UIView { View _view; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The navigation bar reference is used only for title layout while the container is attached and released with the container.")] MauiControlsNavigationBar _bar; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The title view child handler is disconnected and cleared in Dispose.")] IPlatformViewHandler _child; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The title icon view is owned by the container and disposed in Dispose.")] UIImageView _icon; bool _disposed; nfloat? _navigationBarHeight; @@ -2343,6 +2361,7 @@ internal Container(View view, UINavigationBar bar, CGRect navigationBarFrame) : InitializeContainer(view, bar, navigationBarFrame.Height); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The title view ParentSet subscription is removed when it fires and in Dispose.")] void InitializeContainer(View view, UINavigationBar bar, nfloat? navigationBarHeight) { // iOS 26+ and MacCatalyst 26+ require autoresizing masks instead of constraints diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 906f7c1df6d0..75050ac04b0b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; @@ -12,16 +13,30 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility public class ShellFlyoutContentRenderer : UIViewController, IShellFlyoutContentRenderer { CGSize _previousBounds; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The blur view is owned by this renderer and released in Dispose.")] UIVisualEffectView _blurView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The background image view is owned by this renderer and released in Dispose.")] UIImageView _bgImage; - readonly IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The background image result owns the current native image and is disposed when replaced or when this renderer is disposed.")] + IDisposable _bgImageResult; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the renderer lifetime and released after Shell.PropertyChanged is unsubscribed in Dispose.")] + IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The header container is owned by this renderer and disposed when replaced or in Dispose.")] UIContainerView _headerView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The footer container is owned by this renderer and disposed when replaced or in Dispose.")] UIContainerView _footerView; View _footer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The table view controller is a child controller owned by this renderer and released in Dispose.")] ShellTableViewController _tableViewController; ShellFlyoutLayoutManager _shellFlyoutContentManager; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The view ordering cache only references renderer-owned subviews and is cleared in Dispose.")] UIView[] _uIViews; + bool _isDisposed; + + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event subscribers are cleared in Dispose(bool).")] public event EventHandler WillAppear; + + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event subscribers are cleared in Dispose(bool).")] public event EventHandler WillDisappear; const short HeaderIndex = 0; @@ -46,6 +61,7 @@ protected virtual ShellTableViewController CreateShellTableViewController() return new ShellTableViewController(_shellContext, OnElementSelected); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Dispose before the shell context is released.")] protected virtual void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.IsOneOf( @@ -135,23 +151,20 @@ void UpdateFlyoutFooter(View view) int previousIndex = GetPreviousIndex(_footerView); if (_footer is not null) { - var oldRenderer = (IPlatformViewHandler)_footer.Handler; var oldFooterView = _footerView; + _footer.MeasureInvalidated -= OnFooterMeasureInvalidated; _tableViewController.FooterView = null; - _footerView?.Disconnect(); _footerView = null; _uIViews[FooterIndex] = null; oldFooterView?.RemoveFromSuperview(); - - _footer.Handler = null; - oldRenderer?.DisconnectHandler(); + oldFooterView?.Dispose(); } _footer = view; if (_footer is not null) { - _footerView = new UIContainerView(_footer); + _footerView = new UIContainerView(_footer, ownsHandler: true); _uIViews[FooterIndex] = _footerView; AddViewInCorrectOrder(_footerView, previousIndex); @@ -206,6 +219,7 @@ void AddViewInCorrectOrder(UIView newView, int previousIndex) View.AddSubview(newView); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The footer MeasureInvalidated subscription is removed when the footer is replaced and in Dispose.")] void OnFooterMeasureInvalidated(object sender, System.EventArgs e) { ReMeasureFooter(); @@ -243,6 +257,9 @@ void UpdateFooterPosition(nfloat footerHeight) public override void ViewWillLayoutSubviews() { + if (_isDisposed) + return; + base.ViewWillLayoutSubviews(); UpdateFooterPosition(); UpdateFlyoutContent(); @@ -278,13 +295,17 @@ protected virtual void UpdateBackground() void UpdateFlyoutBgImageAsync() { + if (_isDisposed || _shellContext?.Shell is not Shell shell || _bgImage is null) + return; + // Image - var imageSource = _shellContext.Shell.FlyoutBackgroundImage; - if (imageSource is null || !_shellContext.Shell.IsSet(Shell.FlyoutBackgroundImageProperty)) + var imageSource = shell.FlyoutBackgroundImage; + if (imageSource is null || !shell.IsSet(Shell.FlyoutBackgroundImageProperty)) { _bgImage.RemoveFromSuperview(); - _bgImage.Image?.Dispose(); _bgImage.Image = null; + _bgImageResult?.Dispose(); + _bgImageResult = null; return; } @@ -292,40 +313,48 @@ void UpdateFlyoutBgImageAsync() if (mauiContext is null) return; + var bgImage = _bgImage; imageSource.LoadImage(mauiContext, result => { - var nativeImage = result?.Value; - - if (View is null || nativeImage is null) + if (_isDisposed) + { + result?.Dispose(); return; + } - int previousIndex = GetPreviousIndex(_bgImage); + var nativeImage = result?.Value; + var view = ViewIfLoaded; if (nativeImage is null || - _shellContext.Shell.FlyoutBackgroundImage != imageSource) + view is null || + !ReferenceEquals(bgImage, _bgImage) || + _shellContext?.Shell is not Shell currentShell || + !ReferenceEquals(imageSource, currentShell.FlyoutBackgroundImage)) { - _bgImage?.RemoveFromSuperview(); + result?.Dispose(); return; } - _bgImage.Image = nativeImage; - switch (_shellContext.Shell.FlyoutBackgroundImageAspect) + int previousIndex = GetPreviousIndex(bgImage); + var previousResult = _bgImageResult; + _bgImageResult = result; + bgImage.Image = nativeImage; + previousResult?.Dispose(); + switch (currentShell.FlyoutBackgroundImageAspect) { default: case Aspect.AspectFit: - _bgImage.ContentMode = UIViewContentMode.ScaleAspectFit; + bgImage.ContentMode = UIViewContentMode.ScaleAspectFit; break; case Aspect.AspectFill: - _bgImage.ContentMode = UIViewContentMode.ScaleAspectFill; + bgImage.ContentMode = UIViewContentMode.ScaleAspectFill; break; case Aspect.Fill: - _bgImage.ContentMode = UIViewContentMode.ScaleToFill; + bgImage.ContentMode = UIViewContentMode.ScaleToFill; break; } - if (_bgImage.Superview != View) - { - AddViewInCorrectOrder(_bgImage, previousIndex); - } + if (!_isDisposed && ReferenceEquals(bgImage, _bgImage) && bgImage.Superview != view) + AddViewInCorrectOrder(bgImage, previousIndex); }); } @@ -333,6 +362,9 @@ void UpdateFlyoutBgImageAsync() public override void ViewDidLayoutSubviews() { + if (_isDisposed) + return; + base.ViewDidLayoutSubviews(); _tableViewController.LayoutParallax(); @@ -342,6 +374,9 @@ public override void ViewDidLayoutSubviews() public override void ViewDidLoad() { + if (_isDisposed) + return; + base.ViewDidLoad(); @@ -388,6 +423,9 @@ void UpdateFlyoutContent() public override void ViewWillAppear(bool animated) { + if (_isDisposed) + return; + UpdateFlowDirection(); base.ViewWillAppear(animated); WillAppear?.Invoke(this, EventArgs.Empty); @@ -395,6 +433,9 @@ public override void ViewWillAppear(bool animated) public override void ViewWillDisappear(bool animated) { + if (_isDisposed) + return; + base.ViewWillDisappear(animated); WillDisappear?.Invoke(this, EventArgs.Empty); @@ -404,5 +445,54 @@ void OnElementSelected(Element element) { ((IShellController)_shellContext.Shell).OnFlyoutItemSelected(element); } + + protected override void Dispose(bool disposing) + { + if (_isDisposed) + return; + + _isDisposed = true; + + if (disposing) + { + if (_shellContext?.Shell is not null) + _shellContext.Shell.PropertyChanged -= HandleShellPropertyChanged; + + if (_footer is not null) + _footer.MeasureInvalidated -= OnFooterMeasureInvalidated; + + _headerView?.Disconnect(); + _footerView?.Disconnect(); + _headerView?.RemoveFromSuperview(); + _footerView?.RemoveFromSuperview(); + _blurView?.RemoveFromSuperview(); + _bgImage?.RemoveFromSuperview(); + if (_bgImage is not null) + _bgImage.Image = null; + _bgImageResult?.Dispose(); + _bgImage?.Dispose(); + _blurView?.Dispose(); + _headerView?.Dispose(); + _footerView?.Dispose(); + _tableViewController?.ViewIfLoaded?.RemoveFromSuperview(); + _tableViewController?.RemoveFromParentViewController(); + _tableViewController?.Dispose(); + WillAppear = null; + WillDisappear = null; + } + + _bgImageResult = null; + _bgImage = null; + _blurView = null; + _headerView = null; + _footerView = null; + _footer = null; + _tableViewController = null; + _shellFlyoutContentManager = null; + _shellContext = null; + _uIViews = null; + + base.Dispose(disposing); + } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs index a86ca5fd1a01..5db59d2c5e14 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs @@ -8,7 +8,7 @@ internal class ShellFlyoutHeaderContainer : UIContainerView, IPlatformMeasureInv { Thickness _safearea = Thickness.Zero; - public ShellFlyoutHeaderContainer(View view) : base(view) + public ShellFlyoutHeaderContainer(View view) : base(view, ownsHandler: true) { UpdateSafeAreaMargin(); } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs index ec7254f689cb..dee294d09f7f 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreAnimation; using CoreGraphics; using Foundation; @@ -50,6 +51,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) public override UIStatusBarAnimation PreferredStatusBarUpdateAnimation => Detail.PreferredStatusBarUpdateAnimation; + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "AttachFlyout subscriptions are torn down in Dispose: Shell.PropertyChanged is unsubscribed and the pan gesture recognizer is removed and disposed.")] void IShellFlyoutRenderer.AttachFlyout(IShellContext context, UIViewController content) { Context = context; @@ -128,6 +130,7 @@ void IFlyoutBehaviorObserver.OnFlyoutBehaviorChanged(FlyoutBehavior behavior) FlyoutBehavior _flyoutBehavior; bool _gestureActive; bool _isOpen; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout animation is stopped and cleared when replaced, when completed, and in Dispose.")] UIViewPropertyAnimator _flyoutAnimation; Brush _backdropBrush; bool _layoutOccured; @@ -150,10 +153,13 @@ public IShellFlyoutTransition FlyoutTransition SlideFlyoutTransition SlideFlyoutTransition { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the flyout renderer lifetime and released in Dispose after observers are removed.")] IShellContext Context { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The detail controller is owned by the flyout renderer while attached and released in Dispose.")] UIViewController Detail { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout content renderer is owned by this renderer and released in Dispose.")] IShellFlyoutContentRenderer Flyout { get; set; } bool IsOpen @@ -203,12 +209,14 @@ void UpdateFlyoutAccessibility() Detail.View.AccessibilityElementsHidden = detailsElementsHidden; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The pan gesture recognizer is attached to this renderer's view and removed and disposed in Dispose.")] UIPanGestureRecognizer PanGestureRecognizer { get; set; } Shell Shell { get; set; } IShellController ShellController => Shell; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The tap-off view is owned by this renderer and removed and disposed in Dispose.")] UIView TapoffView { get; set; } public override void ViewDidLayoutSubviews() @@ -257,26 +265,47 @@ public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTr protected override void Dispose(bool disposing) { - base.Dispose(disposing); + if (_disposed) + return; + + _disposed = true; if (disposing) { - if (!_disposed) + if (Shell is not null) { ShellController.RemoveAppearanceObserver(this); - - _disposed = true; - Shell.PropertyChanged -= OnShellPropertyChanged; ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); + } - Context = null; - Shell = null; - Detail = null; + _flyoutAnimation?.StopAnimation(true); + _flyoutAnimation = null; + if (PanGestureRecognizer != null) + { + View?.RemoveGestureRecognizer(PanGestureRecognizer); + PanGestureRecognizer.Dispose(); + PanGestureRecognizer = null; + } + RemoveTapoffView(); + if (Flyout?.ViewController is UIViewController flyoutController) + { + flyoutController.View?.RemoveFromSuperview(); + flyoutController.RemoveFromParentViewController(); + flyoutController.Dispose(); } + Flyout = null; + _flyoutTransition = null; + SlideFlyoutTransition = null; + Context = null; + Shell = null; + Detail = null; } + + base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Dispose before the shell reference is released.")] protected virtual void OnShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == Shell.FlyoutIsPresentedProperty.PropertyName) @@ -362,6 +391,7 @@ void AddTapoffView() TapoffView.AddGestureRecognizer(recognizer); } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout transition is retained for the renderer lifetime and cleared in Dispose.")] private IShellFlyoutTransition _flyoutTransition; public UIView NativeView => throw new NotImplementedException(); @@ -514,6 +544,7 @@ void RemoveTapoffView() return; TapoffView.RemoveFromSuperview(); + TapoffView.Dispose(); TapoffView = null; } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs index b96be43ad7d1..8ad18e1067b4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Foundation; @@ -15,6 +16,7 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class ShellItemRenderer : UITabBarController, IShellItemRenderer, IAppearanceObserver, IUINavigationControllerDelegate, IDisconnectable { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "This is a shared static Array.Empty cache and is not renderer-owned NSObject state.")] readonly static UITableViewCell[] EmptyUITableViewCellArray = Array.Empty(); #region IShellItemRenderer @@ -47,8 +49,10 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) #endregion IAppearanceObserver - readonly IShellContext _context; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the tab renderer lifetime and released in Dispose after observers are removed.")] + IShellContext _context; readonly Dictionary _sectionRenderers = new Dictionary(); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The appearance tracker is owned by this renderer and disposed in Dispose.")] IShellTabBarAppearanceTracker _appearanceTracker; ShellSection _currentSection; Page _displayedPage; @@ -56,6 +60,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) ShellItem _shellItem; static UIColor _defaultMoreTextLabelTextColor; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The current section renderer is tracked while selected and cleared in Dispose or when removed.")] internal IShellSectionRenderer CurrentRenderer { get; private set; } public ShellItemRenderer(IShellContext context) @@ -251,6 +256,9 @@ protected override void Dispose(bool disposing) _sectionRenderers.Clear(); CurrentRenderer = null; + _appearanceTracker?.Dispose(); + _appearanceTracker = null; + _context = null; _shellItem = null; _currentSection = null; _displayedPage = null; @@ -259,6 +267,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellItem.PropertyChanged subscription is removed in Disconnect before the shell item is released.")] protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == ShellItem.CurrentItemProperty.PropertyName) @@ -267,6 +276,7 @@ protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEv } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellItemController.ItemsCollectionChanged subscription is removed in Disconnect before the shell item is released.")] protected virtual void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { if (e.OldItems != null) @@ -337,6 +347,7 @@ protected virtual void OnShellItemSet(ShellItem shellItem) ShellItemController.ItemsCollectionChanged += OnItemsCollectionChanged; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Each ShellSection.PropertyChanged subscription is removed in Disconnect and RemoveRenderer.")] protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == BaseShellItem.IsEnabledProperty.PropertyName) @@ -524,6 +535,7 @@ void OnDisplayedPageChanged(Page page) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The displayed page PropertyChanged subscription is removed in Disconnect and when the displayed page changes.")] void OnDisplayedPagePropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == Shell.TabBarIsVisibleProperty.PropertyName) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs index 44d650904b14..40cee13425ea 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs @@ -10,7 +10,7 @@ public class ShellItemTransition : IShellItemTransition { public Task Transition(IShellItemRenderer oldRenderer, IShellItemRenderer newRenderer) { - TaskCompletionSource task = new TaskCompletionSource(); + TaskCompletionSource task = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var oldView = oldRenderer.ViewController.View; var newView = newRenderer.ViewController.View; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index ba0e1c75e0dd..fb1e2ac18337 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -1,6 +1,8 @@ #nullable disable using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls.Platform; @@ -13,6 +15,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility { public class ShellRenderer : UIViewController, IShellContext, IPlatformViewHandler { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Static mapper is shared for the renderer type and does not capture renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper); public static CommandMapper CommandMapper = new CommandMapper(ViewHandler.ViewCommandMapper); @@ -92,11 +95,20 @@ IShellTabBarAppearanceTracker IShellContext.CreateTabBarAppearanceTracker() #endregion IShellContext + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Current item renderer is owned by ShellRenderer and disposed when replaced or when ShellRenderer is disposed.")] IShellItemRenderer _currentShellItemRenderer; bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Flyout renderer is owned by ShellRenderer and disposed in Dispose(bool).")] IShellFlyoutRenderer _flyoutRenderer; Task _activeTransition = Task.CompletedTask; + TaskCompletionSource _activeTransitionCancellation; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Incoming item renderer is a transient transition reference cleared when ShellRenderer is disposed.")] IShellItemRenderer _incomingRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Outgoing item renderer is retained only while its transition is active and disposed when the transition completes or ShellRenderer disconnects.")] + IShellItemRenderer _outgoingRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Pending item renderers are disposed when superseded or when ShellRenderer disconnects.")] + readonly HashSet _pendingRenderers = new(ReferenceEqualityComparer.Instance); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "MauiContext is provided by the handler and cleared when ShellRenderer is disposed.")] IMauiContext _mauiContext; IShellFlyoutRenderer FlyoutRenderer @@ -113,6 +125,7 @@ IShellFlyoutRenderer FlyoutRenderer set { _flyoutRenderer = value; } } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) when ShellRenderer is released.")] public event EventHandler ElementChanged; public VisualElement Element { get; private set; } @@ -203,15 +216,105 @@ protected virtual IShellTabBarAppearanceTracker CreateTabBarAppearanceTracker() protected override void Dispose(bool disposing) { + if (disposing) + DisconnectHandler(); + base.Dispose(disposing); + } + + void DisconnectHandler() + { + if (_disposed) + return; - if (disposing && !_disposed) + _disposed = true; + + var element = Element; + if (element != null) + element.PropertyChanged -= OnElementPropertyChanged; + + ElementChanged = null; + CancelActiveTransition(); + + foreach (var pendingRenderer in _pendingRenderers) { - _disposed = true; - FlyoutRenderer?.Dispose(); + if (!ReferenceEquals(pendingRenderer, _currentShellItemRenderer)) + DisconnectAndDispose(pendingRenderer); } - FlyoutRenderer = null; + _pendingRenderers.Clear(); + var outgoingRenderer = _outgoingRenderer; + _outgoingRenderer = null; + if (outgoingRenderer is not null && + !ReferenceEquals(outgoingRenderer, _currentShellItemRenderer)) + { + DetachAndDispose(outgoingRenderer); + } + + DisconnectAndDispose(_currentShellItemRenderer); + _flyoutRenderer?.Dispose(); + + _activeTransition = Task.CompletedTask; + _activeTransitionCancellation = null; + _incomingRenderer = null; + _currentShellItemRenderer = null; + _flyoutRenderer = null; + _mauiContext = null; + + if (element is IElement shell && ReferenceEquals(shell.Handler, this)) + shell.Handler = null; + + Element = null; + } + + static void DisconnectAndDispose(IShellItemRenderer renderer) + { + DetachRenderer(renderer); + (renderer as IDisconnectable)?.Disconnect(); + renderer?.Dispose(); + } + + static void DetachAndDispose(IShellItemRenderer renderer) + { + DetachRenderer(renderer); + renderer?.Dispose(); + } + + static void DetachRenderer(IShellItemRenderer renderer) + { + var viewController = renderer?.ViewController; + viewController?.ViewIfLoaded?.RemoveFromSuperview(); + viewController?.RemoveFromParentViewController(); + } + + void CancelActiveTransition() + { + if (_activeTransition.IsCompleted) + return; + + _currentShellItemRenderer?.ViewController?.ViewIfLoaded?.Layer.RemoveAllAnimations(); + _activeTransitionCancellation?.TrySetResult(true); + } + + static async Task WaitForTransitionOrCancellationAsync(Task transition, Task cancellation, ILogger logger) + { + var completedTask = await Task.WhenAny(transition, cancellation); + if (!ReferenceEquals(completedTask, transition)) + _ = ObserveTransitionAsync(transition, logger); + + await completedTask; + } + + static async Task ObserveTransitionAsync(Task transition, ILogger logger) + { + try + { + await transition.ConfigureAwait(false); + } + catch (Exception exc) when (exc is not OperationCanceledException) + { + logger?.LogWarning(exc, "Shell item transition failed after cancellation"); + } } protected virtual async void OnCurrentItemChanged() @@ -228,7 +331,11 @@ protected virtual async void OnCurrentItemChanged() protected virtual async Task OnCurrentItemChangedAsync() { - var currentItem = Shell.CurrentItem; + var shell = Element as Shell; + if (_disposed || shell == null) + return; + + var currentItem = shell.CurrentItem; var oldLayer = _currentShellItemRenderer ?.ViewController @@ -239,6 +346,9 @@ protected virtual async Task OnCurrentItemChangedAsync() oldLayer.RemoveAllAnimations(); await _activeTransition; + if (_disposed) + return; + if (_currentShellItemRenderer?.ShellItem != currentItem) { var newController = CreateShellItemRenderer(currentItem); @@ -248,6 +358,9 @@ protected virtual async Task OnCurrentItemChangedAsync() protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) { + if (_disposed) + return; + if (e.PropertyName == Shell.CurrentItemProperty.PropertyName) { OnCurrentItemChanged(); @@ -260,7 +373,7 @@ protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEv void UpdateFlowDirection(bool readdViews = false) { - if (_currentShellItemRenderer?.ViewController == null) + if (_disposed || _currentShellItemRenderer?.ViewController == null) return; var originalValue = _currentShellItemRenderer.ViewController.View.SemanticContentAttribute; @@ -280,6 +393,7 @@ void UpdateFlowDirection(bool readdViews = false) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Shell PropertyChanged subscription is removed in Dispose(bool).")] protected virtual void OnElementSet(Shell element) { if (element == null) @@ -302,16 +416,37 @@ protected async void SetCurrentShellItemController(IShellItemRenderer value) protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value) { + if (_disposed) + { + DisconnectAndDispose(value); + return; + } + + _pendingRenderers.Add(value); _incomingRenderer = value; await _activeTransition; + if (_disposed) + { + if (_pendingRenderers.Remove(value)) + DisconnectAndDispose(value); + + return; + } + // This means the selected item changed while the active transition // was finishing up + var shell = Element as Shell; if (_incomingRenderer != value || - value.ShellItem != this.Shell.CurrentItem) + shell == null || + value.ShellItem != shell.CurrentItem) { - (value as IDisconnectable)?.Disconnect(); - value?.Dispose(); + if (ReferenceEquals(_incomingRenderer, value)) + _incomingRenderer = null; + + if (_pendingRenderers.Remove(value)) + DisconnectAndDispose(value); + return; } @@ -319,7 +454,9 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value (oldRenderer as IDisconnectable)?.Disconnect(); var newRenderer = value; + _pendingRenderers.Remove(value); _currentShellItemRenderer = value; + _incomingRenderer = null; AddChildViewController(newRenderer.ViewController); View.AddSubview(newRenderer.ViewController.View); @@ -330,13 +467,33 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value if (oldRenderer != null) { var transition = CreateShellItemTransition(); + var transitionCancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _activeTransitionCancellation = transitionCancellation; + _outgoingRenderer = oldRenderer; + _activeTransition = WaitForTransitionOrCancellationAsync( + transition.Transition(oldRenderer, newRenderer), + transitionCancellation.Task, + _mauiContext?.CreateLogger()); - _activeTransition = transition.Transition(oldRenderer, newRenderer); - await _activeTransition; + try + { + await _activeTransition; + } + finally + { + if (ReferenceEquals(_activeTransitionCancellation, transitionCancellation)) + _activeTransitionCancellation = null; + + if (ReferenceEquals(_outgoingRenderer, oldRenderer)) + { + _outgoingRenderer = null; + DetachAndDispose(oldRenderer); + } + } - oldRenderer.ViewController.RemoveFromParentViewController(); - oldRenderer.ViewController.View.RemoveFromSuperview(); - oldRenderer.Dispose(); + if (_disposed) + return; } else { @@ -344,7 +501,7 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value } // current renderer is still valid - if (_currentShellItemRenderer == value) + if (!_disposed && _currentShellItemRenderer == value) { UpdateBackgroundColor(); UpdateFlowDirection(); @@ -353,6 +510,9 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value protected virtual void UpdateBackgroundColor() { + if (_disposed || Element == null) + return; + var color = Shell.BackgroundColor?.ToPlatform(); if (color == null) color = Microsoft.Maui.Platform.ColorExtensions.BackgroundColor; @@ -362,6 +522,9 @@ protected virtual void UpdateBackgroundColor() void SetupCurrentShellItem() { + if (_disposed) + return; + if (Shell.CurrentItem == null) { throw new InvalidOperationException("Active Shell Item not set. Have you added any Shell Items to your Shell?"); @@ -421,6 +584,7 @@ void IElementHandler.Invoke(string command, object args) void IElementHandler.DisconnectHandler() { + DisconnectHandler(); } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs index befd05e5c83f..0370c83aaef4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Specialized; using Foundation; using Microsoft.Extensions.Logging; @@ -27,6 +28,7 @@ SearchHandler IShellSearchResultsRenderer.SearchHandler #endregion IShellSearchResultsRenderer + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Shell context is required for the search results renderer lifetime.")] readonly IShellContext _context; DataTemplate _defaultTemplate; @@ -72,6 +74,7 @@ DataTemplate DefaultTemplate } } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) when the search results renderer is released.")] public event EventHandler ItemSelected; public ShellSearchResultsRenderer(IShellContext context) @@ -100,6 +103,7 @@ protected override void Dispose(bool disposing) SearchController.ListProxyChanged -= OnListProxyChanged; } + ItemSelected = null; SearchHandler = null; } @@ -164,6 +168,7 @@ NSIndexPath[] GetPaths(int section, int index, int count) return paths; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListProxy CollectionChanged subscription is removed when the proxy changes and in Dispose(bool).")] void OnListProxyChanged(object sender, ListProxyChangedEventArgs e) { if (e.OldList != null) @@ -266,6 +271,7 @@ void OnProxyCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListProxyChanged subscription is removed in Dispose(bool).")] void OnSearchHandlerSet() { SearchController.ListProxyChanged += OnListProxyChanged; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index 405fb7f21df6..54c58511ea47 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using System.Windows.Input; @@ -70,11 +71,13 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) #endregion IAppearanceObserver + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the section renderer lifetime and released in Dispose after shell event subscriptions are removed.")] IShellContext _context; readonly Dictionary _trackers = new Dictionary(); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The navigation bar appearance tracker is owned by this renderer and disposed in Dispose.")] IShellNavBarAppearanceTracker _appearanceTracker; Dictionary> _completionTasks = @@ -84,6 +87,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) bool _disposed; bool _firstLayoutCompleted; TaskCompletionSource _popCompletionTask; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The root renderer is owned by this section renderer and disposed in Dispose.")] IShellSectionRootRenderer _renderer; ShellSection _shellSection; bool _ignorePopCall; @@ -103,6 +107,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) // ViewControllers = ViewControllers.Remove(vc1) // ViewControllers = ViewControllers.Remove(vc2) // You've now added vc1 back because the second call to ViewControllers will still return a ViewControllers list with vc1 in it + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The pending view controller snapshot is transient navigation state and is cleared after navigation operations and in Dispose.")] UIViewController[] _pendingViewControllers; public ShellSectionRenderer(IShellContext context) : base(typeof(MauiNavigationBar), null) @@ -345,7 +350,7 @@ public override void ViewDidLoad() return; base.ViewDidLoad(); - InteractivePopGestureRecognizer.Delegate = new GestureDelegate(this, ShouldPop); + InteractivePopGestureRecognizer.Delegate = new GestureDelegate(this); UpdateFlowDirection(); } @@ -412,6 +417,7 @@ protected override void Dispose(bool disposing) _shellSection = null; _appearanceTracker = null; _renderer = null; + _pendingViewControllers = null; _context = null; base.Dispose(disposing); @@ -430,12 +436,14 @@ public virtual UIImage GetSecondaryToolbarMenuButtonImage() return UIImage.GetSystemImage("ellipsis.circle"); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Disconnect before the shell context is released.")] protected virtual void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.Is(VisualElement.FlowDirectionProperty)) UpdateFlowDirection(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSection.PropertyChanged subscription is removed in Disconnect before the shell section is released.")] protected virtual void HandlePropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == BaseShellItem.TitleProperty.PropertyName) @@ -499,6 +507,7 @@ protected virtual void OnInsertRequested(NavigationRequestedEventArgs e) InsertViewController(ActiveViewControllers().IndexOf(beforeRenderer.ViewController), renderer.ViewController); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSectionController.NavigationRequested subscription is removed in Disconnect before the shell section is released.")] protected virtual void OnNavigationRequested(object sender, NavigationRequestedEventArgs e) { switch (e.RequestType) @@ -677,6 +686,7 @@ Element ElementForViewController(UIViewController viewController) return null; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The displayed page PropertyChanged subscription is removed in Disconnect and when the displayed page changes.")] void OnDisplayedPagePropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == Shell.NavBarIsVisibleProperty.PropertyName) @@ -687,11 +697,13 @@ void OnDisplayedPagePropertyChanged(object sender, PropertyChangedEventArgs e) // We only care about using pendingViewControllers when we are setting the ViewControllers array directly // So, once navigation starts again (or ends) we can just clear the pendingViewControllers + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.Navigating subscription is removed in Disconnect before the shell context is released.")] void OnNavigating(object sender, ShellNavigatingEventArgs e) { _pendingViewControllers = null; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.Navigated subscription is removed in Disconnect before the shell context is released.")] void OnNavigated(object sender, ShellNavigatedEventArgs e) { _pendingViewControllers = null; @@ -849,30 +861,31 @@ void UpdateShadowImages() class GestureDelegate : UIGestureRecognizerDelegate { - readonly UINavigationController _parent; - readonly Func _shouldPop; + readonly WeakReference _parent; - public GestureDelegate(UINavigationController parent, Func shouldPop) + public GestureDelegate(ShellSectionRenderer parent) { - _parent = parent; - _shouldPop = shouldPop; + _parent = new(parent); } public override bool ShouldBegin(UIGestureRecognizer recognizer) { - if ((_parent as ShellSectionRenderer).ActiveViewControllers().Length == 1) + if (!_parent.TryGetTarget(out var parent) || parent._disposed) + return false; + + if (parent.ActiveViewControllers().Length == 1) return false; - return _shouldPop(); + return parent.ShouldPop(); } } class NavDelegate : UINavigationControllerDelegate { - readonly ShellSectionRenderer _self; + readonly WeakReference _self; public NavDelegate(ShellSectionRenderer renderer) { - _self = renderer; + _self = new(renderer); } // This is currently working around a Mono Interpreter bug @@ -887,10 +900,13 @@ public NavDelegate(ShellSectionRenderer renderer) public override void DidShowViewController(UINavigationController navigationController, [Transient] UIViewController viewController, bool animated) { + if (!_self.TryGetTarget(out var self) || self._disposed) + return; + (navigationController.NavigationBar as MauiNavigationBar)?.RefreshIfNeeded(); - var tasks = _self._completionTasks; - var popTask = _self._popCompletionTask; + var tasks = self._completionTasks; + var popTask = self._popCompletionTask; if (tasks.TryGetValue(viewController, out var source)) { @@ -905,14 +921,17 @@ public override void DidShowViewController(UINavigationController navigationCont public override void WillShowViewController(UINavigationController navigationController, [Transient] UIViewController viewController, bool animated) { - var element = _self.ElementForViewController(viewController); + if (!_self.TryGetTarget(out var self) || self._disposed) + return; + + var element = self.ElementForViewController(viewController); bool navBarVisible = false; if (element is not null) { if (element is ShellSection) - navBarVisible = _self._renderer.ShowNavBar; + navBarVisible = self._renderer.ShowNavBar; else navBarVisible = Shell.GetNavBarIsVisible(element); @@ -931,8 +950,8 @@ public override void WillShowViewController(UINavigationController navigationCon // Because the back button title needs to be set on the previous VC // We want to set the BackButtonItem as early as possible so there is no flickering - var currentPage = _self._context?.Shell?.GetCurrentShellPage(); - var trackers = _self._trackers; + var currentPage = self._context?.Shell?.GetCurrentShellPage(); + var trackers = self._trackers; if (currentPage?.Handler is IPlatformViewHandler pvh && pvh.ViewController == viewController && trackers.TryGetValue(currentPage, out var tracker) && @@ -953,8 +972,11 @@ void OnInteractionChanged(IUIViewControllerTransitionCoordinatorContext context) { if (!context.IsCancelled) { - _self._popCompletionTask = new TaskCompletionSource(); - _self.SendPoppedOnCompletion(_self._popCompletionTask.Task); + if (!_self.TryGetTarget(out var self) || self._disposed) + return; + + self._popCompletionTask = new TaskCompletionSource(); + self.SendPoppedOnCompletion(self._popCompletionTask.Task); } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs index 9c85f14cff9a..e6a11ec31765 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Maui.Graphics; @@ -56,8 +57,11 @@ void SetValues(Color backgroundColor, Color foregroundColor, Color unselectedCol static readonly NSString CellId = new NSString("HeaderCell"); - readonly IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the header lifetime and released when the header is disposed.")] + IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The selection bar view is owned by this header and disposed in Dispose.")] UIView _bar; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The bottom shadow view is owned by this header and disposed in Dispose.")] UIView _bottomShadow; Color _selectedColor; Color _unselectedColor; @@ -228,9 +232,13 @@ protected override void Dispose(bool disposing) ShellSection = null; _bar.RemoveFromSuperview(); + _bottomShadow.RemoveFromSuperview(); this.RemoveFromParentViewController(); _bar.Dispose(); + _bottomShadow.Dispose(); _bar = null; + _bottomShadow = null; + _shellContext = null; } _isDisposed = true; @@ -262,6 +270,7 @@ protected void LayoutBar() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSection.PropertyChanged subscription is removed in Dispose before the shell section is released.")] protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == ShellSection.CurrentItemProperty.PropertyName) @@ -285,6 +294,7 @@ protected virtual void UpdateSelectedIndex(bool animated = false) CollectionView.SelectItem(NSIndexPath.FromItemSection((int)SelectedIndex, 0), false, UICollectionViewScrollPosition.CenteredHorizontally); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSectionController.ItemsCollectionChanged subscription is removed in Dispose before the shell section is released.")] void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs e) { HandleEventsOnItemsChange(e); @@ -310,6 +320,7 @@ void HandleEventsOnItemsChange(NotifyCollectionChangedEventArgs e) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ShellContent.PropertyChanged subscriptions are removed in Dispose and when items are removed from the section.")] void OnShellContentPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ShellContent.Title)) @@ -374,6 +385,7 @@ public override bool Selected } } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The label is owned by the collection view cell ContentView for the cell lifetime.")] public UILabel Label { get; } public override void LayoutSubviews() diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index c2ae5836ea14..399fe20e4cfd 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreAnimation; using CoreGraphics; using Foundation; @@ -23,20 +24,27 @@ public class ShellSectionRootRenderer : UIViewController, IShellSectionRootRende #endregion IShellSectionRootRenderer internal const int HeaderHeight = 35; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the root renderer lifetime and released in Dispose after Shell.PropertyChanged is unsubscribed.")] IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The blur view is owned by this renderer and removed when the renderer is disposed.")] UIView _blurView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The container view is owned by this renderer and removed when the renderer is disposed.")] UIView _containerArea; ShellContent _currentContent; int _currentIndex = 0; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The header renderer is owned by this renderer and disposed in Dispose or when hidden.")] IShellSectionRootHeader _header; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The outgoing page handler is retained only during animation and cleared after the transition or in Dispose.")] IPlatformViewHandler _isAnimatingOut; Dictionary _renderers = new Dictionary(); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The page renderer tracker is owned by this renderer and disposed in Dispose.")] IShellPageRendererTracker _tracker; bool _didLayoutSubviews; int _lastTabThickness = Int32.MinValue; Thickness _lastInset; bool _isDisposed; bool _isRotating; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The page animation is stopped and cleared in Disconnect and whenever a replacement animation starts.")] UIViewPropertyAnimator _pageAnimation; UIEdgeInsets _additionalSafeArea = UIEdgeInsets.Zero; @@ -61,6 +69,9 @@ public ShellSectionRootRenderer(ShellSection shellSection, IShellContext shellCo public override void ViewDidLayoutSubviews() { + if (_isDisposed) + return; + _didLayoutSubviews = true; base.ViewDidLayoutSubviews(); @@ -214,6 +225,10 @@ protected override void Dispose(bool disposing) _header?.Dispose(); _tracker?.Dispose(); + _blurView?.RemoveFromSuperview(); + _blurView?.Dispose(); + _containerArea?.RemoveFromSuperview(); + _containerArea?.Dispose(); foreach (var renderer in _renderers) { @@ -241,7 +256,13 @@ protected override void Dispose(bool disposing) _header = null; _tracker = null; _currentContent = null; + _isAnimatingOut = null; + _blurView = null; + _containerArea = null; + _pageAnimation = null; _isDisposed = true; + + base.Dispose(disposing); } protected virtual void LayoutRenderers() @@ -334,12 +355,14 @@ protected virtual void LoadRenderers() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Disconnect and Dispose before the shell context is released.")] protected virtual void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.Is(VisualElement.FlowDirectionProperty)) UpdateFlowDirection(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSection.PropertyChanged subscription is removed in Disconnect before the shell section is released.")] protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (_isDisposed) @@ -542,6 +565,7 @@ void UpdateFlowDirection() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSectionController.ItemsCollectionChanged subscription is removed in Disconnect before the shell section is released.")] void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs e) { if (_isDisposed) @@ -596,6 +620,7 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellContent.PropertyChanged subscriptions are removed in Disconnect and when items change before the shell section is released.")] void OnShellContentPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { if (_isDisposed) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs index fee6f50e0e9c..45798a25c648 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreAnimation; using CoreGraphics; using ObjCRuntime; @@ -11,9 +12,12 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class ShellTableViewController : UITableViewController { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Shell context is required for the table controller lifetime and event subscriptions are removed in Dispose(bool).")] readonly IShellContext _context; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table source is owned by the controller and detached from events in Dispose(bool).")] readonly ShellTableViewSource _source; bool _isDisposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Selection callback is cleared in Dispose(bool) when the controller is released.")] Action _onElementSelected; IShellController ShellController => _context.Shell; @@ -23,6 +27,7 @@ public ShellTableViewController(IShellContext context, UIContainerView headerVie HeaderView = headerView; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "FlyoutItemsChanged and ScrolledEvent subscriptions are removed in Dispose(bool).")] public ShellTableViewController(IShellContext context, Action onElementSelected) { ShellFlyoutContentManager = ShellFlyoutContentManager ?? new ShellFlyoutLayoutManager(context); @@ -132,7 +137,10 @@ protected override void Dispose(bool disposing) ShellController.FlyoutItemsChanged -= OnFlyoutItemsChanged; if (_source != null) + { _source.ScrolledEvent -= OnScrolled; + _source.Disconnect(); + } ShellFlyoutContentManager.TearDown(); _onElementSelected = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs index e05ac3c9d51e..a3b01a41f1eb 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Foundation; using Microsoft.Maui.Controls.Internals; using ObjCRuntime; @@ -11,7 +12,9 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class ShellTableViewSource : UITableViewSource { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Shell context is required while the table source is owned by ShellTableViewController.")] readonly IShellContext _context; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Selection callback is owned by ShellTableViewController for the table source lifetime.")] readonly Action _onElementSelected; List> _groups; Dictionary _cells; @@ -23,8 +26,14 @@ public ShellTableViewSource(IShellContext context, Action onElementSele _onElementSelected = onElementSelected; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "All event subscribers are cleared by Disconnect when ShellTableViewController is disposed.")] public event EventHandler ScrolledEvent; + internal void Disconnect() + { + ScrolledEvent = null; + } + public List> Groups { get @@ -198,7 +207,7 @@ public override UITableViewCell GetCell(UITableView tableView, NSIndexPath index return cell; } - void OnViewMeasureInvalidated(UIContainerCell cell) + static void OnViewMeasureInvalidated(UIContainerCell cell) { cell.ReloadRow(); } @@ -247,6 +256,7 @@ public override void WillDisplay(UITableView tableView, UITableViewCell cell, NS class SeparatorView : UIView { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Separator line is owned as a UIKit subview for the separator view lifetime.")] UIView _line; public SeparatorView() diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs index 2c6e0c3784e0..e5a0f5196f63 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using Foundation; using Microsoft.Maui.Controls.Internals; using ObjCRuntime; @@ -9,14 +10,20 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class UIContainerCell : UITableViewCell { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Renderer is owned by the container cell and disconnected in Disconnect.")] IPlatformViewHandler _renderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Binding context is unsubscribed from PropertyChanged and cleared in Disconnect.")] object _bindingContext; IElementDefinition _viewResource; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Measure callback is cleared in Disconnect before the cell is released.")] internal Action ViewMeasureInvalidated { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Index path is cleared in Disconnect when the cell is released.")] internal NSIndexPath IndexPath { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view reference is cleared in Disconnect when the cell is released.")] internal UITableView TableView { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "View MeasureInvalidated subscription is removed in Disconnect.")] internal UIContainerCell(string cellId, View view, Shell shell, object context) : base(UITableViewCellStyle.Default, cellId) { View = view; @@ -95,12 +102,15 @@ internal void Disconnect(Shell shell = null, bool keepRenderer = false) View.Handler = null; + _renderer = null; + IndexPath = null; View = null; TableView = null; } public View View { get; private set; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "BaseShellItem PropertyChanged subscription is removed when BindingContext changes and in Disconnect.")] public object BindingContext { get => _bindingContext; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs index 7484b56266a8..bf4d9db21eb9 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Graphics; using ObjCRuntime; @@ -10,16 +11,26 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility public class UIContainerView : UIView { readonly View _view; + readonly bool _ownsHandler; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Captured handler reference is cleared in Disconnect; owning flyout containers also disconnect it.")] IPlatformViewHandler _renderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Platform view is owned as a UIKit subview and detached and cleared in Disconnect.")] UIView _platformView; bool _disposed; double _measuredHeight; + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event subscribers are cleared in Dispose(bool).")] internal event EventHandler HeaderSizeChanged; public UIContainerView(View view) + : this(view, ownsHandler: false) + { + } + + internal UIContainerView(View view, bool ownsHandler) { _view = view; + _ownsHandler = ownsHandler; UpdatePlatformView(); ClipsToBounds = true; @@ -101,7 +112,6 @@ public override CGSize SizeThatFits(CGSize size) public override void WillRemoveSubview(UIView uiview) { - Disconnect(); base.WillRemoveSubview(uiview); } @@ -137,6 +147,17 @@ public override void LayoutSubviews() internal void Disconnect() { + var renderer = _renderer; + var platformView = _platformView; + + _renderer = null; + _platformView = null; + + if (platformView?.Superview == this) + platformView.RemoveFromSuperview(); + + if (_ownsHandler) + renderer?.DisconnectHandler(); } protected override void Dispose(bool disposing) @@ -147,12 +168,7 @@ protected override void Dispose(bool disposing) if (disposing) { Disconnect(); - - if (_platformView.Superview == this) - _platformView.RemoveFromSuperview(); - - _renderer = null; - _platformView = null; + HeaderSizeChanged = null; _disposed = true; } diff --git a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs index 63a2158390f4..3f399416e7a8 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Maui.Controls.Internals; @@ -25,13 +26,16 @@ public class TabbedRenderer : UITabBarController, IPlatformViewHandler UIColor _defaultBarColor; bool _defaultBarColorSet; bool? _defaultBarTranslucent; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The tab bar appearance is owned by the renderer and disposed in Dispose.")] UITabBarAppearance _tabBarAppearance; WeakReference _element; Brush _currentBarBackground; IMauiContext MauiContext => _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(TabbedViewHandler.ViewMapper); public static CommandMapper CommandMapper = new CommandMapper(TabbedViewHandler.ViewCommandMapper); @@ -67,6 +71,7 @@ protected TabbedPage Tabbed public VisualElement Element => _viewHandlerWrapper?.Element ?? _element?.GetTargetOrDefault(); + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler ElementChanged; public Size GetDesiredSize(double widthConstraint, double heightConstraint) => @@ -77,6 +82,7 @@ public UIView NativeView get { return View; } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "TabbedPage PropertyChanged and PagesChanged subscriptions are removed in Dispose.")] public void SetElement(VisualElement element) { _viewHandlerWrapper.SetVirtualView(element, OnElementChanged, false); @@ -152,6 +158,9 @@ protected override void Dispose(bool disposing) { tabbed.PropertyChanged -= OnPropertyChanged; tabbed.PagesChanged -= OnPagesChanged; + + foreach (var page in tabbed.Children) + page.PropertyChanged -= OnPagePropertyChanged; } if (_currentBarBackground is GradientBrush currentGradientBrush) @@ -377,6 +386,7 @@ void SetControllers() UpdateTabBarVisibility(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Page PropertyChanged subscriptions are removed in TeardownPage and Dispose.")] void SetupPage(Page page, int index) { var renderer = (IPlatformViewHandler)page.ToHandler(_mauiContext); @@ -420,6 +430,7 @@ void UpdateBarBackgroundColor() TabBar.BarTintColor = isDefaultColor ? _defaultBarColor : barBackgroundColor.ToPlatform(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Gradient brush InvalidateGradientBrushRequested is removed before replacing the brush and in Dispose.")] void UpdateBarBackground() { if (Tabbed is not TabbedPage tabbed || TabBar == null) diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs index f5d9ec00e7fd..05f2496d0488 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs @@ -30,6 +30,10 @@ public class TableViewModelRenderer : UITableViewSource #pragma warning disable CS0618 // Type or member is obsolete WeakReference _tableView; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Gesture is removed from its view and disposed in Dispose(bool).")] + UILongPressGestureRecognizer _longPressGestureRecognizer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Gesture is removed from its view and disposed in Dispose(bool).")] + UITapGestureRecognizer _tapGestureRecognizer; UITableView PlatformView { @@ -46,16 +50,20 @@ internal TableView TableView } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ModelChanged subscription is removed in Dispose(bool).")] public TableViewModelRenderer(TableView model) #pragma warning restore CS0618 // Type or member is obsolete { TableView = model; - model.ModelChanged += (s, e) => PlatformView?.ReloadData(); + model.ModelChanged += OnModelChanged; AutomaticallyDeselect = true; } public bool AutomaticallyDeselect { get; set; } + void OnModelChanged(object sender, EventArgs e) => + PlatformView?.ReloadData(); + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { #pragma warning disable CS0618 // Type or member is obsolete @@ -174,13 +182,17 @@ void BindGestures(UITableView tableview) HasBoundGestures = true; - var gesture = new UILongPressGestureRecognizer(LongPress); - gesture.MinimumPressDuration = 2; - tableview.AddGestureRecognizer(gesture); + _longPressGestureRecognizer = new UILongPressGestureRecognizer(LongPress) + { + MinimumPressDuration = 2 + }; + tableview.AddGestureRecognizer(_longPressGestureRecognizer); - var dismissGesture = new UITapGestureRecognizer(Tap); - dismissGesture.CancelsTouchesInView = false; - tableview.AddGestureRecognizer(dismissGesture); + _tapGestureRecognizer = new UITapGestureRecognizer(Tap) + { + CancelsTouchesInView = false + }; + tableview.AddGestureRecognizer(_tapGestureRecognizer); PlatformView = tableview; } @@ -189,6 +201,31 @@ void Tap(UITapGestureRecognizer gesture) { gesture.View.EndEditing(true); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { +#pragma warning disable CS0618 // Type or member is obsolete + if (TableView is TableView tableView) + tableView.ModelChanged -= OnModelChanged; +#pragma warning restore CS0618 // Type or member is obsolete + + _longPressGestureRecognizer?.View?.RemoveGestureRecognizer(_longPressGestureRecognizer); + _longPressGestureRecognizer?.Dispose(); + _longPressGestureRecognizer = null; + + _tapGestureRecognizer?.View?.RemoveGestureRecognizer(_tapGestureRecognizer); + _tapGestureRecognizer?.Dispose(); + _tapGestureRecognizer = null; + HasBoundGestures = false; + + PlatformView = null; + TableView = null; + } + + base.Dispose(disposing); + } } public class UnEvenTableViewModelRenderer : TableViewModelRenderer diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs index b2ac7b8f44fd..8e662e115b82 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; using UIKit; @@ -14,6 +15,7 @@ public class TableViewRenderer : ViewRenderer #pragma warning restore CS0618 // Type or member is obsolete { const int DefaultRowHeight = 44; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Original background view is retained so UpdateBackgroundView can restore the UITableView background.")] UIView _originalBackgroundView; RectangleF _previousFrame; @@ -43,6 +45,8 @@ protected override void Dispose(bool disposing) { if (disposing) { + DisposeSource(Control); + var viewsToLookAt = new Stack(Subviews); while (viewsToLookAt.Count > 0) { @@ -81,7 +85,11 @@ protected override void OnElementChanged(ElementChangedEventArgs e) if (Control == null || Control.Style != style) { - Control?.Dispose(); + if (Control is not null) + { + DisposeSource(Control); + Control.Dispose(); + } var tv = CreateNativeControl(); _originalBackgroundView = tv.BackgroundView; @@ -144,7 +152,19 @@ public override void TraitCollectionDidChange(UITraitCollection previousTraitCol void SetSource() { var modeledView = Element; + var previousSource = Control.Source; Control.Source = modeledView.HasUnevenRows ? new UnEvenTableViewModelRenderer(modeledView) : new TableViewModelRenderer(modeledView); + previousSource?.Dispose(); + } + + static void DisposeSource(UITableView tableView) + { + if (tableView is null) + return; + + var source = tableView.Source; + tableView.Source = null; + source?.Dispose(); } void UpdateBackgroundView() diff --git a/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs index a0deb8ed8e64..1d07b352f755 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; @@ -28,6 +29,7 @@ public abstract partial class VisualElementRenderer : IPlatformViewHan where TElement : Element, IView #endif { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper VisualElementRendererMapper = new PropertyMapper(ViewHandler.ViewMapper) { [nameof(IView.AutomationId)] = MapAutomationId, @@ -48,13 +50,17 @@ public abstract partial class VisualElementRenderer : IPlatformViewHan #if IOS || MACCATALYST WeakReference? _virtualView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The temporary element is cleared immediately after SetVirtualView completes on iOS and Mac Catalyst.")] TElement? _tempElement; #else TElement? _virtualView; #endif + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext? _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The active mapper is handler metadata reset during SetVirtualView and used for renderer property updates.")] internal IPropertyMapper _mapper; internal readonly CommandMapper? _commandMapper; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The default mapper is handler metadata retained for the renderer lifetime to reset property mapping.")] internal readonly IPropertyMapper _defaultMapper; protected IMauiContext MauiContext => _mauiContext ?? throw new InvalidOperationException("MauiContext not set"); #if IOS || MACCATALYST @@ -95,7 +101,9 @@ protected VisualElementRenderer(IPropertyMapper mapper, CommandMapper? commandMa _commandMapper = commandMapper; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler>? ElementChanged; + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementPropertyChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler? ElementPropertyChanged; public void SetElement(IView view) diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs index 952fe4f00e5e..76179904f2d4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Controls.Platform; using UIKit; @@ -10,6 +11,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility [Obsolete("Frame is obsolete as of .NET 9. Please use Border instead.")] public class FrameRenderer : VisualElementRenderer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(VisualElementRendererMapper) { @@ -26,6 +28,7 @@ public static IPropertyMapper Mapper public static CommandMapper CommandMapper = new CommandMapper(VisualElementRendererCommandMapper); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The frame content view is owned by the renderer and disposed in Dispose.")] FrameView _actualView; CGSize _previousSize; bool _isDisposed; diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs index 44ce4c6c3c26..5c3d62327ad6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs @@ -1,4 +1,5 @@ -using CoreGraphics; +using System.Diagnostics.CodeAnalysis; +using CoreGraphics; using UIKit; using PlatformView = UIKit.UIView; @@ -15,6 +16,7 @@ public abstract partial class ViewRenderer : VisualElem where TElement : View, IView where TPlatformView : PlatformView { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The native view is cleared in DisconnectHandlerCore before the renderer disconnects.")] TPlatformView? _nativeView; public TPlatformView? Control diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs index f1188b1bc7ef..aad33b3d4faf 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Platform; using UIKit; @@ -68,6 +69,7 @@ partial void ElementPropertyChangedPartial(object sender, PropertyChangedEventAr } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "VisualElement SizeChanged and BatchCommitted subscriptions are removed when the element changes.")] partial void ElementChangedPartial(ElementChangedEventArgs e) { if (e.OldElement is VisualElement oldVe) diff --git a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs index 2ad07699ed1c..6078fbf09f60 100644 --- a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs +++ b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Maui.Graphics; @@ -125,11 +126,15 @@ void OnClicked(object sender, EventArgs e) protected override void Dispose(bool disposing) { - if (disposing && _item.TryGetTarget(out var item)) - item.PropertyChanged -= OnPropertyChanged; + if (disposing) + { + if (_item.TryGetTarget(out var item)) + item.PropertyChanged -= OnPropertyChanged; + } base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ToolbarItem PropertyChanged subscription is removed in Dispose.")] void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { if (!_item.TryGetTarget(out var item)) @@ -276,16 +281,19 @@ void UpdateText(ToolbarItem item) sealed class SecondaryToolbarItem : UIBarButtonItem { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The content view is owned by this toolbar item and released after its TouchUpInside subscription is removed in Dispose.")] + SecondaryToolbarItemContent _content; readonly WeakReference _item; public SecondaryToolbarItem(ToolbarItem item) : base(new SecondaryToolbarItemContent()) { + _content = (SecondaryToolbarItemContent)CustomView; _item = new(item); UpdateText(item); UpdateIcon(item); UpdateIsEnabled(item); - ((SecondaryToolbarItemContent)CustomView).TouchUpInside += OnClicked; + _content.TouchUpInside += OnClicked; item.PropertyChanged += OnPropertyChanged; if (item != null && !string.IsNullOrEmpty(item.AutomationId)) @@ -296,6 +304,7 @@ public SecondaryToolbarItem(ToolbarItem item) : base(new SecondaryToolbarItemCon #pragma warning restore CS0618 // Type or member is obsolete } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The TouchUpInside subscription is removed in Dispose.")] void OnClicked(object sender, EventArgs e) { if (_item.TryGetTarget(out var item)) @@ -306,14 +315,23 @@ void OnClicked(object sender, EventArgs e) protected override void Dispose(bool disposing) { - if (disposing && _item.TryGetTarget(out var item)) - item.PropertyChanged -= OnPropertyChanged; + if (disposing) + { + _content?.TouchUpInside -= OnClicked; + _content = null; + + if (_item.TryGetTarget(out var item)) + item.PropertyChanged -= OnPropertyChanged; + } base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ToolbarItem PropertyChanged subscription is removed in Dispose.")] void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (!_item.TryGetTarget(out var item)) + if (!_item.TryGetTarget(out var item) || + _content is null || + !ReferenceEquals(CustomView, _content)) return; if (e.PropertyName == MenuItem.TextProperty.PropertyName) @@ -336,28 +354,34 @@ void UpdateIcon(ToolbarItem item) { item.IconImageSource.LoadImage(item.FindMauiContext(), result => { - ((SecondaryToolbarItemContent)CustomView).Image = ScaleImageToSystemDefaults(item.IconImageSource, result?.Value); + if (_content is not null) + _content.Image = ScaleImageToSystemDefaults(item.IconImageSource, result?.Value); }); } else { - ((SecondaryToolbarItemContent)CustomView).Image = null; + if (_content is not null) + _content.Image = null; } } void UpdateIsEnabled(ToolbarItem item) { - ((UIControl)CustomView).Enabled = item.IsEnabled; + if (_content is not null) + _content.Enabled = item.IsEnabled; } void UpdateText(ToolbarItem item) { - ((SecondaryToolbarItemContent)CustomView).Text = item.Text; + if (_content is not null) + _content.Text = item.Text; } sealed class SecondaryToolbarItemContent : UIControl { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The image view is a child view owned by SecondaryToolbarItemContent for its UIControl lifetime.")] readonly UIImageView _imageView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The label is a child view owned by SecondaryToolbarItemContent for its UIControl lifetime.")] readonly UILabel _label; public SecondaryToolbarItemContent() diff --git a/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs b/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs index 1f5f7a4cbb99..07f49724f2bc 100644 --- a/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs +++ b/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs @@ -1,4 +1,5 @@ #nullable disable +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -9,6 +10,7 @@ namespace Microsoft.Maui.Controls.Compatibility.Platform.iOS { internal class GlobalCloseContextGestureRecognizer : UIGestureRecognizer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The scroll view is needed by ShouldReceiveTouch and is cleared in Dispose.")] UIScrollView _scrollView; public GlobalCloseContextGestureRecognizer(UIScrollView scrollView, NSAction activated) : base(activated) diff --git a/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs b/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs index 1bf83b6cb54d..d58136873d9f 100644 --- a/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs +++ b/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs @@ -19,7 +19,13 @@ public NativeViewPropertyListener(string targetProperty) TargetProperty = targetProperty; } - public event PropertyChangedEventHandler PropertyChanged; + event PropertyChangedEventHandler PropertyChanged; + + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged + { + add => PropertyChanged += value; + remove => PropertyChanged -= value; + } public override void ObserveValue(NSString keyPath, NSObject ofObject, NSDictionary change, IntPtr context) { diff --git a/src/Controls/src/Core/Controls.Core.csproj b/src/Controls/src/Core/Controls.Core.csproj index 5f867b201d2d..66c127db6f09 100644 --- a/src/Controls/src/Core/Controls.Core.csproj +++ b/src/Controls/src/Core/Controls.Core.csproj @@ -65,6 +65,7 @@ + diff --git a/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs index e3ddb28a1f08..01a9d6d5143f 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs @@ -134,6 +134,7 @@ public override void ViewDidLoad() base.ViewDidLoad(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] void OnDisplayInfoChanged(object sender, DisplayInfoChangedEventArgs e) { _isRotating = true; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs index 71d296a003b9..b9728ac2d8e1 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using ObjCRuntime; @@ -9,8 +10,10 @@ namespace Microsoft.Maui.Controls.Handlers.Items { public abstract class DefaultCell : ItemsViewCell { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public UILabel Label { get; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] protected NSLayoutConstraint Constraint { get; set; } [Export("initWithFrame:")] diff --git a/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs index 335ceb8cfd78..3c66ad9926d8 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using ObjCRuntime; @@ -17,9 +18,15 @@ public class GroupableItemsViewController : SelectableItemsViewContr // Keep out header measurement cells for iOS handy so we don't have to // create new ones all the time. For other versions, the reusable cells // queueing mechanism does this for us. + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] TemplatedCell _measurementCellTemplated; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] DefaultCell _measurementCellDefault; + // Transient: set immediately before a scroll animation and cleared (set to null) + // as soon as it is invoked (see SetScrollAnimationEndedCallback / the ScrollAnimationEnded + // path below), so it is not retained beyond a single scroll animation. + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Transient: cleared (set to null) immediately after it is invoked, so it is not retained.")] Action _scrollAnimationEndedCallback; public GroupableItemsViewController(TItemsView groupableItemsView, ItemsViewLayout layout) diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs index 1996aed66d74..263e62828f26 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs @@ -17,6 +17,7 @@ public abstract class ItemsViewController : UICollectionViewControll public const int EmptyTag = 333; readonly WeakReference _itemsView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public IItemsViewSource ItemsSource { get; protected set; } public TItemsView ItemsView => _itemsView.GetTargetOrDefault(); @@ -193,6 +194,7 @@ public override void LoadView() CollectionView = collectionView; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] private void MovedToWindow(object sender, EventArgs e) { if (CollectionView?.Window != null) diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs index 3c76211b0a17..3f98028c282c 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Foundation; @@ -15,6 +16,7 @@ public class ItemsViewDelegator : UICollectionViewD { readonly WeakReference _viewController; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public ItemsViewLayout ItemsViewLayout { get; } public TViewController ViewController => _viewController.TryGetTarget(out var vc) ? vc : null; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs index 567343fa7e0b..fdd38c14e6c7 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Extensions.Logging; @@ -90,6 +91,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] void LayoutOnPropertyChanged(object sender, PropertyChangedEventArgs propertyChanged) { HandlePropertyChanged(propertyChanged); diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs index 230f48c98a27..bab91ec92c68 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -12,6 +13,7 @@ public class ReorderableItemsViewController : GroupableItemsViewCont where TItemsView : ReorderableItemsView { bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] UILongPressGestureRecognizer _longPressGestureRecognizer; nint _lastMoveSourceSection = -1; nint _lastMoveDestinationSection = -1; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs index d0bfb95fe55c..e2869f8c3674 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Graphics; using ObjCRuntime; @@ -15,9 +16,11 @@ public class StructuredItemsViewController : ItemsViewController int _gotoPosition = -1; CarouselViewLoopManager _carouselViewLoopManager; CancellationTokenSource _scrollDebounce; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The orientation observer token is removed from NSNotificationCenter in TearDown and Dispose, so it does not root this controller.")] NSObject _orientationObserver; // Tracks the last position the controller synced with the CarouselView. This survives diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/DefaultCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/DefaultCell2.cs index fbe44acac0ab..f051306447ce 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/DefaultCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/DefaultCell2.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using ObjCRuntime; @@ -11,8 +12,10 @@ public class DefaultCell2 : ItemsViewCell2 { //public const string ReuseId = "Microsoft.Maui.Controls.DefaultCell2"; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public UILabel Label { get; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] protected NSLayoutConstraint Constraint { get; set; } [Export("initWithFrame:")] diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/GroupableItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/GroupableItemsViewController2.cs index 4aa8a9d4a72b..2e4b363cafda 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/GroupableItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/GroupableItemsViewController2.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using ObjCRuntime; @@ -20,6 +21,7 @@ public class GroupableItemsViewController2 : SelectableItemsViewCont // TemplatedCell2 _measurementCellTemplated; // DefaultCell2 _measurementCellDefault; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Transient: cleared (set to null) immediately after it is invoked, so it is not retained.")] Action _scrollAnimationEndedCallback; public GroupableItemsViewController2(TItemsView groupableItemsView, UICollectionViewLayout layout) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs index 2b685ab23b1c..c9c42dfde6fb 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs @@ -20,6 +20,7 @@ public abstract class ItemsViewController2 : UICollectionViewControl public const int EmptyTag = 333; readonly WeakReference _itemsView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public Items.IItemsViewSource ItemsSource { get; protected set; } public TItemsView ItemsView => _itemsView.GetTargetOrDefault(); @@ -259,6 +260,7 @@ static bool ShouldApplyCellReConfiguration() return OperatingSystem.IsIOSVersionAtLeast(15); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] private void MovedToWindow(object sender, EventArgs e) { if (CollectionView?.Window != null) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs index cfd1b8433fd5..3b35bf581133 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Foundation; @@ -16,6 +17,7 @@ public class ItemsViewDelegator2 : UICollectionView { readonly WeakReference _viewController; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public UICollectionViewLayout ItemsViewLayout { get; } public TViewController ViewController => _viewController.TryGetTarget(out var vc) ? vc : null; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs index 86d97cc7a6a4..e462e3216491 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -12,6 +13,7 @@ public class ReorderableItemsViewController2 : GroupableItemsViewCon where TItemsView : ReorderableItemsView { bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] UILongPressGestureRecognizer _longPressGestureRecognizer; nint _lastMoveSourceSection = -1; nint _lastMoveDestinationSection = -1; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index 1951b81f34db..c2d1fe6af4c9 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Maui.Controls.Internals; @@ -68,8 +69,10 @@ public TemplatedCell2(CGRect frame) : base(frame) public UICollectionViewScrollDirection ScrollDirection { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] internal IPlatformViewHandler PlatformHandler { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] internal UIView PlatformView { get; set; } CollectionViewHandler2 CollectionViewHandler diff --git a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs index d5a4e5a9714e..9a74ee7a7cd7 100644 --- a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs +++ b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using System.Reflection; using System.Runtime.Versioning; @@ -1069,6 +1070,7 @@ ButtonsMask AddFakeRightClickForMacCatalyst(IGestureRecognizer recognizer) internal class FakeRightClickContextMenuInteraction : UIContextMenuInteraction { // Store a reference to the platform delegate so that it is not garbage collected + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The strong reference is required to keep the UIContextMenuInteractionDelegate alive for the interaction lifetime.")] FakeRightClickDelegate? _dontCollectMePlease; public FakeRightClickContextMenuInteraction(TapGestureRecognizer tapGestureRecognizer, GesturePlatformManager gestureManager) @@ -1107,6 +1109,7 @@ public FakeRightClickDelegate(TapGestureRecognizer tapGestureRecognizer, Gesture internal class FakeRightClickPointerInteraction : UIContextMenuInteraction { // Store a reference to the platform delegate so that it is not garbage collected + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The strong reference is required to keep the UIContextMenuInteractionDelegate alive for the interaction lifetime.")] FakeRightClickPointerDelegate? _dontCollectMePlease; bool _disposed; diff --git a/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs b/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs index fd2124b7b1cf..4d8135a97894 100644 --- a/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs +++ b/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Foundation; using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; using Microsoft.Maui.Graphics; @@ -10,6 +11,7 @@ namespace Microsoft.Maui.Controls.Platform { internal class ControlsModalWrapper : ModalWrapper, IUIAdaptivePresentationControllerDelegate { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The modal handler is owned by this wrapper while presented and is released in Dispose.")] IPlatformViewHandler? _modal; bool _isDisposed; Page Page => ((Page?)_modal?.VirtualView) ?? throw new InvalidOperationException("Page cannot be null here"); @@ -206,6 +208,7 @@ public override UIViewController ChildViewControllerForStatusBarStyle() return ChildViewControllers.Last(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The modal page PropertyChanged subscription is removed in Dispose before the modal handler is released.")] void OnModalPagePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == Page.BackgroundColorProperty.PropertyName) diff --git a/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs b/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs index 5758a89e0e9a..37ea0c86459b 100644 --- a/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs +++ b/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -10,7 +11,9 @@ namespace Microsoft.Maui.Controls.Platform.iOS; internal class CustomPressGestureRecognizer : UIGestureRecognizer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The recognizer owns the native target for its lifetime; retaining it keeps callback targets alive until the recognizer is released.")] NSObject _target; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The current UIEvent is replaced by touch callbacks and retained only for the gesture recognizer lifetime.")] UIEvent _currentEvent; ButtonsMask _detectedButton = ButtonsMask.Primary; @@ -36,6 +39,7 @@ public CustomPressGestureRecognizer(Action action) [Register("__UIGestureRecognizer")] class Callback : Token { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The callback token owns this delegate so the recognizer action remains invokable for the recognizer lifetime.")] Action action; internal Callback(Action action) diff --git a/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs b/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs index fb864d2ac188..437ef5f8148b 100644 --- a/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs +++ b/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs @@ -1,6 +1,7 @@ #nullable disable #if __MOBILE__ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; using CoreGraphics; using Foundation; @@ -13,6 +14,7 @@ namespace Microsoft.Maui.Controls.Platform [SupportedOSPlatform("ios11.0")] class DragAndDropDelegate : NSObject, IUIDragInteractionDelegate, IUIDropInteractionDelegate { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The drag/drop delegate releases the handler reference in Disconnect when the platform interactions are disconnected.")] IPlatformViewHandler _viewHandler; PlatformDragStartingEventArgs _platformDragStartingEventArgs; @@ -361,6 +363,7 @@ void HandleDrop(View element, DataPackage datapackage, IUIDropSession session, P class CustomLocalStateData : NSObject { public View View { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The local drag state is session-scoped and released by UIKit when the drag session ends.")] public IViewHandler Handler { get; set; } public DataPackage DataPackage { get; set; } } diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 20a084a1300f..e295c9cd983f 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,16 +1,21 @@ #nullable enable +Microsoft.Maui.Controls.Label.~Label() -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.EntryCellRenderer.EntryCellTableViewCell.Dispose(bool disposing) -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.TableViewModelRenderer.Dispose(bool disposing) -> void override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.AddSubview(UIKit.UIView! view) -> void override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.DisconnectHandler(UIKit.UIView platformView) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutContentRenderer.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.DidMoveToParentViewController(UIKit.UIViewController parent) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void -override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void -~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void +override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void +~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index b65b21e0ebf3..f964cdbc4076 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,18 +1,23 @@ #nullable enable +Microsoft.Maui.Controls.Label.~Label() -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.EntryCellRenderer.EntryCellTableViewCell.Dispose(bool disposing) -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.TableViewModelRenderer.Dispose(bool disposing) -> void override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.AddSubview(UIKit.UIView! view) -> void override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.Dispose(bool disposing) -> void override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.DisconnectHandler(UIKit.UIView platformView) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint ~override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewDelegator2.DraggingStarted(UIKit.UIScrollView scrollView) -> void override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutContentRenderer.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.DidMoveToParentViewController(UIKit.UIViewController parent) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void -override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void -~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void +override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void +~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs index 020969604cc4..4ab85cc67e17 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs @@ -32,13 +32,22 @@ namespace Microsoft.Maui.DeviceTests [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] public partial class ShellTests : ControlsHandlerTestBase { - void SetupBuilder() + void SetupBuilder(Type shellHandlerType = null) { EnsureHandlerCreated(builder => { +#if IOS || MACCATALYST + builder.ConfigureImageSources(services => + { + services.AddService(); + }); +#endif builder.ConfigureMauiHandlers(handlers => { SetupShellHandlers(handlers); + if (shellHandlerType != null) + handlers.AddHandler(typeof(Shell), shellHandlerType); + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); handlers.AddHandler(typeof(Button), typeof(ButtonHandler)); handlers.AddHandler(typeof(Entry), typeof(EntryHandler)); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index e40cafcbc6ba..0ac9624e984c 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -2,10 +2,15 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; +using System.Threading; using System.Threading.Tasks; +using CoreAnimation; using CoreGraphics; using Foundation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Handlers.Compatibility; using Microsoft.Maui.Controls.Platform; @@ -130,7 +135,7 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => shell.CurrentItem = mainTab2; await OnFrameSetToNotEmpty(pageWithoutTopTabs.Content); var boundsWithoutTopTabs = pageWithoutTopTabs.Content.GetPlatformViewBounds(); - + // Both APIs should produce consistent results Assert.Equal(ShellSectionRootRenderer.HeaderHeight, (boundsWithTopTabs.Top - boundsWithoutTopTabs.Top), 1); @@ -436,11 +441,11 @@ public void UpdateLayout(UINavigationController controller) UIView titleView = Shell.GetTitleView(_context.Shell.CurrentPage)?.Handler?.PlatformView as UIView ?? Shell.GetTitleView(_context.Shell)?.Handler?.PlatformView as UIView; UIView parentView = GetParentByType(titleView, typeof(UIKit.UIControl)); - + if (parentView != null) { handler.PreviousFrame = parentView.Frame; - + // height constraint NSLayoutConstraint.Create(parentView, NSLayoutAttribute.Bottom, NSLayoutRelation.Equal, parentView.Superview, NSLayoutAttribute.Bottom, 1.0f, 0.0f).Active = true; NSLayoutConstraint.Create(parentView, NSLayoutAttribute.Top, NSLayoutRelation.Equal, parentView.Superview, NSLayoutAttribute.Top, 1.0f, 0.0f).Active = true; @@ -582,6 +587,487 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => } #endif #endif + [Fact(DisplayName = "Shell Section Delegates Do Not Retain Renderer")] + public async Task ShellSectionDelegatesDoNotRetainRenderer() + { + var references = await InvokeOnMainThreadAsync(CreateShellSectionDelegateReferences); + + Assert.NotNull(references.NavigationDelegate); + Assert.NotNull(references.GestureDelegate); + await AssertionExtensions.WaitForGC(references.Renderer); + GC.KeepAlive(references.NavigationDelegate); + GC.KeepAlive(references.GestureDelegate); + } + + [Fact(DisplayName = "Disposed Shell Section Root Ignores Late Layout")] + public async Task DisposedShellSectionRootIgnoresLateLayout() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + }); + + await CreateHandlerAndAddToWindow(shell, async handler => + { + await OnLoadedAsync(shell.CurrentPage); + + IShellContext shellContext = handler; + var shellItemRenderer = Assert.IsType(shellContext.CurrentShellItemRenderer); + var sectionRenderer = Assert.IsType(shellItemRenderer.CurrentRenderer); + var rootRenderer = Assert.IsType(sectionRenderer.ViewControllers[0]); + + rootRenderer.Dispose(); + rootRenderer.ViewDidLayoutSubviews(); + }); + } + + [Fact(DisplayName = "Disposed Shell Flyout Content Ignores Late Lifecycle Callbacks")] + public async Task DisposedShellFlyoutContentIgnoresLateLifecycleCallbacks() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + }); + + await CreateHandlerAndAddToWindow(shell, handler => + { + var flyoutContent = handler.ViewController + .ChildViewControllers + .OfType() + .First(); + + flyoutContent.Dispose(); + flyoutContent.ViewDidLoad(); + flyoutContent.ViewWillAppear(false); + flyoutContent.ViewWillLayoutSubviews(); + flyoutContent.ViewDidLayoutSubviews(); + flyoutContent.ViewWillDisappear(false); + + return Task.CompletedTask; + }); + } + + [Fact(DisplayName = "Disposed Shell Flyout Content Disconnects Header And Footer Handlers")] + public async Task DisposedShellFlyoutContentDisconnectsHeaderAndFooterHandlers() + { + SetupBuilder(); + var header = new Label { Text = "Header" }; + var footer = new Label { Text = "Footer" }; + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + shell.FlyoutHeader = header; + shell.FlyoutFooter = footer; + }); + + await CreateHandlerAndAddToWindow(shell, handler => + { + var flyoutContent = handler.ViewController + .ChildViewControllers + .OfType() + .First(); + var tableViewController = flyoutContent.ChildViewControllers + .OfType() + .First(); + var tableView = tableViewController.View; + var headerPlatformView = header.ToPlatform(); + var footerPlatformView = footer.ToPlatform(); + + Assert.NotNull(header.Handler); + Assert.NotNull(footer.Handler); + Assert.NotNull(tableView.Superview); + Assert.NotNull(headerPlatformView.Superview); + Assert.NotNull(footerPlatformView.Superview); + + flyoutContent.Dispose(); + + Assert.Null(header.Handler); + Assert.Null(footer.Handler); + Assert.Null(tableView.Superview); + Assert.Null(headerPlatformView.Superview); + Assert.Null(footerPlatformView.Superview); + + return Task.CompletedTask; + }); + } + + [Fact(DisplayName = "Disconnecting Reparented UI Container Preserves Shared Handler")] + public Task DisconnectingReparentedUIContainerPreservesSharedHandler() + { + SetupBuilder(); + + return InvokeOnMainThreadAsync(() => + { + var titleView = new Label { Text = "Title View" }; + var handler = CreateHandler(titleView); + using var firstContainer = new UIContainerView(titleView); + using var secondContainer = new UIContainerView(titleView); + var platformView = handler.PlatformView; + + Assert.Same(secondContainer, platformView.Superview); + + firstContainer.Disconnect(); + + Assert.Same(handler, titleView.Handler); + Assert.Same(secondContainer, platformView.Superview); + + ((IElementHandler)handler).DisconnectHandler(); + }); + } + + [Fact(DisplayName = "Disposed Shell Table Source Clears Scrolled Subscribers")] + public async Task DisposedShellTableSourceClearsScrolledSubscribers() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + }); + + await CreateHandlerAndAddToWindow(shell, handler => + { + var flyoutContent = handler.ViewController + .ChildViewControllers + .OfType() + .First(); + var tableViewController = flyoutContent.ChildViewControllers + .OfType() + .First(); + var source = Assert.IsType(tableViewController.TableView.Source); + var scrolled = false; + source.ScrolledEvent += OnScrolled; + + flyoutContent.Dispose(); + + using var nativeScrollView = new UIScrollView(); + source.Scrolled(nativeScrollView); + Assert.False(scrolled); + + return Task.CompletedTask; + + void OnScrolled(object sender, UIScrollView view) + { + scrolled = true; + } + }); + } + + [Fact(DisplayName = "Shell Flyout Renderer Disposal Is Idempotent After Native Teardown")] + public Task ShellFlyoutRendererDisposalIsIdempotentAfterNativeTeardown() => + InvokeOnMainThreadAsync(() => + { + var renderer = new TestableShellFlyoutRenderer(); + + renderer.DisposeForTest(false); + renderer.DisposeForTest(true); + }); + + [Fact(DisplayName = "Unattached Shell Flyout Renderer Can Be Disposed")] + public Task UnattachedShellFlyoutRendererCanBeDisposed() => + InvokeOnMainThreadAsync(() => + { + var renderer = new TestableShellFlyoutRenderer(); + + renderer.DisposeForTest(true); + renderer.DisposeForTest(true); + }); + + [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] + public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() + { + SetupBuilder(typeof(TestableShellRenderer)); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + shell.Items.Add(new ContentPage()); + }); + + await InvokeOnMainThreadAsync(async () => + { + using var handler = (TestableShellRenderer)shell.ToHandler(MauiContext); + var activeTransitionField = typeof(ShellHandler).GetField("_activeTransition", BindingFlags.Instance | BindingFlags.NonPublic); + var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + var incomingRendererField = typeof(ShellHandler).GetField("_incomingRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(activeTransitionField); + Assert.NotNull(currentRendererField); + Assert.NotNull(incomingRendererField); + + var transition = new TaskCompletionSource(); + activeTransitionField.SetValue(handler, transition.Task); + + shell.CurrentItem = shell.Items[1]; + var currentItemChanged = handler.CurrentItemChangedTask; + Assert.False(currentItemChanged.IsCompleted); + ((IElementHandler)handler).DisconnectHandler(); + transition.SetResult(true); + await currentItemChanged; + + Assert.Null(handler.Element); + Assert.Null(shell.Handler); + Assert.Null(currentRendererField.GetValue(handler)); + Assert.Null(incomingRendererField.GetValue(handler)); + }); + } + + [Fact(DisplayName = "Disconnect Shell Detaches Current Item Renderer Before Disposal")] + public Task DisconnectShellDetachesCurrentItemRendererBeforeDisposal() => + InvokeOnMainThreadAsync(() => + { + using var handler = new TestableShellRenderer(); + using var parentViewController = new UIViewController(); + var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(currentRendererField); + + var renderer = new TrackedShellItemRenderer(); + parentViewController.AddChildViewController(renderer.ViewController); + parentViewController.View.AddSubview(renderer.ViewController.View); + currentRendererField.SetValue(handler, renderer); + + ((IElementHandler)handler).DisconnectHandler(); + + Assert.Null(renderer.ParentAtDispose); + Assert.Null(renderer.SuperviewAtDispose); + Assert.Equal(1, renderer.DisconnectCount); + Assert.Equal(1, renderer.DisposeCount); + }); + + [Fact(DisplayName = "Disconnect Shell Cancels Active Item Transition Before Disposal")] + public Task DisconnectShellCancelsActiveItemTransitionBeforeDisposal() => + InvokeOnMainThreadAsync(async () => + { + using var handler = new TestableShellRenderer(); + var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + var elementProperty = typeof(ShellHandler).GetProperty(nameof(ShellHandler.Element), BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(currentRendererField); + Assert.NotNull(elementProperty); + + var shell = new Shell(); + shell.Items.Add(new ContentPage()); + shell.Items.Add(new ContentPage()); + shell.CurrentItem = shell.Items[1]; + elementProperty.SetValue(handler, shell); + + var oldRenderer = new TrackedShellItemRenderer { ShellItem = shell.Items[0] }; + var newRenderer = new TrackedShellItemRenderer { ShellItem = shell.Items[1] }; + var transition = new ControlledShellItemTransition(); + handler.ShellItemTransition = transition; + currentRendererField.SetValue(handler, oldRenderer); + + var setCurrentItem = handler.SetCurrentShellItemControllerForTestAsync(newRenderer); + await transition.Started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.NotEmpty(newRenderer.ViewController.View.Layer.AnimationKeys); + + ((IElementHandler)handler).DisconnectHandler(); + + var completedTask = await Task.WhenAny(setCurrentItem, Task.Delay(TimeSpan.FromSeconds(1))); + transition.Complete(); + await setCurrentItem; + + Assert.Same(setCurrentItem, completedTask); + Assert.False(newRenderer.HadAnimationsAtDispose); + Assert.Equal(1, oldRenderer.DisconnectCount); + Assert.Equal(1, oldRenderer.DisposeCount); + Assert.Equal(1, newRenderer.DisconnectCount); + Assert.Equal(1, newRenderer.DisposeCount); + }); + + [Fact(DisplayName = "Disconnect Shell Disposes All Pending Item Renderers")] + public Task DisconnectShellDisposesAllPendingItemRenderers() => + InvokeOnMainThreadAsync(async () => + { + using var handler = new TestableShellRenderer(); + var activeTransitionField = typeof(ShellHandler).GetField("_activeTransition", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(activeTransitionField); + + var transition = new TaskCompletionSource(); + activeTransitionField.SetValue(handler, transition.Task); + + var firstRenderer = new TrackedShellItemRenderer(); + var secondRenderer = new TrackedShellItemRenderer(); + var firstTransition = handler.SetCurrentShellItemControllerForTestAsync(firstRenderer); + var secondTransition = handler.SetCurrentShellItemControllerForTestAsync(secondRenderer); + + ((IElementHandler)handler).DisconnectHandler(); + transition.SetResult(true); + await Task.WhenAll(firstTransition, secondTransition); + + Assert.Equal(1, firstRenderer.DisconnectCount); + Assert.Equal(1, firstRenderer.DisposeCount); + Assert.Equal(1, secondRenderer.DisconnectCount); + Assert.Equal(1, secondRenderer.DisposeCount); + }); + + [Fact(DisplayName = "Stale Shell Flyout Background Image Is Disposed")] + public async Task StaleShellFlyoutBackgroundImageIsDisposed() + { + SetupBuilder(); + var imageSource = new DelayedImageSource(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + shell.FlyoutBackgroundImage = imageSource; + }); + var imageSourceServiceProvider = MauiContext.Services.GetRequiredService(); + var imageSourceService = imageSourceServiceProvider.GetRequiredImageSourceService(); + var delayedImageSourceService = Assert.IsType(imageSourceService); + + await CreateHandlerAndAddToWindow(shell, async _ => + { + try + { + Assert.True(await Task.Run(() => delayedImageSourceService.Starting.WaitOne(TimeSpan.FromSeconds(5)))); + shell.FlyoutBackgroundImage = null; + } + finally + { + delayedImageSourceService.DoWork.Set(); + } + + Assert.True(await Task.Run(() => delayedImageSourceService.Disposed.WaitOne(TimeSpan.FromSeconds(5)))); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static ShellSectionDelegateReferences CreateShellSectionDelegateReferences() + { + var renderer = new ShellSectionRenderer(new TestShellContext()); + renderer.LoadViewIfNeeded(); + + var references = new ShellSectionDelegateReferences( + new WeakReference(renderer), + renderer.Delegate, + renderer.InteractivePopGestureRecognizer.Delegate); + + ((IDisconnectable)renderer).Disconnect(); + return references; + } + + readonly record struct ShellSectionDelegateReferences( + WeakReference Renderer, + object NavigationDelegate, + object GestureDelegate); + + sealed class TestShellContext : IShellContext + { + public bool AllowFlyoutGesture => true; + public IShellItemRenderer CurrentShellItemRenderer => null; + public Shell Shell { get; } = new(); + public IMauiContext MauiContext => null; + + public IShellNavBarAppearanceTracker CreateNavBarAppearanceTracker() => null; + public IShellPageRendererTracker CreatePageRendererTracker() => null; + public IShellFlyoutContentRenderer CreateShellFlyoutContentRenderer() => null; + public IShellSearchResultsRenderer CreateShellSearchResultsRenderer() => null; + public IShellSectionRenderer CreateShellSectionRenderer(ShellSection shellSection) => null; + public IShellTabBarAppearanceTracker CreateTabBarAppearanceTracker() => null; + } + + sealed class TestableShellRenderer : ShellHandler + { + public Task CurrentItemChangedTask { get; private set; } = Task.CompletedTask; + public IShellItemTransition ShellItemTransition { get; set; } + + public Task SetCurrentShellItemControllerForTestAsync(IShellItemRenderer renderer) => + SetCurrentShellItemControllerAsync(renderer); + + protected override IShellItemTransition CreateShellItemTransition() => + ShellItemTransition ?? base.CreateShellItemTransition(); + + protected override void OnCurrentItemChanged() + { + CurrentItemChangedTask = OnCurrentItemChangedAsync(); + } + } + + sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable + { + public int DisconnectCount { get; private set; } + public int DisposeCount { get; private set; } + public bool HadAnimationsAtDispose { get; private set; } + public UIViewController ParentAtDispose { get; private set; } + public UIView SuperviewAtDispose { get; private set; } + public ShellItem ShellItem { get; set; } + public UIViewController ViewController { get; } = new UIViewController(); + + public void Disconnect() => DisconnectCount++; + + public void Dispose() + { + HadAnimationsAtDispose = ViewController.ViewIfLoaded?.Layer.AnimationKeys?.Length > 0; + ParentAtDispose = ViewController.ParentViewController; + SuperviewAtDispose = ViewController.ViewIfLoaded?.Superview; + DisposeCount++; + ViewController.Dispose(); + } + } + + sealed class ControlledShellItemTransition : IShellItemTransition + { + readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Transition(IShellItemRenderer oldRenderer, IShellItemRenderer newRenderer) + { + using var animation = CABasicAnimation.FromKeyPath("opacity"); + animation.From = NSNumber.FromDouble(0); + animation.To = NSNumber.FromDouble(1); + animation.Duration = 60; + newRenderer.ViewController.View.Layer.AddAnimation(animation, "active-shell-item-transition"); + Started.TrySetResult(true); + return _completion.Task; + } + + public void Complete() => _completion.TrySetResult(true); + } + + sealed class TestableShellFlyoutRenderer : ShellFlyoutRenderer + { + public void DisposeForTest(bool disposing) => base.Dispose(disposing); + } + + interface IDelayedImageSource : IImageSource + { + } + + sealed class DelayedImageSource : ImageSource, IDelayedImageSource + { + } + + sealed class DelayedImageSourceService : IImageSourceService + { + public AutoResetEvent Starting { get; } = new(false); + public AutoResetEvent DoWork { get; } = new(false); + public AutoResetEvent Disposed { get; } = new(false); + + public Task> GetImageAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) => + GetImageAsync((IDelayedImageSource)imageSource, scale, cancellationToken); + + public async Task> GetImageAsync( + IDelayedImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + Starting.Set(); + // The test needs the canceled load to produce a stale result, but the wait must remain bounded. + if (!await Task.Run(() => DoWork.WaitOne(TimeSpan.FromSeconds(30)))) + throw new TimeoutException("Timed out waiting to continue the delayed image load."); + + var image = await Microsoft.Maui.ApplicationModel.MainThread.InvokeOnMainThreadAsync(() => new UIImage()); + return new ImageSourceServiceResult(image, () => + { + image.Dispose(); + Disposed.Set(); + }); + } + } + async Task TapToSelect(ContentPage page) { var shellContent = page.Parent as ShellContent; diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index c39e4e15d9e0..fd4b1e4504b9 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -459,6 +460,118 @@ sealed record ShapePointsLeakProbeResult( WeakReference PlatformViewReference, PointCollection RootedOriginalPoints); +#if IOS || MACCATALYST + [Fact("TableView Source Replacement Unsubscribes Previous Source")] + public async Task TableViewSourceReplacementUnsubscribesPreviousSource() + { + SetupBuilder(); + + await InvokeOnMainThreadAsync(() => + { +#pragma warning disable CS0618 // Type or member is obsolete + var tableView = new TableView(new TableRoot()); + var renderer = CreateHandler(tableView); +#pragma warning restore CS0618 // Type or member is obsolete + var platformView = (UIKit.UITableView)((IElementHandler)renderer).PlatformView; + var previousSource = platformView.Source; + var initialSubscribers = GetModelChangedSubscribers(tableView); + + Assert.NotNull(previousSource); + Assert.Single(initialSubscribers); + previousSource.NumberOfSections(platformView); + var previousSourceGestureCount = platformView.GestureRecognizers?.Length ?? 0; +#pragma warning disable CS0618 // Type or member is obsolete + tableView.HasUnevenRows = true; +#pragma warning restore CS0618 // Type or member is obsolete + + var replacementSubscribers = GetModelChangedSubscribers(tableView); + var replacementSource = platformView.Source; + replacementSource.NumberOfSections(platformView); + Assert.NotSame(previousSource, platformView.Source); + Assert.Single(replacementSubscribers); + Assert.NotSame(initialSubscribers[0].Target, replacementSubscribers[0].Target); + Assert.Equal(previousSourceGestureCount, platformView.GestureRecognizers?.Length ?? 0); + + renderer.Dispose(); + }); + } + + [Fact("ContextActionsCell Disposal Unsubscribes Cell")] + public async Task ContextActionsCellDisposalUnsubscribesCell() + { + await InvokeOnMainThreadAsync(() => + { +#pragma warning disable CS0618 // Type or member is obsolete + var cell = new ViewCell(); + cell.ContextActions.Add(new MenuItem { Text = "Action" }); +#pragma warning restore CS0618 // Type or member is obsolete + using var tableView = new UIKit.UITableView(); + using var nativeCell = new CellTableViewCell(UIKit.UITableViewCellStyle.Default, "ContextActionsCellTest") + { + Cell = cell + }; + var contextActionsCell = new ContextActionsCell(); + + contextActionsCell.Update(tableView, cell, nativeCell); + Assert.Contains(GetPropertyChangedSubscribers(cell), subscriber => ReferenceEquals(subscriber.Target, contextActionsCell)); + + contextActionsCell.Dispose(); + Assert.DoesNotContain(GetPropertyChangedSubscribers(cell), subscriber => ReferenceEquals(subscriber.Target, contextActionsCell)); + Assert.Same(cell, ((Microsoft.Maui.Controls.Compatibility.INativeElementView)contextActionsCell).Element); + contextActionsCell.SizeThatFits(new CoreGraphics.CGSize(100, 44)); + }); + } + + [Fact("Secondary Toolbar Item Disposal Unsubscribes After Custom View Replacement")] + public async Task SecondaryToolbarItemDisposalUnsubscribesAfterCustomViewReplacement() + { + await InvokeOnMainThreadAsync(() => + { + var item = new ToolbarItem + { + Order = ToolbarItemOrder.Secondary + }; + var nativeItem = Microsoft.Maui.Controls.Compatibility.Platform.iOS.ToolbarItemExtensions.ToUIBarButtonItem(item); + using var originalContent = Assert.IsAssignableFrom(nativeItem.CustomView); + using var replacementView = new UIKit.UIView(); + var activationCount = 0; + item.Command = new Command(() => activationCount++); + + Assert.Contains(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); + originalContent.SendActionForControlEvents(UIKit.UIControlEvent.TouchUpInside); + Assert.Equal(1, activationCount); + + nativeItem.CustomView = replacementView; + item.Text = "Updated"; + item.IconImageSource = new FileImageSource(); + item.IsEnabled = false; + Assert.True(originalContent.Enabled); + Assert.Same(replacementView, nativeItem.CustomView); + nativeItem.Dispose(); + + Assert.DoesNotContain(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); + originalContent.SendActionForControlEvents(UIKit.UIControlEvent.TouchUpInside); + Assert.Equal(1, activationCount); + }); + } + + static Delegate[] GetModelChangedSubscribers(object tableView) + { +#pragma warning disable CS0618 // Type or member is obsolete + var eventField = typeof(TableView).GetField("ModelChanged", BindingFlags.Instance | BindingFlags.NonPublic); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.NotNull(eventField); + return (eventField.GetValue(tableView) as MulticastDelegate)?.GetInvocationList() ?? []; + } + + static Delegate[] GetPropertyChangedSubscribers(BindableObject bindable) + { + var eventField = typeof(BindableObject).GetField("PropertyChanged", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(eventField); + return (eventField.GetValue(bindable) as MulticastDelegate)?.GetInvocationList() ?? []; + } +#endif + [Theory("CollectionView Header/Footer Doesn't Leak")] [InlineData(typeof(CollectionView))] #if IOS || MACCATALYST