Kaynağa Gözat

图片动画调整

MinJieLiu 7 yıl önce
ebeveyn
işleme
ae8bdef579
7 değiştirilmiş dosya ile 288 ekleme ve 61 silme
  1. 0 1
      examples/simple.tsx
  2. 39 21
      src/Photo.tsx
  3. 72 36
      src/PhotoView.tsx
  4. 9 3
      src/Spinner.tsx
  5. 25 0
      src/StyledElements.tsx
  6. 134 0
      src/util.ts
  7. 9 0
      src/variables.ts

+ 0 - 1
examples/simple.tsx

@@ -1,4 +1,3 @@
-import 'babel-polyfill';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import styled from 'styled-components';

+ 39 - 21
src/Photo.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
-import styled from 'styled-components';
 import Spinner from './Spinner';
+import { getSuitableImageSize } from './util';
 
 export interface IPhotoProps {
   src: string;
@@ -14,24 +14,40 @@ type ImageProps = {
 
 type PhotoState = ImageProps & {
   broken: boolean;
+  naturalWidth: number;
+  naturalHeight: number;
+  width: number;
+  height: number;
 };
 
-const Image = styled.img<ImageProps>`
-  opacity: ${props => +props.loaded};
-  transition: opacity 0.4s ease-out;
-`;
-
 export default class Photo extends React.Component<IPhotoProps, PhotoState> {
   static displayName = 'Photo';
 
   readonly state = {
     loaded: false,
     broken: false,
+    naturalWidth: 0,
+    naturalHeight: 0,
+    width: 0,
+    height: 0,
   };
 
-  handleImageLoaded = () => {
+  constructor(props) {
+    super(props);
+
+    const currPhoto = new Image();
+    currPhoto.src = props.src;
+    currPhoto.onload = this.handleImageLoaded;
+    currPhoto.onerror = this.handleImageBroken;
+  }
+
+  handleImageLoaded = (e) => {
+    const { naturalWidth, naturalHeight } = e.target;
     this.setState({
       loaded: true,
+      naturalWidth,
+      naturalHeight,
+      ...getSuitableImageSize(naturalWidth, naturalHeight),
     });
   }
 
@@ -43,19 +59,21 @@ export default class Photo extends React.Component<IPhotoProps, PhotoState> {
 
   render() {
     const { src, loadingElement, brokenElement, ...restProps } = this.props;
-    const { loaded, broken } = this.state;
-
-    return src && !broken ? (
-      <React.Fragment>
-        {loaded ? undefined : loadingElement || <Spinner fill="white" />}
-        <Image
-          src={src}
-          {...restProps}
-          loaded={loaded}
-          onLoad={this.handleImageLoaded}
-          onError={this.handleImageBroken}
-        />
-      </React.Fragment>
-    ) : brokenElement || null;
+    const { loaded, broken, width, height } = this.state;
+
+    if (src && !broken) {
+      if (loaded) {
+        return (
+          <img
+            src={src}
+            width={width}
+            height={height}
+            {...restProps}
+          />
+        );
+      }
+      return loadingElement || <Spinner />;
+    }
+    return brokenElement || null;
   }
 }

+ 72 - 36
src/PhotoView.tsx

@@ -2,50 +2,46 @@ import React from 'react';
 import styled from 'styled-components';
 import throttle from 'lodash.throttle';
 import Photo from './Photo';
+import { PhotoContainer, Backdrop } from './StyledElements';
+import { slideToPosition, jumpToSuitableOffset } from './util';
+import { animationDefault, animationTimeBase } from './variables';
 
 export interface IPhotoViewProps {
   src: string;
 }
 
 type PhotoViewState = {
+  // 图片 X 偏移量
   x: number;
+  // 图片 y 偏移量
   y: number;
+  // 图片缩放程度
   scale: number;
+  // 图片处于触摸的状态
   touched: boolean;
-
+  // 触摸开始时 x 原始坐标
   pageX: number;
+  // 触摸开始时 y 原始坐标
   pageY: number;
+  // 触摸开始时图片 x 偏移量
   offsetX: number;
+  // 触摸开始时图片 y 偏移量
   offsetY: number;
+  // 触摸开始时时间
+  touchedTime: number;
+  // 动画名称
+  animationName: string | null;
+  // 动画时间
+  animationTime: number;
 };
 
-interface DragPhotoProps extends React.HTMLAttributes<any> {}
-
-const PhotoContainer = styled.section`
-  position: fixed;
-  top: 0;
-  left: 0;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  width: 100%;
-  height: 100%;
-  z-index: 2000;
-  overflow: hidden;
-`;
-
-const Backdrop = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background: rgba(0, 0, 0, 0.4);
-  z-index: -1;
-`;
-
-const DragPhoto = styled(Photo)<DragPhotoProps>`
+const DragPhoto = styled(Photo)<React.HTMLAttributes<any>>`
   will-change: transform;
+  cursor: -webkit-grab;
+
+  &:active {
+    cursor: -webkit-grabbing;
+  }
 `;
 
 export default class PhotoView extends React.Component<
@@ -62,8 +58,13 @@ export default class PhotoView extends React.Component<
     pageY: 0,
     offsetX: 0,
     offsetY: 0,
+    touchedTime: 0,
+    animationName: animationDefault,
+    animationTime: animationTimeBase,
   };
 
+  private photoRef;
+
   constructor(props) {
     super(props);
     this.handleMove = throttle(this.handleMove, 8);
@@ -91,21 +92,26 @@ export default class PhotoView extends React.Component<
       pageY,
       offsetX: prevState.x,
       offsetY: prevState.y,
+      touchedTime: Date.now(),
     }));
   }
 
