MOBILE-3833 tabs: Fix tab size calculations

main
Pau Ferrer Ocaña 2022-03-30 15:51:55 +02:00
parent f56cfa3ab6
commit b2246a01c5
8 changed files with 131 additions and 103 deletions

View File

@ -22,7 +22,6 @@ import {
OnDestroy, OnDestroy,
AfterViewInit, AfterViewInit,
ViewChild, ViewChild,
ElementRef,
SimpleChange, SimpleChange,
} from '@angular/core'; } from '@angular/core';
import { IonSlides } from '@ionic/angular'; import { IonSlides } from '@ionic/angular';
@ -34,6 +33,8 @@ import { CoreSettingsHelper } from '@features/settings/services/settings-helper'
import { CoreAriaRoleTab, CoreAriaRoleTabFindable } from './aria-role-tab'; import { CoreAriaRoleTab, CoreAriaRoleTabFindable } from './aria-role-tab';
import { CoreEventObserver } from '@singletons/events'; import { CoreEventObserver } from '@singletons/events';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreError } from './errors/error';
/** /**
* Class to abstract some common code for tabs. * Class to abstract some common code for tabs.
@ -54,6 +55,7 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
tabs: T[] = []; // List of tabs. tabs: T[] = []; // List of tabs.
hideTabs = false;
selected?: string; // Selected tab id. selected?: string; // Selected tab id.
showPrevButton = false; showPrevButton = false;
showNextButton = false; showNextButton = false;
@ -65,10 +67,11 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
initialSlide: 0, initialSlide: 0,
slidesPerView: 3, slidesPerView: 3,
centerInsufficientSlides: true, centerInsufficientSlides: true,
threshold: 10,
}; };
protected slidesElement?: HTMLIonSlidesElement;
protected initialized = false; protected initialized = false;
protected afterViewInitTriggered = false;
protected resizeListener?: CoreEventObserver; protected resizeListener?: CoreEventObserver;
protected isDestroyed = false; protected isDestroyed = false;
@ -79,54 +82,41 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
protected firstSelectedTab?: string; // ID of the first selected tab to control history. protected firstSelectedTab?: string; // ID of the first selected tab to control history.
protected backButtonFunction: (event: BackButtonEvent) => void; protected backButtonFunction: (event: BackButtonEvent) => void;
protected languageChangedSubscription?: Subscription;
// Swiper 6 documentation: https://swiper6.vercel.app/ // Swiper 6 documentation: https://swiper6.vercel.app/
protected isInTransition = false; // Wether Slides is in transition. protected isInTransition = false; // Wether Slides is in transition.
protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any protected subscriptions: Subscription[] = [];
protected slidesSwiperLoaded = false;
tabAction: CoreTabsRoleTab<T>; tabAction: CoreTabsRoleTab<T>;
constructor( constructor() {
protected element: ElementRef,
) {
this.backButtonFunction = this.backButtonClicked.bind(this); this.backButtonFunction = this.backButtonClicked.bind(this);
this.tabAction = new CoreTabsRoleTab(this); this.tabAction = new CoreTabsRoleTab(this);
} }
/** /**
* Component being initialized. * @inheritdoc
*/ */
ngOnInit(): void { async ngOnInit(): Promise<void> {
this.direction = Platform.isRTL ? 'rtl' : 'ltr'; this.direction = Platform.isRTL ? 'rtl' : 'ltr';
// Change the side when the language changes. // Change the side when the language changes.
this.languageChangedSubscription = Translate.onLangChange.subscribe(() => { this.subscriptions.push(Translate.onLangChange.subscribe(() => {
setTimeout(() => { setTimeout(() => {
this.direction = Platform.isRTL ? 'rtl' : 'ltr'; this.direction = Platform.isRTL ? 'rtl' : 'ltr';
}); });
}); }));
} }
/** /**
* View has been initialized. * @inheritdoc
*/ */
async ngAfterViewInit(): Promise<void> { ngAfterViewInit(): void {
if (this.isDestroyed) { if (this.isDestroyed) {
return; return;
} }
this.afterViewInitTriggered = true; this.init();
if (!this.initialized && this.hideUntil) {
// Tabs should be shown, initialize them.
await this.initializeTabs();
}
this.resizeListener = CoreDom.onWindowResize(() => {
this.windowResized();
});
} }
/** /**
@ -134,14 +124,7 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
ngOnChanges(changes: Record<string, SimpleChange>): void { ngOnChanges(changes: Record<string, SimpleChange>): void {
// Wait for ngAfterViewInit so it works in the case that each tab has its own component. this.init();
if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) {
// Tabs should be shown, initialize them.
// Use a setTimeout so child components update their inputs before initializing the tabs.
setTimeout(() => {
this.initializeTabs();
});
}
} }
/** /**
@ -206,6 +189,16 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
return; return;
} }
this.numTabsShown = this.tabs.reduce((prev: number, current) => current.enabled ? prev + 1 : prev, 0);
if (this.numTabsShown <= 1) {
this.hideTabs = true;
// Only one, nothing to do here.
return;
}
this.hideTabs = false;
await this.calculateMaxSlides(); await this.calculateMaxSlides();
await this.updateSlides(); await this.updateSlides();
@ -233,28 +226,81 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
} }
/** /**
* Initialize the tabs, determining the first tab to be shown. * Init the component.
*/ */
protected async initializeTabs(): Promise<void> { protected async init(): Promise<void> {
// Initialize slider. if (!this.hideUntil) {
this.slidesSwiper = await this.slides?.getSwiper(); // Hidden, do nothing.
this.slidesSwiper.once('progress', () => {
this.slidesSwiperLoaded = true;
this.calculateSlides();
});
const selectedTab = this.calculateInitialTab();
if (!selectedTab) {
return; return;
} }
this.firstSelectedTab = selectedTab.id!; try {
this.selectTab(this.firstSelectedTab); await this.initializeSlider();
await this.initializeTabs();
} catch {
// Something went wrong, ignore.
}
}
/**
* Initialize the slider elements.
*/
protected async initializeSlider(): Promise<void> {
if (this.initialized) {
return;
}
if (this.slidesElement) {
// Already initializated, await for ready.
await this.slidesElement.componentOnReady();
return;
}
if (!this.slides) {
await CoreUtils.nextTick();
}
const slidesSwiper = await this.slides?.getSwiper();
if (!slidesSwiper || !this.slides) {
throw new CoreError('Swiper not found, will try on next change.');
}
this.slidesElement = <HTMLIonSlidesElement>slidesSwiper.el;
await this.slidesElement.componentOnReady();
this.initialized = true; this.initialized = true;
// Subscribe to changes.
this.subscriptions.push(this.slides.ionSlideDidChange.subscribe(() => {
this.slideChanged();
}));
}
/**
* Initialize the tabs, determining the first tab to be shown.
*/
protected async initializeTabs(): Promise<void> {
if (!this.initialized || !this.slidesElement) {
return;
}
const selectedTab = this.calculateInitialTab();
if (!selectedTab) {
// No enabled tabs, return.
throw new CoreError('No enabled tabs.');
}
this.firstSelectedTab = selectedTab.id;
if (this.firstSelectedTab !== undefined) {
this.selectTab(this.firstSelectedTab);
}
// Check which arrows should be shown. // Check which arrows should be shown.
this.calculateSlides(); this.calculateSlides();
this.resizeListener = CoreDom.onWindowResize(() => {
this.calculateSlides();
});
} }
/** /**
@ -277,7 +323,7 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
* Method executed when the slides are changed. * Method executed when the slides are changed.
*/ */
async slideChanged(): Promise<void> { async slideChanged(): Promise<void> {
if (!this.slidesSwiperLoaded) { if (!this.slidesElement) {
return; return;
} }
@ -302,17 +348,15 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
* Updates the number of slides to show. * Updates the number of slides to show.
*/ */
protected async updateSlides(): Promise<void> { protected async updateSlides(): Promise<void> {
this.numTabsShown = this.tabs.reduce((prev: number, current) => current.enabled ? prev + 1 : prev, 0); if (!this.slides) {
return;
}
this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) };
this.slideChanged(); await this.slideChanged();
// @todo: This call to update() can trigger JS errors in the console if tabs are re-loaded and there's only 1 tab. await this.slides.update();
// For some reason, swiper.slides is undefined inside the Slides class, and the swiper is marked as destroyed.
// Changing *ngIf="hideUntil" to [hidden] doesn't solve the issue, and it causes another error to be raised.
// This can be tested in lesson as a student, play a lesson and go back to the entry page.
await this.slides!.update();
if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) {
this.hasSliddenToInitial = true; this.hasSliddenToInitial = true;
@ -320,7 +364,7 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
setTimeout(() => { setTimeout(() => {
if (this.shouldSlideToInitial) { if (this.shouldSlideToInitial) {
this.slides!.slideTo(this.selectedIndex, 0); this.slides?.slideTo(this.selectedIndex, 0);
this.shouldSlideToInitial = false; this.shouldSlideToInitial = false;
} }
}, 400); }, 400);
@ -339,17 +383,23 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
* Calculate the number of slides that can fit on the screen. * Calculate the number of slides that can fit on the screen.
*/ */
protected async calculateMaxSlides(): Promise<void> { protected async calculateMaxSlides(): Promise<void> {
if (!this.slidesSwiperLoaded) { if (!this.slidesElement || !this.slides) {
return; return;
} }
this.maxSlides = 3; this.maxSlides = 3;
let width = this.slidesSwiper.width; await CoreUtils.nextTick();
if (!width) {
this.slidesSwiper.updateSize();
width = this.slidesSwiper.width;
let width: number = this.slidesElement.getBoundingClientRect().width;
if (!width) { if (!width) {
const slidesSwiper = await this.slides.getSwiper();
await slidesSwiper.updateSize();
await CoreUtils.nextTick();
width = slidesSwiper.width;
if (!width) {
return; return;
} }
} }
@ -456,12 +506,12 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
return; return;
} }
if (this.selected) { if (this.selected && this.slides) {
// Check if we need to slide to the tab because it's not visible. // Check if we need to slide to the tab because it's not visible.
const firstVisibleTab = await this.slides!.getActiveIndex(); const firstVisibleTab = await this.slides.getActiveIndex();
const lastVisibleTab = firstVisibleTab + this.slidesOpts.slidesPerView - 1; const lastVisibleTab = firstVisibleTab + this.slidesOpts.slidesPerView - 1;
if (index < firstVisibleTab || index > lastVisibleTab) { if (index < firstVisibleTab || index > lastVisibleTab) {
await this.slides!.slideTo(index, 0, true); await this.slides.slideTo(index, 0, true);
} }
} }
@ -470,9 +520,9 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
return; return;
} }
const ok = await this.loadTab(tabToSelect); const suceeded = await this.loadTab(tabToSelect);
if (ok !== false) { if (suceeded !== false) {
this.tabSelected(tabToSelect, index); this.tabSelected(tabToSelect, index);
} }
} }
@ -503,15 +553,6 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
return true; return true;
} }
/**
* Adapt tabs to a window resize.
*/
protected windowResized(): void {
setTimeout(() => {
this.calculateSlides();
}, 200);
}
/** /**
* Component destroyed. * Component destroyed.
*/ */
@ -519,7 +560,7 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
this.isDestroyed = true; this.isDestroyed = true;
this.resizeListener?.off(); this.resizeListener?.off();
this.languageChangedSubscription?.unsubscribe(); this.subscriptions.forEach((subscription) => subscription.unsubscribe());
} }
} }
@ -541,8 +582,8 @@ class CoreTabsRoleTab<T extends CoreTabBase> extends CoreAriaRoleTab<CoreTabsBas
*/ */
getSelectableTabs(): CoreAriaRoleTabFindable[] { getSelectableTabs(): CoreAriaRoleTabFindable[] {
return this.componentInstance.tabs.filter((tab) => tab.enabled).map((tab) => ({ return this.componentInstance.tabs.filter((tab) => tab.enabled).map((tab) => ({
id: tab.id!, id: tab.id || '',
findIndex: tab.id!, findIndex: tab.id || '',
})); }));
} }

