// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Component, Input, Output, EventEmitter, OnInit, OnChanges, OnDestroy, AfterViewInit, ViewChild, ElementRef, } from '@angular/core'; import { Platform, IonSlides, IonTabs } from '@ionic/angular'; import { TranslateService } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreConstants } from '@/core/constants'; import { CoreUtils } from '@services/utils/utils'; import { Params } from '@angular/router'; import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; import { CoreDomUtils } from '@services/utils/dom'; import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; import { CoreNavigator } from '@services/navigator'; /** * This component displays some top scrollable tabs that will autohide on vertical scroll. * * Example usage: * * * * Tab contents will only be shown if that tab is selected. * * @todo: Test behaviour when tabs are added late. * @todo: Test RTL and tab history. */ @Component({ selector: 'core-tabs', templateUrl: 'core-tabs.html', styleUrls: ['tabs.scss'], }) export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { // Minimum tab's width to display fully the word "Competencies" which is the longest tab in the app. protected static readonly MIN_TAB_WIDTH = 107; // Max height that allows tab hiding. protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768; @Input() protected selectedIndex = 0; // Index of the tab to select. @Input() hideUntil = false; // Determine when should the contents be shown. /** * Determine tabs layout. */ @Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide'; @Input() tabs: CoreTab[] = []; @Output() protected ionChange: EventEmitter = new EventEmitter(); // Emitted when the tab changes. @ViewChild(IonSlides) protected slides?: IonSlides; @ViewChild(IonTabs) protected ionTabs?: IonTabs; selected?: string; // Selected tab id. showPrevButton = false; showNextButton = false; maxSlides = 3; numTabsShown = 0; direction = 'ltr'; description = ''; lastScroll = 0; slidesOpts = { initialSlide: 0, slidesPerView: 3, centerInsufficientSlides: true, }; protected initialized = false; protected afterViewInitTriggered = false; protected tabBarHeight = 0; protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element. protected tabsElement?: HTMLIonTabsElement; // The ionTabs native Element. protected tabsShown = true; protected resizeFunction?: EventListenerOrEventListenerObject; protected isDestroyed = false; protected isCurrentView = true; protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view. protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need. protected selectHistory: string[] = []; protected firstSelectedTab?: string; // ID of the first selected tab to control history. protected unregisterBackButtonAction: any; protected languageChangedSubscription: Subscription; protected isInTransition = false; // Weather Slides is in transition. protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any protected slidesSwiperLoaded = false; protected stackEventsSubscription?: Subscription; constructor( protected element: ElementRef, platform: Platform, translate: TranslateService, ) { this.direction = platform.isRTL ? 'rtl' : 'ltr'; // Change the side when the language changes. this.languageChangedSubscription = translate.onLangChange.subscribe(() => { setTimeout(() => { this.direction = platform.isRTL ? 'rtl' : 'ltr'; }); }); } /** * Component being initialized. */ async ngOnInit(): Promise { this.tabs.forEach((tab) => { this.initTab(tab); }); } /** * Init tab info. * * @param tab Tab class. */ protected initTab(tab: CoreTab): void { tab.id = tab.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabsComponent'); if (typeof tab.enabled == 'undefined') { tab.enabled = true; } } /** * View has been initialized. */ async ngAfterViewInit(): Promise { if (this.isDestroyed) { return; } this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { if (this.isCurrentView) { const content = stackEvent.enteringView.element.querySelector('ion-content'); this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); if (content) { const scroll = await content.getScrollElement(); content.scrollEvents = true; content.addEventListener('ionScroll', (e: CustomEvent): void => { this.showHideTabs(e, scroll); }); } } }); this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); this.tabsElement = this.element.nativeElement.querySelector('ion-tabs'); this.slidesSwiper = await this.slides?.getSwiper(); this.slidesSwiper.once('progress', () => { this.slidesSwiperLoaded = true; this.calculateSlides(); }); this.afterViewInitTriggered = true; if (!this.initialized && this.hideUntil) { // Tabs should be shown, initialize them. await this.initializeTabs(); } this.resizeFunction = this.windowResized.bind(this); window.addEventListener('resize', this.resizeFunction!); } /** * Detect changes on input properties. */ ngOnChanges(): void { this.tabs.forEach((tab) => { this.initTab(tab); }); // We need to wait for ngAfterViewInit because we need core-tab components to be executed. if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) { // Tabs should be shown, initialize them. // Use a setTimeout so child core-tab update their inputs before initializing the tabs. setTimeout(() => { this.initializeTabs(); }); } } /** * User entered the page that contains the component. */ ionViewDidEnter(): void { this.isCurrentView = true; this.calculateSlides(); this.registerBackButtonAction(); } /** * Register back button action. */ protected registerBackButtonAction(): void { this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => { // The previous page in history is not the last one, we need the previous one. if (this.selectHistory.length > 1) { const tabIndex = this.selectHistory[this.selectHistory.length - 2]; // Remove curent and previous tabs from history. this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId); this.selectTab(tabIndex); return true; } else if (this.selected != this.firstSelectedTab) { // All history is gone but we are not in the first selected tab. this.selectHistory = []; this.selectTab(this.firstSelectedTab!); return true; } return false; }, 750); } /** * User left the page that contains the component. */ ionViewDidLeave(): void { // Unregister the custom back button action for this page this.unregisterBackButtonAction && this.unregisterBackButtonAction(); this.isCurrentView = false; } /** * Calculate slides. */ protected async calculateSlides(): Promise { if (!this.isCurrentView || !this.initialized) { // Don't calculate if component isn't in current view, the calculations are wrong. return; } if (!this.tabsShown) { if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) { // Ensure tabbar is shown. this.tabsShown = true; this.tabBarElement!.classList.remove('tabs-hidden'); this.lastScroll = 0; } } await this.calculateMaxSlides(); this.updateSlides(); } /** * Calculate the tab bar height. */ protected calculateTabBarHeight(): void { if (!this.tabBarElement || !this.tabsElement) { return; } this.tabBarHeight = this.tabBarElement.offsetHeight; if (this.tabsShown) { // Smooth translation. this.tabsElement.style.top = - this.lastScroll + 'px'; this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; } else { this.tabBarElement.classList.add('tabs-hidden'); this.tabsElement.style.top = '0'; this.tabsElement.style.height = ''; } } /** * Get the tab on a index. * * @param tabId Tab ID. * @return Selected tab. */ protected getTabIndex(tabId: string): number { return this.tabs.findIndex((tab) => tabId == tab.id); } /** * Get the current selected tab. * * @return Selected tab. */ getSelected(): CoreTab | undefined { const index = this.selected && this.getTabIndex(this.selected); return index && index >= 0 ? this.tabs[index] : undefined; } /** * Initialize the tabs, determining the first tab to be shown. */ protected async initializeTabs(): Promise { let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined; if (!selectedTab || !selectedTab.enabled) { // The tab is not enabled or not shown. Get the first tab that is enabled. selectedTab = this.tabs.find((tab) => tab.enabled) || undefined; } if (!selectedTab) { return; } this.firstSelectedTab = selectedTab.id!; this.selectTab(this.firstSelectedTab); // Setup tab scrolling. this.calculateTabBarHeight(); this.initialized = true; // Check which arrows should be shown. this.calculateSlides(); } /** * Method executed when the slides are changed. */ async slideChanged(): Promise { if (!this.slidesSwiperLoaded) { return; } this.isInTransition = false; const slidesCount = await this.slides?.length() || 0; if (slidesCount > 0) { this.showPrevButton = !await this.slides?.isBeginning(); this.showNextButton = !await this.slides?.isEnd(); } else { this.showPrevButton = false; this.showNextButton = false; } const currentIndex = await this.slides!.getActiveIndex(); if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) { // Current tab has changed, don't slide to initial anymore. this.shouldSlideToInitial = false; } } /** * Updates the number of slides to show. */ protected async updateSlides(): Promise { this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0); this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; this.calculateTabBarHeight(); await this.slides!.update(); if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { this.hasSliddenToInitial = true; this.shouldSlideToInitial = true; setTimeout(() => { if (this.shouldSlideToInitial) { this.slides!.slideTo(this.selectedIndex, 0); this.shouldSlideToInitial = false; } }, 400); return; } else if (this.selectedIndex) { this.hasSliddenToInitial = true; } setTimeout(() => { this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated. }, 400); } /** * Calculate the number of slides that can fit on the screen. */ protected async calculateMaxSlides(): Promise { if (!this.slidesSwiperLoaded) { return; } this.maxSlides = 3; const width = this.slidesSwiper.width; if (width) { const fontSize = await CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]); this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] * CoreTabsComponent.MIN_TAB_WIDTH)); } } /** * Method that shows the next tab. */ async slideNext(): Promise { // Stop if slides are in transition. if (!this.showNextButton || this.isInTransition) { return; } if (await this.slides!.isBeginning()) { // Slide to the second page. this.slides!.slideTo(this.maxSlides); } else { const currentIndex = await this.slides!.getActiveIndex(); if (typeof currentIndex !== 'undefined') { const nextSlideIndex = currentIndex + this.maxSlides; this.isInTransition = true; if (nextSlideIndex < this.numTabsShown) { // Slide to the next page. await this.slides!.slideTo(nextSlideIndex); } else { // Slide to the latest slide. await this.slides!.slideTo(this.numTabsShown - 1); } } } } /** * Method that shows the previous tab. */ async slidePrev(): Promise { // Stop if slides are in transition. if (!this.showPrevButton || this.isInTransition) { return; } if (await this.slides!.isEnd()) { this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2); // Slide to the previous of the latest page. } else { const currentIndex = await this.slides!.getActiveIndex(); if (typeof currentIndex !== 'undefined') { const prevSlideIndex = currentIndex - this.maxSlides; this.isInTransition = true; if (prevSlideIndex >= 0) { // Slide to the previous page. await this.slides!.slideTo(prevSlideIndex); } else { // Slide to the first page. await this.slides!.slideTo(0); } } } } /** * Show or hide the tabs. This is used when the user is scrolling inside a tab. * * @param scrollEvent Scroll event to check scroll position. * @param content Content element to check measures. */ protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void { if (!this.tabBarElement || !this.tabsElement || !content) { return; } // Always show on very tall screens. if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) { return; } if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) { // Wrong tab height, recalculate it. this.calculateTabBarHeight(); } if (!this.tabBarHeight) { // We don't have the tab bar height, this means the tab bar isn't shown. return; } const scroll = parseInt(scrollEvent.detail.scrollTop, 10); if (scroll <= 0) { // Ensure tabbar is shown. this.tabsElement.style.top = '0'; this.tabsElement.style.height = ''; this.tabBarElement!.classList.remove('tabs-hidden'); this.tabsShown = true; this.lastScroll = 0; return; } if (scroll == this.lastScroll) { // Ensure scroll has been modified to avoid flicks. return; } if (this.tabsShown && scroll > this.tabBarHeight) { this.tabsShown = false; // Hide tabs. this.tabBarElement.classList.add('tabs-hidden'); this.tabsElement.style.top = '0'; this.tabsElement.style.height = ''; } else if (!this.tabsShown && scroll <= this.tabBarHeight) { this.tabsShown = true; this.tabBarElement!.classList.remove('tabs-hidden'); } if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) { // Smooth translation. this.tabsElement.style.top = - scroll + 'px'; this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; } // Use lastScroll after moving the tabs to avoid flickering. this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10); } /** * Select a tab by ID. * * @param tabId Tab ID. * @param e Event. * @return Promise resolved when done. */ async selectTab(tabId: string, e?: Event): Promise { const index = this.tabs.findIndex((tab) => tabId == tab.id); return this.selectByIndex(index, e); } /** * Select a tab by index. * * @param index Index to select. * @param e Event. * @return Promise resolved when done. */ async selectByIndex(index: number, e?: Event): Promise { if (index < 0 || index >= this.tabs.length) { if (this.selected) { // Invalid index do not change tab. e?.preventDefault(); e?.stopPropagation(); return; } // Index isn't valid, select the first one. index = 0; } const tabToSelect = this.tabs[index]; if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { // Already selected or not enabled. e?.preventDefault(); e?.stopPropagation(); return; } if (this.selected) { await this.slides!.slideTo(index); } const ok = await CoreNavigator.instance.navigate(tabToSelect.page, { params: tabToSelect.pageParams, }); if (ok !== false) { this.selectHistory.push(tabToSelect.id!); this.selected = tabToSelect.id; this.selectedIndex = index; this.ionChange.emit(tabToSelect); } } /** * Get all child core-navbar-buttons and show or hide depending on the page state. * We need to use querySelectorAll because ContentChildren doesn't work with ng-template. * https://github.com/angular/angular/issues/14842 * * @param activatedPageName Activated page name. */ protected showHideNavBarButtons(activatedPageName: string): void { const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons'); const domUtils = CoreDomUtils.instance; elements.forEach((element) => { const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element); if (instance) { const pagetagName = element.closest('.ion-page')?.tagName; instance.forceHide(activatedPageName != pagetagName); } }); } /** * Adapt tabs to a window resize. */ protected windowResized(): void { setTimeout(() => { this.calculateSlides(); }); } /** * Component destroyed. */ ngOnDestroy(): void { this.isDestroyed = true; if (this.resizeFunction) { window.removeEventListener('resize', this.resizeFunction); } this.stackEventsSubscription?.unsubscribe(); this.languageChangedSubscription.unsubscribe(); } } /** * Core Tab class. */ export type CoreTab = { page: string; // Page to navigate to. title: string; // The translatable tab title. id?: string; // Unique tab id. class?: string; // Class, if needed. icon?: string; // The tab icon. badge?: string; // A badge to add in the tab. badgeStyle?: string; // The badge color. enabled?: boolean; // Whether the tab is enabled. pageParams?: Params; // Page params. };