// 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; /// /// A horizontal menu bar control. /// public class SkiaMenuBar : SkiaView { private readonly List _items = new(); private int _hoveredIndex = -1; private int _openIndex = -1; private SkiaMenuFlyout? _openFlyout; /// /// Gets the menu bar items. /// public IList Items => _items; /// /// Gets or sets the background color. /// public SKColor BackgroundColor { get; set; } = new SKColor(240, 240, 240); /// /// Gets or sets the text color. /// public SKColor TextColor { get; set; } = new SKColor(33, 33, 33); /// /// Gets or sets the hover background color. /// public SKColor HoverBackgroundColor { get; set; } = new SKColor(220, 220, 220); /// /// Gets or sets the active background color. /// public SKColor ActiveBackgroundColor { get; set; } = new SKColor(200, 200, 200); /// /// Gets or sets the bar height. /// public float BarHeight { get; set; } = 28f; /// /// Gets or sets the font size. /// public float FontSize { get; set; } = 13f; /// /// Gets or sets the item padding. /// public float ItemPadding { get; set; } = 12f; protected override SKSize MeasureOverride(SKSize availableSize) { return new SKSize(availableSize.Width, BarHeight); } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { canvas.Save(); // Draw background using var bgPaint = new SKPaint { Color = BackgroundColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(Bounds, bgPaint); // Draw bottom border using var borderPaint = new SKPaint { Color = new SKColor(200, 200, 200), Style = SKPaintStyle.Stroke, StrokeWidth = 1 }; canvas.DrawLine(Bounds.Left, Bounds.Bottom, Bounds.Right, Bounds.Bottom, borderPaint); // Draw menu items using var textPaint = new SKPaint { Color = TextColor, TextSize = FontSize, IsAntialias = true }; float x = Bounds.Left; for (int i = 0; i < _items.Count; i++) { var item = _items[i]; var textBounds = new SKRect(); textPaint.MeasureText(item.Text, ref textBounds); float itemWidth = textBounds.Width + ItemPadding * 2; var itemBounds = new SKRect(x, Bounds.Top, x + itemWidth, Bounds.Bottom); // Draw item background if (i == _openIndex) { using var activePaint = new SKPaint { Color = ActiveBackgroundColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(itemBounds, activePaint); } else if (i == _hoveredIndex) { using var hoverPaint = new SKPaint { Color = HoverBackgroundColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(itemBounds, hoverPaint); } // Draw text float textX = x + ItemPadding; float textY = Bounds.MidY - textBounds.MidY; canvas.DrawText(item.Text, textX, textY, textPaint); item.Bounds = itemBounds; x += itemWidth; } // Draw open flyout _openFlyout?.Draw(canvas); canvas.Restore(); } public override SkiaView? HitTest(float x, float y) { if (!IsVisible) return null; // Check flyout first if (_openFlyout != null) { var flyoutHit = _openFlyout.HitTest(x, y); if (flyoutHit != null) return flyoutHit; } if (Bounds.Contains(x, y)) { return this; } // Close flyout if clicking outside if (_openFlyout != null) { CloseFlyout(); } return null; } public override void OnPointerMoved(PointerEventArgs e) { if (!IsEnabled) return; int newHovered = -1; for (int i = 0; i < _items.Count; i++) { if (_items[i].Bounds.Contains(e.X, e.Y)) { newHovered = i; break; } } if (newHovered != _hoveredIndex) { _hoveredIndex = newHovered; // If a menu is open and we hover another item, open that one if (_openIndex >= 0 && newHovered >= 0 && newHovered != _openIndex) { OpenFlyout(newHovered); } Invalidate(); } base.OnPointerMoved(e); } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled) return; // Check if clicking on flyout if (_openFlyout != null) { _openFlyout.OnPointerPressed(e); if (e.Handled) { CloseFlyout(); return; } } // Check menu bar items for (int i = 0; i < _items.Count; i++) { if (_items[i].Bounds.Contains(e.X, e.Y)) { if (_openIndex == i) { CloseFlyout(); } else { OpenFlyout(i); } e.Handled = true; return; } } // Click outside - close flyout if (_openFlyout != null) { CloseFlyout(); e.Handled = true; } base.OnPointerPressed(e); } private void OpenFlyout(int index) { if (index < 0 || index >= _items.Count) return; var item = _items[index]; _openIndex = index; _openFlyout = new SkiaMenuFlyout { Items = item.Items }; // Position below the menu item float x = item.Bounds.Left; float y = item.Bounds.Bottom; _openFlyout.Position = new SKPoint(x, y); _openFlyout.ItemClicked += OnFlyoutItemClicked; Invalidate(); } private void CloseFlyout() { if (_openFlyout != null) { _openFlyout.ItemClicked -= OnFlyoutItemClicked; _openFlyout = null; } _openIndex = -1; Invalidate(); } private void OnFlyoutItemClicked(object? sender, MenuItemClickedEventArgs e) { CloseFlyout(); } } /// /// Represents a top-level menu bar item. /// public class MenuBarItem { /// /// Gets or sets the display text. /// public string Text { get; set; } = string.Empty; /// /// Gets the menu items. /// public List Items { get; } = new(); /// /// Gets or sets the bounds (set during rendering). /// internal SKRect Bounds { get; set; } } /// /// Represents a menu item. /// public class MenuItem { /// /// Gets or sets the display text. /// public string Text { get; set; } = string.Empty; /// /// Gets or sets the keyboard shortcut text. /// public string? Shortcut { get; set; } /// /// Gets or sets whether this is a separator. /// public bool IsSeparator { get; set; } /// /// Gets or sets whether this item is enabled. /// public bool IsEnabled { get; set; } = true; /// /// Gets or sets whether this item is checked. /// public bool IsChecked { get; set; } /// /// Gets or sets the icon source. /// public string? IconSource { get; set; } /// /// Gets the sub-menu items. /// public List SubItems { get; } = new(); /// /// Event raised when the item is clicked. /// public event EventHandler? Clicked; internal void OnClicked() { Clicked?.Invoke(this, EventArgs.Empty); } } /// /// A dropdown menu flyout. /// public class SkiaMenuFlyout : SkiaView { private int _hoveredIndex = -1; private SKRect _bounds; /// /// Gets or sets the menu items. /// public List Items { get; set; } = new(); /// /// Gets or sets the position. /// public SKPoint Position { get; set; } /// /// Gets or sets the background color. /// public SKColor BackgroundColor { get; set; } = SKColors.White; /// /// Gets or sets the text color. /// public SKColor TextColor { get; set; } = new SKColor(33, 33, 33); /// /// Gets or sets the disabled text color. /// public SKColor DisabledTextColor { get; set; } = new SKColor(160, 160, 160); /// /// Gets or sets the hover background color. /// public SKColor HoverBackgroundColor { get; set; } = new SKColor(230, 230, 230); /// /// Gets or sets the separator color. /// public SKColor SeparatorColor { get; set; } = new SKColor(220, 220, 220); /// /// Gets or sets the font size. /// public float FontSize { get; set; } = 13f; /// /// Gets or sets the item height. /// public float ItemHeight { get; set; } = 28f; /// /// Gets or sets the separator height. /// public float SeparatorHeight { get; set; } = 9f; /// /// Gets or sets the minimum width. /// public float MinWidth { get; set; } = 180f; /// /// Event raised when an item is clicked. /// public event EventHandler? ItemClicked; protected override void OnDraw(SKCanvas canvas, SKRect bounds) { if (Items.Count == 0) return; // Calculate bounds float width = MinWidth; float height = 0; using var textPaint = new SKPaint { TextSize = FontSize, IsAntialias = true }; foreach (var item in Items) { if (item.IsSeparator) { height += SeparatorHeight; } else { height += ItemHeight; var textBounds = new SKRect(); textPaint.MeasureText(item.Text, ref textBounds); float itemWidth = textBounds.Width + 50; // Padding + icon space if (!string.IsNullOrEmpty(item.Shortcut)) { textPaint.MeasureText(item.Shortcut, ref textBounds); itemWidth += textBounds.Width + 20; } width = Math.Max(width, itemWidth); } } _bounds = new SKRect(Position.X, Position.Y, Position.X + width, Position.Y + height); // Draw shadow using var shadowPaint = new SKPaint { ImageFilter = SKImageFilter.CreateDropShadow(0, 2, 8, 8, new SKColor(0, 0, 0, 40)) }; canvas.DrawRect(_bounds, shadowPaint); // Draw background using var bgPaint = new SKPaint { Color = BackgroundColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(_bounds, bgPaint); // Draw border using var borderPaint = new SKPaint { Color = new SKColor(200, 200, 200), Style = SKPaintStyle.Stroke, StrokeWidth = 1 }; canvas.DrawRect(_bounds, borderPaint); // Draw items float y = _bounds.Top; textPaint.Color = TextColor; for (int i = 0; i < Items.Count; i++) { var item = Items[i]; if (item.IsSeparator) { float separatorY = y + SeparatorHeight / 2; using var sepPaint = new SKPaint { Color = SeparatorColor, StrokeWidth = 1 }; canvas.DrawLine(_bounds.Left + 8, separatorY, _bounds.Right - 8, separatorY, sepPaint); y += SeparatorHeight; } else { var itemBounds = new SKRect(_bounds.Left, y, _bounds.Right, y + ItemHeight); // Draw hover background if (i == _hoveredIndex && item.IsEnabled) { using var hoverPaint = new SKPaint { Color = HoverBackgroundColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(itemBounds, hoverPaint); } // Draw check mark if (item.IsChecked) { using var checkPaint = new SKPaint { Color = item.IsEnabled ? TextColor : DisabledTextColor, TextSize = FontSize, IsAntialias = true }; canvas.DrawText("✓", _bounds.Left + 8, y + ItemHeight / 2 + 5, checkPaint); } // Draw text textPaint.Color = item.IsEnabled ? TextColor : DisabledTextColor; canvas.DrawText(item.Text, _bounds.Left + 28, y + ItemHeight / 2 + 5, textPaint); // Draw shortcut if (!string.IsNullOrEmpty(item.Shortcut)) { textPaint.Color = DisabledTextColor; var shortcutBounds = new SKRect(); textPaint.MeasureText(item.Shortcut, ref shortcutBounds); canvas.DrawText(item.Shortcut, _bounds.Right - shortcutBounds.Width - 12, y + ItemHeight / 2 + 5, textPaint); } // Draw submenu arrow if (item.SubItems.Count > 0) { canvas.DrawText("▸", _bounds.Right - 16, y + ItemHeight / 2 + 5, textPaint); } y += ItemHeight; } } } public override SkiaView? HitTest(float x, float y) { if (_bounds.Contains(x, y)) { return this; } return null; } public override void OnPointerMoved(PointerEventArgs e) { if (!_bounds.Contains(e.X, e.Y)) { _hoveredIndex = -1; Invalidate(); return; } float y = _bounds.Top; int newHovered = -1; for (int i = 0; i < Items.Count; i++) { var item = Items[i]; float itemHeight = item.IsSeparator ? SeparatorHeight : ItemHeight; if (e.Y >= y && e.Y < y + itemHeight && !item.IsSeparator) { newHovered = i; break; } y += itemHeight; } if (newHovered != _hoveredIndex) { _hoveredIndex = newHovered; Invalidate(); } } public override void OnPointerPressed(PointerEventArgs e) { if (_hoveredIndex >= 0 && _hoveredIndex < Items.Count) { var item = Items[_hoveredIndex]; if (item.IsEnabled && !item.IsSeparator) { item.OnClicked(); ItemClicked?.Invoke(this, new MenuItemClickedEventArgs(item)); e.Handled = true; } } } } /// /// Event args for menu item clicked. /// public class MenuItemClickedEventArgs : EventArgs { public MenuItem Item { get; } public MenuItemClickedEventArgs(MenuItem item) { Item = item; } }