// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Maui.Graphics; using SkiaSharp; namespace Microsoft.Maui.Platform; /// /// Shell provides a common navigation experience for MAUI applications. /// Supports flyout menu, tabs, and URI-based navigation. /// public class SkiaShell : SkiaLayoutView { #region BindableProperties /// /// Bindable property for FlyoutIsPresented. /// public static readonly BindableProperty FlyoutIsPresentedProperty = BindableProperty.Create( nameof(FlyoutIsPresented), typeof(bool), typeof(SkiaShell), false, BindingMode.OneWay, propertyChanged: (b, o, n) => ((SkiaShell)b).OnFlyoutIsPresentedChanged((bool)n)); /// /// Bindable property for FlyoutBehavior. /// public static readonly BindableProperty FlyoutBehaviorProperty = BindableProperty.Create( nameof(FlyoutBehavior), typeof(ShellFlyoutBehavior), typeof(SkiaShell), ShellFlyoutBehavior.Flyout, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).Invalidate()); /// /// Bindable property for FlyoutWidth. /// public static readonly BindableProperty FlyoutWidthProperty = BindableProperty.Create( nameof(FlyoutWidth), typeof(float), typeof(SkiaShell), 280f, BindingMode.TwoWay, coerceValue: (b, v) => Math.Max(100f, (float)v), propertyChanged: (b, o, n) => ((SkiaShell)b).Invalidate()); /// /// Bindable property for FlyoutBackgroundColor. /// public static readonly BindableProperty FlyoutBackgroundColorProperty = BindableProperty.Create( nameof(FlyoutBackgroundColor), typeof(Color), typeof(SkiaShell), Colors.White, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).OnFlyoutBackgroundColorChanged()); /// /// Bindable property for FlyoutTextColor. /// public static readonly BindableProperty FlyoutTextColorProperty = BindableProperty.Create( nameof(FlyoutTextColor), typeof(Color), typeof(SkiaShell), Color.FromRgb(33, 33, 33), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).OnFlyoutTextColorChanged()); /// /// Bindable property for NavBarBackgroundColor. /// public static readonly BindableProperty NavBarBackgroundColorProperty = BindableProperty.Create( nameof(NavBarBackgroundColor), typeof(Color), typeof(SkiaShell), Color.FromRgb(33, 150, 243), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).OnNavBarBackgroundColorChanged()); /// /// Bindable property for NavBarTextColor. /// public static readonly BindableProperty NavBarTextColorProperty = BindableProperty.Create( nameof(NavBarTextColor), typeof(Color), typeof(SkiaShell), Colors.White, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).OnNavBarTextColorChanged()); /// /// Bindable property for NavBarHeight. /// public static readonly BindableProperty NavBarHeightProperty = BindableProperty.Create( nameof(NavBarHeight), typeof(float), typeof(SkiaShell), 56f, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure()); /// /// Bindable property for TabBarHeight. /// public static readonly BindableProperty TabBarHeightProperty = BindableProperty.Create( nameof(TabBarHeight), typeof(float), typeof(SkiaShell), 56f, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure()); /// /// Bindable property for NavBarIsVisible. /// public static readonly BindableProperty NavBarIsVisibleProperty = BindableProperty.Create( nameof(NavBarIsVisible), typeof(bool), typeof(SkiaShell), true, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure()); /// /// Bindable property for TabBarIsVisible. /// public static readonly BindableProperty TabBarIsVisibleProperty = BindableProperty.Create( nameof(TabBarIsVisible), typeof(bool), typeof(SkiaShell), false, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure()); /// /// Bindable property for ContentPadding. /// public static readonly BindableProperty ContentPaddingProperty = BindableProperty.Create( nameof(ContentPadding), typeof(float), typeof(SkiaShell), 0f, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure()); /// /// Bindable property for ContentBackgroundColor. /// public static readonly BindableProperty ContentBackgroundColorProperty = BindableProperty.Create( nameof(ContentBackgroundColor), typeof(Color), typeof(SkiaShell), Color.FromRgb(250, 250, 250), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).OnContentBackgroundColorChanged()); /// /// Bindable property for Title. /// public static readonly BindableProperty TitleProperty = BindableProperty.Create( nameof(Title), typeof(string), typeof(SkiaShell), string.Empty, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaShell)b).Invalidate()); #endregion private readonly List _sections = new(); private SkiaView? _currentContent; private float _flyoutAnimationProgress = 0f; private int _selectedSectionIndex = 0; private int _selectedItemIndex = 0; // Navigation stack for push/pop navigation private readonly Stack<(SkiaView Content, string Title)> _navigationStack = new(); private float _flyoutScrollOffset; private readonly Dictionary> _registeredRoutes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _routeTitles = new(StringComparer.OrdinalIgnoreCase); // Internal SKColor fields for rendering private SKColor _flyoutBackgroundColorSK = SkiaTheme.BackgroundWhiteSK; private SKColor _flyoutTextColorSK = SkiaTheme.TextPrimarySK; private SKColor _navBarBackgroundColorSK = SkiaTheme.PrimarySK; private SKColor _navBarTextColorSK = SkiaTheme.BackgroundWhiteSK; private SKColor _contentBackgroundColorSK = SkiaTheme.Gray50SK; private void OnFlyoutBackgroundColorChanged() { _flyoutBackgroundColorSK = FlyoutBackgroundColor?.ToSKColor() ?? SkiaTheme.BackgroundWhiteSK; Invalidate(); } private void OnFlyoutTextColorChanged() { _flyoutTextColorSK = FlyoutTextColor?.ToSKColor() ?? SkiaTheme.TextPrimarySK; Invalidate(); } private void OnNavBarBackgroundColorChanged() { _navBarBackgroundColorSK = NavBarBackgroundColor?.ToSKColor() ?? SkiaTheme.PrimarySK; Invalidate(); } private void OnNavBarTextColorChanged() { _navBarTextColorSK = NavBarTextColor?.ToSKColor() ?? SkiaTheme.BackgroundWhiteSK; Invalidate(); } private void OnContentBackgroundColorChanged() { _contentBackgroundColorSK = ContentBackgroundColor?.ToSKColor() ?? SkiaTheme.Gray50SK; Invalidate(); } private void OnFlyoutIsPresentedChanged(bool newValue) { _flyoutAnimationProgress = newValue ? 1f : 0f; FlyoutIsPresentedChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } /// /// Gets or sets whether the flyout is presented. /// public bool FlyoutIsPresented { get => (bool)GetValue(FlyoutIsPresentedProperty); set => SetValue(FlyoutIsPresentedProperty, value); } /// /// Gets or sets the flyout behavior. /// public ShellFlyoutBehavior FlyoutBehavior { get => (ShellFlyoutBehavior)GetValue(FlyoutBehaviorProperty); set => SetValue(FlyoutBehaviorProperty, value); } /// /// Gets or sets the flyout width. /// public float FlyoutWidth { get => (float)GetValue(FlyoutWidthProperty); set => SetValue(FlyoutWidthProperty, value); } /// /// Background color of the flyout. /// public Color? FlyoutBackgroundColor { get => (Color?)GetValue(FlyoutBackgroundColorProperty); set => SetValue(FlyoutBackgroundColorProperty, value); } /// /// Text color in the flyout. /// public Color? FlyoutTextColor { get => (Color?)GetValue(FlyoutTextColorProperty); set => SetValue(FlyoutTextColorProperty, value); } /// /// Optional header view in the flyout. /// public SkiaView? FlyoutHeaderView { get; set; } /// /// Height of the flyout header. /// public float FlyoutHeaderHeight { get; set; } = 140f; /// /// Optional footer text in the flyout. /// public string? FlyoutFooterText { get; set; } /// /// Height of the flyout footer. /// public float FlyoutFooterHeight { get; set; } = 40f; /// /// Background color of the navigation bar. /// public Color? NavBarBackgroundColor { get => (Color?)GetValue(NavBarBackgroundColorProperty); set => SetValue(NavBarBackgroundColorProperty, value); } /// /// Text color of the navigation bar title. /// public Color? NavBarTextColor { get => (Color?)GetValue(NavBarTextColorProperty); set => SetValue(NavBarTextColorProperty, value); } /// /// Height of the navigation bar. /// public float NavBarHeight { get => (float)GetValue(NavBarHeightProperty); set => SetValue(NavBarHeightProperty, value); } /// /// Height of the tab bar (when using bottom tabs). /// public float TabBarHeight { get => (float)GetValue(TabBarHeightProperty); set => SetValue(TabBarHeightProperty, value); } /// /// Gets or sets whether the navigation bar is visible. /// public bool NavBarIsVisible { get => (bool)GetValue(NavBarIsVisibleProperty); set => SetValue(NavBarIsVisibleProperty, value); } /// /// Gets or sets whether the tab bar is visible. /// public bool TabBarIsVisible { get => (bool)GetValue(TabBarIsVisibleProperty); set => SetValue(TabBarIsVisibleProperty, value); } /// /// Gets or sets the padding applied to page content. /// public float ContentPadding { get => (float)GetValue(ContentPaddingProperty); set => SetValue(ContentPaddingProperty, value); } /// /// Background color of the content area. /// public Color? ContentBackgroundColor { get => (Color?)GetValue(ContentBackgroundColorProperty); set => SetValue(ContentBackgroundColorProperty, value); } /// /// Current title displayed in the navigation bar. /// public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); } /// /// The sections in this shell. /// public IReadOnlyList Sections => _sections; /// /// Gets the currently selected section index. /// public int CurrentSectionIndex => _selectedSectionIndex; /// /// Reference to the MAUI Shell this view represents. /// public Shell? MauiShell { get; set; } /// /// Callback to render content from a ShellContent. /// public Func? ContentRenderer { get; set; } /// /// Callback to refresh shell colors. /// public Action? ColorRefresher { get; set; } /// /// Event raised when FlyoutIsPresented changes. /// public event EventHandler? FlyoutIsPresentedChanged; /// /// Event raised when navigation occurs. /// public event EventHandler? Navigated; /// /// Adds a section to the shell. /// public void AddSection(ShellSection section) { _sections.Add(section); if (_sections.Count == 1) { NavigateToSection(0, 0); } Invalidate(); } /// /// Removes a section from the shell. /// public void RemoveSection(ShellSection section) { _sections.Remove(section); Invalidate(); } /// /// Navigates to a specific section and item. /// public void NavigateToSection(int sectionIndex, int itemIndex = 0) { if (sectionIndex < 0 || sectionIndex >= _sections.Count) return; var section = _sections[sectionIndex]; if (itemIndex < 0 || itemIndex >= section.Items.Count) return; // Clear navigation stack when navigating to a new section _navigationStack.Clear(); _selectedSectionIndex = sectionIndex; _selectedItemIndex = itemIndex; var item = section.Items[itemIndex]; SetCurrentContent(item.Content); Title = item.Title; Navigated?.Invoke(this, new ShellNavigationEventArgs(section, item)); Invalidate(); } /// /// Refreshes the shell theme and re-renders all pages. /// public void RefreshTheme() { Console.WriteLine("[SkiaShell] RefreshTheme called - refreshing all pages"); if (MauiShell != null && ColorRefresher != null) { Console.WriteLine("[SkiaShell] Refreshing shell colors"); ColorRefresher(this, MauiShell); } if (ContentRenderer != null) { foreach (var section in _sections) { foreach (var item in section.Items) { if (item.MauiShellContent != null) { Console.WriteLine("[SkiaShell] Re-rendering: " + item.Title); var skiaView = ContentRenderer(item.MauiShellContent); if (skiaView != null) { item.Content = skiaView; } } } } } // Only update current content if there are no pushed pages on the navigation stack // Pushed pages are handled separately by LinuxApplication.RefreshViewTheme if (_navigationStack.Count == 0 && _selectedSectionIndex >= 0 && _selectedSectionIndex < _sections.Count) { var section = _sections[_selectedSectionIndex]; if (_selectedItemIndex >= 0 && _selectedItemIndex < section.Items.Count) { var item = section.Items[_selectedItemIndex]; SetCurrentContent(item.Content); } } InvalidateMeasure(); Invalidate(); } /// /// Navigates using a URI route. /// public void GoToAsync(string route) { GoToAsync(route, null); } /// /// Navigates using a URI route with parameters. /// public void GoToAsync(string route, IDictionary? parameters) { if (string.IsNullOrEmpty(route)) return; string routePath = route; Dictionary queryParams = new Dictionary(); int queryIndex = route.IndexOf('?'); if (queryIndex >= 0) { routePath = route.Substring(0, queryIndex); queryParams = ParseQueryString(route.Substring(queryIndex + 1)); } Dictionary allParams = new Dictionary(); foreach (var kvp in queryParams) { allParams[kvp.Key] = kvp.Value; } if (parameters != null) { foreach (var kvp in parameters) { allParams[kvp.Key] = kvp.Value; } } var parts = routePath.TrimStart('/').Split('/'); if (parts.Length == 0) return; // Check registered routes first if (_registeredRoutes.TryGetValue(routePath.TrimStart('/'), out Func? factory)) { var view = factory(); if (view != null) { ApplyQueryParameters(view, allParams); PushAsync(view, GetRouteTitle(routePath.TrimStart('/'))); return; } } // Find matching section for (int i = 0; i < _sections.Count; i++) { var section = _sections[i]; if (!section.Route.Equals(parts[0], StringComparison.OrdinalIgnoreCase)) continue; if (parts.Length > 1) { // Find matching item for (int j = 0; j < section.Items.Count; j++) { if (section.Items[j].Route.Equals(parts[1], StringComparison.OrdinalIgnoreCase)) { NavigateToSection(i, j); if (section.Items[j].Content != null && allParams.Count > 0) { ApplyQueryParameters(section.Items[j].Content!, allParams); } return; } } } NavigateToSection(i); if (section.Items.Count > 0 && section.Items[0].Content != null && allParams.Count > 0) { ApplyQueryParameters(section.Items[0].Content!, allParams); } break; } } private static Dictionary ParseQueryString(string queryString) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrEmpty(queryString)) return result; var pairs = queryString.Split('&', StringSplitOptions.RemoveEmptyEntries); foreach (var pair in pairs) { var parts = pair.Split('=', 2); if (parts.Length == 2) { result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]); } else if (parts.Length == 1) { result[Uri.UnescapeDataString(parts[0])] = string.Empty; } } return result; } private static void ApplyQueryParameters(SkiaView content, IDictionary parameters) { if (parameters.Count == 0) return; if (content is ISkiaQueryAttributable attributable) { attributable.ApplyQueryAttributes(parameters); } var type = content.GetType(); foreach (var param in parameters) { var prop = type.GetProperty(param.Key, System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); if (prop != null && prop.CanWrite) { try { var value = Convert.ChangeType(param.Value, prop.PropertyType); prop.SetValue(content, value); } catch { } } } } /// /// Registers a route with a content factory. /// public void RegisterRoute(string route, Func contentFactory, string? title = null) { var key = route.TrimStart('/'); _registeredRoutes[key] = contentFactory; if (!string.IsNullOrEmpty(title)) { _routeTitles[key] = title; } } /// /// Unregisters a route. /// public void UnregisterRoute(string route) { var key = route.TrimStart('/'); _registeredRoutes.Remove(key); _routeTitles.Remove(key); } private string GetRouteTitle(string route) { if (_routeTitles.TryGetValue(route, out string? title)) { return title; } return route.Split('/').LastOrDefault() ?? route; } /// /// Gets whether there are pages on the navigation stack. /// public bool CanGoBack => _navigationStack.Count > 0; /// /// Gets the current navigation stack depth. /// public int NavigationStackDepth => _navigationStack.Count; /// /// Pushes a new page onto the navigation stack. /// public void PushAsync(SkiaView page, string title) { // Save current content to stack if (_currentContent != null) { _navigationStack.Push((_currentContent, Title)); } // Set new content SetCurrentContent(page); Title = title; Invalidate(); } /// /// Pops the current page from the navigation stack. /// public bool PopAsync() { if (_navigationStack.Count == 0) return false; var (previousContent, previousTitle) = _navigationStack.Pop(); SetCurrentContent(previousContent); Title = previousTitle; Invalidate(); return true; } /// /// Pops all pages from the navigation stack, returning to the root. /// public void PopToRootAsync() { if (_navigationStack.Count == 0) return; // Get the root content (SkiaView Content, string Title) root = default; while (_navigationStack.Count > 0) { root = _navigationStack.Pop(); } SetCurrentContent(root.Content); Title = root.Title ?? string.Empty; Invalidate(); } private void SetCurrentContent(SkiaView? content) { if (_currentContent != null) { RemoveChild(_currentContent); } _currentContent = content; if (_currentContent != null) { AddChild(_currentContent); } } protected override Size MeasureOverride(Size availableSize) { // Measure current content with padding accounted for (consistent with ArrangeOverride) if (_currentContent != null) { float contentTop = NavBarIsVisible ? NavBarHeight : 0; float contentBottom = TabBarIsVisible ? TabBarHeight : 0; var contentSize = new Size( availableSize.Width - Padding.Left - Padding.Right, availableSize.Height - contentTop - contentBottom - Padding.Top - Padding.Bottom); _currentContent.Measure(contentSize); } return availableSize; } protected override Rect ArrangeOverride(Rect bounds) { Console.WriteLine($"[SkiaShell] ArrangeOverride - bounds={bounds}"); // Arrange current content with padding if (_currentContent != null) { float contentTop = (float)bounds.Top + (NavBarIsVisible ? NavBarHeight : 0) + ContentPadding; float contentBottom = (float)bounds.Bottom - (TabBarIsVisible ? TabBarHeight : 0) - ContentPadding; var contentBounds = new Rect( bounds.Left + ContentPadding, contentTop, bounds.Width - ContentPadding * 2, contentBottom - contentTop); Console.WriteLine($"[SkiaShell] Arranging content with bounds={contentBounds}, padding={ContentPadding}"); _currentContent.Arrange(contentBounds); } return bounds; } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { canvas.Save(); canvas.ClipRect(bounds); // Draw content _currentContent?.Draw(canvas); // Draw navigation bar if (NavBarIsVisible) { DrawNavBar(canvas, bounds); } // Draw tab bar if (TabBarIsVisible) { DrawTabBar(canvas, bounds); } // Draw flyout overlay and panel if (_flyoutAnimationProgress > 0) { DrawFlyout(canvas, bounds); } canvas.Restore(); } private void DrawNavBar(SKCanvas canvas, SKRect bounds) { var navBarBounds = new SKRect( bounds.Left, bounds.Top, bounds.Right, bounds.Top + NavBarHeight); // Draw background using var bgPaint = new SKPaint { Color = _navBarBackgroundColorSK, Style = SKPaintStyle.Fill, IsAntialias = true }; canvas.DrawRect(navBarBounds, bgPaint); // Draw nav icon (back arrow if can go back, else hamburger menu if flyout enabled) using var iconPaint = new SKPaint { Color = _navBarTextColorSK, Style = SKPaintStyle.Stroke, StrokeWidth = 2, StrokeCap = SKStrokeCap.Round, IsAntialias = true }; float iconLeft = navBarBounds.Left + 16; float iconCenter = navBarBounds.MidY; if (CanGoBack) { // Draw iOS-style back chevron "<" using var chevronPaint = new SKPaint { Color = _navBarTextColorSK, Style = SKPaintStyle.Stroke, StrokeWidth = 2.5f, StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Round, IsAntialias = true }; // Clean chevron pointing left float chevronX = iconLeft + 6; float chevronSize = 10; canvas.DrawLine(chevronX + chevronSize, iconCenter - chevronSize, chevronX, iconCenter, chevronPaint); canvas.DrawLine(chevronX, iconCenter, chevronX + chevronSize, iconCenter + chevronSize, chevronPaint); } else if (FlyoutBehavior == ShellFlyoutBehavior.Flyout) { // Draw hamburger menu icon canvas.DrawLine(iconLeft, iconCenter - 8, iconLeft + 18, iconCenter - 8, iconPaint); canvas.DrawLine(iconLeft, iconCenter, iconLeft + 18, iconCenter, iconPaint); canvas.DrawLine(iconLeft, iconCenter + 8, iconLeft + 18, iconCenter + 8, iconPaint); } // Draw title using var titlePaint = new SKPaint { Color = _navBarTextColorSK, TextSize = 20f, IsAntialias = true, FakeBoldText = true }; float titleX = (CanGoBack || FlyoutBehavior == ShellFlyoutBehavior.Flyout) ? navBarBounds.Left + 56 : navBarBounds.Left + 16; float titleY = navBarBounds.MidY + 6; canvas.DrawText(Title, titleX, titleY, titlePaint); } private void DrawTabBar(SKCanvas canvas, SKRect bounds) { if (_selectedSectionIndex < 0 || _selectedSectionIndex >= _sections.Count) return; var section = _sections[_selectedSectionIndex]; if (section.Items.Count <= 1) return; var tabBarBounds = new SKRect( bounds.Left, bounds.Bottom - TabBarHeight, bounds.Right, bounds.Bottom); // Draw background using var bgPaint = new SKPaint { Color = SkiaTheme.BackgroundWhiteSK, Style = SKPaintStyle.Fill, IsAntialias = true }; canvas.DrawRect(tabBarBounds, bgPaint); // Draw top border using var borderPaint = new SKPaint { Color = SkiaTheme.Gray300SK, Style = SKPaintStyle.Stroke, StrokeWidth = 1 }; canvas.DrawLine(tabBarBounds.Left, tabBarBounds.Top, tabBarBounds.Right, tabBarBounds.Top, borderPaint); // Draw tabs float tabWidth = tabBarBounds.Width / section.Items.Count; using var textPaint = new SKPaint { TextSize = 12f, IsAntialias = true }; for (int i = 0; i < section.Items.Count; i++) { var item = section.Items[i]; bool isSelected = i == _selectedItemIndex; textPaint.Color = isSelected ? _navBarBackgroundColorSK : SkiaTheme.TextTertiarySK; var textBounds = new SKRect(); textPaint.MeasureText(item.Title, ref textBounds); float textX = tabBarBounds.Left + i * tabWidth + tabWidth / 2 - textBounds.MidX; float textY = tabBarBounds.MidY - textBounds.MidY; canvas.DrawText(item.Title, textX, textY, textPaint); } } private void DrawFlyout(SKCanvas canvas, SKRect bounds) { // Draw scrim using var scrimPaint = new SKPaint { Color = SkiaTheme.Shadow40SK.WithAlpha((byte)(100 * _flyoutAnimationProgress)), Style = SKPaintStyle.Fill }; canvas.DrawRect(bounds, scrimPaint); // Draw flyout panel float flyoutX = bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress); var flyoutBounds = new SKRect( flyoutX, bounds.Top, flyoutX + FlyoutWidth, bounds.Bottom); using var flyoutPaint = new SKPaint { Color = _flyoutBackgroundColorSK, Style = SKPaintStyle.Fill, IsAntialias = true }; canvas.DrawRect(flyoutBounds, flyoutPaint); // Draw flyout items float itemY = flyoutBounds.Top + 80; float itemHeight = 48f; using var itemTextPaint = new SKPaint { TextSize = 14f, IsAntialias = true }; for (int i = 0; i < _sections.Count; i++) { var section = _sections[i]; bool isSelected = i == _selectedSectionIndex; // Draw selection background if (isSelected) { using var selectionPaint = new SKPaint { Color = SkiaTheme.PrimarySelectionSK.WithAlpha(30), Style = SKPaintStyle.Fill }; var selectionRect = new SKRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight); canvas.DrawRect(selectionRect, selectionPaint); } itemTextPaint.Color = isSelected ? _navBarBackgroundColorSK : _flyoutTextColorSK; canvas.DrawText(section.Title, flyoutBounds.Left + 16, itemY + 30, itemTextPaint); itemY += itemHeight; } } public override SkiaView? HitTest(float x, float y) { if (!IsVisible || !Bounds.Contains(x, y)) return null; // Check flyout area if (_flyoutAnimationProgress > 0) { float flyoutX = (float)Bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress); var flyoutBounds = new SKRect(flyoutX, (float)Bounds.Top, flyoutX + FlyoutWidth, (float)Bounds.Bottom); if (flyoutBounds.Contains(x, y)) { return this; // Flyout handles its own hits } // Tap on scrim closes flyout if (FlyoutIsPresented) { return this; } } // Check nav bar if (NavBarIsVisible && y < (float)Bounds.Top + NavBarHeight) { return this; } // Check tab bar if (TabBarIsVisible && y > (float)Bounds.Bottom - TabBarHeight) { return this; } // Check content if (_currentContent != null) { var hit = _currentContent.HitTest(x, y); if (hit != null) return hit; } return this; } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled) return; // Check flyout tap if (_flyoutAnimationProgress > 0) { float flyoutX = (float)Bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress); var flyoutBounds = new SKRect(flyoutX, (float)Bounds.Top, flyoutX + FlyoutWidth, (float)Bounds.Bottom); if (flyoutBounds.Contains(e.X, e.Y)) { // Check which section was tapped float itemY = flyoutBounds.Top + 80; float itemHeight = 48f; for (int i = 0; i < _sections.Count; i++) { if (e.Y >= itemY && e.Y < itemY + itemHeight) { NavigateToSection(i, 0); FlyoutIsPresented = false; e.Handled = true; return; } itemY += itemHeight; } } else if (FlyoutIsPresented) { // Tap on scrim FlyoutIsPresented = false; e.Handled = true; return; } } // Check nav bar icon tap (back button or hamburger menu) if (NavBarIsVisible && e.Y < Bounds.Top + NavBarHeight && e.X < 56) { if (CanGoBack) { // Back button pressed PopAsync(); e.Handled = true; return; } else if (FlyoutBehavior == ShellFlyoutBehavior.Flyout) { // Hamburger menu pressed FlyoutIsPresented = !FlyoutIsPresented; e.Handled = true; return; } } // Check tab bar tap if (TabBarIsVisible && e.Y > (float)Bounds.Bottom - TabBarHeight) { if (_selectedSectionIndex >= 0 && _selectedSectionIndex < _sections.Count) { var section = _sections[_selectedSectionIndex]; float tabWidth = (float)Bounds.Width / section.Items.Count; int tappedIndex = (int)((e.X - (float)Bounds.Left) / tabWidth); tappedIndex = Math.Clamp(tappedIndex, 0, section.Items.Count - 1); if (tappedIndex != _selectedItemIndex) { NavigateToSection(_selectedSectionIndex, tappedIndex); } e.Handled = true; return; } } base.OnPointerPressed(e); } public override void OnScroll(ScrollEventArgs e) { if (FlyoutIsPresented && _flyoutAnimationProgress > 0) { float flyoutX = (float)Bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress); var flyoutBounds = new SKRect(flyoutX, (float)Bounds.Top, flyoutX + FlyoutWidth, (float)Bounds.Bottom); if (flyoutBounds.Contains(e.X, e.Y)) { float headerHeight = FlyoutHeaderView != null ? FlyoutHeaderHeight : 0f; float footerHeight = !string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f; float itemHeight = 48f; float totalItemsHeight = _sections.Count * itemHeight; float viewableHeight = flyoutBounds.Height - headerHeight - footerHeight; float maxScroll = Math.Max(0f, totalItemsHeight - viewableHeight); _flyoutScrollOffset -= e.DeltaY * 30f; _flyoutScrollOffset = Math.Max(0f, Math.Min(_flyoutScrollOffset, maxScroll)); Invalidate(); e.Handled = true; return; } } base.OnScroll(e); } } /// /// Shell flyout behavior options. /// public enum ShellFlyoutBehavior { /// /// No flyout menu. /// Disabled, /// /// Flyout slides over content. /// Flyout, /// /// Flyout is always visible (side-by-side layout). /// Locked } /// /// Represents a section in the shell (typically shown in flyout). /// public class ShellSection { /// /// The route identifier for this section. /// public string Route { get; set; } = string.Empty; /// /// The display title. /// public string Title { get; set; } = string.Empty; /// /// Optional icon path. /// public string? IconPath { get; set; } /// /// Items in this section. /// public List Items { get; } = new(); } /// /// Represents content within a shell section. /// public class ShellContent { /// /// The route identifier for this content. /// public string Route { get; set; } = string.Empty; /// /// The display title. /// public string Title { get; set; } = string.Empty; /// /// Optional icon path. /// public string? IconPath { get; set; } /// /// The content view. /// public SkiaView? Content { get; set; } /// /// Reference to the MAUI ShellContent this represents. /// public Microsoft.Maui.Controls.ShellContent? MauiShellContent { get; set; } } /// /// Event args for shell navigation events. /// public class ShellNavigationEventArgs : EventArgs { public ShellSection Section { get; } public ShellContent Content { get; } public ShellNavigationEventArgs(ShellSection section, ShellContent content) { Section = section; Content = content; } }