From ec57b0e797a00faba2eb5b5815f5cd03f7e303ca Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Mon, 3 Nov 2025 03:11:56 +0100 Subject: [PATCH 1/7] initial commit --- .../lib/src/controls/interactive_viewer.dart | 232 +++++++++++++++++- 1 file changed, 221 insertions(+), 11 deletions(-) diff --git a/packages/flet/lib/src/controls/interactive_viewer.dart b/packages/flet/lib/src/controls/interactive_viewer.dart index 658e20b964..8f279b71ed 100644 --- a/packages/flet/lib/src/controls/interactive_viewer.dart +++ b/packages/flet/lib/src/controls/interactive_viewer.dart @@ -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'; @@ -25,10 +29,12 @@ class _InteractiveViewerControlState extends State with SingleTickerProviderStateMixin { final TransformationController _transformationController = TransformationController(); + final GlobalKey _childKey = GlobalKey(); late AnimationController _animationController; Animation? _animation; Matrix4? _savedMatrix; int _interactionUpdateTimestamp = DateTime.now().millisecondsSinceEpoch; + final double _currentRotation = 0.0; @override void initState() { @@ -45,19 +51,20 @@ class _InteractiveViewerControlState extends State 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": @@ -102,6 +109,11 @@ class _InteractiveViewerControlState extends State 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)!, @@ -142,11 +154,209 @@ class _InteractiveViewerControlState extends State } } : 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 viewportPoints = [ + 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)), + ); + } } From 43d2398d0d87ea01b9dad002353f0c9b8901cc22 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 5 Nov 2025 01:23:52 +0100 Subject: [PATCH 2/7] more docs --- .../flet/controls/core/interactive_viewer.py | 53 +++++++++++++++++-- .../packages/flet/src/flet/controls/types.py | 51 +++++++++++------- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py b/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py index ce028a15b1..a3cd653a06 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py +++ b/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py @@ -1,3 +1,4 @@ +from dataclasses import field from typing import Optional from flet.controls.alignment import Alignment @@ -11,7 +12,7 @@ ScaleUpdateEvent, ) from flet.controls.layout_control import LayoutControl -from flet.controls.margin import MarginValue +from flet.controls.margin import Margin, MarginValue from flet.controls.types import ClipBehavior, Number __all__ = ["InteractiveViewer"] @@ -50,8 +51,20 @@ class InteractiveViewer(LayoutControl): constrained: bool = True """ - Whether the normal size constraints at this point in the widget tree are applied - to the child. + Whether the normal size constraints at this point in the control tree are applied + to the [`content`][(c).]. + + If set to `False`, then the content will be given infinite constraints. This + is often useful when a content should be bigger than this `InteractiveViewer`. + + For example, for a content which is bigger than the viewport but can be + panned to reveal parts that were initially offscreen, `constrained` must + be set to `False` to allow it to size itself properly. If `constrained` is + `True` and the content can only size itself to the viewport, then areas + initially outside of the viewport will not be able to receive user + interaction events. If experiencing regions of the content that are not + receptive to user gestures, make sure `constrained` is `False` and the content + is sized properly. """ max_scale: Number = 2.5 @@ -67,6 +80,17 @@ class InteractiveViewer(LayoutControl): """ The minimum allowed scale. + Scale is also affected by [`boundary_margin`][(c).]. If the scale would result in + viewing beyond the boundary, then it will not be allowed. By default, + [`boundary_margin`][(c).] is `0`, so scaling below 1.0 will not be + allowed in most cases without first increasing the [`boundary_margin`][(c).]. + + The effective scale is limited by the value of [`boundary_margin`][(c).]. + If scaling would cause the content to be displayed outside the defined boundary, + it is prevented. By default, `boundary_margin` is set to `Margin.all(0)`, + so scaling below `1.0` is typically not possible unless you increase the + `boundary_margin` value. + Raises: ValueError: If it is not greater than `0` or less than [`max_scale`][(c).]. """ @@ -82,11 +106,22 @@ class InteractiveViewer(LayoutControl): scale_factor: Number = 200 """ The amount of scale to be performed per pointer scroll. + + Increasing this value above the default causes scaling to feel slower, + while decreasing it causes scaling to feel faster. + + Note: + Has effect only on pointer device scrolling, not pinch to zoom. """ clip_behavior: ClipBehavior = ClipBehavior.HARD_EDGE """ Defines how to clip the [`content`][(c).]. + + If set to [`ClipBehavior.NONE`][flet.], the [`content`][(c).] can visually overflow + the bounds of this `InteractiveViewer`, but gesture events (such as pan or zoom) + will only be recognized within the viewer's area. Ensure this `InteractiveViewer` + is sized appropriately when using [`ClipBehavior.NONE`][flet.]. """ alignment: Optional[Alignment] = None @@ -94,9 +129,19 @@ class InteractiveViewer(LayoutControl): The alignment of the [`content`][(c).] within this viewer. """ - boundary_margin: MarginValue = 0 + boundary_margin: MarginValue = field(default_factory=lambda: Margin.all(0)) """ A margin for the visible boundaries of the [`content`][(c).]. + + Any transformation that results in the viewport being able to view outside + of the boundaries will be stopped at the boundary. The boundaries do not + rotate with the rest of the scene, so they are always aligned with the + viewport. + + To produce no boundaries at all, pass an infinite value. + + Defaults to `Margin.all(0)`, which results in boundaries that are the + exact same size and position as the [`content`][(c).]. """ interaction_update_interval: int = 200 diff --git a/sdk/python/packages/flet/src/flet/controls/types.py b/sdk/python/packages/flet/src/flet/controls/types.py index 38e754e6c4..4ed37e96ee 100644 --- a/sdk/python/packages/flet/src/flet/controls/types.py +++ b/sdk/python/packages/flet/src/flet/controls/types.py @@ -50,7 +50,7 @@ class RouteUrlStrategy(Enum): class UrlTarget(Enum): """ - TBD + Specifies where to open a URL. """ BLANK = "blank" @@ -426,7 +426,9 @@ class ScrollMode(Enum): class ClipBehavior(Enum): """ - Different ways to clip content. See [Clip](https://api.flutter.dev/flutter/dart-ui/Clip.html) + Different ways to clip content. + + See [Clip](https://api.flutter.dev/flutter/dart-ui/Clip.html) from Flutter documentation for ClipBehavior examples. """ @@ -466,9 +468,16 @@ class ImageRepeat(Enum): """ NO_REPEAT = "noRepeat" + """Repeat the image in both the x and y directions until the box is filled.""" + REPEAT = "repeat" + """Repeat the image in the x direction until the box is filled horizontally.""" + REPEAT_X = "repeatX" + """Repeat the image in the y direction until the box is filled vertically.""" + REPEAT_Y = "repeatY" + """Leave uncovered portions of the box transparent.""" class PagePlatform(Enum): @@ -1000,39 +1009,44 @@ class VisualDensity(Enum): STANDARD = "standard" """ - The default profile for VisualDensity. This default value represents a visual - density that is less dense than either `comfortable` or `compact`, and corresponds - to density values of zero in both axes. + The default/standard profile for visual density. + + This default value represents a visual density that is less dense than + either [`COMFORTABLE`][(c).] or [`COMPACT`][(c).], and corresponds to + density values of zero in both axes. """ COMPACT = "compact" """ - The profile for a "compact" interpretation of VisualDensity. + The profile for a "compact" interpretation of visual density. Individual components will interpret the density value independently, making - themselves more visually dense than `standard` and `comfortable` to different - degrees based on the Material Design specification of the `comfortable` setting for - their particular use case. + themselves more visually dense than [`STANDARD`][(c).] and [`COMFORTABLE`][(c).] to + different degrees based on the Material Design specification of the + [`COMFORTABLE`][(c).] setting for their particular use case. - It corresponds to a density value of -2 in both axes. + It corresponds to a density value of `-2` in both axes. """ COMFORTABLE = "comfortable" """ - The profile for a `comfortable` interpretation of `VisualDensity`. Individual + The profile for a "comfortable" interpretation of visual density. + + Individual components will interpret the density value independently, making themselves more - visually dense than `standard` and less dense than `compact` to different degrees - based on the Material Design specification of the `comfortable` setting for their - particular use case. + visually dense than [`STANDARD`][(c).] and less dense than [`COMPACT`][(c).] + to different degrees based on the Material Design specification of the + comfortable setting for their particular use case. - It corresponds to a density value of -1 in both axes. + It corresponds to a density value of `-1` in both axes. """ ADAPTIVE_PLATFORM_DENSITY = "adaptivePlatformDensity" """ - Visual density that is adaptive based on the given platform. For desktop platforms, - this returns `compact`, and for other platforms, it returns a default-constructed - VisualDensity. + Visual density that is adaptive based on the given platform. + + For desktop platforms, this returns [`COMPACT`][(c).], and for other platforms, + it returns a default-constructed visual density. """ @@ -1098,7 +1112,6 @@ class LocaleConfiguration: Represents a string or a control and can be: - a string, which will be converted internally into a [`Text`][flet.] control, - or a control. - """ # Wrapper From 9b8fad89d99ad19bf8dc62aea6a078889e3a69b8 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 5 Nov 2025 01:36:38 +0100 Subject: [PATCH 3/7] example + docs --- .../interactive_viewer/transformations.py | 51 +++++++++++++++++++ .../flet/docs/controls/interactiveviewer.md | 6 +++ 2 files changed, 57 insertions(+) create mode 100644 sdk/python/examples/controls/interactive_viewer/transformations.py diff --git a/sdk/python/examples/controls/interactive_viewer/transformations.py b/sdk/python/examples/controls/interactive_viewer/transformations.py new file mode 100644 index 0000000000..4ffb2d71fc --- /dev/null +++ b/sdk/python/examples/controls/interactive_viewer/transformations.py @@ -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) diff --git a/sdk/python/packages/flet/docs/controls/interactiveviewer.md b/sdk/python/packages/flet/docs/controls/interactiveviewer.md index d9f617f6a7..0f9fc41d1a 100644 --- a/sdk/python/packages/flet/docs/controls/interactiveviewer.md +++ b/sdk/python/packages/flet/docs/controls/interactiveviewer.md @@ -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) }} From 4e4d68554c2e8eb34a047d39c977db7112cd9d0d Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 5 Nov 2025 01:39:42 +0100 Subject: [PATCH 4/7] cleanup --- .../flet/src/flet/controls/core/interactive_viewer.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py b/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py index a3cd653a06..34a8315290 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py +++ b/sdk/python/packages/flet/src/flet/controls/core/interactive_viewer.py @@ -80,11 +80,6 @@ class InteractiveViewer(LayoutControl): """ The minimum allowed scale. - Scale is also affected by [`boundary_margin`][(c).]. If the scale would result in - viewing beyond the boundary, then it will not be allowed. By default, - [`boundary_margin`][(c).] is `0`, so scaling below 1.0 will not be - allowed in most cases without first increasing the [`boundary_margin`][(c).]. - The effective scale is limited by the value of [`boundary_margin`][(c).]. If scaling would cause the content to be displayed outside the defined boundary, it is prevented. By default, `boundary_margin` is set to `Margin.all(0)`, From 3e69cc457d8befdbf6c288349ec02c2583158f41 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 7 Nov 2025 00:58:20 +0100 Subject: [PATCH 5/7] comment code and fix dart analysis --- .../lib/src/controls/interactive_viewer.dart | 140 +++++++++++++++++- packages/flet/pubspec.yaml | 1 + 2 files changed, 133 insertions(+), 8 deletions(-) diff --git a/packages/flet/lib/src/controls/interactive_viewer.dart b/packages/flet/lib/src/controls/interactive_viewer.dart index 8f279b71ed..6622cabb6f 100644 --- a/packages/flet/lib/src/controls/interactive_viewer.dart +++ b/packages/flet/lib/src/controls/interactive_viewer.dart @@ -27,9 +27,18 @@ class InteractiveViewerControl extends StatefulWidget { class _InteractiveViewerControlState extends State with SingleTickerProviderStateMixin { + /// Controller shared with Flutter's InteractiveViewer to orchestrate + /// programmatic and gesture-driven transforms. final TransformationController _transformationController = TransformationController(); + + /// Keyed wrapper around the content so we can read its render box for + /// boundary calculations when clamping zoom/pan invoked from Python. final GlobalKey _childKey = GlobalKey(); + + /// `InteractiveViewer` sits inside `LayoutControl` wrappers; this key lets us + /// grab the actual viewport size without the extra decoration. + final GlobalKey _viewerKey = GlobalKey(); late AnimationController _animationController; Animation? _animation; Matrix4? _savedMatrix; @@ -44,6 +53,8 @@ class _InteractiveViewerControlState extends State widget.control.addInvokeMethodListener(_invokeMethod); } + /// Handles method channel calls from the Python side, mirroring the + /// user-driven gestures Flutter's [InteractiveViewer] supports. Future _invokeMethod(String name, dynamic args) async { debugPrint("InteractiveViewer.$name($args)"); switch (name) { @@ -115,6 +126,7 @@ class _InteractiveViewerControlState extends State } var interactiveViewer = InteractiveViewer( + key: _viewerKey, transformationController: _transformationController, panEnabled: widget.control.getBool("pan_enabled", true)!, scaleEnabled: widget.control.getBool("scale_enabled", true)!, @@ -160,6 +172,8 @@ class _InteractiveViewerControlState extends State return LayoutControl(control: widget.control, child: interactiveViewer); } + /// Returns a copy of [matrix] scaled by [scale] while honoring the viewer's + /// min/max scale settings and ensuring the content still covers the viewport. Matrix4 _matrixScale(Matrix4 matrix, double scale) { if (scale == 1.0) { return matrix.clone(); @@ -174,6 +188,8 @@ class _InteractiveViewerControlState extends State final double maxScale = widget.control.getDouble("max_scale", 2.5)!; double totalScale = currentScale * scale; + // Ensure we never shrink the content to a size where the viewport would + // extend beyond the boundaries – Flutter does the same during gestures. final Rect? boundaryRect = _currentBoundaryRect(); final Rect? viewportRect = _currentViewportRect(); if (boundaryRect != null && @@ -196,16 +212,21 @@ class _InteractiveViewerControlState extends State final double clampedTotalScale = clampDouble(totalScale, minScale, maxScale); final double clampedScale = clampedTotalScale / currentScale; - return matrix.clone()..scale(clampedScale, clampedScale, clampedScale); + return matrix.clone() + ..scaleByDouble(clampedScale, clampedScale, clampedScale, 1.0); } + /// Returns a matrix translated by [translation] and clamped to the same + /// boundaries Flutter enforces for gesture-driven panning. Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { if (translation == Offset.zero) { return matrix.clone(); } + // Apply the requested translation optimistically; we’ll clamp below if it + // violates the viewer bounds. final Matrix4 nextMatrix = matrix.clone() - ..translate(translation.dx, translation.dy, 0); + ..translateByDouble(translation.dx, translation.dy, 0.0, 1.0); final Rect? boundaryRect = _currentBoundaryRect(); final Rect? viewportRect = _currentViewportRect(); @@ -225,6 +246,8 @@ class _InteractiveViewerControlState extends State return nextMatrix; } + // Translation went out of bounds; pull it back so the viewport is fully + // inside the clamped area. final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix); final double currentScale = matrix.getMaxScaleOnAxis(); if (currentScale == 0) { @@ -250,11 +273,14 @@ class _InteractiveViewerControlState extends State return correctedMatrix; } + // If we still exceed in both axes the viewport is larger than the bounds, + // so do not permit the translation at all. if (offendingCorrectedDistance.dx != 0.0 && offendingCorrectedDistance.dy != 0.0) { return matrix.clone(); } + // Otherwise allow motion in the one dimension that still fits. final Offset unidirectionalCorrectedTotalTranslation = Offset( offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0, offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0, @@ -268,6 +294,7 @@ class _InteractiveViewerControlState extends State )); } + /// Computes the boundary rectangle, including margins, for the current child. Rect? _currentBoundaryRect() { final BuildContext? childContext = _childKey.currentContext; if (childContext == null) { @@ -283,8 +310,13 @@ class _InteractiveViewerControlState extends State return boundaryMargin.inflateRect(Offset.zero & childSize); } + /// Returns the visible viewport rectangle of the wrapped `InteractiveViewer`. Rect? _currentViewportRect() { - final RenderObject? renderObject = context.findRenderObject(); + final BuildContext? viewerContext = _viewerKey.currentContext; + if (viewerContext == null) { + return null; + } + final RenderObject? renderObject = viewerContext.findRenderObject(); if (renderObject is! RenderBox) { return null; } @@ -292,11 +324,14 @@ class _InteractiveViewerControlState extends State return Offset.zero & size; } + /// Extracts the translation component from [matrix] as an [Offset]. Offset _getMatrixTranslation(Matrix4 matrix) { final Vector3 translation = matrix.getTranslation(); return Offset(translation.x, translation.y); } + /// Applies the inverse transform of [matrix] to [viewport] to understand how + /// the viewport would move after the child transform is applied. Quad _transformViewport(Matrix4 matrix, Rect viewport) { final Matrix4 inverseMatrix = matrix.clone()..invert(); return Quad.points( @@ -315,20 +350,24 @@ class _InteractiveViewerControlState extends State ); } + /// Builds an axis-aligned bounding box for [rect] rotated by [rotation]. Quad _axisAlignedBoundingBoxWithRotation(Rect rect, double rotation) { final Matrix4 rotationMatrix = Matrix4.identity() - ..translate(rect.size.width / 2, rect.size.height / 2, 0) + ..translateByDouble(rect.size.width / 2, rect.size.height / 2, 0.0, 1.0) ..rotateZ(rotation) - ..translate(-rect.size.width / 2, -rect.size.height / 2, 0); + ..translateByDouble( + -rect.size.width / 2, -rect.size.height / 2, 0.0, 1.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); + return _axisAlignedBoundingBox(boundariesRotated); } + /// Measures how far [viewport] spills outside [boundary], returning the + /// required correction as an [Offset]. Offset _exceedsBy(Quad boundary, Quad viewport) { final List viewportPoints = [ viewport.point0, @@ -338,8 +377,7 @@ class _InteractiveViewerControlState extends State ]; Offset largestExcess = Offset.zero; for (final Vector3 point in viewportPoints) { - final Vector3 pointInside = - InteractiveViewer.getNearestPointInside(point, boundary); + final Vector3 pointInside = _nearestPointInside(point, boundary); final Offset excess = Offset(pointInside.x - point.x, pointInside.y - point.y); if (excess.dx.abs() > largestExcess.dx.abs()) { @@ -353,10 +391,96 @@ class _InteractiveViewerControlState extends State return _roundOffset(largestExcess); } + /// Rounds [offset] to trim floating point noise that accumulates during + /// transform calculations. Offset _roundOffset(Offset offset) { return Offset( double.parse(offset.dx.toStringAsFixed(9)), double.parse(offset.dy.toStringAsFixed(9)), ); } + + /// Returns the axis-aligned bounding box enclosing [quad]. + Quad _axisAlignedBoundingBox(Quad quad) { + final double minX = math.min( + quad.point0.x, + math.min(quad.point1.x, math.min(quad.point2.x, quad.point3.x)), + ); + final double minY = math.min( + quad.point0.y, + math.min(quad.point1.y, math.min(quad.point2.y, quad.point3.y)), + ); + final double maxX = math.max( + quad.point0.x, + math.max(quad.point1.x, math.max(quad.point2.x, quad.point3.x)), + ); + final double maxY = math.max( + quad.point0.y, + math.max(quad.point1.y, math.max(quad.point2.y, quad.point3.y)), + ); + return Quad.points( + Vector3(minX, minY, 0), + Vector3(maxX, minY, 0), + Vector3(maxX, maxY, 0), + Vector3(minX, maxY, 0), + ); + } + + /// Finds the closest point to [point] that still lies inside [quad]. + Vector3 _nearestPointInside(Vector3 point, Quad quad) { + if (_pointIsInside(point, quad)) { + return point; + } + + // Find the closest point on each edge and keep the minimum distance. + final List closestPoints = [ + _nearestPointOnLine(point, quad.point0, quad.point1), + _nearestPointOnLine(point, quad.point1, quad.point2), + _nearestPointOnLine(point, quad.point2, quad.point3), + _nearestPointOnLine(point, quad.point3, quad.point0), + ]; + double minDistance = double.infinity; + late Vector3 closestOverall; + for (final Vector3 closePoint in closestPoints) { + final double dx = point.x - closePoint.x; + final double dy = point.y - closePoint.y; + final double distance = math.sqrt(dx * dx + dy * dy); + if (distance < minDistance) { + minDistance = distance; + closestOverall = closePoint; + } + } + return closestOverall; + } + + /// Checks whether [point] is contained inside [quad] (inclusive). + bool _pointIsInside(Vector3 point, Quad quad) { + final Vector3 aM = point - quad.point0; + final Vector3 aB = quad.point1 - quad.point0; + final Vector3 aD = quad.point3 - quad.point0; + + final double aMAB = aM.dot(aB); + final double aBAB = aB.dot(aB); + final double aMAD = aM.dot(aD); + final double aDAD = aD.dot(aD); + + return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD; + } + + /// Finds the closest point on the line segment [l1]-[l2] to [point]. + Vector3 _nearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) { + final double dx = l2.x - l1.x; + final double dy = l2.y - l1.y; + final double lengthSquared = dx * dx + dy * dy; + + if (lengthSquared == 0) { + return l1; + } + + final Vector3 l1P = point - l1; + final Vector3 l1L2 = l2 - l1; + final double fraction = + clampDouble(l1P.dot(l1L2) / lengthSquared, 0.0, 1.0); + return l1 + l1L2 * fraction; + } } diff --git a/packages/flet/pubspec.yaml b/packages/flet/pubspec.yaml index c18d34bcad..7caf4f1b3d 100644 --- a/packages/flet/pubspec.yaml +++ b/packages/flet/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: sensors_plus: ^6.1.1 shared_preferences: 2.5.3 url_launcher: 6.3.2 + vector_math: ^2.2.0 web: ^1.1.1 web_socket_channel: ^3.0.2 window_manager: ^0.5.1 From 94380a84d5b3879068b28f0f0f401d12273bceff Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 7 Nov 2025 01:48:49 +0100 Subject: [PATCH 6/7] refactor: enhance documentation for reorderable components --- .../controls/reorderable_draggable/basic.py | 10 +- .../controls/core/reorderable_draggable.py | 18 ++++ .../material/reorderable_list_view.py | 99 ++++++++++++++----- 3 files changed, 96 insertions(+), 31 deletions(-) diff --git a/sdk/python/examples/controls/reorderable_draggable/basic.py b/sdk/python/examples/controls/reorderable_draggable/basic.py index 96d2572c0b..5df582b02b 100644 --- a/sdk/python/examples/controls/reorderable_draggable/basic.py +++ b/sdk/python/examples/controls/reorderable_draggable/basic.py @@ -2,23 +2,21 @@ def main(page: ft.Page): - get_color = lambda i: ( - ft.Colors.ERROR if i % 2 == 0 else ft.Colors.ON_ERROR_CONTAINER - ) + def get_color(index: int) -> ft.Colors: + return ft.Colors.ERROR if index % 2 == 0 else ft.Colors.ON_ERROR_CONTAINER page.add( ft.ReorderableListView( expand=True, - build_controls_on_demand=False, + show_default_drag_handles=False, on_reorder=lambda e: print( f"Reordered from {e.old_index} to {e.new_index}" ), - show_default_drag_handles=True, controls=[ ft.ReorderableDraggable( index=i, content=ft.ListTile( - title=ft.Text(f"Item {i}", color=ft.Colors.BLACK), + title=ft.Text(f"Draggable Item {i}", color=ft.Colors.BLACK), leading=ft.Icon(ft.Icons.CHECK, color=ft.Colors.RED), bgcolor=get_color(i), ), diff --git a/sdk/python/packages/flet/src/flet/controls/core/reorderable_draggable.py b/sdk/python/packages/flet/src/flet/controls/core/reorderable_draggable.py index 0c0e3c55b7..3de091cb3e 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/reorderable_draggable.py +++ b/sdk/python/packages/flet/src/flet/controls/core/reorderable_draggable.py @@ -11,6 +11,24 @@ class ReorderableDraggable(LayoutControl, AdaptiveControl): It creates a listener for a drag immediately following a pointer down event over the given [`content`][(c).] control. + + Example: + ```python + ft.ReorderableListView( + expand=True, + show_default_drag_handles=False, + controls=[ + ft.ReorderableDraggable( + index=i, + content=ft.ListTile( + title=f"Draggable Item {i}", + bgcolor=ft.Colors.GREY if i % 2 == 0 else ft.Colors.BLUE_ACCENT, + ), + ) + for i in range(10) + ], + ) + ``` """ index: int diff --git a/sdk/python/packages/flet/src/flet/controls/material/reorderable_list_view.py b/sdk/python/packages/flet/src/flet/controls/material/reorderable_list_view.py index ccc29ff667..c5715e9a24 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/reorderable_list_view.py +++ b/sdk/python/packages/flet/src/flet/controls/material/reorderable_list_view.py @@ -18,14 +18,28 @@ @dataclass class OnReorderEvent(Event["ReorderableListView"]): + """ + Represents an event triggered during the reordering of items in a + [`ReorderableListView`][flet.]. + """ + new_index: Optional[int] + """The new position of the item after reordering.""" + old_index: Optional[int] + """The original/previous position of the item before reordering.""" @control("ReorderableListView") class ReorderableListView(ListView): """ A scrollable list of controls that can be reordered. + + Tip: + By default, each child control (from [`controls`][(c).]) is draggable using an + automatically created drag handle (see [`show_default_drag_handles`][(c).]). + To customize the draggable area, use the [`ReorderableDraggable`][flet.] to + define your own drag handle or region. """ controls: list[Control] = field(default_factory=list) @@ -35,40 +49,42 @@ class ReorderableListView(ListView): horizontal: bool = False """ - Whether the `controls` should be laid out horizontally. + Whether the [`controls`][(c).] should be laid out horizontally. """ reverse: bool = False """ Whether the scroll view scrolls in the reading direction. - For example, if the reading direction is left-to-right and `horizontal` is `True`, - then the scroll view scrolls from left to right when `reverse` is `False` + For example, if the reading direction is left-to-right and [`horizontal`][(c).] + is `True`, then the scroll view scrolls from left to right when `reverse` is `False` and from right to left when `reverse` is `True`. - Similarly, if `horizontal` is `False`, then the scroll view scrolls from top + Similarly, if [`horizontal`][(c).] is `False`, then the scroll view scrolls from top to bottom when `reverse` is `False` and from bottom to top when `reverse` is `True`. """ item_extent: Optional[Number] = None """ - If non-null, forces the children to have the given extent in the scroll direction. + Defines the extent that the [`controls`][(c).] should have in the scroll direction. - Specifying an `item_extent` is more efficient than letting the children determine - their own extent because the scrolling machinery can make use of the foreknowledge - of the children's extent to save work, for example when the scroll position - changes drastically. + Specifying an `item_extent` is more efficient than letting the [`controls`][(c).] + determine their own extent because the scrolling machinery can make use of the + foreknowledge of the `controls` extent to save work, for example when the scroll + position changes drastically. """ first_item_prototype: bool = False """ - `True` if the dimensions of the first item should be used as a "prototype" for all - other items, i.e. their height or width will be the same as the first item. + Whether the dimensions of the first item should be used as a "prototype" + for all other items. + + If `True`, their height or width will be the same as the first item. """ padding: Optional[PaddingValue] = None """ - The amount of space by which to inset the `controls`. + The amount of space by which to inset the [`controls`][(c).]. """ clip_behavior: ClipBehavior = ClipBehavior.HARD_EDGE @@ -102,51 +118,84 @@ class ReorderableListView(ListView): auto_scroller_velocity_scalar: Optional[Number] = None """ - The velocity scalar per pixel over scroll. It represents how the velocity scale - with the over scroll distance. The auto-scroll velocity = (distance of overscroll) - * velocity scalar. + The velocity scalar per pixel over scroll. + + It represents how the velocity scale with the over scroll distance. + The auto-scroll velocity = (distance of overscroll) * velocity scalar. """ header: Optional[Control] = None """ - A non-reorderable header item to show before the `controls`. + A non-reorderable header item to show before the [`controls`][(c).]. """ footer: Optional[Control] = None """ - A non-reorderable footer item to show after the `controls`. + A non-reorderable footer item to show after the [`controls`][(c).]. """ build_controls_on_demand: bool = True """ - Whether the `controls` should be built lazily/on-demand, i.e. only when they are - about to become visible. + Whether the [`controls`][(c).] should be built lazily/on-demand, + i.e. only when they are about to become visible. This is particularly useful when dealing with a large number of controls. """ show_default_drag_handles: bool = True """ - TBD + Whether to show default drag handles for each [`controls`][(c).] item. + + If `True`: on desktop platforms, a drag handle is stacked over the + center of each item's trailing edge; on mobile platforms, a long + press anywhere on the item starts a drag. + + The default desktop drag handle is just an [`Icons.DRAG_HANDLE`][flet.] + wrapped by a [`ReorderableDraggable`][flet.]. On mobile platforms, the entire + item is wrapped with a [`ReorderableDelayedDragStartListener`]. + + To customize the appearance or layout of drag handles, wrap each + [`controls`][(c).] item, or a control within each of them, with a + [`ReorderableDraggable`][flet.], [`ReorderableDelayedDragStartListener`], + or your own subclass of [`ReorderableDraggable`][flet.]. For full control + over the drag handles, you might want to set `show_default_drag_handles` to `False`. + + Example: + ```python + ft.ReorderableListView( + expand=True, + show_default_drag_handles=False, + controls=[ + ft.ReorderableDraggable( + index=i, + content=ft.ListTile( + title=f"Draggable Item {i}", + bgcolor=ft.Colors.GREY if i % 2 == 0 else ft.Colors.BLUE_ACCENT, + ), + ) + for i in range(10) + ], + ) + ``` """ mouse_cursor: Optional[MouseCursor] = None """ - TBD + The cursor for a mouse pointer when it enters or is hovering over the drag handle. """ on_reorder: Optional[EventHandler[OnReorderEvent]] = None """ - Called when a child control has been dragged to a new location in the list and the - application should update the order of the items. + Called when a [`controls`][(c).] item has been dragged to a new location/position + and the order of the items gets updated. """ on_reorder_start: Optional[EventHandler[OnReorderEvent]] = None """ - Called when an item drag has started. + Called when a [`controls`][(c).] item drag has started. """ on_reorder_end: Optional[EventHandler[OnReorderEvent]] = None """ - Called when the dragged item is dropped. + Called when the dragged [`controls`][(c).] item is dropped. """ From 01b2b3fb2c6f362241177a37e711363a052a4d55 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 8 Nov 2025 03:06:30 +0100 Subject: [PATCH 7/7] fix `ScaleUpdateDetails.toMap()` --- packages/flet/lib/src/utils/events.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flet/lib/src/utils/events.dart b/packages/flet/lib/src/utils/events.dart index 17db1effa2..add5291730 100644 --- a/packages/flet/lib/src/utils/events.dart +++ b/packages/flet/lib/src/utils/events.dart @@ -13,8 +13,7 @@ extension ScaleEndDetailsExtension on ScaleEndDetails { extension ScaleUpdateDetailsExtension on ScaleUpdateDetails { Map toMap() => { "gfp": {"x": focalPoint.dx, "y": focalPoint.dy}, - "fpdx": focalPointDelta.dx, - "fpdy": focalPointDelta.dy, + "fpd": {"x": focalPointDelta.dx, "y": focalPointDelta.dy}, "lfp": {"x": localFocalPoint.dx, "y": localFocalPoint.dy}, "pc": pointerCount, "hs": horizontalScale,