MOBILE-3814 collapsible: Wait the page transition before calculating
parent
860c3e7483
commit
463194d526
|
@ -18,6 +18,8 @@ import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreAnimations } from '@components/animations';
|
import { CoreAnimations } from '@components/animations';
|
||||||
import { Translate } from '@singletons';
|
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.
|
* 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;
|
uniqueId: string;
|
||||||
protected element: HTMLElement; // Current element.
|
protected element: HTMLElement; // Current element.
|
||||||
loaded = false; // Only comes true once.
|
loaded = false; // Only comes true once.
|
||||||
|
protected firstLoadedPromise = new CorePromisedValue<string>();
|
||||||
|
|
||||||
constructor(element: ElementRef) {
|
constructor(element: ElementRef) {
|
||||||
this.element = element.nativeElement;
|
this.element = element.nativeElement;
|
||||||
|
CoreComponentsRegistry.register(this.element, this);
|
||||||
|
|
||||||
// Calculate the unique ID.
|
// Calculate the unique ID.
|
||||||
this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent');
|
this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component being initialized.
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!this.message) {
|
if (!this.message) {
|
||||||
|
@ -77,50 +81,54 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View has been initialized.
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
// Add class if loaded on init.
|
this.changeState(!!this.hideUntil);
|
||||||
if (this.hideUntil) {
|
|
||||||
this.element.classList.add('core-loading-loaded');
|
|
||||||
}
|
|
||||||
this.loaded = !!this.hideUntil;
|
|
||||||
|
|
||||||
this.content?.nativeElement.classList.toggle('core-loading-content', !!this.hideUntil);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component input changed.
|
* @inheritdoc
|
||||||
*
|
|
||||||
* @param changes Changes.
|
|
||||||
*/
|
*/
|
||||||
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
|
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
|
||||||
if (changes.hideUntil) {
|
if (changes.hideUntil) {
|
||||||
if (!this.loaded) {
|
this.changeState(!!this.hideUntil);
|
||||||
this.loaded = !!this.hideUntil; // Only comes true once.
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hideUntil) {
|
/**
|
||||||
setTimeout(() => {
|
* Change loaded state.
|
||||||
// Content is loaded so, center the spinner on the content itself.
|
*
|
||||||
this.element.classList.add('core-loading-loaded');
|
* @param loaded True to load, false otherwise.
|
||||||
// Change CSS to force calculate height.
|
* @return Promise resolved when done.
|
||||||
// Removed 500ms timeout to avoid reallocating html.
|
*/
|
||||||
this.content?.nativeElement.classList.add('core-loading-content');
|
async changeState(loaded: boolean): Promise<void> {
|
||||||
});
|
await CoreUtils.nextTick();
|
||||||
} 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.
|
this.element.classList.toggle('core-loading-loaded', loaded);
|
||||||
setTimeout(() => {
|
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> {
|
CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, <CoreEventLoadingChangedData> {
|
||||||
loaded: !!this.hideUntil,
|
loaded: loaded,
|
||||||
uniqueId: this.uniqueId,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChange } from '@angular/core';
|
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 { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet';
|
||||||
import { ScrollDetail } from '@ionic/core';
|
import { ScrollDetail } from '@ionic/core';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
@ -55,32 +57,36 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
|
||||||
@Input() collapsible = true;
|
@Input() collapsible = true;
|
||||||
|
|
||||||
protected page?: HTMLElement;
|
protected page?: HTMLElement;
|
||||||
protected collapsedHeader?: Element;
|
protected collapsedHeader: HTMLIonHeaderElement;
|
||||||
protected collapsedFontStyles?: Partial<CSSStyleDeclaration>;
|
protected collapsedFontStyles?: Partial<CSSStyleDeclaration>;
|
||||||
protected expandedHeader?: Element;
|
protected expandedHeader?: HTMLIonItemElement;
|
||||||
protected expandedHeaderHeight?: number;
|
protected expandedHeaderHeight?: number;
|
||||||
protected expandedFontStyles?: Partial<CSSStyleDeclaration>;
|
protected expandedFontStyles?: Partial<CSSStyleDeclaration>;
|
||||||
protected content?: HTMLIonContentElement;
|
protected content?: HTMLIonContentElement;
|
||||||
protected contentScrollListener?: EventListener;
|
protected contentScrollListener?: EventListener;
|
||||||
protected endContentScrollListener?: EventListener;
|
protected endContentScrollListener?: EventListener;
|
||||||
protected floatingTitle?: HTMLElement;
|
protected pageDidEnterListener?: EventListener;
|
||||||
|
protected floatingTitle?: HTMLHeadingElement;
|
||||||
protected scrollingHeight?: number;
|
protected scrollingHeight?: number;
|
||||||
protected subscriptions: Subscription[] = [];
|
protected subscriptions: Subscription[] = [];
|
||||||
protected enabled = true;
|
protected enabled = true;
|
||||||
protected isWithinContent = false;
|
protected isWithinContent = false;
|
||||||
|
protected enteredPromise = new CorePromisedValue<void>();
|
||||||
|
|
||||||
constructor(protected el: ElementRef) {}
|
constructor(el: ElementRef) {
|
||||||
|
this.collapsedHeader = el.nativeElement;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
this.collapsedHeader = this.el.nativeElement;
|
this.initializePage();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.initializePage(),
|
|
||||||
this.initializeCollapsedHeader(),
|
this.initializeCollapsedHeader(),
|
||||||
this.initializeExpandedHeader(),
|
this.initializeExpandedHeader(),
|
||||||
|
await this.enteredPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.initializeFloatingTitle();
|
this.initializeFloatingTitle();
|
||||||
|
@ -111,30 +117,42 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
|
||||||
if (this.content && this.endContentScrollListener) {
|
if (this.content && this.endContentScrollListener) {
|
||||||
this.content.removeEventListener('ionScrollEnd', 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.
|
* Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll.
|
||||||
*/
|
*/
|
||||||
protected async initializePage(): Promise<void> {
|
protected initializePage(): void {
|
||||||
if (!this.collapsedHeader?.parentElement) {
|
if (!this.collapsedHeader.parentElement) {
|
||||||
throw new Error('[collapsible-header] Couldn\'t get page');
|
throw new Error('[collapsible-header] Couldn\'t get page');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find element and prepare classes.
|
// Find element and prepare classes.
|
||||||
this.page = this.collapsedHeader.parentElement;
|
this.page = this.collapsedHeader.parentElement;
|
||||||
|
|
||||||
this.page.classList.add('collapsible-header-page');
|
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.
|
* 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> {
|
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');
|
this.collapsedHeader.classList.add('collapsible-header-collapsed');
|
||||||
|
|
||||||
await this.waitFormatTextsRendered(this.collapsedHeader);
|
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.
|
* 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> {
|
protected async initializeExpandedHeader(): Promise<void> {
|
||||||
do {
|
await this.waitLoadingsDone();
|
||||||
await CoreUtils.wait(50);
|
|
||||||
|
|
||||||
this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined;
|
this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined;
|
||||||
|
|
||||||
if (!this.expandedHeader) {
|
if (!this.expandedHeader) {
|
||||||
continue;
|
throw new Error('[collapsible-header] Couldn\'t initialize expanded header');
|
||||||
}
|
}
|
||||||
|
this.expandedHeader.classList.add('collapsible-header-expanded');
|
||||||
|
|
||||||
await this.waitFormatTextsRendered(this.expandedHeader);
|
await this.waitFormatTextsRendered(this.expandedHeader);
|
||||||
} while (
|
|
||||||
!this.expandedHeader ||
|
|
||||||
this.expandedHeader.clientHeight === 0 ||
|
|
||||||
this.expandedHeader.closest('ion-content.animating')
|
|
||||||
);
|
|
||||||
|
|
||||||
this.expandedHeader.classList.add('collapsible-header-expanded');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -180,6 +191,8 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
|
||||||
|
|
||||||
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
|
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
|
||||||
|
|
||||||
|
onOutletUpdated();
|
||||||
|
|
||||||
return;
|
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.
|
* Initialize a floating title to mimic transitioning the title from one state to the other.
|
||||||
*/
|
*/
|
||||||
protected initializeFloatingTitle(): void {
|
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');
|
throw new Error('[collapsible-header] Couldn\'t create floating title');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add floating title and measure initial position.
|
// Add floating title and measure initial position.
|
||||||
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLElement;
|
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLHeadingElement;
|
||||||
const originalTitle = this.expandedHeader.querySelector('h1') as HTMLElement;
|
const originalTitle = this.expandedHeader.querySelector('h1') as HTMLHeadingElement;
|
||||||
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
|
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');
|
originalTitle.classList.add('collapsible-header-original-title');
|
||||||
floatingTitle.classList.add('collapsible-header-floating-title');
|
floatingTitle.classList.add('collapsible-header-floating-title');
|
||||||
|
@ -265,17 +278,22 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
|
||||||
this.expandedHeaderHeight = expandedHeaderHeight;
|
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.
|
* Wait until all <core-format-text> children inside the element are done rendering.
|
||||||
*
|
*
|
||||||
* @param element Element.
|
* @param element Element.
|
||||||
*/
|
*/
|
||||||
protected async waitFormatTextsRendered(element: Element): Promise<void> {
|
protected async waitFormatTextsRendered(element: Element): Promise<void> {
|
||||||
const formatTexts = Array
|
await CoreComponentsRegistry.resolveAllElementsInside<CoreFormatTextDirective>(element, 'core-format-text', 'rendered');
|
||||||
.from(element.querySelectorAll('core-format-text'))
|
|
||||||
.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective));
|
|
||||||
|
|
||||||
await Promise.all(formatTexts.map(formatText => formatText?.rendered()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -12,6 +12,9 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry to keep track of component instances.
|
* Registry to keep track of component instances.
|
||||||
*/
|
*/
|
||||||
|
@ -36,7 +39,7 @@ export class CoreComponentsRegistry {
|
||||||
* @param componentClass Component class.
|
* @param componentClass Component class.
|
||||||
* @returns Component instance.
|
* @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;
|
const instance = (element && this.instances.get(element) as T) ?? null;
|
||||||
|
|
||||||
return instance && (!componentClass || instance instanceof componentClass)
|
return instance && (!componentClass || instance instanceof componentClass)
|
||||||
|
@ -44,6 +47,39 @@ export class CoreComponentsRegistry {
|
||||||
: null;
|
: 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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue