React+ts手写轮播图组件(1/2)

前言

最近开发移动端的项目比较多,很多组件都共用着,同时也看了一些课程,啥toast、loading、swiper等等,所以想自己用React搭个移动端的组件库。

正文

封装之后组件的使用代码

首先这是我们想实现的组件使用效果(基础版),一般也都这样差不多,当然组件还可以传一些属性,控制自动播放、循环,指示点indicator效果,这一次先搭个基础的。

javascript 复制代码
<Swiper>
    {data!.banner.map((item, index) => (
            <Swiper.Item key={index}>
                    <img src={item.src} alt={item.alt} height={'100%'} width={'100%'} />
            </Swiper.Item>
    ))}
</Swiper>

实现阶段,大致就三步

  1. 基础组件代码、属性类型约束、样式搭建
  2. touch触摸事件、开始touchstart、移动touchmove、结束touchend
  3. 细节完善,比如第一张、最后一张不可以在往前往后滑动

基础组件代码、属性类型约束、样式搭建

先建个组件框架、写一下基础代码,样式我就不展示了,会放后面

那么我们先看Swiper.Item,它比较简单,它其实就是传递一个子元素进去,所以就接收一个children属性,点击事件可选

Swiper.Item.tsx

typescript 复制代码
import React from 'react';
import './styles/swiper-item.scss';

export interface SwiperItemProps {
	onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
	children: React.ReactNode;
}

const classPrefix = 'czh_swiper_item';

const SwiperItem: React.FC<SwiperItemProps> = (props) => {
	return (
		<div className={classPrefix} onClick={props.onClick}>
			{props.children}
		</div>
	);
};

SwiperItem.displayName = 'SwiperItem';

export default SwiperItem;

主要的代码都在Swiper里,围绕着我们封装之后使用组件的代码,Swiper接收着另一个组件Swiper.Item,那么属性我们就可以确认,有个children,再来是默认展示、样式。

ini 复制代码
import React from 'react';
import './styles/swiper.scss';

export interface SwiperProps {
	defultIndex?: number;
	children: React.ReactElement | React.ReactElement[];
	style?: React.CSSProperties & Partial<Record<'--height' | '--width' | '--border-radius' | '--track-padding', string>>;
}

const classPrefix = 'czh_swiper';

const Swiper: React.FC<SwiperProps> = (props) => {
	const renderChild = () => {
		return (
			<div className={`${classPrefix}_track_inner`}>
				{/* React.Children.map,传入子元素可以遍历传入的子元素 */}
				{React.Children.map(props.children, (child, index) => {
					return (
						<div
							className={`${classPrefix}_slide`}
							// 通过left属性,根据index,让图片横向平铺
							style={{
								left: `-${index * 100}%`,
							}}
						>
							{child}
						</div>
					);
				})}
			</div>
		);
	};

	return (
		<div className={classPrefix}>
			<div className={`${classPrefix}_track`}>{renderChild()}</div>
		</div>
	);
};

Swiper.displayName = 'Swiper';

Swiper.defaultProps = {
	defultIndex: 0,
};

export default Swiper;

基础代码写完,为了让我们可以先在浏览器可以观看我们写的效果,我们在首页引入一下看看,照搬上面我们封装代码,这里的疑问,主要是怎么实现Swiper.Item这样的使用效果,我们在文件夹在创建个index.tsx.

这其实是个倒推的过程,从下往上看,Swiper上有个Item

typescript 复制代码
import FinalSwiper from './swiper';
import FinalSwiperItem from './swiper-item';

// 可以直接在间接的导出已经导出的属性
export type { SwiperProps } from './swiper';
export type { SwiperItemProps } from './swiper-item';

type FinalSwiperType = typeof FinalSwiper;
// 那么类型约束里就一个Item,同时还要保留原来Swiper类型
export interface FinalSwiperProps extends FinalSwiperType {
	Item: typeof FinalSwiperItem;
}

// 但是我们创建的Swiper没有Item,那么我们就自己在建一个
const Swiper = FinalSwiper as FinalSwiperProps;

// Swiper上有个Item
Swiper.Item = FinalSwiperItem;

export default Swiper;

下面是页面效果,成功展示了,然后子元素也有我们遍历的元素,横向平铺,到这里,我们的第一步就完成。接下来就是通过touch事件,来操作我们的样式进行图片位移

touch触摸事件、开始touchstart、移动touchmove、结束touchend

通过样式transform来控制图片的位移,创建currentIndex,因为我们需要线性的移动过程,translate3d的X值是一点一点的变化,所以我们需要在移动过程不断设置currentIndex,currentIndex的变化0->0.3->0.63... ,finalPosition的值也会一点一点的变化,实现线程移动

typescript 复制代码
	const [currentIndex, setCurrentIndex] = useState(0); //记录当前index,位于哪个图片
	const getFinalPosition = (index: number) => {
		// 0 * 100 + 0 * 100
		// 0.3 * 100 + 0 * 100
		// 1 * 100 + 1 * 100
		// ...
		const finalPosition = -currentIndex * 100 + index * 100;
		return finalPosition;
	};

	style={{
		left: `-${index * 100}%`,
		transform: `translate3d(${getFinalPosition(index)}%,0,0)`,
	}}

我们需要通过滑动的距离来判断是否切换切换下一页,滑动大于50%就下一张,否则反之,那么我们就需要获取touch开始的坐标和移动的距离,我们在图片外壳加个touch事件,使用useRef来记录坐标,ref可以保存上一次的值

handleTouchStart

