Grid 拖拽佈局實現

来源:https://www.cnblogs.com/nextl/archive/2023/12/02/17871913.html
-Advertisement-
Play Games

最近有個需求需要實現自定義首頁佈局,需要將屏幕按照 6 列 4 行進行等分成多個格子,然後將組件可拖拽對應格子進行渲染展示。 示例 對比一些已有的插件,發現想要實現產品的交互效果,沒有現成可用的。本身功能並不是太過複雜,於是決定自己基於 vue 手擼一個簡易的 Grid 拖拽佈局。 完整源碼在此,在 ...


最近有個需求需要實現自定義首頁佈局,需要將屏幕按照 6 列 4 行進行等分成多個格子,然後將組件可拖拽對應格子進行渲染展示。

示例

對比一些已有的插件,發現想要實現產品的交互效果,沒有現成可用的。本身功能並不是太過複雜,於是決定自己基於 vue 手擼一個簡易的 Grid 拖拽佈局。

完整源碼在此,線上體驗

概況

需要實現 Grid 拖拽佈局,主要瞭解這兩個東西就行

  • 拖放 API,關於拖放 API 介紹文章有很多 ,可以直接看 MDN 里拖放 API介紹,可以說很詳細了。
  • Grid 佈局, Grid 佈局與 Flex 佈局很相似,但是 Grid 像是二維佈局,Flex 則為一維佈局,Grid 佈局遠比 Flex 佈局強大。MDN 關於網格佈局介紹

需要實現主要包含:

  • 組件物料欄拖拽到佈局容器
  • 佈局容器 Grid 佈局
  • 放置時是否重疊判斷
  • 拖拽時樣式
  • 放置後樣式
  • 容器內二次拖拽

拖放操作實現

拖拽中主要使用到的事件如下

  • 被拖拽元素事件:
事件 觸發時刻
dragstart 當用戶開始拖拽一個元素或選中的文本時觸發。
drag 當拖拽元素或選中的文本時觸發。
dragend 當拖拽操作結束時觸發
  • 放置容器事件:
事件 觸發時刻
dragenter 當拖拽元素或選中的文本到一個可釋放目標時觸發。
dragleave 當拖拽元素或選中的文本離開一個可釋放目標時觸發。
dragover 當元素或選中的文本被拖到一個可釋放目標上時觸發。
drop 當元素或選中的文本在可釋放目標上被釋放時觸發。

可拖拽元素

讓一個元素能夠拖拽只需要給元素設置 draggable="true" 即可拖拽,拖拽事件 API 提供了 DataTransfer 對象,可以用於設置拖拽數據信息,但是僅僅只能 drop 事件中獲取到,但是我們需要在拖拽中就需要獲取到拖拽信息,用來顯示拖拽時樣式,所以需要我們自己存儲起來,以便讀取。

需要處理主要是,在拖拽時將 將當前元素信息設置到 dragStore 中,結束時清空當前信息

<script setup lang="ts">
  import { dragStore } from "./drag";

  const props = defineProps<{
    data: DragItem;
    groupName?: string;
  }>();

  const onDragstart = (e) => dragStore.set(props.groupName, { ...props.data });
  const onDragend = () => dragStore.remove(props.groupName);
</script>
<template>
  <div class="drag-item__el" draggable="true" @dragstart="onDragstart" @dragend="onDragend"></div>
</template>

封裝一個存儲方法,然後通過配置相同 key ,可以在同時存在多個放置區域時候,區分開來。

class DragStore<T extends DragItemData> {
  moveItem = new Map<string, DragItemData>();

  set(key: string, data: T) {
    this.moveItem.set(key, data);
  }

  remove(key: string) {
    this.moveItem.delete(key);
  }

  get(key: string): undefined | DragItemData {
    return this.moveItem.get(key);
  }
}

可放置區域

首先時需要告訴瀏覽器當前區域是可以放置的,只需要在元素監聽 dragenterdragleavedragover 事件即可,然後通過 preventDefault 來阻止瀏覽器預設行為。可以在這三個事件中處理判斷當前位置是否可以放置等等。

示例:

<script setup lang="ts">
  // 進入放置目標
  const onDragenter = (e) => {
    e.preventDefault();
  };

  // 在目標中移動
  const onDragover = (e) => {
    e.preventDefault();
  };

  // 離開目標
  const onDragleave = (e) => {
    e.preventDefault();
  };
</script>
<template>
  <div @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)"></div>
</template>

上面的代碼已經可以讓,元素可以拖拽,然後當元素拖到可防止區域時候,可以看到滑鼠樣式會變為可放置樣式了。

