从"改自己"到"改布局"------拖拽调整的通用设计思路
一个看似简单的需求
假设你在做一个可视化编辑器,用户可以拖拽元素边缘来调整尺寸。最初的需求很简单:
每个元素可以拖拽边缘改变自己的宽高。
你写出了第一版代码:
arduino
function handleResize(element, delta) {
element.width += delta; // 直接改自己
}
单个元素拖拽没问题,但多个元素排列在一起时,问题出现了:拖拽 A 的右边缘,A 变宽,旁边的元素被挤开或者重叠,布局瞬间乱掉。
为什么?因为布局是一个整体。一个元素变大,必然要影响周围元素。如果只改自己,总宽度就会超过容器,或者出现重叠。
所以,正确的做法不是"改自己",而是 "改布局" :调整一个元素时,同时调整它的邻居,让总尺寸保持不变。这就是互补调整。
先解决水平拖拽:找出邻居
我们先聚焦最简单的水平流式布局:元素从左到右排列,宽度按百分比设置,一行放不下就换行。
当用户拖拽元素 A 的右边缘时,A 变宽多少,右侧相邻元素就应该变窄多少,这样这一行总宽度保持不变。
但怎么知道 A 的右侧邻居是谁?数据是一个一维数组,元素只有 width 和 height,没有"邻居"概念。我们得先知道:谁和谁在同一行,谁在谁右边。
所以,在写拖拽逻辑之前,必须先计算出行拓扑。
计算行拓扑:谁和谁在同一行?
行拓扑的计算很简单:按数组顺序累加宽度,超过容器宽度(100%)就换行。
看个例子。假设有三个元素,宽度分别是 50%、50%、100%:
- 第 0 个元素宽 50,累计 50,放入第 0 行;
- 第 1 个元素宽 50,累计 50+50=100,正好不超,放入第 0 行;
- 第 2 个元素宽 100,当前行已经 100,再加就超了,所以换行,放入第 1 行。
得到的结果:
yaml
{
rows: [[0, 1], [2]],
itemIndex: {
0: { row: 0, indexInRow: 0 }, // 元素 0 在第 0 行第 0 个
1: { row: 0, indexInRow: 1 }, // 元素 1 在第 0 行第 1 个
2: { row: 1, indexInRow: 0 } // 元素 2 在第 1 行第 0 个
}
}
rows 是一个二维数组,每个子数组代表一行,子数组里存的是元素在原始数组里的索引。
rows[0] = [0, 1]:第 0 行有元素 0 和 1,元素 0 在左,元素 1 在右。rows[1] = [2]:第 1 行只有元素 2。
为什么需要 rows?因为原始数据是一维数组,没有"同一行"的关系。rows 把一维数据分组成了多行,并且保留了行内从左到右的顺序。
itemIndex 则是为了方便快速查找:给定元素索引,直接得到它属于第几行、行内第几个,不用遍历 rows。
这里我们直接用数组索引来标识元素 ,没有额外的 ID。因为数组本身有序,索引就是天然的唯一标识。拖拽时传入的 elementIndex 就是元素在 items 数组中的位置,简单直接。
基于行拓扑实现水平拖拽
有了行拓扑,水平拖拽就清晰了:
ini
function resizeWidth(elementIndex, delta) {
// 1. 从 itemIndex 快速找到元素所在行和行内位置
const { row, indexInRow } = itemIndex[elementIndex];
// 2. 获取当前行数组(里面存的是索引)
const currentRow = rows[row];
// 3. 如果已经是行内最后一个元素,说明右侧没有邻居,禁止向右拖拽
if (indexInRow === currentRow.length - 1) return;
// 4. 右侧邻居就是 currentRow 里下一个位置的元素索引
const nextIndex = currentRow[indexInRow + 1];
// 5. 互补调整:当前元素增加 delta,邻居减少 delta
// 注意:delta 单位要与 width 一致,比如都用百分比
items[elementIndex].width += delta;
items[nextIndex].width -= delta;
}
为什么这样写?因为我们要保持这一行总宽度不变。currentRow 里所有元素宽度之和原本是 100%,一个增加多少,另一个减少多少,总和不变。rows 中元素的顺序就是从左到右,所以"下一个位置"就是右侧邻居。
水平拖拽只需要行拓扑,不需要列。
垂直拖拽:调整整行高度
现在考虑拖拽元素的下边缘,调整高度。
在水平流式布局中,同一行的元素通常共享相同的高度。如果只改一个元素高度,这一行会变得高矮不齐,视觉混乱,也破坏了行的整体性。
所以更合理的做法是:垂直拖拽调整的是整行高度。当前行高度增加 delta,下一行高度减少 delta,保持总高度不变。
实现时,需要找到当前行的下一行,rows 数组已经按行顺序排列,直接用 row + 1 即可。
ini
function resizeRowHeight(elementIndex, delta) {
// 1. 找到元素所在行
const { row } = itemIndex[elementIndex];
// 2. 如果没有下一行,禁止向下拖拽
if (row >= rows.length - 1) return;
// 3. 当前行和下一行
const currentRow = rows[row];
const nextRow = rows[row + 1];
// 4. 假设同一行内高度一致,取当前行第一个元素的高度作为行高
// 原因:水平布局中行高统一,这样才能保证整行调整有意义
const currentHeight = items[currentRow[0]].height;
const nextHeight = items[nextRow[0]].height;
// 5. 计算调整后的新行高
const newCurrentHeight = currentHeight + delta;
const newNextHeight = nextHeight - delta;
// 6. 更新当前行所有元素的高度
currentRow.forEach(index => {
items[index].height = newCurrentHeight;
});
// 7. 更新下一行所有元素的高度
nextRow.forEach(index => {
items[index].height = newNextHeight;
});
}
这里有一个假设:同一行内所有元素的高度是一致的。如果初始数据中同一行元素高度不同,你需要在计算拓扑时统一它们(比如取第一个元素高度或最大值)。但核心思路不变:垂直调整以"行"为单位。
垂直拖拽也只需要行拓扑,不需要列。
水平布局的拓扑模型总结
在水平布局下,我们只需要一个结构:
rows:行分组,告诉我们哪些元素在同一行,以及从左到右的顺序;itemIndex:每个元素所在行和行内位置,方便快速查找。
水平拖拽调宽度:找同一行内右侧邻居,互补宽度。 垂直拖拽调高度:找下一行,整行互补高度。
没有列的概念,因为不同行的元素宽度不同,列对不齐,无法作为稳定的操作单位。
扩展到垂直布局:完全对称
垂直流式布局是水平布局的镜像:元素从上到下排列,高度按百分比,一列放不下就换列。
这时分组单位变成了列:哪些元素在同一列,谁在上谁在下。
拓扑结构类似:
yaml
{
cols: [[0], [1, 2]],
itemIndex: {
0: { col: 0, indexInCol: 0 },
1: { col: 1, indexInCol: 0 },
2: { col: 1, indexInCol: 1 }
}
}
cols 的每个子数组代表一列,里面存的是元素索引。cols[0] = [0] 表示第 0 列只有元素 0;cols[1] = [1, 2] 表示第 1 列有元素 1 和 2,元素 1 在上,元素 2 在下。
调整规则完全对称:
- 垂直拖拽(调整高度) :找同一列内下一个元素,互补高度。代码和水平布局的
resizeWidth一样,只是操作的是height和cols。 - 水平拖拽(调整宽度) :找下一列,整列互补宽度。代码和水平布局的
resizeRowHeight一样,只是操作的是width和cols。
你不需要重写逻辑,只需要把"行"换成"列","宽度"换成"高度"。
拓扑为什么只需要算一次?
互补调整有一个核心优势:每一行(或列)的总尺寸始终保持不变。
这意味着元素之间的分组关系不会因为拖拽而改变。谁和谁在同一行、谁前谁后,从一开始就固定下来了。所以理论上拓扑只需要初始化时计算一次,之后所有拖拽都直接查表。
但这是"理论上"。实际写代码时,有三个问题必须处理,否则拓扑就会失效或者出现异常。下面逐个说清楚。
问题一:拖拽时不能把邻居挤成负数
假设 A 宽 80%,右侧邻居 B 宽 20%。你把 A 向右拖 30%,如果直接写:
ini
items[A].width += 30;
items[B].width -= 30;
B 的宽度就变成 -10%,这显然是错的。所以我们需要钳制 delta:计算出一个最大允许的 delta,保证邻居不小于某个最小值(比如 5%)。
ini
function resizeWidth(elementIndex, delta) {
const { row, indexInRow } = itemIndex[elementIndex];
const currentRow = rows[row];
if (indexInRow === currentRow.length - 1) return;
const nextIndex = currentRow[indexInRow + 1];
const currentWidth = items[elementIndex].width;
const nextWidth = items[nextIndex].width;
// 最小宽度限制,防止把邻居压成负数
const MIN_WIDTH = 5;
// 邻居最多能减多少?它当前宽度减去最小宽度
const maxDelta = nextWidth - MIN_WIDTH;
// 实际 delta 不能超过这个最大值
const clampedDelta = Math.min(delta, maxDelta);
// 如果 delta 是负数(向左拖),也要限制当前元素不能小于最小值,这里先省略
// 假设我们只处理向右拖的情况
items[elementIndex].width = currentWidth + clampedDelta;
items[nextIndex].width = nextWidth - clampedDelta;
}
这样,即使你拖得很猛,邻居最多被压到 5%,不会变成负数。总宽度依然保持 100%(因为增加的量等于减少的量)。
如果你的拖拽允许向左(delta 为负),也要对称处理:当前元素不能小于 MIN_WIDTH,限制 delta 不能小于 MIN_WIDTH - currentWidth。
问题二:行末/列末元素怎么处理?
在 resizeWidth 里,我们直接禁止行内最后一个元素向右拖拽,因为它没有右侧邻居。垂直布局中的列末元素同理。
问题三:容器尺寸变化了怎么办?
我们的拓扑是基于容器宽度 100% 来计算的。如果容器宽度变了(比如用户拉大窗口),原来一行能放三个元素,现在可能只能放两个,分组就变了。
所以,如果布局是响应式的,你需要在容器尺寸变化时重新计算拓扑。
具体做法:
ini
// 监听容器尺寸变化
const container = document.getElementById('container');
const resizeObserver = new ResizeObserver(() => {
// 重新计算行拓扑
const newTopology = computeRows(items);
rows = newTopology.rows;
itemIndex = newTopology.itemIndex;
});
resizeObserver.observe(container);
如果你的容器尺寸固定,就不需要这一步,拓扑可以一直复用。
总结
从"改自己"到"改布局",核心就一句话:别只改自己,要改就改邻居,保持总和不变。
具体做法是:
- 根据布局模式(水平/垂直)计算一次拓扑;
- 水平布局分"行",垂直布局分"列";
- 拖拽时同组内互补(调宽/调高),跨组调整整组(调高/调宽);
- 处理好边界:最小尺寸限制、禁止行末/列末越界、容器变化时重新计算拓扑。
只要这几点做到,拓扑就能稳定复用,拖拽体验流畅不混乱。