难忘的100天
时隔100天,我又来水文了!3D数字孪生的系列好多小伙伴都感兴趣,项目在10月就已经完结了,后续我会将继续补充如何完整的做完一个3D数字孪生项目。
聊聊为什么中间中断了自己写作计划吧,因为这100天大部分的精力不是在研究gpt就是在做了一件快乐且有意义的事;
简单总结:没有什么回报,是比付出的汗水来的直接。
高度总结: 好爽😋!
下面直接进入正题吧。
项目介绍
先看效果:
因为刚搭好框架,里面没有业务内容,在这个阶段做分享是合适的时机,因为主体的技术选型,框架结构,交互设计都已成型,后续只是不同模块盒子的堆砌任务了。
早期的一版长这样(历史的锅):
项目属于公司车体产品中控显示屏的一个操作和浏览界面系统,其功能主要目的,就是为了在实施阶段,手动通过界面调整车体参数、Slam视觉、与搬运状态等信息,此次重构的目的,只是为了改版 顺(让 )应(领 )市(导 )场(开 )需(心)求,也符合之前提出的系统资源整合的纲要;
出厂后,软件产品本身就应该与车体属于一体化的标准产物,由于种种原因,目前也只能是将零散孤立的系统先做好。
技术栈
小伙伴们不用担心技术栈落后的问题,这个项目本身结构不复杂,适合新手学习,采用的都是简单且最新的技术体系,与以往一样,更多分享的是一种设计思路与方法,回归到作为一个前端本身思考的乐趣。
源码地址:mui-pad-hmi
框架与插件
框架 | 组件库 |
---|---|
"vite": "^4.4.5" |
"@mui/material": "^5.14.7" |
"react": "^18.2.0" |
"@react-spring/web": "^9.7.3" |
"swiper": "^11.0.3" |
|
"zustand": "4.4.1" |
zustand可能有些小伙伴比较陌生,与其它状态管理相比,zustand更轻量,且无需申明Provider,更多的是以 React hook的机制,在程序各个地方都能使用(当然包括TS文件中),特别适合小项目中使用。
安装依赖: pnpm i
启动开发环境: pnpm run dev
项目搭建
初始化项目,我们直接到mui github example拿到我们想要的工程模板即可,这里使用的是: material-ui-vite-ts
模板
前期思考
在获取到设计稿之后,一定要先明确几个点,再进行组件设计。
- 平台与兼容性
平台决定你的部署方式与是否需要响应式设计,在物联网领域,特别是制造业,本身软件项目都是单机版的,决定了是否可以使用线上资源和CDN资源、部署方式等。
兼容性则决定项目的框架版本与配置,比如babel兼容配置,eslint的parserOptions配置,动画设计等等。
- 系统边界
为什么会提到系统边界呢? 这也是大部分初级开发者很容易忽视的一个问题,我们需要关心当前系统是否需要与其它系统衔接,是否有关联其他业务系统,系统的数据是否统一输入与输出。这个非常重要,只有对系统有一个整体的认识,再着手进行设计,在设计的过程中才能将更多的可能性考虑进来。
- 领导的意见
他想要的效果是什么? 站在他的角度,这个系统需要考虑哪些因素。只有这样,我们才能能有效合理的规划我们的计划。
当然一定还有更多,也欢迎小伙伴补充~
组件设计
前面的准备工作完成之后,接下来,我们开始针对设计稿进行分析。 这是一个类似于一个App应用的web程序,当然在很多交互与结构设计上,使用起来就更应该像是一个app。
那么,围绕这个思路,首先我们就需要选参照物。交互动画上更多是模仿mac系统的交互设计。 贝塞尔曲线的设计,让我昨晚直接忘了时间冲到了凌晨两点,只是为了调一个觉得接近的值。
接下来,我们就近距离感受下两者动画的对比:
项目整体结构
拆分了左右两边,左边为LeftNavBar,右边为主体内容main区域。
应用列表的设计在SwiperContent组件中;
我们先看App的代码中,如何设计显示主体SwiperContent淡入淡出的动画。
js
import * as React from "react";
import LeftNavBar from "./components/LeftNavBar";
import SwiperContent from "./components/SwiperContent";
import { Box, BoxProps, Paper, styled } from "@mui/material";
import { useGlobaltore } from "./store/global";
import BgImg from "./assets/bg.png";
import SlideDialog from "./components/SlideDialog";
import { Suspense } from "react";
interface SlideBoxProps {
open: boolean;
}
/**
+ * 滑动框的样式化组件。
+ *
+ * @param {SlideBoxProps} props - 滑动框的属性。
+ * @param {Theme} props.theme - MUI 主题。
+ * @param {boolean} props.open - 滑动框是否打开。
+ * @return {JSX.Element} 样式化的滑动框组件。
+ */
const SlideBox = styled(Box)<SlideBoxProps>(({ theme, open }) => ({
transition: "all 0.3s cubic-bezier(0,.98,.4,.99)",
transform: `scale(${open ? 1 : 0.8})`,
transformOrigin: "50% 50%",
opacity: open ? 1 : 0,
height: "100%",
width: "100%",
}));
export default function MiniDrawer() {
const { active, dialogOpen, activeApp } = useGlobaltore((state) => state);
const Component = React.useMemo(() => {
if (!activeApp) return null;
return React.lazy(() => import(`./components/Pages/${activeApp}`));
}, [activeApp]);
return (
<Paper
sx={{
display: "flex",
height: "100vh",
width: "100%",
backgroundImage: `url(${BgImg})`,
backgroundSize: "cover",
}}
>
<LeftNavBar />
<Paper
component="main"
sx={{
width: "calc(100% - 120px)",
height: "100%",
p: 3,
backgroundColor: "rgba(0, 0, 0, 0.6)",
position: "relative",
borderRadius: "0px",
}}
>
<SlideBox open={active}>
<SwiperContent />
</SlideBox>
<SlideDialog open={dialogOpen}>
<Suspense fallback={<div>Loading...</div>}>
{Component && <Component />}
</Suspense>
</SlideDialog>
</Paper>
</Paper>
);
}
通过传入由store中管理的状态active,控制SlideBox组件的opacity、transform样式。同时给transition设置对应的动画去曲线,达到最终的效果。 因为主屏的应用列表的显示与隐藏控制,有可能存在不同的组件层,所以我们将控制开发active放入store中做管理。
接下来,我们看另一个图标的效果,仿Mac应用点击动画。
当点击应用图标,会有一个放大减少透明度的效果。
js
import {
Box,
BoxProps,
ListItemButton,
styled,
Typography,
} from "@mui/material";
import { memo, useState } from "react";
import { useSpring, animated } from "@react-spring/web";
interface IAppBoxProps extends BoxProps {
title?: string;
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
children: React.ReactNode;
}
const BoxCard = styled(animated(ListItemButton))(({ theme, color }) => ({
"&:hover, &.Mui-focusVisible": {
zIndex: 1,
},
}));
const TitleTypography = styled(Typography)(({ theme }) => ({
color: "white",
fontSize: theme.typography.h5.fontSize,
backgroundColor: "rgba(255, 255, 255, 0.1)",
height: "45px",
lineHeight: "45px",
borderRadius: theme.shape.borderRadius + 1,
marginBlockStart: theme.spacing(2),
paddingInline: theme.spacing(2),
}));
const AppBox = ({ children, color, title }: IAppBoxProps) => {
const [loading, setLoading] = useState(false);
const animate = useSpring({
opacity: loading ? 0.2 : 1,
transform: loading ? "scale(1.25)" : "scale(1)",
transformOrigin: "center",
config: { tension: 600, friction: 30 },
onRest: () => {
setLoading(false);
},
});
return (
<Box
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
<BoxCard
disableRipple
style={animate}
color={color}
onClick={() => {
setLoading(true);
}}
>
{children}
</BoxCard>
{title && <TitleTypography>{title}</TitleTypography>}
</Box>
);
};
export default memo(AppBox);
同样的我们我们在点击应用图标时,改变组件中的loading状态,控制useSpring中的样式。
onRest只是执行完动画之后,让原有的动画,恢复到初始状态。
接下来,介绍最后一个动画组件: SlideDialog(app的业务公共组件)
这里包好了两个动画,一个是浮层本身的动画,一个是浮层内容的动画。
js
import { useSpring, animated } from "@react-spring/web";
import { Box, BoxProps, styled } from "@mui/material";
import { memo } from "react";
import { useGlobaltore } from "../../store/global";
const SlideBox = styled(animated(Box))(({ theme }) => ({
position: "absolute",
width: "100%",
height: "100%",
top: 0,
left: 0,
zIndex: theme.zIndex.drawer + 1,
padding: theme.spacing(2.5),
background: "#162640",
}));
type Props = {
children?: JSX.Element | JSX.Element[];
open?: boolean;
setOpen?: (open: any) => void;
} & BoxProps;
const AnimateDialog = (props: Props) => {
const { points } = useGlobaltore((state) => state);
const { children, open, setOpen, ...rest } = props;
const animate = useSpring({
opacity: open ? 1 : 0,
transform: open ? "scale(1)" : "scale(0)",
transformOrigin: `${points.x}px ${points.y}px`,
// zIndex: open ? 1 : -1,
config: { tension: 200, friction: 30 },
});
return (
<SlideBox {...rest} style={animate}>
{children}
</SlideBox>
);
};
export default memo(AnimateDialog);
同样,弹层的组件受父组件传下来的open状态值控制显示与隐藏,由于与Mac系统本身的叠层相反,apps应用列表处在最底层,mac上的app应用则处在最顶层,所以,我们需要在浮层消失的时,让其dom在界面上无法触发,我们将scale直接设置为0。
同时根据app的点击位置,控制transformOrigin基点位置,达到浮层的弹出从点击位置开始和结束。
关于动画,目前只设计了这些,更多的是对spring/web组件库的实际运用。
小花边
由于每个app应用组件都使用了懒加载且都复用了同一个浮层,在组件加载过程中,组件内部的动画的控制,与是否显示浮层的状态共用,会导致动画在加载阶段就已经执行了。为了控制动画能在正常的组件加载完之后执行,需要在组件执行完之后,使用useEffect监听dialogOpen状态值的变化,确保首次加载的时候,正常执行动画。
tsx
import { Box, BoxProps, styled } from "@mui/material";
import { useSpring, animated } from "@react-spring/web";
import React, { memo } from "react";
import { useGlobaltore } from "../../store/global";
interface IProps extends BoxProps {
delay?: number;
}
const StylePanel = styled(animated(Box))(({ theme }) => ({
backgroundColor: "#445260",
borderRadius: "20px",
padding: "20px",
}));
const GlobalPanel = ({ delay = 3000, children, ...rest }: IProps) => {
const { dialogOpen } = useGlobaltore((state) => state);
const [animated, setAnimated] = React.useState(false);
React.useEffect(() => {
setAnimated(dialogOpen);
}, [dialogOpen]);
const animate = useSpring({
opacity: animated ? 1 : 0,
transform: animated
? "translateX(0px) scale(1) rotateY(0deg)"
: "translateX(-100px) scale(0.9) rotateY(10deg)",
delay,
config: { tension: 100, friction: 30 },
});
return (
<StylePanel style={animate} {...rest}>
{children}
</StylePanel>
);
};
export default memo(GlobalPanel);
感兴趣的小伙伴可以自行clone代码查看,或者有更好的思路欢迎留言~
总结
目前项目只花了两天搭建的雏形,后续还会持续的迭代开发,如果你也有很多有趣的想法或者需要指正的地方,欢迎留言私信交流~