MOBILE-3814 collapsible: Wait the page transition before calculating

main
Pau Ferrer Ocaña 2022-03-11 12:42:44 +01:00
parent 860c3e7483
commit 463194d526
3 changed files with 135 additions and 73 deletions

View File

@ -18,6 +18,8 @@ import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events';
import { CoreUtils } from '@services/utils/utils';
import { CoreAnimations } from '@components/animations';
import { Translate } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CorePromisedValue } from '@classes/promised-value';
/**
* Component to show a loading spinner and message while data is being loaded.
@ -56,16 +58,18 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
uniqueId: string;
protected element: HTMLElement; // Current element.
loaded = false; // Only comes true once.
protected firstLoadedPromise = new CorePromisedValue<string>();
constructor(element: ElementRef) {
this.element = element.nativeElement;
CoreComponentsRegistry.register(this.element, this);
// Calculate the unique ID.
this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent');
}
/**
* Component being initialized.
* @inheritdoc
*/
ngOnInit(): void {
if (!this.message) {
@ -77,50 +81,54 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
}
/**
* View has been initialized.
* @inheritdoc
*/
ngAfterViewInit(): void {
// Add class if loaded on init.
if (this.hideUntil) {
this.element.classList.add('core-loading-loaded');
}
this.loaded = !!this.hideUntil;
this.content?.nativeElement.classList.toggle('core-loading-content', !!this.hideUntil);
this.changeState(!!this.hideUntil);
}
/**
* Component input changed.
*
* @param changes Changes.
* @inheritdoc
*/
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes.hideUntil) {
if (!this.loaded) {
this.loaded = !!this.hideUntil; // Only comes true once.
}
if (this.hideUntil) {
setTimeout(() => {
// Content is loaded so, center the spinner on the content itself.
this.element.classList.add('core-loading-loaded');
// Change CSS to force calculate height.
// Removed 500ms timeout to avoid reallocating html.
this.content?.nativeElement.classList.add('core-loading-content');
});
} else {
this.element.classList.remove('core-loading-loaded');
this.content?.nativeElement.classList.remove('core-loading-content');
}
// Trigger the event after a timeout since the elements inside ngIf haven't been added to DOM yet.
setTimeout(() => {
CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, <CoreEventLoadingChangedData> {
loaded: !!this.hideUntil,
uniqueId: this.uniqueId,
});
});
this.changeState(!!this.hideUntil);
}
}
/**
* Change loaded state.
*
* @param loaded True to load, false otherwise.
* @return Promise resolved when done.
*/
async changeState(loaded: boolean): Promise<void> {
await CoreUtils.nextTick();
this.element.classList.toggle('core-loading-loaded', loaded);
this.content?.nativeElement.classList.add('core-loading-content', loaded);
await CoreUtils.nextTick();
// Wait for next tick before triggering the event to make sure ngIf elements have been added to the DOM.
CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, <CoreEventLoadingChangedData> {
loaded: loaded,
uniqueId: this.uniqueId,
});
if (!this.loaded && loaded) {
this.loaded = true; // Only comes true once.
this.firstLoadedPromise.resolve(this.uniqueId);
}
}
/**
* Wait the loading to finish.
*
* @return Promise resolved with the uniqueId when done.
*/
async whenLoaded(): Promise<string> {
return await this.firstLoadedPromise;
}
}

View File