ini 复制代码
	const startRef = useRef(0); //触摸开始的坐标X

	<div className={`${classPrefix}-track`} onTouchStart={(e) => handleTouchStart(e)}>
            {renderChild()}
	</div>

	const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
		//创建一个ref,来保存值,获取一开始touch坐标
		startRef.current = e.changedTouches[0].clientX;
		// 监听原生事件的滑动过程/滑动结束
		document.addEventListener('touchmove', handleTouchmove);
		document.addEventListener('touchend', handleTouchend);
	};

handleTouchmove

ini 复制代码
	const slideRatioRef = useRef(0); //滑动与图片的比例
	const traceRef = useRef<HTMLDivElement>(null);        

	// 获取当前图片滑动比例,是0.5以下,还是0.5以上
	const getSlideRatio = (diff: number) => {
		const ele = traceRef.current;
		if (!ele) return 0;
		return diff / ele.offsetWidth; //通过offsetWidth可以获取元素宽度
	};
        
	const handleTouchmove = (e: TouchEvent) => {
		// 获取滑动最后一个x坐标
		const currentX = e.changedTouches[0].clientX;
		// 运算得出滑动距离
		const diff = startRef.current - currentX;
		// 获取滑动比例
		slideRatioRef.current = getSlideRatio(diff);
		// 不断设置滑动index,以至于translate线性变化
		let slideIndex = slideRatioRef.current + currentIndex;
		setCurrentIndex(slideIndex);
	};
        
	return (
		<div className={classPrefix} onTouchStart={(e) => handleTouchStart(e)} ref={traceRef}>
			<div className={`${classPrefix}_track`}>{renderChild()}</div>
		</div>
	);

至此,我们就可以拖动我们的图片了,接下来我们在滑动结束的通过比例判断是否进入下一张

handleTouchend

至此,我们就可以成功滑动图片切换了

ini 复制代码
	const handleTouchend = (e: TouchEvent) => {
		//四舍五入判断滑动是否超过50%
		const index = Math.round(slideRatioRef.current);
		const targetIndex = index + currentIndex;
		setCurrentIndex(targetIndex);

		// 清除监听
		window.removeEventListener('touchmove', handleTouchmove);
		window.removeEventListener('touchend', handleTouchend);
	};

细节完善

1、transition线性动画

当我们滑动比例超过50%,会进入到下一张,但是进入下一只这个过程是很生硬直接的,唰一下就过了,我们需要添加一个transition线性动画

scss 复制代码
	const [dragging, setDragging] = useState(false);
	const getTransition = () => {
		if (dragging) return '';
		return 'transform 0.3s ease-in-out';
	};
        
	style={{
		left: `-${index * 100}%`,
		transform: `translate3d(${getFinalPosition(index)}%,0,0)`,
		transition: getTransition(),
	}}
	// move的时候不需要线性,因为有translate的动态移动了
	const handleTouchmove = (e: TouchEvent) => {
		...
		setDragging(true);
	};
	// 生硬的切换是在我们离开touch的时候
	const handleTouchend = (e: TouchEvent) => {
		...
		setDragging(false);
	};

效果

2、第一张和最后一张,在往前、往后滑动会划到到空白

应该让其一直保持在第一张或最后一张,被影响的还是currentIndex的值,我们需要给它一个最大最小值的范围,在滑动结束的时候,进行限制

ini 复制代码
	const boundIndex = (currentIndex: number) => {
		let min = 0;
		// React.Children.count可以获取子元素的数量length
		let max = React.Children.count(props.children) - 1;
		let ret = currentIndex;
		ret = Math.max(currentIndex, min); // -0.5 0 / 5.5 0
		ret = Math.min(ret, max); // 0 3 / 5.5 3
		return ret;
	};
        
	const handleTouchend = (e: TouchEvent) => {
		// const targetIndex = index + currentIndex;
		const targetIndex = boundIndex(index + currentIndex);
		setCurrentIndex(targetIndex);
	};

往前滑,currentIndex就会是负数,往后滑,currentIndex又会大于length。

最终

到这,基本就实现了一个基础的轮播图滑动,下一篇就会添加一些属性、新增指示点,优化一些细节

目标

  1. 添加一些属性,循环播放、自动播放、添加自定义样式
  2. 指示点展示、子元素判断
  3. ......看又有啥
相关推荐
red润3 分钟前
使用 HTML5 Canvas 实现动态蜈蚣动画
前端·html·html5
sg_knight11 分钟前
VSCode如何修改默认扩展路径和用户文件夹目录到D盘
前端·ide·vscode·编辑器·web
一个处女座的程序猿O(∩_∩)O20 分钟前
完成第一个 Vue3.2 项目后,这是我的技术总结
前端·vue.js
mubeibeinv21 分钟前
项目搭建+图片(添加+图片)
java·服务器·前端
逆旅行天涯28 分钟前
【Threejs】从零开始(六)--GUI调试开发3D效果
前端·javascript·3d
m0_748255261 小时前
easyExcel导出大数据量EXCEL文件,前端实现进度条或者遮罩层
前端·excel
web147862107231 小时前
C# .Net Web 路由相关配置
前端·c#·.net
m0_748247801 小时前
Flutter Intl包使用指南:实现国际化和本地化
前端·javascript·flutter
飞的肖1 小时前
前端使用 Element Plus架构vue3.0实现图片拖拉拽,后等比压缩,上传到Spring Boot后端
前端·spring boot·架构
青灯文案12 小时前
前端 HTTP 请求由 Nginx 反向代理和 API 网关到后端服务的流程
前端·nginx·http