feat(refresher): add MD native refresher (#20096)

resolves #17316
This commit is contained in:
Liam DeBeasi
2020-01-03 14:56:26 -05:00
committed by GitHub
parent a01c10267e
commit 814ec765b9
11 changed files with 445 additions and 73 deletions

View File

@ -1,11 +1,12 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core';
import { getTimeGivenProgression } from '../../';
import { getIonMode } from '../../global/ionic-global';
import { Gesture, GestureDetail, RefresherEventDetail } from '../../interface';
import { Animation, Gesture, GestureDetail, RefresherEventDetail } from '../../interface';
import { clamp } from '../../utils/helpers';
import { hapticImpact } from '../../utils/native/haptic';
import { handleScrollWhilePulling, handleScrollWhileRefreshing, setSpinnerOpacity, shouldUseNativeRefresher, translateElement } from './refresher.utils';
import { createPullingAnimation, createSnapBackAnimation, getRefresherAnimationType, handleScrollWhilePulling, handleScrollWhileRefreshing, setSpinnerOpacity, shouldUseNativeRefresher, transitionEndAsync, translateElement } from './refresher.utils';
@Component({
tag: 'ion-refresher',
@ -28,6 +29,7 @@ export class Refresher implements ComponentInterface {
private didRefresh = false;
private lastVelocityY = 0;
private elementToTransform?: HTMLElement;
private animations: Animation[] = [];
@State() private nativeRefresher = false;
@ -142,26 +144,24 @@ export class Refresher implements ComponentInterface {
private async resetNativeRefresher(el: HTMLElement | undefined, state: RefresherState) {
this.state = state;
if (el !== undefined) {
if (getIonMode(this) === 'ios') {
await translateElement(el, undefined);
} else {
await transitionEndAsync(this.el.querySelector('.refresher-refreshing-icon'));
}
this.didRefresh = false;
this.needsCompletion = false;
this.pointerDown = false;
this.animations.forEach(ani => ani.destroy());
this.animations = [];
this.progress = 0;
this.state = RefresherState.Inactive;
}
private async setupNativeRefresher(contentEl: HTMLIonContentElement | null) {
if (this.scrollListenerCallback || !contentEl) {
return;
}
const pullingSpinner = this.el.querySelector('ion-refresher-content .refresher-pulling ion-spinner') as HTMLElement;
const refreshingSpinner = this.el.querySelector('ion-refresher-content .refresher-refreshing ion-spinner') as HTMLElement;
private async setupiOSNativeRefresher(pullingSpinner: HTMLIonSpinnerElement, refreshingSpinner: HTMLIonSpinnerElement) {
this.elementToTransform = this.scrollEl!.querySelector(`#scroll-content`) as HTMLElement | undefined;
this.nativeRefresher = true;
const ticks = pullingSpinner.shadowRoot!.querySelectorAll('svg');
const MAX_PULL = this.scrollEl!.clientHeight * 0.16;
const NUM_TICKS = ticks.length;
@ -198,7 +198,10 @@ export class Refresher implements ComponentInterface {
this.ionStart.emit();
}
this.ionPull.emit();
// emit "pulling" on every move
if (this.pointerDown) {
this.ionPull.emit();
}
}
// delay showing the next tick marks until user has pulled 30px
@ -235,39 +238,144 @@ export class Refresher implements ComponentInterface {
this.scrollEl!.addEventListener('scroll', this.scrollListenerCallback);
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.scrollEl!,
gestureName: 'refresher',
gesturePriority: 10,
direction: 'y',
threshold: 0,
onStart: () => {
this.pointerDown = true;
el: this.scrollEl!,
gestureName: 'refresher',
gesturePriority: 10,
direction: 'y',
threshold: 0,
onStart: () => {
this.pointerDown = true;
if (!this.didRefresh) {
translateElement(this.elementToTransform, '0px');
}
},
onMove: ev => {
this.lastVelocityY = ev.velocityY;
},
onEnd: () => {
this.pointerDown = false;
this.didStart = false;
if (this.needsCompletion) {
this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing);
this.needsCompletion = false;
} else if (this.didRefresh) {
readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`));
}
},
});
if (!this.didRefresh) {
translateElement(this.elementToTransform, '0px');
}
},
onMove: ev => {
this.lastVelocityY = ev.velocityY;
},
onEnd: () => {
this.pointerDown = false;
this.didStart = false;
if (this.needsCompletion) {
this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing);
this.needsCompletion = false;
} else if (this.didRefresh) {
readTask(() => {
translateElement(this.elementToTransform, `${this.el.clientHeight}px`);
});
}
},
this.disabledChanged();
}
private async setupMDNativeRefresher(contentEl: HTMLIonContentElement, pullingSpinner: HTMLIonSpinnerElement, refreshingSpinner: HTMLIonSpinnerElement) {
const circle = pullingSpinner.shadowRoot!.querySelector('circle');
const pullingRefresherIcon = this.el.querySelector('ion-refresher-content .refresher-pulling-icon') as HTMLElement;
const refreshingCircle = refreshingSpinner.shadowRoot!.querySelector('circle');
if (circle !== null && refreshingCircle !== null) {
writeTask(() => {
circle.style.setProperty('animation', 'none');
// This lines up the animation on the refreshing spinner with the pulling spinner
refreshingSpinner.style.setProperty('animation-delay', '-655ms');
refreshingCircle.style.setProperty('animation-delay', '-655ms');
});
}
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.scrollEl!,
gestureName: 'refresher',
gesturePriority: 10,
direction: 'y',
threshold: 0,
canStart: () => this.state !== RefresherState.Refreshing && this.state !== RefresherState.Completing && this.scrollEl!.scrollTop === 0,
onStart: (ev: GestureDetail) => {
ev.data = { animation: undefined, didStart: false, cancelled: false };
},
onMove: (ev: GestureDetail) => {
if ((ev.velocityY < 0 && this.progress === 0 && !ev.data.didStart) || ev.data.cancelled) {
ev.data.cancelled = true;
return;
}
if (!ev.data.didStart) {
ev.data.didStart = true;
this.state = RefresherState.Pulling;
writeTask(() => {
const animationType = getRefresherAnimationType(contentEl);
const animation = createPullingAnimation(animationType, pullingRefresherIcon);
ev.data.animation = animation;
this.scrollEl!.style.setProperty('--overflow', 'hidden');
animation.progressStart(false, 0);
this.ionStart.emit();
this.animations.push(animation);
});
return;
}
// Since we are using an easing curve, slow the gesture tracking down a bit
this.progress = clamp(0, (ev.deltaY / 180) * 0.5, 1);
ev.data.animation.progressStep(this.progress);
this.ionPull.emit();
},
onEnd: (ev: GestureDetail) => {
if (!ev.data.didStart) { return; }
writeTask(() => this.scrollEl!.style.removeProperty('--overflow'));
if (this.progress <= 0.4) {
this.gesture!.enable(false);
ev.data.animation
.progressEnd(0, this.progress, 500)
.onFinish(() => {
this.animations.forEach(ani => ani.destroy());
this.animations = [];
this.gesture!.enable(true);
this.state = RefresherState.Inactive;
});
return;
}
const progress = getTimeGivenProgression([0, 0], [0, 0], [1, 1], [1, 1], this.progress)[0];
const snapBackAnimation = createSnapBackAnimation(pullingRefresherIcon);
this.animations.push(snapBackAnimation);
writeTask(async () => {
pullingRefresherIcon.style.setProperty('--ion-pulling-refresher-translate', `${(progress * 100)}px`);
ev.data.animation.progressEnd();
await snapBackAnimation.play();
this.beginRefresh();
ev.data.animation.destroy();
});
}
});
this.disabledChanged();
}
private async setupNativeRefresher(contentEl: HTMLIonContentElement | null) {
if (this.scrollListenerCallback || !contentEl || this.nativeRefresher) {
return;
}
this.nativeRefresher = true;
const pullingSpinner = this.el.querySelector('ion-refresher-content .refresher-pulling ion-spinner') as HTMLIonSpinnerElement;
const refreshingSpinner = this.el.querySelector('ion-refresher-content .refresher-refreshing ion-spinner') as HTMLIonSpinnerElement;
if (getIonMode(this) === 'ios') {
this.setupiOSNativeRefresher(pullingSpinner, refreshingSpinner);
} else {
this.setupMDNativeRefresher(contentEl, pullingSpinner, refreshingSpinner);
}
}
componentDidUpdate() {
this.checkNativeRefresher();
}