动感按钮:如何打造交互感十足的点击动画效果

你是否曾在点击按钮时,期待它不仅仅是一个简单的事件触发?在这篇文章中将深入探讨如何通过细节设计和前端技巧打造出那些"触手可及"的交互效果,让每一次点击都成为一种愉悦的体验提升网站或应用的整体互动感

目录

按钮波纹效果

按钮涟漪效果

封装通用组件

按钮波纹效果

思路引进:对于react开发者来说,常用的组件库就是antd,细心的朋友就会发现antd组件库是有给按钮添加相应的反馈效果的,如下图所示点击按钮有一个类似波纹的效果,今天我们就探讨一下该功能具体是如何实现的:

从f12控制台查看可以看出,当我们点击按钮之后,出现的波纹效果就是一个个的div元素,并且该元素是随着波纹的显示与隐藏同步发生的,当波纹消失的时候对于的div元素也会被销毁掉:

代码实现:按照上面的这种方式,接下来我们通过代码来实现上面的效果,这里我们借助react框架来实现吧,为了让点击的反馈效果更具有通用性,这里我们肯定是要将反馈功能封装成通用的组件的,如下所示:

javascript 复制代码
import Wave from "./components/button/wave"

const style: React.CSSProperties = {
    width: 100,
    height: 30,
    borderRadius: '10px 0 20px 0',
    margin: 100,
    borderColor: 'red',
}

const App = () => {
  return (
    <>
      <Wave>
        <button style={style}>点击</button>
      </Wave>
    </>
  )
}

export default App

接下来我们就需要开始正式的波纹组件的核心逻辑的编写了,在代码开始之前我们肯定是需要先梳理一下我们编写波纹组件有哪些注意点,这里我梳理要注意的地方有以下几点:

1)传递波纹组件的children是否是单一节点?因为是不能同时支持不同的组件有波纹效果

2)什么情况下不能触发波纹效果?是否是元素、常规元素、是否被禁用等需要考虑

3)如何插入子节点元素?如何确保组件能够通过ref获取实例并操作children元素的DOM?

抱着这些问题,接下来我们通过代码来一一实现和解决:

javascript 复制代码
const Wave: React.FC<{ children: React.ReactNode }> = (props) => {
    const { children } = props;
    const containerRef = useRef<HTMLDivElement>(null);

    if (Array.isArray(children)) {
        throw new Error('Wave component only accepts a single child element');
    }
    if (!React.isValidElement(children)) {
        return <>{children}</>
    }

    useEffect(() => {
        const node = containerRef.current; // 获取当前节点的引用
        if (!node || node.nodeType !== 1 || node.getAttribute('disabled')) return; // 确保节点存在且未禁用
        const handleClick = () => {
            const warpper = document.createElement('div') // 创建波纹元素
            warpper.style.position = 'absolute'
            warpper.style.top = '0'
            warpper.style.left = '0'
            node.insertBefore(warpper, node.firstChild) // 将波纹元素插入到子节点之前
            const root = ReactDOM.createRoot(warpper) // 创建React根节点
            root.render(<WaveEffect target={node}/>) // 渲染波纹元素
        }
        node.addEventListener('click', handleClick, true); // 捕获阶段监听点击事件
        return () => {
            node.removeEventListener('click', handleClick, true); // 移除事件监听器
        }
    }, [])
    // @ts-ignore
    return React.cloneElement(children, { ref: containerRef })
}

通过上面的的代码我们实现了点击按钮之后就会不断的生成对应的子元素的dom节点,接下来我们就需要给这些子节点添加样式等其他逻辑功能了,老样子还是思考一下代码开始前的准备工作:

1)波纹颜色验证?例如按钮颜色为白色就无需波纹等效果

2)样式信息同步?例如按钮有无定位的效果,来判断当前波纹位置等样式信息

3)按钮尺寸变化?例如按钮尺寸发生变化的时候波纹效果能否同步发生更新

4)波纹元素移除?波纹效果已经结束之后,插入的子dom元素能否被移除

抱着这些问题,接下来我们通过代码来一一实现和解决:

