From 395c5af6a6fd20b563885eae3f33bd9e75757324 Mon Sep 17 00:00:00 2001 From: nparigi-ledger Date: Mon, 16 May 2022 16:19:47 +0200 Subject: [PATCH 1/2] Add StoryIndicator, an indicator for Carousel component like Instagram stories - Add new options to Carousel and fixes about delay and activeIndex with scroll --- .../native/src/components/Carousel/index.tsx | 100 +++++++++++++--- .../Navigation/SlideIndicator/index.tsx | 4 +- .../Navigation/StoriesIndicator/index.tsx | 113 ++++++++++++++++++ .../native/src/components/Navigation/index.ts | 1 + .../stories/Carousel/Carousel.stories.tsx | 32 ++++- .../StoriesIndicator.stories.tsx | 24 ++++ packages/native/storybook/stories/index.ts | 1 + 7 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 packages/native/src/components/Navigation/StoriesIndicator/index.tsx create mode 100644 packages/native/storybook/stories/Navigation/StoriesIndicator/StoriesIndicator.stories.tsx diff --git a/packages/native/src/components/Carousel/index.tsx b/packages/native/src/components/Carousel/index.tsx index 19e4c9ed..4c84f722 100644 --- a/packages/native/src/components/Carousel/index.tsx +++ b/packages/native/src/components/Carousel/index.tsx @@ -17,6 +17,10 @@ export type Props = React.PropsWithChildren<{ * Called when the active carousel index is updated. */ onChange?: (index: number) => void; + /** + * Called when Carousel try to scroll before the first or after the last item (if restartAfterEnd is false). + */ + onOverflow?: (side: "start" | "end", fromAutoDelay: boolean) => void; /** * This number in milliseconds will enable automatic scrolling when defined. * @@ -27,6 +31,14 @@ export type Props = React.PropsWithChildren<{ * manually change the carousel item displayed. */ autoDelay?: number; + /** + * When the delay is elasped, if the Carousel is at the last item, it will scroll back to the beginning. + */ + restartAfterEnd?: boolean; + /** + * When the user tap on one side of an item, it will scroll to the next or precedent item. Same behavior as Instagram or Snapchat. Default: false. + */ + scrollOnSidePress?: boolean; /** * Additional props to pass to the outer container. * This container is a Flex element. @@ -42,15 +54,28 @@ export type Props = React.PropsWithChildren<{ * This container is a Flex element. */ slideIndicatorContainerProps?: FlexboxProps & ViewProps; + + IndicatorComponent?: + | React.ComponentType<{ + activeIndex: number; + slidesLength: number; + onChange?: (index: number) => void; + duration?: number; + }> + | React.ReactElement; }>; function Carousel({ - activeIndex, + activeIndex = 0, autoDelay, + restartAfterEnd = true, + scrollOnSidePress = false, containerProps, slideIndicatorContainerProps, scrollViewProps, onChange, + onOverflow, + IndicatorComponent = SlideIndicator, children, }: Props) { const [init, setInit] = useState(false); @@ -81,7 +106,9 @@ function Carousel({ useEffect(() => { // On init scroll to the active index prop location - if specified. - if (init && activeIndex) scrollToIndex(activeIndex, false); + if (init && activeIndex) { + scrollToIndex(activeIndex, false); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [init]); @@ -97,6 +124,24 @@ function Carousel({ setInit(true); }; + const onTap = useCallback( + (event) => { + const tapPositionXPercent = event.nativeEvent.locationX / itemWidth; + if (tapPositionXPercent > 0.25) { + if (slidesLength > activeIndexState + 1) { + scrollToIndex(activeIndexState + 1, false); + } else { + onOverflow && onOverflow("end", false); + } + } else if (activeIndexState > 0) { + scrollToIndex(activeIndexState - 1, false); + } else { + onOverflow && onOverflow("start", false); + } + }, + [slidesLength, activeIndexState, scrollToIndex, onOverflow, itemWidth], + ); + const onScroll = ({ nativeEvent: { contentOffset, contentSize }, }: { @@ -110,19 +155,31 @@ function Carousel({ useEffect(() => { if (!autoDelay) return; - const interval = setInterval(() => { + const interval = setTimeout(() => { if (!disableTimer.current) { - setActiveIndexState((index) => { - const newIndex = typeof index !== "undefined" ? (index + 1) % slidesLength : 0; + const newIndex = + typeof activeIndexState !== "undefined" ? (activeIndexState + 1) % slidesLength : 0; + if (restartAfterEnd || newIndex !== 0) { scrollToIndex(newIndex); - onChange && onChange(newIndex); - return newIndex; - }); + } else { + onOverflow && onOverflow("end", true); + } } }, autoDelay); - return () => clearInterval(interval); - }, [resetTimer, slidesLength, scrollToIndex, onChange, autoDelay]); + return () => { + return clearTimeout(interval); + }; + }, [ + resetTimer, + slidesLength, + scrollToIndex, + onChange, + autoDelay, + activeIndexState, + onOverflow, + restartAfterEnd, + ]); return ( @@ -141,6 +198,8 @@ function Carousel({ scrollEventThrottle={200} contentContainerStyle={{ width: `${fullWidth}%` }} decelerationRate="fast" + disableIntervalMomentum={true} + onTouchEnd={scrollOnSidePress ? onTap : undefined} {...scrollViewProps} > {React.Children.map(children, (child, index) => ( @@ -150,14 +209,19 @@ function Carousel({ ))} - { - scrollToIndex(index); - setResetTimer({}); - }} - slidesLength={slidesLength} - /> + {React.isValidElement(IndicatorComponent) ? ( + IndicatorComponent + ) : ( + { + scrollToIndex(index); + setResetTimer({}); + }} + slidesLength={slidesLength} + duration={autoDelay} + /> + )} ); diff --git a/packages/native/src/components/Navigation/SlideIndicator/index.tsx b/packages/native/src/components/Navigation/SlideIndicator/index.tsx index 0b6adf30..c4d7e03e 100644 --- a/packages/native/src/components/Navigation/SlideIndicator/index.tsx +++ b/packages/native/src/components/Navigation/SlideIndicator/index.tsx @@ -10,7 +10,7 @@ import Animated, { type Props = { slidesLength: number; activeIndex: number; - onChange: (index: number) => void; + onChange?: (index: number) => void; }; const Container = styled.View` @@ -63,7 +63,7 @@ function SlideIndicator({ slidesLength, activeIndex = 0, onChange }: Props): Rea return ( {slidesArray.map((_, index) => ( - onChange(index)} /> + onChange && onChange(index)} /> ))} diff --git a/packages/native/src/components/Navigation/StoriesIndicator/index.tsx b/packages/native/src/components/Navigation/StoriesIndicator/index.tsx new file mode 100644 index 00000000..cda7fd0b --- /dev/null +++ b/packages/native/src/components/Navigation/StoriesIndicator/index.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useMemo } from "react"; +import styled from "styled-components/native"; +import Animated, { + useAnimatedStyle, + withTiming, + useSharedValue, + Easing, +} from "react-native-reanimated"; +import { FlexBoxProps } from "../../Layout/Flex"; +import { Flex } from "../../Layout"; + +export interface StoryBarProps { + /** + * Is this step active. + */ + isActive?: boolean; + /** + * Has this step been completed. + */ + full?: boolean; + /** + * The duration of this step. + */ + duration?: number; +} + +export interface StoriesIndicatorProps extends FlexBoxProps { + /** + * The index of the active step. + */ + activeIndex: number; + /** + * The total number of steps. + */ + slidesLength: number; + onChange?: (index: number) => void; + /** + * The duration of each step. + */ + duration?: number; +} + +const ActiveBar = styled.View<{ full?: boolean }>` + background-color: ${(p) => p.theme.colors.primary.c100}; + height: 100%; + width: 100%; + border-radius: 8px; +`; + +const AnimatedBar = Animated.createAnimatedComponent(ActiveBar); + +export const TabsContainer = styled(Flex).attrs({ + // Avoid conflict with styled-system's size property by nulling size and renaming it + size: undefined, + flexDirection: "row", + alignItems: "stretch", +})` + width: 100%; +`; + +function StoryBar({ full = false, isActive, duration }: StoryBarProps) { + const width = useSharedValue(full ? 100 : 0); + + useEffect(() => { + if (isActive) { + width.value = 100; + } else if (full) { + width.value = 0; + } else { + width.value = 0; + } + }, [isActive, full, width]); + + const animatedStyles = useAnimatedStyle( + () => ({ + width: withTiming(`${width.value}%`, { + duration: isActive ? duration || 200 : 0, + easing: duration ? Easing.linear : Easing.linear, + }), + }), + [isActive, duration, full], + ); + + return ( + + {full ? : } + + ); +} + +function StoriesIndicator({ activeIndex, slidesLength, duration }: StoriesIndicatorProps) { + const storiesArray = useMemo(() => new Array(slidesLength).fill(0), [slidesLength]); + return ( + + {storiesArray.map((_, storyIndex) => ( + storyIndex} + isActive={activeIndex === storyIndex} + duration={duration} + /> + ))} + + ); +} + +export default React.memo(StoriesIndicator); diff --git a/packages/native/src/components/Navigation/index.ts b/packages/native/src/components/Navigation/index.ts index ebc50785..8298dc8a 100644 --- a/packages/native/src/components/Navigation/index.ts +++ b/packages/native/src/components/Navigation/index.ts @@ -1,3 +1,4 @@ export { default as SlideIndicator } from "./SlideIndicator"; export { default as Stepper } from "./Stepper"; export { default as FlowStepper } from "./FlowStepper"; +export { default as StoriesIndicator } from "./StoriesIndicator"; diff --git a/packages/native/storybook/stories/Carousel/Carousel.stories.tsx b/packages/native/storybook/stories/Carousel/Carousel.stories.tsx index 88494413..3d1d9951 100644 --- a/packages/native/storybook/stories/Carousel/Carousel.stories.tsx +++ b/packages/native/storybook/stories/Carousel/Carousel.stories.tsx @@ -1,7 +1,10 @@ import React from "react"; import styled, { useTheme } from "styled-components/native"; +import { number, boolean } from "@storybook/addon-knobs"; +import { action } from "@storybook/addon-actions"; import { storiesOf } from "../storiesOf"; import { Flex, Carousel, Text, Button } from "../../../src"; +import StoriesIndicator from "../../../src/components/Navigation/StoriesIndicator"; const description = ` ### A simple responsive carousel. @@ -135,6 +138,32 @@ const Controlled = (): JSX.Element => { ); }; +const CustomIndicator = (): JSX.Element => { + return ( + + + + + + + + ); +}; + storiesOf((story) => story("Carousel", module) .add("Default", Default, { @@ -147,5 +176,6 @@ storiesOf((story) => }) .add("AutoDelay", AutoDelay) .add("WithProps", WithProps) - .add("Controlled", Controlled), + .add("Controlled", Controlled) + .add("CustomIndicator - Story", CustomIndicator), ); diff --git a/packages/native/storybook/stories/Navigation/StoriesIndicator/StoriesIndicator.stories.tsx b/packages/native/storybook/stories/Navigation/StoriesIndicator/StoriesIndicator.stories.tsx new file mode 100644 index 00000000..4d7e3ad2 --- /dev/null +++ b/packages/native/storybook/stories/Navigation/StoriesIndicator/StoriesIndicator.stories.tsx @@ -0,0 +1,24 @@ +import React, { useState } from "react"; +import { number } from "@storybook/addon-knobs"; +import { storiesOf } from "../../storiesOf"; +import StoriesIndicator from "../../../../src/components/Navigation/StoriesIndicator"; +import { Box, Button, Text } from "../../../../src"; + +const StoriesIndicatorSample = () => { + const [activeIndex, setActiveIndex] = useState(1); + + return ( + + {activeIndex} + + + + + ); +}; + +storiesOf((story) => story("Navigation", module).add("StoriesIndicator", StoriesIndicatorSample)); diff --git a/packages/native/storybook/stories/index.ts b/packages/native/storybook/stories/index.ts index a0aa587c..047278b4 100644 --- a/packages/native/storybook/stories/index.ts +++ b/packages/native/storybook/stories/index.ts @@ -14,6 +14,7 @@ import "./Layout/List/TipList.stories"; import "./Layout/List/List.stories"; import "./message/Notification/Notification.stories"; import "./Navigation/SlideIndicator/SlideIndicator.stories"; +import "./Navigation/StoriesIndicator/StoriesIndicator.stories"; import "./Navigation/Stepper/Stepper.stories"; import "./Icon/BoxedIcon.stories"; import "./Icon/IconBox.stories"; From 3f38dbd1d55f24369767973a2f345312b132ed0b Mon Sep 17 00:00:00 2001 From: nparigi-ledger Date: Wed, 18 May 2022 14:54:57 +0200 Subject: [PATCH 2/2] Simplify StoriesIndicator + PR feedbacks --- .../native/src/components/Carousel/index.tsx | 1 - .../Navigation/StoriesIndicator/index.tsx | 55 +++++++------------ .../stories/Carousel/Carousel.stories.tsx | 34 ++++++++++-- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/packages/native/src/components/Carousel/index.tsx b/packages/native/src/components/Carousel/index.tsx index 4c84f722..083b258f 100644 --- a/packages/native/src/components/Carousel/index.tsx +++ b/packages/native/src/components/Carousel/index.tsx @@ -198,7 +198,6 @@ function Carousel({ scrollEventThrottle={200} contentContainerStyle={{ width: `${fullWidth}%` }} decelerationRate="fast" - disableIntervalMomentum={true} onTouchEnd={scrollOnSidePress ? onTap : undefined} {...scrollViewProps} > diff --git a/packages/native/src/components/Navigation/StoriesIndicator/index.tsx b/packages/native/src/components/Navigation/StoriesIndicator/index.tsx index cda7fd0b..2efec06f 100644 --- a/packages/native/src/components/Navigation/StoriesIndicator/index.tsx +++ b/packages/native/src/components/Navigation/StoriesIndicator/index.tsx @@ -40,57 +40,39 @@ export interface StoriesIndicatorProps extends FlexBoxProps { duration?: number; } -const ActiveBar = styled.View<{ full?: boolean }>` +const ProgressBar = styled.View` background-color: ${(p) => p.theme.colors.primary.c100}; height: 100%; width: 100%; - border-radius: 8px; + border-radius: ${(p) => p.theme.radii[2]}px; `; -const AnimatedBar = Animated.createAnimatedComponent(ActiveBar); +const AnimatedProgressBar = Animated.createAnimatedComponent(ProgressBar); -export const TabsContainer = styled(Flex).attrs({ - // Avoid conflict with styled-system's size property by nulling size and renaming it - size: undefined, - flexDirection: "row", - alignItems: "stretch", -})` - width: 100%; -`; - -function StoryBar({ full = false, isActive, duration }: StoryBarProps) { - const width = useSharedValue(full ? 100 : 0); +function ActiveProgressBar({ duration }: StoryBarProps) { + const width = useSharedValue(0); useEffect(() => { - if (isActive) { - width.value = 100; - } else if (full) { - width.value = 0; - } else { - width.value = 0; - } - }, [isActive, full, width]); + width.value = 100; + }, [width]); const animatedStyles = useAnimatedStyle( () => ({ width: withTiming(`${width.value}%`, { - duration: isActive ? duration || 200 : 0, - easing: duration ? Easing.linear : Easing.linear, + duration: duration || 200, + easing: Easing.linear, }), }), - [isActive, duration, full], + [width, duration], ); + return ; +} + +function StoryBar({ full = false, isActive, duration }: StoryBarProps) { return ( - - {full ? : } + + {isActive ? : full ? : null} ); } @@ -98,15 +80,16 @@ function StoryBar({ full = false, isActive, duration }: StoryBarProps) { function StoriesIndicator({ activeIndex, slidesLength, duration }: StoriesIndicatorProps) { const storiesArray = useMemo(() => new Array(slidesLength).fill(0), [slidesLength]); return ( - + {storiesArray.map((_, storyIndex) => ( storyIndex} isActive={activeIndex === storyIndex} duration={duration} /> ))} - + ); } diff --git a/packages/native/storybook/stories/Carousel/Carousel.stories.tsx b/packages/native/storybook/stories/Carousel/Carousel.stories.tsx index 3d1d9951..f58bdbb5 100644 --- a/packages/native/storybook/stories/Carousel/Carousel.stories.tsx +++ b/packages/native/storybook/stories/Carousel/Carousel.stories.tsx @@ -64,7 +64,11 @@ const Item = ({ label }: { label: string }) => ( const Default = (): JSX.Element => { return ( - + @@ -76,7 +80,14 @@ const Default = (): JSX.Element => { const AutoDelay = (): JSX.Element => { return ( - + @@ -92,10 +103,10 @@ const WithProps = (): JSX.Element => { { return ( <> - + @@ -147,6 +166,11 @@ const CustomIndicator = (): JSX.Element => { IndicatorComponent={StoriesIndicator} onOverflow={action("onOverflow")} onChange={action("onChange")} + scrollViewProps={{ + style: { + width: "100%", + }, + }} slideIndicatorContainerProps={{ position: "absolute", top: 0,