File: Compatibility\Handlers\Shell\iOS\ShellFlyoutLayoutManager.cs
Web Access
Project: src\src\Controls\src\Core\Controls.Core.csproj (Microsoft.Maui.Controls)
#nullable disable
using System;
using System.ComponentModel;
using CoreAnimation;
using CoreGraphics;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Graphics.Platform;
using Microsoft.Maui.Primitives;
using UIKit;
 
namespace Microsoft.Maui.Controls.Platform.Compatibility
{
	class ShellFlyoutLayoutManager
	{
		double _headerOffset = 0;
		UIView _contentView;
		UIScrollView ScrollView { get; set; }
		UIContainerView _headerView;
		UIView _footerView;
		double _headerSize;
		readonly IShellContext _context;
		Action removeScrolledEvent;
 
		// This is the height of the AppBar on Android, which is used
		// as the default minimum height on `Android`.
		// We use the same value here on iOS/Catalyst to stay consistent between the two platforms.
		// Users can set a MinimumHeightRequest if they want this value to be smaller.
		const double MinimumCollapsedHeaderHeight = 56;
 
		IShellController ShellController => _context.Shell;
		public ShellFlyoutLayoutManager(IShellContext context)
		{
			_context = context;
			_context.Shell.PropertyChanged += OnShellPropertyChanged;
			ShellController.StructureChanged += OnStructureChanged;
		}
 
		public void SetCustomContent(View content)
		{
			if (content == Content)
				return;
 
			removeScrolledEvent?.Invoke();
			removeScrolledEvent = null;
 
			if (Content is not null)
			{
				var oldRenderer = (IPlatformViewHandler)Content.Handler;
				var oldContentView = ContentView;
				var oldContent = Content;
 
				Content = null;
				ContentView = null;
				oldContent.Handler = null;
				oldContentView?.RemoveFromSuperview();
				oldRenderer?.DisconnectHandler();
			}
			// If the user hasn't defined custom content then only the ContentView is set
			else if (ContentView is not null)
			{
				var oldContentView = ContentView;
				ContentView = null;
				oldContentView.RemoveFromSuperview();
			}
 
			Content = content;
			if (Content is not null)
			{
				var renderer = Content.ToHandler(_context.Shell.FindMauiContext());
				ContentView = renderer.PlatformView;
				ContentView.ClipsToBounds = true;
 
				// not sure if there's a more efficient way to do this
				// I can test the native control to see if it inherits from UIScrollView
				// But the CollectionViewRenderer doesn't inherit from UIScrollView
				if (Content is ScrollView sv)
				{
					sv.Scrolled += ScrollViewScrolled;
					removeScrolledEvent = () => sv.Scrolled -= ScrollViewScrolled;
					void ScrollViewScrolled(object sender, ScrolledEventArgs e) =>
						OnScrolled((nfloat)sv.ScrollY);
				}
				else if (Content is CollectionView cv)
				{
					cv.Scrolled += CollectionViewScrolled;
					removeScrolledEvent = () => cv.Scrolled -= CollectionViewScrolled;
					void CollectionViewScrolled(object sender, ItemsViewScrolledEventArgs e) =>
						// OnScrolled expects a negative offset based on the header height since it is based on ScrollView.ScrollY
						// So we fix it up here by subtracting the header height
						OnScrolled((nfloat)(e.VerticalOffset - MeasuredHeaderViewHeightWithMargin));
				}
				else if (Content is ListView lv)
				{
					lv.Scrolled += ListViewScrolled;
					removeScrolledEvent = () => lv.Scrolled -= ListViewScrolled;
					void ListViewScrolled(object sender, ScrolledEventArgs e) =>
						OnScrolled((nfloat)e.ScrollY);
				}
			}
		}
 
		public void SetDefaultContent(UIView view)
		{
			if (ContentView == view)
				return;
 
			SetCustomContent(null);
			ContentView = view;
		}
 
		public View Content
		{
			get;
			private set;
		}
 
