feat(refresher): add iOS native refresher (#20037)

fixes #18664
This commit is contained in:
Liam DeBeasi
2019-12-18 10:52:58 -05:00
committed by GitHub
parent 6d6aba6d40
commit 04e7c03132
17 changed files with 684 additions and 58 deletions

View File

@ -1973,7 +1973,7 @@ export namespace Components {
*/ */
'cancel': () => Promise<void>; 'cancel': () => Promise<void>;
/** /**
* Time it takes to close the refresher. * Time it takes to close the refresher. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'closeDuration': string; 'closeDuration': string;
/** /**
@ -1989,27 +1989,27 @@ export namespace Components {
*/ */
'getProgress': () => Promise<number>; 'getProgress': () => Promise<number>;
/** /**
* How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'pullFactor': number; 'pullFactor': number;
/** /**
* The maximum distance of the pull until the refresher will automatically go into the `refreshing` state. Defaults to the result of `pullMin + 60`. * The maximum distance of the pull until the refresher will automatically go into the `refreshing` state. Defaults to the result of `pullMin + 60`. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'pullMax': number; 'pullMax': number;
/** /**
* The minimum distance the user must pull down until the refresher will go into the `refreshing` state. * The minimum distance the user must pull down until the refresher will go into the `refreshing` state. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'pullMin': number; 'pullMin': number;
/** /**
* Time it takes the refresher to to snap back to the `refreshing` state. * Time it takes the refresher to to snap back to the `refreshing` state. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'snapbackDuration': string; 'snapbackDuration': string;
} }
interface IonRefresherContent { interface IonRefresherContent {
/** /**
* A static icon to display when you begin to pull down * A static icon or a spinner to display when you begin to pull down. A spinner name can be provided to gradually show tick marks when pulling down on iOS devices.
*/ */
'pullingIcon'?: string | null; 'pullingIcon'?: SpinnerTypes | string | null;
/** /**
* The text you want to display when you begin to pull down. `pullingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) * The text you want to display when you begin to pull down. `pullingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security)
*/ */
@ -5214,7 +5214,7 @@ declare namespace LocalJSX {
} }
interface IonRefresher { interface IonRefresher {
/** /**
* Time it takes to close the refresher. * Time it takes to close the refresher. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'closeDuration'?: string; 'closeDuration'?: string;
/** /**
@ -5234,27 +5234,27 @@ declare namespace LocalJSX {
*/ */
'onIonStart'?: (event: CustomEvent<void>) => void; 'onIonStart'?: (event: CustomEvent<void>) => void;
/** /**
* How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'pullFactor'?: number; 'pullFactor'?: number;
/** /**
* The maximum distance of the pull until the refresher will automatically go into the `refreshing` state. Defaults to the result of `pullMin + 60`. * The maximum distance of the pull until the refresher will automatically go into the `refreshing` state. Defaults to the result of `pullMin + 60`. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'pullMax'?: number; 'pullMax'?: number;
/** /**
* The minimum distance the user must pull down until the refresher will go into the `refreshing` state. * The minimum distance the user must pull down until the refresher will go into the `refreshing` state. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'pullMin'?: number; 'pullMin'?: number;
/** /**
* Time it takes the refresher to to snap back to the `refreshing` state. * Time it takes the refresher to to snap back to the `refreshing` state. Does not apply when the refresher content uses a spinner, enabling the native refresher.
*/ */
'snapbackDuration'?: string; 'snapbackDuration'?: string;
} }
interface IonRefresherContent { interface IonRefresherContent {
/** /**
* A static icon to display when you begin to pull down * A static icon or a spinner to display when you begin to pull down. A spinner name can be provided to gradually show tick marks when pulling down on iOS devices.
*/ */
'pullingIcon'?: string | null; 'pullingIcon'?: SpinnerTypes | string | null;
/** /**
* The text you want to display when you begin to pull down. `pullingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) * The text you want to display when you begin to pull down. `pullingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security)
*/ */

View File

@ -330,7 +330,9 @@ export class Content implements ComponentInterface {
ref={el => this.scrollEl = el!} ref={el => this.scrollEl = el!}
onScroll={ev => this.onScroll(ev)} onScroll={ev => this.onScroll(ev)}
> >
<slot></slot> <div id="scroll-content">
<slot></slot>
</div>
</main> </main>
{transitionShadow ? ( {transitionShadow ? (

View File

@ -124,7 +124,7 @@ export class Header implements ComponentInterface {
* showing/hiding border on last toolbar * showing/hiding border on last toolbar
* in primary header * in primary header
*/ */
this.contentScrollCallback = () => { handleContentScroll(this.scrollEl!, scrollHeaderIndex); }; this.contentScrollCallback = () => { handleContentScroll(this.scrollEl!, scrollHeaderIndex, contentEl); };
this.scrollEl!.addEventListener('scroll', this.contentScrollCallback); this.scrollEl!.addEventListener('scroll', this.contentScrollCallback);
}); });

View File

@ -49,14 +49,18 @@ export const createHeaderIndex = (headerEl: HTMLElement | undefined): HeaderInde
} as HeaderIndex; } as HeaderIndex;
}; };
export const handleContentScroll = (scrollEl: HTMLElement, scrollHeaderIndex: HeaderIndex) => { export const handleContentScroll = (scrollEl: HTMLElement, scrollHeaderIndex: HeaderIndex, contentEl: HTMLElement) => {
readTask(() => { readTask(() => {
const scrollTop = scrollEl.scrollTop; const scrollTop = scrollEl.scrollTop;
const scale = clamp(1, 1 + (-scrollTop / 500), 1.1); const scale = clamp(1, 1 + (-scrollTop / 500), 1.1);
writeTask(() => { // Native refresher should not cause titles to scale
scaleLargeTitles(scrollHeaderIndex.toolbars, scale); const nativeRefresher = contentEl.querySelector('ion-refresher.refresher-native');
}); if (nativeRefresher === null) {
writeTask(() => {
scaleLargeTitles(scrollHeaderIndex.toolbars, scale);
});
}
}); });
}; };

View File

@ -11,7 +11,7 @@ The refresher content contains the text, icon and spinner to display during a pu
| Property | Attribute | Description | Type | Default | | Property | Attribute | Description | Type | Default |
| ------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------- | | ------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------- |
| `pullingIcon` | `pulling-icon` | A static icon to display when you begin to pull down | `null \| string \| undefined` | `undefined` | | `pullingIcon` | `pulling-icon` | A static icon or a spinner to display when you begin to pull down. A spinner name can be provided to gradually show tick marks when pulling down on iOS devices. | `null \| string \| undefined` | `undefined` |
| `pullingText` | `pulling-text` | The text you want to display when you begin to pull down. `pullingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) | `string \| undefined` | `undefined` | | `pullingText` | `pulling-text` | The text you want to display when you begin to pull down. `pullingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) | `string \| undefined` | `undefined` |
| `refreshingSpinner` | `refreshing-spinner` | An animated SVG spinner that shows when refreshing begins | `"bubbles" \| "circles" \| "circular" \| "crescent" \| "dots" \| "lines" \| "lines-small" \| null \| undefined` | `undefined` | | `refreshingSpinner` | `refreshing-spinner` | An animated SVG spinner that shows when refreshing begins | `"bubbles" \| "circles" \| "circular" \| "crescent" \| "dots" \| "lines" \| "lines-small" \| null \| undefined` | `undefined` |
| `refreshingText` | `refreshing-text` | The text you want to display when performing a refresh. `refreshingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) | `string \| undefined` | `undefined` | | `refreshingText` | `refreshing-text` | The text you want to display when performing a refresh. `refreshingText` can accept either plaintext or HTML as a string. To display characters normally reserved for HTML, they must be escaped. For example `<Ionic>` would become `&lt;Ionic&gt;` For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) | `string \| undefined` | `undefined` |
@ -21,14 +21,14 @@ The refresher content contains the text, icon and spinner to display during a pu
### Depends on ### Depends on
- ion-icon
- [ion-spinner](../spinner) - [ion-spinner](../spinner)
- ion-icon
### Graph ### Graph
```mermaid ```mermaid
graph TD; graph TD;
ion-refresher-content --> ion-icon
ion-refresher-content --> ion-spinner ion-refresher-content --> ion-spinner
ion-refresher-content --> ion-icon
style ion-refresher-content fill:#f9f,stroke:#333,stroke-width:4px style ion-refresher-content fill:#f9f,stroke:#333,stroke-width:4px
``` ```

View File

@ -3,7 +3,9 @@ import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core';
import { config } from '../../global/config'; import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import { SpinnerTypes } from '../../interface'; import { SpinnerTypes } from '../../interface';
import { isPlatform } from '../../utils/platform';
import { sanitizeDOMString } from '../../utils/sanitization'; import { sanitizeDOMString } from '../../utils/sanitization';
import { SPINNERS } from '../spinner/spinner-configs';
@Component({ @Component({
tag: 'ion-refresher-content' tag: 'ion-refresher-content'
@ -11,9 +13,11 @@ import { sanitizeDOMString } from '../../utils/sanitization';
export class RefresherContent implements ComponentInterface { export class RefresherContent implements ComponentInterface {
/** /**
* A static icon to display when you begin to pull down * A static icon or a spinner to display when you begin to pull down.
* A spinner name can be provided to gradually show tick marks
* when pulling down on iOS devices.
*/ */
@Prop({ mutable: true }) pullingIcon?: string | null; @Prop({ mutable: true }) pullingIcon?: SpinnerTypes | string | null;
/** /**
* The text you want to display when you begin to pull down. * The text you want to display when you begin to pull down.
@ -44,7 +48,11 @@ export class RefresherContent implements ComponentInterface {
componentWillLoad() { componentWillLoad() {
if (this.pullingIcon === undefined) { if (this.pullingIcon === undefined) {
this.pullingIcon = config.get('refreshingIcon', 'arrow-down'); const mode = getIonMode(this);
this.pullingIcon = config.get(
'refreshingIcon',
mode === 'ios' && isPlatform('mobile') ? config.get('spinner', 'lines') : 'arrow-down'
);
} }
if (this.refreshingSpinner === undefined) { if (this.refreshingSpinner === undefined) {
const mode = getIonMode(this); const mode = getIonMode(this);
@ -56,10 +64,17 @@ export class RefresherContent implements ComponentInterface {
} }
render() { render() {
const pullingIcon = this.pullingIcon;
const hasSpinner = pullingIcon != null && SPINNERS[pullingIcon] as any !== undefined;
return ( return (
<Host class={getIonMode(this)}> <Host class={getIonMode(this)}>
<div class="refresher-pulling"> <div class="refresher-pulling">
{this.pullingIcon && {this.pullingIcon && hasSpinner &&
<div class="refresher-pulling-icon">
<ion-spinner name={this.pullingIcon as SpinnerTypes} paused></ion-spinner>
</div>
}
{this.pullingIcon && !hasSpinner &&
<div class="refresher-pulling-icon"> <div class="refresher-pulling-icon">
<ion-icon icon={this.pullingIcon} lazy={false}></ion-icon> <ion-icon icon={this.pullingIcon} lazy={false}></ion-icon>
</div> </div>

View File

@ -8,6 +8,16 @@ Data should be modified during the refresher's output events. Once the async
operation has completed and the refreshing should end, call `complete()` on the operation has completed and the refreshing should end, call `complete()` on the
refresher. 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.
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.
#### 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.
<!-- Auto Generated Below --> <!-- Auto Generated Below -->
@ -201,14 +211,14 @@ export const RefresherExample: React.FC = () => (
## Properties ## Properties
| Property | Attribute | Description | Type | Default | | Property | Attribute | Description | Type | Default |
| ------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------- | | ------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------- |
| `closeDuration` | `close-duration` | Time it takes to close the refresher. | `string` | `'280ms'` | | `closeDuration` | `close-duration` | Time it takes to close the refresher. Does not apply when the refresher content uses a spinner, enabling the native refresher. | `string` | `'280ms'` |
| `disabled` | `disabled` | If `true`, the refresher will be hidden. | `boolean` | `false` | | `disabled` | `disabled` | If `true`, the refresher will be hidden. | `boolean` | `false` |
| `pullFactor` | `pull-factor` | How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. | `number` | `1` | | `pullFactor` | `pull-factor` | How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. | `number` | `1` |
| `pullMax` | `pull-max` | The maximum distance of the pull until the refresher will automatically go into the `refreshing` state. Defaults to the result of `pullMin + 60`. | `number` | `this.pullMin + 60` | | `pullMax` | `pull-max` | The maximum distance of the pull until the refresher will automatically go into the `refreshing` state. Defaults to the result of `pullMin + 60`. Does not apply when the refresher content uses a spinner, enabling the native refresher. | `number` | `this.pullMin + 60` |
| `pullMin` | `pull-min` | The minimum distance the user must pull down until the refresher will go into the `refreshing` state. | `number` | `60` | | `pullMin` | `pull-min` | The minimum distance the user must pull down until the refresher will go into the `refreshing` state. Does not apply when the refresher content uses a spinner, enabling the native refresher. | `number` | `60` |
| `snapbackDuration` | `snapback-duration` | Time it takes the refresher to to snap back to the `refreshing` state. | `string` | `'280ms'` | | `snapbackDuration` | `snapback-duration` | Time it takes the refresher to to snap back to the `refreshing` state. Does not apply when the refresher content uses a spinner, enabling the native refresher. | `string` | `'280ms'` |
## Events ## Events

View File

@ -25,3 +25,67 @@
.refresher-ios .refresher-refreshing .spinner-dots circle { .refresher-ios .refresher-refreshing .spinner-dots circle {
fill: $refresher-ios-icon-color; fill: $refresher-ios-icon-color;
} }
ion-refresher.refresher-native {
display: block;
z-index: 1;
ion-spinner {
@include margin(0, auto, 0, auto);
}
}
.refresher-native {
.refresher-refreshing ion-spinner {
--refreshing-rotation-duration: 2s;
display: none;
animation: var(--refreshing-rotation-duration) ease-out refresher-rotate forwards;
}
.refresher-refreshing {
display: none;
animation: 250ms linear refresher-pop forwards;
}
}
.refresher-native.refresher-refreshing,
.refresher-native.refresher-completing {
.refresher-pulling ion-spinner {
display: none;
}
.refresher-refreshing ion-spinner {
display: block;
}
}
.refresher-native.refresher-pulling {
.refresher-pulling ion-spinner {
display: block;
}
.refresher-refreshing ion-spinner {
display: none;
}
}
@keyframes refresher-pop {
0% {
transform: scale(1);
animation-timing-function: ease-in;
}
50% {
transform: scale(1.2);
animation-timing-function: ease-out;
}
100% {
transform: scale(1);
}
}
@keyframes refresher-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(180deg);
}
}

View File

@ -10,6 +10,8 @@ ion-refresher {
width: 100%; width: 100%;
height: $refresher-height; height: $refresher-height;
pointer-events: none;
z-index: $z-index-refresher; z-index: $z-index-refresher;
&.refresher-active { &.refresher-active {

View File

@ -1,7 +1,11 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import { Gesture, GestureDetail, RefresherEventDetail } from '../../interface'; import { 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';
@Component({ @Component({
tag: 'ion-refresher', tag: 'ion-refresher',
@ -16,9 +20,18 @@ export class Refresher implements ComponentInterface {
private didStart = false; private didStart = false;
private progress = 0; private progress = 0;
private scrollEl?: HTMLElement; private scrollEl?: HTMLElement;
private scrollListenerCallback?: any;
private gesture?: Gesture; private gesture?: Gesture;
@Element() el!: HTMLElement; private pointerDown = false;
private needsCompletion = false;
private didRefresh = false;
private lastVelocityY = 0;
private elementToTransform?: HTMLElement;
@State() private nativeRefresher = false;
@Element() el!: HTMLIonRefresherElement;
/** /**
* The current state which the refresher is in. The refresher's states include: * The current state which the refresher is in. The refresher's states include:
@ -35,6 +48,8 @@ export class Refresher implements ComponentInterface {
/** /**
* The minimum distance the user must pull down until the * The minimum distance the user must pull down until the
* refresher will go into the `refreshing` state. * refresher will go into the `refreshing` state.
* Does not apply when the refresher content uses a spinner,
* enabling the native refresher.
*/ */
@Prop() pullMin = 60; @Prop() pullMin = 60;
@ -42,16 +57,22 @@ export class Refresher implements ComponentInterface {
* The maximum distance of the pull until the refresher * The maximum distance of the pull until the refresher
* will automatically go into the `refreshing` state. * will automatically go into the `refreshing` state.
* Defaults to the result of `pullMin + 60`. * Defaults to the result of `pullMin + 60`.
* Does not apply when the refresher content uses a spinner,
* enabling the native refresher.
*/ */
@Prop() pullMax: number = this.pullMin + 60; @Prop() pullMax: number = this.pullMin + 60;
/** /**
* Time it takes to close the refresher. * Time it takes to close the refresher.
* Does not apply when the refresher content uses a spinner,
* enabling the native refresher.
*/ */
@Prop() closeDuration = '280ms'; @Prop() closeDuration = '280ms';
/** /**
* Time it takes the refresher to to snap back to the `refreshing` state. * Time it takes the refresher to to snap back to the `refreshing` state.
* Does not apply when the refresher content uses a spinner,
* enabling the native refresher.
*/ */
@Prop() snapbackDuration = '280ms'; @Prop() snapbackDuration = '280ms';
@ -65,6 +86,9 @@ export class Refresher implements ComponentInterface {
* `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels * `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels
* (an increase of 20 percent). If the value passed is `0.8`, the dragged amount * (an increase of 20 percent). If the value passed is `0.8`, the dragged amount
* will be `8` pixels, less than the amount the cursor has moved. * will be `8` pixels, less than the amount the cursor has moved.
*
* Does not apply when the refresher content uses a spinner,
* enabling the native refresher.
*/ */
@Prop() pullFactor = 1; @Prop() pullFactor = 1;
@ -97,34 +121,192 @@ export class Refresher implements ComponentInterface {
*/ */
@Event() ionStart!: EventEmitter<void>; @Event() ionStart!: EventEmitter<void>;
private checkNativeRefresher() {
if (shouldUseNativeRefresher(this.el, getIonMode(this))) {
const contentEl = this.el.closest('ion-content');
this.setupNativeRefresher(contentEl);
} else {
this.destroyNativeRefresher();
}
}
private destroyNativeRefresher() {
if (this.scrollEl && this.scrollListenerCallback) {
this.scrollEl.removeEventListener('scroll', this.scrollListenerCallback);
this.scrollListenerCallback = undefined;
}
this.nativeRefresher = false;
}
private async resetNativeRefresher(el: HTMLElement | undefined, state: RefresherState) {
this.state = state;
if (el !== undefined) {
await translateElement(el, undefined);
}
this.didRefresh = false;
this.needsCompletion = false;
this.pointerDown = false;
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;
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;
writeTask(() => ticks.forEach(el => el.style.setProperty('animation', 'none')));
this.scrollListenerCallback = () => {
// If pointer is not on screen or refresher is not active, ignore scroll
if (!this.pointerDown && this.state === RefresherState.Inactive) { return; }
readTask(() => {
// PTR should only be active when overflow scrolling at the top
const scrollTop = this.scrollEl!.scrollTop;
const refresherHeight = this.el.clientHeight;
if (scrollTop > 0) {
/**
* If refresher is refreshing and user tries to scroll
* progressively fade refresher out/in
*/
if (this.state === RefresherState.Refreshing) {
const ratio = clamp(0, scrollTop / (refresherHeight * 0.5), 1);
writeTask(() => setSpinnerOpacity(refreshingSpinner, 1 - ratio));
return;
}
writeTask(() => setSpinnerOpacity(pullingSpinner, 0));
return;
}
if (this.pointerDown) {
if (!this.didStart) {
this.didStart = true;
this.ionStart.emit();
}
this.ionPull.emit();
}
// delay showing the next tick marks until user has pulled 30px
const opacity = clamp(0, Math.abs(scrollTop) / refresherHeight, 0.99);
const pullAmount = this.progress = clamp(0, (Math.abs(scrollTop) - 30) / MAX_PULL, 1);
const currentTickToShow = clamp(0, Math.floor(pullAmount * NUM_TICKS), NUM_TICKS - 1);
const shouldShowRefreshingSpinner = this.state === RefresherState.Refreshing || currentTickToShow === NUM_TICKS - 1;
if (shouldShowRefreshingSpinner) {
if (this.pointerDown) {
handleScrollWhileRefreshing(refreshingSpinner, this.lastVelocityY);
}
if (!this.didRefresh) {
this.beginRefresh();
this.didRefresh = true;
hapticImpact({ style: 'light' });
/**
* Translate the content element otherwise when pointer is removed
* from screen the scroll content will bounce back over the refresher
*/
if (!this.pointerDown) {
translateElement(this.elementToTransform, `${refresherHeight}px`);
}
}
} else {
this.state = RefresherState.Pulling;
handleScrollWhilePulling(pullingSpinner, ticks, opacity, currentTickToShow);
}
});
};
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;
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();
}
componentDidUpdate() {
this.checkNativeRefresher();
}
async connectedCallback() { async connectedCallback() {
if (this.el.getAttribute('slot') !== 'fixed') { if (this.el.getAttribute('slot') !== 'fixed') {
console.error('Make sure you use: <ion-refresher slot="fixed">'); console.error('Make sure you use: <ion-refresher slot="fixed">');
return; return;
} }
const contentEl = this.el.closest('ion-content'); const contentEl = this.el.closest('ion-content');
if (!contentEl) { if (!contentEl) {
console.error('<ion-refresher> must be used inside an <ion-content>'); console.error('<ion-refresher> must be used inside an <ion-content>');
return; return;
} }
this.scrollEl = await contentEl.getScrollElement();
this.gesture = (await import('../../utils/gesture')).createGesture({
el: contentEl,
gestureName: 'refresher',
gesturePriority: 10,
direction: 'y',
threshold: 20,
passive: false,
canStart: () => this.canStart(),
onStart: () => this.onStart(),
onMove: ev => this.onMove(ev),
onEnd: () => this.onEnd(),
});
this.disabledChanged(); this.scrollEl = await contentEl.getScrollElement();
if (shouldUseNativeRefresher(this.el, getIonMode(this))) {
this.setupNativeRefresher(contentEl);
} else {
this.gesture = (await import('../../utils/gesture')).createGesture({
el: contentEl,
gestureName: 'refresher',
gesturePriority: 10,
direction: 'y',
threshold: 20,
passive: false,
canStart: () => this.canStart(),
onStart: () => this.onStart(),
onMove: ev => this.onMove(ev),
onEnd: () => this.onEnd(),
});
this.disabledChanged();
}
} }
disconnectedCallback() { disconnectedCallback() {
this.destroyNativeRefresher();
this.scrollEl = undefined; this.scrollEl = undefined;
if (this.gesture) { if (this.gesture) {
this.gesture.destroy(); this.gesture.destroy();
@ -143,7 +325,16 @@ export class Refresher implements ComponentInterface {
*/ */
@Method() @Method()
async complete() { async complete() {
this.close(RefresherState.Completing, '120ms'); if (this.nativeRefresher) {
this.needsCompletion = true;
// Do not reset scroll el until user removes pointer from screen
if (!this.pointerDown) {
this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing);
}
} else {
this.close(RefresherState.Completing, '120ms');
}
} }
/** /**
@ -151,7 +342,14 @@ export class Refresher implements ComponentInterface {
*/ */
@Method() @Method()
async cancel() { async cancel() {
this.close(RefresherState.Cancelling, ''); if (this.nativeRefresher) {
// Do not reset scroll el until user removes pointer from screen
if (!this.pointerDown) {
this.resetNativeRefresher(this.elementToTransform, RefresherState.Cancelling);
}
} else {
this.close(RefresherState.Cancelling, '');
}
} }
/** /**
@ -341,6 +539,8 @@ export class Refresher implements ComponentInterface {
} }
private setCss(y: number, duration: string, overflowVisible: boolean, delay: string) { private setCss(y: number, duration: string, overflowVisible: boolean, delay: string) {
if (this.nativeRefresher) { return; }
this.appliedStyles = (y > 0); this.appliedStyles = (y > 0);
writeTask(() => { writeTask(() => {
if (this.scrollEl) { if (this.scrollEl) {
@ -363,13 +563,13 @@ export class Refresher implements ComponentInterface {
// Used internally for styling // Used internally for styling
[`refresher-${mode}`]: true, [`refresher-${mode}`]: true,
'refresher-native': this.nativeRefresher,
'refresher-active': this.state !== RefresherState.Inactive, 'refresher-active': this.state !== RefresherState.Inactive,
'refresher-pulling': this.state === RefresherState.Pulling, 'refresher-pulling': this.state === RefresherState.Pulling,
'refresher-ready': this.state === RefresherState.Ready, 'refresher-ready': this.state === RefresherState.Ready,
'refresher-refreshing': this.state === RefresherState.Refreshing, 'refresher-refreshing': this.state === RefresherState.Refreshing,
'refresher-cancelling': this.state === RefresherState.Cancelling, 'refresher-cancelling': this.state === RefresherState.Cancelling,
'refresher-completing': this.state === RefresherState.Completing 'refresher-completing': this.state === RefresherState.Completing,
}} }}
> >
</Host> </Host>

View File

@ -0,0 +1,90 @@
import { writeTask } from '@stencil/core';
import { isPlatform } from '../../utils/platform';
export const setSpinnerOpacity = (spinner: HTMLElement, opacity: number) => {
spinner.style.setProperty('opacity', opacity.toString());
};
export const handleScrollWhilePulling = (
spinner: HTMLElement,
ticks: NodeListOf<SVGElement>,
opacity: number,
currentTickToShow: number
) => {
writeTask(() => {
setSpinnerOpacity(spinner, opacity);
ticks.forEach((el, i) => el.style.setProperty('opacity', (i <= currentTickToShow) ? '0.99' : '0'));
});
};
export const handleScrollWhileRefreshing = (
spinner: HTMLElement,
lastVelocityY: number
) => {
writeTask(() => {
// If user pulls down quickly, the spinner should spin faster
spinner.style.setProperty('--refreshing-rotation-duration', (lastVelocityY >= 1.0) ? '0.5s' : '2s');
spinner.style.setProperty('opacity', '1');
});
};
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');
return (
pullingSpinner !== null &&
refreshingSpinner !== null &&
mode === 'ios' &&
isPlatform('mobile')
);
};
export const translateElement = (el?: HTMLElement, value?: string) => {
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)`);
}
});
});
};
const transitionEnd = (el: HTMLElement | null, callback: (ev?: TransitionEvent) => void) => {
let unRegTrans: (() => void) | undefined;
const opts: any = { passive: true };
const unregister = () => {
if (unRegTrans) {
unRegTrans();
}
};
const onTransitionEnd = (ev: Event) => {
if (el === ev.target) {
unregister();
callback(ev as TransitionEvent);
}
};
if (el) {
el.addEventListener('webkitTransitionEnd', onTransitionEnd, opts);
el.addEventListener('transitionend', onTransitionEnd, opts);
unRegTrans = () => {
el.removeEventListener('webkitTransitionEnd', onTransitionEnd, opts);
el.removeEventListener('transitionend', onTransitionEnd, opts);
};
}
return unregister;
};