@ -13,6 +13,8 @@
// limitations under the License.
import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChange } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet';
import { ScrollDetail } from '@ionic/core';
import { CoreUtils } from '@services/utils/utils';
@ -55,32 +57,36 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
@Input() collapsible = true;
protected page?: HTMLElement;
protected collapsedHeader?: Element;
protected collapsedHeader: HTMLIonHeaderElement;
protected collapsedFontStyles?: Partial<CSSStyleDeclaration>;
protected expandedHeader?: Element;
protected expandedHeader?: HTMLIonItemElement;
protected expandedHeaderHeight?: number;
protected expandedFontStyles?: Partial<CSSStyleDeclaration>;
protected content?: HTMLIonContentElement;
protected contentScrollListener?: EventListener;
protected endContentScrollListener?: EventListener;
protected floatingTitle?: HTMLElement;
protected pageDidEnterListener?: EventListener;
protected floatingTitle?: HTMLHeadingElement;
protected scrollingHeight?: number;
protected subscriptions: Subscription[] = [];
protected enabled = true;
protected isWithinContent = false;
protected enteredPromise = new CorePromisedValue<void>();
constructor(protected el: ElementRef) {}
constructor(el: ElementRef) {
this.collapsedHeader = el.nativeElement;
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.collapsedHeader = this.el.nativeElement;
this.initializePage();
await Promise.all([
this.initializePage(),
this.initializeCollapsedHeader(),
this.initializeExpandedHeader(),
await this.enteredPromise,
]);
this.initializeFloatingTitle();
@ -111,30 +117,42 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
if (this.content && this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
}
if (this.page && this.pageDidEnterListener) {
this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener);
}
}
/**
* Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/
protected async initializePage(): Promise<void> {
if (!this.collapsedHeader?.parentElement) {
protected initializePage(): void {
if (!this.collapsedHeader.parentElement) {
throw new Error('[collapsible-header] Couldn\'t get page');
}
// Find element and prepare classes.
this.page = this.collapsedHeader.parentElement;
this.page.classList.add('collapsible-header-page');
this.page.addEventListener(
'ionViewDidEnter',
this.pageDidEnterListener = () => {
clearTimeout(timeout);
this.enteredPromise.resolve();
},
{ once: true },
);
// Timeout in case event is never fired.
const timeout = window.setTimeout(() => {
this.enteredPromise.reject(new Error('[collapsible-header] Waiting for ionViewDidEnter timeout reached'));
}, 5000);
}
/**
* Search the collapsed header element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/
protected async initializeCollapsedHeader(): Promise<void> {
if (!this.collapsedHeader) {
throw new Error('[collapsible-header] Couldn\'t initialize collapsed header');
}
this.collapsedHeader.classList.add('collapsible-header-collapsed');
await this.waitFormatTextsRendered(this.collapsedHeader);
@ -144,23 +162,16 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
* Search the expanded header element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/
protected async initializeExpandedHeader(): Promise<void> {
do {
await CoreUtils.wait(50);
await this.waitLoadingsDone();
this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined;
if (!this.expandedHeader) {
continue;
}
await this.waitFormatTextsRendered(this.expandedHeader);
} while (
!this.expandedHeader ||
this.expandedHeader.clientHeight === 0 ||
this.expandedHeader.closest('ion-content.animating')
);
this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined;
if (!this.expandedHeader) {
throw new Error('[collapsible-header] Couldn\'t initialize expanded header');
}
this.expandedHeader.classList.add('collapsible-header-expanded');
await this.waitFormatTextsRendered(this.expandedHeader);
}
/**
@ -180,6 +191,8 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
onOutletUpdated();
return;
}
@ -197,15 +210,15 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
* Initialize a floating title to mimic transitioning the title from one state to the other.
*/
protected initializeFloatingTitle(): void {
if (!this.page || !this.collapsedHeader || !this.expandedHeader) {
if (!this.page || !this.expandedHeader) {
throw new Error('[collapsible-header] Couldn\'t create floating title');
}
// Add floating title and measure initial position.
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLElement;
const originalTitle = this.expandedHeader.querySelector('h1') as HTMLElement;
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLHeadingElement;
const originalTitle = this.expandedHeader.querySelector('h1') as HTMLHeadingElement;
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
const floatingTitle = originalTitle.cloneNode(true) as HTMLElement;
const floatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement;
originalTitle.classList.add('collapsible-header-original-title');
floatingTitle.classList.add('collapsible-header-floating-title');
@ -265,17 +278,22 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
this.expandedHeaderHeight = expandedHeaderHeight;
}
/**
* Wait until all <core-loading> children inside the page.
*
* @return Promise resolved when loadings are done.
*/
protected async waitLoadingsDone(): Promise<void> {
await CoreComponentsRegistry.resolveAllElementsInside<CoreLoadingComponent>(this.page, 'core-loading', 'whenLoaded');
}
/**
* Wait until all <core-format-text> children inside the element are done rendering.
*
* @param element Element.
*/
protected async waitFormatTextsRendered(element: Element): Promise<void> {
const formatTexts = Array
.from(element.querySelectorAll('core-format-text'))
.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective));
await Promise.all(formatTexts.map(formatText => formatText?.rendered()));
await CoreComponentsRegistry.resolveAllElementsInside<CoreFormatTextDirective>(element, 'core-format-text', 'rendered');
}
/**

View File

@ -12,6 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { CoreUtils } from '@services/utils/utils';
/**
* Registry to keep track of component instances.
*/
@ -36,7 +39,7 @@ export class CoreComponentsRegistry {
* @param componentClass Component class.
* @returns Component instance.
*/
static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
static resolve<T = Component>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
const instance = (element && this.instances.get(element) as T) ?? null;
return instance && (!componentClass || instance instanceof componentClass)
@ -44,6 +47,39 @@ export class CoreComponentsRegistry {
: null;
}
/**
* Waits all elements to be resolved.
*
* @param element Parent element where to search.
* @param selector Selector to search on parent.
* @param fnName Function that needs to get resolved.
* @param params Params of function that needs to get resolved.
* @return Promise resolved when done.
*/
static async resolveAllElementsInside<T = Component>(
element: Element | undefined | null,
selector: string,
fnName: string,
params?: unknown[],
): Promise<void> {
if (!element) {
return;
}
const components = Array
.from(element.querySelectorAll(selector))
.map(element => CoreComponentsRegistry.resolve<T>(element));
await Promise.all(components.map(component => {
if (!component) {
return;
}
return component[fnName].apply(component, params);
}));
await CoreUtils.nextTick();
}
}
/**