0%

Flutter画面缩放平移

我们来“抽丝剥茧”地分析一下 _updateTransformation 方法是如何工作、为什么要这样计算,以及其中涉及到的数学原理(主要是平面几何、线性代数的知识),并在此过程中看看它是如何帮助我们在 Flutter 中实现「可缩放和可平移」的功能。


1. 整体背景

在 Flutter 中,如果我们想手动控制一个大画布(例如 2000 x 2000 像素)在屏幕上的平移、缩放、甚至旋转,我们可以使用一个 4x4 的矩阵 (Matrix4) 来描述这一变换。TransformationController 内部就维护了这样一份 矩阵

  • 矩阵的含义:在 2D 或 2D+Z 的场景下,常用 3x3 或 4x4 齐次坐标变换矩阵来描述平移、缩放、旋转等操作。其中
    [
    \begin{bmatrix}
    1 & 0 & 0 & t_x \
    0 & 1 & 0 & t_y \
    0 & 0 & 1 & t_z \
    0 & 0 & 0 & 1
    \end{bmatrix}
    ]
    可以表达一个平移( (t_x, t_y) ),而
    [
    \begin{bmatrix}
    s & 0 & 0 & 0 \
    0 & s & 0 & 0 \
    0 & 0 & s & 0 \
    0 & 0 & 0 & 1
    \end{bmatrix}
    ]
    可以表达一个缩放(Scale) 倍数 ( s )(简化为等比缩放,没有考虑旋转)。

本示例中 _updateTransformation() 的核心就是:根据新的缩放值和新的平移量,构建一份矩阵,并把它设置给 TransformationController.value,从而让 Flutter 知道如何在屏幕上呈现那块大画布。


2. 方法概览

void _updateTransformation({double? scaleDelta, Offset? translateDelta}) {
final matrix = _transformationController.value.clone();
double currentScale = matrix.getMaxScaleOnAxis();
final currentTranslation = matrix.getTranslation();

double newScale = _currentScale;
if (scaleDelta != null) {
newScale = scaleDelta.clamp(0.3, 3.0);
}

Offset newTranslation = Offset(currentTranslation.x, currentTranslation.y);
if (translateDelta != null) {
newTranslation += translateDelta;
}

...

// 计算视口大小 (就是屏幕上这块可视区域的大小)
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
final viewportSize = renderBox?.size;
if (viewportSize == null) {
return;
}

final viewportWidth = viewportSize.width;
final viewportHeight = viewportSize.height;

// 计算缩放后内容的大小
final scaledContentWidth = _contentWidth * newScale;
final scaledContentHeight = _contentHeight * newScale;

double minX = 0.0;
double maxX = 0.0;
double minY = 0.0;
double maxY = 0.0;

// 根据内容相对于视口的大小,确定 x/y 方向上的可移动范围
if (scaledContentWidth > viewportWidth) {
minX = viewportWidth - scaledContentWidth;
maxX = 0.0;
} else {
final centerX = (viewportWidth - scaledContentWidth) / 2;
minX = centerX;
maxX = centerX;
}

if (scaledContentHeight > viewportHeight) {
minY = viewportHeight - scaledContentHeight;
maxY = 0.0;
} else {
final centerY = (viewportHeight - scaledContentHeight) / 2;
minY = centerY;
maxY = centerY;
}

// 将新的平移量限制在 [minX, maxX] 和 [minY, maxY] 之间
final clampedX = newTranslation.dx.clamp(minX, maxX);
final clampedY = newTranslation.dy.clamp(minY, maxY);

// 构建新的 4x4 矩阵
final newMatrix = Matrix4.identity()
..translate(clampedX, clampedY)
..scale(newScale);

setState(() {
_currentScale = newScale;
_transformationController.value = newMatrix;
});
}

3. 分步骤理解

3.1 获取当前矩阵与当前的平移 / 缩放

