Giter Site home page Giter Site logo

blogs's People

Contributors

nnmax avatar

Stargazers

 avatar  avatar  avatar

Watchers

 avatar

Forkers

renovate-bot

blogs's Issues

使用 JavaScript 实现按钮的涟漪效果

使用 JavaScript 实现按钮的涟漪效果

不知道你们有没有使用过 Material UI。这是一个 React UI 组件库,它实现了 Google 的 Material Design。

Material Design 设计规范中包含了很多关于点击的涟漪效果,类似于一块石头跌落水中所产生的波浪效果。

以下是效果图

点击效果

长按效果

键盘焦点效果

速度放慢之后的效果:

速度放慢后的效果

我们把 overflow: hidden 去掉之后:
去掉 overflow hidden 后的效果

本文就以 Material Design 中的涟漪效果作为目标,来使用原生的 JavaScript、CSS、HTML 来实现此效果。

分析

通过观察,我们可以发现点击的涟漪效果是在鼠标点击的点开始以一个正圆往外扩散。当圆形扩散到正好能将 Button 全部包围住的时候停止,在扩散的过程中颜色逐渐变浅直到消失,并且此效果可以叠加。

长按效果也是一个圆往外扩散,只不过是在长按结束之后,圆才会消失。

除了鼠标点击效果外,还有键盘焦点事件的效果。当使用键盘的 Tab 键切换到按钮上的时候,会有一个抖动的效果,类似于呼吸效果。

我们提炼出几个比较关键的点:

  1. 从鼠标点击的位置开始扩散;
  2. 是一个正圆形;
  3. 圆形扩散到正好能将 Button 全部包围住的时候停止;
  4. 长按效果;
  5. 效果叠加;

第一点,我们可以通过 JavaScript 计算当前鼠标的坐标信息;第三点,获取 Button 四个顶点的坐标信息,再选一个距离鼠标最远的点,以它们的距离作为半径来画一个圆;第五点,每一个效果都是一个 dom 元素,每点击一次就追加一个 dom 元素,在动画结束的时候,移除此 dom;

实现

创建一个 index.html 文件,包含以下内容;

<!-- index.html -->
<style>
  @import 'button.css';
  @import 'ripple.css';
</style>

<button class="button-root" id="ripple-example-button" type="button">
  Button
  <!-- 用来装涟漪效果的 DOM 的容器 -->
  <span class="ripple-root"></span>
</button>

创建 button.css 和 ripple.css,分别是 Button 的基础样式和涟漪效果的样式。

/* button.css */
.button-root {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 6px 16px;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.75;
  min-width: 64px;
  margin: 0;
  border-radius: 4px;
  border: 1px solid rgba(25, 118, 210, 0.5);
  cursor: pointer;
  box-sizing: border-box;
  outline: none;
  appearance: none;
  user-select: none;
  color: #1976d2;
  background-color: transparent;
  transition-property: background-color, color, box-shadow, border-color;
  transition-duration: 0.25s;
}

.button-root:hover {
  background-color: rgba(25, 118, 210, 0.04);
  border: 1px solid #1976d2;
}
/* ripple.css */
@keyframes enterKeyframe {
  0% {
    transform: scale(0);
    opacity: 0.1;
  }

  100% {
    transform: scale(1);
    opacity: 0.3;
  }
}

@keyframes exitKeyframe {
  0% {
    opacity: 1;
  }

  100% {
    opacity: 0;
  }
}

@keyframes pulsateKeyframe {
  0% {
    transform: scale(0.9);
  }

  50% {
    transform: scale(0.8);
  }

  100% {
    transform: scale(0.9);
  }
}

.ripple-root {
  display: block;
  position: absolute;
  overflow: hidden;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  pointer-events: none;
  background-color: transparent;
  z-index: 0;
  border-radius: inherit;
}

.ripple-root > .ripple-child {
  position: absolute;
  display: block;
  opacity: 0;
}

.ripple-root > .ripple-child.enter {
  opacity: 0.3;
  transform: scale(1);
  animation: enterKeyframe 550ms ease-in-out;
}

.ripple-root > .ripple-child > .ripple-child-child {
  opacity: 1;
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background-color: currentColor;
}

.ripple-root > .ripple-child.exit > .ripple-child-child {
  opacity: 0;
  animation: exitKeyframe 550ms ease-in-out;
}

