4using System.Collections.Specialized;
5using System.ComponentModel;
6using System.Threading.Tasks;
7using Microsoft.Maui.ApplicationModel;
8using Microsoft.Maui.Controls;
21 BindableProperty.Create(nameof(
ItemsSource), typeof(IEnumerable), typeof(
TabNavigationHost),
default(IEnumerable), propertyChanged: OnItemsSourcePropertyChanged);
27 BindableProperty.Create(nameof(
ItemTemplate), typeof(DataTemplate), typeof(
TabNavigationHost),
default(DataTemplate), propertyChanged: OnItemTemplatePropertyChanged);
33 BindableProperty.Create(nameof(
BadgeTemplate), typeof(DataTemplate), typeof(
TabNavigationHost),
default(DataTemplate), propertyChanged: OnBadgeTemplatePropertyChanged);
39 BindableProperty.Create(nameof(
SelectedIndex), typeof(
int), typeof(
TabNavigationHost), 0, BindingMode.TwoWay, propertyChanged: OnSelectedIndexPropertyChanged);
45 BindableProperty.Create(nameof(
SelectedItem), typeof(
object), typeof(
TabNavigationHost),
default(
object), BindingMode.TwoWay, propertyChanged: OnSelectedItemPropertyChanged);
57 BindableProperty.Create(nameof(
IsScrollable), typeof(
bool), typeof(
TabNavigationHost),
false, propertyChanged: OnIsScrollablePropertyChanged);
71 private readonly Grid fixedGrid;
72 private readonly ScrollView scrollView;
73 private readonly StackLayout scrollLayout;
74 private readonly List<TabNavigationItemContainer> itemContainers;
75 private readonly List<object?> items;
76 private int currentSelectedIndex = -1;
78 private INotifyCollectionChanged? itemsSourceNotifier;
79 private bool isInternalSelectionChange;
80 private bool isApplyingSelection;
87 this.fixedGrid =
new Grid
91 RowDefinitions = {
new RowDefinition { Height = GridLength.Star } },
92 ColumnDefinitions = {
new ColumnDefinition { Width = GridLength.Star } }
95 this.scrollLayout =
new StackLayout
97 Orientation = StackOrientation.Horizontal,
101 this.scrollView =
new ScrollView
103 Orientation = ScrollOrientation.Horizontal,
104 Content = this.scrollLayout
107 this.itemContainers =
new List<TabNavigationItemContainer>();
108 this.items =
new List<object?>();
110 this.Content = this.fixedGrid;
194 private static void OnItemsSourcePropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
197 Host.OnItemsSourceChanged(oldValue as IEnumerable, newValue as IEnumerable);
200 private static void OnItemTemplatePropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
203 if (!Equals(oldValue, newValue))
209 private static void OnBadgeTemplatePropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
212 if (!Equals(oldValue, newValue))
218 private static void OnSelectedIndexPropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
221 if (Host.isInternalSelectionChange)
226 int OldIndex = (int)oldValue;
227 int NewIndex = (int)newValue;
229 if (OldIndex == NewIndex && Host.isApplyingSelection)
234 Host.ApplySelectionFromProperty(NewIndex);
237 private static void OnSelectedItemPropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
240 if (Host.isInternalSelectionChange)
245 if (ReferenceEquals(oldValue, newValue))
250 Host.ApplySelectionFromSelectedItem(newValue);
253 private static void OnTabPlacementPropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
256 if (!Equals(oldValue, newValue))
258 Host.UpdatePresenterRoot();
263 private static void OnIsScrollablePropertyChanged(BindableObject bindable,
object oldValue,
object newValue)
266 if (!Equals(oldValue, newValue))
268 Host.UpdatePresenterRoot();
273 private void OnItemsSourceChanged(IEnumerable? OldItems, IEnumerable? NewItems)
275 this.DetachItemsSourceNotifier(OldItems as INotifyCollectionChanged);
276 this.AttachItemsSourceNotifier(NewItems as INotifyCollectionChanged);
280 private void AttachItemsSourceNotifier(INotifyCollectionChanged? Notifier)
282 if (Notifier is
null)
284 this.itemsSourceNotifier =
null;
288 this.itemsSourceNotifier = Notifier;
289 this.itemsSourceNotifier.CollectionChanged += this.OnItemsSourceCollectionChanged;
292 private void DetachItemsSourceNotifier(INotifyCollectionChanged? Notifier)
294 if (Notifier is
null)
299 Notifier.CollectionChanged -= this.OnItemsSourceCollectionChanged;
300 if (ReferenceEquals(this.itemsSourceNotifier, Notifier))
302 this.itemsSourceNotifier =
null;
306 private void OnItemsSourceCollectionChanged(
object? Sender, NotifyCollectionChangedEventArgs E)
308 if (MainThread.IsMainThread)
314 MainThread.BeginInvokeOnMainThread(this.RebuildItems);
318 private void RebuildItems()
320 this.isApplyingSelection =
true;
324 this.ClearPresenter();
327 foreach (TabNavigationItemContainer Container
in this.itemContainers)
332 this.itemContainers.Clear();
336 this.SetSelectionInternal(-1,
null);
343 this.items.Add(Item);
344 TabNavigationItemContainer Container = this.CreateContainer(Item, Index);
345 this.itemContainers.Add(Container);
346 this.AddContainerToPresenter(Container);
350 this.EnsureSelectionConsistency();
354 this.isApplyingSelection =
false;
358 private void ClearPresenter()
362 this.scrollLayout.Children.Clear();
366 this.fixedGrid.Children.Clear();
367 this.fixedGrid.RowDefinitions.Clear();
368 this.fixedGrid.ColumnDefinitions.Clear();
370 if (this.IsHorizontalPlacement())
372 this.fixedGrid.RowDefinitions.Add(
new RowDefinition { Height = GridLength.Star });
376 this.fixedGrid.ColumnDefinitions.Add(
new ColumnDefinition { Width = GridLength.Star });
381 private void AddContainerToPresenter(TabNavigationItemContainer Container)
385 if (!this.scrollLayout.Children.Contains(Container))
387 this.scrollLayout.Children.Add(Container);
392 if (this.IsHorizontalPlacement())
394 this.fixedGrid.ColumnDefinitions.Add(
new ColumnDefinition { Width = GridLength.Star });
395 int ColumnIndex = this.fixedGrid.ColumnDefinitions.Count - 1;
396 Grid.SetColumn(Container, ColumnIndex);
397 Grid.SetRow(Container, 0);
401 this.fixedGrid.RowDefinitions.Add(
new RowDefinition { Height = GridLength.Star });
402 int RowIndex = this.fixedGrid.RowDefinitions.Count - 1;
403 Grid.SetRow(Container, RowIndex);
404 Grid.SetColumn(Container, 0);
407 if (!this.fixedGrid.Children.Contains(Container))
409 this.fixedGrid.Children.Add(Container);
414 private TabNavigationItemContainer CreateContainer(
object? Item,
int Index)
416 DataTemplate Template = this.ItemTemplate ?? this.ResolveDefaultTemplate();
417 View ContentView = this.CreateContentView(Template, Item);
419 TabNavigationItemContainer Container =
new(
this, Item, ContentView, Index);
423 if (this.IsHorizontalPlacement())
425 Container.HorizontalOptions = LayoutOptions.Fill;
429 Container.VerticalOptions = LayoutOptions.Fill;
433 this.ApplyBadgeTemplate(Container);
438 private DataTemplate ResolveDefaultTemplate()
440 string ResourceKey = this.TabPlacement
switch
442 TabPlacement.Top =>
"DefaultTopUnderlineTabTemplate",
443 TabPlacement.Left =>
"DefaultVerticalTabTemplate",
444 TabPlacement.Right =>
"DefaultVerticalTabTemplate",
445 _ =>
"DefaultBottomPillTabTemplate"
448 if (Application.Current?.Resources.TryGetValue(ResourceKey, out
object? ResourceValue) ??
false)
450 if (ResourceValue is DataTemplate Template)
456 return new DataTemplate(() =>
458 Label TitleLabel =
new()
460 HorizontalOptions = LayoutOptions.Center,
461 VerticalOptions = LayoutOptions.Center,
465 TitleLabel.SetBinding(Label.TextProperty, nameof(TabDefinition.Title));
466 TitleLabel.SetDynamicResource(Label.TextColorProperty,
"ContentPrimaryWL");
471 private View CreateContentView(DataTemplate Template,
object? BindingContext)
473 object? Created = Template.CreateContent();
475 if (Created is ViewCell CreatedCell && CreatedCell.View is not
null)
477 CreatedCell.View.BindingContext = BindingContext;
478 return CreatedCell.View;
481 if (Created is View CreatedView)
483 CreatedView.BindingContext = BindingContext;
487 throw new InvalidOperationException(
"Tab item template must produce a View.");
490 private void ApplyBadgeTemplate(TabNavigationItemContainer Container)
494 Container.SetCustomBadge(
null);
498 View BadgeView = this.CreateContentView(this.
BadgeTemplate, Container.Item);
499 Container.SetCustomBadge(BadgeView);
502 private void EnsureSelectionConsistency()
504 if (this.items.Count == 0)
506 this.SetSelectionInternal(-1,
null);
512 if (ExistingIndex >= 0 && ExistingIndex < this.items.Count)
514 object? ExistingItem = this.items[ExistingIndex];
515 this.SetSelectionInternal(ExistingIndex, ExistingItem);
521 int LocatedIndex = this.GetIndexOfItem(this.
SelectedItem);
522 if (LocatedIndex >= 0)
524 object? LocatedItem = this.items[LocatedIndex];
525 this.SetSelectionInternal(LocatedIndex, LocatedItem);
530 this.SetSelectionInternal(0, this.items[0]);
533 private void SetSelectionInternal(
int Index,
object? Item)
535 this.isInternalSelectionChange =
true;
544 this.isInternalSelectionChange =
false;
547 this.ApplySelectionStates(Index);
552 int PreviousIndex = this.currentSelectedIndex;
555 for (
int i = 0; i < this.itemContainers.Count; i++)
557 TabNavigationItemContainer Container = this.itemContainers[i];
559 Container.ApplySelectionState(IsSelected);
561 if (Container.Item is TabDefinition Definition)
563 Definition.IsSelected = IsSelected;
570 private void ApplySelectionFromProperty(
int NewIndex)
572 if (NewIndex < 0 || NewIndex >= this.items.Count)
574 this.SetSelectionInternal(-1,
null);
578 object? Item = this.items[NewIndex];
579 this.SetSelectionInternal(NewIndex, Item);
582 private void ApplySelectionFromSelectedItem(
object?
SelectedItem)
586 this.SetSelectionInternal(-1,
null);
593 object? Item = this.items[FoundIndex];
594 this.SetSelectionInternal(FoundIndex, Item);
598 private int GetIndexOfItem(
object? Item)
600 for (
int i = 0; i < this.items.Count; i++)
602 object? Candidate = this.items[i];
603 if (ReferenceEquals(Candidate, Item) || Equals(Candidate, Item))
612 private async Task HandleItemTappedAsync(TabNavigationItemContainer Container)
614 if (Container.Item is TabDefinition Definition && !Definition.IsEnabled)
619 await this.ApplyTouchFeedbackAsync(Container);
621 this.SetSelectionInternal(Container.Index, Container.Item);
623 if (Container.Item is TabDefinition Descriptor && Descriptor.Command is not
null)
625 if (Descriptor.Command.CanExecute(Descriptor.CommandParameter ?? Descriptor))
627 Descriptor.Command.Execute(Descriptor.CommandParameter ?? Descriptor);
632 private Task ApplyTouchFeedbackAsync(TabNavigationItemContainer Container)
637 return this.AnimateOpacityAsync(Container);
639 return this.AnimateScaleAsync(Container);
641 return Task.CompletedTask;
645 private async Task AnimateOpacityAsync(VisualElement Element)
647 const double TargetOpacity = 0.6;
648 const uint Duration = 80;
650 double OriginalOpacity = Element.Opacity;
651 await Element.FadeToAsync(TargetOpacity, Duration, Easing.SinOut);
652 await Element.FadeToAsync(OriginalOpacity, Duration, Easing.SinIn);
655 private async Task AnimateScaleAsync(VisualElement Element)
657 const double TargetScale = 0.92;
658 const uint Duration = 80;
660 double OriginalScale = Element.Scale;
661 await Element.ScaleToAsync(TargetScale, Duration, Easing.CubicOut);
662 await Element.ScaleToAsync(OriginalScale, Duration, Easing.CubicIn);
665 private void UpdatePresenterRoot()
667 bool IsHorizontal = this.IsHorizontalPlacement();
671 this.scrollLayout.Orientation = IsHorizontal ? StackOrientation.Horizontal : StackOrientation.Vertical;
672 this.scrollView.Orientation = IsHorizontal ? ScrollOrientation.Horizontal : ScrollOrientation.Vertical;
674 if (!ReferenceEquals(this.Content, this.scrollView))
676 this.Content = this.scrollView;
683 this.fixedGrid.RowDefinitions.Clear();
684 this.fixedGrid.ColumnDefinitions.Clear();
685 this.fixedGrid.RowDefinitions.Add(
new RowDefinition { Height = GridLength.Star });
689 this.fixedGrid.RowDefinitions.Clear();
690 this.fixedGrid.ColumnDefinitions.Clear();
691 this.fixedGrid.ColumnDefinitions.Add(
new ColumnDefinition { Width = GridLength.Star });
694 if (!ReferenceEquals(this.Content, this.fixedGrid))
696 this.Content = this.fixedGrid;
701 private bool IsHorizontalPlacement()
703 return this.TabPlacement == TabPlacement.Top || this.TabPlacement ==
TabPlacement.Bottom;
706 private void RunSelectionAnimation(
int PreviousIndex,
int SelectedIndex)
713 if (SelectedIndex < 0 || SelectedIndex >= this.itemContainers.Count)
718 TabNavigationItemContainer TargetContainer = this.itemContainers[
SelectedIndex];
723 _ = this.AnimateFadeScaleAsync(TargetContainer);
726 this.AnimateUnderlineTransition(PreviousIndex,
SelectedIndex);
731 private void AnimateUnderlineTransition(
int PreviousIndex,
int SelectedIndex)
741 private async Task AnimateFadeScaleAsync(VisualElement Element)
743 const double TargetScale = 1.05;
744 const uint ScaleInDuration = 120;
745 const uint ScaleOutDuration = 140;
747 double OriginalScale = Element.Scale;
749 await Element.ScaleToAsync(TargetScale, ScaleInDuration, Easing.CubicOut);
750 await Element.ScaleToAsync(OriginalScale, ScaleOutDuration, Easing.CubicIn);
753 private sealed
class TabNavigationItemContainer : ContentView, IDisposable
756 private readonly TapGestureRecognizer tapRecognizer;
757 private readonly INotifyPropertyChanged? notifier;
758 private bool disposed;
759 private View? customBadgeView;
761 public TabNavigationItemContainer(
TabNavigationHost Host,
object? Item, View Content,
int Index)
766 this.tapRecognizer =
new TapGestureRecognizer();
767 this.tapRecognizer.Tapped += this.OnTapped;
768 this.GestureRecognizers.Add(this.tapRecognizer);
770 this.Content = Content;
771 this.BindingContext = Item;
772 Content.BindingContext = Item;
774 if (Item is TabDefinition Definition)
776 this.IsEnabled = Definition.IsEnabled;
779 if (Item is INotifyPropertyChanged NotifyPropertyChanged)
781 this.notifier = NotifyPropertyChanged;
782 this.notifier.PropertyChanged += this.OnItemPropertyChanged;
785 this.Padding =
new Thickness(0);
786 this.Margin =
new Thickness(0);
789 public int Index {
get; }
791 public object? Item {
get; }
793 public void ApplySelectionState(
bool isSelected)
795 VisualStateManager.GoToState(
this, isSelected ?
"Selected" :
"Normal");
798 public void SetCustomBadge(View? badgeView)
800 this.customBadgeView = badgeView;
802 Layout? BadgeHost = this.Content.FindByName<Layout>(
"CustomBadgeHost");
803 if (BadgeHost is
null)
808 BadgeHost.Children.Clear();
810 if (badgeView is
null)
815 badgeView.BindingContext = this.BindingContext;
816 BadgeHost.Children.Add(badgeView);
819 protected override void OnBindingContextChanged()
821 base.OnBindingContextChanged();
823 if (this.customBadgeView is not
null)
825 this.customBadgeView.BindingContext = this.BindingContext;
829 private void OnItemPropertyChanged(
object? Sender, PropertyChangedEventArgs E)
831 if (
string.Equals(E.PropertyName, nameof(TabDefinition.IsEnabled), StringComparison.Ordinal) &&
this.Item is TabDefinition Definition)
833 this.IsEnabled = Definition.IsEnabled;
837 private void OnTapped(
object? Sender, TappedEventArgs E)
839 _ = this.host.HandleItemTappedAsync(
this);
842 public void Dispose()
849 this.disposed =
true;
850 this.tapRecognizer.Tapped -= this.OnTapped;
851 this.GestureRecognizers.Remove(this.tapRecognizer);
853 if (this.notifier is not
null)
855 this.notifier.PropertyChanged -= this.OnItemPropertyChanged;
Visual host that renders a collection of tabs and keeps its selection in sync with external consumers...
static readonly BindableProperty ItemsSourceProperty
Backing store for the ItemsSource property.
DataTemplate? BadgeTemplate
Gets or sets an optional badge template used by the built-in tab templates.
object? SelectedItem
Gets or sets the currently selected item.
static readonly BindableProperty SelectedItemProperty
Backing store for the SelectedItem property.
TabTouchEffectType TouchEffectType
Gets or sets the touch effect applied to tab interactions.
DataTemplate? ItemTemplate
Gets or sets the template used to create tab visuals for each item.
static readonly BindableProperty AnimationStyleProperty
Backing store for the AnimationStyle property.
static readonly BindableProperty TouchEffectTypeProperty
Backing store for the TouchEffectType property.
static readonly BindableProperty BadgeTemplateProperty
Backing store for the BadgeTemplate property.
TabPlacement TabPlacement
Gets or sets the placement of the tab host (horizontal or vertical layout).
int SelectedIndex
Gets or sets the zero-based index of the selected tab.
bool IsScrollable
Gets or sets a value indicating whether the tab list should be scrollable instead of distributing ite...
TabAnimationStyle AnimationStyle
Gets or sets the animation style used when the selected tab changes.
static readonly BindableProperty IsScrollableProperty
Backing store for the IsScrollable property.
IEnumerable? ItemsSource
Gets or sets the collection of items rendered as tabs.
TabNavigationHost()
Initializes a new instance of the TabNavigationHost class.
static readonly BindableProperty ItemTemplateProperty
Backing store for the ItemTemplate property.
static readonly BindableProperty TabPlacementProperty
Backing store for the TabPlacement property.
static readonly BindableProperty SelectedIndexProperty
Backing store for the SelectedIndex property.
TabTouchEffectType
Defines the touch feedback effect applied when interacting with tab items.
TabAnimationStyle
Specifies the animation strategy used when the selected tab changes.
TabPlacement
Defines the placement of tab items within a TabNavigationHost.