From 15a854ed48bea51886fa0c07856ec8a2cd2fa262 Mon Sep 17 00:00:00 2001 From: Shewart Date: Sun, 11 Oct 2026 20:51:57 +0200 Subject: [PATCH 1/3] feat: add resizable, navbar and sidebar for MAUI and Avalonia The three components that finish P6, each with a MAUI and an Avalonia version. - resizable (Resizable, ResizablePanel): panels side by side or stacked with a divider to drag. A panel has a share of the space and a minimum size, and groups nest for both directions. - navbar (Navbar, NavbarItem): brand, links and actions in a 56px bar. Below CollapseBelow the links move into a menu under the bar. - sidebar (Sidebar, SidebarGroup, SidebarItem): a 256px column that collapses to a 56px icon rail, with header and footer slots. On Avalonia the links, sidebar items and dividers are also tab stops, and the arrow keys move a divider. --- .../Avalonia.Demo/Components/UI/Navbar.cs | 364 ++++++++ .../Avalonia.Demo/Components/UI/Resizable.cs | 282 +++++++ .../Avalonia.Demo/Components/UI/Sidebar.cs | 396 +++++++++ examples/Avalonia.Demo/MainWindow.axaml | 64 ++ examples/Avalonia.Demo/MainWindow.axaml.cs | 16 + examples/MAUI.Demo/Components/UI/Navbar.cs | 331 ++++++++ examples/MAUI.Demo/Components/UI/Resizable.cs | 225 +++++ examples/MAUI.Demo/Components/UI/Sidebar.cs | 376 +++++++++ examples/MAUI.Demo/MainPage.xaml | 72 ++ examples/MAUI.Demo/MainPage.xaml.cs | 16 + scripts/sync-templates.py | 6 + .../ComponentRegistry.cs | 5 +- .../Templates/NavbarTemplate.cs | 718 ++++++++++++++++ .../Templates/ResizableTemplate.cs | 530 ++++++++++++ .../Templates/SidebarTemplate.cs | 795 ++++++++++++++++++ .../TemplateContentTests.cs | 3 + 16 files changed, 4198 insertions(+), 1 deletion(-) create mode 100644 examples/Avalonia.Demo/Components/UI/Navbar.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Resizable.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Sidebar.cs create mode 100644 examples/MAUI.Demo/Components/UI/Navbar.cs create mode 100644 examples/MAUI.Demo/Components/UI/Resizable.cs create mode 100644 examples/MAUI.Demo/Components/UI/Sidebar.cs create mode 100644 src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/ResizableTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/SidebarTemplate.cs diff --git a/examples/Avalonia.Demo/Components/UI/Navbar.cs b/examples/Avalonia.Demo/Components/UI/Navbar.cs new file mode 100644 index 0000000..9130fac --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Navbar.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Top navigation bar — h-14 border-b bg-background: a brand (icon and title), links, and actions +// at the end. Narrower than CollapseBelow, the links move into a menu that a button opens under +// the bar. +// +// +// +// +// +// +// +public class Navbar : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + // Replaces the icon and title. + public static readonly StyledProperty BrandProperty = + AvaloniaProperty.Register(nameof(Brand)); + + // Shown at the end of the bar (buttons, an avatar, a theme toggle). + public static readonly StyledProperty ActionsProperty = + AvaloniaProperty.Register(nameof(Actions)); + + // The active item's Value. + public static readonly StyledProperty SelectedValueProperty = + AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay); + + // Below this width the links collapse into the menu. 0 never collapses. + public static readonly StyledProperty CollapseBelowProperty = + AvaloniaProperty.Register(nameof(CollapseBelow), 640); + + public static readonly StyledProperty IsMenuOpenProperty = + AvaloniaProperty.Register(nameof(IsMenuOpen), defaultBindingMode: BindingMode.TwoWay); + + private readonly Icon _brandIcon; + private readonly TextBlock _title; + private readonly StackPanel _brandRow; + private readonly Border _brand; + private readonly StackPanel _links; + private readonly Border _actions; + private readonly Border _menuButton; + private readonly Icon _menuIcon; + private readonly StackPanel _menu; + private bool _collapsed; + + static Navbar() + { + TitleProperty.Changed.AddClassHandler((n, _) => n.UpdateBrand()); + IconProperty.Changed.AddClassHandler((n, _) => n.UpdateBrand()); + BrandProperty.Changed.AddClassHandler((n, _) => n.UpdateBrand()); + ActionsProperty.Changed.AddClassHandler((n, e) => n._actions.Child = e.NewValue as Control); + CollapseBelowProperty.Changed.AddClassHandler((n, _) => n.UpdateLayout(force: false)); + IsMenuOpenProperty.Changed.AddClassHandler((n, _) => n.UpdateLayout(force: false)); + SelectedValueProperty.Changed.AddClassHandler((n, _) => + { + n.Refresh(); + n.SelectedValueChanged?.Invoke(n, n.SelectedValue); + }); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public Control? Brand + { + get => GetValue(BrandProperty); + set => SetValue(BrandProperty, value); + } + + public Control? Actions + { + get => GetValue(ActionsProperty); + set => SetValue(ActionsProperty, value); + } + + public string? SelectedValue + { + get => GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + public double CollapseBelow + { + get => GetValue(CollapseBelowProperty); + set => SetValue(CollapseBelowProperty, value); + } + + public bool IsMenuOpen + { + get => GetValue(IsMenuOpenProperty); + set => SetValue(IsMenuOpenProperty, value); + } + + // The NavbarItem children. + [Content] + public Controls Items { get; } = new(); + + public event EventHandler? SelectedValueChanged; + + public Navbar() + { + _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _title = new TextBlock { FontSize = 16, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _brandRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _brandIcon, _title } }; + _brand = new Border { VerticalAlignment = VerticalAlignment.Center, Child = _brandRow }; + _links = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(16, 0, 0, 0) }; + Grid.SetColumn(_links, 1); + _actions = new Border { VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_actions, 2); + + _menuIcon = new Icon { Kind = IconName.Menu, Size = 18, Token = ShellToken.Foreground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _menuButton = new Border + { + Child = _menuIcon, + Width = 36, + Height = 36, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 0, 0), + Cursor = new Cursor(StandardCursorType.Hand), + IsVisible = false + }; + Grid.SetColumn(_menuButton, 3); + ShellFocus.Ring(_menuButton, _menuButton); + _menuButton.PointerEntered += (_, _) => _menuButton.Token(BackgroundProperty, ShellToken.Accent); + _menuButton.PointerExited += (_, _) => + { + _menuButton.ClearToken(BackgroundProperty); + _menuButton.Background = Brushes.Transparent; + }; + _menuButton.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + IsMenuOpen = !IsMenuOpen; + e.Handled = true; + }; + _menuButton.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + IsMenuOpen = !IsMenuOpen; + e.Handled = true; + }; + + var bar = new Grid + { + Height = 56, + Margin = new Thickness(16, 0), + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto"), + Children = { _brand, _links, _actions, _menuButton } + }; + _menu = new StackPanel { Spacing = 2, Margin = new Thickness(12, 0, 12, 12), IsVisible = false }; + var edge = new Border { Height = 1 }; + edge.Token(BackgroundProperty, ShellToken.Border); + + Child = new StackPanel { Children = { bar, _menu, edge } }; + this.Token(BackgroundProperty, ShellToken.Background); + Items.CollectionChanged += (_, _) => UpdateLayout(force: true); + SizeChanged += (_, _) => UpdateLayout(force: false); + UpdateBrand(); + } + + private List ItemList => Items.OfType().ToList(); + + internal void Select(NavbarItem item) + { + if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value; + IsMenuOpen = false; + } + + private void UpdateBrand() + { + _brandIcon.Kind = Icon; + _brandIcon.IsVisible = Icon != IconName.None; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _brand.Child = Brand ?? _brandRow; + } + + // The links live in the bar or, when collapsed, in the menu under it. + private void UpdateLayout(bool force) + { + var items = ItemList; + var width = Bounds.Width; + var collapsed = CollapseBelow > 0 && width > 0 && width < CollapseBelow && items.Count > 0; + if (collapsed != _collapsed || force) + { + _collapsed = collapsed; + _links.Children.Clear(); + _menu.Children.Clear(); + foreach (var item in items) + { + if (collapsed) _menu.Children.Add(item); + else _links.Children.Add(item); + } + } + _menuButton.IsVisible = _collapsed; + _menu.IsVisible = _collapsed && IsMenuOpen; + _menuIcon.Kind = IsMenuOpen && _collapsed ? IconName.X : IconName.Menu; + AutomationProperties.SetName(_menuButton, IsMenuOpen ? "Close menu" : "Open menu"); + Refresh(); + } + + // Items don't track the bar's state: the bar pushes it to them. + private void Refresh() + { + foreach (var item in ItemList) + item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, inMenu: _collapsed); + } +} + +// A link in a Navbar — text-sm font-medium, muted until hovered or active. A tab stop that +// activates with Enter or Space. +public class NavbarItem : Border, IShellFocusable +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // Identifies the item for Navbar.SelectedValue. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private bool _active; + + static NavbarItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public event EventHandler? Clicked; + + public NavbarItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center, Children = { _icon, _label } }; + Height = 36; + Padding = new Thickness(12, 0); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + HorizontalAlignment = HorizontalAlignment.Left; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + Press(); + e.Handled = true; + } + + // Picks the link in code, as a click or Enter/Space would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + this.FindParentOfType()?.Select(this); + Clicked?.Invoke(this, EventArgs.Empty); + } + + internal void Apply(bool isActive, bool inMenu) + { + _active = isActive; + // In the menu each link is a full-width row. + HorizontalAlignment = inMenu ? HorizontalAlignment.Stretch : HorizontalAlignment.Left; + Update(); + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _label.FontWeight = _active ? FontWeight.Medium : FontWeight.Normal; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + + var hovered = IsPointerOver && IsEffectivelyEnabled; + var foreground = _active || hovered ? ShellToken.Foreground : ShellToken.MutedForeground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _icon.Token = foreground; + + if (_active) this.Token(BackgroundProperty, ShellToken.Accent); + else if (hovered) this.Token(BackgroundProperty, ShellToken.Muted); + else + { + // Transparent, not null, so the whole link stays hit-testable. + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + Opacity = IsEnabled ? 1.0 : 0.5; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Resizable.cs b/examples/Avalonia.Demo/Components/UI/Resizable.cs new file mode 100644 index 0000000..1e6443e --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Resizable.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Resizable panels — panels side by side (or stacked) with a hairline between them; drag the +// hairline to move space from one neighbor to the other. Each divider is a tab stop: the arrow +// keys move it. Give the group a size. +// +// ...any control... +// ... +// +public class Resizable : Border +{ + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register(nameof(Orientation), Orientation.Horizontal); + + // The short bar on each divider. The divider can be dragged with or without it. + public static readonly StyledProperty ShowHandleProperty = + AvaloniaProperty.Register(nameof(ShowHandle), true); + + // The divider is a hairline; its grab area reaches this far into the panels on each side. + private const double GrabReach = 4; + private const double KeyStep = 16; + + private readonly Grid _grid = new() { ClipToBounds = true }; + private double _startBefore; + private double _startAfter; + private Point? _dragFrom; + + static Resizable() + { + OrientationProperty.Changed.AddClassHandler((r, _) => r.Rebuild()); + ShowHandleProperty.Changed.AddClassHandler((r, _) => r.Rebuild()); + } + + public Orientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + public bool ShowHandle + { + get => GetValue(ShowHandleProperty); + set => SetValue(ShowHandleProperty, value); + } + + // The ResizablePanel children. + [Content] + public Controls Panels { get; } = new(); + + // Raised when a drag ends or a key moves a divider. + public event EventHandler? Resized; + + public Resizable() + { + Child = _grid; + Panels.CollectionChanged += (_, _) => Rebuild(); + } + + private bool IsHorizontal => Orientation == Orientation.Horizontal; + private List PanelList => Panels.OfType().ToList(); + + private void Rebuild() + { + _grid.Children.Clear(); + _grid.ColumnDefinitions.Clear(); + _grid.RowDefinitions.Clear(); + + var panels = PanelList; + for (var i = 0; i < panels.Count; i++) + { + var panel = panels[i]; + panel.SizeSet -= ApplySizes; + panel.SizeSet += ApplySizes; + if (i > 0) AddTrack(new GridLength(1), CreateHandle(i - 1)); + AddTrack(new GridLength(Math.Max(0.01, panel.Size), GridUnitType.Star), panel); + } + } + + private void AddTrack(GridLength length, Control control) + { + if (IsHorizontal) + { + _grid.ColumnDefinitions.Add(new ColumnDefinition(length)); + Grid.SetColumn(control, _grid.ColumnDefinitions.Count - 1); + Grid.SetRow(control, 0); + } + else + { + _grid.RowDefinitions.Add(new RowDefinition(length)); + Grid.SetRow(control, _grid.RowDefinitions.Count - 1); + Grid.SetColumn(control, 0); + } + _grid.Children.Add(control); + } + + // Panel i sits on track 2i, the divider after it on track 2i + 1. + private void ApplySizes() + { + var panels = PanelList; + for (var i = 0; i < panels.Count; i++) + { + var length = new GridLength(Math.Max(0.01, panels[i].Size), GridUnitType.Star); + if (IsHorizontal && 2 * i < _grid.ColumnDefinitions.Count) _grid.ColumnDefinitions[2 * i].Width = length; + else if (!IsHorizontal && 2 * i < _grid.RowDefinitions.Count) _grid.RowDefinitions[2 * i].Height = length; + } + } + + private Control CreateHandle(int index) + { + var line = new Border(); + line.Token(BackgroundProperty, ShellToken.Border); + if (IsHorizontal) + { + line.Width = 1; + line.HorizontalAlignment = HorizontalAlignment.Center; + } + else + { + line.Height = 1; + line.VerticalAlignment = VerticalAlignment.Center; + } + + // A short rounded bar across the hairline; it also carries the focus ring. + var grip = new Border + { + Width = IsHorizontal ? 5 : 28, + Height = IsHorizontal ? 28 : 5, + BorderThickness = new Thickness(ShowHandle ? 1 : 0), + CornerRadius = new CornerRadius(2.5), + // Not null: a border with no background paints no focus ring. + Background = Brushes.Transparent, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + if (ShowHandle) grip.Token(BackgroundProperty, ShellToken.Border).Token(BorderBrushProperty, ShellToken.Ring); + + var handle = new Border + { + Child = new Panel { Children = { line, grip } }, + // Transparent, not null, so the whole grab area takes the pointer. + Background = Brushes.Transparent, + Margin = IsHorizontal ? new Thickness(-GrabReach, 0) : new Thickness(0, -GrabReach), + ZIndex = 1, + Cursor = new Cursor(IsHorizontal ? StandardCursorType.SizeWestEast : StandardCursorType.SizeNorthSouth) + }; + AutomationProperties.SetName(handle, "Resize"); + ShellFocus.Ring(handle, grip); + + handle.PointerEntered += (_, _) => line.Token(BackgroundProperty, ShellToken.Ring); + handle.PointerExited += (_, _) => + { + if (_dragFrom is null) line.Token(BackgroundProperty, ShellToken.Border); + }; + handle.PointerPressed += (_, e) => + { + if (!e.GetCurrentPoint(handle).Properties.IsLeftButtonPressed || !Start(index)) return; + _dragFrom = e.GetPosition(this); + e.Pointer.Capture(handle); + e.Handled = true; + }; + handle.PointerMoved += (_, e) => + { + if (_dragFrom is not { } from) return; + var now = e.GetPosition(this); + Move(index, IsHorizontal ? now.X - from.X : now.Y - from.Y); + e.Handled = true; + }; + handle.PointerReleased += (_, e) => + { + if (_dragFrom is null) return; + e.Pointer.Capture(null); + e.Handled = true; + }; + handle.PointerCaptureLost += (_, _) => + { + if (_dragFrom is null) return; + _dragFrom = null; + if (!handle.IsPointerOver) line.Token(BackgroundProperty, ShellToken.Border); + Resized?.Invoke(this, EventArgs.Empty); + }; + handle.KeyDown += (_, e) => + { + var back = IsHorizontal ? Key.Left : Key.Up; + var forward = IsHorizontal ? Key.Right : Key.Down; + if (e.Key != back && e.Key != forward) return; + if (Start(index)) + { + Move(index, e.Key == forward ? KeyStep : -KeyStep); + Resized?.Invoke(this, EventArgs.Empty); + } + e.Handled = true; + }; + return handle; + } + + // Remembers the two neighbors' sizes at the start of a move. + private bool Start(int index) + { + var panels = PanelList; + if (index + 1 >= panels.Count) return false; + _startBefore = IsHorizontal ? panels[index].Bounds.Width : panels[index].Bounds.Height; + _startAfter = IsHorizontal ? panels[index + 1].Bounds.Width : panels[index + 1].Bounds.Height; + return _startBefore + _startAfter > 0; + } + + // Space only moves between the two neighbors, and neither goes under its minimum. + private void Move(int index, double delta) + { + var panels = PanelList; + if (index + 1 >= panels.Count) return; + var before = panels[index]; + var after = panels[index + 1]; + var total = _startBefore + _startAfter; + var low = Math.Min(before.MinSize, total / 2); + var high = total - Math.Min(after.MinSize, total / 2); + var size = Math.Clamp(_startBefore + delta, low, high); + var weight = before.Size + after.Size; + before.SetSize(weight * size / total); + after.SetSize(weight - before.Size); + ApplySizes(); + } +} + +// One panel of a Resizable. Size is its share of the space (2 is twice as much as 1). +public class ResizablePanel : Border +{ + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), 1, defaultBindingMode: BindingMode.TwoWay); + + // The smallest the panel can be dragged to, in device-independent pixels. + public static readonly StyledProperty MinSizeProperty = + AvaloniaProperty.Register(nameof(MinSize), 48); + + private bool _dragging; + + static ResizablePanel() + { + SizeProperty.Changed.AddClassHandler((p, _) => + { + if (!p._dragging) p.SizeSet?.Invoke(); + }); + } + + public double Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public double MinSize + { + get => GetValue(MinSizeProperty); + set => SetValue(MinSizeProperty, value); + } + + internal event Action? SizeSet; + + public ResizablePanel() + { + ClipToBounds = true; + } + + // Set by the group during a drag; it applies the new sizes itself. + internal void SetSize(double size) + { + _dragging = true; + Size = size; + _dragging = false; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Sidebar.cs b/examples/Avalonia.Demo/Components/UI/Sidebar.cs new file mode 100644 index 0000000..e58d6e6 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Sidebar.cs @@ -0,0 +1,396 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// App sidebar — a w-64 column (bg-card, border on its inner edge) with a header, scrolling +// groups of items and a footer. Collapsed, it is a w-14 rail that shows the icons only. Put it in +// a column next to your content. +// +// ...any control... +// +// +// +// +// ... +// +// Collapse it with IsCollapsed or Toggle(). +public class Sidebar : Border +{ + public static readonly StyledProperty IsCollapsedProperty = + AvaloniaProperty.Register(nameof(IsCollapsed), defaultBindingMode: BindingMode.TwoWay); + + // The active item's Value. + public static readonly StyledProperty SelectedValueProperty = + AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty ExpandedWidthProperty = + AvaloniaProperty.Register(nameof(ExpandedWidth), 256); + + public static readonly StyledProperty CollapsedWidthProperty = + AvaloniaProperty.Register(nameof(CollapsedWidth), 56); + + // Which edge of the window it sits on; the border is drawn on the other edge. + public static readonly StyledProperty SideProperty = + AvaloniaProperty.Register(nameof(Side)); + + public static readonly StyledProperty HeaderProperty = + AvaloniaProperty.Register(nameof(Header)); + + public static readonly StyledProperty FooterProperty = + AvaloniaProperty.Register(nameof(Footer)); + + private readonly Border _header = new() { Padding = new Thickness(8), IsVisible = false }; + private readonly Border _footer = new() { Padding = new Thickness(8), IsVisible = false }; + private readonly StackPanel _groups = new() { Spacing = 8, Margin = new Thickness(8) }; + private readonly Border _edge = new() { Width = 1 }; + + static Sidebar() + { + IsCollapsedProperty.Changed.AddClassHandler((s, _) => + { + s.Refresh(); + s.Width = s.IsCollapsed ? s.CollapsedWidth : s.ExpandedWidth; + s.IsCollapsedChanged?.Invoke(s, s.IsCollapsed); + }); + SelectedValueProperty.Changed.AddClassHandler((s, _) => + { + s.Refresh(); + s.SelectedValueChanged?.Invoke(s, s.SelectedValue); + }); + ExpandedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth()); + CollapsedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth()); + SideProperty.Changed.AddClassHandler((s, _) => s.UpdateEdge()); + HeaderProperty.Changed.AddClassHandler((s, _) => s.UpdateSlots()); + FooterProperty.Changed.AddClassHandler((s, _) => s.UpdateSlots()); + } + + public bool IsCollapsed + { + get => GetValue(IsCollapsedProperty); + set => SetValue(IsCollapsedProperty, value); + } + + public string? SelectedValue + { + get => GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + public double ExpandedWidth + { + get => GetValue(ExpandedWidthProperty); + set => SetValue(ExpandedWidthProperty, value); + } + + public double CollapsedWidth + { + get => GetValue(CollapsedWidthProperty); + set => SetValue(CollapsedWidthProperty, value); + } + + public SidebarSide Side + { + get => GetValue(SideProperty); + set => SetValue(SideProperty, value); + } + + public Control? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public Control? Footer + { + get => GetValue(FooterProperty); + set => SetValue(FooterProperty, value); + } + + // The SidebarGroup children. + [Content] + public Controls Groups => _groups.Children; + + public event EventHandler? IsCollapsedChanged; + public event EventHandler? SelectedValueChanged; + + public Sidebar() + { + _groups.Children.CollectionChanged += (_, _) => Refresh(); + _edge.Token(BackgroundProperty, ShellToken.Border); + var scroll = new ScrollViewer { Content = _groups, VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }; + Grid.SetRow(scroll, 1); + Grid.SetRow(_footer, 2); + + Child = new Panel + { + Children = + { + new Grid { RowDefinitions = new RowDefinitions("Auto,*,Auto"), Children = { _header, scroll, _footer } }, + _edge + } + }; + ClipToBounds = true; + HorizontalAlignment = HorizontalAlignment.Left; + Width = ExpandedWidth; + this.Token(BackgroundProperty, ShellToken.Card); + UpdateEdge(); + } + + public void Toggle() => IsCollapsed = !IsCollapsed; + + // The width animates only once the sidebar is on screen, so it doesn't slide in at startup. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(); + Transitions = new Transitions + { + new DoubleTransition { Property = WidthProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } + }; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + Transitions = null; + } + + private void SnapWidth() + { + var transitions = Transitions; + Transitions = null; + Width = IsCollapsed ? CollapsedWidth : ExpandedWidth; + Transitions = transitions; + } + + private void UpdateEdge() => + _edge.HorizontalAlignment = Side == SidebarSide.Left ? HorizontalAlignment.Right : HorizontalAlignment.Left; + + private void UpdateSlots() + { + _header.Child = Header; + _header.IsVisible = Header != null; + _footer.Child = Footer; + _footer.IsVisible = Footer != null; + } + + // Groups and items don't track the sidebar's state: the sidebar pushes it to them. + private void Refresh() + { + foreach (var group in this.FindDescendantsOfType()) + group.Apply(IsCollapsed); + foreach (var item in this.FindDescendantsOfType()) + item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, collapsed: IsCollapsed); + } +} + +// A labelled group of SidebarItems — the label is text-xs font-medium in the muted foreground. +public class SidebarGroup : Border +{ + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label)); + + private readonly TextBlock _label; + private readonly StackPanel _items = new() { Spacing = 2 }; + private bool _collapsed; + + static SidebarGroup() + { + LabelProperty.Changed.AddClassHandler((g, _) => g.Update()); + } + + public string? Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + [Content] + public Controls Items => _items.Children; + + public SidebarGroup() + { + _label = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + var header = new Border { Child = _label, Height = 28, Padding = new Thickness(8, 0) }; + Child = new StackPanel { Children = { header, _items } }; + Update(); + } + + internal void Apply(bool collapsed) + { + _collapsed = collapsed; + Update(); + } + + private void Update() + { + _label.Text = Label ?? string.Empty; + if (_label.Parent is Control header) header.IsVisible = !_collapsed && !string.IsNullOrEmpty(Label); + } +} + +// One row of a Sidebar — h-9 rounded-md px-2 text-sm: icon, text and an optional badge; +// hover:bg-accent, and bg-accent font-medium when active. Collapsed, only the icon shows. A tab +// stop that activates with Enter or Space. +public class SidebarItem : Border, IShellFocusable +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // Identifies the item for Sidebar.SelectedValue. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + // Short text at the end of the row, e.g. a count. + public static readonly StyledProperty BadgeProperty = + AvaloniaProperty.Register(nameof(Badge)); + + private readonly Grid _grid; + private readonly Icon _icon; + private readonly TextBlock _label; + private readonly TextBlock _badge; + private bool _active; + private bool _collapsed; + + static SidebarItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + BadgeProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Badge + { + get => GetValue(BadgeProperty); + set => SetValue(BadgeProperty, value); + } + + public event EventHandler? Clicked; + + public SidebarItem() + { + _icon = new Icon { Size = 16, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + Grid.SetColumn(_label, 1); + _badge = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _badge.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + Grid.SetColumn(_badge, 2); + + _grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8, Children = { _icon, _label, _badge } }; + Child = _grid; + Height = 36; + Padding = new Thickness(8, 0); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + Press(); + e.Handled = true; + } + + // Picks the item in code, as a click or Enter/Space would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value; + Clicked?.Invoke(this, EventArgs.Empty); + } + + internal void Apply(bool isActive, bool collapsed) + { + _active = isActive; + _collapsed = collapsed; + Update(); + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _label.FontWeight = _active ? FontWeight.Medium : FontWeight.Normal; + _label.IsVisible = !_collapsed; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _badge.Text = Badge ?? string.Empty; + _badge.IsVisible = !_collapsed && !string.IsNullOrEmpty(Badge); + // Collapsed, the icon column takes the row so the icon sits in the middle of the rail. + _grid.ColumnDefinitions[0].Width = _collapsed ? GridLength.Star : GridLength.Auto; + _grid.ColumnDefinitions[1].Width = _collapsed ? new GridLength(0) : GridLength.Star; + _grid.ColumnSpacing = _collapsed ? 0 : 8; + AutomationProperties.SetName(this, Text ?? string.Empty); + + var foreground = _active ? ShellToken.AccentForeground : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _icon.Token = foreground; + + if (_active || (IsPointerOver && IsEffectivelyEnabled)) this.Token(BackgroundProperty, ShellToken.Accent); + else + { + // Transparent, not null, so the whole row stays hit-testable. + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + Opacity = IsEnabled ? 1.0 : 0.5; + } +} + +public enum SidebarSide { Left, Right } diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 36071f9..12678d1 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -516,6 +516,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 5850417..db34ba0 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -68,6 +68,22 @@ public MainWindow() SourceCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {SourceCard.Title}."; FileTree.SelectedValueChanged += (_, value) => DisplayChoice.Text = $"Tree: {value}."; + DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}."; + DemoSidebar.SelectedValueChanged += (_, value) => + { + SidebarPage.Text = string.IsNullOrEmpty(value) ? string.Empty : char.ToUpperInvariant(value[0]) + value.Substring(1); + LayoutChoice.Text = $"Sidebar: {value}."; + }; + DemoSidebar.IsCollapsedChanged += (_, collapsed) => + { + SidebarBrand.IsVisible = !collapsed; + LayoutChoice.Text = $"Sidebar: {(collapsed ? "collapsed" : "expanded")}."; + }; + SidebarToggle.Clicked += (_, _) => DemoSidebar.Toggle(); + SidebarLogOut.Clicked += (_, _) => LayoutChoice.Text = "Sidebar: Log out."; + DemoResizable.Resized += (_, _) => + LayoutChoice.Text = $"Resizable: first panel is {ResizableFirst.Size / DemoResizable.Panels.OfType().Sum(p => p.Size):P0} of the width."; + for (var i = 1; i <= 30; i++) { var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 }; diff --git a/examples/MAUI.Demo/Components/UI/Navbar.cs b/examples/MAUI.Demo/Components/UI/Navbar.cs new file mode 100644 index 0000000..901581c --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Navbar.cs @@ -0,0 +1,331 @@ +using System.Collections.ObjectModel; +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Top navigation bar — h-14 border-b bg-background: a brand (icon and title), links, and actions +// at the end. Narrower than CollapseBelow, the links move into a menu that a button opens under +// the bar. +// +// +// +// +// +// +// +[ContentProperty(nameof(Items))] +public partial class Navbar : ContentView +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(Navbar), string.Empty, + propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand()); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(Navbar), IconName.None, + propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand()); + + // Replaces the icon and title. + public static readonly BindableProperty BrandProperty = + BindableProperty.Create(nameof(Brand), typeof(View), typeof(Navbar), null, + propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand()); + + // Shown at the end of the bar (buttons, an avatar, a theme toggle). + public static readonly BindableProperty ActionsProperty = + BindableProperty.Create(nameof(Actions), typeof(View), typeof(Navbar), null, + propertyChanged: (b, o, n) => ((Navbar)b)._actions.Content = n as View); + + // The active item's Value. + public static readonly BindableProperty SelectedValueProperty = + BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(Navbar), null, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Navbar)b).OnSelectedValueChanged()); + + // Below this width the links collapse into the menu. 0 never collapses. + public static readonly BindableProperty CollapseBelowProperty = + BindableProperty.Create(nameof(CollapseBelow), typeof(double), typeof(Navbar), 640d, + propertyChanged: (b, o, n) => ((Navbar)b).UpdateLayout()); + + public static readonly BindableProperty IsMenuOpenProperty = + BindableProperty.Create(nameof(IsMenuOpen), typeof(bool), typeof(Navbar), false, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Navbar)b).UpdateLayout()); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public View? Brand + { + get => (View?)GetValue(BrandProperty); + set => SetValue(BrandProperty, value); + } + + public View? Actions + { + get => (View?)GetValue(ActionsProperty); + set => SetValue(ActionsProperty, value); + } + + public string? SelectedValue + { + get => (string?)GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + public double CollapseBelow + { + get => (double)GetValue(CollapseBelowProperty); + set => SetValue(CollapseBelowProperty, value); + } + + public bool IsMenuOpen + { + get => (bool)GetValue(IsMenuOpenProperty); + set => SetValue(IsMenuOpenProperty, value); + } + + public IList Items => _items; + + public event EventHandler? SelectedValueChanged; + + private readonly ObservableCollection _items = new(); + private readonly Icon _brandIcon; + private readonly Label _title; + private readonly ContentView _brand; + private readonly HorizontalStackLayout _brandRow; + private readonly HorizontalStackLayout _links; + private readonly ContentView _actions; + private readonly Border _menuButton; + private readonly Icon _menuIcon; + private readonly VerticalStackLayout _menu; + private bool _collapsed; + + public Navbar() + { + _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalOptions = LayoutOptions.Center, IsVisible = false }; + _title = new Label { FontSize = 16, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.NoWrap }; + _title.Token(Label.TextColorProperty, ShellToken.Foreground); + _brandRow = new HorizontalStackLayout { Spacing = 8, Children = { _brandIcon, _title } }; + _brand = new ContentView { VerticalOptions = LayoutOptions.Center, Content = _brandRow }; + _links = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(16, 0, 0, 0) }; + _actions = new ContentView { VerticalOptions = LayoutOptions.Center }; + + _menuIcon = new Icon { Name = IconName.Menu, Size = 18, Token = ShellToken.Foreground }; + _menuButton = new Border + { + Content = _menuIcon, + WidthRequest = 36, + HeightRequest = 36, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(4, 0, 0, 0), + IsVisible = false + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => _menuButton.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { _menuButton.ClearValue(VisualElement.BackgroundColorProperty); _menuButton.BackgroundColor = Colors.Transparent; }; + _menuButton.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => IsMenuOpen = !IsMenuOpen; + _menuButton.GestureRecognizers.Add(tap); + + var bar = new Grid + { + HeightRequest = 56, + Padding = new Thickness(16, 0), + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Auto) + } + }; + bar.Add(_brand, 0, 0); + bar.Add(_links, 1, 0); + bar.Add(_actions, 2, 0); + bar.Add(_menuButton, 3, 0); + + _menu = new VerticalStackLayout { Spacing = 2, Padding = new Thickness(12, 0, 12, 12), IsVisible = false }; + var edge = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + edge.Token(BoxView.ColorProperty, ShellToken.Border); + + var root = new VerticalStackLayout { Spacing = 0, Children = { bar, _menu, edge } }; + root.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + Content = root; + + _items.CollectionChanged += (_, _) => UpdateLayout(force: true); + SizeChanged += (_, _) => UpdateLayout(); + UpdateBrand(); + } + + internal void Select(NavbarItem item) + { + if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value; + IsMenuOpen = false; + } + + private void OnSelectedValueChanged() + { + Refresh(); + SelectedValueChanged?.Invoke(this, SelectedValue); + } + + private void UpdateBrand() + { + _brandIcon.Name = Icon; + _brandIcon.IsVisible = Icon != IconName.None; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _brand.Content = Brand ?? _brandRow; + } + + private void UpdateLayout() => UpdateLayout(force: false); + + // The links live in the bar or, when collapsed, in the menu under it. + private void UpdateLayout(bool force) + { + var collapsed = CollapseBelow > 0 && Width > 0 && Width < CollapseBelow && _items.Count > 0; + if (collapsed != _collapsed || force) + { + _collapsed = collapsed; + _links.Children.Clear(); + _menu.Children.Clear(); + foreach (var item in _items) + { + if (collapsed) _menu.Children.Add(item); + else _links.Children.Add(item); + } + } + _menuButton.IsVisible = _collapsed; + _menu.IsVisible = _collapsed && IsMenuOpen; + _menuIcon.Name = IsMenuOpen && _collapsed ? IconName.X : IconName.Menu; + SemanticProperties.SetDescription(_menuButton, IsMenuOpen ? "Close menu" : "Open menu"); + Refresh(); + } + + // Items don't track the bar's state: the bar pushes it to them. + private void Refresh() + { + foreach (var item in _items) + item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, inMenu: _collapsed); + } +} + +// A link in a Navbar — text-sm font-medium, muted until hovered or active. +public partial class NavbarItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(NavbarItem), string.Empty, + propertyChanged: (b, o, n) => ((NavbarItem)b).Update()); + + // Identifies the item for Navbar.SelectedValue. + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(NavbarItem), null); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(NavbarItem), IconName.None, + propertyChanged: (b, o, n) => ((NavbarItem)b).Update()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => (string?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Border _border; + private readonly Icon _icon; + private readonly Label _label; + private bool _active; + private bool _hovered; + + public NavbarItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center }; + _label = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap }; + _border = new Border + { + Content = new HorizontalStackLayout { Spacing = 8, VerticalOptions = LayoutOptions.Center, Children = { _icon, _label } }, + HeightRequest = 36, + Padding = new Thickness(12, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; Update(); }; + pointer.PointerExited += (_, _) => { _hovered = false; Update(); }; + _border.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); }; + _border.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, Press); + + Content = _border; + Update(); + } + + private void Press() + { + if (!IsEnabled) return; + this.FindParentOfType()?.Select(this); + Clicked?.Invoke(this, EventArgs.Empty); + } + + internal void Apply(bool isActive, bool inMenu) + { + _active = isActive; + // In the menu each link is a full-width row. + HorizontalOptions = inMenu ? LayoutOptions.Fill : LayoutOptions.Start; + Update(); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _label.FontAttributes = _active ? FontAttributes.Bold : FontAttributes.None; + _icon.Name = Icon; + _icon.IsVisible = Icon != IconName.None; + + var foreground = _active || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground; + _label.Token(Label.TextColorProperty, foreground); + _icon.Token = foreground; + + if (_active) + _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + else if (_hovered && IsEnabled) + _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted); + else + { + _border.ClearValue(VisualElement.BackgroundColorProperty); + _border.BackgroundColor = Colors.Transparent; + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/Resizable.cs b/examples/MAUI.Demo/Components/UI/Resizable.cs new file mode 100644 index 0000000..5001659 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Resizable.cs @@ -0,0 +1,225 @@ +using System.Collections.ObjectModel; +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Resizable panels — panels side by side (or stacked) with a hairline between them; drag the +// hairline to move space from one neighbor to the other. Give the group a size. +// +// ...any view... +// ... +// +[ContentProperty(nameof(Panels))] +public partial class Resizable : ContentView +{ + public static readonly BindableProperty OrientationProperty = + BindableProperty.Create(nameof(Orientation), typeof(StackOrientation), typeof(Resizable), StackOrientation.Horizontal, + propertyChanged: (b, o, n) => ((Resizable)b).Rebuild()); + + // The short bar on each divider. The divider can be dragged with or without it. + public static readonly BindableProperty ShowHandleProperty = + BindableProperty.Create(nameof(ShowHandle), typeof(bool), typeof(Resizable), true, + propertyChanged: (b, o, n) => ((Resizable)b).Rebuild()); + + public StackOrientation Orientation + { + get => (StackOrientation)GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + public bool ShowHandle + { + get => (bool)GetValue(ShowHandleProperty); + set => SetValue(ShowHandleProperty, value); + } + + public IList Panels => _panels; + + // Raised when a drag ends. + public event EventHandler? Resized; + + // The divider is a hairline; its grab area reaches this far into the panels on each side. + private const double GrabReach = 4; + + private readonly ObservableCollection _panels = new(); + private readonly Grid _grid; + private double _startBefore; + private double _startAfter; + + public Resizable() + { + _grid = new Grid { IsClippedToBounds = true }; + Content = _grid; + _panels.CollectionChanged += (_, _) => Rebuild(); + } + + private bool IsHorizontal => Orientation == StackOrientation.Horizontal; + + private void Rebuild() + { + _grid.Children.Clear(); + _grid.ColumnDefinitions.Clear(); + _grid.RowDefinitions.Clear(); + + for (var i = 0; i < _panels.Count; i++) + { + var panel = _panels[i]; + panel.SizeSet -= ApplySizes; + panel.SizeSet += ApplySizes; + if (i > 0) AddTrack(new GridLength(1), CreateHandle(i - 1)); + AddTrack(new GridLength(Math.Max(0.01, panel.Size), GridUnitType.Star), panel); + } + } + + private void AddTrack(GridLength length, View view) + { + if (IsHorizontal) + { + _grid.ColumnDefinitions.Add(new ColumnDefinition(length)); + _grid.Add(view, _grid.ColumnDefinitions.Count - 1, 0); + } + else + { + _grid.RowDefinitions.Add(new RowDefinition(length)); + _grid.Add(view, 0, _grid.RowDefinitions.Count - 1); + } + } + + // Panel i sits on track 2i, the divider after it on track 2i + 1. + private void ApplySizes() + { + for (var i = 0; i < _panels.Count; i++) + { + var length = new GridLength(Math.Max(0.01, _panels[i].Size), GridUnitType.Star); + if (IsHorizontal && 2 * i < _grid.ColumnDefinitions.Count) _grid.ColumnDefinitions[2 * i].Width = length; + else if (!IsHorizontal && 2 * i < _grid.RowDefinitions.Count) _grid.RowDefinitions[2 * i].Height = length; + } + } + + private View CreateHandle(int index) + { + var line = new BoxView { BackgroundColor = Colors.Transparent }; + line.Token(BoxView.ColorProperty, ShellToken.Border); + if (IsHorizontal) + { + line.WidthRequest = 1; + line.HorizontalOptions = LayoutOptions.Center; + } + else + { + line.HeightRequest = 1; + line.VerticalOptions = LayoutOptions.Center; + } + + var handle = new Grid + { + BackgroundColor = Colors.Transparent, + Margin = IsHorizontal ? new Thickness(-GrabReach, 0) : new Thickness(0, -GrabReach), + ZIndex = 1, + Children = { line } + }; + if (ShowHandle) + { + // A short rounded bar across the hairline. + var grip = new Border + { + WidthRequest = IsHorizontal ? 5 : 28, + HeightRequest = IsHorizontal ? 28 : 5, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 2.5 }, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + }; + grip.Token(VisualElement.BackgroundColorProperty, ShellToken.Border); + grip.Token(Border.StrokeProperty, ShellToken.Ring); + handle.Children.Add(grip); + } + SemanticProperties.SetDescription(handle, "Resize"); + + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => line.Token(BoxView.ColorProperty, ShellToken.Ring); + pointer.PointerExited += (_, _) => line.Token(BoxView.ColorProperty, ShellToken.Border); + handle.GestureRecognizers.Add(pointer); + + var pan = new PanGestureRecognizer(); + pan.PanUpdated += (_, e) => OnPan(index, e); + handle.GestureRecognizers.Add(pan); + return handle; + } + + private void OnPan(int index, PanUpdatedEventArgs e) + { + if (index + 1 >= _panels.Count) return; + var before = _panels[index]; + var after = _panels[index + 1]; + switch (e.StatusType) + { + case GestureStatus.Started: + _startBefore = IsHorizontal ? before.Width : before.Height; + _startAfter = IsHorizontal ? after.Width : after.Height; + break; + case GestureStatus.Running: + var total = _startBefore + _startAfter; + if (total <= 0) return; + // Space only moves between the two neighbors, and neither goes under its minimum. + var low = Math.Min(before.MinSize, total / 2); + var high = total - Math.Min(after.MinSize, total / 2); + var size = Math.Clamp(_startBefore + (IsHorizontal ? e.TotalX : e.TotalY), low, high); + var weight = before.Size + after.Size; + before.SetSize(weight * size / total); + after.SetSize(weight - before.Size); + ApplySizes(); + break; + case GestureStatus.Completed: + case GestureStatus.Canceled: + Resized?.Invoke(this, EventArgs.Empty); + break; + } + } +} + +// One panel of a Resizable. Size is its share of the space (2 is twice as much as 1). +public partial class ResizablePanel : ContentView +{ + public static readonly BindableProperty SizeProperty = + BindableProperty.Create(nameof(Size), typeof(double), typeof(ResizablePanel), 1d, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((ResizablePanel)b).OnSizeSet()); + + // The smallest the panel can be dragged to, in device-independent pixels. + public static readonly BindableProperty MinSizeProperty = + BindableProperty.Create(nameof(MinSize), typeof(double), typeof(ResizablePanel), 48d); + + public double Size + { + get => (double)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public double MinSize + { + get => (double)GetValue(MinSizeProperty); + set => SetValue(MinSizeProperty, value); + } + + internal event Action? SizeSet; + + private bool _dragging; + + public ResizablePanel() + { + IsClippedToBounds = true; + } + + // Set by the group during a drag; it applies the new sizes itself. + internal void SetSize(double size) + { + _dragging = true; + Size = size; + _dragging = false; + } + + private void OnSizeSet() + { + if (!_dragging) SizeSet?.Invoke(); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Sidebar.cs b/examples/MAUI.Demo/Components/UI/Sidebar.cs new file mode 100644 index 0000000..512a79d --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Sidebar.cs @@ -0,0 +1,376 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// App sidebar — a w-64 column (bg-card, border on its inner edge) with a header, scrolling +// groups of items and a footer. Collapsed, it is a w-14 rail that shows the icons only. Put it in +// a column next to your content, or inside a Sheet on phones. +// +// ...any view... +// +// +// +// +// ... +// +// Collapse it with IsCollapsed or Toggle(). +[ContentProperty(nameof(Groups))] +public partial class Sidebar : ContentView +{ + public static readonly BindableProperty IsCollapsedProperty = + BindableProperty.Create(nameof(IsCollapsed), typeof(bool), typeof(Sidebar), false, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Sidebar)b).OnCollapsedChanged()); + + // The active item's Value. + public static readonly BindableProperty SelectedValueProperty = + BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(Sidebar), null, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Sidebar)b).OnSelectedValueChanged()); + + public static readonly BindableProperty ExpandedWidthProperty = + BindableProperty.Create(nameof(ExpandedWidth), typeof(double), typeof(Sidebar), 256d, + propertyChanged: (b, o, n) => ((Sidebar)b).ApplyWidth(animate: false)); + + public static readonly BindableProperty CollapsedWidthProperty = + BindableProperty.Create(nameof(CollapsedWidth), typeof(double), typeof(Sidebar), 56d, + propertyChanged: (b, o, n) => ((Sidebar)b).ApplyWidth(animate: false)); + + // Which edge of the window it sits on; the border is drawn on the other edge. + public static readonly BindableProperty SideProperty = + BindableProperty.Create(nameof(Side), typeof(SidebarSide), typeof(Sidebar), SidebarSide.Left, + propertyChanged: (b, o, n) => ((Sidebar)b).UpdateEdge()); + + public static readonly BindableProperty HeaderProperty = + BindableProperty.Create(nameof(Header), typeof(View), typeof(Sidebar), null, + propertyChanged: (b, o, n) => ((Sidebar)b).UpdateSlots()); + + public static readonly BindableProperty FooterProperty = + BindableProperty.Create(nameof(Footer), typeof(View), typeof(Sidebar), null, + propertyChanged: (b, o, n) => ((Sidebar)b).UpdateSlots()); + + public bool IsCollapsed + { + get => (bool)GetValue(IsCollapsedProperty); + set => SetValue(IsCollapsedProperty, value); + } + + public string? SelectedValue + { + get => (string?)GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + public double ExpandedWidth + { + get => (double)GetValue(ExpandedWidthProperty); + set => SetValue(ExpandedWidthProperty, value); + } + + public double CollapsedWidth + { + get => (double)GetValue(CollapsedWidthProperty); + set => SetValue(CollapsedWidthProperty, value); + } + + public SidebarSide Side + { + get => (SidebarSide)GetValue(SideProperty); + set => SetValue(SideProperty, value); + } + + public View? Header + { + get => (View?)GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public View? Footer + { + get => (View?)GetValue(FooterProperty); + set => SetValue(FooterProperty, value); + } + + public IList Groups => _groups.Children; + + public event EventHandler? IsCollapsedChanged; + public event EventHandler? SelectedValueChanged; + + private const string WidthAnimation = "SidebarWidth"; + + private readonly ContentView _header; + private readonly ContentView _footer; + private readonly VerticalStackLayout _groups; + private readonly BoxView _edge; + + public Sidebar() + { + _header = new ContentView { Padding = new Thickness(8), IsVisible = false }; + _footer = new ContentView { Padding = new Thickness(8), IsVisible = false }; + _groups = new VerticalStackLayout { Spacing = 8, Padding = new Thickness(8) }; + _groups.ChildAdded += (_, _) => Refresh(); + _edge = new BoxView { WidthRequest = 1, BackgroundColor = Colors.Transparent }; + _edge.Token(BoxView.ColorProperty, ShellToken.Border); + + var column = new Grid + { + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star), new RowDefinition(GridLength.Auto) } + }; + column.Add(_header, 0, 0); + column.Add(new ScrollView { Content = _groups, VerticalScrollBarVisibility = ScrollBarVisibility.Never }, 0, 1); + column.Add(_footer, 0, 2); + + var root = new Grid { IsClippedToBounds = true, Children = { column, _edge } }; + root.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); + Content = root; + WidthRequest = ExpandedWidth; + HorizontalOptions = LayoutOptions.Start; + UpdateEdge(); + Loaded += (_, _) => Refresh(); + } + + public void Toggle() => IsCollapsed = !IsCollapsed; + + private void OnCollapsedChanged() + { + Refresh(); + ApplyWidth(animate: true); + IsCollapsedChanged?.Invoke(this, IsCollapsed); + } + + private void OnSelectedValueChanged() + { + Refresh(); + SelectedValueChanged?.Invoke(this, SelectedValue); + } + + private void ApplyWidth(bool animate) + { + var target = IsCollapsed ? CollapsedWidth : ExpandedWidth; + this.AbortAnimation(WidthAnimation); + if (!animate || Handler is null || Width <= 0) + { + WidthRequest = target; + return; + } + new Animation(v => WidthRequest = v, Width, target) + .Commit(this, WidthAnimation, 16, 200, Easing.CubicOut); + } + + private void UpdateEdge() => + _edge.HorizontalOptions = Side == SidebarSide.Left ? LayoutOptions.End : LayoutOptions.Start; + + private void UpdateSlots() + { + _header.Content = Header; + _header.IsVisible = Header != null; + _footer.Content = Footer; + _footer.IsVisible = Footer != null; + } + + // Groups and items don't track the sidebar's state: the sidebar pushes it to them. + private void Refresh() + { + foreach (var group in this.FindDescendantsOfType()) + group.Apply(IsCollapsed); + foreach (var item in this.FindDescendantsOfType()) + item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, collapsed: IsCollapsed); + } +} + +// A labelled group of SidebarItems — the label is text-xs font-medium in the muted foreground. +[ContentProperty(nameof(Items))] +public partial class SidebarGroup : ContentView +{ + public static readonly BindableProperty LabelProperty = + BindableProperty.Create(nameof(Label), typeof(string), typeof(SidebarGroup), string.Empty, + propertyChanged: (b, o, n) => ((SidebarGroup)b).Update()); + + public string Label + { + get => (string)GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public IList Items => _items.Children; + + private readonly Microsoft.Maui.Controls.Label _label; + private readonly VerticalStackLayout _items; + private bool _collapsed; + + public SidebarGroup() + { + _label = new Microsoft.Maui.Controls.Label + { + FontSize = 12, + FontAttributes = FontAttributes.Bold, + HeightRequest = 28, + Padding = new Thickness(8, 0), + VerticalTextAlignment = TextAlignment.Center, + LineBreakMode = LineBreakMode.TailTruncation + }; + _label.Token(Microsoft.Maui.Controls.Label.TextColorProperty, ShellToken.MutedForeground); + _items = new VerticalStackLayout { Spacing = 2 }; + Content = new VerticalStackLayout { Spacing = 0, Children = { _label, _items } }; + Update(); + } + + internal void Apply(bool collapsed) + { + _collapsed = collapsed; + Update(); + } + + private void Update() + { + _label.Text = Label ?? string.Empty; + _label.IsVisible = !_collapsed && !string.IsNullOrEmpty(Label); + } +} + +// One row of a Sidebar — h-9 rounded-md px-2 text-sm: icon, text and an optional badge; +// hover:bg-accent, and bg-accent font-medium when active. Collapsed, only the icon shows. +public partial class SidebarItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(SidebarItem), string.Empty, + propertyChanged: (b, o, n) => ((SidebarItem)b).Update()); + + // Identifies the item for Sidebar.SelectedValue. + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(SidebarItem), null); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(SidebarItem), IconName.None, + propertyChanged: (b, o, n) => ((SidebarItem)b).Update()); + + // Short text at the end of the row, e.g. a count. + public static readonly BindableProperty BadgeProperty = + BindableProperty.Create(nameof(Badge), typeof(string), typeof(SidebarItem), string.Empty, + propertyChanged: (b, o, n) => ((SidebarItem)b).Update()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => (string?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Badge + { + get => (string)GetValue(BadgeProperty); + set => SetValue(BadgeProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Border _row; + private readonly Grid _grid; + private readonly Icon _icon; + private readonly Label _label; + private readonly Label _badge; + private bool _active; + private bool _collapsed; + private bool _hovered; + + public SidebarItem() + { + _icon = new Icon { Size = 16, VerticalOptions = LayoutOptions.Center, HorizontalOptions = LayoutOptions.Center }; + _label = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.TailTruncation }; + _badge = new Label { FontSize = 12, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, IsVisible = false }; + _badge.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + _grid = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + } + }; + _grid.Add(_icon, 0, 0); + _grid.Add(_label, 1, 0); + _grid.Add(_badge, 2, 0); + + _row = new Border + { + Content = _grid, + HeightRequest = 36, + Padding = new Thickness(8, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; Update(); }; + pointer.PointerExited += (_, _) => { _hovered = false; Update(); }; + _row.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); }; + _row.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, Press); + + Content = _row; + Update(); + } + + private void Press() + { + if (!IsEnabled) return; + if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value; + Clicked?.Invoke(this, EventArgs.Empty); + } + + internal void Apply(bool isActive, bool collapsed) + { + _active = isActive; + _collapsed = collapsed; + Update(); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _label.FontAttributes = _active ? FontAttributes.Bold : FontAttributes.None; + _label.IsVisible = !_collapsed; + _icon.Name = Icon; + _icon.IsVisible = Icon != IconName.None; + _badge.Text = Badge ?? string.Empty; + _badge.IsVisible = !_collapsed && !string.IsNullOrEmpty(Badge); + // Collapsed, the icon column takes the row so the icon sits in the middle of the rail. + _grid.ColumnDefinitions[0].Width = _collapsed ? GridLength.Star : GridLength.Auto; + _grid.ColumnDefinitions[1].Width = _collapsed ? new GridLength(0) : GridLength.Star; + _grid.ColumnSpacing = _collapsed ? 0 : 8; + SemanticProperties.SetDescription(this, Text ?? string.Empty); + + var foreground = _active ? ShellToken.AccentForeground : ShellToken.Foreground; + _label.Token(Label.TextColorProperty, foreground); + _icon.Token = foreground; + + if (_active || (_hovered && IsEnabled)) + _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + else + { + _row.ClearValue(VisualElement.BackgroundColorProperty); + _row.BackgroundColor = Colors.Transparent; + } + } +} + +public enum SidebarSide { Left, Right } diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 3aa413c..edcf061 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -456,6 +456,78 @@ + +