Step-by-step dismantling of React components - Swipe carousel

I have written one before A simplified version of the swipe carousel component , I didn't consider a lot of details and general parameter configuration at that time, the main thing was to record the implementation ideas, and there was no source code. I picked it up a few years ago, sorted it out, and packaged it into a component. There is nothing but react itself. Third-party dependencies, the size after gzip compression is only 3.7kb, Source address,example address

Review of ideas

The core idea is to use the visual sense to switch back when the user does not feel it. Here is a way of thinking that is a bit different from the previous one. The action of switching back is changed to reset and reset when switching, and the previous absolute is deprecated. The layout is changed to the flex layout method. The movement is mainly achieved by changing the outer container transform. The idea of ​​seamless carousel is as follows.

  1. The current position is as shown in the figure, position 3, the red arrow is the visible area of ​​the mobile phone.
  2. Before moving to the right, move position 1 to the back of position 3 through transform
  3. Then move the outer container to complete the first seamless
  4. Before moving to the right, reset position 1 to the original position (quick movement)
  5. And move the outer container to position 1 to reach the visible area (quick move)
  6. Then move right to complete the second seamless. Left shift is the same

After understanding the design ideas, start designing the component API and methods. The documents are as follows

API

parameter

illustrate

type

Defaults

autoplay

Optional, automatic rotation interval, in ms

number

3000

duration

Optional, animation duration, in ms

number

500

initialSwipe

optional, default location

number

0

loop

Optional, whether to loop playback

boolean

true

vertical

Optional, whether to slide vertically

boolean

false

touchable

Optional, whether to swipe with gestures

boolean

true

showIndicators

Optional, whether to display dot

boolean

true

style

Optional, container style, its height needs to be set when it is vertical

object

-

onSlideChange

optional, callback to toggle index

function(current)

-

method

name

describe

slideTo(to, swiping)

Switch to the specified index, when swiping = true, no animation is used

next()

switch to next index

prev()

switch to previous index

Get ready and have fun starting code implementation against the document! !

The first step, layout the page

Here, by splitting the components into two components, Swipe and SwipeItem, Swipe is the main container, SwipeItem is the child, Swipe verifies whether the children are the SwipeItem component, the layout adopts the flex layout, and the flexDirection itself can be displayed horizontally and vertically. In order to facilitate the horizontal and vertical layout switching through the vertical attribute later. The carousel movement mainly relies on changing the transfrom property of the outer container for offset. The core of the layout is to dynamically calculate the width of the SwipeItem and the width of the mobile container (the width of the SwipeItem * the number of SwipeItems).

// Swipe.tsx
import React from 'react';
import SwipeItem from './SwipeItem';
import './style.less';

const Swipe:React.FC<SwipeProps> = (props) => {
    const {
        initialSwipe = 0, // default index
        vertical = false, // whether vertical
        duration = 500,   // Toggle animation time
        autoplay = 3000,  // Autoplay interval
        touchable = true, // Whether to support gesture swipe
        loop = true,      // Whether to rotate seamlessly
        showIndicators = true, // whether to show dots
        onSlideChange
    } = props;
    
    // Calculate the number of SwipeItem s
    const count = useMemo(() => React.Children.count(props.children), [props.children]);
    // Get the width and height of the container
    const { size, root } = useRect<HTMLDivElement>([count]);
    // Get the value of SwipeItem's height/width
    const itemSize = useMemo(() => vertical ? size.height : size.width, [size, vertical]);
    // Get SwipeItem should set height or width
    const itemKey = useMemo(() => vertical ? 'height' : 'width', [vertical]);
    // Style the SwipeItem
    const itemStyle = useMemo(() => ({ [itemKey]: itemSize }), [itemKey, itemSize]);
    // Style the mobile container
    const wrappStyle = useMemo(() => ({ [itemKey]: itemSize * count }), [count, itemSize, itemKey]);
    
    return (
    	<div ref={root} style={props.style} className="lumu-swipe">
            <div style={wrappStyle} className={`lumu-swipe__container ${vertical ? 'lumu-swipe__vertical' : ''}`}>
            {
                React.Children.map(props.children, (child, index) => {
                    if (!React.isValidElement(child)) return null
                    if (child.type !== SwipeItem) return null;
                    // Passing props to child through cloneElement
                    return React.cloneElement(child, {
                        style: itemStyle,
                        vertical: vertical
                    })
                });
            }	
            </div>
        </div>
    )
}
copy
// SwipeItem.tsx
import React from 'react';
import './style.less';
const SwipeItem:React.FC<SwipeItemProps> = (props) => {
    const { children, style, vertical } = props;

    return (
        <div className="lumu-swipe__item"} style={style}>
            {children}
        </div>
    )
};
copy
// style.less
@name: lumu;

.@{name}-swipe {
    overflow: hidden;
    &__container {
        display: flex;
        align-items: center;
        height: 100%;
    }
    &__vertical {
        flex-direction: column;
    }
    &__item {
        width: 100%;
        height: 100%;
        flex-shrink: 0;
    }
}
copy

