這是我的項(xiàng)目記錄系列文章第三篇嫌佑,目前項(xiàng)目進(jìn)度有些停滯豆茫,主要是最近其他事情比較多加懶,于是我強(qiáng)行讓自己在這幾天對點(diǎn)擊圖標(biāo)跳出彈窗這一過程進(jìn)行優(yōu)化屋摇,及時總結(jié)和記錄揩魂,同時讓大家知道我還活著。
本篇將介紹目前項(xiàng)目當(dāng)中炮温,點(diǎn)擊 Dock 圖標(biāo)所產(chǎn)生的系列效果火脉,如生成可拖住的彈窗等,目前只有計(jì)算器和畫板等四個圖標(biāo)可用柒啤。
本文所有代碼均在 項(xiàng)目代碼倦挂,項(xiàng)目會一直優(yōu)化,歡迎 watch 和 star担巩。
過程分析
上篇我們已經(jīng)實(shí)現(xiàn) Dock 的動態(tài)效果方援,接下來我們肯定會不由自主想點(diǎn)圖標(biāo)。當(dāng)我們點(diǎn)擊圖標(biāo)涛癌,首先會出現(xiàn)圖標(biāo)彈跳的動效犯戏,然后出現(xiàn)圖標(biāo)對應(yīng)應(yīng)用彈框窥浪,并同時在圖標(biāo)下方出現(xiàn)高亮小圓點(diǎn)。接下來我會用畫板 drawing 作為例子展示代碼笛丙,關(guān)于畫板的詳細(xì)內(nèi)容本篇暫不作介紹漾脂,預(yù)計(jì)會成為第四篇主角。
本文出現(xiàn)代碼內(nèi)容對應(yīng)目錄:
圖標(biāo)點(diǎn)擊交互
動效實(shí)現(xiàn)
當(dāng)我們初次點(diǎn)擊圖標(biāo)使其變成激活狀態(tài)時胚鸯,應(yīng)該有交互動畫:
這里我參考了 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(應(yīng)用開啟骨稿、關(guān)閉)和 isDrawingShow(應(yīng)用展示、最小化)
給圖標(biāo)加上點(diǎn)擊事件姜钳,通過其名字判斷是哪個圖標(biāo)坦冠。每一個圖標(biāo)我們給到一個布爾值對象,如這里的 isDrawingOpen哥桥,它是個對象辙浑,里面記錄一個布爾值 type,作為彈框開關(guān)(只有在打開和關(guān)閉應(yīng)用時使用)拟糕;一個 index 記錄圖標(biāo)對應(yīng)順序判呕。
點(diǎn)擊后給對應(yīng)圖標(biāo)增加 .bounce,此時圖標(biāo)開始 bounce 動畫送滞,同時我們在 2.5s 后改變 type (畫板出現(xiàn))和記錄 index侠草,并且將類選擇器移除,便于下次重新點(diǎn)擊使用犁嗅。
// 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]
);
與此同時可以看到有一個單獨(dú)的布爾值:isDrawingShow边涕,它的作用是在應(yīng)用激活時點(diǎn)擊圖標(biāo)或最小化按鈕時切換展示狀態(tài)。
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 的作用褂微,由于關(guān)閉應(yīng)用不受 Dock 控制功蜓,我們需要監(jiān)聽 isDrawingOpen 來判斷是否加類選擇器 active,它的作用主要是圖標(biāo)高亮小圓點(diǎn)的開關(guān)
小圓點(diǎn)的實(shí)現(xiàn)
// footer/index.scss
#DockItem {
position: relative;
display: flex;
&.active {
&::after {
content: "●";
font-size: 0.1em;
position: absolute;
bottom: -7px;
}
}
}
createContext 實(shí)現(xiàn)組件通信:
這里我們的畫板組件肯定是單獨(dú)成文件的宠蚂,因此開啟和關(guān)閉彈窗操作就要用到組件通信式撼。
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>
);
看過該系列 第二篇 的朋友或許還記得,之前我們的圖標(biāo)均為 img 肥矢,而現(xiàn)在改為了 div端衰,其主要目的是為了配合 active 下的偽元素使用(img 使用 ::after 無效)叠洗。
我們通過 createContext 生成一個 FooterContext甘改,像我們的 Drawing 子組件傳遞 [isDrawingOpen, setDrawingOpen, isDrawingShow, setDrawingShow] ,同時子組件可以調(diào)用 FooterContext灭抑,改變應(yīng)用狀態(tài)十艾。
下面是子組件 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 內(nèi)使用了
/// <reference path="react-desktop.d.ts" />
TitleBar
我們繼續(xù)看我們的 drawing/index.tsx访递,這里主要用到了 TitleBar
可以看到 useModal 里釋出了 open, close, RenderModal晦嵌,其中 RenderModal 就是一會講到的 彈窗,前兩個就是控制彈窗的開關(guān)拷姿。
我們點(diǎn)擊紅色的關(guān)閉時惭载,會調(diào)用父組件傳過來的 isDrawingOpen, setDrawingOpen;而黃色的最小化按鈕則調(diào)用 setDrawingShow(false)响巢,這里我們直接設(shè)置為 false 因?yàn)樵俅握故臼峭ㄟ^點(diǎn)擊圖標(biāo)描滔,最小化時高亮點(diǎn)不應(yīng)該去除;maximizeClick 函數(shù)用于綠色最大化按鈕踪古,其中我用 width 和 height 是 -1 告訴 Modal 全屏含长,彈窗及其拖拽需要包括他倆再內(nèi)的 data 所傳遞過去的值。
用 Portal 實(shí)現(xiàn)彈窗組件
項(xiàng)目的每個小應(yīng)用本質(zhì)上是個彈窗伏穆,因此實(shí)現(xiàn)一個可復(fù)用的組件十分必要茎芋,得益于 Portal ,我們能快速實(shí)現(xiàn)蜈出。
我直接復(fù)用了 這篇文章 里的 React Hooks 版本 Portal 實(shí)現(xiàn)方式田弥。
可拖拽彈窗:
// 代碼篇幅較長,可以先看上面參考博客內(nèi)版本
// 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 中加入了拖拽的功能铡原,代碼篇幅很長偷厦,但原理其實(shí)比較簡單,可以先看參考博客中的純 Modal 版本后在看加入拖拽代碼的版本燕刻。
這里我直接展示了完整代碼只泼,原本打算像第二篇講動效那樣介紹,但事實(shí)上兩者思路十分相似卵洗,都是通過 useEffect 監(jiān)聽鼠標(biāo)事件请唱,那么我簡單介紹下思路,便于理解:
首先我們看到有三個 dom元素 domEl过蹂、dragEl 十绑、moveEl:domEl 和參考文章中一樣,主要是彈窗出現(xiàn)的 dom酷勺,我將它加在了 APP.tsx 內(nèi)本橙;dragEl 就代表了 應(yīng)用主體 dom(這里就是 Drawing);moveEl 則是應(yīng)用組件內(nèi)部可拖拽部分脆诉,一般是 TitleBar甚亭。
由于模擬應(yīng)用贷币,我們需要記錄應(yīng)用當(dāng)前位置,所以用到了 localStorage亏狰,initPosition 初始化應(yīng)用位置役纹,通過 -1 判斷是否全屏。
state 用于記錄鼠標(biāo)數(shù)據(jù)及是否可拖拽暇唾;handleMouseDown 記錄下當(dāng)前鼠標(biāo)坐標(biāo)字管,并開啟拖拽;handleMouseMove 計(jì)算出移動位移信不,賦值給 position嘲叔,需要注意邊界情況,當(dāng)然這里我簡化了操作抽活,直接不允許出屏了硫戈;handleMouseUp 關(guān)閉拖拽;closeModal, onDrag, onDragEnd 分別是彈窗內(nèi)部關(guān)閉函數(shù)下硕,可附加的拖拽事件和停止事件丁逝。
以上就是彈框組件及拖拽的主要思路了。
UseModal
UseModal 基本和文中一致:
// UseModal.tsx
import React, { useState } from "react";
import Modal from "./Modal";
// Modal組件最基礎(chǔ)的兩個事件梭姓,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,
};
};
如何使用該組件我們上文已講到霜幼,如果你忘了可以回看。
至此誉尖,我們已經(jīng)完成了開篇的過程分析了罪既。
小結(jié)
本篇文章介紹了項(xiàng)目從點(diǎn)擊 Dock 呈現(xiàn)應(yīng)用到關(guān)閉應(yīng)用的過程實(shí)現(xiàn),里面有較多細(xì)節(jié)铡恕,值得反復(fù)回味與優(yōu)化琢感。
此篇相對前兩篇較長,能看到這里都是真愛(學(xué)習(xí)和我)探熔。既然如此驹针,不如給我點(diǎn)個贊吧??。
目前該項(xiàng)目已完成部分功能诀艰,包括簡單設(shè)置柬甥,基礎(chǔ)計(jì)算器,基礎(chǔ)畫板等其垄,即使是這些已有功能也有很多需要完善的地方苛蒲。
后續(xù)我會慢慢優(yōu)化,并在相應(yīng)模塊代碼優(yōu)化到一定程度時不定時更新系列文章捉捅。