You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PC端聊天气泡左滑手势:元素内联样式更新的更优方案问询

聊天气泡PC端左滑手势优化方案

问题描述

我正在为聊天气泡开发PC端左滑手势逻辑,后续会适配移动端的touchMove、touchStart等事件,但目前先聚焦PC/web场景。现在已经实现了基础功能,但通过更新内联transform样式实现滑动的方式不够自然,我试过用scroll-snap的取巧方案但没成功。想找纯CSS方案,请问当前这种内联样式更新是不是唯一可行的方案?

当前实现代码

React组件代码

const { createRoot } = ReactDOM;
const { StrictMode, useEffect, useState } = React;

const createSwipeHandlers = ({
  leftSwipeHandler,
  swipeStopHandler,
  minSwipeDist = 5
}) => {
  let xDown = null;
  let yDown = null;

  return {
    mouseStart: ((e) => {
      xDown = e.clientX;
      yDown = e.clientY;
    }),
    mouseMove: (e) => {
      if (!xDown || !yDown) return;

      const xUp = e.clientX;
      const yUp = e.clientY;

      const xDiff = xDown - xUp;
      const xDiffAbs = Math.abs(xDiff);
      const yDiff = yDown - yUp;
      const yDiffAbs = Math.abs(yDiff);

      if (xDiffAbs > yDiffAbs && xDiff > 0 && xDiffAbs > minSwipeDist) {
        leftSwipeHandler(xDiffAbs);
      }
    },
    mouseOut: (e) => {
      xDown = null;
      yDown = null;
    },
    mouseStop: (e) => {
      xDown = null;
      yDown = null;
      swipeStopHandler(e);
    }
  };
};

const BubbleRow = React.memo(
  ({
    messageText,
    setSelectedMessage,
    isLastBubble
  }) => {
    const bubbleRef = React.useRef(null);

    const handleLeftSwipe = React.useCallback((dist) => {
      if (bubbleRef.current) {
        if (dist > 72) {
          bubbleRef.current.style.transform = null;
          setSelectedMessage(messageText);
          return;
        }

        bubbleRef.current.style.transform = `translateX(-${dist}px)`;
      }
    }, []);

    const { mouseStart, mouseMove, mouseOut, mouseStop } = createSwipeHandlers({
      leftSwipeHandler: handleLeftSwipe,
      swipeStopHandler: (e) => {
        if (bubbleRef.current) {
          //@ts-expect-error
          bubbleRef.current.style.transform = null;
        }
      }
    });

    const className = `row${isLastBubble ? " last-bubble-row" : ""}`;
    return (
      <div className={className}>
        <div
          ref={bubbleRef}
          className={"bubble"}
          onMouseDown={mouseStart}
          onMouseMove={mouseMove}
          onMouseOut={mouseOut}
          onMouseLeave={mouseStop}
          onMouseUp={mouseStop}
        >
          {messageText}
        </div>
      </div>
    );
  }
);

const Footer = React.memo(
  ({
    selectedMessage,
    setSelectedMessage
  }) => {
    const clearSelectedMessage = React.useCallback(() => {
      setSelectedMessage(null);
    }, []);

    if (!selectedMessage) return null;

    return (
      <div key={selectedMessage} className="footer">
        <span>{selectedMessage}</span>
        <span onClick={clearSelectedMessage} className="clear-button">
          {"×"}
        </span>
      </div>
    );
  }
);

const numberOfChatBubbles = 5;

