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.
|
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
|
|
|
import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet';
|
2021-11-19 12:20:00 +00:00
|
|
|
import { ScrollDetail } from '@ionic/core';
|
|
|
|
import { CoreUtils } from '@services/utils/utils';
|
2022-02-24 16:23:22 +00:00
|
|
|
import { CoreComponentsRegistry } from '@singletons/components-registry';
|
2021-11-19 12:20:00 +00:00
|
|
|
import { CoreMath } from '@singletons/math';
|
2022-02-24 16:23:22 +00:00
|
|
|
import { Subscription } from 'rxjs';
|
|
|
|
import { CoreFormatTextDirective } from './format-text';
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
/**
|
2022-02-24 16:23:22 +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>
|
2022-02-24 16:23:22 +00:00
|
|
|
* <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]',
|
|
|
|
})
|
2022-02-24 16:23:22 +00:00
|
|
|
export class CoreCollapsibleHeaderDirective implements OnInit, OnDestroy {
|
|
|
|
|
|
|
|
protected page?: HTMLElement;
|
|
|
|
protected collapsedHeader?: Element;
|
|
|
|
protected collapsedFontStyles?: Partial<CSSStyleDeclaration>;
|
|
|
|
protected expandedHeader?: Element;
|
|
|
|
protected expandedHeaderHeight?: number;
|
|
|
|
protected expandedFontStyles?: Partial<CSSStyleDeclaration>;
|
|
|
|
protected content?: HTMLIonContentElement;
|
|
|
|
protected contentScrollListener?: EventListener;
|
|
|
|
protected floatingTitle?: HTMLElement;
|
|
|
|
protected scrollingHeight?: number;
|
|
|
|
protected subscriptions: Subscription[] = [];
|
|
|
|
|
|
|
|
constructor(protected el: ElementRef) {}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @inheritdoc
|
|
|
|
*/
|
|
|
|
async ngOnInit(): Promise<void> {
|
|
|
|
this.collapsedHeader = this.el.nativeElement;
|
2021-12-01 15:19:26 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
await Promise.all([
|
|
|
|
this.initializePage(),
|
|
|
|
this.initializeCollapsedHeader(),
|
|
|
|
this.initializeExpandedHeader(),
|
|
|
|
]);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.initializeFloatingTitle();
|
|
|
|
this.initializeContent();
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
2022-02-15 11:43:14 +00:00
|
|
|
/**
|
2022-02-24 16:23:22 +00:00
|
|
|
* @inheritdoc
|
2022-02-15 11:43:14 +00:00
|
|
|
*/
|
2022-02-24 16:23:22 +00:00
|
|
|
ngOnDestroy(): void {
|
|
|
|
this.subscriptions.forEach(subscription => subscription.unsubscribe());
|
2022-02-15 11:43:14 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
if (this.content && this.contentScrollListener) {
|
|
|
|
this.content.removeEventListener('ionScroll', this.contentScrollListener);
|
2022-02-15 11:43:14 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-11-19 12:20:00 +00:00
|
|
|
/**
|
2022-02-24 16:23:22 +00:00
|
|
|
* Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll.
|
2021-11-19 12:20:00 +00:00
|
|
|
*/
|
2022-02-24 16:23:22 +00:00
|
|
|
protected async initializePage(): Promise<void> {
|
|
|
|
if (!this.collapsedHeader?.parentElement) {
|
|
|
|
throw new Error('[collapsible-header] Couldn\'t get page');
|
|
|
|
}
|
2022-01-24 10:19:34 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
// Find element and prepare classes.
|
|
|
|
this.page = this.collapsedHeader.parentElement;
|
2022-01-24 10:19:34 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.page.classList.add('collapsible-header-page');
|
|
|
|
}
|
2022-01-24 10:19:34 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
/**
|
|
|
|
* 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');
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.collapsedHeader.classList.add('collapsible-header-collapsed');
|
|
|
|
|
|
|
|
await this.waitFormatTextsRendered(this.collapsedHeader);
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-02-24 16:23:22 +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
|
|
|
*/
|
2022-02-24 16:23:22 +00:00
|
|
|
protected async initializeExpandedHeader(): Promise<void> {
|
|
|
|
do {
|
|
|
|
await CoreUtils.wait(50);
|
|
|
|
|
|
|
|
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.classList.add('collapsible-header-expanded');
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-02-24 16:23:22 +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
|
|
|
*/
|
2022-02-24 16:23:22 +00:00
|
|
|
protected async initializeContent(): Promise<void> {
|
|
|
|
// Initialize from tabs.
|
|
|
|
const tabs = CoreComponentsRegistry.resolve(this.page?.querySelector('core-tabs-outlet'), CoreTabsOutletComponent);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +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
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.updateContent(activePage?.querySelector('ion-content:not(.disable-scroll-y)') as HTMLIonContentElement);
|
|
|
|
};
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
|
|
|
|
this.subscriptions.push(outlet.deactivateEvents.subscribe(onOutletUpdated));
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
onOutletUpdated();
|
2022-01-18 13:44:43 +00:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +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
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
if (!content) {
|
|
|
|
throw new Error('[collapsible-header] Couldn\'t get content');
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.trackContentScroll(content as HTMLIonContentElement);
|
|
|
|
}
|
2021-12-09 11:28:44 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
/**
|
|
|
|
* 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) {
|
|
|
|
throw new Error('[collapsible-header] Couldn\'t create floating title');
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
// Add floating title and measure initial position.
|
|
|
|
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLElement;
|
|
|
|
const originalTitle = this.expandedHeader.querySelector('h1') as HTMLElement;
|
|
|
|
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
|
|
|
|
const floatingTitle = originalTitle.cloneNode(true) as HTMLElement;
|
|
|
|
|
|
|
|
originalTitle.classList.add('collapsible-header-original-title');
|
|
|
|
floatingTitle.classList.add('collapsible-header-floating-title');
|
|
|
|
floatingTitleWrapper.classList.add('collapsible-header-floating-title-wrapper');
|
|
|
|
floatingTitleWrapper.insertBefore(floatingTitle, originalTitle);
|
|
|
|
|
|
|
|
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('is-active');
|
|
|
|
|
|
|
|
this.floatingTitle = floatingTitle;
|
|
|
|
this.scrollingHeight = originalTitleBoundingBox.top - collapsedHeaderTitleBoundingBox.top;
|
|
|
|
this.collapsedFontStyles = collapsedFontStyles;
|
|
|
|
this.expandedFontStyles = expandedFontStyles;
|
|
|
|
this.expandedHeaderHeight = expandedHeaderHeight;
|
2022-01-24 10:19:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-02-24 16:23:22 +00:00
|
|
|
* Wait until all <core-format-text> children inside the element are done rendering.
|
2022-01-24 10:19:34 +00:00
|
|
|
*
|
2022-02-24 16:23:22 +00:00
|
|
|
* @param element Element.
|
2022-01-24 10:19:34 +00:00
|
|
|
*/
|
2022-02-24 16:23:22 +00:00
|
|
|
protected async waitFormatTextsRendered(element: Element): Promise<void> {
|
|
|
|
const formatTexts = Array
|
|
|
|
.from(element.querySelectorAll('core-format-text'))
|
|
|
|
.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective));
|
2022-01-24 10:19:34 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
await Promise.all(formatTexts.map(formatText => formatText?.rendered()));
|
|
|
|
}
|
2022-01-24 10:19:34 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
/**
|
|
|
|
* Update content element whos scroll is being tracked.
|
|
|
|
*
|
|
|
|
* @param content Content element.
|
|
|
|
*/
|
|
|
|
protected updateContent(content?: HTMLIonContentElement | null): void {
|
|
|
|
if (this.content && this.contentScrollListener) {
|
|
|
|
this.content.removeEventListener('ionScroll', this.contentScrollListener);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
delete this.content;
|
|
|
|
delete this.contentScrollListener;
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
content && this.trackContentScroll(content);
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-02-24 16:23:22 +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
|
|
|
*/
|
2022-02-24 16:23:22 +00:00
|
|
|
protected async trackContentScroll(content: HTMLIonContentElement): Promise<void> {
|
|
|
|
if (content === this.content) {
|
2022-01-24 10:19:34 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
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 content.getScrollElement();
|
|
|
|
|
|
|
|
if (
|
|
|
|
!page ||
|
|
|
|
!scrollingHeight ||
|
|
|
|
!expandedHeader ||
|
|
|
|
!expandedHeaderHeight ||
|
|
|
|
!expandedFontStyles ||
|
|
|
|
!collapsedFontStyles ||
|
|
|
|
!floatingTitle
|
|
|
|
) {
|
|
|
|
throw new Error('[collapsible-header] Couldn\'t set up scrolling');
|
2022-02-17 13:16:49 +00:00
|
|
|
}
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
page.style.setProperty('--collapsible-header-progress', '0');
|
|
|
|
page.classList.toggle('is-within-content', content.contains(expandedHeader));
|
|
|
|
page.classList.toggle('is-collapsed', false);
|
2022-01-24 10:19:34 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
Object
|
|
|
|
.entries(expandedFontStyles)
|
|
|
|
.forEach(([property, value]) => floatingTitle.style.setProperty(property, value as string));
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
this.content = content;
|
|
|
|
this.content.addEventListener('ionScroll', this.contentScrollListener = ({ target }: CustomEvent<ScrollDetail>): void => {
|
|
|
|
if (target !== this.content) {
|
|
|
|
return;
|
|
|
|
}
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
const scrollableHeight = contentScroll.scrollHeight - contentScroll.clientHeight;
|
|
|
|
const collapsedHeight = expandedHeaderHeight - (expandedHeader.clientHeight ?? 0);
|
|
|
|
const frozen = scrollableHeight + collapsedHeight <= 2 * expandedHeaderHeight;
|
|
|
|
const progress = frozen
|
|
|
|
? 0
|
|
|
|
: CoreMath.clamp(contentScroll.scrollTop / scrollingHeight, 0, 1);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +00:00
|
|
|
page.style.setProperty('--collapsible-header-progress', `${progress}`);
|
|
|
|
page.classList.toggle('is-frozen', frozen);
|
|
|
|
page.classList.toggle('is-collapsed', progress === 1);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-02-24 16:23:22 +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
|
|
|
}
|
|
|
|
|
|
|
|
}
|