Vue3瀑布流布局实现

Vue3瀑布流布局实现

介绍

以下是使用Vue3TypeScript实现的瀑布流,采用hook方式。如果这篇文章启发到你,你可以尝试使用class重构一下实现,加深理解。废话不多说,直接上代码!
未完成的功能(待更新):
1. 断点响应 🚀
2. 数据懒加载
3. Loading加载层效果
4. 拖拽自定义布局

思路

  1. 获取容器的宽度,根据列计算出每一个item的宽度,并初始化每一项的宽。
  2. 根据列数,将第一行column个排列(先排一行),并将这一行的每一个元素的高度存储到colHeight中。
  3. 找出colHeight中高度最小的一个,并获取最小高度值和索引值。索引值和宽度得出left,最小高度和间距得到top,最后排列一个item
  4. 重复步骤3。

准备

为了代码更简洁优雅一点,所以必须要封装几个工具函数,以及类型的定义

/**
 * @description: 获取元素样式
 * @param {HTMLElement} element 元素
 * @param {string} styleName 样式名称
 * @return {any}
 */
export const getStyle = (element: HTMLElement, styleName: string): any => {
    return getComputedStyle ? getComputedStyle(element).getPropertyValue(styleName) : element.style[styleName as any];
}

/**
 * @description: 设置元素样式
 * @param {HTMLElement} el 元素
 * @param {string} key 样式名称 | 样式对象
 * @param {string} value 样式值
 * @return {void}
 */
export const injectStyle = (el: HTMLElement, key: string | CSSProperties, value?: string):void => {
    if(typeof key === 'string') {
        el.style[key as any] = value;
    }else {
        Object.keys(key).forEach((k) => {
            el.style[k as any] = key[k];
        })
    }
}

/**
 * @description: 设置元素属性
 * @param {HTMLElement} el  元素
 * @param {string} key 属性名称 | 属性对象
 * @param {string} value 属性值
 * @return {void}
 */
export const injectProp = (el: HTMLElement, key: string | CSSProperties, value?: string):void => {
    if(typeof key === 'string') {
        el.setAttribute(key, value);
    }else {
        Object.keys(key).forEach((k) => {
            el.setAttribute(k, key[k]);
        })
    }
}
export interface IUseWaterfallOptionsBreakpoint {
    point: number // 断点
    column: number // 列数
    gap: number // 间隙
}

export interface IUseWaterfallOptions {
    container: string | HTMLElement | null // 容器
    column: number // 列数
    gap: number // 间隙
    isResize: boolean // 是否使用resize监听
    isLazy: boolean // 是否使用懒加载
    isLoadingLayer: boolean // 是否使用loading层

    resizeDelay: number // resize延迟时间
    resizeTimer: number // resize定时器

    scrollDelay: number // scroll延迟时间  懒加载使用
    scrollTimer: number // scroll定时器  懒加载使用

    leftAnimateDelay: number // 左侧动画延迟时间
    topAnimateDelay: number // 上侧动画延迟时间
    animateEase: import('vue').CSSProperties['animation-timing-function'] // 动画缓动函数

    breakpoint: Partial<IUseWaterfallOptionsBreakpoint> & Pick<IUseWaterfallOptionsBreakpoint, 'point'>[] // 断点
}

export interface IUseWaterfallResult {
    raw: Partial<IUseWaterfallOptions>,
    colWidth: number, // 列宽
    colHeight: number[], // 列高度
    children: HTMLCollection, // 子元素集合

    getColWidth: () => number, // 获取列宽
}

实现

