MOBILE-3814 dom: Improve scroll handling

main
Pau Ferrer Ocaña 2022-03-21 13:34:32 +01:00
parent dbc91004e4
commit a76914f25a
15 changed files with 166 additions and 144 deletions

View File

@ -1106,10 +1106,10 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
* Scroll to the first new unread message.
*/
scrollToFirstUnreadMessage(): void {
if (this.newMessages > 0 && this.content) {
const messages = Array.from(this.hostElement.querySelectorAll('.addon-message-not-mine'));
if (this.newMessages > 0) {
const messages = Array.from(this.hostElement.querySelectorAll<HTMLElement>('.addon-message-not-mine'));
CoreDomUtils.scrollToElement(this.content, <HTMLElement> messages[messages.length - this.newMessages]);
CoreDomUtils.scrollViewToElement(messages[messages.length - this.newMessages]);
}
}

View File

@ -159,7 +159,7 @@ export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave {
this.messages[this.messages.length - 1].showTail = true;
// New messages or beeps, scroll to bottom.
setTimeout(() => this.scrollToBottom());
this.scrollToBottom();
}
protected async loadMessageBeepWho(message: AddonModChatFormattedMessage): Promise<void> {
@ -341,13 +341,12 @@ export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave {
/**
* Scroll bottom when render has finished.
*/
scrollToBottom(): void {
async scrollToBottom(): Promise<void> {
// Need a timeout to leave time to the view to be rendered.
setTimeout(() => {
if (!this.viewDestroyed) {
this.content?.scrollToBottom();
}
});
await CoreUtils.nextTick();
if (!this.viewDestroyed) {
this.content?.scrollToBottom();
}
}
/**

View File

@ -352,9 +352,7 @@ export class AddonModDataEditPage implements OnInit {
}
this.jsData!.errors = this.errors;
setTimeout(() => {
this.scrollToFirstError();
});
this.scrollToFirstError();
}
} finally {
modal.dismiss();
@ -449,8 +447,9 @@ export class AddonModDataEditPage implements OnInit {
/**
* Scroll to first error or to the top if not found.
*/
protected scrollToFirstError(): void {
if (!CoreDomUtils.scrollToElementBySelector(this.formElement.nativeElement, this.content, '.addon-data-error')) {
protected async scrollToFirstError(): Promise<void> {
const scrolled = await CoreDomUtils.scrollViewToElement(this.formElement.nativeElement, '.addon-data-error');
if (!scrolled) {
this.content?.scrollToTop();
}
}

View File

@ -20,7 +20,6 @@ import {
OnChanges,
OnDestroy,
OnInit,
Optional,
Output,
SimpleChange,
ViewChild,
@ -41,7 +40,6 @@ import {
import { CoreTag } from '@features/tag/services/tag';
import { Translate } from '@singletons';
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
import { IonContent } from '@ionic/angular';
import { AddonModForumSync } from '../../services/forum-sync';
import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text';
@ -94,7 +92,6 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
constructor(
protected elementRef: ElementRef,
@Optional() protected content?: IonContent,
) {}
get showForm(): boolean {
@ -308,8 +305,8 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
this.post.id > 0 ? this.post.id : undefined,
);
this.scrollToForm(5);
} catch (error) {
this.scrollToForm();
} catch {
// Cancelled.
}
}
@ -540,19 +537,11 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
/**
* Scroll to reply/edit form.
*
* @param ticksToWait Number of ticks to wait before scrolling.
* @return Promise resolved when done.
*/
protected async scrollToForm(ticksToWait = 1): Promise<void> {
if (!this.content) {
return;
}
await CoreUtils.nextTicks(ticksToWait);
CoreDomUtils.scrollToElementBySelector(
protected async scrollToForm(): Promise<void> {
await CoreDomUtils.scrollViewToElement(
this.elementRef.nativeElement,
this.content,
'#addon-forum-reply-edit-form-' + this.uniqueId,
);
}

View File

@ -187,13 +187,10 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
const scrollTo = this.postId || this.parent;
if (scrollTo) {
// Scroll to the post.
setTimeout(() => {
CoreDomUtils.scrollToElementBySelector(
this.elementRef.nativeElement,
this.content,
'#addon-mod_forum-post-' + scrollTo,
);
});
CoreDomUtils.scrollViewToElement(
this.elementRef.nativeElement,
'#addon-mod_forum-post-' + scrollTo,
);
}
}

View File

@ -14,7 +14,6 @@
import { Component, OnInit, ViewChild, ElementRef, Input, Type } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { IonContent } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
@ -32,7 +31,6 @@ import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../service
})
export class AddonModQuizPreflightModalComponent implements OnInit {
@ViewChild(IonContent) content?: IonContent;
@ViewChild('preflightFormEl') formElement?: ElementRef;
@Input() title!: string;
@ -111,15 +109,14 @@ export class AddonModQuizPreflightModalComponent implements OnInit {
*
* @param e Event.
*/
sendData(e: Event): void {
async sendData(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
if (!this.preflightForm.valid) {
// Form not valid. Scroll to the first element with errors.
const hasScrolled = CoreDomUtils.scrollToInputError(
const hasScrolled = await CoreDomUtils.scrollViewToInputError(
this.elementRef.nativeElement,
this.content,
);
if (!hasScrolled) {

View File

@ -318,10 +318,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
this.loaded = true;
if (slot !== undefined) {
// Scroll to the question. Give some time to the questions to render.
setTimeout(() => {
this.scrollToQuestion(slot);
}, 2000);
// Scroll to the question.
this.scrollToQuestion(slot);
}
}
}
@ -689,9 +687,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
* @param slot Slot of the question to scroll to.
*/
protected scrollToQuestion(slot: number): void {
CoreDomUtils.scrollToElementBySelector(
CoreDomUtils.scrollViewToElement(
this.elementRef.nativeElement,
this.content,
'#addon-mod_quiz-question-' + slot,
);
}

View File

@ -133,10 +133,8 @@ export class AddonModQuizReviewPage implements OnInit {
this.loaded = true;
if (slot !== undefined) {
// Scroll to the question. Give some time to the questions to render.
setTimeout(() => {
this.scrollToQuestion(slot);
}, 2000);
// Scroll to the question.
this.scrollToQuestion(slot);
}
}
}
@ -249,9 +247,8 @@ export class AddonModQuizReviewPage implements OnInit {
* @param slot Slot of the question to scroll to.
*/
protected scrollToQuestion(slot: number): void {
CoreDomUtils.scrollToElementBySelector(
CoreDomUtils.scrollViewToElement(
this.elementRef.nativeElement,
this.content,
`#addon-mod_quiz-question-${slot}`,
);
}

View File

@ -142,11 +142,13 @@ export class CoreLinkDirective implements OnInit {
if (href.charAt(0) == '#') {
// Look for id or name.
href = href.substring(1);
CoreDomUtils.scrollToElementBySelector(
this.element.closest('ion-content'),
this.content,
`#${href}, [name='${href}']`,
);
const container = this.element.closest('ion-content');
if (container) {
CoreDomUtils.scrollViewToElement(
container,
`#${href}, [name='${href}']`,
);
}
return;
}

View File

@ -492,9 +492,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
// Scroll to module if needed. Give more priority to the input.
const moduleIdToScroll = this.moduleId && previousValue === undefined ? this.moduleId : moduleId;
if (moduleIdToScroll) {
setTimeout(() => {
this.scrollToModule(moduleIdToScroll);
}, 200);
this.scrollToModule(moduleIdToScroll);
} else {
this.content.scrollToTop(0);
}
@ -513,9 +511,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
* @param moduleId Module ID.
*/
protected scrollToModule(moduleId: number): void {
CoreDomUtils.scrollToElementBySelector(
CoreDomUtils.scrollViewToElement(
this.elementRef.nativeElement,
this.content,
'#core-course-module-' + moduleId,
);
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
import { Component, ElementRef, Input, OnInit } from '@angular/core';
import {
CoreCourseModuleCompletionStatus,
CoreCourseModuleCompletionTracking,
@ -21,7 +21,6 @@ import {
import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { IonContent } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons';
@ -35,8 +34,6 @@ import { ModalController } from '@singletons';
})
export class CoreCourseCourseIndexComponent implements OnInit {
@ViewChild(IonContent) content?: IonContent;
@Input() sections: CoreCourseSection[] = [];
@Input() selectedId?: number;
@Input() course?: CoreCourseAnyCourseData;
@ -112,13 +109,10 @@ export class CoreCourseCourseIndexComponent implements OnInit {
this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course);
setTimeout(() => {
CoreDomUtils.scrollToElementBySelector(
this.elementRef.nativeElement,
this.content,
'.item.item-current',
);
}, 300);
CoreDomUtils.scrollViewToElement(
this.elementRef.nativeElement,
'.item.item-current',
);
}
/**

View File

@ -13,8 +13,8 @@
// limitations under the License.
import { ActivatedRoute } from '@angular/router';
import { AfterViewInit, Component, ElementRef, OnDestroy, Optional } from '@angular/core';
import { IonContent, IonRefresher } from '@ionic/angular';
import { AfterViewInit, Component, ElementRef, OnDestroy } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreGrades } from '@features/grades/services/grades';
@ -59,7 +59,6 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
constructor(
protected route: ActivatedRoute,
protected element: ElementRef<HTMLElement>,
@Optional() protected content?: IonContent,
) {
try {
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId', { route });
@ -170,11 +169,9 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
if (row) {
this.toggleRow(row, true);
await CoreUtils.nextTick();
CoreDomUtils.scrollToElementBySelector(
CoreDomUtils.scrollViewToElement(
this.element.nativeElement,
this.content,
'#grade-' + row.id,
);
this.gradeId = undefined;

View File

@ -14,7 +14,7 @@
import { Component, ViewChild, ElementRef, OnInit, ChangeDetectorRef } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
import { IonContent, IonRefresher } from '@ionic/angular';
import { IonRefresher } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
@ -46,7 +46,6 @@ import { CoreText } from '@singletons/text';
})
export class CoreLoginEmailSignupPage implements OnInit {
@ViewChild(IonContent) content?: IonContent;
@ViewChild(CoreRecaptchaComponent) recaptchaComponent?: CoreRecaptchaComponent;
@ViewChild('ageForm') ageFormElement?: ElementRef;
@ViewChild('signupFormEl') signupFormElement?: ElementRef;
@ -285,9 +284,8 @@ export class CoreLoginEmailSignupPage implements OnInit {
this.changeDetector.detectChanges();
// Scroll to the first element with errors.
const errorFound = CoreDomUtils.scrollToInputError(
const errorFound = await CoreDomUtils.scrollViewToInputError(
this.elementRef.nativeElement,
this.content,
);
if (!errorFound) {

View File

@ -725,10 +725,14 @@ export class CoreDomUtilsProvider {
* @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll.
* @return positionLeft, positionTop of the element relative to.
*/
getElementXY(container: HTMLElement, selector: undefined, positionParentClass?: string): number[];
getElementXY(container: HTMLElement, selector?: undefined, positionParentClass?: string): number[];
getElementXY(container: HTMLElement, selector: string, positionParentClass?: string): number[] | null;
getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] | null {
let element: HTMLElement | null = <HTMLElement> (selector ? container.querySelector(selector) : container);
getElementXY(container: HTMLElement, selector?: string, positionParentClass = 'inner-scroll'): number[] | null {
let element = (selector ? container.querySelector<HTMLElement>(selector) : container);
if (!element) {
return null;
}
let positionTop = 0;
let positionLeft = 0;
@ -736,10 +740,6 @@ export class CoreDomUtilsProvider {
positionParentClass = 'inner-scroll';
}
if (!element) {
return null;
}
while (element) {
positionLeft += (element.offsetLeft - element.scrollLeft + element.clientLeft);
positionTop += (element.offsetTop - element.scrollTop + element.clientTop);
@ -766,6 +766,25 @@ export class CoreDomUtilsProvider {
return [positionLeft, positionTop];
}
/**
* Retrieve the position of a element relative to another element.
*
* @param element Element to get the position.
* @param parent Parent element to get relative position.
* @return X and Y position.
*/
getRelativeElementPosition(element: HTMLElement, parent: HTMLElement): { x: number; y: number} {
// Get the top, left coordinates of two elements
const elementRectangle = element.getBoundingClientRect();
const parentRectangle = parent.getBoundingClientRect();
// Calculate the top and left positions
return {
x: elementRectangle.x - parentRectangle.x,
y: elementRectangle.y - parentRectangle.y,
};
}
/**
* Given a message, it deduce if it's a network error.
*
@ -1096,11 +1115,9 @@ export class CoreDomUtilsProvider {
* @param selector Selector to search.
*/
removeElement(element: HTMLElement, selector: string): void {
if (element) {
const selected = element.querySelector(selector);
if (selected) {
selected.remove();
}
const selected = element.querySelector(selector);
if (selected) {
selected.remove();
}
}
@ -1198,9 +1215,9 @@ export class CoreDomUtilsProvider {
}
// Treat video posters.
if (media.tagName == 'VIDEO' && media.getAttribute('poster')) {
const currentPoster = media.getAttribute('poster');
const newPoster = paths[CoreTextUtils.decodeURIComponent(currentPoster!)];
const currentPoster = media.getAttribute('poster');
if (media.tagName == 'VIDEO' && currentPoster) {
const newPoster = paths[CoreTextUtils.decodeURIComponent(currentPoster)];
if (newPoster !== undefined) {
media.setAttribute('poster', newPoster);
}
@ -1237,8 +1254,8 @@ export class CoreDomUtilsProvider {
* @return Returns a promise which is resolved when the scroll has completed.
* @deprecated since 3.9.5. Use directly the IonContent class.
*/
scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise<void> {
return content.scrollToPoint(x, y, duration || 0);
scrollTo(content: IonContent, x: number, y: number, duration = 0): Promise<void> {
return content.scrollToPoint(x, y, duration);
}
/**
@ -1261,7 +1278,7 @@ export class CoreDomUtilsProvider {
* @return Returns a promise which is resolved when the scroll has completed.
* @deprecated since 3.9.5. Use directly the IonContent class.
*/
scrollToTop(content: IonContent, duration?: number): Promise<void> {
scrollToTop(content: IonContent, duration = 0): Promise<void> {
return content.scrollToTop(duration);
}
@ -1308,7 +1325,7 @@ export class CoreDomUtilsProvider {
const scrollElement = await content.getScrollElement();
return scrollElement.scrollTop || 0;
} catch (error) {
} catch {
return 0;
}
}
@ -1316,51 +1333,34 @@ export class CoreDomUtilsProvider {
/**
* Scroll to a certain element.
*
* @param content The content that must be scrolled.
* @param element The element to scroll to.
* @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll.
* @param selector Selector to find the element to scroll to inside the defined element.
* @param duration Duration of the scroll animation in milliseconds.
* @return True if the element is found, false otherwise.
* @return Wether the scroll suceeded.
*/
scrollToElement(content: IonContent, element: HTMLElement, scrollParentClass?: string, duration?: number): boolean {
const position = this.getElementXY(element, undefined, scrollParentClass);
if (!position) {
return false;
async scrollViewToElement(element: HTMLElement, selector?: string, duration = 0): Promise<boolean> {
await CoreDomUtils.waitToBeInDOM(element);
if (selector) {
const foundElement = element.querySelector<HTMLElement>(selector);
if (!foundElement) {
// Element not found.
return false;
}
element = foundElement;
}
content.scrollToPoint(position[0], position[1], duration || 0);
return true;
}
/**
* Scroll to a certain element using a selector to find it.
*
* @param container The element that contains the element that must be scrolled.
* @param content The content that must be scrolled.
* @param selector Selector to find the element to scroll to.
* @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll.
* @param duration Duration of the scroll animation in milliseconds.
* @return True if the element is found, false otherwise.
*/
scrollToElementBySelector(
container: HTMLElement | null,
content: IonContent | undefined,
selector: string,
scrollParentClass?: string,
duration?: number,
): boolean {
if (!container || !content) {
const content = element.closest<HTMLIonContentElement>('ion-content') ?? undefined;
if (!content) {
// Content to scroll, not found.
return false;
}
try {
const position = this.getElementXY(container, selector, scrollParentClass);
if (!position) {
return false;
}
const position = CoreDomUtils.getRelativeElementPosition(element, content);
content.scrollToPoint(position[0], position[1], duration || 0);
await content.scrollToPoint(position.x, position.y, duration);
return true;
} catch {
@ -1372,12 +1372,71 @@ export class CoreDomUtilsProvider {
* Search for an input with error (core-input-error directive) and scrolls to it if found.
*
* @param container The element that contains the element that must be scrolled.
* @param content The content that must be scrolled.
* @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll.
* @return True if the element is found, false otherwise.
*/
scrollToInputError(container: HTMLElement | null, content?: IonContent, scrollParentClass?: string): boolean {
return this.scrollToElementBySelector(container, content, '.core-input-error', scrollParentClass);
async scrollViewToInputError(container: HTMLElement): Promise<boolean> {
return this.scrollViewToElement(container, '.core-input-error');
}
/**
* Scroll to a certain element.
*
* @param content Not used anymore.
* @param element The element to scroll to.
* @param scrollParentClass Not used anymore.
* @param duration Duration of the scroll animation in milliseconds.
* @return True if the element is found, false otherwise.
* @deprecated since app 4.0 Use scrollViewToElement instead.
*/
scrollToElement(content: IonContent, element: HTMLElement, scrollParentClass?: string, duration = 0): boolean {
CoreDomUtils.scrollViewToElement(element, undefined, duration);
return true;
}
/**
* Scroll to a certain element using a selector to find it.
*
* @param container The element that contains the element that must be scrolled.
* @param content Not used anymore.
* @param selector Selector to find the element to scroll to.
* @param scrollParentClass Not used anymore.
* @param duration Duration of the scroll animation in milliseconds.
* @return True if the element is found, false otherwise.
* @deprecated since app 4.0 Use scrollViewToElement instead.
*/
scrollToElementBySelector(
container: HTMLElement | null,
content: unknown | null,
selector: string,
scrollParentClass?: string,
duration = 0,
): boolean {
if (!container || !content) {
return false;
}
CoreDomUtils.scrollViewToElement(container, selector, duration);
return true;
}
/**
* Search for an input with error (core-input-error directive) and scrolls to it if found.
*
* @param container The element that contains the element that must be scrolled.
* @return True if the element is found, false otherwise.
* @deprecated since app 4.0 Use scrollViewToInputError instead.
*/
scrollToInputError(container: HTMLElement | null): boolean {
if (!container) {
return false;
}
this.scrollViewToInputError(container);
return true;
}
/**

View File

@ -72,7 +72,7 @@
&:before {
content: '';
height: 60px;
height: 100%;
position: absolute;
@include position(null, 0, 0, 0);
background: linear-gradient(to bottom, rgba(var(--background-gradient-rgb), 0) calc(100% - var(--gradient-size)), rgba(var(--background-gradient-rgb), 1) calc(100% - 4px));