660 lines
21 KiB
TypeScript
Raw Normal View History

// (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, NavController } 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 { NavigationOptions } from '@ionic/angular/providers/nav-controller';
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';
/**
* This component displays some top scrollable tabs that will autohide on vertical scroll.
*
* Example usage:
*
* <core-tabs selectedIndex="1" [tabs]="tabs"></core-tabs>
*
* 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<CoreTab> = new EventEmitter<CoreTab>(); // 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;
protected slidesSwiperLoaded = false;
protected stackEventsSubscription?: Subscription;
constructor(
protected element: ElementRef,
platform: Platform,
translate: TranslateService,
protected navCtrl: NavController,
) {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
// 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);
}
/**
* Tab selected.
*
* @param tabId Selected tab index.
* @param e Event.
*/
async selectTab(tabId: string, e?: Event): Promise<void> {
let index = this.tabs.findIndex((tab) => tabId == tab.id);
if (index < 0 || index >= this.tabs.length) {
if (this.selected) {
// Invalid index do not change tab.
e && e.preventDefault();
e && e.stopPropagation();
return;
}
// Index isn't valid, select the first one.
index = 0;
}
const selectedTab = this.tabs[index];
if (tabId == this.selected || !selectedTab || !selectedTab.enabled) {
// Already selected or not enabled.
e && e.preventDefault();
e && e.stopPropagation();
return;
}
if (this.selected) {
await this.slides!.slideTo(index);
}
const pageParams: NavigationOptions = {};
if (selectedTab.pageParams) {
pageParams.queryParams = selectedTab.pageParams;
}
const ok = await this.navCtrl.navigateForward(selectedTab.page, pageParams);
if (ok) {
this.selectHistory.push(tabId);
this.selected = tabId;
this.selectedIndex = index;
this.ionChange.emit(selectedTab);
}
}
/**
* 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.
*/
class CoreTab {
id = ''; // Unique tab id.
class = ''; // Class, if needed.
title = ''; // The translatable tab title.
icon?: string; // The tab icon.
badge?: string; // A badge to add in the tab.
badgeStyle?: string; // The badge color.
enabled = true; // Whether the tab is enabled.
page = ''; // Page to navigate to.
pageParams?: Params; // Page params.
}