File: iOS\VisualElementTracker.cs
Web Access
Project: src\src\Compatibility\Core\src\Compatibility.csproj (Microsoft.Maui.Controls.Compatibility)
using System;
using System.ComponentModel;
using System.Threading;
using CoreAnimation;
using CoreGraphics;
using Microsoft.Maui.Controls.Internals;
using Microsoft.Maui.Controls.Platform;
using Microsoft.Maui.Graphics;
 
#if __MOBILE__
using ObjCRuntime;
using UIKit;
using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;
 
namespace Microsoft.Maui.Controls.Compatibility.Platform.iOS
#else

namespace Microsoft.Maui.Controls.Compatibility.Platform.MacOS
#endif
{
	public class VisualElementTracker : IDisposable
	{
		const string ClipShapeLayer = "ClipShapeLayer";
 
		readonly EventHandler<EventArg<VisualElement>> _batchCommittedHandler;
 
		readonly PropertyChangedEventHandler _propertyChangedHandler;
		readonly EventHandler _sizeChangedEventHandler;
		bool _disposed;
		VisualElement _element;
 
		// Track these by hand because the calls down into iOS are too expensive
		bool _isInteractive;
		Rect _lastBounds;
#if !__MOBILE__
		Rect _lastParentBounds;
#endif
		CALayer _layer;
		CGPoint _originalAnchor;
		int _updateCount;
 
		public VisualElementTracker(IVisualElementRenderer renderer) : this(renderer, true)
		{
		}
 
		public VisualElementTracker(IVisualElementRenderer renderer, bool trackFrame)
		{
			Renderer = renderer ?? throw new ArgumentNullException("renderer");
 
			_propertyChangedHandler = HandlePropertyChanged;
			_sizeChangedEventHandler = HandleSizeChanged;
			_batchCommittedHandler = HandleRedrawNeeded;
 
			TrackFrame = trackFrame;
			renderer.ElementChanged += OnRendererElementChanged;
			SetElement(null, renderer.Element);
		}
 
		bool TrackFrame { get; set; }
 
		IVisualElementRenderer Renderer { get; set; }
 
		public void Dispose()
		{
			Dispose(true);
		}
 
		public event EventHandler NativeControlUpdated;
 
		internal void Disconnect()
		{
			Disconnect(_element);
		}
 
		void Disconnect(VisualElement oldElement)
		{
			if (oldElement == null)
				return;
 
			oldElement.PropertyChanged -= _propertyChangedHandler;
			oldElement.SizeChanged -= _sizeChangedEventHandler;
			oldElement.BatchCommitted -= _batchCommittedHandler;
		}
 
		protected virtual void Dispose(bool disposing)
		{
			if (_disposed)
				return;
 
			_disposed = true;
 
			if (disposing)
			{
				SetElement(_element, null);
 
				if (_layer != null)
				{
					_layer.Dispose();
					_layer = null;
				}
 
				Renderer.ElementChanged -= OnRendererElementChanged;
				Renderer = null;
			}
		}
 
		void HandlePropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			if (TrackFrame && (e.PropertyName == VisualElement.XProperty.PropertyName ||
							   e.PropertyName == VisualElement.YProperty.PropertyName ||
							   e.PropertyName == VisualElement.WidthProperty.PropertyName ||
							   e.PropertyName == VisualElement.HeightProperty.PropertyName))
			{
				UpdateNativeControl();
			}
			else if (e.PropertyName == VisualElement.AnchorXProperty.PropertyName ||
				e.PropertyName == VisualElement.AnchorYProperty.PropertyName ||
				e.PropertyName == VisualElement.TranslationXProperty.PropertyName ||
				e.PropertyName == VisualElement.TranslationYProperty.PropertyName ||
				e.PropertyName == VisualElement.ScaleProperty.PropertyName ||
				e.PropertyName == VisualElement.ScaleXProperty.PropertyName ||
				e.PropertyName == VisualElement.ScaleYProperty.PropertyName ||
				e.PropertyName == VisualElement.RotationProperty.PropertyName ||
				e.PropertyName == VisualElement.RotationXProperty.PropertyName ||
				e.PropertyName == VisualElement.RotationYProperty.PropertyName ||
				e.PropertyName == VisualElement.IsVisibleProperty.PropertyName ||
				e.PropertyName == VisualElement.IsEnabledProperty.PropertyName ||
				e.PropertyName == VisualElement.InputTransparentProperty.PropertyName ||
				e.PropertyName == VisualElement.OpacityProperty.PropertyName ||
				e.PropertyName == Layout.CascadeInputTransparentProperty.PropertyName)
			{
				UpdateNativeControl(); // poorly optimized
			}
			else if (e.PropertyName == VisualElement.ClipProperty.PropertyName)
			{
				UpdateClip();
			}
		}
 
		void HandleRedrawNeeded(object sender, EventArgs e)
		{
			UpdateNativeControl();
		}
 
		void HandleSizeChanged(object sender, EventArgs e)
		{
			UpdateNativeControl();
		}
 
		void OnRendererElementChanged(object s, VisualElementChangedEventArgs e)
		{
			if (_element == e.NewElement)
				return;
 
			SetElement(_element, e.NewElement);
		}
 
		void OnUpdateNativeControl(CALayer caLayer)
		{
			var view = Renderer.Element;
			var uiview = Renderer.NativeView;
 
			if (view == null || view.Batched)
				return;
 
			bool shouldInteract;
 
			if (view is Layout layout)
			{
				if (layout.InputTransparent)
				{
					shouldInteract = !layout.CascadeInputTransparent;
				}
				else
				{
					shouldInteract = layout.IsEnabled;
				}
			}
			else
			{
				shouldInteract = !view.InputTransparent && view.IsEnabled;
			}
 
			if (_isInteractive != shouldInteract)
			{
#if __MOBILE__
				uiview.UserInteractionEnabled = shouldInteract;
#endif
				_isInteractive = shouldInteract;
			}
 
			var boundsChanged = _lastBounds != view.Bounds && TrackFrame;
#if !__MOBILE__
			var viewParent = view.RealParent as VisualElement;
			var parentBoundsChanged = _lastParentBounds != (viewParent == null ? Rectangle.Zero : viewParent.Bounds);
#else
			var thread = !boundsChanged && !caLayer.Frame.IsEmpty && Application.Current?.OnThisPlatform()?.GetHandleControlUpdatesOnMainThread() == false;
#endif
			var anchorX = (float)view.AnchorX;
			var anchorY = (float)view.AnchorY;
			var translationX = (float)view.TranslationX;
			var translationY = (float)view.TranslationY;
			var rotationX = (float)view.RotationX;
			var rotationY = (float)view.RotationY;
			var rotation = (float)view.Rotation;
			var scale = (float)view.Scale;
			var scaleX = (float)view.ScaleX * scale;
			var scaleY = (float)view.ScaleY * scale;
			var width = (float)view.Width;
			var height = (float)view.Height;
			var x = (float)view.X + (float)CompressedLayout.GetHeadlessOffset(view).X;
			var y = (float)view.Y + (float)CompressedLayout.GetHeadlessOffset(view).Y;
			var opacity = (float)view.Opacity;
			var isVisible = view.IsVisible;
 
			var updateTarget = Interlocked.Increment(ref _updateCount);
 
			void update()
			{
				if (updateTarget != _updateCount)
					return;
#if __MOBILE__
				var visualElement = view;
#endif
				var parent = view.RealParent;
 
				var shouldRelayoutSublayers = false;
				if (isVisible && caLayer.Hidden)
				{
#if !__MOBILE__
					uiview.Hidden = false;
#endif
					caLayer.Hidden = false;
					if (!caLayer.Frame.IsEmpty)
						shouldRelayoutSublayers = true;
				}
 
				if (!isVisible && !caLayer.Hidden)
				{
#if !__MOBILE__
					uiview.Hidden = true;
#endif
					caLayer.Hidden = true;
					shouldRelayoutSublayers = true;
				}
 
				// ripe for optimization
				var transform = CATransform3D.Identity;
 
#if __MOBILE__
				bool shouldUpdate = (!(visualElement is Page) || visualElement is ContentPage) && width > 0 && height > 0 && parent != null && boundsChanged;
#else
				// We don't care if it's a page or not since bounds of the window can change
				// TODO: Find why it doesn't work to check if the parentsBounds changed  and remove true;
				parentBoundsChanged = true;
				bool shouldUpdate = width > 0 && height > 0 && parent != null && (boundsChanged || parentBoundsChanged);
#endif
				// Dont ever attempt to actually change the layout of a Page unless it is a ContentPage
				// iOS is a really big fan of you not actually modifying the View's of the UIViewControllers
				if (shouldUpdate && TrackFrame)
				{
#if __MOBILE__
					var target = new RectF(x, y, width, height);
#else
					var visualParent = parent as VisualElement;
					float newY = visualParent == null ? y : Math.Max(0, (float)(visualParent.Height - y - view.Height));
					var target = new RectF(x, newY, width, height);
#endif
 
					// must reset transform prior to setting frame...
					if (caLayer.AnchorPoint != _originalAnchor)
						caLayer.AnchorPoint = _originalAnchor;
 
					caLayer.Transform = transform;
					uiview.Frame = target;
					if (shouldRelayoutSublayers)
						caLayer.LayoutSublayers();
				}
				else if (width <= 0 || height <= 0)
				{
					//TODO: FInd why it doesn't work
#if __MOBILE__
					caLayer.Hidden = true;
#endif
					return;
				}
#if !__MOBILE__
				// Y-axe on macos is inverted				
				translationY = -translationY;
				anchorY = 1 - anchorY;
 
				// rotation direction on macos also inverted
				rotationX = -rotationX;
				rotationY = -rotationY;
				rotation = -rotation;
 
				//otherwise scaled/rotated image clipped by parent bounds
				caLayer.MasksToBounds = false;
#endif
				caLayer.AnchorPoint = new PointF(anchorX, anchorY);
				caLayer.Opacity = opacity;
				const double epsilon = 0.001;
 
#if !__MOBILE__
				// fix position, position in macos is also relative to anchor point
				// but it's (0,0) by default, so we don't need to substract 0.5
				transform = transform.Translate(anchorX * width, 0, 0);
				transform = transform.Translate(0, anchorY * height, 0);
#else
				// position is relative to anchor point
				if (Math.Abs(anchorX - .5) > epsilon)
					transform = transform.Translate((anchorX - .5f) * width, 0, 0);
				if (Math.Abs(anchorY - .5) > epsilon)
					transform = transform.Translate(0, (anchorY - .5f) * height, 0);
#endif
 
				if (Math.Abs(translationX) > epsilon || Math.Abs(translationY) > epsilon)
					transform = transform.Translate(translationX, translationY, 0);
 
				// not just an optimization, iOS will not "pixel align" a view which has M34 set
				if (Math.Abs(rotationY % 180) > epsilon || Math.Abs(rotationX % 180) > epsilon)
					transform.M34 = 1.0f / -400f;
 
				if (Math.Abs(rotationX % 360) > epsilon)
					transform = transform.Rotate(rotationX * MathF.PI / 180.0f, 1.0f, 0.0f, 0.0f);
				if (Math.Abs(rotationY % 360) > epsilon)
					transform = transform.Rotate(rotationY * MathF.PI / 180.0f, 0.0f, 1.0f, 0.0f);
 
				transform = transform.Rotate(rotation * MathF.PI / 180.0f, 0.0f, 0.0f, 1.0f);
 
				if (Math.Abs(scaleX - 1) > epsilon || Math.Abs(scaleY - 1) > epsilon)
					transform = transform.Scale(scaleX, scaleY, scale);
 
				if (Foundation.NSThread.IsMain)
				{
					caLayer.Transform = transform;
					return;
				}
				CoreFoundation.DispatchQueue.MainQueue.DispatchAsync(() =>
				{
					caLayer.Transform = transform;
				});
			}
 
#if __MOBILE__
			if (thread)
				view.Dispatcher.DispatchIfRequired(update);
			else
				update();
#else
			update();
#endif
 
			_lastBounds = view.Bounds;
#if !__MOBILE__
			_lastParentBounds = viewParent?.Bounds ?? Rectangle.Zero;
#endif
		}
 
		void SetElement(VisualElement oldElement, VisualElement newElement)
		{
			if (oldElement != null)
			{
				Disconnect(oldElement);
			}
 
			_element = newElement;
 
			if (newElement != null)
			{
				newElement.BatchCommitted += _batchCommittedHandler;
				newElement.SizeChanged += _sizeChangedEventHandler;
				newElement.PropertyChanged += _propertyChangedHandler;
 
				UpdateNativeControl();
			}
		}
 
		[PortHandler("Partially ported")]
		void UpdateNativeControl()
		{
			Performance.Start(out string reference);
 
			if (_disposed)
				return;
 
			if (_layer == null)
			{
#if !__MOBILE__
				Renderer.NativeView.WantsLayer = true;
#endif
				_layer = Renderer.NativeView.Layer;
#if __MOBILE__
				_isInteractive = Renderer.NativeView.UserInteractionEnabled;
#endif
 
				_originalAnchor = _layer.AnchorPoint;
			}
 
			OnUpdateNativeControl(_layer);
 
			UpdateClip();
 
			NativeControlUpdated?.Invoke(this, EventArgs.Empty);
			Performance.Stop(reference);
		}
 
		void UpdateClip()
		{
			if (!ShouldUpdateClip())
				return;
 
			var element = Renderer.Element;
			var uiview = Renderer.NativeView;
 
			var formsGeometry = element.Clip;
			var nativeGeometry = formsGeometry.ToCGPath();
 
			var maskLayer = new StaticCAShapeLayer
			{
				Name = ClipShapeLayer,
				Path = nativeGeometry.Data,
				FillRule = nativeGeometry.IsNonzeroFillRule ? CAShapeLayer.FillRuleNonZero : CAShapeLayer.FillRuleEvenOdd
			};
#if __MOBILE__
			if (Forms.IsiOS11OrNewer)
			{
				if (formsGeometry != null)
					uiview.Layer.Mask = maskLayer;
				else
					uiview.Layer.Mask = null;
			}
			else
			{
				if (formsGeometry != null)
				{
					var maskView = new UIView
					{
						Frame = uiview.Frame,
						BackgroundColor = UIColor.Black
					};
 
					maskView.Layer.Mask = maskLayer;
					uiview.MaskView = maskView;
				}
				else
					uiview.MaskView = null;
			}
#else
			if (formsGeometry != null)
				uiview.Layer.Mask = maskLayer;
			else
				uiview.Layer.Mask = null;
#endif
		}
 
		bool ShouldUpdateClip()
		{
			var element = Renderer?.Element;
			var uiview = Renderer?.NativeView;
 
			if (element == null || uiview == null)
				return false;
 
			bool hasClipShapeLayer = false;
#if __MOBILE__
			if (Forms.IsiOS11OrNewer)
				hasClipShapeLayer =
					uiview.Layer != null &&
					uiview.Layer.Mask != null &&
					uiview.Layer.Mask?.Name == ClipShapeLayer;
			else
			{
				hasClipShapeLayer =
					uiview.MaskView != null &&
					uiview.MaskView.Layer.Mask != null &&
					uiview.MaskView.Layer.Mask?.Name == ClipShapeLayer;
			}
#else
			hasClipShapeLayer =
				uiview.Layer != null &&
				uiview.Layer.Mask != null &&
				uiview.Layer.Mask?.Name == ClipShapeLayer;
#endif
 
			var formsGeometry = element.Clip;
 
			if (formsGeometry != null)
				return true;
 
			if (formsGeometry == null && hasClipShapeLayer)
				return true;
 
			return false;
		}
	}
}