The second step, move the container (core)

At this point, you can basically see a static carousel layout, and then start the core content. The core content is encapsulated in a hook method of useSwipe. Through the method exposed by useSwipe, functions such as automatic playback, gesture sliding, etc. can be realized later.

// Swipe.tsx
	...Ditto omitted
     // core method
    const { 
        swipeRef, // Move the container's ref
        setRefs, // set child component ref
        current, // current index
        slideTo, // moving position
        next, // Fast moving method encapsulated by slideTo
        prev, // Fast moving method encapsulated by slideTo
        loopMove, // The circular movement method encapsulated by slideTo
    } = useSwipe({ count, vertical, duration, size: itemSize, loop }); 
    
    return (
    	<div ref={root} style={props.style} className="lumu-swipe">
            <div ref={swipeRef} style={wrappStyle} className={`lumu-swipe__container ${vertical ? 'lumu-swipe__vertical' : ''}`}>
            {
                React.Children.map(props.children, (child, index) => {
                    if (!React.isValidElement(child)) return null
                    if (child.type !== SwipeItem) return null;
                    // Passing props to child through cloneElement
                    return React.cloneElement(child, {
                        style: itemStyle,
                        vertical: vertical,
                        // Mount subcomponent instances through setRefs for later movement
                        ref: setRefs(index)
                    })
                });
            }	
            </div>
        </div>
    )
copy
// useSwipe.ts
import { useRef, useState, useMemo, useEffect } from 'react';
import { SwipeItemRef } from '../SwipeItem';
import useRefs from './useRefs';

type SwipeParams = {
    count: number;
    vertical: boolean;
    duration: number;
    size: number;
    loop: boolean;
}

type SlideToParams = Partial<{
    step: number;
    swiping: boolean;
    offset: number;
}>;

const useSwipe = (options: SwipeParams) => {
    const { count, vertical, duration, size, loop } = options;
    // current index
    const [current, setCurrent] = useState(0);
    // Calculated index is also an index thrown to the outside.
    const realCurrent = useMemo(() => (current + count) % count || 0, [current, count]);
    // mobile container
    const swipeRef = useRef<HTMLDivElement>(null);
    // This method is mainly to mount the subcomponent instance, and it is biased to manipulate the movement position of the subcomponent later.
    const [refs, setRefs] = useRefs<SwipeItemRef>();
    // minimum index value
    const minCurrent = useMemo(() => loop ? -1 : 0, [loop]);
    // maximum index value
    const maxCurrent = useMemo(() => loop ? count : count - 1, [loop, count]);
    // current moving direction
    const loopDirection = useRef<1|-1>(1);
	
    // Monitor the index to change the current moving direction
    useEffect(() => {
        if (realCurrent === 0) {
            loopDirection.current = 1;
        }
        if (realCurrent === count - 1) {
            loopDirection.current = -1;
        }
    }, [realCurrent]);

    // Set the position of the mobile container and whether there is a mobile animation
    const setStyle = (dom: HTMLDivElement | null, options: { swiping: boolean, offset: number }) => {
        if (!dom) return;
        const { swiping, offset } = options;
        dom.style.transition = `all ${swiping ? 0 : duration}ms`;
        dom.style.transform = `translate${vertical ? 'Y' : 'X'}(${offset}px)`;
    }
    // reset container
    const resetCurrent = () => {
        setStyle(swipeRef.current, {
            swiping: true, offset: -realCurrent * size
        })
    }
    // reset subcomponent position
    const resetChild = (step: number, offset: number) => {
        let direction = '';
        if (step < 0 || offset > 0) {
            direction = 'left';
        }
        if (step > 0 || offset < 0) {
            direction = 'right';
        }
        if ([-1, count - 1].includes(current)) {
            refs[0].setOffset(direction === 'right' ? count * size : 0);
            refs[refs.length - 1].setOffset(0);
        }
        if ([count, 0].includes(current)) {
            refs[0].setOffset(0);
            refs[refs.length - 1].setOffset(direction === 'right' ? 0 : -count * size)
        }
    }
	
    // Move container, the number of steps moved by step, whether swiping closes the animation, offset offset, mainly used for gesture movement
    const slideTo = ({ step = 0, swiping = false, offset = 0 }: SlideToParams) => {
        if (count <= 1) return;
        // If it is a seamless rotation, you need to reset the position of the child component before moving
        loop && resetChild(step, offset);
        // Calculate the index to be reached
        const fetureCurrent = Math.min(Math.max(realCurrent + step, minCurrent), maxCurrent);
        // Calculate the offset of the move
        const fetureOffset = -fetureCurrent * size + offset;
        if (swiping) {
            setStyle(swipeRef.current, {
                swiping, offset: fetureOffset
            });
        } else {
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    setStyle(swipeRef.current, {
                        swiping, offset: fetureOffset
                    });
                })
            })
        }
        setCurrent(fetureCurrent);
    }

    const next = () => {
        resetCurrent();
        slideTo({ step: 1 });
    }

    const prev = () => {
        resetCurrent();
        slideTo({ step: -1 });
    }

    const loopSwipe = () => {
        if (loop) {
            next();
            return;
        }
        if (loopDirection.current === 1) {
            next();
        } else {
            prev();
        }
    }

    return {
        swipeRef,
        setRefs,
        current: realCurrent,
        slideTo,
        next,
        prev,
        loopSwipe
    }
}

