2
0
Fork 0
Vmeda.Online/src/core/directives/collapsible-header.ts

565 lines
21 KiB
TypeScript
Raw Normal View History

2021-11-19 12:20:00 +00:00
// (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 { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChange } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet';
import { CoreTabsComponent } from '@components/tabs/tabs';
import { CoreSettingsHelper } from '@features/settings/services/settings-helper';
2021-11-19 12:20:00 +00:00
import { ScrollDetail } from '@ionic/core';
import { CoreUtils } from '@services/utils/utils';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom';
import { CoreEventObserver } from '@singletons/events';
2021-11-19 12:20:00 +00:00
import { CoreMath } from '@singletons/math';
import { Subscription } from 'rxjs';
import { CoreFormatTextDirective } from './format-text';
2021-11-19 12:20:00 +00:00
/**
* Directive to make <ion-header> collapsible.
*
* This directive expects h1 titles to be duplicated in a header and an item inside the page, and it will transition
* from one state to another listening to the scroll in the page content. The item to be used as the expanded form
* should also have the [collapsed] attribute.
2021-11-19 12:20:00 +00:00
*
* Example usage:
*
* <ion-header collapsible>
* <ion-toolbar>
* <ion-title>
* <h1>Title</h1>
* </ion-title>
* </ion-toolbar>
* </ion-header>
*
* <ion-content>
* <ion-item collapsible>
* <ion-label>
* <h1>Title</h1>
* </ion-label>
* </ion-item>
* ...
* </ion-content>
2021-11-19 12:20:00 +00:00
*/
@Directive({
selector: 'ion-header[collapsible]',
})
export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDestroy {
@Input() collapsible = true;
protected page?: HTMLElement;
protected collapsedHeader: HTMLIonHeaderElement;
protected collapsedFontStyles?: Partial<CSSStyleDeclaration>;
protected expandedHeader?: HTMLIonItemElement;
protected expandedHeaderHeight?: number;
protected expandedFontStyles?: Partial<CSSStyleDeclaration>;
protected content?: HTMLIonContentElement;
protected contentScrollListener?: EventListener;
protected endContentScrollListener?: EventListener;
protected pageDidEnterListener?: EventListener;
protected resizeListener?: CoreEventObserver;
protected floatingTitle?: HTMLHeadingElement;
protected scrollingHeight?: number;
protected subscriptions: Subscription[] = [];
protected enabled = true;
protected isWithinContent = false;
protected enteredPromise = new CorePromisedValue<void>();
protected mutationObserver?: MutationObserver;
protected loadingFloatingTitle = false;
protected visiblePromise?: CoreCancellablePromise<void>;
constructor(el: ElementRef) {
this.collapsedHeader = el.nativeElement;
}
/**
* @inheritdoc
*/
ngOnInit(): void {
this.collapsible = !CoreUtils.isFalseOrZero(this.collapsible);
this.init();
}
/**
* Init function.
*/
async init(): Promise<void> {
if (!this.collapsible || this.expandedHeader) {
return;
}
this.initializePage();
await Promise.all([
this.initializeCollapsedHeader(),
this.initializeExpandedHeader(),
await this.enteredPromise,
]);
2021-11-19 12:20:00 +00:00
await this.initializeFloatingTitle();
this.initializeContent();
2021-11-19 12:20:00 +00:00
}
/**
* @inheritdoc
*/
async ngOnChanges(changes: {[name: string]: SimpleChange}): Promise<void> {
if (changes.collapsible && !changes.collapsible.firstChange) {
this.collapsible = !CoreUtils.isFalseOrZero(changes.collapsible.currentValue);
this.enabled = this.collapsible;
await this.init();
setTimeout(() => {
this.setEnabled(this.enabled);
}, 200);
}
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription.unsubscribe());
if (this.content && this.contentScrollListener) {
this.content.removeEventListener('ionScroll', this.contentScrollListener);
}
if (this.content && this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
}
if (this.page && this.pageDidEnterListener) {
this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener);
}
this.resizeListener?.off();
this.mutationObserver?.disconnect();
this.visiblePromise?.cancel();
}
2021-11-19 12:20:00 +00:00
/**
* Listen to changing events.
2021-11-19 12:20:00 +00:00
*/
protected listenEvents(): void {
this.resizeListener = CoreDom.onWindowResize(() => {
this.initializeFloatingTitle();
}, 50);
this.subscriptions.push(CoreSettingsHelper.onDarkModeChange().subscribe(() => {
this.initializeFloatingTitle();
}));
this.mutationObserver = new MutationObserver(() => {
if (!this.expandedHeader) {
return;
}
const originalTitle = this.expandedHeader.querySelector('h1.collapsible-header-original-title') ||
this.expandedHeader.querySelector('h1') as HTMLHeadingElement;
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
const floatingTitle = floatingTitleWrapper.querySelector('.collapsible-header-floating-title') as HTMLHeadingElement;
if (!floatingTitle || !originalTitle) {
return;
}
// Original title changed, change the contents.
const newFloatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement;
newFloatingTitle.classList.add('collapsible-header-floating-title');
newFloatingTitle.classList.remove('collapsible-header-original-title');
floatingTitleWrapper.replaceChild(newFloatingTitle, floatingTitle);
this.initializeFloatingTitle();
});
}
/**
* Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/
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();
if (this.page && this.pageDidEnterListener) {
this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener);
}
},
);
// 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> {
this.collapsedHeader.classList.add('collapsible-header-collapsed');
await this.waitFormatTextsRendered(this.collapsedHeader);
2021-11-19 12:20:00 +00:00
}
/**
* Search the expanded header element, initialize it, and wait until it's ready for the transition to trigger on scroll.
2021-11-19 12:20:00 +00:00
*/
protected async initializeExpandedHeader(): Promise<void> {
await this.waitLoadingsDone();
this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined;
if (!this.expandedHeader) {
this.enabled = false;
this.setEnabled(this.enabled);
throw new Error('[collapsible-header] Couldn\'t initialize expanded header');
}
this.expandedHeader.classList.add('collapsible-header-expanded');
await this.waitFormatTextsRendered(this.expandedHeader);
2021-11-19 12:20:00 +00:00
}
/**
* Search the page content, initialize it, and wait until it's ready for the transition to trigger on scroll.
2021-11-19 12:20:00 +00:00
*/
protected async initializeContent(): Promise<void> {
if (!this.page) {
return;
}
this.listenEvents();
// Initialize from tabs.
const tabs = CoreComponentsRegistry.resolve(this.page.querySelector('core-tabs-outlet'), CoreTabsOutletComponent);
2021-11-19 12:20:00 +00:00
if (tabs) {
const outlet = tabs.getOutlet();
const onOutletUpdated = () => {
const activePage = outlet.nativeEl.querySelector('.ion-page:not(.ion-page-hidden)');
2021-11-19 12:20:00 +00:00
this.updateContent(activePage?.querySelector('ion-content:not(.disable-scroll-y)') as HTMLIonContentElement);
};
2021-11-19 12:20:00 +00:00
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
onOutletUpdated();
return;
}
2021-11-19 12:20:00 +00:00
// Initialize from page content.
const content = this.page.querySelector('ion-content:not(.disable-scroll-y)');
2021-11-19 12:20:00 +00:00
if (!content) {
throw new Error('[collapsible-header] Couldn\'t get content');
2021-11-19 12:20:00 +00:00
}
this.trackContentScroll(content as HTMLIonContentElement);
}
/**
* Initialize a floating title to mimic transitioning the title from one state to the other.
*/
protected async initializeFloatingTitle(): Promise<void> {
if (!this.page || !this.expandedHeader) {
return;
2021-11-19 12:20:00 +00:00
}
if (this.loadingFloatingTitle) {
// Already calculating, return.
return;
}
this.loadingFloatingTitle = true;
this.visiblePromise = CoreDom.waitToBeVisible(this.expandedHeader);
await this.visiblePromise;
this.page.classList.remove('collapsible-header-page-is-active');
await CoreUtils.nextTick();
// Add floating title and measure initial position.
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLHeadingElement;
const originalTitle = this.expandedHeader.querySelector('h1.collapsible-header-original-title') ||
this.expandedHeader.querySelector('h1') as HTMLHeadingElement;
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
let floatingTitle = floatingTitleWrapper.querySelector('.collapsible-header-floating-title') as HTMLHeadingElement;
if (!floatingTitle) {
// First time, create it.
floatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement;
floatingTitle.classList.add('collapsible-header-floating-title');
floatingTitleWrapper.classList.add('collapsible-header-floating-title-wrapper');
floatingTitleWrapper.insertBefore(floatingTitle, originalTitle);
originalTitle.classList.add('collapsible-header-original-title');
this.mutationObserver?.observe(originalTitle, { childList: true, subtree: true });
}
const floatingTitleBoundingBox = floatingTitle.getBoundingClientRect();
// Prepare styles variables.
const collapsedHeaderTitleBoundingBox = collapsedHeaderTitle.getBoundingClientRect();
const collapsedTitleStyles = getComputedStyle(collapsedHeaderTitle);
const expandedHeaderHeight = this.expandedHeader.clientHeight;
const expandedTitleStyles = getComputedStyle(originalTitle);
const originalTitleBoundingBox = originalTitle.getBoundingClientRect();
const textProperties = ['overflow', 'white-space', 'text-overflow', 'color'];
const [collapsedFontStyles, expandedFontStyles] = Array
.from(collapsedTitleStyles)
.filter(
property =>
property.startsWith('font-') ||
property.startsWith('letter-') ||
textProperties.includes(property),
)
.reduce((styles, property) => {
styles[0][property] = collapsedTitleStyles.getPropertyValue(property);
styles[1][property] = expandedTitleStyles.getPropertyValue(property);
return styles;
}, [{}, {}]);
const cssVariables = {
'--collapsible-header-collapsed-height': `${this.collapsedHeader.clientHeight}px`,
'--collapsible-header-expanded-y-delta': `-${this.collapsedHeader.clientHeight}px`,
'--collapsible-header-expanded-height': `${expandedHeaderHeight}px`,
'--collapsible-header-floating-title-top': `${originalTitleBoundingBox.top - floatingTitleBoundingBox.top}px`,
'--collapsible-header-floating-title-left': `${originalTitleBoundingBox.left - floatingTitleBoundingBox.left}px`,
'--collapsible-header-floating-title-width': `${originalTitle.clientWidth}px`,
'--collapsible-header-floating-title-x-delta':
`${collapsedHeaderTitleBoundingBox.left - originalTitleBoundingBox.left}px`,
'--collapsible-header-floating-title-width-delta': `${collapsedHeaderTitle.clientWidth - originalTitle.clientWidth}px`,
};
Object
.entries(cssVariables)
.forEach(([property, value]) => this.page?.style.setProperty(property, value));
Object
.entries(expandedFontStyles)
.forEach(([property, value]) => floatingTitle.style.setProperty(property, value as string));
// Activate styles.
this.page.classList.add('collapsible-header-page-is-active');
this.floatingTitle = floatingTitle;
this.scrollingHeight = originalTitleBoundingBox.top - collapsedHeaderTitleBoundingBox.top;
this.collapsedFontStyles = collapsedFontStyles;
this.expandedFontStyles = expandedFontStyles;
this.expandedHeaderHeight = expandedHeaderHeight;
this.loadingFloatingTitle = false;
}
/**
* Wait until all <core-loading> children inside the page.
*/
protected async waitLoadingsDone(): Promise<void> {
if (!this.page) {
return;
}
// Wait loadings to finish.
await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-loading', CoreLoadingComponent);
// Wait tabs to be ready.
await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-tabs', CoreTabsComponent);
await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-tabs-outlet', CoreTabsOutletComponent);
// Wait loadings to finish, inside tabs (if any).
await CoreComponentsRegistry.waitComponentsReady(
this.page,
'core-tab core-loading, ion-router-outlet core-loading',
CoreLoadingComponent,
);
}
/**
* Wait until all <core-format-text> children inside the element are done rendering.
*
* @param element Element.
* @return Promise resolved when texts are rendered.
*/
protected async waitFormatTextsRendered(element: Element): Promise<void> {
await CoreComponentsRegistry.waitComponentsReady(element, 'core-format-text', CoreFormatTextDirective);
}
/**
* Update content element whos scroll is being tracked.
*
* @param content Content element.
*/
protected updateContent(content?: HTMLIonContentElement | null): void {
if (content === (this.content ?? null)) {
return;
}
if (this.content) {
if (this.contentScrollListener) {
this.content.removeEventListener('ionScroll', this.contentScrollListener);
delete this.contentScrollListener;
}
2021-11-19 12:20:00 +00:00
if (this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
delete this.endContentScrollListener;
}
delete this.content;
2021-11-19 12:20:00 +00:00
}
content && this.trackContentScroll(content);
2021-11-19 12:20:00 +00:00
}
/**
* Set collapsed/expanded based on properties.
*
* @param enable True to enable, false otherwise
*/
async setEnabled(enable: boolean): Promise<void> {
if (!this.page) {
return;
}
if (enable && this.content) {
const contentScroll = await this.content.getScrollElement();
// Do nothing, since scroll has already started on the page.
if (contentScroll.scrollTop > 0) {
return;
}
}
this.page.style.setProperty('--collapsible-header-progress', enable ? '0' : '1');
this.page.classList.toggle('collapsible-header-page-is-collapsed', !enable);
}
2021-11-19 12:20:00 +00:00
/**
* Listen to a content element for scroll events that will control the header state transition.
*
* @param content Content element.
2021-11-19 12:20:00 +00:00
*/
protected async trackContentScroll(content: HTMLIonContentElement): Promise<void> {
if (content === this.content) {
return;
}
this.content = content;
const page = this.page;
const scrollingHeight = this.scrollingHeight;
const expandedHeader = this.expandedHeader;
const expandedHeaderHeight = this.expandedHeaderHeight;
const expandedFontStyles = this.expandedFontStyles;
const collapsedFontStyles = this.collapsedFontStyles;
const floatingTitle = this.floatingTitle;
const contentScroll = await this.content.getScrollElement();
if (
!page ||
!scrollingHeight ||
!expandedHeader ||
!expandedHeaderHeight ||
!expandedFontStyles ||
!collapsedFontStyles ||
!floatingTitle
) {
page?.classList.remove('collapsible-header-page-is-active');
throw new Error('[collapsible-header] Couldn\'t set up scrolling');
}
2021-11-19 12:20:00 +00:00
this.isWithinContent = content.contains(expandedHeader);
page.classList.toggle('collapsible-header-page-is-within-content', this.isWithinContent);
this.setEnabled(this.enabled);
Object
.entries(expandedFontStyles)
.forEach(([property, value]) => floatingTitle.style.setProperty(property, value as string));
2021-11-19 12:20:00 +00:00
this.content.scrollEvents = true;
this.content.addEventListener('ionScroll', this.contentScrollListener = ({ target }: CustomEvent<ScrollDetail>): void => {
if (target !== this.content || !this.enabled) {
return;
}
2021-11-19 12:20:00 +00:00
const scrollableHeight = contentScroll.scrollHeight - contentScroll.clientHeight;
let frozen = false;
if (this.isWithinContent) {
frozen = scrollableHeight <= scrollingHeight;
} else {
const collapsedHeight = expandedHeaderHeight - (expandedHeader.clientHeight ?? 0);
frozen = scrollableHeight + collapsedHeight <= 2 * expandedHeaderHeight;
}
const progress = frozen
? 0
: CoreMath.clamp(contentScroll.scrollTop / scrollingHeight, 0, 1);
2021-11-19 12:20:00 +00:00
page.style.setProperty('--collapsible-header-progress', `${progress}`);
page.classList.toggle('collapsible-header-page-is-frozen', frozen);
page.classList.toggle('collapsible-header-page-is-collapsed', progress === 1);
2021-11-19 12:20:00 +00:00
Object
.entries(progress > .5 ? collapsedFontStyles : expandedFontStyles)
.forEach(([property, value]) => floatingTitle.style.setProperty(property, value as string));
});
2021-11-19 12:20:00 +00:00
this.content.addEventListener(
'ionScrollEnd',
this.endContentScrollListener = ({ target }: CustomEvent<ScrollDetail>): void => {
if (target !== this.content || !this.enabled) {
return;
}
if (page.classList.contains('collapsible-header-page-is-frozen')) {
return;
}
const progress = parseFloat(page.style.getPropertyValue('--collapsible-header-progress'));
const scrollTop = contentScroll.scrollTop;
const collapse = progress > 0.5;
page.style.setProperty('--collapsible-header-progress', collapse ? '1' : '0');
page.classList.toggle('collapsible-header-page-is-collapsed', collapse);
if (collapse && this.scrollingHeight && this.scrollingHeight > 0 && scrollTop < this.scrollingHeight) {
this.content?.scrollToPoint(null, this.scrollingHeight);
}
if (!collapse && this.scrollingHeight && this.scrollingHeight > 0 && scrollTop > 0) {
this.content?.scrollToPoint(null, 0);
}
},
);
}
2021-11-19 12:20:00 +00:00
}