final matrix = _transformationController.value.clone();
double currentScale = matrix.getMaxScaleOnAxis();
final currentTranslation = matrix.getTranslation();
  • matrix.getMaxScaleOnAxis() 获取这个矩阵在 X/Y/Z 中的最大缩放因子。本例只用 2D,所以 X 和 Y 的缩放应该相同。
  • matrix.getTranslation() 可以从 4x4 矩阵里得到 ({t_x, t_y, t_z}) 这几个平移量。

3.2 处理新的缩放与平移

double newScale = _currentScale;
if (scaleDelta != null) {
newScale = scaleDelta.clamp(0.3, 3.0);
}

Offset newTranslation = Offset(currentTranslation.x, currentTranslation.y);
if (translateDelta != null) {
newTranslation += translateDelta;
}
  • 如果我们想更新缩放(传入的 scaleDelta),就将新缩放限制在 [0.3, 3.0] 范围内。这里的 clamp 函数体现了最基本的数学操作:
    [
    newScale = \max(0.3, \min(3.0, scaleDelta)).
    ]
  • 如果我们想在原有平移基础上再偏移一些(translateDelta),就直接相加。因为平移在 2D 平面里是向量加法:
    [
    \mathbf{p}{new} = \mathbf{p}{old} + \mathbf{t}_{\Delta}.
    ]

3.3 计算视口大小和 “已缩放后的内容” 大小

final viewportWidth = viewportSize.width;
final viewportHeight = viewportSize.height;

final scaledContentWidth = _contentWidth * newScale;
final scaledContentHeight = _contentHeight * newScale;
  • 视口 (viewport) 指的是手机屏幕上可显示的那块区域的尺寸。
  • 让我们假设 _contentWidth=2000, _contentHeight=2000, 如果 newScale=1.5, 那么计算后的画布宽度/高度就变成 (2000 \times 1.5 = 3000)。
  • 如果可视区域只有 1000 x 800,那么在这个例子中,画布就比视口更大,可以滚动或拖动查看不同部分。

3.4 计算平移边界 (minX, maxX, minY, maxY)

if (scaledContentWidth > viewportWidth) {
minX = viewportWidth - scaledContentWidth;
maxX = 0.0;
} else {
final centerX = (viewportWidth - scaledContentWidth) / 2;
minX = centerX;
maxX = centerX;
}
  • 这是最关键的逻辑:当内容比视口大时,可以左右拖动。当内容比视口小或差不多时,为了避免画布漂移,常常把内容居中。
  • 这里做了一个数学推导:如果画布很大,比视口宽,那么
    • 最右边画布可以跟视口左边对齐(此时 translation.x = 0 表示不平移),
    • 最左边画布可以跟视口右边对齐(此时 translation.x = viewportWidth - scaledContentWidth),
    • 所以 X 的可取范围就是 [viewportWidth - scaledContentWidth, 0]。这保证了“画布至少要保证右侧紧贴视口或在视口内”。
  • 若画布比视口还小,则 (viewportWidth - scaledContentWidth) 为正数,所以它其实是一种让画布尽量居中的设计:
    • centerX = (viewportWidth - scaledContentWidth)/2
    • 这样 minX = maxX = centerX,说明横向没有可拖动的余地,就只固定在中间了。

Y 方向的逻辑与之相同。

3.5 用 clamp 限制新的平移值

final clampedX = newTranslation.dx.clamp(minX, maxX);
final clampedY = newTranslation.dy.clamp(minY, maxY);
  • 计算好的平移强行夹在 [minX, maxX] 之间,这是一个典型的数学操作:
    [
    \text{clampedX} = \max(\text{minX}, \min(\text{maxX}, \text{newTranslation.dx})).
    ]
  • 这样就不会出现“把画布拖得远远的,看不到了”这种情况。
  • 缺点是如果这些边界计算有一点差,就可能出现看不到右下角或“无法拖动到最左侧”等问题,需要根据需求来调整逻辑。

3.6 构造新的矩阵

final newMatrix = Matrix4.identity()
..translate(clampedX, clampedY)
..scale(newScale);

