Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 221 additions & 11 deletions packages/flet/lib/src/controls/interactive_viewer.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import 'dart:math' as math;

import 'package:flet/src/utils/events.dart';
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3;

import '../extensions/control.dart';
import '../models/control.dart';
Expand All @@ -25,10 +29,12 @@ class _InteractiveViewerControlState extends State<InteractiveViewerControl>
with SingleTickerProviderStateMixin {
final TransformationController _transformationController =
TransformationController();
final GlobalKey _childKey = GlobalKey();
late AnimationController _animationController;
Animation<Matrix4>? _animation;
Matrix4? _savedMatrix;
int _interactionUpdateTimestamp = DateTime.now().millisecondsSinceEpoch;
final double _currentRotation = 0.0;

@override
void initState() {
Expand All @@ -45,19 +51,20 @@ class _InteractiveViewerControlState extends State<InteractiveViewerControl>
var factor = parseDouble(args["factor"]);
if (factor != null) {
_transformationController.value =
_transformationController.value.scaled(factor, factor);
_matrixScale(_transformationController.value, factor);
}
break;
case "pan":
var dx = parseDouble(args["dx"]);
if (dx != null) {
_transformationController.value =
_transformationController.value.clone()
..translate(
dx,
parseDouble(args["dy"], 0)!,
parseDouble(args["dz"], 0)!,
);
final double dy = parseDouble(args["dy"], 0)!;
final double dz = parseDouble(args["dz"], 0)!;
final Matrix4 updated =
_matrixTranslate(_transformationController.value, Offset(dx, dy));
if (dz != 0) {
updated.translateByDouble(0.0, 0.0, dz, 1.0);
}
_transformationController.value = updated;
}
break;
case "reset":
Expand Down Expand Up @@ -102,6 +109,11 @@ class _InteractiveViewerControlState extends State<InteractiveViewerControl>
debugPrint("InteractiveViewer build: ${widget.control.id}");

var content = widget.control.buildWidget("content");
if (content == null) {
return const ErrorControl(
"InteractiveViewer.content must be provided and visible");
}

var interactiveViewer = InteractiveViewer(
transformationController: _transformationController,
panEnabled: widget.control.getBool("pan_enabled", true)!,
Expand Down Expand Up @@ -142,11 +154,209 @@ class _InteractiveViewerControlState extends State<InteractiveViewerControl>
}
}
: null,
child: content ??
const ErrorControl(
"InteractiveViewer.content must be provided and visible"),
child: KeyedSubtree(key: _childKey, child: content),
);

return LayoutControl(control: widget.control, child: interactiveViewer);
}

Matrix4 _matrixScale(Matrix4 matrix, double scale) {
if (scale == 1.0) {
return matrix.clone();
}

final double currentScale = matrix.getMaxScaleOnAxis();
if (currentScale == 0) {
return matrix.clone();
}

final double minScale = widget.control.getDouble("min_scale", 0.8)!;
final double maxScale = widget.control.getDouble("max_scale", 2.5)!;
double totalScale = currentScale * scale;

final Rect? boundaryRect = _currentBoundaryRect();
final Rect? viewportRect = _currentViewportRect();
if (boundaryRect != null &&
viewportRect != null &&
boundaryRect.width > 0 &&
boundaryRect.height > 0 &&
boundaryRect.width.isFinite &&
boundaryRect.height.isFinite &&
viewportRect.width.isFinite &&
viewportRect.height.isFinite) {
final double minFitScale = math.max(
viewportRect.width / boundaryRect.width,
viewportRect.height / boundaryRect.height,
);
if (minFitScale.isFinite && minFitScale > 0) {
totalScale = math.max(totalScale, minFitScale);
}
}

final double clampedTotalScale =
clampDouble(totalScale, minScale, maxScale);
final double clampedScale = clampedTotalScale / currentScale;
return matrix.clone()..scale(clampedScale, clampedScale, clampedScale);
}

Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
if (translation == Offset.zero) {
return matrix.clone();
}

final Matrix4 nextMatrix = matrix.clone()
..translate(translation.dx, translation.dy, 0);

final Rect? boundaryRect = _currentBoundaryRect();
final Rect? viewportRect = _currentViewportRect();
if (boundaryRect == null || viewportRect == null) {
return nextMatrix;
}

if (boundaryRect.isInfinite) {
return nextMatrix;
}

final Quad nextViewport = _transformViewport(nextMatrix, viewportRect);
final Quad boundsQuad =
_axisAlignedBoundingBoxWithRotation(boundaryRect, _currentRotation);
final Offset offendingDistance = _exceedsBy(boundsQuad, nextViewport);
if (offendingDistance == Offset.zero) {
return nextMatrix;
}

final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix);
final double currentScale = matrix.getMaxScaleOnAxis();
if (currentScale == 0) {
return matrix.clone();
}
final Offset correctedTotalTranslation = Offset(
nextTotalTranslation.dx - offendingDistance.dx * currentScale,
nextTotalTranslation.dy - offendingDistance.dy * currentScale,
);

final Matrix4 correctedMatrix = matrix.clone()
..setTranslation(Vector3(
correctedTotalTranslation.dx,
correctedTotalTranslation.dy,
0.0,
));

final Quad correctedViewport =
_transformViewport(correctedMatrix, viewportRect);
final Offset offendingCorrectedDistance =
_exceedsBy(boundsQuad, correctedViewport);
if (offendingCorrectedDistance == Offset.zero) {
return correctedMatrix;
}