javascript 复制代码
const WaveEffect: React.FC<{ target: HTMLElement }> = ({ target }) => {
  const divRef = useRef<HTMLDivElement>(null);
  const [style, setStyle] = useState<React.CSSProperties>({});

  // 颜色验证和获取合并
  const getWaveColor = (node: HTMLElement, defaultColor='blue') => {
    const { borderColor, backgroundColor } = getComputedStyle(node);
    const isValid = (color: string) => color && !['transparent', '#fff', '#ffffff', 'rgba(255, 255, 255, 1)', 'rgb(255, 255, 255)'].includes(color);
    return isValid(borderColor) ? borderColor : isValid(backgroundColor) ? backgroundColor : defaultColor;
  };

  // 同步样式信息
  const syncStyle = () => {
    const { 
      position, borderTopWidth, borderLeftWidth,
      borderTopLeftRadius, borderTopRightRadius,
      borderBottomRightRadius, borderBottomLeftRadius
    } = getComputedStyle(target);
    // 判断是否为静态定位,如果是则使用偏移量作为top和left值
    const isStatic = !position || position === 'static';
    setStyle({
      width: target.offsetWidth,
      height: target.offsetHeight,
      top: isStatic ? target.offsetTop : -parseFloat(borderTopWidth),
      left: isStatic ? target.offsetLeft : -parseFloat(borderLeftWidth),
      color: getWaveColor(target),
      borderRadius: [
        borderTopLeftRadius, borderTopRightRadius,
        borderBottomRightRadius, borderBottomLeftRadius
      ].join(' ')
    });
  };

  useEffect(() => {
    // 监听尺寸变化,同步样式信息
    const ob = new ResizeObserver(syncStyle);
    ob.observe(target);
    // 延迟执行,确保样式已经同步
    const id = requestAnimationFrame(() => {
      divRef.current?.classList.add('wave-hide');
      syncStyle();
    });
    // 清理函数
    return () => {
      ob.disconnect();
      cancelAnimationFrame(id);
    };
  }, [target]);

  useEffect(() => {
    // 监听过渡结束,移除波纹元素
    const callback = () => divRef.current?.parentElement?.remove();
    const currentRef = divRef.current;
    
    currentRef?.addEventListener('transitionend', callback);
    return () => currentRef?.removeEventListener('transitionend', callback);
  }, []);

  return <div style={style} ref={divRef} className='wave' />;
};

接下来就是对类名wave和wave-hide进行相应的样式编写了,如下所示:

css 复制代码
.wave {
    box-shadow: 0 0 0 5px currentColor;
    position: absolute;
    background-color: transparent;
    pointer-events: none;
    box-sizing: border-box;
    opacity: 0.2;
    transition: box-shadow 2s ease, opacity 2s ease;
}

.wave-hide {
    opacity: 0;
    box-shadow: 0 0 0 2px currentColor;
}

最终实现的效果如下所示,效果还不错:

按钮涟漪效果

思路引进:除了react开发来常用的组件库就是,一些其他的组件库如varlet也是有比较有特点的按钮点击的反馈效果的,如下图所示点击按钮有一个类似涟漪的效果,今天我们就探讨一下该功能具体是如何实现的:

从f12控制台查看可以看出,当我们点击按钮之后,出现的涟漪效果就是一个div元素,并且该元素是随着涟漪的显示与隐藏同步发生的,当涟漪消失的时候对于的div元素也会被销毁掉:

代码实现:按照上面的这种方式,接下来我们通过代码来实现上面的效果,这里我们借助react框架来实现吧,实现思路就是点击按钮的时候会在点击的地方生成一个小圆圆,然后这个圆圆圈的动画是逐渐放大,不透明度是越来越低然后就模拟成了波纹,最后再把这个按钮剩余的区域隐藏掉即可,实现的代码如下所示:

javascript 复制代码
// 确保容器样式正确
node.style.position = node.style.position || 'relative';
node.style.overflow = 'hidden';

// 定义小波样式
const color = 'rgba(255, 255, 255, 0.3)';
const duration = 1000;
const rect = node.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

const ripple = document.createElement('div');

