WEB端小屏切换纯CSS实现

背景

最近开发了一个内部问答平台(deepseek套层皮🐷),前端实现很简单,用户输入一个问题,跟后端建立一个SSE连接,后端将结果流式输出,前端流式渲染展示。

突然有一天老板想搞点事:小明,页面这个流式内容能不能支持缩小到页面右下角,让用户更加聚焦输入框内容?

WTF,只见过全屏展示的,还没见过小屏展示的。

好像也不是,暂停播放的时候,各大视频网站就喜欢小屏放内容,让你更加聚焦于弹窗广告👻。

没有办法,在AI的冲击下前端已经死了至少七次了,可不敢惹恼boss,不然就等不到被AI的第八次kill,已经被老板一次KO了🫡。

秉着负(牛)责(马)的态度,帮助boss梳理交互细节~

希望缩小后流式内容还在动态输出...

希望缩小后右侧区域自动扩张到全部区域,然后小窗"悬浮"在右下角...

希望可以丝滑地还原...

明确了需求,直接开干。

方案思路

希望缩小后流式内容还在动态输出...

把当前内容区的容器整个缩小不就行了transform: scale(0.x),也就是从视觉上缩小流式内容区,其他渲染逻辑完全不动,SSE流的解析和渲染还是在内容区组件里完成,太easy了🥰

希望缩小后右侧区域自动扩张到全部区域,然后小窗"悬浮"在右下角...

啧啧,也好实现,在内容区多包一层div,让这个div脱离文档流position:absolute,再设置一下相对位置就可以悬浮到指定位置啦。

希望可以丝滑地还原...

CSS加上过渡效果,transition: transform 0.3s ease简单需求简单做嘛,哈哈哈

技术实现

容器结构

分为两层容器,父容器负责定位,实际缩小内容放置在 .right-panel-content__container 中:

为了拥有过渡效果,通过setTimeout制造时间差,让过渡动画完成后才让父容器.right-panel-content浮动到右下角为止。看下实现效果:

唉,唉!唉?缩小的内容怎么不见了?
F12打开调试面板看下DOM结构:

DOM结构正常啊,内容都在正确位置,但是展示怎么就不对呢?

继续捣鼓CSS...猛然间看到div.right-panel-content.min-mode 的大小,突然感到一丝诡异,按理说待缩放区域设置的缩小比例为scale(0.2),计算下来容器的宽高至少应该是:90x158。怎么会是64x46呢?

继续分析DOM结构及大小,发现.right-panel-content__header的大小是:46x28,加上div.right-panel-content.min-mode自己的padding: 8px,好家伙,刚好凑成62x44(=64x46- 1px border😭)。

感情.right-panel-content__container被缩小得根本不占空间了嘛~

问题分析

为什么会这样呢?因为被.right-panel-content__container被二次缩小了,原因在于container的父容器为了定位而做出的自身宽高调整。下面分步骤还原一下内容缩放过程:

设置以下别名:

.right-panel-content__container => content__container

.right-panel-content__header => content__header

.right-panel-content => panel-content

  1. 初始时宽高正常,content__container缩放后达到了目标大小90x158;
  2. 然后panel-content进入min-mode,宽高被强制设置为auto !important,因为内容区坍塌,panel-content被迫坍塌到跟内容一样的高度90x158;
  3. panel-content高度变化激活了content__container的 scale(0.2),content__container继续缩小;
  4. content__container的缩小又引发panel-content的高度坍塌...直到 content__header "独自"撑起panel-content的内容。

一切都解释清楚了,核心问题变成了怎么解决循环坍塌。

解决方案

既然问题父子容器间高度变化引发的循环坍塌,那可以不可以在中间加一层,阻断这种循环效果呢?

而且要阻断循环,那增加的一层应该脱离文档流,让其大小不受父容器和其子容器干扰。 新的DOM结果如下:

为了让操作栏样式更合理,根据minMode状态对 ContentHeader 进行动态渲染:

