Files
maui-linux/Views/SkiaShell.cs

1325 lines
42 KiB
C#
Raw Normal View History

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
2026-01-17 03:36:37 +00:00
using Microsoft.Maui.Graphics;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Shell provides a common navigation experience for MAUI applications.
/// Supports flyout menu, tabs, and URI-based navigation.
/// </summary>
public class SkiaShell : SkiaLayoutView
{
#region BindableProperties
/// <summary>
/// Bindable property for FlyoutIsPresented.
/// </summary>
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));
/// <summary>
/// Bindable property for FlyoutBehavior.
/// </summary>
public static readonly BindableProperty FlyoutBehaviorProperty =
BindableProperty.Create(
nameof(FlyoutBehavior),
typeof(ShellFlyoutBehavior),
typeof(SkiaShell),
ShellFlyoutBehavior.Flyout,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaShell)b).Invalidate());
/// <summary>
/// Bindable property for FlyoutWidth.
/// </summary>
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());
/// <summary>
/// Bindable property for FlyoutBackgroundColor.
/// </summary>
public static readonly BindableProperty FlyoutBackgroundColorProperty =
BindableProperty.Create(
nameof(FlyoutBackgroundColor),
2026-01-17 03:36:37 +00:00
typeof(Color),
typeof(SkiaShell),
2026-01-17 03:36:37 +00:00
Colors.White,
BindingMode.TwoWay,
2026-01-17 03:36:37 +00:00
propertyChanged: (b, o, n) => ((SkiaShell)b).OnFlyoutBackgroundColorChanged());
/// <summary>
/// Bindable property for FlyoutTextColor.
/// </summary>
public static readonly BindableProperty FlyoutTextColorProperty =
BindableProperty.Create(
nameof(FlyoutTextColor),
2026-01-17 03:36:37 +00:00
typeof(Color),
typeof(SkiaShell),
2026-01-17 03:36:37 +00:00
Color.FromRgb(33, 33, 33),
BindingMode.TwoWay,
2026-01-17 03:36:37 +00:00
propertyChanged: (b, o, n) => ((SkiaShell)b).OnFlyoutTextColorChanged());
/// <summary>
/// Bindable property for NavBarBackgroundColor.
/// </summary>
public static readonly BindableProperty NavBarBackgroundColorProperty =
BindableProperty.Create(
nameof(NavBarBackgroundColor),
2026-01-17 03:36:37 +00:00
typeof(Color),
typeof(SkiaShell),
2026-01-17 03:36:37 +00:00
Color.FromRgb(33, 150, 243),
BindingMode.TwoWay,
2026-01-17 03:36:37 +00:00
propertyChanged: (b, o, n) => ((SkiaShell)b).OnNavBarBackgroundColorChanged());
/// <summary>
/// Bindable property for NavBarTextColor.
/// </summary>
public static readonly BindableProperty NavBarTextColorProperty =
BindableProperty.Create(
nameof(NavBarTextColor),
2026-01-17 03:36:37 +00:00
typeof(Color),
typeof(SkiaShell),
2026-01-17 03:36:37 +00:00
Colors.White,
BindingMode.TwoWay,
2026-01-17 03:36:37 +00:00
propertyChanged: (b, o, n) => ((SkiaShell)b).OnNavBarTextColorChanged());
/// <summary>
/// Bindable property for NavBarHeight.
/// </summary>
public static readonly BindableProperty NavBarHeightProperty =
BindableProperty.Create(
nameof(NavBarHeight),
typeof(float),
typeof(SkiaShell),
56f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure());
/// <summary>
/// Bindable property for TabBarHeight.
/// </summary>
public static readonly BindableProperty TabBarHeightProperty =
BindableProperty.Create(
nameof(TabBarHeight),
typeof(float),
typeof(SkiaShell),
56f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure());
/// <summary>
/// Bindable property for NavBarIsVisible.
/// </summary>
public static readonly BindableProperty NavBarIsVisibleProperty =
BindableProperty.Create(
nameof(NavBarIsVisible),
typeof(bool),
typeof(SkiaShell),
true,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure());
/// <summary>
/// Bindable property for TabBarIsVisible.
/// </summary>
public static readonly BindableProperty TabBarIsVisibleProperty =
BindableProperty.Create(
nameof(TabBarIsVisible),
typeof(bool),
typeof(SkiaShell),
false,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure());
/// <summary>
/// Bindable property for ContentPadding.
/// </summary>
public static readonly BindableProperty ContentPaddingProperty =
BindableProperty.Create(
nameof(ContentPadding),
typeof(float),
typeof(SkiaShell),
0f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaShell)b).InvalidateMeasure());
/// <summary>
/// Bindable property for ContentBackgroundColor.
/// </summary>
public static readonly BindableProperty ContentBackgroundColorProperty =
BindableProperty.Create(
nameof(ContentBackgroundColor),
2026-01-17 03:36:37 +00:00
typeof(Color),
typeof(SkiaShell),
2026-01-17 03:36:37 +00:00
Color.FromRgb(250, 250, 250),
BindingMode.TwoWay,
2026-01-17 03:36:37 +00:00
propertyChanged: (b, o, n) => ((SkiaShell)b).OnContentBackgroundColorChanged());
/// <summary>
/// Bindable property for Title.
/// </summary>
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<ShellSection> _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<string, Func<SkiaView?>> _registeredRoutes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _routeTitles = new(StringComparer.OrdinalIgnoreCase);
2026-01-17 03:36:37 +00:00
// 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();
}
/// <summary>
/// Gets or sets whether the flyout is presented.
/// </summary>
public bool FlyoutIsPresented
{
get => (bool)GetValue(FlyoutIsPresentedProperty);
set => SetValue(FlyoutIsPresentedProperty, value);
}
/// <summary>
/// Gets or sets the flyout behavior.
/// </summary>
public ShellFlyoutBehavior FlyoutBehavior
{
get => (ShellFlyoutBehavior)GetValue(FlyoutBehaviorProperty);
set => SetValue(FlyoutBehaviorProperty, value);
}
/// <summary>
/// Gets or sets the flyout width.
/// </summary>
public float FlyoutWidth
{
get => (float)GetValue(FlyoutWidthProperty);
set => SetValue(FlyoutWidthProperty, value);
}
/// <summary>
/// Background color of the flyout.
/// </summary>
2026-01-17 03:36:37 +00:00
public Color? FlyoutBackgroundColor
{
2026-01-17 03:36:37 +00:00
get => (Color?)GetValue(FlyoutBackgroundColorProperty);
set => SetValue(FlyoutBackgroundColorProperty, value);
}
/// <summary>
/// Text color in the flyout.
/// </summary>
2026-01-17 03:36:37 +00:00
public Color? FlyoutTextColor
{
2026-01-17 03:36:37 +00:00
get => (Color?)GetValue(FlyoutTextColorProperty);
set => SetValue(FlyoutTextColorProperty, value);
}
/// <summary>
/// Optional header view in the flyout.
/// </summary>
public SkiaView? FlyoutHeaderView { get; set; }
/// <summary>
/// Height of the flyout header.
/// </summary>
public float FlyoutHeaderHeight { get; set; } = 140f;
/// <summary>
2026-01-24 03:18:08 +00:00
/// Optional footer text in the flyout (fallback if no FlyoutFooterView).
/// </summary>
public string? FlyoutFooterText { get; set; }
2026-01-24 03:18:08 +00:00
/// <summary>
/// Optional footer view in the flyout.
/// </summary>
public SkiaView? FlyoutFooterView { get; set; }
/// <summary>
/// Height of the flyout footer.
/// </summary>
public float FlyoutFooterHeight { get; set; } = 40f;
/// <summary>
/// Background color of the navigation bar.
/// </summary>
2026-01-17 03:36:37 +00:00
public Color? NavBarBackgroundColor
{
2026-01-17 03:36:37 +00:00
get => (Color?)GetValue(NavBarBackgroundColorProperty);
set => SetValue(NavBarBackgroundColorProperty, value);
}
/// <summary>
/// Text color of the navigation bar title.
/// </summary>
2026-01-17 03:36:37 +00:00
public Color? NavBarTextColor
{
2026-01-17 03:36:37 +00:00
get => (Color?)GetValue(NavBarTextColorProperty);
set => SetValue(NavBarTextColorProperty, value);
}
/// <summary>
/// Height of the navigation bar.
/// </summary>
public float NavBarHeight
{
get => (float)GetValue(NavBarHeightProperty);
set => SetValue(NavBarHeightProperty, value);
}
/// <summary>
/// Height of the tab bar (when using bottom tabs).
/// </summary>
public float TabBarHeight
{
get => (float)GetValue(TabBarHeightProperty);
set => SetValue(TabBarHeightProperty, value);
}
/// <summary>
/// Gets or sets whether the navigation bar is visible.
/// </summary>
public bool NavBarIsVisible
{
get => (bool)GetValue(NavBarIsVisibleProperty);
set => SetValue(NavBarIsVisibleProperty, value);
}
/// <summary>
/// Gets or sets whether the tab bar is visible.
/// </summary>
public bool TabBarIsVisible
{
get => (bool)GetValue(TabBarIsVisibleProperty);
set => SetValue(TabBarIsVisibleProperty, value);
}
/// <summary>
/// Gets or sets the padding applied to page content.
/// </summary>
public float ContentPadding
{
get => (float)GetValue(ContentPaddingProperty);
set => SetValue(ContentPaddingProperty, value);
}
/// <summary>
/// Background color of the content area.
/// </summary>
2026-01-17 03:36:37 +00:00
public Color? ContentBackgroundColor
{
2026-01-17 03:36:37 +00:00
get => (Color?)GetValue(ContentBackgroundColorProperty);
set => SetValue(ContentBackgroundColorProperty, value);
}
/// <summary>
/// Current title displayed in the navigation bar.
/// </summary>
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
/// <summary>
/// The sections in this shell.
/// </summary>
public IReadOnlyList<ShellSection> Sections => _sections;
/// <summary>
/// Gets the currently selected section index.
/// </summary>
public int CurrentSectionIndex => _selectedSectionIndex;
/// <summary>
/// Reference to the MAUI Shell this view represents.
/// </summary>
public Shell? MauiShell { get; set; }
/// <summary>
/// Callback to render content from a ShellContent.
/// </summary>
public Func<Microsoft.Maui.Controls.ShellContent, SkiaView?>? ContentRenderer { get; set; }
/// <summary>
/// Callback to refresh shell colors.
/// </summary>
public Action<SkiaShell, Shell>? ColorRefresher { get; set; }
/// <summary>
/// Event raised when FlyoutIsPresented changes.
/// </summary>
public event EventHandler? FlyoutIsPresentedChanged;
/// <summary>
/// Event raised when navigation occurs.
/// </summary>
public event EventHandler<ShellNavigationEventArgs>? Navigated;
/// <summary>
/// Adds a section to the shell.
/// </summary>
public void AddSection(ShellSection section)
{
_sections.Add(section);
if (_sections.Count == 1)
{
NavigateToSection(0, 0);
}
Invalidate();
}
/// <summary>
/// Removes a section from the shell.
/// </summary>
public void RemoveSection(ShellSection section)
{
_sections.Remove(section);
Invalidate();
}
/// <summary>
/// Navigates to a specific section and item.
/// </summary>
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();
}
/// <summary>
/// Refreshes the shell theme and re-renders all pages.
/// </summary>
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;
}
}
}
}
}
2026-01-24 02:37:37 +00:00
// 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();
}
/// <summary>
/// Navigates using a URI route.
/// </summary>
public void GoToAsync(string route)
{
GoToAsync(route, null);
}
/// <summary>
/// Navigates using a URI route with parameters.
/// </summary>
public void GoToAsync(string route, IDictionary<string, object>? parameters)
{
if (string.IsNullOrEmpty(route)) return;
string routePath = route;
Dictionary<string, string> queryParams = new Dictionary<string, string>();
int queryIndex = route.IndexOf('?');
if (queryIndex >= 0)
{
routePath = route.Substring(0, queryIndex);
queryParams = ParseQueryString(route.Substring(queryIndex + 1));
}
Dictionary<string, object> allParams = new Dictionary<string, object>();
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<SkiaView?>? 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<string, string> ParseQueryString(string queryString)
{
var result = new Dictionary<string, string>(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<string, object> 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 { }
}
}
}
/// <summary>
/// Registers a route with a content factory.
/// </summary>
public void RegisterRoute(string route, Func<SkiaView?> contentFactory, string? title = null)
{
var key = route.TrimStart('/');
_registeredRoutes[key] = contentFactory;
if (!string.IsNullOrEmpty(title))
{
_routeTitles[key] = title;
}
}
/// <summary>
/// Unregisters a route.
/// </summary>
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;
}
/// <summary>
/// Gets whether there are pages on the navigation stack.
/// </summary>
public bool CanGoBack => _navigationStack.Count > 0;
/// <summary>
/// Gets the current navigation stack depth.
/// </summary>
public int NavigationStackDepth => _navigationStack.Count;
/// <summary>
/// Pushes a new page onto the navigation stack.
/// </summary>
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();
}
/// <summary>
/// Pops the current page from the navigation stack.
/// </summary>
public bool PopAsync()
{
if (_navigationStack.Count == 0) return false;
var (previousContent, previousTitle) = _navigationStack.Pop();
SetCurrentContent(previousContent);
Title = previousTitle;
Invalidate();
return true;
}
/// <summary>
/// Pops all pages from the navigation stack, returning to the root.
/// </summary>
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);
}
}
2026-01-17 05:22:37 +00:00
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;
2026-01-17 05:22:37 +00:00
var contentSize = new Size(
availableSize.Width - Padding.Left - Padding.Right,
availableSize.Height - contentTop - contentBottom - Padding.Top - Padding.Bottom);
_currentContent.Measure(contentSize);
}
return availableSize;
}
2026-01-17 05:22:37 +00:00
protected override Rect ArrangeOverride(Rect bounds)
{
Console.WriteLine($"[SkiaShell] ArrangeOverride - bounds={bounds}");
// Arrange current content with padding
if (_currentContent != null)
{
2026-01-17 05:22:37 +00:00
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,
2026-01-17 05:22:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
Color = SkiaTheme.BackgroundWhiteSK,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRect(tabBarBounds, bgPaint);
// Draw top border
using var borderPaint = new SKPaint
{
2026-01-17 03:36:37 +00:00
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;
2026-01-17 03:36:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
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
{
2026-01-17 03:36:37 +00:00
Color = _flyoutBackgroundColorSK,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRect(flyoutBounds, flyoutPaint);
2026-01-24 03:18:08 +00:00
// Calculate header and footer heights
float headerHeight = FlyoutHeaderView != null ? FlyoutHeaderHeight : 0f;
float footerHeight = FlyoutFooterView != null ? FlyoutFooterHeight :
(!string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f);
// Draw flyout header if present
if (FlyoutHeaderView != null)
{
var headerBounds = new SKRect(flyoutBounds.Left, flyoutBounds.Top, flyoutBounds.Right, flyoutBounds.Top + headerHeight);
FlyoutHeaderView.Measure(new Size(headerBounds.Width, headerBounds.Height));
FlyoutHeaderView.Arrange(new Rect(headerBounds.Left, headerBounds.Top, headerBounds.Width, headerBounds.Height));
FlyoutHeaderView.Draw(canvas);
}
// Draw flyout items with scrolling support
float itemHeight = 48f;
2026-01-24 03:18:08 +00:00
float itemsAreaTop = flyoutBounds.Top + headerHeight;
float itemsAreaBottom = flyoutBounds.Bottom - footerHeight;
// Clip to items area (between header and footer)
canvas.Save();
canvas.ClipRect(new SKRect(flyoutBounds.Left, itemsAreaTop, flyoutBounds.Right, itemsAreaBottom));
// Apply scroll offset
float itemY = itemsAreaTop - _flyoutScrollOffset;
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;
2026-01-24 03:18:08 +00:00
// Skip items that are scrolled above the visible area
if (itemY + itemHeight < itemsAreaTop)
{
itemY += itemHeight;
continue;
}
// Stop if we're below the visible area
if (itemY > itemsAreaBottom)
break;
// Draw selection background
if (isSelected)
{
using var selectionPaint = new SKPaint
{
2026-01-17 03:36:37 +00:00
Color = SkiaTheme.PrimarySelectionSK.WithAlpha(30),
Style = SKPaintStyle.Fill
};
var selectionRect = new SKRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight);
canvas.DrawRect(selectionRect, selectionPaint);
}
2026-01-17 03:36:37 +00:00
itemTextPaint.Color = isSelected ? _navBarBackgroundColorSK : _flyoutTextColorSK;
canvas.DrawText(section.Title, flyoutBounds.Left + 16, itemY + 30, itemTextPaint);
itemY += itemHeight;
}
2026-01-24 03:18:08 +00:00
canvas.Restore();
// Draw flyout footer
if (FlyoutFooterView != null)
{
var footerBounds = new SKRect(flyoutBounds.Left, flyoutBounds.Bottom - footerHeight, flyoutBounds.Right, flyoutBounds.Bottom);
FlyoutFooterView.Measure(new Size(footerBounds.Width, footerBounds.Height));
FlyoutFooterView.Arrange(new Rect(footerBounds.Left, footerBounds.Top, footerBounds.Width, footerBounds.Height));
FlyoutFooterView.Draw(canvas);
}
else if (!string.IsNullOrEmpty(FlyoutFooterText))
{
// Fallback: draw simple text footer
using var footerPaint = new SKPaint
{
TextSize = 12f,
Color = _flyoutTextColorSK.WithAlpha(180),
IsAntialias = true
};
var footerY = flyoutBounds.Bottom - footerHeight / 2 + 4;
canvas.DrawText(FlyoutFooterText, flyoutBounds.Left + 16, footerY, footerPaint);
}
}
public override SkiaView? HitTest(float x, float y)
{
if (!IsVisible || !Bounds.Contains(x, y)) return null;
// Check flyout area
if (_flyoutAnimationProgress > 0)
{
2026-01-17 05:22:37 +00:00
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
2026-01-17 05:22:37 +00:00
if (NavBarIsVisible && y < (float)Bounds.Top + NavBarHeight)
{
return this;
}
// Check tab bar
2026-01-17 05:22:37 +00:00
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)
{
2026-01-17 05:22:37 +00:00
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))
{
2026-01-24 03:18:08 +00:00
// Calculate header and footer heights
float headerHeight = FlyoutHeaderView != null ? FlyoutHeaderHeight : 0f;
float footerHeight = FlyoutFooterView != null ? FlyoutFooterHeight :
(!string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f);
float itemsAreaTop = flyoutBounds.Top + headerHeight;
float itemsAreaBottom = flyoutBounds.Bottom - footerHeight;
2026-01-24 03:18:08 +00:00
// Only check items if tap is in items area
if (e.Y >= itemsAreaTop && e.Y < itemsAreaBottom)
{
2026-01-24 03:18:08 +00:00
// Apply scroll offset to find which item was tapped
float itemY = itemsAreaTop - _flyoutScrollOffset;
float itemHeight = 48f;
for (int i = 0; i < _sections.Count; i++)
{
2026-01-24 03:18:08 +00:00
if (e.Y >= itemY && e.Y < itemY + itemHeight)
{
NavigateToSection(i, 0);
FlyoutIsPresented = false;
_flyoutScrollOffset = 0; // Reset scroll when closing
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
2026-01-17 05:22:37 +00:00
if (TabBarIsVisible && e.Y > (float)Bounds.Bottom - TabBarHeight)
{
if (_selectedSectionIndex >= 0 && _selectedSectionIndex < _sections.Count)
{
var section = _sections[_selectedSectionIndex];
2026-01-17 05:22:37 +00:00
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)
{
2026-01-17 05:22:37 +00:00
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;
2026-01-24 03:18:08 +00:00
float footerHeight = FlyoutFooterView != null ? FlyoutFooterHeight :
(!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);
2026-01-24 03:18:08 +00:00
_flyoutScrollOffset += e.DeltaY * 30f;
_flyoutScrollOffset = Math.Max(0f, Math.Min(_flyoutScrollOffset, maxScroll));
Invalidate();
e.Handled = true;
return;
}
}
base.OnScroll(e);
}
}
/// <summary>
/// Shell flyout behavior options.
/// </summary>
public enum ShellFlyoutBehavior
{
/// <summary>
/// No flyout menu.
/// </summary>
Disabled,
/// <summary>
/// Flyout slides over content.
/// </summary>
Flyout,
/// <summary>
/// Flyout is always visible (side-by-side layout).
/// </summary>
Locked
}
/// <summary>
/// Represents a section in the shell (typically shown in flyout).
/// </summary>
public class ShellSection
{
/// <summary>
/// The route identifier for this section.
/// </summary>
public string Route { get; set; } = string.Empty;
/// <summary>
/// The display title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Optional icon path.
/// </summary>
public string? IconPath { get; set; }
/// <summary>
/// Items in this section.
/// </summary>
public List<ShellContent> Items { get; } = new();
}
/// <summary>
/// Represents content within a shell section.
/// </summary>
public class ShellContent
{
/// <summary>
/// The route identifier for this content.
/// </summary>
public string Route { get; set; } = string.Empty;
/// <summary>
/// The display title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Optional icon path.
/// </summary>
public string? IconPath { get; set; }
/// <summary>
/// The content view.
/// </summary>
public SkiaView? Content { get; set; }
/// <summary>
/// Reference to the MAUI ShellContent this represents.
/// </summary>
public Microsoft.Maui.Controls.ShellContent? MauiShellContent { get; set; }
}
/// <summary>
/// Event args for shell navigation events.
/// </summary>
public class ShellNavigationEventArgs : EventArgs
{
public ShellSection Section { get; }
public ShellContent Content { get; }
public ShellNavigationEventArgs(ShellSection section, ShellContent content)
{
Section = section;
Content = content;
}
}