maui-linux/Views/SkiaShell.cs

752 lines
22 KiB
C#

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
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
{
private readonly List<ShellSection> _sections = new();
private SkiaView? _currentContent;
private bool _flyoutIsPresented = false;
private float _flyoutWidth = 280f;
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();
/// <summary>
/// Gets or sets whether the flyout is presented.
/// </summary>
public bool FlyoutIsPresented
{
get => _flyoutIsPresented;
set
{
if (_flyoutIsPresented != value)
{
_flyoutIsPresented = value;
_flyoutAnimationProgress = value ? 1f : 0f;
FlyoutIsPresentedChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
}
}
/// <summary>
/// Gets or sets the flyout behavior.
/// </summary>
public ShellFlyoutBehavior FlyoutBehavior { get; set; } = ShellFlyoutBehavior.Flyout;
/// <summary>
/// Gets or sets the flyout width.
/// </summary>
public float FlyoutWidth
{
get => _flyoutWidth;
set
{
if (_flyoutWidth != value)
{
_flyoutWidth = Math.Max(100, value);
Invalidate();
}
}
}
/// <summary>
/// Background color of the flyout.
/// </summary>
public SKColor FlyoutBackgroundColor { get; set; } = SKColors.White;
/// <summary>
/// Background color of the navigation bar.
/// </summary>
public SKColor NavBarBackgroundColor { get; set; } = new SKColor(33, 150, 243);
/// <summary>
/// Text color of the navigation bar title.
/// </summary>
public SKColor NavBarTextColor { get; set; } = SKColors.White;
/// <summary>
/// Height of the navigation bar.
/// </summary>
public float NavBarHeight { get; set; } = 56f;
/// <summary>
/// Height of the tab bar (when using bottom tabs).
/// </summary>
public float TabBarHeight { get; set; } = 56f;
/// <summary>
/// Gets or sets whether the navigation bar is visible.
/// </summary>
public bool NavBarIsVisible { get; set; } = true;
/// <summary>
/// Gets or sets whether the tab bar is visible.
/// </summary>
public bool TabBarIsVisible { get; set; } = false;
/// <summary>
/// Gets or sets the padding applied to page content.
/// Default is 16 pixels on all sides.
/// </summary>
public float ContentPadding { get; set; } = 16f;
/// <summary>
/// Current title displayed in the navigation bar.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <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>
/// 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>
/// Navigates using a URI route.
/// </summary>
public void GoToAsync(string route)
{
// Simple route parsing - format: "//section/item"
if (string.IsNullOrEmpty(route)) return;
var parts = route.TrimStart('/').Split('/');
if (parts.Length == 0) return;
// Find matching section
for (int i = 0; i < _sections.Count; i++)
{
var section = _sections[i];
if (section.Route.Equals(parts[0], StringComparison.OrdinalIgnoreCase))
{
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);
return;
}
}
}
NavigateToSection(i, 0);
return;
}
}
}
/// <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;
Invalidate();
}
private void SetCurrentContent(SkiaView? content)
{
if (_currentContent != null)
{
RemoveChild(_currentContent);
}
_currentContent = content;
if (_currentContent != null)
{
AddChild(_currentContent);
}
}
protected override SKSize MeasureOverride(SKSize 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 SKSize(
availableSize.Width - (float)Padding.Left - (float)Padding.Right,
availableSize.Height - contentTop - contentBottom - (float)Padding.Top - (float)Padding.Bottom);
_currentContent.Measure(contentSize);
}
return availableSize;
}
protected override SKRect ArrangeOverride(SKRect bounds)
{
Console.WriteLine($"[SkiaShell] ArrangeOverride - bounds={bounds}");
// Arrange current content with padding
if (_currentContent != null)
{
float contentTop = bounds.Top + (NavBarIsVisible ? NavBarHeight : 0) + ContentPadding;
float contentBottom = bounds.Bottom - (TabBarIsVisible ? TabBarHeight : 0) - ContentPadding;
var contentBounds = new SKRect(
bounds.Left + ContentPadding,
contentTop,
bounds.Right - ContentPadding,
contentBottom);
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 = NavBarBackgroundColor,
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 = NavBarTextColor,
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 = NavBarTextColor,
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 = NavBarTextColor,
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 = SKColors.White,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRect(tabBarBounds, bgPaint);
// Draw top border
using var borderPaint = new SKPaint
{
Color = new SKColor(224, 224, 224),
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 ? NavBarBackgroundColor : new SKColor(117, 117, 117);
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 = new SKColor(0, 0, 0, (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 = FlyoutBackgroundColor,
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 = new SKColor(33, 150, 243, 30),
Style = SKPaintStyle.Fill
};
var selectionRect = new SKRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight);
canvas.DrawRect(selectionRect, selectionPaint);
}
itemTextPaint.Color = isSelected ? NavBarBackgroundColor : new SKColor(33, 33, 33);
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 = Bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress);
var flyoutBounds = new SKRect(flyoutX, Bounds.Top, flyoutX + FlyoutWidth, 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 < Bounds.Top + NavBarHeight)
{
return this;
}
// Check tab bar
if (TabBarIsVisible && y > 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 = Bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress);
var flyoutBounds = new SKRect(flyoutX, Bounds.Top, flyoutX + FlyoutWidth, 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 > Bounds.Bottom - TabBarHeight)
{
if (_selectedSectionIndex >= 0 && _selectedSectionIndex < _sections.Count)
{
var section = _sections[_selectedSectionIndex];
float tabWidth = Bounds.Width / section.Items.Count;
int tappedIndex = (int)((e.X - 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);
}
}
/// <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>
/// 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;
}
}