import { spring, TransitionMotion, TransitionStyle, } from "@serprex/react-motion"; import { uniqueId, throttle } from "lodash"; import React from "react"; import { DOWN, SwipeEventData, UP } from "react-swipeable"; import { Swipeable } from "~/components/Swipeable"; import { AppEnvDimensionsState } from "../../../Shared/stores/typings"; import { ComponentCarouselItem } from "./ComponentCarouselItem"; interface CarouselWindowItem { offset: number; idx: number; key: string; } interface Props { activeItemKey: string; onItemChange?: (newKey: string | null, oldKey: string | null) => void; overscanAmount: number; changingWaitTime: number; changeStartDelta: number; resizeThrottleTime: number; resizeBlockWaitTime: number; envDimensions: AppEnvDimensionsState; children: | Array> | React.ReactElement; } interface WindowsItemState { windowItems: Array; currentIdx: number; swipedAmount: number; } interface State extends WindowsItemState { activeIdx: number; placeholderWidth: number; filteredChildren: Array; childByIdxLookup: Record; childrenCount: number; isChanging: boolean; isResizeBlocked: boolean; } export default class ComponentCarousel extends React.PureComponent< Props, State > { static defaultProps = { overscanAmount: 2, changingWaitTime: 550, changeStartDelta: 75, resizeThrottleTime: 300, resizeBlockWaitTime: 2000, }; placeholderRef = React.createRef(); changingRafId: number | null = null; changingStartTime: number | null = null; resizeBlockRafId: number | null = null; resizeBlockStartTime: number | null = null; orientationHandler: () => void; resizeHandler: () => void; constructor(props) { super(props); this.state = { activeIdx: 0, currentIdx: 0, swipedAmount: 0, placeholderWidth: 0, childByIdxLookup: {}, childrenCount: 0, windowItems: [], filteredChildren: [], isChanging: false, isResizeBlocked: false, }; } static getDerivedStateFromProps(props: Props, state: State): State { const { children, activeItemKey } = props; let activeIdx: number = 0; //TODO need to refactor to better handle this and not require any let filteredChildren: Array = []; if (children) { filteredChildren = React.Children.toArray(children).filter( (child: any) => !!child && child.props.isEnabled ); } React.Children.forEach(filteredChildren, (child, idx) => { if (!child) { return; } const { itemKey, isEnabled } = child.props; if (isEnabled && itemKey === activeItemKey) { activeIdx = idx; } }); let childByIdxLookup: Record = filteredChildren.reduce( (acc, child, idx) => ({ ...acc, [idx]: child, }), {} ); let newState: State = { ...state, activeIdx, currentIdx: activeIdx, filteredChildren, childrenCount: filteredChildren.length, childByIdxLookup, }; let windowItemsState: WindowsItemState = state.isChanging ? state : ComponentCarousel.deriveWindowItemsState(props, newState); return { ...newState, ...windowItemsState, }; } componentDidMount() { this.initResizeHandlers(); this.updatePlaceholderDimensions(); } componentWillUnmount() { this.destroyResizeHandlers(); this.stopChangeCheckLoop(); this.stopResizeBlockCheckLoop(); } destroyResizeHandlers = (): void => { window.removeEventListener("resize", this.resizeHandler); window.removeEventListener("orientationchange", this.orientationHandler); }; initResizeHandlers = (): void => { const { resizeThrottleTime, envDimensions } = this.props; this.resizeHandler = throttle(() => { if (!this.state.isResizeBlocked) { this.updatePlaceholderDimensions(); } }, resizeThrottleTime); this.orientationHandler = throttle(() => { let oldSize: number = envDimensions ? envDimensions.window.width : 0; let count: number = 0; let orientationIntervalId: number | undefined; orientationIntervalId = window.setInterval(() => { let newEnvWidth = envDimensions ? envDimensions.window.width : oldSize; if (oldSize !== newEnvWidth || count > 15) { window.clearInterval(orientationIntervalId); return; } if (oldSize !== window.innerWidth) { this.updatePlaceholderDimensions(); } count++; }, 100); }, resizeThrottleTime); window.addEventListener("resize", this.resizeHandler); window.addEventListener("orientationchange", this.orientationHandler); }; updatePlaceholderDimensions = (): void => { if (this.placeholderRef.current) { let placeholderWidth = this.placeholderRef.current.offsetWidth; this.setState( { placeholderWidth, }, this.updateWindowItems ); } }; getItemKeyByIdx = (itemIdx: number): string | null => { const { filteredChildren } = this.state; let itemKey: string | null = null; React.Children.forEach(filteredChildren, (child, idx) => { if (itemIdx === idx) { itemKey = child.props.itemKey; } }); return itemKey; }; static getSwipedItemPosIdx = ( activeIdx: number, placeholderWidth: number, swipedAmount: number ): number => { if (placeholderWidth === 0) { return 0; } let swipedSlideCount = Math.round(swipedAmount / placeholderWidth); return activeIdx - swipedSlideCount; // subtract because of swipe direction makes it negative going right }; // Need to be able to get what array index to use based on a position index that is both positive, zero, and negative // example: [a, b, c, d, e, f, g, h] (length = 8) // position index: -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10 // array index: 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 // // position index "-9" gives letter "h" static getArrayIdxByPositionIdx = ( posIndex: number, total: number ): number => { if (posIndex >= 0) { return posIndex % total; } else { const indexMod = Math.abs(posIndex) % total; return (indexMod === 0 ? 0 : 1) * (total - indexMod); } }; runChangeCheck = (callback: () => void): void => { const { changingWaitTime } = this.props; if ( this.changingStartTime !== null && Number(new Date()) - this.changingStartTime > changingWaitTime ) { callback(); } else { this.changingRafId = window.requestAnimationFrame( this.runChangeCheck.bind(this, callback) ); } }; startChangeCheckLoop = (callback: () => void): void => { this.changingStartTime = Number(new Date()); this.stopChangeCheckLoop(); this.changingRafId = window.requestAnimationFrame( this.runChangeCheck.bind(this, callback) ); }; stopChangeCheckLoop = (): void => { if (this.changingRafId !== null) { window.cancelAnimationFrame(this.changingRafId); } }; startResizeBlockCheckLoop = (callback: () => void): void => { this.resizeBlockStartTime = Number(new Date()); this.stopResizeBlockCheckLoop(); this.resizeBlockRafId = window.requestAnimationFrame( this.runResizeBlockCheck.bind(this, callback) ); }; stopResizeBlockCheckLoop = (): void => { if (this.resizeBlockRafId !== null) { window.cancelAnimationFrame(this.resizeBlockRafId); } }; runResizeBlockCheck = (callback: () => void): void => { const { resizeBlockWaitTime } = this.props; if ( this.resizeBlockStartTime !== null && Number(new Date()) - this.resizeBlockStartTime > resizeBlockWaitTime ) { callback(); } else { this.resizeBlockRafId = window.requestAnimationFrame( this.runResizeBlockCheck.bind(this, callback) ); } }; static deriveWindowItemsState = ( props: Props, state: State ): WindowsItemState => { const { overscanAmount } = props; const { windowItems, placeholderWidth, swipedAmount, childrenCount, currentIdx, } = state; let currentPosIdx: number = ComponentCarousel.getSwipedItemPosIdx( currentIdx, placeholderWidth, swipedAmount ); let startIdx: number = currentPosIdx - overscanAmount; let endIdx: number = currentPosIdx + overscanAmount; let newWindowItems: Array = []; if (childrenCount > 0) { let offset: number = -1 * overscanAmount; for (let i = startIdx; i <= endIdx; i++) { let itemIdx = ComponentCarousel.getArrayIdxByPositionIdx( i, childrenCount ); let existingItem = windowItems.find( (windowItem) => windowItem.idx === itemIdx ); newWindowItems.push({ offset, idx: itemIdx, key: existingItem ? existingItem.key : uniqueId(), }); offset++; } } return { windowItems: newWindowItems, currentIdx: currentPosIdx, swipedAmount: 0, }; }; updateWindowItems = (startDelayedChange: boolean = false): void => { const { childrenCount, activeIdx } = this.state; let windowItemsState = ComponentCarousel.deriveWindowItemsState( this.props, this.state ); this.setState(windowItemsState); if (startDelayedChange) { this.startChangeCheckLoop(() => { let newActiveIdx = ComponentCarousel.getArrayIdxByPositionIdx( windowItemsState.currentIdx, childrenCount ); this.handleItemChange(newActiveIdx, activeIdx); }); } }; handleSwiping = ({ deltaX, dir }: SwipeEventData): void => { const isVerticalChange = [UP, DOWN].includes(dir); if (!isVerticalChange) { this.stopChangeCheckLoop(); this.setState((prevState) => { return { swipedAmount: deltaX, isChanging: true, isResizeBlocked: true, }; }); } }; handleSwipedHorizontal = ({ event, deltaX, velocity, }: SwipeEventData): void => { const isFlick = velocity > 0.1; const { placeholderWidth } = this.state; const { changeStartDelta } = this.props; let newSwipedAmount: number = 0; if (isFlick) { if (deltaX > 0) { newSwipedAmount = placeholderWidth; } else if (deltaX < 0) { newSwipedAmount = -1 * placeholderWidth; } } this.setState( (prevState) => { let isChanging: boolean = isFlick || Math.abs(deltaX) <= changeStartDelta; return { swipedAmount: isFlick ? newSwipedAmount : prevState.swipedAmount, isChanging, isResizeBlocked: isChanging, }; }, () => { this.updateWindowItems(true); } ); }; handleItemChange = (newItemIdx: number, oldItemIdx: number): void => { const { onItemChange } = this.props; if (onItemChange) { onItemChange( this.getItemKeyByIdx(newItemIdx), this.getItemKeyByIdx(oldItemIdx) ); this.stopChangeCheckLoop(); this.setState({ activeIdx: newItemIdx, isChanging: false, }); this.startResizeBlockCheckLoop(() => { this.setState({ isResizeBlocked: false, }); }); } }; handlePlaceholderClick = (newIdx: number, evt: React.MouseEvent): void => { const { activeIdx } = this.state; const { onItemChange } = this.props; if (onItemChange) { evt.stopPropagation(); evt.nativeEvent.stopImmediatePropagation(); this.handleItemChange(newIdx, activeIdx); } }; static getItemPositionX = ( swipedAmount: number, offset: number, placeholderWidth: number ): number => { let itemPositionX = swipedAmount + offset * placeholderWidth; if (itemPositionX >= 0) { return Math.min(placeholderWidth * 2, itemPositionX); } else { return Math.max(-1 * placeholderWidth * 2, itemPositionX); } }; renderPlaceholders = (): React.ReactNode => { const { childByIdxLookup, windowItems, swipedAmount, placeholderWidth, isChanging, } = this.state; let placeholderStyles: Array = windowItems.map( (windowItem) => { const child = childByIdxLookup[windowItem.idx]; return { key: windowItem.key, style: { transform: spring( ComponentCarousel.getItemPositionX( swipedAmount, windowItem.offset, placeholderWidth ), { stiffness: 400, damping: 30 } ), }, data: { offset: windowItem.offset, itemIdx: windowItem.idx, child, }, }; } ); let classNames: Array = ["ct-component-carousel__placeholders"]; if (isChanging) { classNames.push("ct-component-carousel__placeholders--changing"); } return (
{(interpolatedStyles) => ( {interpolatedStyles.map((config) => { const { PlaceholderComponent, placeholderProps, itemKey } = config.data.child.props; return (
); })}
)}
); //` }; renderActiveComponent = (): React.ReactNode => { const { activeIdx, childByIdxLookup, filteredChildren } = this.state; if (activeIdx === null || !filteredChildren.length) { return null; } let child = childByIdxLookup[activeIdx]; const { ContentComponent } = child.props; return (
); }; render() { return (
{this.renderPlaceholders()} {this.renderActiveComponent()}
); } }