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

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

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

过程分析

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

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

图标点击交互

动效实现

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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,并且将类选择器移除,便于下次重新点击使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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,它的作用是在应用激活时点击图标或最小化按钮时切换展示状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,它的作用主要是图标高亮小圆点的开关

小圆点的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// footer/index.scss

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

createContext 实现组件通信:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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 内使用了

1
/// <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 实现方式。

可拖拽弹窗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// 代码篇幅较长,可以先看上面参考博客内版本
// 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 基本和文中一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 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 呈现应用到关闭应用的过程实现,里面有较多细节,值得反复回味与优化。

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

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

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