横刷动画学习

知识点梳理

css clip-path

用于裁剪元素的可见区域——被裁掉的部分完全不可见、不可交互,但元素本身仍占据原来的布局空间。

inset() 函数

inset(top right bottom left) 表示从四条边向内收缩裁剪:

         top
    ┌─────────────────┐
    │  ┌───────────┐  │
left│  │  可见区域  │  │right
    │  └───────────┘  │
    └─────────────────┘
         bottom

只露出左侧 30% 的图片内容

clipPath: inset(0 70% 0 0)
/*              ↑  ↑  ↑  ↑
                上 右 下 左  */

top = 0:上边不裁
right = 70%:从右边裁掉 70%
bottom = 0:下边不裁
left = 0:左边不裁
clipPath还支持其他形状函数,具体自查文档看

为什么用 clipPath 而不是其他方案

Clipboard_Screenshot_1778727650

clipPath 在现代浏览器中属于 合成层属性(compositor-friendly),配合 requestAnimationFrame 逐帧更新百分比,动画非常流畅。

performance.now()

高精度时间戳,返回从页面打开(navigationStart)到调用时刻的毫秒数,精度可达微秒级(小数点后多位)。
requestAnimationFrame 的回调参数 now 本身就是 performance.now() 的值

源码展示

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <meta name="referrer" content="no-referrer">
    <title>初始横刷演示</title>
    <style>
        :root {
            --page-bg-start: #f6f2ea;
            --page-bg-end: #f0e5d6;
            --viewer-bg: #ece7e0;
            --viewer-radius: 16px;
            --viewer-shadow: 0 20px 50px rgba(60, 43, 20, 0.12);
            --handle-width: 2px;
            --handle-color: rgba(255, 255, 255, 0.58);
            --handle-shadow: 0 0 4px rgba(0, 0, 0, 0.15);
        }

        * {
            box-sizing: border-box;
        }

        html,
        body {
            width: 100%;
            min-height: 100%;
            margin: 0;
            font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", sans-serif;
            background: linear-gradient(135deg, var(--page-bg-start), var(--page-bg-end));
        }

        body {
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }

        .demo-shell {
            width: min(100%, 600px);
        }

        .image-viewer {
            position: relative;
            width: 100%;
            aspect-ratio: 1 / 1;
            background: var(--viewer-bg);
            overflow: hidden;
            border-radius: var(--viewer-radius);
            box-shadow: var(--viewer-shadow);
        }

        .viewer-image {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
            object-fit: cover;
            object-position: center center;
            pointer-events: none;
            user-select: none;
            filter: blur(20px);
            transform: scale(1.1);
            transition: filter 0.6s ease-out, transform 0.6s ease-out;
            will-change: clip-path, filter, transform;
        }

        .viewer-image.loaded {
            filter: blur(0);
            transform: scale(1);
        }

        .image-before {
            z-index: 2;
        }

        .image-after {
            z-index: 10;
            clip-path: inset(0 0 0 0);
        }

        .compare-handle {
            position: absolute;
            top: 0;
            bottom: 0;
            left: 50%;
            width: var(--handle-width);
            background: var(--handle-color);
            z-index: 12;
            transform: translateX(-50%);
            pointer-events: none;
            box-shadow: var(--handle-shadow);
            opacity: 0;
        }

        @media (max-width: 640px) {
            body {
                padding: 16px;
            }

            .image-viewer {
                border-radius: 14px;
            }
        }
    </style>
