仿mac系统交互设计的车载Pad操作系统

难忘的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模板

前期思考

在获取到设计稿之后,一定要先明确几个点,再进行组件设计

  1. 平台与兼容性

平台决定你的部署方式与是否需要响应式设计,在物联网领域,特别是制造业,本身软件项目都是单机版的,决定了是否可以使用线上资源和CDN资源、部署方式等。

兼容性则决定项目的框架版本与配置,比如babel兼容配置,eslint的parserOptions配置,动画设计等等。

  1. 系统边界

为什么会提到系统边界呢? 这也是大部分初级开发者很容易忽视的一个问题,我们需要关心当前系统是否需要与其它系统衔接,是否有关联其他业务系统,系统的数据是否统一输入与输出。这个非常重要,只有对系统有一个整体的认识,再着手进行设计,在设计的过程中才能将更多的可能性考虑进来。

  1. 领导的意见

他想要的效果是什么? 站在他的角度,这个系统需要考虑哪些因素。只有这样,我们才能能有效合理的规划我们的计划。

当然一定还有更多,也欢迎小伙伴补充~

组件设计

前面的准备工作完成之后,接下来,我们开始针对设计稿进行分析。 这是一个类似于一个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代码查看,或者有更好的思路欢迎留言~

总结

目前项目只花了两天搭建的雏形,后续还会持续的迭代开发,如果你也有很多有趣的想法或者需要指正的地方,欢迎留言私信交流~

相关推荐
奋斗吧程序媛1 分钟前
删除VSCode上 origin/分支名,但GitLab上实际上不存在的分支
前端·vscode
IT女孩儿11 分钟前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748248942 小时前
WebChat——一个开源的聊天应用
开源
m0_748256562 小时前
如何解决前端发送数据到后端为空的问题
前端
请叫我飞哥@2 小时前
HTML5适配手机
前端·html·html5
@解忧杂货铺4 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js
F-2H6 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++
gqkmiss6 小时前
Chrome 浏览器插件获取网页 iframe 中的 window 对象
前端·chrome·iframe·postmessage·chrome 插件
_oP_i7 小时前
Pinpoint 是一个开源的分布式追踪系统
java·分布式·开源
m0_748247559 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php