react手写滚动条效果

封装的一个完整的select组件实现手写滚动条效果不适用原生的scroll,使用div仿原生滚动条效果。
主要的难点就是计算滑块的一个高度,根据不同的内容大小同比例算出滑块在容器中的一个比例。
其次是滑块滑动的距离也是一个比值,计算的是滑块在可滑动轨道的一个滑动比值,滑动滑块的时候同时也需要滑动内容区,内容区的滑动距离也是一个比值。需要注意滑动滑块的那个距离是不能用作直接设置内容区的scrollTop一定也是一个比值。

类比就是 滑块的一个大小/滑块可以滑动的一个大小=容器的一个大小/容器可滑动的一个大小


import { DownOutlined, UpOutlined } from '@ant-design/icons';
import classNames from 'classnames';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import './transition.css';

export type Option = {
  label: string;
  value: string | number | symbol;
  disabled: boolean;
};
export type OptionType = Omit<Option, 'disabled'> &
  Partial<Pick<Option, 'disabled'>>;

export type Iprops = {
  style: Record<string, any>;
  onChange: (value: OptionType) => void;
  classnameSelect: string;
  size?: string;
  placeholder: string;

  disabled: boolean;
  option: OptionType[];
  emptyImg: string;
};

const Select: React.FC<Partial<Iprops>> = (props: any) => {
  const {
    style,
    onChange,
    classnameSelect,
    size,
    placeholder,
    disabled,
    option,

    emptyImg,
  } = props;
  const [focused, setFocused] = useState<boolean>(false);
  const [chooseStyle, setChooseStyle] = useState<Record<string, any>>({
    display: 'none',
  });

  const contentRef = useRef<HTMLDivElement>(null);

  const selectRef = useRef<HTMLDivElement>(null);
  const chooseRef = useRef<HTMLDivElement>(null);
  const scrollbar = useRef<HTMLDivElement>(null); //获取可视区

  const scrollInner = useRef<HTMLDivElement>(null);
  const scrollContent = useRef<HTMLDivElement>(null);
  const scrollContentNode = useRef<HTMLDivElement>(null);

  const [scrollStyle, setScrollStyle] = useState({});
  const [scrollY, setScrollY] = useState(0);

  const [isMouseDown, setIsMouseDown] = useState(false); // 状态用于记录鼠标是否按下
  const [scrollPosition, setScrollPosition] = useState(0); // 状态用于记录滚动位置
  const [contentDistance, setContentDistance] = useState(0); // 状态用于记录滚动位置

  const classnameContent = classNames('select-content', {
    ['ant-select-focused']: focused,
    [classnameSelect]: classnameSelect,
    [`ant-select-size-${size}`]: size,
    [`ant-select-disabled`]: disabled,
  });
  const classnamee = classNames('choose-content', []);
  const [value, setValue] = useState<string | number>();

  useEffect(() => {
    function handleClickOutside(event: Record<string, any>) {
      if (
        selectRef.current &&
        !selectRef.current?.contains(event.target) &&
        !chooseRef.current?.contains(event.target)
      ) {
        setFocused(false);
      }
    }
    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [selectRef]);

  const onResize = (e) => {
    const { width } = selectRef.current?.getBoundingClientRect();
    setChooseStyle({
      ...chooseStyle,
      width: width + 'px',
      height: 'auto',
      marginTop: 5 + 'px',
    });
  };
  useEffect(() => {
    window.addEventListener('resize', onResize);

    return () => {
      window.removeEventListener('resize', onResize);
    };
  }, [onResize]);
  useEffect(() => {
    scrollContentNode.current?.classList.add('scrollHover');
    if (focused) {
      const divClient = scrollbar.current!.clientHeight;
      //滑块的大小的
      const distance =
        (divClient * divClient) /
        (contentRef.current!.clientHeight - divClient);
      setScrollStyle({
        height: `${Math.floor(
          (scrollbar?.current!.clientHeight /
            (contentRef?.current!.clientHeight -
              scrollbar.current!.clientHeight +
              8)) *
            100,
        )}%`,
        transform: `translateY(${
          (contentDistance / (contentRef.current!.clientHeight - divClient)) *
          ((divClient - distance) / distance) *
          100
        }%)`,
      });
      const flag =
        contentRef.current!.clientHeight >
        Number(scrollInner.current!.style.maxHeight?.split('px')[0]);
      console.log(flag);
      if (!flag) {
        scrollContentNode.current?.classList.remove('scrollHover');
      }
      scrollContentNode.current!.style.opacity = flag ? '1' : '0'; //断言
    }
  }, [focused]);

  const mousemoveFun = useCallback(
    (e) => {
      if (isMouseDown) {
        // 根据滑动距离更新滚动位置

        setScrollY(scrollY + e.clientY - scrollPosition);

        const divClient = scrollbar.current!.clientHeight;
        //滑块的大小的
        const distance =
          (divClient * divClient) /
          (contentRef.current!.clientHeight - divClient);

        const contentDistanceScroll =
          ((scrollY + e.clientY - scrollPosition) *
            (contentRef?.current!.clientHeight - divClient)) /
          (divClient - distance);
        setContentDistance(contentDistanceScroll);
        scrollInner.current!.scrollTop = contentDistanceScroll;
      }
    },
    [isMouseDown],
  );
  //计算滑块滑动的距离
  useEffect(() => {
    if (scrollbar.current && contentRef.current) {
      const divClient = scrollbar.current!.clientHeight;

      //滑块的高度 页面滑动距离/(滑动容器距离-视口距离)=滑块滑动距离/(视口距离-滑块高度)
      const distance =
        (divClient * divClient) /
        (contentRef.current!.clientHeight - divClient);

      setScrollStyle({
        ...scrollStyle,
        transform: `translateY(${
          (contentDistance / (contentRef.current!.clientHeight - divClient)) *
          ((divClient - distance) / distance) *
          100
        }%)`,
      });

      if (!isMouseDown) {
        document.removeEventListener('mousemove', mousemoveFun);
      } else {
        document.addEventListener('mousemove', mousemoveFun);
      }
    }

    return () => {
      if (document) {
        document.removeEventListener('mousemove', mousemoveFun);
      }
    };
  }, [isMouseDown, contentDistance]);
  return (
    <>
      <div
        ref={selectRef}
        onClick={(e: any) => {
          if (disabled) {
            return;
          }
          setFocused(true);
          const { width, height } = selectRef.current?.getBoundingClientRect();
          setChooseStyle({
            width: width + 'px',
            height: 'auto',
            marginTop: 5 + 'px',
            padding: '4.0px 0',
          });
        }}
        style={style}
        className={classnameContent}
      >
        <div className="ant-select-selector">
          <span className="ant-select-selection-search">
            <input className="ant-select-selection-search-input" />
          </span>
          {value ? (
            <span className="ant-select-selection-item">{value}</span>
          ) : (
            <span className="ant-select-selection-placeholder">
              {placeholder}
            </span>
          )}
        </div>
        <span className="ant-select-arrow">
          <span>
            {focused ? (
              <UpOutlined
                style={{ color: 'rgb(0, 0, 0, 0.2)', fontSize: '12.0px' }}
              />
            ) : (
              <DownOutlined
                style={{ color: 'rgb(0, 0, 0, 0.2)', fontSize: '12.0px' }}
              />
            )}
          </span>
        </span>
        <CSSTransition
          in={focused}
          timeout={200}
          classNames={'my-node'}
          //可选属性,当动画出场后,在页面上移除包裹的节点
          //https://www.cnblogs.com/nimon-hugo/p/12781481.html
          onEnter={(el: Record<string, any>) => {
            //可选属性,动画进场后的回调,el指被包裹的dom
            el.style.display = 'block';
          }}
          onExited={(el: Record<string, any>) => {
            //出场后的回调,可以在吃操作setSate操作
            el.style.display = 'none';
          }}
        >
          <div
            onClick={(e) => {
              setFocused(true);
            }}
            ref={chooseRef}
            style={chooseStyle}
            className={classnamee}
          >
            <div ref={scrollbar} className="ant-scrollbar">
              <div
                onScroll={(e) => {
                  setContentDistance(e.currentTarget!.scrollTop);
                }}
                style={{
                  maxHeight: '255.0px',
                  overflow: 'scroll',
                  overflowAnchor: 'none',
                  marginRight: '-18.0px',
                  marginBottom: '-18.0px',
                }}
                className="rc-virtual-list-holder-inner"
                ref={scrollInner}
              >
                <div ref={contentRef}>
                  {option.length !== 0 ? (
                    option?.map((item: OptionType) => (
                      <div
                        key={item.value}
                        title={item.label}
                        onClick={(e) => {
                          setValue(item.value);
                          setFocused(false);
                          onChange?.(item);
                          e.stopPropagation();
                        }}
                        className={classNames('ant-select-item', [
                          {
                            'ant-select-option-selected': item.value === value,
                          },
                        ])}
                      >
                        <div className="ant-select-item-option-content">
                          {item.label}
                        </div>
                      </div>
                    ))
                  ) : (
                    <div
                      style={{
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                      }}
                    >
                      <img src={emptyImg} />
                    </div>
                  )}
                </div>
              </div>
              <div ref={scrollContentNode} className="ant-scroll">
                <div
                  style={scrollStyle}
                  onMouseDown={(e) => {
                    setScrollPosition(e.clientY);

                    setIsMouseDown(true);

                    scrollContentNode.current!.style.opacity = '1'; //断言

                    document.addEventListener('mouseup', (ev) => {
                      setIsMouseDown(false);
                      scrollContentNode.current!.style.opacity = '0';
                      scrollContentNode.current?.classList.add('scrollHover');

                      document.removeEventListener('mousemove', mousemoveFun);
                    });
                  }}
                  className="ant-scroll-content"
                  ref={scrollContent}
                ></div>
              </div>
            </div>
          </div>
        </CSSTransition>
      </div>
    </>
  );
};
export default Select;

Select.defaultProps = {
  placeholder: '请选择',
  option: [],
  emptyImg: 'https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg',
};

在这里插入图片描述

.my-node-enter {
  opacity: 0;
}
.my-node-enter-active,
.my-node-enter-done {
  animation: antSlideUpIn 0.2s forwards ease-in-out;
}
.my-node-exit {
  opacity: 1;
}
.my-node-exit-active,
.my-node-exit-done {
  animation: antSlideUpOut 0.2s forwards  ease-in-out;
}

@keyframes antSlideUpIn {
  0% {
    transform: scaleY(0.8);
    transform-origin: 0% 0%;
    opacity: 0;
  }

  to {
    transform: scaleY(1);
    transform-origin: 0% 0%;
    opacity: 1;
  }
}

@keyframes antSlideUpOut {
  0% {
    transform: scaleY(1);
    transform-origin: 0% 0%;
    opacity: 1;
  }

  to {
    transform: scaleY(0.8);
    transform-origin: 0% 0%;
    opacity: 0;
  }
}

这就是一个最终的实现效果,滚动条支持点击滑动,左边内容区也能同步滑动,鼠标滚动同时也支持,手写的滚动条同步滚动。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