// 应用内联样式
Object.assign(ripple.style, {
    position: 'absolute',
    width: '2px',
    height: '2px',
    left: `${x}px`,
    top: `${y}px`,
    borderRadius: '50%',
    backgroundColor: color,
    transform: 'scale(0)',
    opacity: '1',
    pointerEvents: 'none',
    transition: `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`
});

node.appendChild(ripple);

// 触发动画
requestAnimationFrame(() => {
    const maxDim = Math.max(node.offsetWidth, node.offsetHeight);
    ripple.style.transform = `scale(${maxDim * 2})`;
    ripple.style.opacity = '0';
});

// 动画结束后移除元素
setTimeout(() => ripple.remove(), duration);

实现的效果如下所示:

封装通用组件

因为这里我们封装的是一个通用组件,所以这里我们可以将上面的按钮效果合并在一起,并通过传递props来确保用户当前要选择哪种按钮反馈效果,如果后期还需加其他按钮效果的话也可以继续在代码上添加,这里定义的props内容如下:

TypeScript 复制代码
import Wave from "./components/button/wave"

const style: React.CSSProperties = {
    width: 200,
    height: 100,
    borderRadius: '10px 0 20px 0',
    margin: 100,
    backgroundColor: '#1677ff',
    outline: 'none',
    border: 'none',
}

const App = () => {
  return (
    <>
      <Wave feedback="ripple">
        <button style={style}>ripple点击</button>
      </Wave>
      <Wave feedback="wavelet">
        <button style={{ ...style, backgroundColor: 'red' }}>wavelet点击</button>
      </Wave>
      <Wave feedback="none">
        <button style={style}>none点击</button>
      </Wave>
      <Wave>
        <button style={style}>正常点击</button>
      </Wave>
    </>
  )
}

export default App

这里给出四种情况的点击效果:

为了简化代码,这里我将不再设置class类名而是直接给添加的子元素设置样式style,也就是说代码样式全用js来实现,不再使用单独的css文件来处理样式,也就是说封装的组件就是一个单独的tsx文件,完整代码如下所示:

TypeScript 复制代码
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom/client';

interface WaveProps {
    children: React.ReactNode;
    feedback?: 'ripple' | 'wavelet' | 'none' | undefined;
}

// 定义波纹基础样式
const waveBaseStyles: React.CSSProperties = {
    boxShadow: '0 0 0 5px currentColor',
    position: 'absolute',
    backgroundColor: 'transparent',
    pointerEvents: 'none',
    boxSizing: 'border-box',
    opacity: 0.2,
    transition: 'box-shadow 2s ease, opacity 2s ease'
};

// 定义波纹隐藏状态样式
const waveHideStyles: React.CSSProperties = {
    ...waveBaseStyles,
    opacity: 0,
    boxShadow: '0 0 0 2px currentColor'
};

const WaveEffect: React.FC<{ target: HTMLElement }> = ({ target }) => {
    const divRef = useRef<HTMLDivElement>(null);
    const [style, setStyle] = useState<React.CSSProperties>({});
    const [isHidden, setIsHidden] = useState(false);

    // 颜色验证和获取合并
    const getWaveColor = (node: HTMLElement, defaultColor = 'blue') => {
        const { borderColor, backgroundColor } = getComputedStyle(node);
        const isValid = (color: string) => color && !['transparent', 
            '#fff', '#ffffff', 'rgba(255, 255, 255, 1)', 'rgb(255, 255, 255)', // 白色系列
            '#000', '#000000', 'rgba(0, 0, 0, 1)', 'rgb(0, 0, 0)' // 黑色系列
        ].includes(color); 
        return isValid(backgroundColor) ? backgroundColor : isValid(borderColor) ? borderColor : defaultColor;
    };
    // 同步样式信息
    const syncStyle = () => {
        const {
            position, borderTopWidth, borderLeftWidth,
            borderTopLeftRadius, borderTopRightRadius,
            borderBottomRightRadius, borderBottomLeftRadius
        } = getComputedStyle(target);
        
        // 判断是否为静态定位,如果是则使用偏移量作为top和left值
        const isStatic = !position || position === 'static';
        setStyle({
            width: target.offsetWidth,
            height: target.offsetHeight,
            top: isStatic ? target.offsetTop : -parseFloat(borderTopWidth),
            left: isStatic ? target.offsetLeft : -parseFloat(borderLeftWidth),
            color: getWaveColor(target),
            borderRadius: [
                borderTopLeftRadius, borderTopRightRadius,
                borderBottomRightRadius, borderBottomLeftRadius
            ].join(' ')
        });
    };

    useEffect(() => {
        // 监听尺寸变化,同步样式信息
        const ob = new ResizeObserver(syncStyle);
        ob.observe(target);
        
        // 延迟执行,确保样式已经同步
        const id = requestAnimationFrame(() => {
            setIsHidden(true);
            syncStyle();
        });
        
        // 清理函数
        return () => {
            ob.disconnect();
            cancelAnimationFrame(id);
        };
    }, [target]);

    useEffect(() => {
        // 监听过渡结束,移除波纹元素
        const callback = () => divRef.current?.parentElement?.remove();
        const currentRef = divRef.current;

        currentRef?.addEventListener('transitionend', callback);
        return () => currentRef?.removeEventListener('transitionend', callback);
    }, []);

    // 合并基础样式、动态样式和隐藏状态样式
    const combinedStyles = {
        ...waveBaseStyles,
        ...style,
        ...(isHidden ? waveHideStyles : {})
    };

    return <div style={combinedStyles} ref={divRef} />;
};