View File

@ -6,10 +6,9 @@
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left" [attr.aria-label]="'core.previous' | translate"></ion-icon> <ion-icon *ngIf="showPrevButton" name="fas-chevron-left" [attr.aria-label]="'core.previous' | translate"></ion-icon>
</ion-col> </ion-col>
<ion-col class="ion-no-padding" size="10"> <ion-col class="ion-no-padding" size="10">
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" <ion-slides [options]="slidesOpts" [dir]="direction" role="tablist" [attr.aria-label]="description">
[attr.aria-label]="description">
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<ion-slide role="presentation" [id]="tab.id! + '-tab'" class="tab-slide" tabindex="-1" <ion-slide role="presentation" class="tab-slide" [id]=" tab.id! + '-tab'" tabindex="-1"
[class.selected]="selected == tab.id"> [class.selected]="selected == tab.id">
<ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" (keydown)="tabAction.keyDown($event)" <ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" (keydown)="tabAction.keyDown($event)"
(keyup)="tabAction.keyUp(tab.id, $event)" [tab]="tab.page" [layout]="layout" class="{{tab.class}}" (keyup)="tabAction.keyUp(tab.id, $event)" [tab]="tab.page" [layout]="layout" class="{{tab.class}}"

View File

@ -70,7 +70,7 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent<CoreTabsOutle
protected existsInNavigationStack = false; protected existsInNavigationStack = false;
constructor(element: ElementRef) { constructor(element: ElementRef) {
super(element); super();
CoreComponentsRegistry.register(element.nativeElement, this); CoreComponentsRegistry.register(element.nativeElement, this);
} }