.ripple-root > .ripple-child.pulsate > .ripple-child-child {
  position: absolute;
  left: 0;
  top: 0;
  animation: pulsateKeyframe 2500ms ease-in-out 200ms infinite;
}

开始写 JavaScript。创建一个 ripple.apis.js 文件,编写 startRipple 函数。该函数首先要获取 Button 的位置信息和宽高。

// ripple.apis.js
export function startRipple(event) {
  const { currentTarget: container } = event
  const { left, top, width, height } = container.getBoundingClientRect()
}

接着计算开始扩散的位置。

// ripple.apis.js
export function startRipple(event) {
  // ...
  // 效果开始的坐标(相对于 Button)
  let rippleX, rippleY
  // 鼠标当前的坐标
  let clientX = 0, clientY = 0
 
 /**
  * 涟漪效果是否从节点的中心扩散,否则从鼠标点击的位置开始扩散
  * 使用 Tab 键移动焦点的时候,从节点的中心扩散
  */
  let center = false
  let isFocusVisible = false
 
  if (container.matches(':focus-visible')) {
    center = isFocusVisible = true
  } else {
    clientX = event.clientX
    clientY = event.clientY
  }
 
  rippleX = center ? width / 2 : clientX - left
  rippleY = center ? height / 2 : clientY - top
}

通过勾股定理,构造一个能正好包围当前元素的圆。

// ripple.apis.js
export function startRipple(event) {
  // ...
  // 从鼠标点击的中心位置,构造一个能正好包围当前元素的圆
  const sizeX = Math.max(width - rippleX, rippleX) * 2
  const sizeY = Math.max(height - rippleY, rippleY) * 2
  const diagonal = Math.sqrt(sizeX ** 2 + sizeY ** 2)
}

再创建一个 createRippleChild 函数,用来创建涟漪效果的 DOM,并且使用一个全局变量来保存已经创建的 DOM。

// ripple.apis.js
const rippleChildren = []

/**
 * 创建以下结构并返回:
 * <span class="ripple-child enter">
 *   <span class="ripple-child-child"></span>
 * </span>
 */
function createRippleChild(rect) {
  const rippleChild = document.createElement('span')
  rippleChild.classList.add('ripple-child', 'enter')
  const rippleChildChild = document.createElement('span')
  rippleChildChild.classList.add('ripple-child-child')
  rippleChild.appendChild(rippleChildChild)

  const { height, left, top, width } = rect
  rippleChild.style.height = height
  rippleChild.style.width = width
  rippleChild.style.top = top
  rippleChild.style.left = left

  rippleChildren.push(rippleChild)

  return rippleChild
}

回到 startRipple 函数,使用刚才创建的 createRippleChild 函数。

// ripple.apis.js
export function startRipple(event) {
  // ...
  const rippleChild = createRippleChild({
    width: `${diagonal}px`,
    height: `${diagonal}px`,
    left: `${-diagonal / 2 + rippleX}px`,
    top: `${-diagonal / 2 + rippleY}px`,
  })
  if (isFocusVisible) {
    rippleChild.classList.add('pulsate')
  }
  const rippleRoot = container.querySelector(':scope > .ripple-root')
  rippleRoot.appendChild(rippleChild)
}

完成了 startRipple 函数之后,我们再创建一个 stopRipple 函数。该函数中,从 rippleChildren 取出最早创建的 DOM,添加一个动画结束的监听事件,在动画结束的时候,删除该 DOM。

// ripple.apis.js
export function stopRipple() {
  const rippleChild = rippleChildren.shift()

  if (!rippleChild) return

  rippleChild.addEventListener('animationend', (event) => {
    if (event.animationName === 'exitKeyframe') {
      rippleChild.remove()
    }
  })
  rippleChild.classList.add('exit')
}

此时,我们已经完成了大部分的代码,接下来就是给 Button 绑定事件的时候了。在 index.html 文件中添加以下代码:

<!-- index.html -->
<style>
  @import 'button.css';
  @import 'ripple.css';
</style>

<script type="module">
  import { startRipple, stopRipple } from 'ripple.apis.js'

  const button = document.querySelector('#ripple-example-button')

  button.addEventListener('mousedown', startRipple)
  button.addEventListener('focus', startRipple)
  button.addEventListener('mouseup', stopRipple)
  button.addEventListener('mouseleave', stopRipple)
  button.addEventListener('blur', stopRipple)