const Wave: React.FC<WaveProps> = (props) => {
    const { children, feedback } = props;
    const containerRef = useRef<HTMLDivElement>(null);

    if (Array.isArray(children)) {
        throw new Error('Wave component only accepts a single child element');
    }
    if (!React.isValidElement(children)) {
        return <>{children}</>;
    }

    useEffect(() => {
        const node = containerRef.current;
        if (!node || node.nodeType !== 1 || node.getAttribute('disabled')) return;
        if (feedback === 'none') return;

        const handleClick = (e: MouseEvent) => {
            if (feedback === 'wavelet') {
                // 确保容器样式正确
                node.style.position = node.style.position || 'relative';
                node.style.overflow = 'hidden';

                // 定义小波样式
                const color = 'rgba(255, 255, 255, 0.3)';
                const duration = 1000;
                const rect = node.getBoundingClientRect();
                const x = e.clientX - rect.left;
                const y = e.clientY - rect.top;

                const ripple = document.createElement('div');

                // 应用内联样式
                Object.assign(ripple.style, {
                    position: 'absolute',
                    width: '2px',
                    height: '2px',
                    left: `${x}px`,
                    top: `${y}px`,
                    borderRadius: '50%',
                    backgroundColor: color,
                    transform: 'scale(0)',
                    opacity: '1',
                    pointerEvents: 'none',
                    transition: `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`
                });

                node.appendChild(ripple);

                // 触发动画
                requestAnimationFrame(() => {
                    const maxDim = Math.max(node.offsetWidth, node.offsetHeight);
                    ripple.style.transform = `scale(${maxDim * 2})`;
                    ripple.style.opacity = '0';
                });

                // 动画结束后移除元素
                setTimeout(() => ripple.remove(), duration);
            }
            
            if (feedback === 'ripple' || feedback === undefined) {
                const warpper = document.createElement('div');
                Object.assign(warpper.style, {
                    position: 'absolute',
                    top: '0',
                    left: '0'
                });
                
                node.insertBefore(warpper, node.firstChild);
                const root = ReactDOM.createRoot(warpper);
                root.render(<WaveEffect target={node} />);
            }
        };

        node.addEventListener('click', handleClick, true);
        return () => {
            node.removeEventListener('click', handleClick, true);
        };
    }, [feedback]);

    // 克隆子元素并添加ref
    return React.cloneElement(children as React.ReactElement, { 
        ref: containerRef as React.RefObject<HTMLDivElement> 
    });
};

export default Wave;

最终实现的效果如下所示:

最后总结

从效果上看也是实现了antd的一个按钮反馈效果,而且我设置的通用组件也是沿用了antd的效果并且组件默认也是波纹效果,如果想选择其他反馈效果或者取消反馈效果的话,也是通过我设置的feedback属性来进行控制,后期如果想添加新的反馈效果也可以继续添加,ok,今天的分享就到这里,感兴趣的朋友可以点个关注,我们下期再见!