diff --git a/src/framework/ui/animation/animation.ts b/src/framework/ui/animation/animation.ts new file mode 100644 index 000000000..234552a52 --- /dev/null +++ b/src/framework/ui/animation/animation.ts @@ -0,0 +1,63 @@ +import { Animated } from 'react-native'; + +export const DEFAULT_CONFIG: AnimationConfig = { + cycles: 1, + useNativeDriver: true, +}; + +/** + * @property cycles - number of animation cycles. -1 for infinite + */ +export interface AnimationConfig extends Animated.AnimationConfig { + cycles?: number; +} + +export abstract class Animation { + + protected abstract animation: Animated.CompositeAnimation; + protected counter: number = 0; + protected endCallback: Animated.EndCallback; + protected running: boolean = false; + protected config: C; + + public abstract toProps(): R; + + constructor(config?: C) { + this.config = { + ...DEFAULT_CONFIG, + ...config, + }; + } + + public start(callback?: Animated.EndCallback) { + this.endCallback = callback; + this.running = true; + + this.animation.start(this.onAnimationEnd); + } + + public stop() { + this.running = false; + + this.animation.stop(); + } + + public release() { + this.stop(); + } + + protected onAnimationEnd = (result: Animated.EndResult) => { + this.counter += 1; + if (this.counter === this.config.cycles) { + this.stop(); + } + if (this.running) { + this.start(this.endCallback); + } + if (!this.running) { + this.counter = 0; + this.endCallback && this.endCallback(result); + this.endCallback = null; + } + }; +} diff --git a/src/framework/ui/index.ts b/src/framework/ui/index.ts index b7dc6ac14..5312efda3 100644 --- a/src/framework/ui/index.ts +++ b/src/framework/ui/index.ts @@ -79,6 +79,11 @@ export { RadioGroupProps, RadioGroupElement, } from './radioGroup/radioGroup.component'; +export { + Spinner, + SpinnerProps, + SpinnerElement, +} from './spinner/spinner.component'; export { TabView, TabViewProps, diff --git a/src/framework/ui/spinner/animation.ts b/src/framework/ui/spinner/animation.ts new file mode 100644 index 000000000..18f0b3da2 --- /dev/null +++ b/src/framework/ui/spinner/animation.ts @@ -0,0 +1,165 @@ +import { + Animated, + Easing, + EasingFunction, + ViewStyle, +} from 'react-native'; +import { + Animation, + AnimationConfig, +} from '../animation/animation'; + +const PI: number = 180; +const PI2: number = 360; +const OFFSET_MIN: number = PI / 12; +const OFFSET_MAX: number = PI / 6; + +const BaseBezierEasing: EasingFunction = Easing.bezier(0.4, 0.0, 0.7, 1.0); + +const StartArcEasing: EasingFunction = (progress: number): number => { + return -PI + OFFSET_MIN + (PI - OFFSET_MAX) * BaseBezierEasing(progress); +}; + +const EndArcEasing: EasingFunction = (progress: number): number => { + return PI2 - OFFSET_MIN + (-PI + OFFSET_MAX) * BaseBezierEasing(progress); +}; + +const DEFAULT_CONFIG: SpinnerAnimationConfig = { + duration: 2400, + easing: Easing.linear, + cycles: -1, +}; + +type TimingAnimationConfig = Omit; + +export interface SpinnerAnimationStyle { + container: ViewStyle; + start: ViewStyle; + end: ViewStyle; +} + +export type SpinnerAnimationConfig = AnimationConfig & TimingAnimationConfig; + +/** + * Animates a Spinner in a Material Design way. + * + * Thanks these guys for open sourcing the algorithm: https://github.com/n4kz/react-native-indicators + */ +export class SpinnerAnimation extends Animation { + + private animationValue: Animated.Value; + private animationFrames: number[]; + private arcSize: number; + + protected get animation(): Animated.CompositeAnimation { + return Animated.timing(this.animationValue, { toValue: 1.0, ...this.config }); + } + + constructor(arcSize: number, config?: SpinnerAnimationConfig) { + super({ ...DEFAULT_CONFIG, ...config }); + this.arcSize = arcSize; + this.animationValue = new Animated.Value(0); + this.animationFrames = this.createFrameRange(this.config.duration); + } + + public start(callback?: Animated.EndCallback) { + // reset animation value before the next animation cycle + this.animationValue.setValue(0); + super.start(callback); + } + + public stop() { + super.stop(); + this.animationValue.setValue(0); + } + + /** + * @returns {SpinnerAnimationStyle} - an object that contains container, start and end arcs transform styles. + */ + public toProps(): SpinnerAnimationStyle { + const containerInterpolation: Animated.AnimatedInterpolation = this.createContainerInterpolation(); + const startArcInterpolation: Animated.AnimatedInterpolation = this.createArcInterpolation(StartArcEasing); + const endArcInterpolation: Animated.AnimatedInterpolation = this.createArcInterpolation(EndArcEasing); + + return { + container: this.toStyleTransformProp(containerInterpolation), + start: this.toStyleTransformProp(startArcInterpolation), + end: this.toStyleTransformProp(endArcInterpolation, { + transform: [{ translateY: -this.arcSize / 2 }], + }), + }; + } + + /** + * @param {number} duration - animation duration. + * @returns an array of frames fitted into animation. + */ + private createFrameRange = (duration: number): number[] => { + const numberOfFrames: number = 60 * duration / 1000; + + return new Array(numberOfFrames).fill(0); + }; + + private createContainerInterpolation = (): Animated.AnimatedInterpolation => { + return this.animationValue.interpolate({ + inputRange: [0, 1], + outputRange: [ + this.toDegValue(OFFSET_MAX + OFFSET_MIN), + this.toDegValue((2 * PI2 + OFFSET_MAX + OFFSET_MIN)), + ], + }); + }; + + private createArcInterpolation = (easing: EasingFunction): Animated.AnimatedInterpolation => { + return this.animationValue.interpolate({ + inputRange: this.createArcInterpolationInputRange(), + outputRange: this.createArcInterpolationOutputRange(easing), + }); + }; + + /** + * Maps the animation frames into initial animation values specific for each frame. + * + * @returns a container interpolation input range in a numeric format. + */ + private createArcInterpolationInputRange = (): number[] => { + return this.animationFrames.map((item: number, frame: number): number => { + return frame / (this.animationFrames.length - 1); + }); + }; + + /** + * Maps the animation frames into a final animation values specific for each frame. + * + * @param {(progress: number) => number} easing - Easing function specific for the arc. + * @returns an arc interpolation end values eased with an `easing` function in a StyleSheet degree format. + */ + private createArcInterpolationOutputRange = (easing: EasingFunction): string[] => { + return this.animationFrames.map((item: number, frame: number): string => { + const progress: number = 2 * frame / (this.animationFrames.length - 1); + const boundedProgress: number = Math.min(2.0 - progress, progress); + + return this.toDegValue(easing(boundedProgress)); + }); + }; + + /** + * @param {Animated.AnimatedInterpolation} rotate - animated rotation animationValue. + * @param {ViewStyle} source - initial StyleSheet object. + * @returns a final StyleSheet object with a `rotate` animation value. + */ + private toStyleTransformProp = (rotate: Animated.AnimatedInterpolation, source: ViewStyle = {}): ViewStyle => { + const transform = [...(source.transform || []), { rotate }]; + + // @ts-ignore: AnimatedInterpolation does not fit RotateTransform type declaration + return { ...source, transform }; + }; + + /** + * @param {number} source - degrees in a numeric format. + * @returns degrees in a StyleSheet format. + */ + private toDegValue = (source: number): string => { + return `${source}deg`; + }; +} diff --git a/src/framework/ui/spinner/spinner.component.tsx b/src/framework/ui/spinner/spinner.component.tsx new file mode 100644 index 000000000..c5910d451 --- /dev/null +++ b/src/framework/ui/spinner/spinner.component.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { + Animated, + StyleSheet, + View, + ViewProps, + ViewStyle, +} from 'react-native'; +import { + styled, + StyledComponentProps, +} from '@kitten/theme'; +import { + SpinnerAnimation, + SpinnerAnimationStyle, +} from './animation'; +// TODO: Frame, Point, Size types should be refactored to common types +import { Size } from '../popover/type'; + +interface ArcElementStyle { + container: ViewStyle; + arc: ViewStyle; + overflow?: ViewStyle; +} + +interface ComponentProps extends ViewProps { + animating?: boolean; + size?: string; + status?: string; +} + +export type SpinnerProps = StyledComponentProps & ComponentProps; +export type SpinnerElement = React.ReactElement; + +/** + * Styled Spinner component. Designed to be used as ActivityIndicator component + * + * @property {boolean} animating - Determines whether component is animating. Default is `true`. + * @property {string} containerSize - Determines the the component. + * Can be `giant`, `large`, `medium`, `small` or `tiny`. + * Default is `medium`. + * @property status - Determines the status of the component. + * Can be `primary`, `success`, `info`, `warning` or `danger`. + * Default is `primary`. + * + * @overview-example Simple Usage + * + * ``` + * import React from 'react'; + * import { Spinner } from 'react-native-ui-kitten'; + * + * export const SpinnerShowcase = () => ( + * + * ); + * ``` + * + * @overview-example Loading Data + * + * ``` + * import React from 'react'; + * import { View, StyleSheet } from 'react-native'; + * import { Spinner, List, ListItem } from 'react-native-ui-kitten'; + * + * export class SpinnerDataLoading extends React.Component { + * + * state = { + * data: [], + * }; + * + * componentDidMount() { + * setTimeout(this.loadData, 3000); + * } + * + * loadData = () => { + * const data = [ + * { + * title: 'Item 1', + * }, + * { + * title: 'Item 2', + * }, + * { + * title: 'Item 3', + * }, + * ]; + * this.setState({ data }); + * }; + * + * private renderLoading = () => ( + * + * + * + * ); + * + * renderDataItem = ({ item }) => ( + * + * ); + * + * renderData = () => ( + * + * ); + * + * render() { + * const isLoaded: boolean = this.state.data.length > 0; + * return isLoaded ? this.renderData() : this.renderLoading(); + * } + *} + * + * const styles = StyleSheet.create({ + * loading: { + * flex: 1, + * justifyContent: 'center', + * alignItems: 'center', + * }, + *}); + *``` + * + * @example Size + * + * ``` + * import React from 'react'; + * import { Spinner } from 'react-native-ui-kitten'; + * + * export const GiantSpinner = () => ( + * + * ); + * ``` + * + * @example Status + * + * ``` + * import React from 'react'; + * import { Spinner } from 'react-native-ui-kitten'; + * + * export const DangerSpinner = () => ( + * + * ); + * ``` + */ +export class SpinnerComponent extends React.PureComponent { + + static styledComponentName: string = 'Spinner'; + + static defaultProps: Partial = { + animating: true, + }; + + private animation: SpinnerAnimation = new SpinnerAnimation(this.containerSize.height); + + private get containerSize(): Size { + const { width, height } = StyleSheet.flatten([this.props.themedStyle, this.props.style]); + // @ts-ignore: width and height are restricted to be a number + return new Size(width, height); + } + + public componentDidMount() { + if (this.props.animating) { + this.startAnimation(); + } + } + + public componentDidUpdate(prevProps: SpinnerProps) { + const animatingChanged: boolean = this.props.animating !== prevProps.animating; + + if (animatingChanged && this.props.animating) { + this.startAnimation(); + } + + if (animatingChanged && !this.props.animating) { + this.stopAnimation(); + } + } + + public componentWillUnmount() { + this.animation.release(); + } + + private startAnimation = () => { + this.animation.start(); + }; + + private stopAnimation = () => { + this.animation.stop(); + }; + + private getComponentStyle = (source: SpinnerAnimationStyle) => { + const start: ArcElementStyle = { + container: source.container, + arc: source.start, + }; + + const end: ArcElementStyle = { + container: source.container, + arc: source.end, + overflow: { top: this.containerSize.height / 2 }, + }; + + return { start, end }; + }; + + private renderArcElement = (style: ArcElementStyle, size: Size): React.ReactElement => { + const arcSize: Size = new Size(size.width, size.height / 2); + + return ( + + + + + + + + + + ); + }; + + public render(): React.ReactElement { + const containerSize: Size = this.containerSize; + const { start, end } = this.getComponentStyle(this.animation.toProps()); + + return ( + + {this.renderArcElement(start, containerSize)} + {this.renderArcElement(end, containerSize)} + + ); + } +} + +const styles = StyleSheet.create({ + absolute: StyleSheet.absoluteFillObject, + noOverflow: { + overflow: 'hidden', + }, +}); + +export const Spinner = styled(SpinnerComponent); diff --git a/src/playground/src/navigation/navigation.component.tsx b/src/playground/src/navigation/navigation.component.tsx index 95771b53a..796b6cb5c 100644 --- a/src/playground/src/navigation/navigation.component.tsx +++ b/src/playground/src/navigation/navigation.component.tsx @@ -24,6 +24,7 @@ import { LayoutContainer, SampleContainer, ModalContainer, + SpinnerContainer, } from '../ui/screen'; export interface RouteType { @@ -43,6 +44,7 @@ const AppNavigator = createStackNavigator({ ['Popover']: PopoverContainer, ['Radio']: RadioContainer, ['Radio Group']: RadioGroupContainer, + ['Spinner']: SpinnerContainer, ['Tab View']: TabViewContainer, ['Tooltip']: TooltipContainer, ['Text']: TextContainer, diff --git a/src/playground/src/ui/screen/home.component.tsx b/src/playground/src/ui/screen/home.component.tsx index 282295852..e13af8ede 100644 --- a/src/playground/src/ui/screen/home.component.tsx +++ b/src/playground/src/ui/screen/home.component.tsx @@ -27,6 +27,7 @@ export const routes: RouteType[] = [ { name: 'Popover' }, { name: 'Radio' }, { name: 'Radio Group' }, + { name: 'Spinner' }, { name: 'Tab View' }, { name: 'Tooltip' }, { name: 'Text' }, diff --git a/src/playground/src/ui/screen/index.ts b/src/playground/src/ui/screen/index.ts index 11d21a28a..69d187b66 100644 --- a/src/playground/src/ui/screen/index.ts +++ b/src/playground/src/ui/screen/index.ts @@ -9,6 +9,7 @@ export { ListContainer } from './list/list.container'; export { PopoverContainer } from './popover/popover.container'; export { RadioContainer } from './radio/radio.container'; export { RadioGroupContainer } from './radioGroup/radioGroup.container'; +export { SpinnerContainer } from './spinner/spinner.container'; export { TabViewContainer } from './tabView/tabView.container'; export { TooltipContainer } from './tooltip/tooltip.container'; export { TextContainer } from './text/text.container'; diff --git a/src/playground/src/ui/screen/spinner/spinner.container.tsx b/src/playground/src/ui/screen/spinner/spinner.container.tsx new file mode 100644 index 000000000..651e8a031 --- /dev/null +++ b/src/playground/src/ui/screen/spinner/spinner.container.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { SpinnerProps } from '@kitten/ui'; +import { SpinnerShowcase } from './spinnerShowcase.component'; +import { + spinnerSettings, + spinnerShowcase, +} from './type'; +import { ShowcaseContainer } from '../common/showcase.container'; + +export class SpinnerContainer extends React.Component { + + private renderItem = (props: SpinnerProps): React.ReactElement => { + return ( + + ); + }; + + public render(): React.ReactNode { + return ( + + ); + } +} diff --git a/src/playground/src/ui/screen/spinner/spinnerShowcase.component.tsx b/src/playground/src/ui/screen/spinner/spinnerShowcase.component.tsx new file mode 100644 index 000000000..b3fd1286f --- /dev/null +++ b/src/playground/src/ui/screen/spinner/spinnerShowcase.component.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { + Spinner, + SpinnerProps, +} from '@kitten/ui'; + +export const SpinnerShowcase = (props?: SpinnerProps): React.ReactElement => { + return ( + + ); +}; diff --git a/src/playground/src/ui/screen/spinner/type.ts b/src/playground/src/ui/screen/spinner/type.ts new file mode 100644 index 000000000..5dc9b8f02 --- /dev/null +++ b/src/playground/src/ui/screen/spinner/type.ts @@ -0,0 +1,117 @@ +import { + ComponentShowcase, + ComponentShowcaseItem, + ComponentShowcaseSection, + ComponentShowcaseSetting, +} from '../common/type'; + +const giantSpinner: ComponentShowcaseItem = { + title: 'Giant', + props: { + size: 'giant', + }, +}; + +const largeSpinner: ComponentShowcaseItem = { + title: 'Large', + props: { + size: 'large', + }, +}; + +const mediumSpinner: ComponentShowcaseItem = { + title: 'Medium', + props: { + size: 'medium', + }, +}; + +const smallSpinner: ComponentShowcaseItem = { + title: 'Small', + props: { + size: 'small', + }, +}; + +const tinySpinner: ComponentShowcaseItem = { + title: 'Tiny', + props: { + size: 'tiny', + }, +}; + +const primarySpinner: ComponentShowcaseItem = { + title: 'Primary', + props: { + status: 'primary', + }, +}; + +const successSpinner: ComponentShowcaseItem = { + title: 'Success', + props: { + status: 'success', + }, +}; + +const infoSpinner: ComponentShowcaseItem = { + title: 'Info', + props: { + status: 'info', + }, +}; + +const warningSpinner: ComponentShowcaseItem = { + title: 'Warning', + props: { + status: 'warning', + }, +}; + +const dangerSpinner: ComponentShowcaseItem = { + title: 'Danger', + props: { + status: 'danger', + }, +}; + +const sizeSection: ComponentShowcaseSection = { + title: 'Size', + items: [ + giantSpinner, + largeSpinner, + mediumSpinner, + smallSpinner, + tinySpinner, + ], +}; + +const statusSection: ComponentShowcaseSection = { + title: 'Status', + items: [ + primarySpinner, + successSpinner, + infoSpinner, + warningSpinner, + dangerSpinner, + ], +}; + +export const spinnerShowcase: ComponentShowcase = { + sections: [ + sizeSection, + statusSection, + ], +}; + +export const spinnerSettings: ComponentShowcaseSetting[] = [ + { + propertyName: 'animating', + value: true, + }, + { + propertyName: 'animating', + value: false, + }, +]; +