</head>
<body>
    <div class="demo-shell">
        <div class="image-viewer" id="imageViewer" aria-label="初始状态横刷动画演示">
            <img src="" alt="Before" class="viewer-image image-before" id="imgBefore">
            <img src="" alt="After" class="viewer-image image-after" id="imgAfter">
            <div class="compare-handle" id="compareHandle"></div>
        </div>
    </div>

    <script>
        const DEMO_IMAGES = [
            {
                id: 'template-1',
                src: 'https://zh.gtimg.com/m-1/bll-apollo/common/upload/67ee0017687b2ed4cdc092fb/fc51a4e594336a71bd6f0b91d76f5b98.png',
                position: 'center center'
            },
            {
                id: 'template-9',
                src: 'https://zh.gtimg.com/m-1/bll-apollo/common/upload/67ee0017687b2ed4cdc092fb/1687a052ed4db21c7112ef8f82939d12.png',
                position: 'center center'
            },
            {
                id: 'template-lion-dance',
                src: 'https://search.imtt.qq.com/ai-card-assets/peel-off-card/horse-year/lion-dance-new.jpg',
                position: 'center center'
            },
            {
                id: 'template-shishi-ruyi',
                src: 'https://search.imtt.qq.com/ai-card-assets/peel-off-card/horse-year/shishi-ruyi-v2.webp',
                position: 'center center'
            },
            {
                id: 'template-gonghe-xinxi',
                src: 'https://search.imtt.qq.com/ai-card-assets/peel-off-card/horse-year/gonghe-xinxi-v2.webp',
                position: 'center center'
            }
        ];

        const imgBefore = document.getElementById('imgBefore');
        const imgAfter = document.getElementById('imgAfter');
        const compareHandle = document.getElementById('compareHandle');

        const SWEEP_DURATION = 2500;
        const SWEEP_PAUSE = 1000;
        const INITIAL_DELAY = 300;

        let isRunning = false;
        let currentIndex = 0;
        let direction = 1;
        let animationFrameId = null;
        let pauseTimer = null;
        let startTimer = null;

        function easeInOutQuad(t) {
            return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
        }

        function updateImagePosition(position) {
            imgBefore.style.objectPosition = position;
            imgAfter.style.objectPosition = position;
        }

        function loadImageInto(imgElement, src) {
            imgElement.classList.remove('loaded');
            const tempImg = new Image();
            tempImg.onload = () => {
                imgElement.src = src;
                imgElement.classList.add('loaded');
            };
            tempImg.src = src;
            imgElement.src = src;
        }

        function renderSweep(percent) {
            compareHandle.style.left = `${percent}%`;
            imgAfter.style.clipPath = `inset(0 0 0 ${percent}%)`;
        }

        function stopSweepDemo() {
            isRunning = false;
            if (animationFrameId) {
                cancelAnimationFrame(animationFrameId);
                animationFrameId = null;
            }
            if (pauseTimer) {
                clearTimeout(pauseTimer);
                pauseTimer = null;
            }
            if (startTimer) {
                clearTimeout(startTimer);
                startTimer = null;
            }
        }

        function animateSweep(fromPercent, toPercent, onComplete) {
            const startTime = performance.now();

            function step(now) {
                if (!isRunning) {
                    return;
                }

                const elapsed = now - startTime;
                const progress = Math.min(elapsed / SWEEP_DURATION, 1);
                const eased = easeInOutQuad(progress);
                const currentPercent = fromPercent + (toPercent - fromPercent) * eased;
                renderSweep(currentPercent);

                if (progress < 1) {
                    animationFrameId = requestAnimationFrame(step);
                    return;
                }

                animationFrameId = null;
                onComplete();
            }

            animationFrameId = requestAnimationFrame(step);
        }

        function runSweepLoop() {
            if (!isRunning) {
                return;
            }

            const fromPercent = parseFloat(compareHandle.style.left || '0');
            const targetPercent = direction === 0 ? 0 : 100;

            animateSweep(fromPercent, targetPercent, () => {
                if (!isRunning) {
                    return;
                }

                pauseTimer = setTimeout(() => {
                    if (!isRunning) {
                        return;
                    }

                    currentIndex = (currentIndex + 1) % DEMO_IMAGES.length;
                    const nextItem = DEMO_IMAGES[(currentIndex + 1) % DEMO_IMAGES.length];

                    if (direction === 0) {
                        loadImageInto(imgBefore, nextItem.src);
                    } else {
                        loadImageInto(imgAfter, nextItem.src);
                        updateImagePosition(nextItem.position);
                    }

                    direction = 1 - direction;
                    runSweepLoop();
                }, SWEEP_PAUSE);
            });
        }

        function startSweepDemo() {
            stopSweepDemo();
            isRunning = true;
            currentIndex = 0;
            direction = 1;

            compareHandle.style.opacity = '1';
            loadImageInto(imgAfter, DEMO_IMAGES[0].src);
            loadImageInto(imgBefore, DEMO_IMAGES[1].src);
            updateImagePosition(DEMO_IMAGES[0].position);
            renderSweep(0);

            startTimer = setTimeout(() => {
                runSweepLoop();
            }, INITIAL_DELAY);
        }

        startSweepDemo();

        window.addEventListener('beforeunload', stopSweepDemo);
    </script>
</body>
</html>
posted @ 2026-05-14 10:47  懒懒同学不懒  阅读(5)  评论(0)    收藏  举报