PhotoSlider.tsx 13 KB

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