View File

@ -0,0 +1,10 @@
import { newE2EPage } from '@stencil/core/testing';
test('refresher: spec', async () => {
const page = await newE2EPage({
url: '/src/components/refresher/test/spec?ionic:_testing=true'
});
const compare = await page.compareScreenshot();
expect(compare).toMatchScreenshot();
});

View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta charset="UTF-8">
<title>Refresher - Spec</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script></head>
<style>
ion-spinner {
color: #555;
}
</style>
<body>
<ion-app>
<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>
<ion-content>
<ion-refresher id="refresher" slot="fixed">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">All Inboxes</ion-title>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar></ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-list id="list"></ion-list>
</ion-content>
</ion-app>
<script>
let items = [];
for (var i = 0; i < 30; i++) {
items.push(i + 1);
}
const list = document.getElementById('list');
const refresher = document.getElementById('refresher');
refresher.addEventListener('ionPull', () => {
console.log('ionPull');
});
refresher.addEventListener('ionStart', () => {
console.log('ionStart');
});
refresher.addEventListener('ionRefresh', async function () {
console.log('Loading data...');
const data = await getAsyncData();
items = items.concat(data);
refresher.complete();
render();
console.log('Done');
});
function render() {
let html = '';
for (let item of items) {
html += `<ion-item button>${item}</ion-item>`;
}
list.innerHTML = html;
}
function getAsyncData() {
// async return mock data
return new Promise(resolve => {
setTimeout(() => {
let data = [];
for (var i = 0; i < 30; i++) {
data.push(i);
}
resolve(data);
}, 5000);
});
}
render();
</script>
</body>
</html>