js 复制代码
<div className="right-panel-middle-wrapper">
  {minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
  <div className={`right-panel-content__container ${containerClassname}`}>
    {!minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
    <ContentSection />
  </div>
</div>;

完整实现

js 复制代码
import { useEffect, useRef, useState } from "react";
import { Layout, Button } from "@arco-design/web-react";
import { IconExpand, IconShrink } from "@arco-design/web-react/icon";

import "./App.less";

const Sider = Layout.Sider;
const Header = Layout.Header;
const Footer = Layout.Footer;
const Content = Layout.Content;

function App() {
  const [minMode, setMinMode] = useState(false);
  const [panelClassname, setPanelClassname] = useState("");
  const [containerClassname, setContainerClassname] = useState("");
  const classnameTimer = useRef<number>(0);

  useEffect(() => {
    if (minMode) {
      // 立即启动缩小动效
      setContainerClassname("min-mode-transition");

      // 动效结束后,再设置 min-mode 类名
      if (classnameTimer.current) {
        clearTimeout(classnameTimer.current);
      }
      // 动画约350ms,需要等动画结束后再设置min-mode
      classnameTimer.current = setTimeout(() => {
        setPanelClassname("min-mode");
      }, 350);
    } else {
      setPanelClassname("");
      setContainerClassname("");
    }
    return () => {
      if (classnameTimer.current) {
        clearTimeout(classnameTimer.current);
      }
    };
  }, [minMode]);

  return (
    <div className="app-layout">
      <Layout>
        <Header>Header</Header>
        <Layout>
          <Sider>Sider</Sider>
          <Content>
            <div className="left-panel-content">左侧内容栏</div>
            <div className={`right-panel-content ${panelClassname}`}>
              <div className="right-panel-middle-wrapper">
                {minMode && (
                  <ContentHeader minMode={minMode} setMinMode={setMinMode} />
                )}
                <div
                  className={`right-panel-content__container ${containerClassname}`}
                >
                  {!minMode && (
                    <ContentHeader minMode={minMode} setMinMode={setMinMode} />
                  )}
                  <ContentSection />
                </div>
              </div>
            </div>
          </Content>
        </Layout>
        <Footer>Footer</Footer>
      </Layout>
    </div>
  );
}

function ContentHeader({
  minMode,
  setMinMode,
}: {
  minMode: boolean;
  setMinMode: (minMode: boolean) => void;
}) {
  return (
    <div className="right-panel-content__header">
      {minMode ? (
        <Button type="text" size="small" onClick={() => setMinMode(false)}>
          <IconExpand style={{ color: "var(--color-border-4)" }} />
        </Button>
      ) : (
        <Button type="text" size="small" onClick={() => setMinMode(true)}>
          <IconShrink style={{ color: "var(--color-border-4)" }} />
        </Button>
      )}
    </div>
  );
}

function ContentSection() {
  return (
    <div className={`right-panel-content__section`}>
      <div className="right-panel-content__section-title">
        张若虚《春江花月夜》
      </div>
      春江潮水连海平,海上明月共潮生。 <br />
      滟滟随波千万里,何处春江无月明。
      <br />
      江流宛转绕芳甸,月照花林皆似霰。
      <br /> 空里流霜不觉飞,汀上白沙看不见。
      <br />
      江天一色无纤尘,皎皎空中孤月轮。
      <br /> 江畔何人初见月?江月何年初照人?
      <br />
      人生代代无穷已,江月年年望相似。
      <br />
      不知江月待何人,但见长江送流水。
      <br />
      白云一片去悠悠,青枫浦上不胜愁。
      <br />
      谁家今夜扁舟子?何处相思明月楼?
      <br />
      可怜楼上月裴回,应照离人妆镜台。
      <br />
      玉户帘中卷不去,捣衣砧上拂还来。
      <br /> 此时相望不相闻,愿逐月华流照君。
      <br />
      鸿雁长飞光不度,鱼龙潜跃水成文。
      <br /> 昨夜闲潭梦落花,可怜春半不还家。
      <br />
      江水流春去欲尽,江潭落月复西斜。
      <br /> 斜月沉沉藏海雾,碣石潇湘无限路。
      <br />
      不知乘月几人归,落月摇情满江树。
    </div>
  );
}

export default App;

样式文件:

css 复制代码
.app-layout {
  height: 100%;
  width: 100%;

  .arco-layout {
    height: 100%;
  }

  .arco-layout-header,
  .arco-layout-footer,
  .arco-layout-sider,
  .arco-layout-sider-children,
  .arco-layout-content {
    color: var(--color-white);
    text-align: center;
    font-stretch: condensed;
    font-size: 16px;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

  .arco-layout-content {
    flex-direction: row;
  }

  .arco-layout-header,
  .arco-layout-footer {
    height: 64px;
    background-color: var(--color-primary-light-4);
  }

  .arco-layout-sider {
    width: 206px;
    background-color: var(--color-primary-light-3);
  }

  .arco-layout-content {
    background-color: rgb(var(--arcoblue-6));

    .left-panel-content {
      flex: auto;
    }

    .right-panel-content {
      background-color: var(--color-primary-light-3);
      height: 100%;
      flex: 1;

      .right-panel-middle-wrapper {
        width: 100%;
        height: 100%;
      }

      .right-panel-content__container {
        width: 100%;
        height: 100%;

        .right-panel-content__header {
          height: 40px;
          line-height: 40px;
          background-color: var(--color-warning-light-1);
          text-align: right;
          padding: 0 10px;
        }

        .right-panel-content__section {
          width: 100%;
          height: calc(100% - 40px);
          color: #000;

          .right-panel-content__section-title {
            font-size: 20px;
            padding: 10px 0;
          }
        }
      }

      .min-mode-transition {
        position: absolute;
        z-index: 1999;
        width: 442px;
        height: 794px;
        transform: scale(0.2);
        transform-origin: right bottom;
        transition: transform 0.3s ease;
      }

      &.min-mode {
        position: absolute;
        z-index: 499;
        bottom: 25px;
        right: 25px;
        border-radius: 6px;
        padding: 8px;
        width: auto !important;
        height: auto !important;
        display: flex;
        flex-direction: column;
        align-items: center;
        transition: none;
        border: 1px solid var(--color-border);
        box-sizing: border-box;

        .right-panel-middle-wrapper {
          width: 90px;
          height: 158px;
          overflow: hidden;
          position: relative;

          .right-panel-content__header {
            height: fit-content;
            line-height: unset;
            background-color: unset;
            padding: 0;
          }

          .right-panel-content__container {
            transform-origin: top left;
          }
        }
      }
    }
  }
}

最终效果

小结

文中的小窗效果虽然谈不上尽善尽美,但胜在简单,只需要调整css样式就可以实现。在实现中需要注意的点便是父容器与待缩放内容间的循环依赖问题。本文通过增加一个wrapper层达到依赖解耦的目的。方案有点不够优雅,如果有哪位大佬知道更好的方案,欢迎在评论区给出,让笔者也学习学习🖖

相关推荐
LaughingDangZi2 小时前
vue+java分离项目实现微信公众号开发全流程梳理
java·前端·后端
爬山算法2 小时前
Netty(14)如何处理Netty中的异常和错误?
java·前端·数据库
再出发Start2 小时前
并发事务 A/B 如何避免互相影响(UPDATE 有交集
前端
Running_slave2 小时前
聊聊TCP滑窗的一些有趣“病症”
前端·网络协议·tcp/ip
恋猫de小郭2 小时前
再次紧急修复,Flutter 针对 WebView 无法点击问题增加新的快速修复
android·前端·flutter
1024肥宅2 小时前
浏览器存储 API:全面解析与高级实践
前端·数据库·浏览器
HIT_Weston2 小时前
63、【Ubuntu】【Gitlab】拉出内网 Web 服务:Gitlab 配置审视(七)
前端·ubuntu·gitlab
jinxinyuuuus2 小时前
vsGPU:硬件参数的数据仓库设计、ETL流程与前端OLAP分析
前端·数据仓库·etl