美文网首页
React Hooks + TypeScript 做个仿 Mac

React Hooks + TypeScript 做个仿 Mac

作者: 不讨喜的大雄 | 来源:发表于2020-06-12 22:01 被阅读0次

    这是我的项目记录系列文章第三篇,目前项目进度有些停滞,主要是最近其他事情比较多加懒,于是我强行让自己在这几天对点击图标跳出弹窗这一过程进行优化,及时总结和记录,同时让大家知道我还活着。

    本篇将介绍目前项目当中,点击 Dock 图标所产生的系列效果,如生成可拖住的弹窗等,目前只有计算器和画板等四个图标可用。

    本文所有代码均在 项目代码,项目会一直优化,欢迎 watch 和 star。

    过程分析

    上篇我们已经实现 Dock 的动态效果,接下来我们肯定会不由自主想点图标。当我们点击图标,首先会出现图标弹跳的动效,然后出现图标对应应用弹框,并同时在图标下方出现高亮小圆点。接下来我会用画板 drawing 作为例子展示代码,关于画板的详细内容本篇暂不作介绍,预计会成为第四篇主角。

    本文出现代码内容对应目录:

    图标点击交互

    动效实现

    当我们初次点击图标使其变成激活状态时,应该有交互动画:

    这里我参考了 animate-css 的 bounce.css

    // footer/index.scss
    @keyframes bounce {
      from,
      20%,
      53%,
      to {
        animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
        transform: translate3d(0, 0, 0);
      }
    
      40%,
      43% {
        animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
        transform: translate3d(0, -35px, 0) scaleY(1.1);
      }
    
      70% {
        animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
        transform: translate3d(0, -35px, 0) scaleY(1.05);
      }
    
      80% {
        transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
        transform: translate3d(0, 0, 0) scaleY(0.95);
      }
    
      90% {
        transform: translate3d(0, -6px, 0) scaleY(1.02);
      }
    }
    .bounce {
      animation-duration: 2s;
      animation-name: top; 
    }
    

    isDrawingOpen(应用开启、关闭)和 isDrawingShow(应用展示、最小化)

    给图标加上点击事件,通过其名字判断是哪个图标。每一个图标我们给到一个布尔值对象,如这里的 isDrawingOpen,它是个对象,里面记录一个布尔值 type,作为弹框开关(只有在打开和关闭应用时使用);一个 index 记录图标对应顺序。

    点击后给对应图标增加 .bounce,此时图标开始 bounce 动画,同时我们在 2.5s 后改变 type (画板出现)和记录 index,并且将类选择器移除,便于下次重新点击使用。

    // Footer.tsx
    interface OpenTypes {
      type: boolean;
      index?: number;
    }
    
    const [isDrawingOpen, setDrawingOpen] = useState<OpenTypes>({
      type: false
    });
    
    const [isDrawingShow, setDrawingShow] = useState(true);
    
    const dockItemClick = useCallback(
      (item: string, index: number) => {
        if (!dockRef.current) {
          return;
        }
        const imgList = dockRef.current.childNodes;
        const img = imgList[index] as HTMLDivElement;
        switch (item) {
          case "PrefApp.png":
            if (!isDrawingOpen.type) {
              img.classList.add("bounce");
              setTimeout(() => {
                setDrawingOpen({ type: !isDrawingOpen.type, index });
                img.classList.remove("bounce");
              }, 2500);
              return;
            }
            setDrawingShow(!isDrawingShow);
            return;
        }
      },
      [isDrawingOpen, isDrawingShow]
    );
    

    与此同时可以看到有一个单独的布尔值:isDrawingShow,它的作用是在应用激活时点击图标或最小化按钮时切换展示状态。

    useEffect(() => {
      if (!dockRef.current) {
        return;
      }
      const imgList = dockRef.current.childNodes;
      [isDrawingOpen].forEach((item) => {
        if (item.index) {
          const img = imgList[item.index] as HTMLDivElement;
          !item.type
            ? setTimeout(() => {
                img?.classList.remove("active");
              }, 1000)
            : img.classList.add("active");
        }
      });
    }, [isDrawingOpen]);
    

    上面就是我们记录 index 的作用,由于关闭应用不受 Dock 控制,我们需要监听 isDrawingOpen 来判断是否加类选择器 active,它的作用主要是图标高亮小圆点的开关

    小圆点的实现

    // footer/index.scss
    
    #DockItem {
      position: relative;
      display: flex;
      &.active {
        &::after {
          content: "●";
          font-size: 0.1em;
          position: absolute;
          bottom: -7px;
        }
      }
    }
    

    createContext 实现组件通信:

    这里我们的画板组件肯定是单独成文件的,因此开启和关闭弹窗操作就要用到组件通信。

    export const FooterContext = createContext<any>([]);
    ...
    return (
       <React.Fragment>
        <FooterContext.Provider
          value={[isDrawingOpen, setDrawingOpen, isDrawingShow, setDrawingShow]}
          >
          <Drawing />
        </FooterContext.Provider>
        <div ref={dockRef} style={{ height: defaultWidth }}>
          {dockList.map((item, index) => {
            return (
              <div
                id="DockItem"
                style={
                  {
                    backgroundImage: "url(" + require("./image/" + item) + ")",
                    backgroundPosition: "center",
                    backgroundSize: "cover",
                    backgroundRepeat: "no-repeat",
                  } as CSSProperties
                }
                key={index + item}
                onClick={() => dockItemClick(item, index)}
              />
            );
          })}
        </div>
      </React.Fragment>
    );
    

    看过该系列 第二篇 的朋友或许还记得,之前我们的图标均为 img ,而现在改为了 div,其主要目的是为了配合 active 下的伪元素使用(img 使用 ::after 无效)。

    我们通过 createContext 生成一个 FooterContext,像我们的 Drawing 子组件传递 [isDrawingOpen, setDrawingOpen, isDrawingShow, setDrawingShow] ,同时子组件可以调用 FooterContext,改变应用状态。

    下面是子组件 Drawing 使用 FooterContext 的完整代码:

    // drawing/index.tsx
    import React, { useContext, useEffect, useState, useCallback } from "react";
    import { useModal } from "../modal/UseModal";
    import { FooterContext } from "../footer/Footer";
    import { TitleBar } from "react-desktop/macOs";
    import Canvas from "./Canvas";
    import "./index.scss";
    /// <reference path="react-desktop.d.ts" />
    
    export const Drawing = React.memo(() => {
      const { open, close, RenderModal } = useModal();
      const [
        isDrawingOpen,
        setDrawingOpen,
        isDrawingShow,
        setDrawingShow,
      ] = useContext(FooterContext);
      const [style, setStyle] = useState({ width: 1200, height: 800 });
      const [isFullscreen, setFullscreen] = useState(false);
    
      useEffect(isDrawingOpen.type ? open : close, [isDrawingOpen]);
      const maximizeClick = useCallback(() => {
        if (isFullscreen) {
          setStyle({ width: 1200, height: 800 });
        } else {
          setStyle({ width: -1, height: -1 });
        }
        setFullscreen(!isFullscreen);
      }, [isFullscreen]);
    
      return (
        <RenderModal
          data={{
            width: style.width,
            height: style.height,
            id: "DrawingView",
            moveId: "DrawingMove",
            isShow: isDrawingShow,
          }}
        >
          <div className="drawing-wrapper">
            <TitleBar
              controls
              id="DrawingMove"
              isFullscreen={isFullscreen}
              onCloseClick={() => {
                close();
                setDrawingOpen({ ...isDrawingOpen, type: false });
              }}
              onMinimizeClick={() => {
                setDrawingShow(false);
              }}
              onMaximizeClick={maximizeClick}
              onResizeClick={maximizeClick}
            ></TitleBar>
            <Canvas
              height={isFullscreen ? document.body.clientHeight - 32 : style.height}
              width={isFullscreen ? document.body.clientWidth : style.width}
            />
          </div>
        </RenderModal>
      );
    });
    

    这里的 useModal 是一个弹框组件,下文详解。Canvas 是 drawing 的主体,这里我们不过多介绍。

    react-desktop/macOs 的使用及自定义声明文件

    可以看到我使用了 react-desktop/macOs 组件,一个 react 的桌面 UI ,但是这个库没有 @types ,需要自己写 .d.ts:

    // tsconfig.json
    {
      "compilerOptions": {
        "baseUrl": "./",
        "target": "es5",
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        "esModuleInterop": true,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react"
      },
      "include": ["src", "typings"] // 主要是这里加了 typings
    }
    
    // typings/react-desktop.d.ts
    declare module "react-desktop/macOs" {
      export const View: JSX;
      export const Radio: JSX;
      export const TitleBar: JSX;
      export const Toolbar: JSX;
      export const Text: JSX;
      export const Box: JSX;
      export const ListView: JSX;
      export const ListViewRow: JSX;
      export const Window: JSX;
      export const Dialog: JSX;
      export const Button: JSX;
    }
    

    然后通过下面方式引入,就可以在 TypeScript 内使用了

    /// <reference path="react-desktop.d.ts" />
    

    TitleBar

    我们继续看我们的 drawing/index.tsx,这里主要用到了 TitleBar

    可以看到 useModal 里释出了 open, close, RenderModal,其中 RenderModal 就是一会讲到的 弹窗,前两个就是控制弹窗的开关。

    我们点击红色的关闭时,会调用父组件传过来的 isDrawingOpen, setDrawingOpen;而黄色的最小化按钮则调用 setDrawingShow(false),这里我们直接设置为 false 因为再次展示是通过点击图标,最小化时高亮点不应该去除;maximizeClick 函数用于绿色最大化按钮,其中我用 width 和 height 是 -1 告诉 Modal 全屏,弹窗及其拖拽需要包括他俩再内的 data 所传递过去的值。

    用 Portal 实现弹窗组件

    项目的每个小应用本质上是个弹窗,因此实现一个可复用的组件十分必要,得益于 Portal ,我们能快速实现。
    我直接复用了 这篇文章 里的 React Hooks 版本 Portal 实现方式。

    可拖拽弹窗:

    // 代码篇幅较长,可以先看上面参考博客内版本
    // Modal.tsx
    import ReactDOM from "react-dom";
    import React, {
      useState,
      useCallback,
      useMemo,
      useEffect,
      CSSProperties,
    } from "react";
    
    type Props = {
      children: React.ReactChild;
      closeModal: () => void;
      onDrag: (T: any) => void;
      onDragEnd: () => void;
      data: {
        width: number;
        height: number;
        id: string;
        moveId: string;
        isShow: boolean;
      };
    };
    
    const Modal = React.memo(
      ({ children, closeModal, onDrag, onDragEnd, data }: Props) => {
        const domEl = document.getElementById("main-view") as HTMLDivElement;
        if (!domEl) return null;
        const dragEl = document.getElementById(data.id) as HTMLDivElement;
        const moveEl = document.getElementById(data.moveId) as HTMLDivElement;
        const localPosition = localStorage.getItem(data.id) || null;
        const initPosition = localPosition
          ? JSON.parse(localPosition)
          : {
              x: data.width === -1 ? 0 : (window.innerWidth - data.width) / 2,
              y: data.height === -1 ? 0 : (window.innerHeight - data.height) / 2,
            };
        const [state, setState] = useState({
          isDragging: false,
          origin: { x: 0, y: 0 },
          position: initPosition,
        });
    
        const handleMouseDown = useCallback(({ clientX, clientY }) => {
          setState((state) => ({
            ...state,
            isDragging: true,
            origin: {
              x: clientX - state.position.x,
              y: clientY - state.position.y,
            },
          }));
        }, []);
    
        const handleMouseMove = useCallback(
          ({ clientX, clientY, target }) => {
            if (!state.isDragging || (moveEl && target !== moveEl)) return;
            let x = clientX - state.origin.x;
            let y = clientY - state.origin.y;
            if (x <= 0) {
              x = 0;
            } else if (x > window.innerWidth - dragEl.offsetWidth) {
              x = window.innerWidth - dragEl.offsetWidth;
            }
            if (y <= 0) {
              y = 0;
            } else if (y > window.innerHeight - dragEl.offsetHeight) {
              y = window.innerHeight - dragEl.offsetHeight;
            }
            const newPosition = { x, y };
            setState((state) => ({
              ...state,
              position: newPosition,
            }));
            onDrag({ newPosition, domEl });
          },
          [state.isDragging, state.origin, moveEl, dragEl, onDrag, domEl]
        );
    
        const handleMouseUp = useCallback(() => {
          if (state.isDragging) {
            setState((state) => ({
              ...state,
              isDragging: false,
            }));
    
            onDragEnd();
          }
        }, [state.isDragging, onDragEnd]);
    
        useEffect(() => {
          if (data.width === -1) {
            setState({
              isDragging: false,
              origin: { x: 0, y: 0 },
              position: { x: 0, y: 0 },
            });
          }
        }, [data.width]);
    
        useEffect(() => {
          if (!domEl) return;
          domEl.addEventListener("mousemove", handleMouseMove);
          domEl.addEventListener("mouseup", handleMouseUp);
          return () => {
            domEl.removeEventListener("mousemove", handleMouseMove);
            domEl.removeEventListener("mouseup", handleMouseUp);
            if (data.width !== -1) {
              localStorage.setItem(data.id, JSON.stringify(state.position));
            }
          };
        }, [
          domEl,
          handleMouseMove,
          handleMouseUp,
          data.id,
          data.width,
          state.position,
        ]);
    
        const styles = useMemo(
          () => ({
            left: `${state.position.x}px`,
            top: `${state.position.y}px`,
            zIndex: state.isDragging ? 2 : 1,
            display: data.isShow ? "block" : "none",
            position: "absolute",
          }),
          [state.isDragging, state.position, data.isShow]
        );
    
        return ReactDOM.createPortal(
          <div
            style={styles as CSSProperties}
            onMouseDown={handleMouseDown}
            id={data.id}
          >
            {children}
          </div>,
          domEl
        );
      }
    );
    

    可以看到我在 Modal.tsx 中加入了拖拽的功能,代码篇幅很长,但原理其实比较简单,可以先看参考博客中的纯 Modal 版本后在看加入拖拽代码的版本。

    这里我直接展示了完整代码,原本打算像第二篇讲动效那样介绍,但事实上两者思路十分相似,都是通过 useEffect 监听鼠标事件,那么我简单介绍下思路,便于理解:

    首先我们看到有三个 dom元素 domEl、dragEl 、moveEl:domEl 和参考文章中一样,主要是弹窗出现的 dom,我将它加在了 APP.tsx 内;dragEl 就代表了 应用主体 dom(这里就是 Drawing);moveEl 则是应用组件内部可拖拽部分,一般是 TitleBar。

    由于模拟应用,我们需要记录应用当前位置,所以用到了 localStorage,initPosition 初始化应用位置,通过 -1 判断是否全屏。

    state 用于记录鼠标数据及是否可拖拽;handleMouseDown 记录下当前鼠标坐标,并开启拖拽;handleMouseMove 计算出移动位移,赋值给 position,需要注意边界情况,当然这里我简化了操作,直接不允许出屏了;handleMouseUp 关闭拖拽;closeModal, onDrag, onDragEnd 分别是弹窗内部关闭函数,可附加的拖拽事件和停止事件。
    以上就是弹框组件及拖拽的主要思路了。

    UseModal

    UseModal 基本和文中一致:

    // UseModal.tsx
    import React, { useState } from "react";
    
    import Modal from "./Modal";
    
    // Modal组件最基础的两个事件,open/close
    export const useModal = () => {
      const [isVisible, setIsVisible] = useState(false);
    
      const open = () => setIsVisible(true);
      const close = () => setIsVisible(false);
    
      const RenderModal = ({
        children,
        data,
      }: {
        children: React.ReactChild;
        data: {
          width: number;
          height: number;
          id: string;
          moveId: string;
          isShow: boolean;
        };
      }) => (
        <React.Fragment>
          {isVisible && (
            <Modal
              data={data}
              closeModal={close}
              onDrag={() => console.log("onDrag")}
              onDragEnd={() => console.log("onDragEnd")}
            >
              {children}
            </Modal>
          )}
        </React.Fragment>
      );
    
      return {
        open,
        close,
        RenderModal,
      };
    };
    

    如何使用该组件我们上文已讲到,如果你忘了可以回看。

    至此,我们已经完成了开篇的过程分析了。

    小结

    本篇文章介绍了项目从点击 Dock 呈现应用到关闭应用的过程实现,里面有较多细节,值得反复回味与优化。

    此篇相对前两篇较长,能看到这里都是真爱(学习和我)。既然如此,不如给我点个赞吧🍮。

    目前该项目已完成部分功能,包括简单设置,基础计算器,基础画板等,即使是这些已有功能也有很多需要完善的地方。

    后续我会慢慢优化,并在相应模块代码优化到一定程度时不定时更新系列文章。

    相关文章

      网友评论

          本文标题:React Hooks + TypeScript 做个仿 Mac

          本文链接:https://www.haomeiwen.com/subject/hknmtktx.html