export const useWaterfall = (options: Partial<IUseWaterfallOptions>): IUseWaterfallResult => {

    if (!options.container) throw new Error('Waterfall:container is required!');

    const defaultOpts: Partial<IUseWaterfallOptions> = {
        column: 4,
        gap: 20,
        isResize: true,
        resizeDelay: 500,
        leftAnimateDelay: 300,
        topAnimateDelay: 300,
        animateEase: 'ease'
    }
    // options保留默认设置和用户参数设置
    options = { ...defaultOpts, ...options };

    // opts用于动态更改设置
    const opts: Partial<IUseWaterfallOptions> = reactive({
        ...JSON.parse(JSON.stringify(options)), ...defaultOpts
    })

    let children: HTMLCollection = null; // 子元素集合
    let colWidth = 0; // 列宽
    let containerObserver: MutationObserver = null; // 容器监测
    const colHeight: number[] = []; // 列高度

    /**
     * @description 获取列宽
     * @return {number} 列宽
     */
    const getColWidth = (): number => {
        const containerWidth = parseInt(getStyle(opts.container as HTMLElement, 'width'), 10);
        const { column, gap } = opts;
        return (containerWidth - (column - 1) * gap) / column;
    }

    onMounted(() => {
        if (typeof opts.container === 'string') {
            opts.container = document.querySelector(opts.container) as HTMLElement;
        }
        colWidth = getColWidth();
        const containerStyle: CSSProperties = {
            position: 'relative',
            width: '100%',
            height: '100%',
            overflow: 'hidden',
            boxSizing: 'content-box',
            paddingBottom: `${opts.gap}px`,
        }
        const itemStyle: CSSProperties = {
            position: 'absolute',
            top: 0,
            left: 0,
            transition: `left ${opts.leftAnimateDelay}ms ${opts.animateEase}, top ${opts.topAnimateDelay}ms ${opts.animateEase}`,
            zIndex: 1,
            width: `${colWidth}px`,
        }

        children = opts.container.children;
        injectStyle(opts.container, containerStyle);
        Array.from(children).forEach((item: any, index: number) => {
            injectStyle(item, itemStyle);
            injectProp(item, 'data-loaded', 'false');
            injectProp(item, 'data-index', index.toString());
        });
    })

    /**
     * @description 初始化布局
     * @param {object} o - 参数
     * @param {boolean} o.force - 是否强制执行 默认false 为true时会重新计算整个瀑布流
     * @return {void}
     */
    const initLayout = (
        o: { force: boolean; column?: number; gap?: number } = { force: false }): void => {

        const { force, column = opts.column, gap = opts.gap } = o;

        // 强制执行,表示重新计算整个瀑布流
        if (force) colHeight.length = 0;

        colWidth = getColWidth();

        let top = 0;
        let left = 0;

        for (let i = 0; i < children.length; i++) {
            const el = children[i] as HTMLElement;

            // eslint-disable-next-line no-continue
            if (el.dataset.loaded === 'true' && !force) continue;

            injectStyle(el, 'width', `${colWidth}px`);  // 需要预先设置宽度,高度才能被正确计算

            // eslint-disable-next-line no-use-before-define
            setTimeout(() => layout(el, i), 300);
        }

        function layout(el: HTMLElement, i: number) {
            const height = el.clientHeight || parseInt(getStyle(el, 'height'), 10);
            if (i >= column) {
                const minIndex = colHeight.indexOf(Math.min(...colHeight));
                top = colHeight[minIndex] + gap;
                left = minIndex * colWidth + gap * minIndex;
                colHeight[minIndex] = top + height;
            } else {
                colHeight[i] = height;
                top = 0;
                left = i * colWidth + gap * i;
            }
            injectStyle(el, {
                position: 'absolute',
                top: `${top}px`,
                left: `${left}px`,
            })
            injectProp(el, 'data-loaded', 'true');
            injectStyle(opts.container as HTMLElement, 'height', `${Math.max(...colHeight)}px`);
        }
    }


    /**
     * @description: 处理响应宽度断点
     * @return {void}
     */
    const processBreakpoint = (): void => {
        if (opts.breakpoint.length) {
            opts.breakpoint.forEach(item => {
                if (window.innerWidth <= item.point) {
                    opts.column = item.column || options.column;
                    opts.gap = item.gap || options.gap;
                }
            })
            // 如果当前宽度比断点最大值还大,就恢复默认
            if (window.innerWidth > opts.breakpoint[0].point) {
                opts.column = options.column;
                opts.gap = options.gap;
            }
        }
    }

    /**
     * @description: 监测容器的子项,处理新增项
     * @return {void}
     */
    const processContainerObserver = (): void => {
        containerObserver = new MutationObserver(() => {
            initLayout();
        });
        containerObserver.observe(opts.container as HTMLElement, {
            subtree: false,
            attributes: false,
            characterData: false,
            childList: true
        });
    }


    /**
     * @description resize事件处理函数
     * @return {void}
     */
    const handleResize = (): void => {
        clearTimeout(opts.resizeTimer);
        opts.resizeTimer = setTimeout(() => {
            processBreakpoint();
            initLayout({ force: true });
        }, opts.resizeDelay);
    }

    onMounted(() => {
        // 等待300ms,保证元素完全渲染
        setTimeout(() => {
            processContainerObserver();
            if (opts.breakpoint.length) processBreakpoint();
            initLayout();
            if (opts.isResize) window.addEventListener('resize', handleResize);
        }, 300);
    })

    onUnmounted(() => {
        if (opts.isResize) window.removeEventListener('resize', handleResize);
        if(containerObserver) containerObserver.disconnect();
    });

    return {
        raw: opts,
        colWidth,
        colHeight,
        children,

        getColWidth,
    }
}

使用

useWaterfall({
  containerClass: 'y-waterfall__container',
});
  • 10
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值