if (offendingCorrectedDistance.dx != 0.0 &&
offendingCorrectedDistance.dy != 0.0) {
return matrix.clone();
}

final Offset unidirectionalCorrectedTotalTranslation = Offset(
offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0,
offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0,
);

return matrix.clone()
..setTranslation(Vector3(
unidirectionalCorrectedTotalTranslation.dx,
unidirectionalCorrectedTotalTranslation.dy,
0.0,
));
}

Rect? _currentBoundaryRect() {
final BuildContext? childContext = _childKey.currentContext;
if (childContext == null) {
return null;
}
final RenderObject? renderObject = childContext.findRenderObject();
if (renderObject is! RenderBox) {
return null;
}
final Size childSize = renderObject.size;
final EdgeInsets boundaryMargin =
widget.control.getMargin("boundary_margin", EdgeInsets.zero)!;
return boundaryMargin.inflateRect(Offset.zero & childSize);
}

Rect? _currentViewportRect() {
final RenderObject? renderObject = context.findRenderObject();
if (renderObject is! RenderBox) {
return null;
}
final Size size = renderObject.size;
return Offset.zero & size;
}

Offset _getMatrixTranslation(Matrix4 matrix) {
final Vector3 translation = matrix.getTranslation();
return Offset(translation.x, translation.y);
}

Quad _transformViewport(Matrix4 matrix, Rect viewport) {
final Matrix4 inverseMatrix = matrix.clone()..invert();
return Quad.points(
inverseMatrix.transform3(
Vector3(viewport.topLeft.dx, viewport.topLeft.dy, 0.0),
),
inverseMatrix.transform3(
Vector3(viewport.topRight.dx, viewport.topRight.dy, 0.0),
),
inverseMatrix.transform3(
Vector3(viewport.bottomRight.dx, viewport.bottomRight.dy, 0.0),
),
inverseMatrix.transform3(
Vector3(viewport.bottomLeft.dx, viewport.bottomLeft.dy, 0.0),
),
);
}

Quad _axisAlignedBoundingBoxWithRotation(Rect rect, double rotation) {
final Matrix4 rotationMatrix = Matrix4.identity()
..translate(rect.size.width / 2, rect.size.height / 2, 0)
..rotateZ(rotation)
..translate(-rect.size.width / 2, -rect.size.height / 2, 0);
final Quad boundariesRotated = Quad.points(
rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)),
rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)),
rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)),
rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)),
);
return InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated);
}

Offset _exceedsBy(Quad boundary, Quad viewport) {
final List<Vector3> viewportPoints = <Vector3>[
viewport.point0,
viewport.point1,
viewport.point2,
viewport.point3,
];
Offset largestExcess = Offset.zero;
for (final Vector3 point in viewportPoints) {
final Vector3 pointInside =
InteractiveViewer.getNearestPointInside(point, boundary);
final Offset excess =
Offset(pointInside.x - point.x, pointInside.y - point.y);
if (excess.dx.abs() > largestExcess.dx.abs()) {
largestExcess = Offset(excess.dx, largestExcess.dy);
}
if (excess.dy.abs() > largestExcess.dy.abs()) {
largestExcess = Offset(largestExcess.dx, excess.dy);
}
}

return _roundOffset(largestExcess);
}

Offset _roundOffset(Offset offset) {
return Offset(
double.parse(offset.dx.toStringAsFixed(9)),
double.parse(offset.dy.toStringAsFixed(9)),
);
}
}
51 changes: 51 additions & 0 deletions sdk/python/examples/controls/interactive_viewer/transformations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import flet as ft


def main(page: ft.Page):
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
page.vertical_alignment = ft.MainAxisAlignment.CENTER

async def handle_zoom_in(e: ft.Event[ft.Button]):
await i.zoom(1.2)

async def handle_zoom_out(e: ft.Event[ft.Button]):
await i.zoom(0.8)

async def handle_pan(e: ft.Event[ft.Button]):
await i.pan(dx=50, dy=50)

async def handle_reset(e: ft.Event[ft.Button]):
await i.reset()

async def handle_reset_slow(e: ft.Event[ft.Button]):
await i.reset(animation_duration=ft.Duration(seconds=2))

async def handle_save_state(e: ft.Event[ft.Button]):
await i.save_state()

async def handle_restore_state(e: ft.Event[ft.Button]):
await i.restore_state()

page.add(
i := ft.InteractiveViewer(
min_scale=0.1,
max_scale=5,
boundary_margin=ft.Margin.all(20),
content=ft.Image(src="https://picsum.photos/500/500"),
),
ft.Row(
wrap=True,
controls=[
ft.Button("Zoom In", on_click=handle_zoom_in),
ft.Button("Zoom Out", on_click=handle_zoom_out),
ft.Button("Pan", on_click=handle_pan),
ft.Button("Save State", on_click=handle_save_state),
ft.Button("Restore State", on_click=handle_restore_state),
ft.Button("Reset (instant)", on_click=handle_reset),
ft.Button("Reset (slow)", on_click=handle_reset_slow),
],
),
)


ft.run(main)
6 changes: 6 additions & 0 deletions sdk/python/packages/flet/docs/controls/interactiveviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ examples: ../../examples/controls/interactive_viewer
--8<-- "{{ examples }}/handling_events.py"
```

### Programmatic transformations

```python
--8<-- "{{ examples }}/transformations.py"
```

{{ class_members(class_name) }}
Loading
Loading