Vmeda.Online/src/core/directives/collapsible-header.ts
Noel De Martin 984ec8fb78 MOBILE-4189 core: Fix collapsible header zoom
When the system font size is increased, the CSS values and computed style values are not equivalent. So we have to take that into account when mirroring styles to the floating title.
2023-09-28 12:25:28 +02:00

601 lines
22 KiB
TypeScript

// (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';
import { ScrollDetail } from '@ionic/core';
import { CoreUtils } from '@services/utils/utils';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
import { CoreDom } from '@singletons/dom';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreMath } from '@singletons/math';
import { Subscription } from 'rxjs';
import { CoreFormatTextDirective } from './format-text';
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[COLLAPSIBLE_HEADER_UPDATED]: { collapsed: boolean };
}
}
export const COLLAPSIBLE_HEADER_UPDATED = 'collapsible_header_updated';
/**
* 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.
*
* 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>
*/
@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,
]);
this.listenEvents();
await this.initializeFloatingTitle();
this.initializeContent();
}
/**
* @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();
}
/**
* Update collapsed status of the header.
*
* @param collapsed Whether header is collapsed or not.
*/
protected setCollapsed(collapsed: boolean): void {
if (!this.page) {
return;
}
const isCollapsed = this.page.classList.contains('collapsible-header-page-is-collapsed');
if (isCollapsed === collapsed) {
return;
}
this.page.classList.toggle('collapsible-header-page-is-collapsed', collapsed);
CoreEvents.trigger(COLLAPSIBLE_HEADER_UPDATED, { collapsed });
}
/**
* Listen to changing events.
*/
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);
}
/**
* 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> {
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);
}
/**
* Search the page content, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/
protected async initializeContent(): Promise<void> {
if (!this.page) {
return;
}
// Initialize from tabs.
const tabs = CoreDirectivesRegistry.resolve(this.page.querySelector('core-tabs-outlet'), CoreTabsOutletComponent);
if (tabs) {
const outlet = tabs.getOutlet();
const onOutletUpdated = () => {
const activePage = outlet.nativeEl.querySelector('.ion-page:not(.ion-page-hidden)');
this.updateContent(activePage?.querySelector('ion-content:not(.disable-scroll-y)') as HTMLIonContentElement);
};
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
onOutletUpdated();
return;
}
// Initialize from page content.
const content = this.page.querySelector('ion-content:not(.disable-scroll-y)');
if (!content) {
throw new Error('[collapsible-header] Couldn\'t get content');
}
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;
}
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] = CoreDom.getCSSPropertyValue(collapsedTitleStyles, property);
styles[1][property] = CoreDom.getCSSPropertyValue(expandedTitleStyles, 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 CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-loading', CoreLoadingComponent);
// Wait tabs to be ready.
await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-tabs', CoreTabsComponent);
await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-tabs-outlet', CoreTabsOutletComponent);
// Wait loadings to finish, inside tabs (if any).
await CoreDirectivesRegistry.waitDirectivesReady(
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.
* @returns Promise resolved when texts are rendered.
*/
protected async waitFormatTextsRendered(element: Element): Promise<void> {
await CoreDirectivesRegistry.waitDirectivesReady(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;
}
if (this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
delete this.endContentScrollListener;
}
delete this.content;
}
content && this.trackContentScroll(content);
}
/**
* 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.setCollapsed(!enable);
this.page.style.setProperty('--collapsible-header-progress', enable ? '0' : '1');
}
/**
* Listen to a content element for scroll events that will control the header state transition.
*
* @param content Content element.
*/
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');
}
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));
this.content.scrollEvents = true;
this.content.addEventListener('ionScroll', this.contentScrollListener = ({ target }: CustomEvent<ScrollDetail>): void => {
if (target !== this.content || !this.enabled) {
return;
}
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);
this.setCollapsed(progress === 1);
page.style.setProperty('--collapsible-header-progress', `${progress}`);
page.classList.toggle('collapsible-header-page-is-frozen', frozen);
Object
.entries(progress > .5 ? collapsedFontStyles : expandedFontStyles)
.forEach(([property, value]) => floatingTitle.style.setProperty(property, value as string));
});
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;
this.setCollapsed(collapse);
page.style.setProperty('--collapsible-header-progress', collapse ? '1' : '0');
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);
}
},
);
}
}