		public UIView ContentView
		{
			get
			{
				return _contentView;
			}
			private set
			{
				_contentView = value;
 
				ScrollView = null;
 
				if (ContentView is UIScrollView sv)
					ScrollView = sv;
				else if (ContentView is IPlatformViewHandler ver && ver.PlatformView is UIScrollView uIScroll)
					ScrollView = uIScroll;
				else if (Content is ItemsView && ContentView.Subviews.Length > 0 && ContentView.Subviews[0] is UICollectionView cv)
					ScrollView = cv;
 
				if (ScrollView is not null && (OperatingSystem.IsIOSVersionAtLeast(11) || OperatingSystem.IsMacCatalystVersionAtLeast(11)
#if TVOS
					|| OperatingSystem.IsTvOSVersionAtLeast(11)
#endif
				))
				{
					ScrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Never;
				}
 
				UpdateHeaderSize();
			}
		}
 
		public virtual UIContainerView HeaderView
		{
			get => _headerView;
			set
			{
				if (_headerView == value)
					return;
 
				if (_headerView is not null)
					_headerView.HeaderSizeChanged -= OnHeaderViewMeasureChanged;
 
				_headerView = value;
 
				if (_headerView is not null)
					_headerView.HeaderSizeChanged += OnHeaderViewMeasureChanged;
 
				UpdateHeaderSize();
			}
		}
 
		public virtual UIView FooterView
		{
			get => _footerView;
			set
			{
				if (_footerView == value)
					return;
 
				_footerView = value;
				UpdateHeaderSize();
			}
		}
 
		void OnHeaderViewMeasureChanged(object sender, EventArgs e)
		{
			if (HeaderView is null || ContentView?.Superview is null)
				return;
 
			HeaderView.SizeThatFits(new CGSize(ContentView.Superview.Frame.Width, double.PositiveInfinity));
			UpdateHeaderSize();
		}
 
		internal void UpdateHeaderSize()
		{
			if (HeaderView is null || ContentView?.Superview is null)
				return;
 
			// If the HeaderView hasn't been measured we need to measure it
			if (double.IsNaN(MeasuredHeaderViewHeightWithMargin))
			{
				HeaderView.SizeThatFits(new CGSize(ContentView.Superview.Frame.Width, double.PositiveInfinity));
			}
 
			SetHeaderContentInset();
			UpdateHeaderMaximumSize(ScrollView?.ContentOffset.Y);
			LayoutParallax();
		}
 
 
		internal void SetHeaderContentInset()
		{
			if (ScrollView is null)
				return;
 
			var offset = ScrollView.ContentInset.Top;
 
			if (HeaderView is not null)
			{
				if (double.IsNaN(MeasuredHeaderViewHeightWithNoMargin))
				{
					return;
				}
 
				// We take the measured header height without margin, since the margin is already accounted for in the positioning of the scroll view itself.
				ScrollView.ContentInset = new UIEdgeInsets((nfloat)Math.Max(HeaderMinimumHeight, MeasuredHeaderViewHeightWithNoMargin), 0, 0, 0);
			}
			else
			{
				ScrollView.ContentInset = new UIEdgeInsets(UIApplication.SharedApplication.GetSafeAreaInsetsForWindow().Top, 0, 0, 0);
			}
 
			offset -= ScrollView.ContentInset.Top;
 
			var yContentOffset = ScrollView.ContentOffset.Y;
			ScrollView.ContentOffset =
				new CGPoint(ScrollView.ContentOffset.X, yContentOffset + offset);
 
			UpdateVerticalScrollMode();
		}
 
		public void UpdateVerticalScrollMode()
		{
			if (ScrollView is null)
				return;
 
			switch (_context.Shell.FlyoutVerticalScrollMode)
			{
				case ScrollMode.Auto:
					ScrollView.ScrollEnabled = true;
					ScrollView.AlwaysBounceVertical = false;
					break;
				case ScrollMode.Enabled:
					ScrollView.ScrollEnabled = true;
					ScrollView.AlwaysBounceVertical = true;
					break;
				case ScrollMode.Disabled:
					ScrollView.ScrollEnabled = false;
					ScrollView.AlwaysBounceVertical = false;
					break;
			}
		}
 