Grid 佈局

我們是需要進行 Grid 拖拽佈局,所以先對上面放置容器進行改造,首先就是需要將容器進行格子劃分區域顯示。

計算 Grid 格子大小

我這裡直接使用了 @vueuse/coreuseElementSize 的 hooks 去獲取容器元素大小變動,也可以自己通過 ResizeObserver 去監聽元素變動,然後根據設置列數、行數、間隔去計算單個格子大小。

import { useElementSize } from "@vueuse/core";

/**
 * 容器等分尺寸
 * @param {*} target 容器 HTML
 * @param {*} column 列數
 * @param {*} row 行數
 * @param {*} gap 間隔
 * @returns
 */
export const useBoxSize = (target: Ref<HTMLElement | undefined>, column: number, row: number, gap: number) => {
  const { width, height } = useElementSize(target);
  return computed(() => ({
    width: (width.value - (column - 1) * gap) / column,
    height: (height.value - (row - 1) * gap) / row,
  }));
};

設置 Grid 樣式

根據列數和行數迴圈生成格子數,rowCountcolumnCount為行數和列數。

<div class="drop-content__drop-container" @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)">
  <template v-for="x in rowCount">
    <div class="bg-column" v-for="y in columnCount" :key="`${x}-${y}`"></div>
  </template>
</div>

設置 Grid 樣式,下麵變數中 gap 為格子間隔,repeat 是 Grid 用來重覆設置相同值的,grid-template-columns: repeat(2,100px) 等效於 grid-template-columns: 100px 100px。因為我們只需在容器里監聽拖拽放置事件,所以我們還需要將
所有的 bg-column 事件去掉,設置 pointer-events: none 即可。

.drop-content__drop-container {
  display: grid;
  row-gap: v-bind("gap+'px'");
  column-gap: v-bind("gap+'px'");
  grid-template-columns: repeat(v-bind("columnCount"), v-bind("boxSize.width+'px'"));
  grid-template-rows: repeat(v-bind("rowCount"), v-bind("boxSize.height+'px'"));
  .bg-column {
    background-color: #fff;
    border-radius: 6px;
    pointer-events: none;
  }
}

效果如下:
Grid 容器樣式

放置元素

放置元素時我們需要先計算出元素在 Grid 位置信息等,這樣才知道元素應該放置那哪個地方。

拖拽位置計算

當元素拖拽進容器中時,我們可以通過 offsetXoffsetY兩個數據獲取當前滑鼠距離容器左上角位置距離,我們可以根據這兩個值計算出對應的在 Grid 中做坐標。

計算方式:

// 計算 x 坐標
const getX = (num) => parseInt(num / (boxSizeWidth + gap));
// 計算 y 坐標
const getY = (num) => parseInt(num / (boxSizeHeight + gap));

需要註意的是上面計算坐標是 0,0 開始的,而 Grid 是 1,1 開始的。

獲取拖拽信息

我們在進入容器時,通過上面封裝 dragData 來獲取當前拖拽元素信息,獲取它尺寸信息等等。

// 拖拽中的元素
const current = reactive({
  show: <boolean>false,
  id: <undefined | number>undefined,
  column: <number>0, // 寬
  row: <number>0, // 高
  x: <number>0, // 列
  y: <number>0, // 行
});

// 進入放置目標
const onDragenter = (e) => {
  e.preventDefault();
  const dragData = dragStore.get(props.groupName);
  if (dragData) {
    current.column = dragData.column;
    current.row = dragData.row;
    current.x = getX(e.offsetX);
    current.y = getY(e.offsetY);
    current.show = true;
  }
};

// 在目標中移動
const onDragover = (e) => {
  e.preventDefault();
  const dragData = dragStore.get(props.groupName);
  if (dragData) {
    current.x = getX(e.offsetX);
    current.y = getY(e.offsetY);
  }
};

const onDragleave = (e) => {
  e.preventDefault();
  current.show = false;
  current.id = undefined;
};

在 drop 事件中,我們將當前拖拽元素存放起來,list 會存放每一次拖拽進來元素信息。

const list = ref([]);

// 放置在目標上
const onDrop = async (e) => {
  e.preventDefault();
  current.show = false;
  const item = dragStore.get(props.groupName);

  list.value.push({
    ...item,
    x: current.x,
    y: current.y,
    id: new Date().getTime(),
  });
};

計算碰撞

在上面還需要計算當前拖拽的位置是否可以放置,需要處理是否包含在容器內,是否與其他已放置元素存在重疊等等。

計算是否在容器內

