PhotoView.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import React from 'react';
  2. import { Motion, spring } from 'react-motion';
  3. import throttle from 'lodash.throttle';
  4. import Photo from './Photo';
  5. import PhotoWrap from './components/PhotoWrap';
  6. import isMobile from './utils/isMobile';
  7. import getMultipleTouchPosition from './utils/getMultipleTouchPosition';
  8. import getPositionOnMoveOrScale from './utils/getPositionOnMoveOrScale';
  9. import slideToSuitableOffset from './utils/slideToSuitableOffset';
  10. import { getClosedHorizontal, getClosedVertical } from './utils/getCloseEdge';
  11. import {
  12. defaultAnimationConfig,
  13. minReachOffset,
  14. minScale,
  15. maxScale,
  16. scaleBuffer,
  17. } from './variables';
  18. type ReachFunction = (pageX: number, pageY: number) => void;
  19. export interface IPhotoViewProps {
  20. // 图片地址
  21. src: string;
  22. // 容器类名
  23. wrapClassName?: string;
  24. // 图片类名
  25. className?: string;
  26. // style
  27. style?: object;
  28. // 缩放,用于下拉关闭变小的效果
  29. photoScale?: number;
  30. // 到达顶部滑动事件
  31. onReachTopMove?: ReachFunction;
  32. // 到达右部滑动事件
  33. onReachRightMove?: ReachFunction;
  34. // 到达底部滑动事件
  35. onReachBottomMove?: ReachFunction;
  36. // 到达左部滑动事件
  37. onReachLeftMove?: ReachFunction;
  38. // 触摸解除事件
  39. onReachUp?: ReachFunction;
  40. onPhotoResize?: () => void;
  41. }
  42. const initialState = {
  43. // 图片 X 偏移量
  44. x: 0,
  45. // 图片 y 偏移量
  46. y: 0,
  47. // 图片缩放程度
  48. scale: 1,
  49. // 图片处于触摸的状态
  50. touched: false,
  51. // 触摸开始时 x 原始坐标
  52. pageX: 0,
  53. // 触摸开始时 y 原始坐标
  54. pageY: 0,
  55. // 触摸开始时图片 x 偏移量
  56. lastX: 0,
  57. // 触摸开始时图片 y 偏移量
  58. lastY: 0,
  59. // 触摸开始时时间
  60. touchedTime: 0,
  61. // 多指触控间距
  62. lastTouchLength: 0,
  63. // 动画类型
  64. animation: defaultAnimationConfig,
  65. // 当前边缘触发状态,0: 未触发,1: x 轴,2: y 轴
  66. reachState: 0,
  67. };
  68. export default class PhotoView extends React.Component<
  69. IPhotoViewProps,
  70. typeof initialState
  71. > {
  72. static displayName = 'PhotoView';
  73. readonly state = initialState;
  74. private photoRef;
  75. constructor(props) {
  76. super(props);
  77. this.handleMove = throttle(this.handleMove, 8);
  78. }
  79. componentDidMount() {
  80. if (isMobile) {
  81. window.addEventListener('touchmove', this.handleTouchMove, { passive: false });
  82. window.addEventListener('touchend', this.handleTouchEnd);
  83. } else {
  84. window.addEventListener('mousemove', this.handleMouseMove);
  85. window.addEventListener('mouseup', this.handleMouseUp);
  86. }
  87. }
  88. componentWillUnmount() {
  89. if (isMobile) {
  90. window.removeEventListener('touchmove', this.handleTouchMove);
  91. window.removeEventListener('touchend', this.handleTouchEnd);
  92. } else {
  93. window.removeEventListener('mousemove', this.handleMouseMove);
  94. window.removeEventListener('mouseup', this.handleMouseUp);
  95. }
  96. }
  97. handleStart = (pageX: number, pageY: number, touchLength: number = 0) => {
  98. this.setState(prevState => ({
  99. touched: true,
  100. pageX,
  101. pageY,
  102. lastX: prevState.x,
  103. lastY: prevState.y,
  104. lastTouchLength: touchLength,
  105. touchedTime: Date.now(),
  106. }));
  107. }
  108. handleMove = (newPageX: number, newPageY: number, touchLength: number = 0) => {
  109. if (this.state.touched) {
  110. const { width, naturalWidth } = this.photoRef.state;
  111. const {
  112. x,
  113. y,
  114. pageX,
  115. pageY,
  116. lastX,
  117. lastY,
  118. scale,
  119. lastTouchLength,
  120. reachState,
  121. } = this.state;
  122. let currentX = x;
  123. let currentY = y;
  124. // 边缘状态
  125. let currentReachState = 0;
  126. if (touchLength === 0) {
  127. currentX = newPageX - pageX + lastX;
  128. currentY = newPageY - pageY + lastY;
  129. // 边缘触发检测
  130. currentReachState = this.handleReachCallback(
  131. currentX,
  132. currentY,
  133. scale,
  134. newPageX,
  135. newPageY,
  136. reachState,
  137. );
  138. }
  139. // 横向边缘触发禁用当前滑动
  140. if (currentReachState === 1) {
  141. this.setState({
  142. reachState: 1,
  143. });
  144. } else {
  145. // 目标倍数
  146. const endScale = scale + (touchLength - lastTouchLength) / 100 / 2 * scale;
  147. // 限制最大倍数和最小倍数
  148. const toScale = Math.max(
  149. Math.min(
  150. endScale,
  151. Math.max(maxScale, naturalWidth / width)
  152. ),
  153. minScale - scaleBuffer,
  154. );
  155. this.setState({
  156. lastTouchLength: touchLength,
  157. reachState: currentReachState,
  158. ...getPositionOnMoveOrScale({
  159. x: currentX,
  160. y: currentY,
  161. pageX: newPageX,
  162. pageY: newPageY,
  163. fromScale: scale,
  164. toScale,
  165. }),
  166. });
  167. }
  168. }
  169. }
  170. handleDoubleClick = (e) => {
  171. const { pageX, pageY } = e;
  172. const { width, naturalWidth } = this.photoRef.state;
  173. this.setState(({ x, y, scale }) => {
  174. return {
  175. pageX,
  176. pageY,
  177. ...getPositionOnMoveOrScale({
  178. x,
  179. y,
  180. pageX,
  181. pageY,
  182. fromScale: scale,
  183. // 若图片足够大,则放大适应的倍数
  184. toScale: scale !== 1 ? 1 : Math.max(2, naturalWidth / width),
  185. }),
  186. animation: defaultAnimationConfig,
  187. };
  188. });
  189. }
  190. handleWheel = (e) => {
  191. e.preventDefault();
  192. const { pageX, pageY, deltaY } = e;
  193. const { width, naturalWidth } = this.photoRef.state;
  194. this.setState(({ x, y, scale }) => {
  195. const endScale = scale - deltaY / 100 / 2;
  196. // 限制最大倍数和最小倍数
  197. const toScale = Math.max(
  198. Math.min(
  199. endScale,
  200. Math.max(maxScale, naturalWidth / width)
  201. ),
  202. minScale,
  203. );
  204. return {
  205. pageX,
  206. pageY,
  207. ...getPositionOnMoveOrScale({
  208. x,
  209. y,
  210. pageX,
  211. pageY,
  212. fromScale: scale,
  213. toScale,
  214. }),
  215. animation: defaultAnimationConfig,
  216. };
  217. });
  218. }
  219. handleTouchStart = e => {
  220. if (e.touches.length >= 2) {
  221. const { pageX, pageY, touchLength } = getMultipleTouchPosition(e);
  222. this.handleStart(pageX, pageY, touchLength);
  223. } else {
  224. const { pageX, pageY } = e.touches[0];
  225. this.handleStart(pageX, pageY);
  226. }
  227. }
  228. handleMouseDown = e => {
  229. e.preventDefault();
  230. this.handleStart(e.pageX, e.pageY);
  231. }
  232. handleTouchMove = e => {
  233. e.preventDefault();
  234. if (e.touches.length >= 2) {
  235. const { pageX, pageY, touchLength } = getMultipleTouchPosition(e);
  236. this.handleMove(pageX, pageY, touchLength);
  237. } else {
  238. const { pageX, pageY } = e.touches[0];
  239. this.handleMove(pageX, pageY);
  240. }
  241. }
  242. handleMouseMove = e => {
  243. e.preventDefault();
  244. this.handleMove(e.pageX, e.pageY);
  245. }
  246. handleUp = (newPageX: number, newPageY: number) => {
  247. if (this.state.touched) {
  248. const { onReachUp } = this.props;
  249. const { width, height } = this.photoRef.state;
  250. this.setState(({
  251. x,
  252. y,
  253. lastX,
  254. lastY,
  255. scale,
  256. touchedTime,
  257. pageX,
  258. pageY,
  259. }) => {
  260. if (onReachUp) {
  261. onReachUp(newPageX, newPageY);
  262. }
  263. const hasMove = pageX !== newPageX || pageY !== newPageY;
  264. // 缩放弹性效果
  265. const toScale = Math.max(
  266. Math.min(
  267. scale,
  268. maxScale,
  269. ),
  270. minScale,
  271. );
  272. return {
  273. touched: false,
  274. scale: toScale,
  275. reachState: 0, // 重置触发状态
  276. ...hasMove
  277. ? slideToSuitableOffset({
  278. x,
  279. y,
  280. lastX,
  281. lastY,
  282. width,
  283. height,
  284. scale,
  285. touchedTime,
  286. }) : {
  287. x,
  288. y,
  289. animation: defaultAnimationConfig,
  290. },
  291. };
  292. });
  293. }
  294. }
  295. handleTouchEnd = (e) => {
  296. const { pageX, pageY } = e.changedTouches[0];
  297. this.handleUp(pageX, pageY);
  298. }
  299. handleMouseUp = (e) => {
  300. const { pageX, pageY } = e;
  301. this.handleUp(pageX, pageY);
  302. }
  303. handleResize = () => {
  304. this.setState(initialState);
  305. const { onPhotoResize } = this.props;
  306. if (onPhotoResize) {
  307. onPhotoResize();
  308. }
  309. }
  310. handleReachCallback = (
  311. x: number,
  312. y: number,
  313. scale: number,
  314. newPageX: number,
  315. newPageY: number,
  316. reachState: number,
  317. ): number => {
  318. const { width, height } = this.photoRef.state;
  319. const horizontalType = getClosedHorizontal(x, scale, width);
  320. const verticalType = getClosedVertical(y, scale, height);
  321. const {
  322. onReachTopMove,
  323. onReachRightMove,
  324. onReachBottomMove,
  325. onReachLeftMove,
  326. } = this.props;
  327. // 触碰到边缘
  328. if (
  329. onReachLeftMove
  330. && (horizontalType
  331. && x > minReachOffset
  332. && reachState === 0
  333. || reachState === 1)
  334. ) {
  335. onReachLeftMove(newPageX, newPageY);
  336. return 1;
  337. } else if (
  338. onReachRightMove
  339. && (horizontalType
  340. && x < -minReachOffset
  341. && reachState === 0
  342. || reachState === 1)
  343. ) {
  344. onReachRightMove(newPageX, newPageY);
  345. return 1;
  346. } else if (
  347. onReachTopMove
  348. && (verticalType
  349. && y > minReachOffset
  350. && reachState === 0
  351. || reachState === 2)
  352. ) {
  353. onReachTopMove(newPageX, newPageY);
  354. return 2;
  355. } else if (
  356. onReachBottomMove
  357. && (verticalType
  358. && y < -minReachOffset
  359. && reachState === 0
  360. || reachState === 2)
  361. ) {
  362. onReachBottomMove(newPageX, newPageY);
  363. return 2;
  364. }
  365. return 0;
  366. }
  367. handlePhotoRef = (ref) => {
  368. this.photoRef = ref;
  369. }
  370. render() {
  371. const { src, wrapClassName, className, style, photoScale = 1 } = this.props;
  372. const { x, y, scale, touched, animation } = this.state;
  373. return (
  374. <PhotoWrap className={wrapClassName} style={style}>
  375. <Motion
  376. style={{
  377. currX: touched ? x : spring(x, animation),
  378. currY: touched ? y : spring(y, animation),
  379. currScale: touched ? scale : spring(scale, animation),
  380. }}
  381. >
  382. {({ currX, currY, currScale }) => {
  383. const transform = `translate3d(${currX}px, ${currY}px, 0) scale(${currScale * photoScale})`;
  384. return (
  385. <Photo
  386. className={className}
  387. src={src}
  388. ref={this.handlePhotoRef}
  389. onDoubleClick={this.handleDoubleClick}
  390. onMouseDown={isMobile ? undefined : this.handleMouseDown}
  391. onTouchStart={isMobile ? this.handleTouchStart : undefined}
  392. onWheel={this.handleWheel}
  393. onPhotoResize={this.handleResize}
  394. style={{
  395. WebkitTransform: transform,
  396. transform,
  397. }}
  398. />
  399. );
  400. }}
  401. </Motion>
  402. </PhotoWrap>
  403. );
  404. }
  405. }