PhotoSlider.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import React from 'react';
  2. import classNames from 'classnames';
  3. import PhotoView from './PhotoView';
  4. import SlideWrap from './components/SlideWrap';
  5. import VisibleAnimationHandle from './components/VisibleAnimationHandle';
  6. import Close from './components/Close';
  7. import ArrowLeft from './components/ArrowLeft';
  8. import ArrowRight from './components/ArrowRight';
  9. import isTouchDevice from './utils/isTouchDevice';
  10. import { dataType, IPhotoProviderBase, ReachTypeEnum, ShowAnimateEnum } from './types';
  11. import { defaultOpacity, horizontalOffset, maxMoveOffset } from './variables';
  12. import './PhotoSlider.less';
  13. export interface IPhotoSliderProps extends IPhotoProviderBase {
  14. // 图片列表
  15. images: dataType[];
  16. // 图片当前索引
  17. index?: number;
  18. // 可见
  19. visible: boolean;
  20. // 关闭事件
  21. onClose: (evt?: React.MouseEvent | React.TouchEvent) => void;
  22. // 索引改变回调
  23. onIndexChange?: Function;
  24. }
  25. type PhotoSliderState = {
  26. // 偏移量
  27. translateX: number;
  28. // 图片当前的 index
  29. photoIndex: number;
  30. // 图片处于触摸的状态
  31. touched: boolean;
  32. // 该状态是否需要 transition
  33. shouldTransition: boolean;
  34. // Reach 开始时 x 坐标
  35. lastClientX: number | undefined;
  36. // Reach 开始时 y 坐标
  37. lastClientY: number | undefined;
  38. // 背景透明度
  39. backdropOpacity: number;
  40. // 上次关闭的背景透明度
  41. lastBackdropOpacity: number;
  42. // 覆盖物可见度
  43. overlayVisible: boolean;
  44. // 可下拉关闭
  45. canPullClose: boolean;
  46. };
  47. export default class PhotoSlider extends React.Component<IPhotoSliderProps, PhotoSliderState> {
  48. static displayName = 'PhotoSlider';
  49. static defaultProps = {
  50. maskClosable: true,
  51. photoClosable: false,
  52. bannerVisible: true,
  53. introVisible: true,
  54. };
  55. static getDerivedStateFromProps(nextProps, prevState) {
  56. if (nextProps.index !== undefined && nextProps.index !== prevState.photoIndex) {
  57. return {
  58. photoIndex: nextProps.index,
  59. translateX: -(window.innerWidth + horizontalOffset) * nextProps.index,
  60. };
  61. }
  62. return null;
  63. }
  64. constructor(props) {
  65. super(props);
  66. this.state = {
  67. translateX: 0,
  68. photoIndex: 0,
  69. touched: false,
  70. shouldTransition: true,
  71. lastClientX: undefined,
  72. lastClientY: undefined,
  73. backdropOpacity: defaultOpacity,
  74. lastBackdropOpacity: defaultOpacity,
  75. overlayVisible: true,
  76. canPullClose: true,
  77. };
  78. }
  79. componentDidMount() {
  80. const { index = 0 } = this.props;
  81. this.setState({
  82. translateX: index * -(window.innerWidth + horizontalOffset),
  83. photoIndex: index,
  84. });
  85. window.addEventListener('keydown', this.handleKeyDown);
  86. }
  87. componentWillUnmount() {
  88. window.removeEventListener('keydown', this.handleKeyDown);
  89. }
  90. handleClose = (evt?: React.MouseEvent | React.TouchEvent) => {
  91. const { onClose } = this.props;
  92. const { backdropOpacity } = this.state;
  93. onClose(evt);
  94. this.setState({
  95. overlayVisible: true,
  96. // 记录当前关闭时的透明度
  97. lastBackdropOpacity: backdropOpacity,
  98. });
  99. };
  100. handlePhotoTap = () => {
  101. const { photoClosable } = this.props;
  102. if (photoClosable) {
  103. this.handleClose();
  104. } else {
  105. this.setState(prevState => ({
  106. overlayVisible: !prevState.overlayVisible,
  107. }));
  108. }
  109. };
  110. handlePhotoMaskTap = () => {
  111. const { maskClosable } = this.props;
  112. if (maskClosable) {
  113. this.handleClose();
  114. }
  115. };
  116. handleResize = () => {
  117. const { innerWidth } = window;
  118. this.setState(({ photoIndex }) => {
  119. return {
  120. translateX: -(innerWidth + horizontalOffset) * photoIndex,
  121. lastClientX: undefined,
  122. lastClientY: undefined,
  123. shouldTransition: false,
  124. };
  125. });
  126. };
  127. handleKeyDown = (evt: KeyboardEvent) => {
  128. const { visible } = this.props;
  129. if (visible) {
  130. switch (evt.key) {
  131. case 'ArrowLeft':
  132. this.handlePrevious(false);
  133. break;
  134. case 'ArrowRight':
  135. this.handleNext(false);
  136. break;
  137. case 'Escape':
  138. this.handleClose();
  139. break;
  140. }
  141. }
  142. };
  143. handleReachVerticalMove = (clientY, scale) => {
  144. this.setState(({ lastClientY, backdropOpacity }) => {
  145. if (lastClientY === undefined) {
  146. return {
  147. touched: true,
  148. lastClientY: clientY,
  149. backdropOpacity,
  150. canPullClose: true,
  151. };
  152. }
  153. const offsetClientY = Math.abs(clientY - lastClientY);
  154. const opacity = Math.max(Math.min(defaultOpacity, defaultOpacity - offsetClientY / 100 / 4), 0);
  155. return {
  156. touched: true,
  157. lastClientY,
  158. backdropOpacity: scale === 1 ? opacity : defaultOpacity,
  159. canPullClose: scale === 1,
  160. };
  161. });
  162. };
  163. handleReachHorizontalMove = clientX => {
  164. const { innerWidth } = window;
  165. const { images } = this.props;
  166. this.setState(({ lastClientX, translateX, photoIndex }) => {
  167. if (lastClientX === undefined) {
  168. return {
  169. touched: true,
  170. lastClientX: clientX,
  171. translateX,
  172. shouldTransition: true,
  173. };
  174. }
  175. const originOffsetClientX = clientX - lastClientX;
  176. let offsetClientX = originOffsetClientX;
  177. // 第一张和最后一张超出距离减半
  178. if (
  179. (photoIndex === 0 && originOffsetClientX > 0) ||
  180. (photoIndex === images.length - 1 && originOffsetClientX < 0)
  181. ) {
  182. offsetClientX = originOffsetClientX / 2;
  183. }
  184. return {
  185. touched: true,
  186. lastClientX: lastClientX,
  187. translateX: -(innerWidth + horizontalOffset) * photoIndex + offsetClientX,
  188. shouldTransition: true,
  189. };
  190. });
  191. };
  192. handleIndexChange = (photoIndex: number, shouldTransition: boolean = true) => {
  193. const singlePageWidth = window.innerWidth + horizontalOffset;
  194. const translateX = -singlePageWidth * photoIndex;
  195. this.setState({
  196. touched: false,
  197. lastClientX: undefined,
  198. lastClientY: undefined,
  199. translateX,
  200. photoIndex,
  201. shouldTransition,
  202. });
  203. const { onIndexChange } = this.props;
  204. if (onIndexChange) {
  205. onIndexChange(photoIndex);
  206. }
  207. };
  208. handlePrevious = (shouldTransition?: boolean) => {
  209. const { photoIndex } = this.state;
  210. if (photoIndex > 0) {
  211. this.handleIndexChange(photoIndex - 1, shouldTransition);
  212. }
  213. };
  214. handleNext = (shouldTransition?: boolean) => {
  215. const { images } = this.props;
  216. const { photoIndex } = this.state;
  217. if (photoIndex < images.length - 1) {
  218. this.handleIndexChange(photoIndex + 1, shouldTransition);
  219. }
  220. };
  221. handleReachMove = (reachState: ReachTypeEnum, clientX: number, clientY: number, scale?: number) => {
  222. if (reachState === ReachTypeEnum.XReach) {
  223. this.handleReachHorizontalMove(clientX);
  224. } else if (reachState === ReachTypeEnum.YReach) {
  225. this.handleReachVerticalMove(clientY, scale);
  226. }
  227. };
  228. handleReachUp = (clientX: number, clientY: number) => {
  229. const { images } = this.props;
  230. const { lastClientX = clientX, lastClientY = clientY, photoIndex, overlayVisible, canPullClose } = this.state;
  231. const offsetClientX = clientX - lastClientX;
  232. const offsetClientY = clientY - lastClientY;
  233. let willClose = false;
  234. // 下一张
  235. if (offsetClientX < -maxMoveOffset && photoIndex < images.length - 1) {
  236. this.handleIndexChange(photoIndex + 1);
  237. return;
  238. }
  239. // 上一张
  240. if (offsetClientX > maxMoveOffset && photoIndex > 0) {
  241. this.handleIndexChange(photoIndex - 1);
  242. return;
  243. }
  244. const singlePageWidth = window.innerWidth + horizontalOffset;
  245. // 当前偏移
  246. let currentTranslateX = -singlePageWidth * photoIndex;
  247. let currentPhotoIndex = photoIndex;
  248. if (Math.abs(offsetClientY) > window.innerHeight * 0.14 && canPullClose) {
  249. willClose = true;
  250. this.handleClose();
  251. }
  252. this.setState({
  253. touched: false,
  254. translateX: currentTranslateX,
  255. photoIndex: currentPhotoIndex,
  256. lastClientX: undefined,
  257. lastClientY: undefined,
  258. backdropOpacity: defaultOpacity,
  259. overlayVisible: willClose ? true : overlayVisible,
  260. });
  261. };
  262. render() {
  263. const {
  264. images,
  265. visible,
  266. className,
  267. maskClassName,
  268. viewClassName,
  269. imageClassName,
  270. bannerVisible,
  271. introVisible,
  272. overlayRender,
  273. loadingElement,
  274. brokenElement,
  275. } = this.props;
  276. const {
  277. translateX,
  278. touched,
  279. photoIndex,
  280. backdropOpacity,
  281. lastBackdropOpacity,
  282. overlayVisible,
  283. shouldTransition,
  284. } = this.state;
  285. const imageLength = images.length;
  286. const currentImage = images.length ? images[photoIndex] : undefined;
  287. const transform = `translate3d(${translateX}px, 0px, 0)`;
  288. // Overlay
  289. const overlayIntro = currentImage && currentImage.intro;
  290. return (
  291. <VisibleAnimationHandle visible={visible} currentImage={currentImage}>
  292. {({ photoVisible, showAnimateType, originRect, onShowAnimateEnd }) => {
  293. if (photoVisible) {
  294. const { innerWidth } = window;
  295. const currentOverlayVisible = overlayVisible && showAnimateType === ShowAnimateEnum.None;
  296. // 关闭过程中使用下拉保存的透明度
  297. const currentOpacity = visible ? backdropOpacity : lastBackdropOpacity;
  298. return (
  299. <SlideWrap
  300. className={classNames(
  301. {
  302. 'PhotoView-PhotoSlider__clean': !currentOverlayVisible,
  303. 'PhotoView-PhotoSlider__willClose': !visible,
  304. },
  305. className,
  306. )}
  307. role="dialog"
  308. onClick={e => e.stopPropagation()}
  309. >
  310. <div
  311. className={classNames('PhotoView-PhotoSlider__Backdrop', maskClassName, {
  312. 'PhotoView-PhotoSlider__fadeIn': showAnimateType === ShowAnimateEnum.In,
  313. 'PhotoView-PhotoSlider__fadeOut': showAnimateType === ShowAnimateEnum.Out,
  314. })}
  315. style={{
  316. background: `rgba(0, 0, 0, ${currentOpacity})`,
  317. }}
  318. onAnimationEnd={onShowAnimateEnd}
  319. />
  320. {bannerVisible && (
  321. <div className="PhotoView-PhotoSlider__BannerWrap">
  322. <div className="PhotoView-PhotoSlider__Counter">
  323. {photoIndex + 1} / {imageLength}
  324. </div>
  325. <div className="PhotoView-PhotoSlider__BannerRight">
  326. <Close className="PhotoView-PhotoSlider__Close" onClick={this.handleClose} />
  327. </div>
  328. </div>
  329. )}
  330. {images
  331. .slice(
  332. // 加载相邻三张
  333. Math.max(photoIndex - 1, 0),
  334. Math.min(photoIndex + 2, imageLength + 1),
  335. )
  336. .map((item: dataType, index) => {
  337. // 截取之前的索引位置
  338. const realIndex = photoIndex === 0 ? photoIndex + index : photoIndex - 1 + index;
  339. return (
  340. <PhotoView
  341. key={item.key || realIndex}
  342. src={item.src}
  343. onReachMove={this.handleReachMove}
  344. onReachUp={this.handleReachUp}
  345. onPhotoTap={this.handlePhotoTap}
  346. onMaskTap={this.handlePhotoMaskTap}
  347. viewClassName={viewClassName}
  348. className={imageClassName}
  349. style={{
  350. left: `${(innerWidth + horizontalOffset) * realIndex}px`,
  351. WebkitTransform: transform,
  352. transform,
  353. transition:
  354. touched || !shouldTransition
  355. ? undefined
  356. : 'transform 0.6s cubic-bezier(0.25, 0.8, 0.25, 1)',
  357. }}
  358. loadingElement={loadingElement}
  359. brokenElement={brokenElement}
  360. onPhotoResize={this.handleResize}
  361. isActive={photoIndex === realIndex}
  362. showAnimateType={showAnimateType}
  363. originRect={originRect}
  364. />
  365. );
  366. })}
  367. {!isTouchDevice && bannerVisible && (
  368. <>
  369. {photoIndex !== 0 && (
  370. <div className="PhotoView-PhotoSlider__ArrowLeft" onClick={() => this.handlePrevious(false)}>
  371. <ArrowLeft />
  372. </div>
  373. )}
  374. {photoIndex + 1 < imageLength && (
  375. <div className="PhotoView-PhotoSlider__ArrowRight" onClick={() => this.handleNext(false)}>
  376. <ArrowRight />
  377. </div>
  378. )}
  379. </>
  380. )}
  381. {Boolean(introVisible && overlayIntro) && (
  382. <div className="PhotoView-PhotoSlider__FooterWrap">{overlayIntro}</div>
  383. )}
  384. {overlayRender &&
  385. overlayRender({
  386. images,
  387. index: photoIndex,
  388. visible,
  389. onClose: this.handleClose,
  390. onIndexChange: this.handleIndexChange,
  391. overlayVisible: currentOverlayVisible,
  392. })}
  393. </SlideWrap>
  394. );
  395. }
  396. return null;
  397. }}
  398. </VisibleAnimationHandle>
  399. );
  400. }
  401. }