File: Compatibility\Handlers\Shell\iOS\ShellFlyoutRenderer.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 Foundation;
using MediaPlayer;
using Microsoft.Maui.Controls.Platform;
using ObjCRuntime;
using UIKit;
 
namespace Microsoft.Maui.Controls.Platform.Compatibility
{
	public class ShellFlyoutRenderer : UIViewController, IShellFlyoutRenderer, IFlyoutBehaviorObserver, IAppearanceObserver
	{
		#region IAppearanceObserver
 
		void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance)
		{
			if (appearance == null)
			{
				_backdropBrush = Brush.Default;
			}
			else
			{
				_backdropBrush = appearance.FlyoutBackdrop;
 
				if (SlideFlyoutTransition?.UpdateFlyoutSize(appearance.FlyoutHeight, appearance.FlyoutWidth) ==
					true)
				{
					if (_layoutOccured)
						LayoutSidebar(false, true);
				}
			}
 
			UpdateTapoffViewBackgroundColor();
		}
 
		#endregion IAppearanceObserver
 
		#region IShellFlyoutRenderer
 
		UIView IShellFlyoutRenderer.View => View;
 
		UIViewController IShellFlyoutRenderer.ViewController => this;
 
		public override bool PrefersHomeIndicatorAutoHidden => Detail.PrefersHomeIndicatorAutoHidden;
 
		public override bool PrefersStatusBarHidden() => Detail.PrefersStatusBarHidden();
 
		public override UIStatusBarAnimation PreferredStatusBarUpdateAnimation => Detail.PreferredStatusBarUpdateAnimation;
 
		void IShellFlyoutRenderer.AttachFlyout(IShellContext context, UIViewController content)
		{
			Context = context;
			Shell = Context.Shell;
			Detail = content;
 
			Shell.PropertyChanged += OnShellPropertyChanged;
 
			PanGestureRecognizer = new UIPanGestureRecognizer(HandlePanGesture);
			PanGestureRecognizer.ShouldRecognizeSimultaneously += (a, b) =>
			{
				// This handles tapping outside the open flyout
				if (a is UIPanGestureRecognizer pr && pr.State == UIGestureRecognizerState.Failed &&
					b is UITapGestureRecognizer && b.State == UIGestureRecognizerState.Ended && IsOpen)
				{
					IsOpen = false;
					LayoutSidebar(true);
				}
 
				return false;
			};
 
			PanGestureRecognizer.ShouldReceiveTouch += (sender, touch) =>
			{
				if (!context.AllowFlyoutGesture || _flyoutBehavior != FlyoutBehavior.Flyout)
					return false;
				var view = View;
				CGPoint loc = touch.LocationInView(View);
				if (touch.View is UISlider ||
					touch.View is MPVolumeView ||
					IsSwipeView(touch.View) ||
					(loc.X > view.Frame.Width * 0.1 && !IsOpen))
					return false;
 
				return true;
			};
 
			ShellController.AddAppearanceObserver(this, Shell);
			IsOpen = Shell.FlyoutIsPresented;
		}
 
		bool IsSwipeView(UIView view)
		{
			if (view == null)
				return false;
 
			// TODO MAUI
			//if (view is SwipeView)
			//return true;
 
			return IsSwipeView(view.Superview);
		}
 
		#endregion IShellFlyoutRenderer
 
		#region IFlyoutBehaviorObserver
 
		void IFlyoutBehaviorObserver.OnFlyoutBehaviorChanged(FlyoutBehavior behavior)
		{
			_flyoutBehavior = behavior;
			if (behavior == FlyoutBehavior.Locked)
				IsOpen = true;
			else if (behavior == FlyoutBehavior.Disabled)
				IsOpen = false;
			LayoutSidebar(false);
			UpdateFlyoutAccessibility();
		}
 
		#endregion IFlyoutBehaviorObserver
 
		const string FlyoutAnimationName = "Flyout";
		bool _disposed;
		FlyoutBehavior _flyoutBehavior;
		bool _gestureActive;
		bool _isOpen;
		UIViewPropertyAnimator _flyoutAnimation;
		Brush _backdropBrush;
		bool _layoutOccured;
 
		public UIViewAnimationCurve AnimationCurve { get; set; } = UIViewAnimationCurve.EaseOut;
 
		public int AnimationDuration { get; set; } = 250;
 
		double AnimationDurationInSeconds => ((double)AnimationDuration) / 1000.0;
 
		public IShellFlyoutTransition FlyoutTransition
		{
			get => _flyoutTransition;
			set
			{
				_flyoutTransition = value;
				SlideFlyoutTransition = value as SlideFlyoutTransition;
			}
		}
 
		SlideFlyoutTransition SlideFlyoutTransition { get; set; }
 
		IShellContext Context { get; set; }
 
		UIViewController Detail { get; set; }
 
		IShellFlyoutContentRenderer Flyout { get; set; }
 