</script>

<button class="button-root" id="ripple-example-button" type="button">
  Button
  <!-- 用来装涟漪效果的 DOM 的容器 -->
  <span class="ripple-root"></span>
</button>

我们完成了所有的功能!完整的代码在此仓库中。

也可以直接在 CodeSandbox 中编辑

Edit Button

将 Props 的更新同步到 State

将 Props 的更新同步到 State

前言

在您的项目中,是否有很多将 Props 或 Context 的更新同步到 State 的情况?

我们有多种方法处理这种情况。

  1. 使用 useEffect 监听变化;
  2. 使用 useRef 跟踪上一个值的变化;
  3. 封装自定义 Hooks;

使用 useEffect( ❌ 最好不要)

您是不是经常在项目中看到这样的用法?

import { useState, useRef, useEffect } from 'react';

const Users = ({ users }) => {

  const [internalUsers, setInternalUsers] = useState(users);

  useEffect(() => {
    setInternalUsers(users);
  }, [users]);

  return (
    <ul>
      {internalUsers.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

这几行代码看起来非常的优雅:使用 useEffect 的第二个参数监听 users 的变化,然后重新设置 state,完美!✌️

可是您有没有想过,上面的代码会额外的触发了一次重渲染。🤔️

每当 users 变化时,直到整个应用加载完 DOM 后,useEffect 里的函数才会执行。由于我们调用了 setInternalUsers,这又会导致我们的程序额外的重渲染一次。

为了给用户提供一个加载更快、体验更好的应用程序,请不要这样做。

使用 useRef( ✅ )

import { useState, useRef, useEffect } from 'react';

const Users = ({ users }) => {

  const [internalUsers, setInternalUsers] = useState(users);
  const previousUsersRef = useRef();

  if (users !== previousUsersRef.current && users !== internalUsers) {
    setInternalUsers(users);
  }

  useEffect(() => {
    previousUsersRef.current = users
  }, [users])

  return (
    <ul>
      {internalUsers.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

此写法用到了 useRef 来保存上一次更新的值,然后在渲染阶段判断更新前和更新后的值是否相同,如果不相同则设置 state。

从而在渲染前的阶段就完成了 state 的设置,避免了额外的渲染。

虽然比上一个写法多出了几行代码,但是在性能、用户体验(屏幕闪烁)上都优于第一种写法。

自定义 Hooks ( ✅ ✅ )

第二种写法虽然解决了我们的问题,但是其可维护性较低、耦合度较高。

这些逻辑全部写在了组件内部,后来的维护者们有可能因为修改其他问题,而使你写的功能失效或出现其他小问题;

或者您的组件不止监听一个 Props;

又或者其他组件也需要此功能;

对此,您应该将此逻辑抽离成自定义 Hooks。

// File: useStateListeningProp.ts
import { useState, useRef, useEffect } from "react";

type StateListeningPropResult<T> = [T, React.Dispatch<React.SetStateAction<T>>];

const useStateListeningProp = <T>(prop: T): StateListeningPropResult<T> => {
  const [state, setState] = useState<T>(prop);
  const previousPropRef = useRef<T>();

  if (prop !== previousPropRef.current && prop !== state) {
    setState(prop);
  }

  useEffect(() => {
    previousPropRef.current = prop;
  }, [prop]);

  return [state, setState];
};

export default useStateListeningProp;

像这样,就能在您的组件中使用了。

// File: Users.tsx
import React from "react";
import useStateListeningProp from "./useStateListeningProp";

type User = { id: string; name: string };

const Users: React.FC<{ users: User[] }> = (props) => {
  const { users: usersProp } = props;

  const [users, setUsers] = useStateListeningProp(usersProp);

  const addUser = (userName: User['name']) => {
    setUsers((oldUsers) =>
      oldUsers.concat({
        id: Date.now().toString(),
        name: userName
      })
    )
  }

  const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.code === "Enter") {
      addUser(event.currentTarget.value)
      event.currentTarget.value = "";
    }
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Enter Your Name"
        onKeyPress={handleKeyPress}
      />
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

本人开源了此自定义 hooks 的 npm 包 use-state-listening-prop,可以在您的项目中使用 npm install use-state-listening-prop 来安装并使用。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.