		public void LayoutParallax()
		{
			var parent = ContentView?.Superview;
			if (parent is null)
			{
				return;
			}
 
			nfloat footerHeight = 0;
			if (FooterView is not null)
			{
				footerHeight = FooterView.Frame.Height;
			}
 
			LayoutHeader(parent.Frame);
			LayoutContent(parent.Bounds, footerHeight);
		}
 
		void LayoutHeader(CGRect parentFrame)
		{
			if (HeaderView is not null && !double.IsNaN(ArrangedHeaderViewHeightWithMargin))
			{
				nfloat safeArea = 0;
				if (ShouldHonorSafeArea(HeaderView.View))
				{
					// We add the safe area if margin is not explicitly set.
					safeArea = UIApplication.SharedApplication.GetSafeAreaInsetsForWindow().Top;
				}
 
				// For header's Y offset, we should only consider the safe area but not its margin, since it will be handled by MAUI's layout system.
				HeaderView.Frame = new CGRect(0, _headerOffset + safeArea, parentFrame.Width, ArrangedHeaderViewHeightWithMargin);
 
				if (_context.Shell.FlyoutHeaderBehavior == FlyoutHeaderBehavior.Scroll && HeaderViewTopVerticalOffset > 0 && _headerOffset < 0)
				{
					var headerHeight = Math.Max(HeaderMinimumHeight, ArrangedHeaderViewHeightWithMargin + _headerOffset);
					CAShapeLayer shapeLayer = new StaticCAShapeLayer();
					CGRect rect = new CGRect(0, _headerOffset * -1, parentFrame.Width, headerHeight);
					var path = CGPath.FromRect(rect);
					shapeLayer.Path = path;
					HeaderView.Layer.Mask = shapeLayer;
				}
				else if (HeaderView.Layer.Mask is not null)
				{
					HeaderView.Layer.Mask = null;
				}
			}
		}
 
		void LayoutContent(CGRect parentBounds, nfloat footerHeight)
		{
			double contentYOffset = 0;
 
			if (ShouldHonorSafeArea(HeaderView?.View) ||
				(HeaderView is null && ShouldHonorSafeArea(Content)))
			{
				// We add the safe area if margin is not explicitly set. This matches the header behavior.
				contentYOffset += (float)UIApplication.SharedApplication.GetSafeAreaInsetsForWindow().Top;
			}
 
			if (HeaderView is not null)
			{
				if (ScrollView is null)
				{
					// The margin is already managed by MAUI's layout system, so we don't need to add it here and we just offset the content by the header's height.				
					contentYOffset += HeaderView.Frame.Height;
				}
				else
				{
					// For ScrollView, we need to consider the margin, but we should not consider the header height, since it should overlap with the scroll view. 
					// The content inset is already managed by SetHeaderContentInset.
					contentYOffset += HeaderView.View.Margin.VerticalThickness;
				}
			}
 
			var contentFrame = new Rect(parentBounds.X, contentYOffset, parentBounds.Width, parentBounds.Height - contentYOffset - footerHeight);
			if (Content is null)
			{
				ContentView.Frame = contentFrame.AsCGRect();
			}
			else
			{
				(Content as IView)?.Arrange(contentFrame);
			}
		}
 
		bool ShouldHonorSafeArea(View view)
		{
			return view != null
				&& !view.IsSet(View.MarginProperty)
				&& !(view is ISafeAreaView sav && sav.IgnoreSafeArea);
		}
 
		void OnStructureChanged(object sender, EventArgs e) => UpdateVerticalScrollMode();
 
		void OnShellPropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			if (e.Is(Shell.FlyoutHeaderBehaviorProperty))
			{
				SetHeaderContentInset();
				LayoutParallax();
			}
			else if (e.Is(Shell.FlyoutVerticalScrollModeProperty))
				UpdateVerticalScrollMode();
		}
 
