File: Shell\ShellContent.cs
Web Access
Project: src\src\Controls\src\Core\Controls.Core.csproj (Microsoft.Maui.Controls)
#nullable disable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Reflection;
using Microsoft.Maui.Controls.Internals;
 
namespace Microsoft.Maui.Controls
{
	/// <include file="../../../docs/Microsoft.Maui.Controls/ShellContent.xml" path="Type[@FullName='Microsoft.Maui.Controls.ShellContent']/Docs/*" />
	[ContentProperty(nameof(Content))]
	[TypeConverter(typeof(ShellContentConverter))]
	public class ShellContent : BaseShellItem, IShellContentController, IVisualTreeElement
	{
		static readonly BindablePropertyKey MenuItemsPropertyKey =
			BindableProperty.CreateReadOnly(nameof(MenuItems), typeof(MenuItemCollection), typeof(ShellContent), null,
				defaultValueCreator: bo => new MenuItemCollection());
 
		/// <summary>Bindable property for <see cref="MenuItems"/>.</summary>
		public static readonly BindableProperty MenuItemsProperty = MenuItemsPropertyKey.BindableProperty;
 
		/// <summary>Bindable property for <see cref="Content"/>.</summary>
		public static readonly BindableProperty ContentProperty =
			BindableProperty.Create(nameof(Content), typeof(object), typeof(ShellContent), null, BindingMode.OneTime, propertyChanged: OnContentChanged);
 
		/// <summary>Bindable property for <see cref="ContentTemplate"/>.</summary>
		public static readonly BindableProperty ContentTemplateProperty =
			BindableProperty.Create(nameof(ContentTemplate), typeof(DataTemplate), typeof(ShellContent), null, BindingMode.OneTime);
 
		internal static readonly BindableProperty QueryAttributesProperty =
			BindableProperty.CreateAttached("QueryAttributes", typeof(ShellRouteParameters), typeof(ShellContent), defaultValue: null, propertyChanged: OnQueryAttributesPropertyChanged);
 
		/// <include file="../../../docs/Microsoft.Maui.Controls/ShellContent.xml" path="//Member[@MemberName='MenuItems']/Docs/*" />
		public MenuItemCollection MenuItems => (MenuItemCollection)GetValue(MenuItemsProperty);
 
		/// <include file="../../../docs/Microsoft.Maui.Controls/ShellContent.xml" path="//Member[@MemberName='Content']/Docs/*" />
		public object Content
		{
			get => GetValue(ContentProperty);
			set => SetValue(ContentProperty, value);
		}
 
		/// <include file="../../../docs/Microsoft.Maui.Controls/ShellContent.xml" path="//Member[@MemberName='ContentTemplate']/Docs/*" />
		public DataTemplate ContentTemplate
		{
			get => (DataTemplate)GetValue(ContentTemplateProperty);
			set => SetValue(ContentTemplateProperty, value);
		}
 
		Page IShellContentController.Page => ContentCache;
 
		EventHandler _isPageVisibleChanged;
		event EventHandler IShellContentController.IsPageVisibleChanged { add => _isPageVisibleChanged += value; remove => _isPageVisibleChanged -= value; }
 
		bool _createdViaService;
		Page IShellContentController.GetOrCreateContent()
		{
			var template = ContentTemplate;
			var content = Content;
 
			Page result = null;
			if (template is null)
			{
				if (content is Page page)
					result = page;
			}
			else
			{
				if (template.Type is not null)
				{
					template.LoadTemplate = () =>
					{
						var services = Parent?.FindMauiContext()?.Services;
						if (services is not null)
						{
							var result = services.GetService(template.Type);
							if (result is not null)
							{
								_createdViaService = true;
								return result;
							}
						}
 
						_createdViaService = false;
						return Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(services, template.Type);
					};
				}
 
				result = ContentCache ?? (Page)template.CreateContent(content, this);
				ContentCache = result;
			}
 
			if (result is null)
				throw new InvalidOperationException($"No Content found for {nameof(ShellContent)}, Title:{Title}, Route {Route}");
 
			if (result is TabbedPage)
				throw new NotSupportedException($"Shell is currently not compatible with TabbedPage. Please use TabBar, Tab or switch to using NavigationPage for your {Application.Current}.MainPage");
 
			if (result is FlyoutPage)
				throw new NotSupportedException("Shell is currently not compatible with FlyoutPage.");
 
			if (result is NavigationPage)
				throw new NotSupportedException("Shell is currently not compatible with NavigationPage. Shell has Navigation built in and doesn't require a NavigationPage.");
 
			if (GetValue(QueryAttributesProperty) is ShellRouteParameters delayedQueryParams)
				result.SetValue(QueryAttributesProperty, delayedQueryParams);
 
			return result;
		}
 
		void IShellContentController.RecyclePage(Page page)
		{
		}
 
		Page _contentCache;
 