		bool IsOpen
		{
			get { return _isOpen; }
			set
			{
				if (_isOpen == value)
					return;
 
				_isOpen = value;
				Shell.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, value);
				UpdateFlyoutAccessibility();
			}
		}
 
		void UpdateFlyoutAccessibility()
		{
			bool flyoutElementsHidden = false;
			bool detailsElementsHidden = false;
 
			switch (_flyoutBehavior)
			{
				case FlyoutBehavior.Flyout:
					flyoutElementsHidden = !IsOpen;
					detailsElementsHidden = IsOpen;
 
					break;
 
				case FlyoutBehavior.Locked:
					flyoutElementsHidden = false;
					detailsElementsHidden = false;
 
					break;
 
				case FlyoutBehavior.Disabled:
					flyoutElementsHidden = true;
					detailsElementsHidden = false;
 
					break;
			}
 
			if (Flyout?.ViewController?.View != null)
				Flyout.ViewController.View.AccessibilityElementsHidden = flyoutElementsHidden;
 
			if (Detail?.View != null)
				Detail.View.AccessibilityElementsHidden = detailsElementsHidden;
		}
 
		UIPanGestureRecognizer PanGestureRecognizer { get; set; }
 
		Shell Shell { get; set; }
 
		IShellController ShellController => Shell;
 
		UIView TapoffView { get; set; }
 
		public override void ViewDidLayoutSubviews()
		{
			base.ViewDidLayoutSubviews();
 
			if (_flyoutAnimation == null)
				LayoutSidebar(false);
		}
 
		public override void ViewWillAppear(bool animated)
		{
			UpdateFlowDirection();
			base.ViewWillAppear(animated);
		}
 
		public override void ViewDidLoad()
		{
			base.ViewDidLoad();
 
			AddChildViewController(Detail);
			View.AddSubview(Detail.View);
 
			Flyout = Context.CreateShellFlyoutContentRenderer();
			AddChildViewController(Flyout.ViewController);
			View.AddSubview(Flyout.ViewController.View);
			View.AddGestureRecognizer(PanGestureRecognizer);
 
			((IShellController)Shell).AddFlyoutBehaviorObserver(this);
			UpdateFlowDirection();
			UpdateFlyoutAccessibility();
		}
 
		protected override void Dispose(bool disposing)
		{
			base.Dispose(disposing);
 
			if (disposing)
			{
				if (!_disposed)
				{
					ShellController.RemoveAppearanceObserver(this);
 
					_disposed = true;
 
					Shell.PropertyChanged -= OnShellPropertyChanged;
					((IShellController)Shell).RemoveFlyoutBehaviorObserver(this);
 
					Context = null;
					Shell = null;
					Detail = null;
				}
			}
		}
 
		protected virtual void OnShellPropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			if (e.PropertyName == Shell.FlyoutIsPresentedProperty.PropertyName)
			{
				var isPresented = Shell.FlyoutIsPresented;
				if (IsOpen != isPresented)
				{
					IsOpen = isPresented;
					LayoutSidebar(true, true);
				}
			}
			else if (e.PropertyName == VisualElement.FlowDirectionProperty.PropertyName)
			{
				UpdateFlowDirection(true);
			}
		}
 
		void UpdateFlowDirection(bool readdViews = false)
		{
			var originalValue = View.SemanticContentAttribute;
			var originalFlyoutValue = Flyout?.ViewController?.View?.SemanticContentAttribute;
			var originalDetailValue = Detail?.View?.SemanticContentAttribute;
 
			View.UpdateFlowDirection(Shell);
			Flyout?.ViewController?.View.UpdateFlowDirection(Shell);
			Detail?.View?.UpdateFlowDirection(Shell);
 
			bool update = originalValue == View.SemanticContentAttribute;
			update = Flyout?.ViewController?.View?.SemanticContentAttribute == originalFlyoutValue || update;
			update = Detail?.View?.SemanticContentAttribute == originalDetailValue || update;
 
			if (update && readdViews)
			{
				if (Detail?.View != null)
					Detail.View.RemoveFromSuperview();
 
				if (Flyout?.ViewController?.View != null)
					Flyout.ViewController.View.RemoveFromSuperview();
 
				if (Detail?.View != null)
					View.AddSubview(Detail.View);
 
				if (Flyout?.ViewController?.View != null)
					View.AddSubview(Flyout.ViewController.View);
			}
		}
 
		void UpdateTapoffViewBackgroundColor()
		{
			if (TapoffView == null)
				return;
 
			TapoffView.UpdateBackground(_backdropBrush);
 
			if (Brush.IsNullOrEmpty(_backdropBrush))
			{
				if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad)
				{
					TapoffView.BackgroundColor = UIColor.Clear;
				}
				else
				{
					TapoffView.BackgroundColor = Microsoft.Maui.Platform.ColorExtensions.BackgroundColor.ColorWithAlpha(0.5f);
				}
			}
		}
 
		void AddTapoffView()
		{
			if (TapoffView != null)
				return;
 
			TapoffView = new UIView(View.Bounds);
			TapoffView.Layer.Opacity = 0;
			View.InsertSubviewBelow(TapoffView, Flyout.ViewController.View);
			UpdateTapoffViewBackgroundColor();
			var recognizer = new UITapGestureRecognizer(t =>
			{
				IsOpen = false;
				LayoutSidebar(true);
			});
 
			TapoffView.AddGestureRecognizer(recognizer);
		}
 
		private IShellFlyoutTransition _flyoutTransition;
 
		public UIView NativeView => throw new NotImplementedException();
 
		public UIViewController ViewController => throw new NotImplementedException();
 
		void HandlePanGesture(UIPanGestureRecognizer pan)
		{
			var translation = pan.TranslationInView(View).X;
			double openProgress = 0;
			double openLimit = Flyout.ViewController.View.Frame.Width;
 
			if (IsOpen)
			{
				openProgress = 1 - (-translation / openLimit);
			}
			else
			{
				openProgress = translation / openLimit;
			}
 
			openProgress = Math.Min(Math.Max(openProgress, 0.0), 1.0);
			var openPixels = openLimit * openProgress;
 
			switch (pan.State)
			{
				case UIGestureRecognizerState.Changed:
					_gestureActive = true;
 
					if (TapoffView == null)
						AddTapoffView();
 
					if (_flyoutAnimation != null)
					{
						TapoffView.Layer.RemoveAllAnimations();
						_flyoutAnimation?.StopAnimation(true);
						_flyoutAnimation = null;
					}
 
					TapoffView.Layer.Opacity = (float)openProgress;
 
					FlyoutTransition.LayoutViews(View.Bounds, (nfloat)openProgress, Flyout.ViewController.View, Detail.View, _flyoutBehavior);
					break;
 
				case UIGestureRecognizerState.Ended:
					_gestureActive = false;
					if (IsOpen)
					{
						if (openProgress < .8)
							IsOpen = false;
					}
					else
					{
						if (openProgress > 0.2)
						{
							IsOpen = true;
						}
					}
					LayoutSidebar(true);
					break;
			}
		}
 
		void LayoutSidebar(bool animate, bool cancelExisting = false)
		{
			_layoutOccured = true;
			if (_gestureActive)
				return;
 
			if (cancelExisting && _flyoutAnimation != null)
			{
				_flyoutAnimation.StopAnimation(true);
				_flyoutAnimation = null;
			}
 
			if (animate && _flyoutAnimation != null)
				return;
 
			if (!animate && _flyoutAnimation != null)
			{
				_flyoutAnimation.StopAnimation(true);
				_flyoutAnimation = null;
			}
 
			if (IsOpen)
				UpdateTapoffView();
 
			if (animate && TapoffView != null)
			{
				var tapOffViewAnimation = CABasicAnimation.FromKeyPath(@"opacity");
				tapOffViewAnimation.BeginTime = 0;
				tapOffViewAnimation.Duration = AnimationDurationInSeconds;
				tapOffViewAnimation.SetFrom(NSNumber.FromFloat(TapoffView.Layer.Opacity));
				tapOffViewAnimation.SetTo(NSNumber.FromFloat(IsOpen ? 1 : 0));
				tapOffViewAnimation.FillMode = CAFillMode.Forwards;
				tapOffViewAnimation.RemovedOnCompletion = false;
 
				_flyoutAnimation = new UIViewPropertyAnimator(AnimationDurationInSeconds, UIViewAnimationCurve.EaseOut, () =>
				{
					FlyoutTransition.LayoutViews(View.Bounds, IsOpen ? 1 : 0, Flyout.ViewController.View, Detail.View, _flyoutBehavior);
 
					if (TapoffView != null)
					{
						TapoffView.Layer.AddAnimation(tapOffViewAnimation, "opacity");
					}
				});
 
				_flyoutAnimation.AddCompletion((p) =>
				{
					if (p == UIViewAnimatingPosition.End)
					{
						if (TapoffView != null)
						{
							TapoffView.Layer.Opacity = IsOpen ? 1 : 0;
							TapoffView.Layer.RemoveAllAnimations();
						}
 
						UpdateTapoffView();
						_flyoutAnimation = null;
 
						UIAccessibility.PostNotification(UIAccessibilityPostNotification.ScreenChanged, null);
					}
				});
 
				_flyoutAnimation.StartAnimation();
				View.LayoutIfNeeded();
			}
			else if (_flyoutAnimation == null)
			{
				FlyoutTransition.LayoutViews(View.Bounds, IsOpen ? 1 : 0, Flyout.ViewController.View, Detail.View, _flyoutBehavior);
				UpdateTapoffView();
 
				if (TapoffView != null)
				{
					TapoffView.Layer.Opacity = IsOpen ? 1 : 0;
				}
 
				UIAccessibility.PostNotification(UIAccessibilityPostNotification.ScreenChanged, null);
			}
 
			void UpdateTapoffView()
			{
				if (IsOpen && _flyoutBehavior == FlyoutBehavior.Flyout)
					AddTapoffView();
				else
					RemoveTapoffView();
			}
		}
 
		void RemoveTapoffView()
		{
			if (TapoffView == null)
				return;
 
			TapoffView.RemoveFromSuperview();
			TapoffView = null;
		}
	}
}