export default useSwipe;
copy
// SwipeItem.tsx
import React, { useImperativeHandle, useMemo, useRef, useState } from 'react';
import { SwipeProps } from './Swipe';

interface SwipeItemRef {
    setOffset: React.Dispatch<React.SetStateAction<number>>
}

interface SwipeItemProps {
    readonly vertical?: SwipeProps['vertical'];
    readonly style?: React.CSSProperties;
    children: React.ReactNode;
}

const SwipeItem = React.forwardRef<SwipeItemRef, SwipeItemProps>((props, ref) => {
    const { children, style, vertical } = props;
    const [offset, setOffset] = useState(0);
    const swipeItemRef = useRef<HTMLDivElement>(null);
    
    useImperativeHandle(ref, () => {
        return {
            setOffset
        }
    });

    const itemStyle = useMemo(() => {
        return {
            transform: offset ? `translate${props.vertical ? 'Y' : 'X'}(${offset}px)` : '',
            ...style
        }
    }, [offset, style, vertical]);

    return (
        <div ref={swipeItemRef} className={"lumu-swipe__item"} style={itemStyle}>
            {children}
        </div>
    )
});
copy

The third step, gesture processing

For gestures, it is encapsulated into a useTouch method, which mainly records the gesture time and the difference between gestures.

// useTouch.ts
import { useRef } from 'react';

const useTouch = () => {
    const startX = useRef<number>(0); // Starting point X coordinate
    const startY = useRef<number>(0); // Y coordinate of starting point
    const deltaX = useRef<number>(0); // X coordinate distance to move
    const deltaY = useRef<number>(0); // Y coordinate distance to move
    const time = useRef<number>(0); // time record

    const reset = () => {
        startX.current = 0;
        startY.current = 0;
        deltaX.current = 0;
        deltaY.current = 0;
        time.current = 0;
    }

    const start = (event: React.TouchEvent | TouchEvent) => {
        reset();
        time.current = new Date().getTime();
        startX.current = event.touches[0].clientX;
        startY.current = event.touches[0].clientY;
    }

    const move = (event: React.TouchEvent | TouchEvent) => {
        if (!time.current) return;
        deltaX.current = event.touches[0].clientX - startX.current;
        deltaY.current = event.touches[0].clientY - startY.current;
    }

    const end = () => {
        const tempDeltaX = deltaX.current;
        const tempDeltaY = deltaY.current;
        const timediff = new Date().getTime() - time.current;
        reset();
        return {
            deltaX: tempDeltaX,
            deltaY: tempDeltaY,
            time: timediff
        }
    }

    const getDelta = () => {
        return {
            deltaX: deltaX.current,
            deltaY: deltaY.current
        }
    }

    return {
        move, start, end, getDelta
    }
}
copy
// SwipeItem.ts
...Duplicate code omitted
const touch = useTouch();

const onTouchStart = (event: React.TouchEvent | TouchEvent) => {
    if (!touchable) return; 
    touch.start(event);
}

const onTouchMove = (event: React.TouchEvent | TouchEvent) => {
    if (!touchable) return; 
    touch.move(event);
    const { deltaX, deltaY } = touch.getDelta()
    slideTo({ swiping: true, offset: vertical ? deltaY : deltaX });
}

const onTouchEnd = () => {
    if (!touchable) return; 
    const { deltaX, time, deltaY } = touch.end();
    const delta = vertical ? deltaY : deltaX;
    const step = (itemSize / 2 < Math.abs(delta) || Math.abs(delta / time) > 0.25) ? (delta > 0 ? -1 : 1) : 0;
    slideTo({ swiping: false, step });
}
copy

The fourth step, detail branch function processing

The detailed functions are mainly extended through the above core content, and no code will be posted here. The complete source code can be found here , mainly in the following points:

  1. Automatic rotation, which can be achieved by calling the loopMove method
  2. The onSlideChange method is implemented, called by listening to the current index
  3. Page visiblity processing, start and stop automatic rotation by monitoring page visiblity
  4. Prevent touchmove from bubbling when scrolling vertically
  5. Throw next, prev, slideTo methods through useImperativeHandle
  6. The implementation of the showIndicators property also implements the dot component through slideTo and current, and then displays and hides it through the property

example address

Tags: data structure Container

Posted by Coronach on Wed, 05 Oct 2022 12:01:14 +0300