這個是比較好計算的,只需要當前拖拽位置左上角坐標 >= 容器左上角的坐標,然後右下角的坐標 <= 容器的右下角的坐標,就是在容器內的。

代碼實現:

/**
 * 判斷是否在當前四邊形內
 * @param {*} p1 父容器
 * @param {*} p2
 *  對應是 左上角坐標 和 右下角坐標
 *  [0,0,1,1]  => 左上角坐標 0,0  右下角 1,1
 */
export const booleanWithin = (p1: [number, number, number, number], p2: [number, number, number, number]) => {
  return p1[0] <= p2[0] && p1[1] <= p2[1] && p1[2] >= p2[2] && p1[3] >= p2[3];
};

計算是否與現有的相交

兩個矩形相交情況有很多種,計算比較麻煩,但是我們可以計算他們不相交,然後在取反方式判斷是否相交。

不相交情況只有四種,假設有 p1、p2 連個矩形,它們不相交的情況只有四種:

  • p1 在 p2 左邊
  • p1 在 p2 右邊
  • p1 在 p2 上邊
  • p1 在 p2 下邊

代碼實現:

/**
 * 判斷是兩四邊形是否相交
 * @param {*} p1 父容器
 * @param {*} p2
 *  對應是 左上角坐標 和 右下角坐標
 *  [0,0,1,1]  => 左上角坐標 0,0  右下角 1,1
 */
export const booleanIntersects = (p1: [number, number, number, number], p2: [number, number, number, number]) => {
  return !(p1[2] <= p2[0] || p2[2] <= p1[0] || p1[3] <= p2[1] || p2[3] <= p1[1]);
};

在放置前判斷

可以通過計算屬性去計算,在後面拖拽中處理樣式也可以用到。修改 drop 中方法,然後在 drop 中根據 isPutDown 是否有效。

// 是否可以放置
const isPutDown = computed(() => {
  const currentXy = [current.x, current.y, current.x + current.column, current.y + current.row];
  return (
    booleanWithin([0, 0, columnCount.value, rowCount.value], currentXy) && //
    list.value.every((item) => item.id === current.id || !booleanIntersects([item.x, item.y, item.x + item.column, item.y + item.row], currentXy))
  );
});

拖拽時樣式

上處理了基本拖放數據處理邏輯,為了更好的交互,我們可以在拖拽中顯示元素預占位信息,更加直觀的顯示元素占位大小,類似這樣:

可放置示例

我們可以根據上面 current 中信息去計算大小信息,還可以根據 isPutDown 去判斷當前位置是否可以放置,用來顯示不同交互效果。

不可放置示例

可以直接通過 Grid 的 grid-area 屬性,快速計算出放置位置信息,應為我們上面計算的 x 、y 是從 0 開始的,所以這裡需要 +1。

grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`

預覽容器

在元素放置後,我們還需要根據 list 中數據,生成元素占位樣式處理,我們可以拖拽容器上層在放置一個容器,專門用來顯示放置後的樣式,也是可以直接使用 Grid 佈局去處理。

預覽樣式

樣式基本上和 drop-container 樣式抱持一致即可,需要註意的時需要為預覽容器設置 pointer-events: none,避免遮擋了 drop-container 事件監聽。

.drop-content__preview,
.drop-content__drop-container {
  // ...
}

每個元素位置信息計算方式,基本和拖拽時樣式計算方式一致,直接通過 grid-area 去佈局就可以了。

grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`

示例

二次拖拽

當元素拖拽進來後,我們還需要對放置的元素支持繼續拖拽。因為上面我們將預覽事件通過 pointer-events 去除了,所以我們需要給每個子元素都加上去。然後給子元素添加 draggable="true",然後處理拖拽事件,基本上和上面處理方式一樣,在 dragstartdragend 處理拖拽元素信息。

然後我們還需在 onDrop 進行一番修改,如果是二次拖拽時只需要修改坐標信息,修改原 onDrop 處理方式:

if (item.id) {
  item.x = current.x;
  item.y = current.y;
} else {
  list.value.push({
    ...item,
    x: current.x,
    y: current.y,
    id: new Date().getTime(),
  });
}

位置偏移優化

當你對元素二次拖拽時,會發現元素會存在偏移問。比如你放置了一個 1x2 元素後,當你從下麵拖拽,你會發現拖拽中的占位樣式和你拖拽元素位置存在偏差。

效果如下圖

示例

出現這情況應為上面我們時根據滑鼠位置為左上角進行計算的,所以會存在這種偏差問題,我們可在拖拽前計算出偏移量來校正位置。

我們可以在二次拖拽時,獲取到滑鼠在當前元素內位置信息