View File

@ -5,10 +5,9 @@
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left" [attr.aria-label]="'core.previous' | translate"></ion-icon> <ion-icon *ngIf="showPrevButton" name="fas-chevron-left" [attr.aria-label]="'core.previous' | translate"></ion-icon>
</ion-col> </ion-col>
<ion-col class="ion-no-padding" size="10"> <ion-col class="ion-no-padding" size="10">
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" <ion-slides [options]="slidesOpts" [dir]="direction" role="tablist" [attr.aria-label]="description">
[attr.aria-label]="description">
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<ion-slide *ngIf="tab.enabled" role="presentation" [hidden]="!hideUntil" class="tab-slide" [id]="tab.id! + '-tab'" <ion-slide *ngIf="tab.enabled" role="presentation" class="tab-slide" [id]="tab.id! + '-tab'"
[class.selected]="selected == tab.id"> [class.selected]="selected == tab.id">
<ion-tab-button (click)="selectTab(tab.id, $event)" (keydown)="tabAction.keyDown($event)" <ion-tab-button (click)="selectTab(tab.id, $event)" (keydown)="tabAction.keyDown($event)"
(keyup)="tabAction.keyUp(tab.id, $event)" class="{{tab.class}}" [layout]="layout" role="tab" (keyup)="tabAction.keyUp(tab.id, $event)" class="{{tab.class}}" [layout]="layout" role="tab"