		/// <include file="../../../docs/Microsoft.Maui.Controls/ShellContent.xml" path="//Member[@MemberName='.ctor']/Docs/*" />
		public ShellContent()
		{
			((INotifyCollectionChanged)MenuItems).CollectionChanged += MenuItemsCollectionChanged;
		}
 
		internal bool IsVisibleContent => Parent is ShellSection shellSection && shellSection.IsVisibleSection && shellSection.CurrentItem == this;
 
		internal override void SendDisappearing()
		{
			base.SendDisappearing();
			((ContentCache ?? Content) as Page)?.SendDisappearing();
		}
 
		internal override void SendAppearing()
		{
			// only fire Appearing when the Content Page exists on the ShellContent
			var content = ContentCache ?? Content;
			if (content == null)
				return;
 
			base.SendAppearing();
 
			SendPageAppearing((ContentCache ?? Content) as Page);
		}
 
		void SendPageAppearing(Page page)
		{
			if (page == null)
				return;
 
			if (page.Parent == null)
			{
				page.ParentSet += OnPresentedPageParentSet;
				void OnPresentedPageParentSet(object sender, EventArgs e)
				{
					this.FindParentOfType<Shell>().SendPageAppearing(page);
					(sender as Page).ParentSet -= OnPresentedPageParentSet;
				}
			}
			else if (IsVisibleContent && page.IsVisible)
			{
				this.FindParentOfType<Shell>().SendPageAppearing(page);
			}
		}
 
		protected override void OnChildAdded(Element child)
		{
			base.OnChildAdded(child);
			if (child is Page page)
			{
				page.PropertyChanged += OnPagePropertyChanged;
				_isPageVisibleChanged?.Invoke(this, EventArgs.Empty);
			}
		}
 
		protected override void OnChildRemoved(Element child, int oldLogicalIndex)
		{
			base.OnChildRemoved(child, oldLogicalIndex);
			if (child is Page page)
			{
				page.PropertyChanged -= OnPagePropertyChanged;
			}
		}
 
 
		void OnPagePropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			if (e.PropertyName == Page.IsVisibleProperty.PropertyName)
				_isPageVisibleChanged?.Invoke(this, EventArgs.Empty);
		}
 
		Page ContentCache
		{
			get => _contentCache;
			set
			{
				if (_contentCache == value)
					return;
 
				var oldCache = _contentCache;
				_contentCache = value;
				if (oldCache != null)
				{
					RemoveLogicalChild(oldCache);
					oldCache.Unloaded -= OnPageUnloaded;
				}
 
				if (value is not null && value.Parent != this)
				{
					AddLogicalChild(value);
 
					if (_createdViaService)
					{
						value.Unloaded += OnPageUnloaded;
					}
				}
 
				if (Parent is not null)
				{
					((ShellSection)Parent).UpdateDisplayedPage();
				}
			}
		}
 
		internal void EvaluateDisconnect()
		{
			if (!_createdViaService)
				return;
 
			// If the user has set the IsVisible property on this shell content to false
			bool disconnect = true;
 
			Shell shell = null;
 
      if (Parent is ShellSection shellSection &&
				shellSection.Parent is ShellItem shellItem &&
				shellItem.Parent is Shell shellInstance)
			{
				shell = shellInstance;
				disconnect = 
					!this.IsVisible || // user has set the IsVisible property to false
					(_contentCache is not null && !_contentCache.IsVisible) || // user has set IsVisible on the Page to false
					shell.CurrentItem != shellItem || // user has navigated to a different TabBar or a different FlyoutItem
					!shellSection.IsVisible || // user has set IsVisible on the ShellSection to false
					this.Window is null; // user has set the main page to a different shell instance
			}
 
			if (!disconnect)
			{
				shell?.NotifyFlyoutBehaviorObservers();
				return;
			}
 
			if (_contentCache is not null)
			{
				_contentCache.Unloaded -= OnPageUnloaded;
				RemoveLogicalChild(_contentCache);
			}
 
			_contentCache = null;
		}
 
