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

@ -51,14 +51,14 @@ export class RefresherContent implements ComponentInterface {
const mode = getIonMode(this);
this.pullingIcon = config.get(
'refreshingIcon',
mode === 'ios' && isPlatform('mobile') ? config.get('spinner', 'lines') : 'arrow-down'
mode === 'ios' && isPlatform('mobile') ? config.get('spinner', 'lines') : 'circular'
);
}
if (this.refreshingSpinner === undefined) {
const mode = getIonMode(this);
this.refreshingSpinner = config.get(
'refreshingSpinner',
config.get('spinner', mode === 'ios' ? 'lines' : 'crescent')
config.get('spinner', mode === 'ios' ? 'lines' : 'circular')
);
}
}
@ -66,12 +66,21 @@ export class RefresherContent implements ComponentInterface {
render() {
const pullingIcon = this.pullingIcon;
const hasSpinner = pullingIcon != null && SPINNERS[pullingIcon] as any !== undefined;
const mode = getIonMode(this);
return (
<Host class={getIonMode(this)}>
<Host class={mode}>
<div class="refresher-pulling">
{this.pullingIcon && hasSpinner &&
<div class="refresher-pulling-icon">
<ion-spinner name={this.pullingIcon as SpinnerTypes} paused></ion-spinner>
<div class="spinner-arrow-container">
<ion-spinner name={this.pullingIcon as SpinnerTypes} paused></ion-spinner>
{mode === 'md' && this.pullingIcon === 'circular' &&
<div class="arrow-container">
<ion-icon name="caret-back-sharp"></ion-icon>
</div>
}
</div>
</div>
}
{this.pullingIcon && !hasSpinner &&

View File

@ -10,14 +10,19 @@ refresher.
### Native Refreshers
Both iOS and Android platforms provide refreshers that take advantage of properties exposed by their respective devices that give pull to refresh a fluid, native-like feel. One of the limitations of this is that the refreshers only work on their respective platform devices. For example, the iOS native `ion-refresher` works on an iPhone in iOS mode, but does not work on an Android device in iOS mode. In order for the refresher to work on an unsupported device, we provide a fallback refresher. This can also be set manually by overriding the `pullingIcon` property.
Both iOS and Android platforms provide refreshers that take advantage of properties exposed by their respective devices that give pull to refresh a fluid, native-like feel.
Because much of the native refreshers are based on scrolling, certain properties such as `pullMin` and `snapbackDuration` are not compatible. See [ion-refresher Properties](#properties) for more information.
Certain properties such as `pullMin` and `snapbackDuration` are not compatible because much of the native refreshers are scroll-based. See [Refresher Properties](#properties) for more information.
#### iOS Usage
Using the iOS native `ion-refresher` requires setting the `pullingIcon` property on `ion-refresher-content` to the value of one of the available spinners. See the [ion-spinner Documentation](../spinner#properties) for accepted values. The `pullingIcon` defaults to the `lines` spinner on iOS. The spinner tick marks will be progressively shown as the user pulls down on the page. In order for the refresher to work on a device that isn't an iOS mobile device, the `pullingIcon` should be set to an icon.
Using the iOS native `ion-refresher` requires setting the `pullingIcon` property on `ion-refresher-content` to the value of one of the available spinners. See the [Spinner Documentation](../spinner#properties) for accepted values. The `pullingIcon` defaults to the `lines` spinner on iOS. The spinner tick marks will be progressively shown as the user pulls down on the page.
The iOS native `ion-refresher` relies on rubber band scrolling in order to work properly and is only compatible with iOS devices as a result. We provide a fallback refresher for apps running in iOS mode on devices that do not support rubber band scrolling.
#### Android Usage
Using the MD native `ion-refresher` requires setting the `pullingIcon` property on `ion-refresher-content` to the value of one of the available spinners. See the [ion-spinner Documentation](../spinner#properties) for accepted values. `pullingIcon` defaults to the `circular` spinner on MD.
<!-- Auto Generated Below -->
@ -154,7 +159,6 @@ export const RefresherExample: React.FC = () => (
</IonContent>
</IonContent>
);
```

View File

@ -25,3 +25,74 @@
.refresher-md .refresher-refreshing .spinner-dots circle {
fill: $refresher-md-icon-color;
}
ion-refresher.refresher-native {
display: block;
z-index: 1;
ion-spinner {
@include margin(0, auto, 0, auto);
width: 24px;
height: 24px;
color: $refresher-md-native-spinner-color;
}
.spinner-arrow-container {
display: inherit;
}
.arrow-container {
display: block;
position: absolute;
width: 24px;
height: 24px;
ion-icon {
@include margin(0, auto, 0, auto);
@include position(null, 0, -4px, 0);
position: absolute;
color: $refresher-md-native-spinner-color;
font-size: 12px;
}
}
&.refresher-pulling ion-refresher-content,
&.refresher-ready ion-refresher-content {
.refresher-pulling {
display: flex;
}
}
&.refresher-refreshing ion-refresher-content,
&.refresher-completing ion-refresher-content,
&.refresher-cancelling ion-refresher-content {
.refresher-refreshing {
display: flex;
}
}
.refresher-pulling-icon {
transform: translateY(calc(-100% - 10px));
}
.refresher-pulling-icon,
.refresher-refreshing-icon {
@include margin(0, auto, 0, auto);
@include border-radius(100%);
@include padding(8px, 8px, 8px, 8px);
display: flex;
border: $refresher-md-native-spinner-border;
background: $refresher-md-native-spinner-background;
box-shadow: $refresher-md-native-spinner-box-shadow;
}
}

View File

@ -1,7 +1,19 @@
@import "../../themes/ionic.globals.md";
/// @prop - Color of the refresher icon
$refresher-md-icon-color: $text-color !default;
$refresher-md-icon-color: $text-color !default;
/// @prop - Text color of the refresher content
$refresher-md-text-color: $text-color !default;
$refresher-md-text-color: $text-color !default;
/// @prop - Color of the native refresher spinner
$refresher-md-native-spinner-color: #{ion-color(primary, base)} !default;
/// @prop - Border of the native refresher spinner
$refresher-md-native-spinner-border: 1px solid #ececec !default;
/// @prop - Background of the native refresher spinner
$refresher-md-native-spinner-background: white !default;
/// @prop - Box shadow of the native refresher spinner
$refresher-md-native-spinner-box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.1) !default;

View File

@ -56,6 +56,10 @@ ion-refresher-content {
text-align: center;
}
ion-refresher-content .arrow-container {
display: none;
}
// Refresher Content States
// --------------------------------------------------
@ -100,3 +104,12 @@ ion-refresher-content {
transform: scale(0);
}
}
// Refresher Native
// --------------------------------------------------
.refresher-native {
.refresher-pulling-text, .refresher-refreshing-text {
display: none;
}
}

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.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.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();
}

View File

@ -1,7 +1,123 @@
import { writeTask } from '@stencil/core';
import { createAnimation } from '../../';
import { isPlatform } from '../../utils/platform';
// MD Native Refresher
// -----------------------------
type RefresherAnimationType = 'scale' | 'translate';
export const getRefresherAnimationType = (contentEl: HTMLIonContentElement): RefresherAnimationType => {
const previousSibling = contentEl.previousElementSibling;
const hasHeader = previousSibling !== null && previousSibling.tagName === 'ION-HEADER';
return hasHeader ? 'translate' : 'scale';
};
export const createPullingAnimation = (type: RefresherAnimationType, pullingSpinner: HTMLElement) => {
return type === 'scale' ? createScaleAnimation(pullingSpinner) : createTranslateAnimation(pullingSpinner);
};
const createBaseAnimation = (pullingRefresherIcon: HTMLElement) => {
const spinner = pullingRefresherIcon.querySelector('ion-spinner') as HTMLElement;
const circle = spinner!.shadowRoot!.querySelector('circle') as any;
const spinnerArrowContainer = pullingRefresherIcon.querySelector('.spinner-arrow-container') as HTMLElement;
const arrowContainer = pullingRefresherIcon!.querySelector('.arrow-container');
const arrow = (arrowContainer) ? arrowContainer!.querySelector('ion-icon') as HTMLElement : null;
const baseAnimation = createAnimation()
.duration(1000)
.easing('ease-out');
const spinnerArrowContainerAnimation = createAnimation()
.addElement(spinnerArrowContainer)
.keyframes([
{ offset: 0, opacity: '0.3' },
{ offset: 0.45, opacity: '0.3' },
{ offset: 0.55, opacity: '1' },
{ offset: 1, opacity: '1' }
]);
const circleInnerAnimation = createAnimation()
.addElement(circle)
.keyframes([
{ offset: 0, 'stroke-dasharray': '1px, 200px' },
{ offset: 0.20, 'stroke-dasharray': '1px, 200px' },
{ offset: 0.55, 'stroke-dasharray': '100px, 200px' },
{ offset: 1, 'stroke-dasharray': '100px, 200px' }
]);
const circleOuterAnimation = createAnimation()
.addElement(spinner)
.keyframes([
{ offset: 0, transform: 'rotate(-90deg)' },
{ offset: 1, transform: 'rotate(210deg)' }
]);
/**
* Only add arrow animation if present
* this allows users to customize the spinners
* without errors being thrown
*/
if (arrowContainer && arrow) {
const arrowContainerAnimation = createAnimation()
.addElement(arrowContainer)
.keyframes([
{ offset: 0, transform: 'rotate(0deg)' },
{ offset: 0.30, transform: 'rotate(0deg)' },
{ offset: 0.55, transform: 'rotate(280deg)' },
{ offset: 1, transform: 'rotate(400deg)' }
]);
const arrowAnimation = createAnimation()
.addElement(arrow)
.keyframes([
{ offset: 0, transform: 'translateX(2px) scale(0)' },
{ offset: 0.30, transform: 'translateX(2px) scale(0)' },
{ offset: 0.55, transform: 'translateX(-1.5px) scale(1)' },
{ offset: 1, transform: 'translateX(-1.5px) scale(1)' }
]);
baseAnimation.addAnimation([arrowContainerAnimation, arrowAnimation]);
}
return baseAnimation.addAnimation([spinnerArrowContainerAnimation, circleInnerAnimation, circleOuterAnimation]);
};
const createScaleAnimation = (pullingRefresherIcon: HTMLElement) => {
const height = pullingRefresherIcon.clientHeight;
const spinnerAnimation = createAnimation()
.addElement(pullingRefresherIcon)
.keyframes([
{ offset: 0, transform: `scale(0) translateY(-${height + 20}px)` },
{ offset: 1, transform: 'scale(1) translateY(100px)' }
]);
return createBaseAnimation(pullingRefresherIcon).addAnimation([spinnerAnimation]);
};
const createTranslateAnimation = (pullingRefresherIcon: HTMLElement) => {
const height = pullingRefresherIcon.clientHeight;
const spinnerAnimation = createAnimation()
.addElement(pullingRefresherIcon)
.keyframes([
{ offset: 0, transform: `translateY(-${height + 20}px)` },
{ offset: 1, transform: 'translateY(100px)' }
]);
return createBaseAnimation(pullingRefresherIcon).addAnimation([spinnerAnimation]);
};
export const createSnapBackAnimation = (pullingRefresherIcon: HTMLElement) => {
return createAnimation()
.duration(125)
.addElement(pullingRefresherIcon)
.fromTo('transform', 'translateY(var(--ion-pulling-refresher-translate, 100px))', 'translateY(0px)');
};
// iOS Native Refresher
// -----------------------------
export const setSpinnerOpacity = (spinner: HTMLElement, opacity: number) => {
spinner.style.setProperty('opacity', opacity.toString());
};
@ -29,6 +145,27 @@ export const handleScrollWhileRefreshing = (
});
};
export const translateElement = (el?: HTMLElement, value?: string) => {
if (!el) { return Promise.resolve(); }
const trans = transitionEndAsync(el);
writeTask(() => {
el.style.setProperty('transition', '0.2s all ease-out');
if (value === undefined) {
el.style.removeProperty('transform');
} else {
el.style.setProperty('transform', `translate3d(0px, ${value}, 0px)`);
}
});
return trans;
};
// Utils
// -----------------------------
export const shouldUseNativeRefresher = (referenceEl: HTMLIonRefresherElement, mode: string) => {
const pullingSpinner = referenceEl.querySelector('ion-refresher-content .refresher-pulling ion-spinner');
const refreshingSpinner = referenceEl.querySelector('ion-refresher-content .refresher-refreshing ion-spinner');
@ -36,26 +173,17 @@ export const shouldUseNativeRefresher = (referenceEl: HTMLIonRefresherElement, m
return (
pullingSpinner !== null &&
refreshingSpinner !== null &&
mode === 'ios' &&
isPlatform('mobile')
(
(mode === 'ios' && isPlatform('mobile')) ||
mode === 'md'
)
);
};
export const translateElement = (el?: HTMLElement, value?: string) => {
export const transitionEndAsync = (el: HTMLElement | null) => {
return new Promise(resolve => {
if (!el) { return resolve(); }
transitionEnd(el, resolve);
writeTask(() => {
el.style.setProperty('transition', '0.2s all ease-out');
if (value === undefined) {
el.style.removeProperty('transform');
} else {
el.style.setProperty('transform', `translate3d(0px, ${value}, 0px)`);
}
});
});
};

View File

@ -44,11 +44,40 @@
</ion-header>
<ion-list id="list"></ion-list>
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button onclick="toggleHeader()">
<ion-icon name="settings"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>
</ion-app>
<script>
const toggleHeader = () => {
const app = document.querySelector('ion-app');
const header = app.querySelector('ion-header');
if (header) {
header.parentNode.removeChild(header);
} else {
app.insertAdjacentHTML("afterbegin", `
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button default-href="/" text="Mailboxes"></ion-back-button>
</ion-buttons>
<ion-title>All Inboxes</ion-title>
<ion-buttons slot="end">
<ion-button>Edit</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
`);
}
}
let items = [];
for (var i = 0; i < 30; i++) {
items.push(i + 1);
@ -94,5 +123,4 @@
render();
</script>
</body>
</html>
</html>

View File

@ -41,5 +41,4 @@ export const RefresherExample: React.FC = () => (
</IonContent>
</IonContent>
);
```

View File

@ -44,10 +44,10 @@ const spinners = {
fn: () => {
return {
r: 20,
cx: 44,
cy: 44,
cx: 48,
cy: 48,
fill: 'none',
viewBox: '22 22 44 44',
viewBox: '24 24 48 48',
transform: 'translate(0,0)',
style: {}
};

View File

@ -111,7 +111,7 @@ svg {
stroke: currentColor;
stroke-dasharray: 80px, 200px;
stroke-dashoffset: 0px;
stroke-width: 3.6;
stroke-width: 5.6;
fill: none;
}