仿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代码查看,或者有更好的思路欢迎留言~

总结

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

相关推荐
zeijiershuai3 分钟前
Vue框架
前端·javascript·vue.js
写完这行代码打球去5 分钟前
没有与此调用匹配的重载
前端·javascript·vue.js
华科云商xiao徐6 分钟前
使用CPR库编写的爬虫程序
前端
狂炫一碗大米饭8 分钟前
Event Loop事件循环机制,那是什么事件?又是怎么循环呢?
前端·javascript·面试
IT、木易10 分钟前
大白话Vue Router 中路由守卫(全局守卫、路由独享守卫、组件内守卫)的种类及应用场景
前端·javascript·vue.js
顾林海10 分钟前
JavaScript 变量与常量全面解析
前端·javascript
程序员小续10 分钟前
React 组件库:跨版本兼容的解决方案!
前端·react.js·面试
乐坏小陈12 分钟前
2025 年你希望用到的现代 JavaScript 模式 【转载】
前端·javascript
生在地上要上天12 分钟前
从600行"状态地狱"到可维护策略模式:一次列表操作限制重构实践
前端
oil欧哟14 分钟前
🥳 做了三个月的学习卡盒小程序,开源了!
前端·vue.js·微信小程序