-  handleMove = (pageX, pageY) => {
+  handleMove = (newPageX, newPageY) => {
     if (this.state.touched) {
-      this.setState((prevState) => ({
-        x: pageX - prevState.pageX + prevState.offsetX,
-        y: pageY - prevState.pageY + prevState.offsetY,
-      }));
+      this.setState(({ pageX, pageY, offsetX, offsetY }) => {
+        return {
+          x: newPageX - pageX + offsetX,
+          y: newPageY - pageY + offsetY,
+        };
+      });
     }
   }
 
   handleDoubleClick = () => {
     this.setState(prevState => ({
-      scale: prevState.scale > 1 ? 1 : 2,
+      scale: prevState.scale > 1 ? 1 : 3,
+      animationName: animationDefault,
+      animationTime: animationTimeBase,
     }));
   }
 
@@ -128,14 +134,40 @@ export default class PhotoView extends React.Component<
   }
 
   handleMouseUp = () => {
-    this.setState({
-      touched: false,
+    const { width, height } = this.photoRef.state;
+    this.setState(({
+      x,
+      y,
+      offsetX,
+      offsetY,
+      scale,
+      touchedTime,
+    }) => {
+      return {
+        touched: false,
+        ...jumpToSuitableOffset({
+          width,
+          height,
+          scale,
+          ...slideToPosition({
+            x,
+            y,
+            offsetX,
+            offsetY,
+            touchedTime,
+          }),
+        }),
+      };
     });
   }
 
+  handlePhotoRef = (ref) => {
+    this.photoRef = ref;
+  }
+
   render() {
     const { src } = this.props;
-    const { x, y, scale } = this.state;
+    const { x, y, scale, touched, animationName, animationTime } = this.state;
     const transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
 
     return (
@@ -143,12 +175,16 @@ export default class PhotoView extends React.Component<
         <Backdrop />
         <DragPhoto
           src={src}
+          innerRef={this.handlePhotoRef}
           onDoubleClick={this.handleDoubleClick}
           onMouseDown={this.handleMouseDown}
           onTouchStart={this.handleTouchStart}
           style={{
             WebkitTransform: transform,
             transform,
+            transition: touched
+              ? undefined
+              : `transform ${animationTime}ms ${animationName}`,
           }}
         />
       </PhotoContainer>

+ 9 - 3
src/Spinner.tsx

@@ -1,8 +1,14 @@
 import React from 'react';
 
-export default function Spinner(props) {
+export default function Spinner() {
   return (
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="64" height="64" {...props}>
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 32 32"
+      width="64"
+      height="64"
+      fill="white"
+    >
       <path
         opacity=".25"
         d="M16 0 A16 16 0 0 0 16 32 A16 16 0 0 0 16 0 M16 4 A12 12 0 0 1 16 28 A12 12 0 0 1 16 4"
@@ -13,7 +19,7 @@ export default function Spinner(props) {
           type="rotate"
           from="0 16 16"
           to="360 16 16"
-          dur="0.4s"
+          dur="0.6s"
           repeatCount="indefinite"
         />
       </path>

+ 25 - 0
src/StyledElements.tsx

@@ -0,0 +1,25 @@
+import React from 'react';
+import styled from 'styled-components';
+
+export const PhotoContainer = styled.section`
+  position: fixed;
+  top: 0;
+  left: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  z-index: 2000;
+  overflow: hidden;
+`;
+
+export const Backdrop = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.4);
+  z-index: -1;
+`;

+ 134 - 0
src/util.ts

@@ -0,0 +1,134 @@
+import { animationTimeBase } from './variables';
+
+/**
+ * 获取图片合适的大小
+ * @param naturalWidth 图片真实宽度
+ * @param naturalHeight 图片真实高度
+ * @return 图片合适的大小
+ */
+export const getSuitableImageSize = (
+  naturalWidth: number,
+  naturalHeight: number,
+): {
+  width: number;
+  height: number;
+} => {
+  let width = 0;
+  let height = 0;
+  const { innerWidth, innerHeight } = window;
+  if (naturalWidth < innerWidth && naturalHeight < innerHeight) {
+    width = naturalWidth;
+    height = naturalHeight;
+  } else if (naturalWidth < innerWidth && naturalHeight >= innerHeight) {
+    width = (naturalWidth / naturalHeight) * innerHeight;
+    height = innerHeight;
+  } else if (naturalWidth >= innerWidth && naturalHeight < innerHeight) {
+    width = innerWidth;
+    height = (naturalHeight / naturalWidth) * innerWidth;
+  } else if (
+    naturalWidth >= innerWidth &&
+    naturalHeight >= innerHeight &&
+    naturalWidth / naturalHeight > innerWidth / innerHeight
+  ) {
+    width = innerWidth;
+    height = (naturalHeight / naturalWidth) * innerWidth;
+  } else {
+    width = (naturalWidth / naturalHeight) * innerHeight;
+    height = innerHeight;
+  }
+  return {
+    width: Math.floor(width),
+    height: Math.floor(height),
+  };
+};
+
+export const slideToPosition = ({
+  x,
+  y,
+  offsetX,
+  offsetY,
+  touchedTime,
+}: {
+  x: number;
+  y: number;
+  offsetX: number;
+  offsetY: number;
+  touchedTime: number;
+}): {
+  endX: number;
+  endY: number;
+  slideTime: number;
+} => {
+  const moveTime = Date.now() - touchedTime;
+  const speedX = (x - offsetX) / moveTime;
+  const speedY = (y - offsetY) / moveTime;
+  const maxSpeed = Math.max(speedX, speedY);
+  const currentAnimationTime =
+    Math.abs(maxSpeed < 200 ? maxSpeed : 200) * animationTimeBase +
+    animationTimeBase;
+  return {
+    endX: Math.floor(x + speedX * currentAnimationTime),
+    endY: Math.floor(y + speedY * currentAnimationTime),
+    slideTime: Math.floor(currentAnimationTime),
+  };
+};
+
+/**
+ * 跳转到合适的图片偏移量
+ * @param endX x 滑动后距离
+ * @param endY y 滑动后距离
+ * @param width 图片宽度
+ * @param height 图片高度
+ * @param scale 缩放
+ * @param slideTime 动画时间
+ * @return 坐标
+ */
+export const jumpToSuitableOffset = ({
+  endX,
+  endY,
+  width,
+  height,
+  scale,
+  slideTime,
+}: {
+  endX: number;
+  endY: number;
+  width: number;
+  height: number;
+  scale: number;
+  slideTime: number;
+}): {
+  x: number;
+  y: number;
+  animationTime: number;
+} => {
+  const { innerWidth, innerHeight } = window;
+  const outOffsetX = (width * scale - innerWidth) / 2;
+  const outOffsetY = (height * scale - innerHeight) / 2;
+
+  let currentX = endX;
+  let currentY = endY;
+
+  if (width * scale <= innerWidth) {
+    currentX = 0;
+  } else if (endX > 0 && outOffsetX - endX <= 0) {
+    currentX = outOffsetX;
+  } else if (endX < 0 && outOffsetX + endX <= 0) {
+    currentX = -outOffsetX;
+  }
+  if (height * scale <= innerHeight) {
+    currentY = 0;
+  } else if (endY > 0 && outOffsetY - endY <= 0) {
+    currentY = outOffsetY;
+  } else if (endY < 0 && outOffsetY + endY <= 0) {
+    currentY = -outOffsetY;
+  }
+
+  const isSlide = currentX === endX || currentY === endY;
+
+  return {
+    x: currentX,
+    y: currentY,
+    animationTime: isSlide ? slideTime : animationTimeBase,
+  };
+};

+ 9 - 0
src/variables.ts

@@ -0,0 +1,9 @@
+/**
+ * 默认动画
+ */
+export const animationDefault: string = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
+
+/**
+ * 基础动画时间
+ */
+export const animationTimeBase: number = 400;