View File

@ -46,4 +46,4 @@ export class RefresherExample {
}, 2000); }, 2000);
} }
} }
``` ```

View File

@ -24,4 +24,4 @@
</ion-refresher-content> </ion-refresher-content>
</ion-refresher> </ion-refresher>
</ion-content> </ion-content>
``` ```

View File

@ -43,4 +43,4 @@
} }
} }
</script> </script>
``` ```

View File

@ -0,0 +1,131 @@
// Main types for this API
interface HapticImpactOptions {
style: 'light' | 'medium' | 'heavy';
}
interface HapticNotificationOptions {
style: 'success' | 'warning' | 'error';
}
const HapticEngine = {
getEngine() {
const win = (window as any);
return (win.TapticEngine) || (win.Capacitor && win.Capacitor.Plugins.Haptics);
},
available() {
return !!this.getEngine();
},
isCordova() {
return !!(window as any).TapticEngine;
},
isCapacitor() {
const win = (window as any);
return !!win.Capacitor;
},
impact(options: HapticImpactOptions) {
const engine = this.getEngine();
if (!engine) {
return;
}
const style = this.isCapacitor() ? options.style.toUpperCase() : options.style;
engine.impact({ style });
},
notification(options: HapticNotificationOptions) {
const engine = this.getEngine();
if (!engine) {
return;
}
const style = this.isCapacitor() ? options.style.toUpperCase() : options.style;
engine.notification({ style });
},
selection() {
this.impact({ style: 'light' });
},
selectionStart() {
const engine = this.getEngine();
if (!engine) {
return;
}
if (this.isCapacitor()) {
engine.selectionStart();
} else {
engine.gestureSelectionStart();
}
},
selectionChanged() {
const engine = this.getEngine();
if (!engine) {
return;
}
if (this.isCapacitor()) {
engine.selectionChanged();
} else {
engine.gestureSelectionChanged();
}
},
selectionEnd() {
const engine = this.getEngine();
if (!engine) {
return;
}
if (this.isCapacitor()) {
engine.selectionChanged();
} else {
engine.gestureSelectionChanged();
}
}
};
/**
* Check to see if the Haptic Plugin is available
* @return Returns `true` or false if the plugin is available
*/
export const hapticAvailable = (): boolean => {
return HapticEngine.available();
};
/**
* Trigger a selection changed haptic event. Good for one-time events
* (not for gestures)
*/
export const hapticSelection = () => {
HapticEngine.selection();
};
/**
* Tell the haptic engine that a gesture for a selection change is starting.
*/
export const hapticSelectionStart = () => {
HapticEngine.selectionStart();
};
/**
* Tell the haptic engine that a selection changed during a gesture.
*/
export const hapticSelectionChanged = () => {
HapticEngine.selectionChanged();
};
/**
* Tell the haptic engine we are done with a gesture. This needs to be
* called lest resources are not properly recycled.
*/
export const hapticSelectionEnd = () => {
HapticEngine.selectionEnd();
};
/**
* Use this to indicate success/failure/warning to the user.
* options should be of the type `{ type: 'success' }` (or `warning`/`error`)
*/
export const hapticNotification = (options: HapticNotificationOptions) => {
HapticEngine.notification(options);
};
/**
* Use this to indicate success/failure/warning to the user.
* options should be of the type `{ style: 'light' }` (or `medium`/`heavy`)
*/
export const hapticImpact = (options: HapticImpactOptions) => {
HapticEngine.impact(options);
};