diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index e7ef8d7..719e72e 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -112,6 +112,8 @@ Same names, properties and variants as MAUI, except: | Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` | | Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) | | Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) | +| ImageViewer | `Source` is an `ImageSource`; `Aspect`; pinch zooms | `Source` is an `IImage` (a resource path such as `/Assets/photo.jpg`); `Stretch` instead of `Aspect`; the wheel and the + / - / 0 keys zoom; the thumbnail and the controls are tab stops | +| PrevNextNav | Tap a card | The cards are also tab stops (Enter / Space) | | ChatInput | The send button sends | Also Enter sends and Shift+Enter starts a new line | | DataTable | Tap a header to sort | Sortable headers are also tab stops (Enter / Space) and keep focus after sorting; a click on a row's checkbox does not raise `RowTapped` | | Command | Enter picks the highlighted item (the first match) | Up / Down move the highlight and Enter picks it; `Value`, `Text` and the other strings are nullable; children are collected in `Groups` and `Items` | @@ -1120,6 +1122,24 @@ the current one, default 1), `ShowLabels` (text next to the chevrons) · **Event --- +### Prev Next Nav +Previous and next links for the foot of a page: two bordered cards side by side, each a small +"Previous" or "Next" over the page's name. A side with no label is left empty. + +```bash +shellui-native add prev-next-nav +``` + +**Properties:** `PreviousLabel`, `NextLabel`, `PreviousText` ("Previous"), `NextText` ("Next"), +`IsEnabled` · **Events:** `PreviousClicked`, `NextClicked` + +```xml + +``` + +--- + ## Data Display & Feedback ### Badge @@ -1333,6 +1353,29 @@ with `HeightRequest` instead) · **Events:** `PositionChanged` · **Methods:** ` ``` +### Image Viewer +A thumbnail that opens the image over the page on a dark backdrop, with zoom out, reset, zoom in +and close controls. A tap on the backdrop or Escape closes it. Give it a size: the thumbnail +fills it. + +```bash +shellui-native add image-viewer +``` + +**Properties:** `Source`, `ThumbnailSource` (a smaller file for the page; `Source` then loads +only in the viewer), `Description` (for screen readers), `Aspect` (how the thumbnail fills its +box; AspectFill), `Open` (two-way), `Zoom` (read-only; 0.5 to 4, 1 fits the page) · +**Events:** `OpenChanged` · **Methods:** `SetOpen(bool)`, `ZoomIn()`, `ZoomOut()`, `ResetZoom()` + +```xml + +``` + +Zooming scales the image around its centre; it does not pan. + +--- + ### Empty State Placeholder for an empty list or screen: icon in a muted tile, title, description and optional actions, centered. `Bordered="True"` adds a dashed outline. diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 9f95050..d3163cc 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell Last revised: **2026-10-11** (second prerelease, `0.1.0-alpha.2`). -**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form. +**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form, image-viewer, prev-next-nav. --- @@ -296,9 +296,9 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms **After `0.1.0-alpha.2`, from ShellUI's Blazor set** (both platforms): button-group ✅, input-group ✅ and menubar ✅ (2026-10-11), then command ✅ (with a command palette) and navigation-menu ✅, then data-table ✅ (client-side filter, sort, selection and pages; -2026-10-12), then chat ✅ and form ✅ (2026-10-13; 71 families, 102 CLI targets). +2026-10-12), then chat ✅ and form ✅, then image-viewer ✅ and prev-next-nav ✅ (2026-10-13; +73 families, 104 CLI targets). -**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (image-viewer, -prev-next-nav, …). qr-code waits, like the icon switch, for NuGet package dependencies in the +**Backlog:** chart (P7). qr-code waits, like the icon switch, for NuGet package dependencies in the CLI: it needs a QR encoder. Not in the native data-table yet: row actions and server-side paging. diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 3e11e51..12ad79d 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -20,8 +20,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni layout, navigation and pickers on both platforms (PR #14) merged release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16), button-group, input-group and menubar (PR #17), command and navigation-menu (PR #18), - data-table (PR #19) merged - └─ feat/chat-form ← chat and form from ShellUI's Blazor set (active) + data-table (PR #19), chat and form (PR #20) merged + └─ feat/image-viewer-prev-next ← image-viewer and prev-next-nav (active) └─ feat/winui ← Phase 3 (conditional) ``` @@ -38,8 +38,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#19 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | -| `feat/chat-form` | `main` | **Active** | chat and form for MAUI and Avalonia (105 registry entries, 102 CLI targets, 71 families) | +| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#20 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | +| `feat/image-viewer-prev-next` | `main` | **Active** | image-viewer and prev-next-nav for MAUI and Avalonia (107 registry entries, 104 CLI targets, 73 families) | --- @@ -536,6 +536,9 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. - [x] chat and form (`feat/chat-form`): Chat, ChatMessage and ChatInput (the message's name property is `Sender` on both platforms, since Avalonia controls already have a `Name`); Form and FormField, where validation stays with the app and the form only reports field errors +- [x] image-viewer and prev-next-nav (`feat/image-viewer-prev-next`): ImageViewer is a + `ShellOverlayHost` whose trigger is its own thumbnail; zoom scales the image, no panning. + With these, the Blazor set is ported except the chart family and qr-code - [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so components change little: token tinting binds the package's `Color` (MAUI) or `Foreground` diff --git a/examples/Avalonia.Demo/Components/UI/ImageViewer.cs b/examples/Avalonia.Demo/Components/UI/ImageViewer.cs new file mode 100644 index 0000000..de73b7d --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/ImageViewer.cs @@ -0,0 +1,285 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Image viewer — a rounded-md thumbnail that opens the image over the window on a dark backdrop, +// with zoom out, reset, zoom in and close controls. The wheel and the + / - / 0 keys zoom too; a +// click on the backdrop or Escape closes it. Give it a size: the thumbnail fills it. +// +// ThumbnailSource shows a smaller file in the page and Source only in the viewer. +public class ImageViewer : ShellOverlayHost, IShellFocusable +{ + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + public static readonly StyledProperty ThumbnailSourceProperty = + AvaloniaProperty.Register(nameof(ThumbnailSource)); + + // What the image shows, for screen readers. + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + // How the thumbnail fills its box. + public static readonly StyledProperty StretchProperty = + AvaloniaProperty.Register(nameof(Stretch), Stretch.UniformToFill); + + public const double MinZoom = 0.5; + public const double MaxZoom = 4; + + private readonly Image _thumbnailImage = new() { Stretch = Stretch.UniformToFill, RenderTransformOrigin = RelativePoint.Center }; + private readonly Border _thumbnail; + private readonly ImageViewerContent _content; + + static ImageViewer() + { + SourceProperty.Changed.AddClassHandler((v, _) => v.UpdateSources()); + ThumbnailSourceProperty.Changed.AddClassHandler((v, _) => v.UpdateSources()); + DescriptionProperty.Changed.AddClassHandler((v, _) => v.UpdateSources()); + StretchProperty.Changed.AddClassHandler((v, e) => v._thumbnailImage.Stretch = (Stretch)e.NewValue!); + } + + public IImage? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + public IImage? ThumbnailSource + { + get => GetValue(ThumbnailSourceProperty); + set => SetValue(ThumbnailSourceProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public Stretch Stretch + { + get => GetValue(StretchProperty); + set => SetValue(StretchProperty, value); + } + + // 1 is the size that fits the window. + public double Zoom { get; private set; } = 1; + + public ImageViewer() + { + _thumbnailImage.Transitions = new Transitions + { + new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } + }; + _thumbnail = new Border + { + Child = _thumbnailImage, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + ClipToBounds = true, + Cursor = new Cursor(StandardCursorType.Hand) + }; + // The thumbnail grows a little under the pointer. + _thumbnail.PointerEntered += (_, _) => _thumbnailImage.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse("scale(1.05)"); + _thumbnail.PointerExited += (_, _) => _thumbnailImage.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse("scale(1)"); + _thumbnail.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + SetOpen(true); + e.Handled = true; + }; + + _content = new ImageViewerContent(this); + Items.Add(_thumbnail); + Items.Add(_content); + ShellFocus.Ring(this, _thumbnail); + OpenChanged += (_, open) => + { + if (open) SetZoom(1); + }; + } + + public void ZoomIn() => SetZoom(Zoom + 0.25); + public void ZoomOut() => SetZoom(Zoom - 0.25); + public void ResetZoom() => SetZoom(1); + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (Open || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space)) return; + SetOpen(true); + e.Handled = true; + } + + internal void SetZoom(double zoom) + { + Zoom = Math.Clamp(zoom, MinZoom, MaxZoom); + _content.ShowZoom(Zoom); + } + + private void UpdateSources() + { + _thumbnailImage.Source = ThumbnailSource ?? Source; + _content.Image.Source = Source; + AutomationProperties.SetName(this, string.IsNullOrEmpty(Description) ? "View image" : $"View {Description}"); + AutomationProperties.SetName(_content.Image, Description ?? string.Empty); + } +} + +// The backdrop, image and controls of an open ImageViewer. +internal sealed class ImageViewerContent : Border, IShellOverlayContent +{ + public Image Image { get; } = new() + { + Stretch = Stretch.Uniform, + StretchDirection = StretchDirection.DownOnly, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + RenderTransformOrigin = RelativePoint.Center + }; + + private readonly ImageViewer _viewer; + private readonly Border _zoomOut; + private readonly Border _zoomIn; + private readonly TextBlock _percent; + + public ImageViewerContent(ImageViewer viewer) + { + _viewer = viewer; + // bg-black/80 in both themes. + var backdrop = new Border { Background = new SolidColorBrush(Colors.Black, 0.8) }; + backdrop.PointerPressed += (_, e) => + { + viewer.SetOpen(false); + e.Handled = true; + }; + + Image.Transitions = new Transitions + { + new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } + }; + // Clicks on the image must not reach the backdrop behind it. + Image.PointerPressed += (_, e) => e.Handled = true; + + _percent = new TextBlock { FontSize = 12, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _zoomOut = Control(new Icon { Kind = IconName.Minus, Size = 16, Color = Colors.White }, "Zoom out", viewer.ZoomOut); + var reset = Control(_percent, "Reset zoom", viewer.ResetZoom); + reset.Width = 52; + _zoomIn = Control(new Icon { Kind = IconName.Plus, Size = 16, Color = Colors.White }, "Zoom in", viewer.ZoomIn); + var close = Control(new Icon { Kind = IconName.X, Size = 16, Color = Colors.White }, "Close", () => viewer.SetOpen(false)); + + var controls = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Margin = new Thickness(16), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + Children = { _zoomOut, reset, _zoomIn, close } + }; + + Child = new Panel { Children = { backdrop, Image, controls } }; + ClipToBounds = true; + // max-h-[90vh] max-w-[90vw] + SizeChanged += (_, e) => + { + Image.MaxWidth = e.NewSize.Width * 0.9; + Image.MaxHeight = e.NewSize.Height * 0.9; + }; + ShowZoom(1); + } + + public void ShowZoom(double zoom) + { + _percent.Text = $"{zoom * 100:0}%"; + _zoomOut.Opacity = zoom <= ImageViewer.MinZoom ? 0.5 : 1; + _zoomIn.Opacity = zoom >= ImageViewer.MaxZoom ? 0.5 : 1; + Image.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse( + FormattableString.Invariant($"scale({zoom})")); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.OemPlus or Key.Add: + _viewer.ZoomIn(); + break; + case Key.OemMinus or Key.Subtract: + _viewer.ZoomOut(); + break; + case Key.D0 or Key.NumPad0: + _viewer.ResetZoom(); + break; + default: + return; + } + e.Handled = true; + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + if (e.Delta.Y > 0) _viewer.ZoomIn(); + else if (e.Delta.Y < 0) _viewer.ZoomOut(); + e.Handled = true; + } + + // h-9 w-9 rounded-md bg-white/10 text-white hover:bg-white/20; a tab stop (Enter / Space). + private static Border Control(Control content, string name, Action action) + { + content.HorizontalAlignment = HorizontalAlignment.Center; + content.VerticalAlignment = VerticalAlignment.Center; + var normal = new SolidColorBrush(Colors.White, 0.1); + var hover = new SolidColorBrush(Colors.White, 0.2); + var control = new Border + { + Child = content, + Width = 36, + Height = 36, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = normal, + Cursor = new Cursor(StandardCursorType.Hand) + }; + AutomationProperties.SetName(control, name); + ShellFocus.Ring(control, control); + control.PointerEntered += (_, _) => control.Background = hover; + control.PointerExited += (_, _) => control.Background = normal; + control.PointerPressed += (_, e) => e.Handled = true; + control.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + action(); + e.Handled = true; + }; + control.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + action(); + e.Handled = true; + }; + return control; + } + + public async Task AnimateAsync(bool open) + { + if (open) + { + ShellMotion.Set(this, 0, "scale(1)"); + await ShellMotion.To(this, 1, "scale(1)", 150, new CubicEaseOut()); + } + else + { + await ShellMotion.To(this, 0, "scale(1)", 120, new CubicEaseIn()); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/PrevNextNav.cs b/examples/Avalonia.Demo/Components/UI/PrevNextNav.cs new file mode 100644 index 0000000..2fcd63d --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/PrevNextNav.cs @@ -0,0 +1,133 @@ +using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Previous / next links for the foot of a page — two rounded-md bordered cards side by side, +// each a muted "Previous" or "Next" over the page's name. A side with no label is left empty. +// The cards are tab stops (Enter / Space). +// +public class PrevNextNav : Border +{ + public static readonly StyledProperty PreviousLabelProperty = + AvaloniaProperty.Register(nameof(PreviousLabel)); + + public static readonly StyledProperty NextLabelProperty = + AvaloniaProperty.Register(nameof(NextLabel)); + + // The small text over each label. + public static readonly StyledProperty PreviousTextProperty = + AvaloniaProperty.Register(nameof(PreviousText), "Previous"); + + public static readonly StyledProperty NextTextProperty = + AvaloniaProperty.Register(nameof(NextText), "Next"); + + private readonly Border _previous; + private readonly Border _next; + private readonly TextBlock _previousText; + private readonly TextBlock _previousLabel; + private readonly TextBlock _nextText; + private readonly TextBlock _nextLabel; + + static PrevNextNav() + { + PreviousLabelProperty.Changed.AddClassHandler((n, _) => n.Update()); + NextLabelProperty.Changed.AddClassHandler((n, _) => n.Update()); + PreviousTextProperty.Changed.AddClassHandler((n, _) => n.Update()); + NextTextProperty.Changed.AddClassHandler((n, _) => n.Update()); + IsEnabledProperty.Changed.AddClassHandler((n, _) => n.Opacity = n.IsEnabled ? 1.0 : 0.5); + } + + public string? PreviousLabel + { + get => GetValue(PreviousLabelProperty); + set => SetValue(PreviousLabelProperty, value); + } + + public string? NextLabel + { + get => GetValue(NextLabelProperty); + set => SetValue(NextLabelProperty, value); + } + + public string? PreviousText + { + get => GetValue(PreviousTextProperty); + set => SetValue(PreviousTextProperty, value); + } + + public string? NextText + { + get => GetValue(NextTextProperty); + set => SetValue(NextTextProperty, value); + } + + public event EventHandler? PreviousClicked; + public event EventHandler? NextClicked; + + public PrevNextNav() + { + (_previous, _previousText, _previousLabel) = Card(TextAlignment.Left, () => PreviousClicked?.Invoke(this, EventArgs.Empty)); + (_next, _nextText, _nextLabel) = Card(TextAlignment.Right, () => NextClicked?.Invoke(this, EventArgs.Empty)); + Grid.SetColumn(_next, 1); + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), ColumnSpacing = 16, Children = { _previous, _next } }; + Update(); + } + + // rounded-md border p-4, hover:bg-accent: text-sm muted over a font-medium label. + private (Border Card, TextBlock Text, TextBlock Label) Card(TextAlignment alignment, Action clicked) + { + var text = new TextBlock { FontSize = 14, TextAlignment = alignment }; + text.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + var label = new TextBlock { FontSize = 16, FontWeight = FontWeight.Medium, TextAlignment = alignment, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + var card = new Border + { + Child = new StackPanel { Spacing = 2, Children = { text, label } }, + Padding = new Thickness(16), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + // Transparent, not null, so the whole card takes the pointer. + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + card.Token(BorderBrushProperty, ShellToken.Border); + ShellFocus.Ring(card, card); + card.PointerEntered += (_, _) => { if (IsEffectivelyEnabled) card.Token(BackgroundProperty, ShellToken.Accent); }; + card.PointerExited += (_, _) => + { + card.ClearToken(BackgroundProperty); + card.Background = Brushes.Transparent; + }; + card.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + clicked(); + e.Handled = true; + }; + card.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return; + clicked(); + e.Handled = true; + }; + return (card, text, label); + } + + private void Update() + { + _previousText.Text = PreviousText ?? string.Empty; + _previousLabel.Text = PreviousLabel ?? string.Empty; + _previous.IsVisible = !string.IsNullOrEmpty(PreviousLabel); + AutomationProperties.SetName(_previous, $"{PreviousText}: {PreviousLabel}"); + _nextText.Text = NextText ?? string.Empty; + _nextLabel.Text = NextLabel ?? string.Empty; + _next.IsVisible = !string.IsNullOrEmpty(NextLabel); + AutomationProperties.SetName(_next, $"{NextText}: {NextLabel}"); + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index b6b4568..898b46c 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -718,6 +718,16 @@ + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 6b9cc50..1a1d4c8 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -123,6 +123,17 @@ public MainWindow() DemoPassword.HasError = DemoPasswordField.HasError; ChatFormChoice.Text = DemoForm.IsValid ? "Form: valid." : $"Form: {DemoForm.FormFields().Count(f => f.HasError)} error(s), first is {DemoForm.FirstInvalid?.Label}."; }; + var docsPages = new[] { "Installation", "Quick start", "Theming", "Components" }; + var docsPage = 1; + void ShowDocsPage(int page) + { + docsPage = Math.Clamp(page, 0, docsPages.Length - 1); + DemoPrevNext.PreviousLabel = docsPage > 0 ? docsPages[docsPage - 1] : null; + DemoPrevNext.NextLabel = docsPage < docsPages.Length - 1 ? docsPages[docsPage + 1] : null; + PrevNextChoice.Text = $"Page: {docsPages[docsPage]}."; + } + DemoPrevNext.PreviousClicked += (_, _) => ShowDocsPage(docsPage - 1); + DemoPrevNext.NextClicked += (_, _) => ShowDocsPage(docsPage + 1); DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}."; DemoSidebar.SelectedValueChanged += (_, value) => { diff --git a/examples/MAUI.Demo/Components/UI/ImageViewer.cs b/examples/MAUI.Demo/Components/UI/ImageViewer.cs new file mode 100644 index 0000000..f5111a1 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/ImageViewer.cs @@ -0,0 +1,227 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Image viewer — a rounded-md thumbnail that opens the image over the page on a dark backdrop, +// with zoom out, reset, zoom in and close controls. Pinching zooms too; a tap on the backdrop +// or Escape closes it. Give it a size: the thumbnail fills it. +// +// ThumbnailSource shows a smaller file in the page and loads Source only in the viewer. +public partial class ImageViewer : ShellOverlayHost +{ + public static readonly BindableProperty SourceProperty = + BindableProperty.Create(nameof(Source), typeof(ImageSource), typeof(ImageViewer), null, + propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources()); + + public static readonly BindableProperty ThumbnailSourceProperty = + BindableProperty.Create(nameof(ThumbnailSource), typeof(ImageSource), typeof(ImageViewer), null, + propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources()); + + // What the image shows, for screen readers. + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(ImageViewer), string.Empty, + propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources()); + + // How the thumbnail fills its box. + public static readonly BindableProperty AspectProperty = + BindableProperty.Create(nameof(Aspect), typeof(Aspect), typeof(ImageViewer), Aspect.AspectFill, + propertyChanged: (b, o, n) => ((ImageViewer)b)._thumbnail.Image.Aspect = (Aspect)n); + + public ImageSource? Source + { + get => (ImageSource?)GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + public ImageSource? ThumbnailSource + { + get => (ImageSource?)GetValue(ThumbnailSourceProperty); + set => SetValue(ThumbnailSourceProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public Aspect Aspect + { + get => (Aspect)GetValue(AspectProperty); + set => SetValue(AspectProperty, value); + } + + public const double MinZoom = 0.5; + public const double MaxZoom = 4; + + // 1 is the size that fits the page. + public double Zoom { get; private set; } = 1; + + private readonly ImageViewerThumbnail _thumbnail; + private readonly ImageViewerContent _content; + + public ImageViewer() + { + _thumbnail = new ImageViewerThumbnail(() => SetOpen(true)); + _content = new ImageViewerContent(this); + Children.Add(_thumbnail); + Children.Add(_content); + OpenChanged += (_, open) => + { + if (open) SetZoom(1, animate: false); + }; + } + + protected override bool IsTrigger(Element child) => child is ImageViewerThumbnail; + + public void ZoomIn() => SetZoom(Zoom + 0.25); + public void ZoomOut() => SetZoom(Zoom - 0.25); + public void ResetZoom() => SetZoom(1); + + internal void SetZoom(double zoom, bool animate = true) + { + Zoom = Math.Clamp(zoom, MinZoom, MaxZoom); + _content.ShowZoom(Zoom, animate); + } + + private void UpdateSources() + { + _thumbnail.Image.Source = ThumbnailSource ?? Source; + _content.Image.Source = Source; + SemanticProperties.SetDescription(_thumbnail, string.IsNullOrEmpty(Description) ? "View image" : $"View {Description}"); + SemanticProperties.SetDescription(_content.Image, Description ?? string.Empty); + } +} + +// The thumbnail of an ImageViewer; it grows a little under the pointer. +internal sealed partial class ImageViewerThumbnail : ContentView +{ + public Image Image { get; } = new() { Aspect = Aspect.AspectFill }; + + public ImageViewerThumbnail(Action open) + { + var frame = new Border + { + Content = Image, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd } + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => _ = Image.ScaleToAsync(1.05, 200, Easing.CubicOut); + pointer.PointerExited += (_, _) => _ = Image.ScaleToAsync(1, 200, Easing.CubicOut); + frame.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); open(); }; + frame.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, open); + Content = frame; + } +} + +// The backdrop, image and controls of an open ImageViewer. +internal sealed partial class ImageViewerContent : ContentView, IShellOverlayContent +{ + public Image Image { get; } = new() { Aspect = Aspect.AspectFit, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center }; + + private readonly ImageViewer _viewer; + private readonly BoxView _backdrop; + private readonly Border _zoomOut; + private readonly Border _zoomIn; + private readonly Label _percent; + private double _pinchStart = 1; + + public ImageViewerContent(ImageViewer viewer) + { + _viewer = viewer; + // bg-black/80 in both themes. + _backdrop = new BoxView { Color = Color.FromRgba(0, 0, 0, 0.8) }; + var tapBackdrop = new TapGestureRecognizer(); + tapBackdrop.Tapped += (_, _) => viewer.SetOpen(false); + _backdrop.GestureRecognizers.Add(tapBackdrop); + + // Swallow taps on the image so they don't fall through to the backdrop. + Image.GestureRecognizers.Add(new TapGestureRecognizer()); + var pinch = new PinchGestureRecognizer(); + pinch.PinchUpdated += (_, e) => + { + if (e.Status == GestureStatus.Started) _pinchStart = viewer.Zoom; + else if (e.Status == GestureStatus.Running) viewer.SetZoom(_pinchStart * e.Scale, animate: false); + }; + Image.GestureRecognizers.Add(pinch); + + _percent = new Label { FontSize = 12, TextColor = Colors.White, VerticalTextAlignment = TextAlignment.Center, HorizontalTextAlignment = TextAlignment.Center }; + _zoomOut = Control(new Icon { Name = IconName.Minus, Size = 16, Color = Colors.White }, "Zoom out", viewer.ZoomOut); + _zoomIn = Control(new Icon { Name = IconName.Plus, Size = 16, Color = Colors.White }, "Zoom in", viewer.ZoomIn); + var reset = Control(_percent, "Reset zoom", viewer.ResetZoom); + reset.WidthRequest = 52; + var close = Control(new Icon { Name = IconName.X, Size = 16, Color = Colors.White }, "Close", () => viewer.SetOpen(false)); + + var controls = new HorizontalStackLayout + { + Spacing = 8, + Margin = new Thickness(16), + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Start, + Children = { _zoomOut, reset, _zoomIn, close } + }; + + var root = new Grid { Children = { _backdrop, Image, controls } }; + ShellPortal.EdgeToEdge(this, root); + // max-h-[90vh] max-w-[90vw] + root.SizeChanged += (_, _) => + { + Image.MaximumWidthRequest = root.Width * 0.9; + Image.MaximumHeightRequest = root.Height * 0.9; + }; + Content = root; + ShowZoom(1, animate: false); + } + + public void ShowZoom(double zoom, bool animate) + { + _percent.Text = $"{zoom * 100:0}%"; + _zoomOut.Opacity = zoom <= ImageViewer.MinZoom ? 0.5 : 1; + _zoomIn.Opacity = zoom >= ImageViewer.MaxZoom ? 0.5 : 1; + Image.AbortAnimation("ScaleTo"); + if (animate) _ = Image.ScaleToAsync(zoom, 200, Easing.CubicOut); + else Image.Scale = zoom; + } + + // h-9 w-9 rounded-md bg-white/10 text-white hover:bg-white/20 + private static Border Control(View content, string description, Action action) + { + content.HorizontalOptions = LayoutOptions.Center; + content.VerticalOptions = LayoutOptions.Center; + var control = new Border + { + Content = content, + WidthRequest = 36, + HeightRequest = 36, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Color.FromRgba(255, 255, 255, 0.1) + }; + SemanticProperties.SetDescription(control, description); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => control.BackgroundColor = Color.FromRgba(255, 255, 255, 0.2); + pointer.PointerExited += (_, _) => control.BackgroundColor = Color.FromRgba(255, 255, 255, 0.1); + control.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => action(); + control.GestureRecognizers.Add(tap); + return control; + } + + public async Task AnimateAsync(bool open) + { + if (open) + { + Opacity = 0; + await this.FadeToAsync(1, 150, Easing.CubicOut); + } + else + { + await this.FadeToAsync(0, 120, Easing.CubicIn); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/PrevNextNav.cs b/examples/MAUI.Demo/Components/UI/PrevNextNav.cs new file mode 100644 index 0000000..09c2eee --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/PrevNextNav.cs @@ -0,0 +1,123 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Previous / next links for the foot of a page — two rounded-md bordered cards side by side, +// each a muted "Previous" or "Next" over the page's name. A side with no label is left empty. +// +public partial class PrevNextNav : ContentView +{ + public static readonly BindableProperty PreviousLabelProperty = + BindableProperty.Create(nameof(PreviousLabel), typeof(string), typeof(PrevNextNav), string.Empty, + propertyChanged: (b, o, n) => ((PrevNextNav)b).Update()); + + public static readonly BindableProperty NextLabelProperty = + BindableProperty.Create(nameof(NextLabel), typeof(string), typeof(PrevNextNav), string.Empty, + propertyChanged: (b, o, n) => ((PrevNextNav)b).Update()); + + // The small text over each label. + public static readonly BindableProperty PreviousTextProperty = + BindableProperty.Create(nameof(PreviousText), typeof(string), typeof(PrevNextNav), "Previous", + propertyChanged: (b, o, n) => ((PrevNextNav)b).Update()); + + public static readonly BindableProperty NextTextProperty = + BindableProperty.Create(nameof(NextText), typeof(string), typeof(PrevNextNav), "Next", + propertyChanged: (b, o, n) => ((PrevNextNav)b).Update()); + + public string PreviousLabel + { + get => (string)GetValue(PreviousLabelProperty); + set => SetValue(PreviousLabelProperty, value); + } + + public string NextLabel + { + get => (string)GetValue(NextLabelProperty); + set => SetValue(NextLabelProperty, value); + } + + public string PreviousText + { + get => (string)GetValue(PreviousTextProperty); + set => SetValue(PreviousTextProperty, value); + } + + public string NextText + { + get => (string)GetValue(NextTextProperty); + set => SetValue(NextTextProperty, value); + } + + public event EventHandler? PreviousClicked; + public event EventHandler? NextClicked; + + private readonly Border _previous; + private readonly Border _next; + private readonly Label _previousText; + private readonly Label _previousLabel; + private readonly Label _nextText; + private readonly Label _nextLabel; + + public PrevNextNav() + { + (_previous, _previousText, _previousLabel) = Card(TextAlignment.Start, () => PreviousClicked?.Invoke(this, EventArgs.Empty)); + (_next, _nextText, _nextLabel) = Card(TextAlignment.End, () => NextClicked?.Invoke(this, EventArgs.Empty)); + + var grid = new Grid + { + ColumnSpacing = 16, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } + }; + grid.Add(_previous, 0, 0); + grid.Add(_next, 1, 0); + Content = grid; + Update(); + } + + // rounded-md border p-4, hover:bg-accent: text-sm muted over a font-medium label. + private (Border Card, Label Text, Label Label) Card(TextAlignment alignment, Action clicked) + { + var text = new Label { FontSize = 14, HorizontalTextAlignment = alignment }; + text.Token(Label.TextColorProperty, ShellToken.MutedForeground); + var label = new Label { FontSize = 16, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = alignment, LineBreakMode = LineBreakMode.TailTruncation }; + label.Token(Label.TextColorProperty, ShellToken.Foreground); + var card = new Border + { + Content = new VerticalStackLayout { Spacing = 2, Children = { text, label } }, + Padding = new Thickness(16), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + card.Token(Border.StrokeProperty, ShellToken.Border); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { if (IsEnabled) card.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); }; + pointer.PointerExited += (_, _) => { card.ClearValue(VisualElement.BackgroundColorProperty); card.BackgroundColor = Colors.Transparent; }; + card.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { if (IsEnabled) { ShellFocus.FocusPressed(card); clicked(); } }; + card.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(card, () => { if (IsEnabled) clicked(); }); + return (card, text, label); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; + } + + private void Update() + { + _previousText.Text = PreviousText ?? string.Empty; + _previousLabel.Text = PreviousLabel ?? string.Empty; + _previous.IsVisible = !string.IsNullOrEmpty(PreviousLabel); + SemanticProperties.SetDescription(_previous, $"{PreviousText}: {PreviousLabel}"); + _nextText.Text = NextText ?? string.Empty; + _nextLabel.Text = NextLabel ?? string.Empty; + _next.IsVisible = !string.IsNullOrEmpty(NextLabel); + SemanticProperties.SetDescription(_next, $"{NextText}: {NextLabel}"); + } +} diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 6d854f0..038613d 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -508,6 +508,11 @@ + +