View File

@ -65,7 +65,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
return this.isEnabled; return this.isEnabled;
} }
@Input() id?: string; // An ID to identify the tab. @Input() id = ''; // An ID to identify the tab.
@Output() ionSelect: EventEmitter<CoreTabComponent> = new EventEmitter<CoreTabComponent>(); @Output() ionSelect: EventEmitter<CoreTabComponent> = new EventEmitter<CoreTabComponent>();
@ContentChild(TemplateRef) template?: TemplateRef<unknown>; // Template defined by the content. @ContentChild(TemplateRef) template?: TemplateRef<unknown>; // Template defined by the content.
@ -82,7 +82,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
element: ElementRef, element: ElementRef,
) { ) {
this.element = element.nativeElement; this.element = element.nativeElement;
this.id = this.id || 'core-tab-' + CoreUtils.getUniqueId('CoreTabComponent');
this.element.setAttribute('role', 'tabpanel'); this.element.setAttribute('role', 'tabpanel');
this.element.setAttribute('tabindex', '0'); this.element.setAttribute('tabindex', '0');
this.element.setAttribute('aria-hidden', 'true'); this.element.setAttribute('aria-hidden', 'true');
@ -92,7 +92,6 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
* Component being initialized. * Component being initialized.
*/ */
ngOnInit(): void { ngOnInit(): void {
this.id = this.id || 'core-tab-' + CoreUtils.getUniqueId('CoreTabComponent');
this.element.setAttribute('aria-labelledby', this.id + '-tab'); this.element.setAttribute('aria-labelledby', this.id + '-tab');
this.element.setAttribute('id', this.id); this.element.setAttribute('id', this.id);

View File

@ -39,7 +39,7 @@
ion-tab-button { ion-tab-button {
max-width: 100%; max-width: 100%;
ion-label { ion-label {
font-size: 16px; font-size: 14px;
font-weight: 400; font-weight: 400;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -109,8 +109,3 @@
position: relative; position: relative;
} }
} }
:host-context(.ios) {
--height: 53px;
}

View File

@ -51,12 +51,6 @@ export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> i
protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content. protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content.
constructor(
element: ElementRef,
) {
super(element);
}
/** /**
* View has been initialized. * View has been initialized.
*/ */
@ -84,7 +78,7 @@ export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> i
*/ */
addTab(tab: CoreTabComponent): void { addTab(tab: CoreTabComponent): void {
// Check if tab is already in the list. // Check if tab is already in the list.
if (this.getTabIndex(tab.id!) == -1) { if (this.getTabIndex(tab.id) === -1) {
this.tabs.push(tab); this.tabs.push(tab);
this.sortTabs(); this.sortTabs();
@ -100,7 +94,7 @@ export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> i
* @param tab The tab to remove. * @param tab The tab to remove.
*/ */
removeTab(tab: CoreTabComponent): void { removeTab(tab: CoreTabComponent): void {
const index = this.getTabIndex(tab.id!); const index = this.getTabIndex(tab.id);
this.tabs.splice(index, 1); this.tabs.splice(index, 1);
this.calculateSlides(); this.calculateSlides();

View File

@ -131,6 +131,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModal(error); CoreDomUtils.showErrorModal(error);
CoreNavigator.back(); CoreNavigator.back();
this.loaded = true;
return; return;
} }
@ -224,9 +225,9 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
// Select the tab if needed. // Select the tab if needed.
this.firstTabName = undefined; this.firstTabName = undefined;
if (tabToLoad) { if (tabToLoad) {
setTimeout(() => { await CoreUtils.nextTick();
this.tabsComponent?.selectByIndex(tabToLoad!);
}); this.tabsComponent?.selectByIndex(tabToLoad);
} }
} }