diff --git a/CHANGELOG.md b/CHANGELOG.md index 566fc8355..48fd75f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [55.3.0] +- [Toolbar] Added `Toolbar` component with `ToolbarButton` items. The toolbar provides a cross-platform horizontal bar of icon buttons, aligned with Apple HIG toolbars and Material 3 toolbars. + ## [55.2.2] - [iOS26][Tip] Added more padding. diff --git a/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamples.xaml b/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamples.xaml new file mode 100644 index 000000000..3e2884b84 --- /dev/null +++ b/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamples.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamples.xaml.cs b/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamples.xaml.cs new file mode 100644 index 000000000..0b8a3b249 --- /dev/null +++ b/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamples.xaml.cs @@ -0,0 +1,14 @@ +namespace Components.ComponentsSamples.Toolbar; + +public partial class ToolbarSamples +{ + public ToolbarSamples() + { + InitializeComponent(); + } + + private async void OnCloseClicked(object? sender, EventArgs e) + { + await Navigation.PopModalAsync(); + } +} diff --git a/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamplesViewModel.cs b/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamplesViewModel.cs new file mode 100644 index 000000000..85ca80f36 --- /dev/null +++ b/src/app/Components/ComponentsSamples/Toolbar/ToolbarSamplesViewModel.cs @@ -0,0 +1,24 @@ +using DIPS.Mobile.UI.MVVM; + +namespace Components.ComponentsSamples.Toolbar; + +internal class ToolbarSamplesViewModel : ViewModel +{ + private string m_lastAction = "None"; + + public Command EditCommand => new(OnEdit); + public Command SaveCommand => new(OnSave); + public Command FilterCommand => new(OnFilter); + public Command AddCommand => new(OnAdd); + + public string LastAction + { + get => m_lastAction; + set => RaiseWhenSet(ref m_lastAction, value); + } + + private void OnEdit() => LastAction = "Edit tapped"; + private void OnSave() => LastAction = "Save tapped"; + private void OnFilter() => LastAction = "Filter tapped"; + private void OnAdd() => LastAction = "Add tapped"; +} diff --git a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs index 0a0ec44ee..e727a727e 100644 --- a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs +++ b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs @@ -21,6 +21,7 @@ using Components.ComponentsSamples.Sorting; using Components.ComponentsSamples.SyntaxHighlighting; using Components.ComponentsSamples.TabView; +using Components.ComponentsSamples.Toolbar; using Components.ComponentsSamples.Tags; using Components.ComponentsSamples.Text; using Components.ComponentsSamples.TextFields; @@ -73,6 +74,7 @@ public static List RegisterSamples() new(SampleType.Components, "Zoom Container", () => new PanZoomContainerSample()), new(SampleType.Components, "Gallery", () => new GallerySample()), new(SampleType.Components, "TIFF Viewer", () => new TiffViewerSample()), + new(SampleType.Components, "Toolbar", () => new ToolbarSamples(), isModal: true), new(SampleType.Accessibility, "VoiceOver/TalkBack", () => new VoiceOverSamples()), diff --git a/src/library/DIPS.Mobile.UI/AssemblyInfo.cs b/src/library/DIPS.Mobile.UI/AssemblyInfo.cs index 74687532f..b745213da 100644 --- a/src/library/DIPS.Mobile.UI/AssemblyInfo.cs +++ b/src/library/DIPS.Mobile.UI/AssemblyInfo.cs @@ -100,6 +100,7 @@ [assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Components.TiffViewer")] [assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Components.Gallery")] [assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Effects.Accessibility")] +[assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Components.Toolbar")] diff --git a/src/library/DIPS.Mobile.UI/Components/Pages/Android/ContentPage.cs b/src/library/DIPS.Mobile.UI/Components/Pages/Android/ContentPage.cs new file mode 100644 index 000000000..8bf8b4973 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Components/Pages/Android/ContentPage.cs @@ -0,0 +1,14 @@ +namespace DIPS.Mobile.UI.Components.Pages; + +public partial class ContentPage +{ + private partial void UpdateBottomToolbarOnPlatform() + { + // TODO: Implement Android bottom toolbar (Material 3 Bottom App Bar) + } + + private partial void HideBottomToolbarOnPlatform() + { + // TODO: Implement Android bottom toolbar cleanup + } +} diff --git a/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.Properties.cs b/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.Properties.cs index 8811415a6..fd9a461d6 100644 --- a/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.Properties.cs +++ b/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.Properties.cs @@ -1,10 +1,29 @@ using DIPS.Mobile.UI.API.Library; +using DIPS.Mobile.UI.Components.Toolbar; using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; namespace DIPS.Mobile.UI.Components.Pages; public partial class ContentPage { + public static readonly BindableProperty BottomToolbarProperty = BindableProperty.Create( + nameof(BottomToolbar), + typeof(Toolbar.Toolbar), + typeof(ContentPage)); + + /// + /// A bottom toolbar to display on the page. + /// + /// + /// On iOS, this renders the UINavigationController's built-in toolbar with Liquid Glass on iOS 26+. + /// On Android, a Material 3 Bottom App Bar is displayed at the bottom of the page. + /// + public Toolbar.Toolbar? BottomToolbar + { + get => (Toolbar.Toolbar?)GetValue(BottomToolbarProperty); + set => SetValue(BottomToolbarProperty, value); + } + public static readonly BindableProperty ShouldHideFloatingNavigationMenuProperty = BindableProperty.Create( nameof(ShouldHideFloatingNavigationMenuButton), typeof(bool), diff --git a/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.cs b/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.cs index b880297d6..9ba08c27f 100644 --- a/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.cs +++ b/src/library/DIPS.Mobile.UI/Components/Pages/ContentPage.cs @@ -73,6 +73,7 @@ protected override void OnAppearing() HasAppeared = true; HideOrShowFloatingNavigationMenu(); + UpdateBottomToolbarOnPlatform(); #if __ANDROID__ // Update status bar color for this page (works for both modal and non-modal) @@ -80,6 +81,9 @@ protected override void OnAppearing() #endif } + private partial void UpdateBottomToolbarOnPlatform(); + private partial void HideBottomToolbarOnPlatform(); + protected override void OnNavigatedTo(NavigatedToEventArgs args) { base.OnNavigatedTo(args); @@ -185,11 +189,22 @@ protected override void OnDisappearing() base.OnDisappearing(); HasAppeared = false; + HideBottomToolbarOnPlatform(); if (Application.Current != null) { Application.Current.RequestedThemeChanged -= OnRequestedThemeChanged; } } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + + if (propertyName == nameof(BottomToolbar) && HasAppeared) + { + UpdateBottomToolbarOnPlatform(); + } + } } } \ No newline at end of file diff --git a/src/library/DIPS.Mobile.UI/Components/Pages/dotnet/ContentPage.cs b/src/library/DIPS.Mobile.UI/Components/Pages/dotnet/ContentPage.cs new file mode 100644 index 000000000..fac58a465 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Components/Pages/dotnet/ContentPage.cs @@ -0,0 +1,12 @@ +namespace DIPS.Mobile.UI.Components.Pages; + +public partial class ContentPage +{ + private partial void UpdateBottomToolbarOnPlatform() + { + } + + private partial void HideBottomToolbarOnPlatform() + { + } +} diff --git a/src/library/DIPS.Mobile.UI/Components/Pages/iOS/ContentPage.cs b/src/library/DIPS.Mobile.UI/Components/Pages/iOS/ContentPage.cs new file mode 100644 index 000000000..e31c3af3b --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Components/Pages/iOS/ContentPage.cs @@ -0,0 +1,191 @@ +using CoreAnimation; +using DIPS.Mobile.UI.API.Library; +using DIPS.Mobile.UI.Resources.Colors; +using CoreGraphics; +using Microsoft.Maui.Platform; +using UIKit; +using Colors = DIPS.Mobile.UI.Resources.Colors.Colors; + +namespace DIPS.Mobile.UI.Components.Pages; + +public partial class ContentPage +{ + // The glass capsule container that holds the toolbar + private UIView? _capsuleContainer; + private UIVisualEffectView? _glassEffectView; + private UIToolbar? _bottomToolbar; + private NSLayoutConstraint[]? _bottomToolbarConstraints; + + private partial void UpdateBottomToolbarOnPlatform() + { + Dispatcher.Dispatch(UpdateBottomToolbarOnPlatformCore); + } + + private void UpdateBottomToolbarOnPlatformCore() + { + if (Handler?.PlatformView is not UIView pageView) + return; + + if (BottomToolbar is not { } toolbar || toolbar.Buttons.Count == 0) + { + RemoveNativeBottomToolbar(); + return; + } + + var items = new List(); + items.Add(new UIBarButtonItem(UIBarButtonSystemItem.FlexibleSpace)); + + foreach (var toolbarButton in toolbar.Buttons) + { + items.Add(CreateBarButtonItem(toolbarButton)); + items.Add(new UIBarButtonItem(UIBarButtonSystemItem.FlexibleSpace)); + } + + EnsureNativeBottomToolbar(pageView); + _bottomToolbar!.SetItems(items.ToArray(), false); + } + + private void EnsureNativeBottomToolbar(UIView pageView) + { + if (_capsuleContainer is not null) + return; + + var parentVC = FindViewController(); + var containerView = parentVC?.View ?? pageView; + + // 1. Create the capsule container — a rounded rect that clips its children + _capsuleContainer = new UIView(); + _capsuleContainer.TranslatesAutoresizingMaskIntoConstraints = false; + _capsuleContainer.ClipsToBounds = false; // Don't clip — allows button press animations to overflow + _capsuleContainer.Layer.CornerRadius = 30; // Half of 60pt height = true pill shape + _capsuleContainer.Layer.CornerCurve = CoreAnimation.CACornerCurve.Continuous; + containerView.AddSubview(_capsuleContainer); + + // 2. Create the glass background using UIVisualEffectView + UIGlassEffect + if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + _glassEffectView = new UIVisualEffectView(new UIGlassEffect()); + } + else + { + // Fallback for pre-iOS 26: system thin material blur + _glassEffectView = new UIVisualEffectView(UIBlurEffect.FromStyle(UIBlurEffectStyle.SystemThinMaterial)); + } + _glassEffectView.TranslatesAutoresizingMaskIntoConstraints = false; + _glassEffectView.ClipsToBounds = true; + _glassEffectView.Layer.CornerRadius = 30; // Match capsule + _glassEffectView.Layer.CornerCurve = CACornerCurve.Continuous; + _capsuleContainer.AddSubview(_glassEffectView); + + // 3. Create the toolbar with transparent background inside the capsule + _bottomToolbar = new UIToolbar(); + _bottomToolbar.TranslatesAutoresizingMaskIntoConstraints = false; + _bottomToolbar.SetBackgroundImage(new UIImage(), UIToolbarPosition.Any, UIBarMetrics.Default); + _bottomToolbar.SetShadowImage(new UIImage(), UIToolbarPosition.Any); + _bottomToolbar.BackgroundColor = UIColor.Clear; + _capsuleContainer.AddSubview(_bottomToolbar); + + // Layout constraints + var safeArea = containerView.SafeAreaLayoutGuide; + _bottomToolbarConstraints = + [ + // Capsule container: horizontal insets + pinned to safe area bottom + _capsuleContainer.LeadingAnchor.ConstraintEqualTo(safeArea.LeadingAnchor, 48), + _capsuleContainer.TrailingAnchor.ConstraintEqualTo(safeArea.TrailingAnchor, -48), + _capsuleContainer.BottomAnchor.ConstraintEqualTo(safeArea.BottomAnchor, -8), + _capsuleContainer.HeightAnchor.ConstraintEqualTo(60), + + // Glass effect view fills capsule + _glassEffectView.LeadingAnchor.ConstraintEqualTo(_capsuleContainer.LeadingAnchor), + _glassEffectView.TrailingAnchor.ConstraintEqualTo(_capsuleContainer.TrailingAnchor), + _glassEffectView.TopAnchor.ConstraintEqualTo(_capsuleContainer.TopAnchor), + _glassEffectView.BottomAnchor.ConstraintEqualTo(_capsuleContainer.BottomAnchor), + + // Toolbar inset inside capsule for horizontal padding, vertically centered + _bottomToolbar.LeadingAnchor.ConstraintEqualTo(_capsuleContainer.LeadingAnchor, 8), + _bottomToolbar.TrailingAnchor.ConstraintEqualTo(_capsuleContainer.TrailingAnchor, -8), + _bottomToolbar.CenterYAnchor.ConstraintEqualTo(_capsuleContainer.CenterYAnchor), + _bottomToolbar.HeightAnchor.ConstraintEqualTo(44), + ]; + NSLayoutConstraint.ActivateConstraints(_bottomToolbarConstraints); + + containerView.SetNeedsLayout(); + containerView.LayoutIfNeeded(); + } + + private void RemoveNativeBottomToolbar() + { + if (_capsuleContainer is null) + return; + + if (_bottomToolbarConstraints is not null) + { + NSLayoutConstraint.DeactivateConstraints(_bottomToolbarConstraints); + _bottomToolbarConstraints = null; + } + + _bottomToolbar?.RemoveFromSuperview(); + _bottomToolbar?.Dispose(); + _bottomToolbar = null; + + _glassEffectView?.RemoveFromSuperview(); + _glassEffectView?.Dispose(); + _glassEffectView = null; + + _capsuleContainer.RemoveFromSuperview(); + _capsuleContainer.Dispose(); + _capsuleContainer = null; + } + + private partial void HideBottomToolbarOnPlatform() + { + RemoveNativeBottomToolbar(); + } + + private UIViewController? FindViewController() + { + if (Handler?.PlatformView is not UIView platformView) + return null; + + var responder = platformView.NextResponder; + while (responder is not null) + { + if (responder is UIViewController vc) + return vc; + responder = responder.NextResponder; + } + + return null; + } + + private static UIBarButtonItem CreateBarButtonItem(Toolbar.ToolbarButton toolbarButton) + { + UIImage? icon = null; + if (DUI.TryGetUIImageFromImageSource(toolbarButton.Icon, out var uiImage) && uiImage is not null) + { + // Scale icon to 18pt to match Apple system toolbar sizing + var targetSize = new CGSize(18, 18); + var renderer = new UIGraphicsImageRenderer(targetSize); + var scaledImage = renderer.CreateImage(ctx => + { + uiImage.Draw(new CGRect(CGPoint.Empty, targetSize)); + }); + icon = scaledImage.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); + } + + var item = new UIBarButtonItem(icon, UIBarButtonItemStyle.Plain, (_, _) => + { + toolbarButton.Command?.Execute(toolbarButton.CommandParameter); + }); + + item.Enabled = toolbarButton.IsEnabled; + item.TintColor = Colors.GetColor(ColorName.color_icon_action).ToPlatform(); + + if (!string.IsNullOrEmpty(toolbarButton.Title)) + { + item.AccessibilityLabel = toolbarButton.Title; + } + + return item; + } +} diff --git a/src/library/DIPS.Mobile.UI/Components/Shell/Android/ShellRenderer.cs b/src/library/DIPS.Mobile.UI/Components/Shell/Android/ShellRenderer.cs index f82e536dc..cb2d9ed4e 100644 --- a/src/library/DIPS.Mobile.UI/Components/Shell/Android/ShellRenderer.cs +++ b/src/library/DIPS.Mobile.UI/Components/Shell/Android/ShellRenderer.cs @@ -120,8 +120,8 @@ internal class CustomToolbarAppearanceTracker : ShellToolbarAppearanceTracker { private ShellAppearance? m_appearance; - public Toolbar Toolbar { get; set; } - public override void SetAppearance(Toolbar toolbar, IShellToolbarTracker toolbarTracker, ShellAppearance appearance) + public AndroidX.AppCompat.Widget.Toolbar Toolbar { get; set; } + public override void SetAppearance(AndroidX.AppCompat.Widget.Toolbar toolbar, IShellToolbarTracker toolbarTracker, ShellAppearance appearance) { base.SetAppearance(toolbar, toolbarTracker, appearance); @@ -180,12 +180,12 @@ protected override void Dispose(bool disposing) private class ToolbarMenuItemClickListener : Object, IMenuItemOnMenuItemClickListener, Application.IActivityLifecycleCallbacks, PopupMenu.IOnDismissListener, PopupMenu.IOnMenuItemClickListener { private readonly ContextMenu m_contextMenu; - private readonly Toolbar m_materialToolbar; + private readonly AndroidX.AppCompat.Widget.Toolbar m_materialToolbar; private Dictionary m_menuItems; private PopupMenu? m_popupMenu; private bool m_isShowing; - public ToolbarMenuItemClickListener(ContextMenu contextMenu, Toolbar materialToolbar) + public ToolbarMenuItemClickListener(ContextMenu contextMenu, AndroidX.AppCompat.Widget.Toolbar materialToolbar) { m_contextMenu = contextMenu; m_materialToolbar = materialToolbar; diff --git a/src/library/DIPS.Mobile.UI/Components/Shell/iOS/ShellRenderer.cs b/src/library/DIPS.Mobile.UI/Components/Shell/iOS/ShellRenderer.cs index bf4064e68..38f4303ff 100644 --- a/src/library/DIPS.Mobile.UI/Components/Shell/iOS/ShellRenderer.cs +++ b/src/library/DIPS.Mobile.UI/Components/Shell/iOS/ShellRenderer.cs @@ -6,7 +6,6 @@ using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; using Microsoft.Maui.Platform; using UIKit; -using Colors = DIPS.Mobile.UI.Resources.Colors.Colors; using ContentPage = DIPS.Mobile.UI.Components.Pages.ContentPage; using Page = Microsoft.Maui.Controls.Page; diff --git a/src/library/DIPS.Mobile.UI/Components/Toolbar/Toolbar.cs b/src/library/DIPS.Mobile.UI/Components/Toolbar/Toolbar.cs new file mode 100644 index 000000000..9b920f26c --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Components/Toolbar/Toolbar.cs @@ -0,0 +1,44 @@ +using System.Collections.ObjectModel; + +namespace DIPS.Mobile.UI.Components.Toolbar; + +/// +/// A cross-platform toolbar that displays a horizontal bar of icon buttons. +/// Set this on to display a bottom toolbar. +/// +/// +/// On iOS the toolbar is rendered by the UINavigationController's built-in bottom toolbar, +/// which provides native Liquid Glass on iOS 26+. +/// On Android a Material 3 Bottom App Bar is injected at the bottom of the page. +/// +[ContentProperty(nameof(Buttons))] +public class Toolbar : Element +{ + public static readonly BindableProperty ButtonsProperty = BindableProperty.Create( + nameof(Buttons), + typeof(IList), + typeof(Toolbar), + defaultValueCreator: _ => new ObservableCollection()); + + /// + /// The buttons displayed in the toolbar. + /// + public IList Buttons + { + get => (IList)GetValue(ButtonsProperty); + set => SetValue(ButtonsProperty, value); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + if (Buttons is null) + return; + + foreach (var toolbarButton in Buttons) + { + toolbarButton.BindingContext = BindingContext; + } + } +} diff --git a/src/library/DIPS.Mobile.UI/Components/Toolbar/ToolbarButton.Properties.cs b/src/library/DIPS.Mobile.UI/Components/Toolbar/ToolbarButton.Properties.cs new file mode 100644 index 000000000..d203d1d57 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Components/Toolbar/ToolbarButton.Properties.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using System.Windows.Input; + +namespace DIPS.Mobile.UI.Components.Toolbar; + +public partial class ToolbarButton +{ + /// + /// + /// + public static readonly BindableProperty TitleProperty = BindableProperty.Create( + nameof(Title), + typeof(string), + typeof(ToolbarButton)); + + /// + /// + /// + public static readonly BindableProperty IconProperty = BindableProperty.Create( + nameof(Icon), + typeof(ImageSource), + typeof(ToolbarButton)); + + /// + /// + /// + public static readonly BindableProperty CommandProperty = BindableProperty.Create( + nameof(Command), + typeof(ICommand), + typeof(ToolbarButton)); + + /// + /// + /// + public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create( + nameof(CommandParameter), + typeof(object), + typeof(ToolbarButton)); + + /// + /// + /// + public static readonly BindableProperty IsEnabledProperty = BindableProperty.Create( + nameof(IsEnabled), + typeof(bool), + typeof(ToolbarButton), + defaultValue: true); + + /// + /// The title of the toolbar button, used as the accessibility label. + /// + public string? Title + { + get => (string?)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + /// + /// The icon to display in the toolbar button. + /// + [TypeConverter(nameof(ImageSourceConverter))] + public ImageSource? Icon + { + get => (ImageSource?)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + /// + /// The command to execute when the button is tapped. + /// + public ICommand? Command + { + get => (ICommand?)GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + /// + /// The parameter to pass to when the button is tapped. + /// + public object? CommandParameter + { + get => (object?)GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + /// + /// Determines whether the button is enabled. + /// + public bool IsEnabled + { + get => (bool)GetValue(IsEnabledProperty); + set => SetValue(IsEnabledProperty, value); + } +} diff --git a/src/library/DIPS.Mobile.UI/Components/Toolbar/ToolbarButton.cs b/src/library/DIPS.Mobile.UI/Components/Toolbar/ToolbarButton.cs new file mode 100644 index 000000000..a7616251f --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Components/Toolbar/ToolbarButton.cs @@ -0,0 +1,8 @@ +namespace DIPS.Mobile.UI.Components.Toolbar; + +/// +/// A button to display in a . +/// +public partial class ToolbarButton : Element +{ +}