		protected override void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null)
		{
			base.OnPropertyChanged(propertyName);
 
			if (propertyName == WindowProperty.PropertyName)
			{
				if (_contentCache?.IsLoaded == true)
					return;
 
				EvaluateDisconnect();
			}
		}
 
		void OnPageUnloaded(object sender, EventArgs e) => EvaluateDisconnect();
 
		public static implicit operator ShellContent(TemplatedPage page)
		{
			if (page.Parent != null)
			{
				return (ShellContent)page.Parent;
			}
 
			var shellContent = new ShellContent();
 
			var pageRoute = Routing.GetRoute(page);
 
			shellContent.Route = Routing.GenerateImplicitRoute(pageRoute);
 
			shellContent.Content = page;
			shellContent.SetBinding(TitleProperty, static (TemplatedPage page) => page.Title, BindingMode.OneWay, source: page);
			shellContent.SetBinding(IconProperty, static (TemplatedPage page) => page.IconImageSource, BindingMode.OneWay, source: page);
			shellContent.SetBinding(FlyoutIconProperty, static (TemplatedPage page) => page.IconImageSource, BindingMode.OneWay, source: page);
 
			return shellContent;
		}
 
		static void OnContentChanged(BindableObject bindable, object oldValue, object newValue)
		{
			var shellContent = (ShellContent)bindable;
			shellContent._createdViaService = false;
			// This check is wrong but will work for testing
			if (shellContent.ContentTemplate == null)
			{
				// deparent old item
				if (oldValue is Page oldElement)
				{
					shellContent.ContentCache = null;
				}
 
				if (newValue is Page newElement)
				{
					shellContent.ContentCache = newElement;
				}
				else if (newValue != null)
				{
					throw new InvalidOperationException($"{nameof(ShellContent)} {nameof(Content)} should be of type {nameof(Page)}. Title {shellContent?.Title}, Route {shellContent?.Route} ");
				}
			}
 
			if (shellContent.Parent?.Parent is ShellItem shellItem)
				shellItem.SendStructureChanged();
		}
 
		void MenuItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
		{
			if (e.NewItems != null)
				foreach (Element el in e.NewItems)
					OnChildAdded(el);
 
			if (e.OldItems != null)
				for (var i = 0; i < e.OldItems.Count; i++)
				{
					var el = (Element)e.OldItems[i];
					OnChildRemoved(el, e.OldStartingIndex + i);
				}
		}
 
		internal override void ApplyQueryAttributes(ShellRouteParameters query)
		{
			base.ApplyQueryAttributes(query);
 
			// If the query parameters are empty and this attribute wasn't previously set
			// That means there's no work to be done here.
			// An empty query is only valid if we've previously propagated
			// something to this bindable property
			if (query.Count == 0 && !this.IsSet(QueryAttributesProperty))
				return;
 
			SetValue(QueryAttributesProperty, query);
 
			if (ContentCache is BindableObject bindable)
				bindable.SetValue(QueryAttributesProperty, query);
		}
 
		static void OnQueryAttributesPropertyChanged(BindableObject bindable, object oldValue, object newValue)
		{
			ApplyQueryAttributes(bindable, newValue as ShellRouteParameters, oldValue as ShellRouteParameters);
		}
 
		static void ApplyQueryAttributes(object content, ShellRouteParameters query, ShellRouteParameters oldQuery)
		{
			query = query ?? new ShellRouteParameters();
			oldQuery = oldQuery ?? new ShellRouteParameters();
 
			if (content is IQueryAttributable attributable)
			{
				attributable
					.ApplyQueryAttributes(query.ToReadOnlyIfUsingShellNavigationQueryParameters());
			}
 
			if (content is BindableObject bindable && bindable.BindingContext != null && content != bindable.BindingContext)
				ApplyQueryAttributes(bindable.BindingContext, query, oldQuery);
 
			if (RuntimeFeature.IsQueryPropertyAttributeSupported)
			{
				var type = content.GetType();
				var queryPropertyAttributes = type.GetCustomAttributes(typeof(QueryPropertyAttribute), true);
				if (queryPropertyAttributes.Length == 0)
				{
					ClearQueryIfAppliedToPage(query, content);
					return;
				}
 
				foreach (QueryPropertyAttribute attrib in queryPropertyAttributes)
				{
					if (query.TryGetValue(attrib.QueryId, out var value))
					{
						PropertyInfo prop = type.GetRuntimeProperty(attrib.Name);
 
						if (prop != null && prop.CanWrite && prop.SetMethod.IsPublic)
						{
							if (prop.PropertyType == typeof(string))
							{
								if (value != null)
									value = global::System.Net.WebUtility.UrlDecode((string)value);
 
								prop.SetValue(content, value);
							}
							else
							{
								var castValue = Convert.ChangeType(value, prop.PropertyType);
								prop.SetValue(content, castValue);
							}
						}
					}
					else if (oldQuery.TryGetValue(attrib.QueryId, out var oldValue))
					{
						PropertyInfo prop = type.GetRuntimeProperty(attrib.Name);
 
						if (prop != null && prop.CanWrite && prop.SetMethod.IsPublic)
							prop.SetValue(content, null);
					}
				}
			}
 
			ClearQueryIfAppliedToPage(query, content);
 
			static void ClearQueryIfAppliedToPage(ShellRouteParameters query, object content)
			{
				// Once we've applied the attributes to ContentPage lets remove the 
				// parameters used during navigation
				if (content is ContentPage)
					query.ResetToQueryParameters();
			}
		}
 
		private sealed class ShellContentConverter : TypeConverter
		{
			public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
				=> sourceType == typeof(TemplatedPage);
 
			public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
				=> false;
 
			public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
			{
				if (value is TemplatedPage templatedPage)
				{
					return (ShellContent)templatedPage;
				}
 
				throw new NotSupportedException();
			}
 
			public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, Type destinationType)
			{
				throw new NotSupportedException();
			}
		}
	}
}