		public void ViewDidLoad()
		{
			UpdateHeaderSize();
		}
 
		public void OnScrolled(nfloat contentOffsetY)
		{
			var headerBehavior = _context.Shell.FlyoutHeaderBehavior;
 
			switch (headerBehavior)
			{
				case FlyoutHeaderBehavior.Default:
				case FlyoutHeaderBehavior.Fixed:
					ArrangedHeaderViewHeightWithMargin = MeasuredHeaderViewHeightWithMargin;
					_headerOffset = 0;
					break;
 
				case FlyoutHeaderBehavior.Scroll:
					ArrangedHeaderViewHeightWithMargin = MeasuredHeaderViewHeightWithMargin;
					_headerOffset = Math.Min(0, -(MeasuredHeaderViewHeightWithNoMargin + contentOffsetY));
					break;
 
				case FlyoutHeaderBehavior.CollapseOnScroll:
					UpdateHeaderMaximumSize(contentOffsetY);
					_headerOffset = 0;
					break;
			}
 
			LayoutParallax();
		}
 
		void UpdateHeaderMaximumSize(nfloat? contentOffsetY)
		{
			if (HeaderView is not null)
			{
				if (ScrollView is not null && contentOffsetY is not null && _context.Shell.FlyoutHeaderBehavior == FlyoutHeaderBehavior.CollapseOnScroll)
				{
					// Neither HeaderMinimumHeight nor contentOffsetY (calculated in SetHeaderContentInset) contain the header's margin, so we need to add it here.
					ArrangedHeaderViewHeightWithMargin = Math.Max(HeaderMinimumHeight, -contentOffsetY.Value) + HeaderView.View.Margin.VerticalThickness;
				}
				else
				{
					ArrangedHeaderViewHeightWithMargin = MeasuredHeaderViewHeightWithMargin;
				}
			}
		}
 
		double ArrangedHeaderViewHeightWithMargin
		{
			get => _headerSize;
			set
			{
				if (HeaderView is not null &&
					HeaderView.Height != value)
				{
					HeaderView.Height = value;
				}
 
				_headerSize = value;
			}
		}
 
		double MeasuredHeaderViewHeightWithMargin =>
			HeaderView?.MeasuredHeight ?? 0;
 
		double MeasuredHeaderViewHeightWithNoMargin
		{
			get
			{
				if (!double.IsNaN(MeasuredHeaderViewHeightWithMargin) && HeaderView?.View.IsSet(View.MarginProperty) == true)
				{
					return MeasuredHeaderViewHeightWithMargin - HeaderView.View.Margin.VerticalThickness;
				}
 
				return MeasuredHeaderViewHeightWithMargin;
			}
		}
 
		double HeaderMinimumHeight
		{
			get
			{
				if (HeaderView is null ||
					HeaderView.View.MinimumHeightRequest == -1 ||
					HeaderView.View.MinimumHeightRequest == Dimension.Unset)
				{
					if (_context.Shell.FlyoutHeaderBehavior == FlyoutHeaderBehavior.CollapseOnScroll)
						return MinimumCollapsedHeaderHeight;
					else
						return 0;
				}
 
				return HeaderView.View.MinimumHeightRequest;
			}
		}
 
		/// <summary>
		/// This represents the header's top vertical offset caused by either Margin.Top or SafeArea.Top.
		/// It should not be assumed as margin, because if Margin.Top = 0, it will return SafeAre.Top.
		/// </summary>
		double HeaderViewTopVerticalOffset => HeaderView?.Margin.Top ?? 0;
 
		public void TearDown()
		{
			_context.Shell.PropertyChanged -= OnShellPropertyChanged;
			ShellController.StructureChanged -= OnStructureChanged;
			SetCustomContent(null);
			ContentView = null;
			HeaderView = null;
			FooterView = null;
		}
	}
}