const onDragstart = (e) => {
  const data = props.data;
  data.offsetX = e.offsetX;
  data.offsetY = e.offsetY;
  dragStore.set(props.groupName, data);
};

drop-container 內計算 x、y 值時候減去偏移量,對 onDragenteronDragover 進行如下調整修改

current.x = getX(e.offsetX) - getX(dragData?.offsetX ?? 0);
current.y = getY(e.offsetY) - getY(dragData?.offsetY ?? 0);

拖拽元素優化

因為上面我們將預覽元素添加了 pointer-events: all,所以在我們拖拽到現有元素上時,會擋住 drop-container 事件的觸發,在二次拖拽時,比如將一個 2x2 元素我們需要往下移動一格時,會發現也會被自己擋住。

  • 預覽元素遮擋問題,可以在拖拽時將其他元素都設置為 none,二次拖拽時要做自己設置為 all 否則會無法拖拽
:style="{ pointerEvents: current.show && item.id !== current.id ? 'none' : 'all' }"`
  • 二次拖拽時自己位置遮擋問題
    我們可以在拖拽時增加標識,將自己通過 transform 移除到多拽容器外去
moveing.value
  ? {
      opacity: 0,
      transform: `translate(-999999999px, -9999999999px)`,
    }
  : {};

結語

到目前為止基本上的 Grid 拖拽佈局大致實現了,已經滿足基本業務需求了,當然有需要朋友還可以在上面增加支持拖拉調整大小、碰撞後自動調整位置等等。

完整源碼在此,線上體驗


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 環形緩衝區(Circular Buffer 或 Ring Buffer)是一種數據結構,它在邏輯上形成一個閉環。這種結構非常適用於需要固定大小的緩衝區的情況,如音頻處理、網路通信、實時數據傳輸等。環形緩衝區的主要特點和用途包括: 固定大小:環形緩衝區的大小在創建時確定,並且在其生命周期內保持不變。 ...
  • Span<T> 是 C# 7.2 引入的一個強大的數據結構,用於表示記憶體中的一塊連續數據。它可以用於實現高性能的數組操作,而無需額外的記憶體分配。在本文中,我將詳細介紹如何使用 Span<T> 來實現高性能數組操作,並提供一些示例代碼來說明其用法。 什麼是 Span? Span<T> 是 System ...
  • 本項目案例後臺採用.NET6(C#)開發,前端採用React&React Native,數字孿生採用3DMAX&U3D。綜合運用“物、大、智、雲、移”技術,採用雲-邊-端工業互聯網架構,設備端基於工業感測器和物聯網保障動態感知,邊緣側基於工藝機理、專家知識、數據科學等多種技術手段實現工況診斷,大數據... ...
  • 在我們開發一些複雜信息的時候,由於需要動態展示一些相關信息,因此我們需要考慮一些控制項內容的動態展示,可以通過動態構建控制項的方式進行顯示,如動態選項卡展示不同的信息,或者動態展示一個自定義控制項的內容等等,目的就是能夠減少一些硬編碼的處理方式,以及能夠靈活的展示數據。本篇隨筆通過實際案例介紹WPF應用開... ...
  • 痞子衡嵌入式半月刊: 第 86 期 這裡分享嵌入式領域有用有趣的項目/工具以及一些熱點新聞,農曆年分二十四節氣,希望在每個交節之日準時發佈一期。 本期刊是開源項目(GitHub: JayHeng/pzh-mcu-bi-weekly),歡迎提交 issue,投稿或推薦你知道的嵌入式那些事兒。 上期回顧 ...
  • 在src目錄下新建一個文件夾models,用來存放數據模型和操作資料庫的方法。 在models目錄下新建一個文件user.js,用來管理用戶信息相關的資料庫操作。 相關的數據模型和資料庫操作方法,最後通過module.exports暴露出去。 mongoose版本8.0.0 1-創建結構 const ...
  • 隨著移動互聯網的普及,越來越多的人開始學習和欣賞唐詩。不過,對於一些想要獲取指定詩歌ID的人來說,這似乎是一件有點困難的事情。好在《唐詩三百首》介面為我們提供了方便快捷的解決方法。下麵,就讓我們來介紹一下如何獲取指定詩歌ID的《唐詩三百首》介面。 數據源介紹: 數據示例下載 ↓ 《唐詩三百首》共選入 ...
  • 官網 Mongoose.js中文網 (mongoosejs.net) 基本使用 安裝 最新的是mongoose8.0.0版本,基於Promise,以前的版本是基於回調函數。 npm npm i mongoose yarn yarn add mongoose 使用 以mongoose8.0.0舉例: ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...