const App = () => {
  const [selectedMessage, setSelectedMessage] = React.useState(
    null
  );

  return (
    <div className="wrapper">
      <div className="container">
        {Array.from({ length: numberOfChatBubbles }, (_, index) => (
          <BubbleRow
            key={index}
            messageText={`Text for message ${index + 1}`}
            setSelectedMessage={setSelectedMessage}
            isLastBubble={index + 1 === numberOfChatBubbles}
          />
        ))}
        <Footer
          selectedMessage={selectedMessage}
          setSelectedMessage={setSelectedMessage}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root"));
root.render(<StrictMode><App /></StrictMode>);

样式代码

* {
  outline: none;
  box-sizing: border-box;
  margin: 0;
}

.wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100vh;
}

.container {
  border: 1px solid black;
  height: 500px;
  width: 400px;
  display: flex;
  flex-direction: column;
}

.row {
  display: flex;
  justify-content: flex-end;
  width: 100%;
  padding: 8px;
}

.bubble {
  display: flex;
  align-items: center;
  background-color: gray;
  padding: 12px;
  border-radius: 12px 12px 3px 12px;
  transition: 0.04s linear;
  height: fit-content;
  user-select: none;
}

.last-bubble-row {
  flex-grow: 1;
}

@keyframes blinkFade {
  0%,
  100% {
    filter: brightness(1);
  }

  20% {
    filter: brightness(0.4);
  }
}

.footer {
  height: 64px;
  width: 100%;
  padding: 12px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  animation: blinkFade 1.5s;
  background-color: gray;
}

.clear-button {
  cursor: pointer;
  font-size: 32px;
}

HTML结构

<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

当前效果

聊天气泡左滑效果

解决方案

内联样式更新并不是唯一方案,以下是几种优化方向和替代方案:

1. 使用CSS变量替代内联transform

通过CSS变量存储滑动距离,JS只负责更新变量值,由CSS控制transform和过渡效果,能让滑动更顺滑:

.bubble {
  /* 保留原有样式,新增以下内容 */
  --swipe-x: 0px;
  transform: translateX(var(--swipe-x));
  transition: transform 0.1s ease-out; /* 改用更自然的过渡曲线 */
}

修改JS中的滑动处理逻辑:

const handleLeftSwipe = React.useCallback((dist) => {
  if (bubbleRef.current) {
    if (dist > 72) {
      bubbleRef.current.style.setProperty('--swipe-x', '0px');
      setSelectedMessage(messageText);
      return;
    }
    bubbleRef.current.style.setProperty('--swipe-x', `-${dist}px`);
  }
}, []);

const swipeStopHandler = (e) => {
  if (bubbleRef.current) {
    bubbleRef.current.style.setProperty('--swipe-x', '0px');
  }
};

2. 基于滚动容器的优化方案

利用横向滚动容器+scroll-snap实现更接近原生的滑动体验,思路是将气泡放在横向滚动容器中,通过控制容器滚动位置实现滑动:

.row {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none; /* 隐藏滚动条 */
  -ms-overflow-style: none;
  justify-content: flex-end;
  width: 100%;
  padding: 8px;
}
.row::-webkit-scrollbar {
  display: none;
}
.bubble {
  scroll-snap-align: end; /* 初始吸附在右侧 */
  flex-shrink: 0;
  /* 保留原有样式 */
}
/* 添加占位元素,用于触发选中的滑动阈值 */
.swipe-trigger {
  width: 72px;
  flex-shrink: 0;
}

修改BubbleRow的结构与逻辑:

return (
  <div className={className}>
    <div className="swipe-trigger"></div>
    <div
      ref={bubbleRef}
      className={"bubble"}
      onMouseDown={(e) => {
        xDown = e.clientX;
      }}
      onMouseMove={(e) => {
        if (!xDown) return;
        const xDiff = xDown - e.clientX;
        const rowEl = bubbleRef.current.parentElement;
        rowEl.scrollLeft = xDiff;
        // 检测滚动距离是否触发选中
        if (rowEl.scrollLeft > 72) {
          setSelectedMessage(messageText);
          rowEl.scrollLeft = 0;
          xDown = null;
        }
      }}
      onMouseUp={() => {
        const rowEl = bubbleRef.current.parentElement;
        // 松手后自动吸附
        rowEl.scrollLeft = 0;
        xDown = null;
      }}
      onMouseLeave={() => {
        const rowEl = bubbleRef.current.parentElement;
        rowEl.scrollLeft = 0;
        xDown = null;
      }}
    >
      {messageText}
    </div>
  </div>
);

这种方案的滑动手感更贴近原生滚动,scroll-snap会自动处理松手后的吸附效果。

3. 优化现有内联样式的过渡体验

如果坚持使用现有方案,调整过渡曲线并区分拖动/静止状态,能大幅提升自然度:

.bubble {
  /* 修改过渡效果 */
  transition: transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.bubble.dragging {
  /* 拖动时禁用过渡,避免卡顿 */
  transition: none;
}

修改JS逻辑:

const handleLeftSwipe = React.useCallback((dist) => {
  if (bubbleRef.current) {
    bubbleRef.current.classList.add('dragging');
    if (dist > 72) {
      bubbleRef.current.classList.remove('dragging');
      bubbleRef.current.style.transform = null;
      setSelectedMessage(messageText);
      return;
    }
    bubbleRef.current.style.transform = `translateX(-${dist}px)`;
  }
}, []);

const swipeStopHandler = (e) => {
  if (bubbleRef.current) {
    bubbleRef.current.classList.remove('dragging');
    bubbleRef.current.style.transform = null;
  }
};

总结

纯CSS无法完全实现手势逻辑(因为无法监听鼠标拖动事件),必须配合JS处理手势识别,但可以通过CSS优化样式表现,让滑动更自然。内联样式更新不是唯一选择,CSS变量或滚动容器的方案都能带来更好的交互体验。

内容的提问来源于stack exchange,提问作者Mike K

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.12 03:29:55