这里运用了齐次坐标的概念(即在一个 4x4 矩阵中先做平移,再做缩放)。在 Flutter 的 Matrix4 API 中,这些调用相当于:

  1. 创建一个单位矩阵 (I),即
    [
    I = \begin{bmatrix}
    1 & 0 & 0 & 0\
    0 & 1 & 0 & 0\
    0 & 0 & 1 & 0\
    0 & 0 & 0 & 1\
    \end{bmatrix}
    ]
  2. translate(clampedX, clampedY) 会把左上角移动到 ((clampedX, clampedY)) 的位置:
    [
    \begin{bmatrix}
    1 & 0 & 0 & clampedX\
    0 & 1 & 0 & clampedY\
    0 & 0 & 1 & 0\
    0 & 0 & 0 & 1\
    \end{bmatrix}
    ]
  3. scale(newScale) 在这个基础上做等比例缩放:
    [
    \begin{bmatrix}
    newScale & 0 & 0 & 0\
    0 & newScale & 0 & 0\
    0 & 0 & newScale & 0\
    0 & 0 & 0 & 1\
    \end{bmatrix}
    ]

组合起来,最终矩阵大概是
[
\begin{bmatrix}
newScale & 0 & 0 & clampedX \
0 & newScale & 0 & clampedY \
0 & 0 & newScale & 0 \
0 & 0 & 0 & 1 \
\end{bmatrix}
]
(注意在 Flutter 里是先翻译再缩放还是先缩放再翻译,需要看 API 的内部调用顺序,通常 ..translate()..scale() 会被视为“先平移后缩放”;这里要留意如果真正希望“围绕某点缩放”,通常要先将画布平移,把该点移到原点,再scale,再移回来。)

3.7 setState 通知 Flutter 重绘

setState(() {
_currentScale = newScale;
_transformationController.value = newMatrix;
});
  • _transformationController.value 改变时,InteractiveViewer(或任何使用该矩阵的组件)就会重新布局与绘制,这样我们就能在屏幕上看到画布已经被缩放和平移到指定位置了。

4. 数学知识点总结

  1. 向量加法(用于平移更新):
    [
    \mathbf{p}{new} = \mathbf{p}{old} + \mathbf{t}_{\Delta}
    ]
    在代码中表现为:

    newTranslation += translateDelta
  2. **缩放 (Scale)**:
    2D 等比缩放时,新的坐标 = 旧坐标 (\times s)。在矩阵中就是对对角线元素乘以 newScale

  3. 齐次坐标 / 变换矩阵
    在 Flutter 的 Matrix4 中,最常用的变换有 translate(), scale(), rotateZ() 等,会对 4x4 矩阵相应元素进行操作。

  4. Clamp 函数
    在数学上就是
    [
    \text{clamp}(x, min, max) = \max(min, \min(max, x)).
    ]
    用来确保数值不越界。

  5. 边界计算

    • “画布大于视口”时有 [min, max] 范围;
    • “画布小于视口”时让内容居中。
      这些都是常见的平面几何应用:用画布的大小去减视口的大小,再决定能平移多少。
  6. 先平移后缩放先缩放后平移的区别

    • 矩阵运算是可组合但不可交换的:(T \cdot S \neq S \cdot T)。
    • 在本例中 ..translate()..scale() 相当于先平移,再缩放,也就是说被平移的距离会一并被缩放。如果你想让缩放围绕某点进行,需要先把画布平移到那个点是坐标原点,再缩放,再平移回去。

5. 总结

  • _updateTransformation 的作用:把想要的缩放值、平移值合成为一个变换矩阵,并且限制这个变换不会让画布“拖没了”或“缩放到无限大”。
  • 其中包含了典型的2D 几何和线性代数概念:向量加法、齐次坐标转换、矩阵乘法顺序、clamp 限制等。
  • 通过这种手动构造矩阵并赋值给 TransformationController,就实现了在 Flutter 中高度可控的「放大/缩小 + 拖拽移动」效果。

整体来说,这段逻辑清晰地展示了在 Flutter 中用矩阵控制视图变换的一个“模板”方法:先读取当前矩阵,计算新的缩放和平移,再根据边界条件进行 clamp,最后构造新矩阵并赋值给 TransformationController

这就是 _updateTransformation 的核心思想,以及它背后依赖的数学原理。