From ec57b0e797a00faba2eb5b5815f5cd03f7e303ca Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Mon, 3 Nov 2025 03:11:56 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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, From 69a65ddf57c9e4433496f7a4c880c15daf174ce4 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 9 Nov 2025 19:12:36 +0100 Subject: [PATCH 08/11] fix markdown tests: set similarity threshold to 97 --- .../integration_tests/controls/material/test_markdown.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py b/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py index 4af57a6248..c8696b3714 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py @@ -1,9 +1,8 @@ import pytest -import asyncio + import flet as ft import flet.testing as ftt - sample1 = """ # Markdown Example Markdown allows you to easily include formatted text, images, and even formatted Dart @@ -79,7 +78,6 @@ """ md = ft.Markdown( - value=sample1, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, ) @@ -87,9 +85,11 @@ @pytest.mark.asyncio(loop_scope="module") async def test_md_1(flet_app: ftt.FletTestApp, request): + md.value = sample1 await flet_app.assert_control_screenshot( request.node.name, md, + similarity_threshold=97, ) @@ -99,4 +99,5 @@ async def test_md_2(flet_app: ftt.FletTestApp, request): await flet_app.assert_control_screenshot( request.node.name, md, + similarity_threshold=97, ) From d64cb6b3f102da80359df82d9ab8d92036aafee4 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 11 Nov 2025 00:09:40 +0100 Subject: [PATCH 09/11] fix: update documentation for image repeat modes in `types.py` --- sdk/python/packages/flet/src/flet/controls/types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/controls/types.py b/sdk/python/packages/flet/src/flet/controls/types.py index 4ed37e96ee..225767c16e 100644 --- a/sdk/python/packages/flet/src/flet/controls/types.py +++ b/sdk/python/packages/flet/src/flet/controls/types.py @@ -468,16 +468,16 @@ class ImageRepeat(Enum): """ NO_REPEAT = "noRepeat" - """Repeat the image in both the x and y directions until the box is filled.""" + """Leave uncovered portions of the box transparent.""" REPEAT = "repeat" - """Repeat the image in the x direction until the box is filled horizontally.""" + """Repeat the image in both the x and y directions until the box is filled.""" REPEAT_X = "repeatX" - """Repeat the image in the y direction until the box is filled vertically.""" + """Repeat the image in the x direction until the box is filled horizontally.""" REPEAT_Y = "repeatY" - """Leave uncovered portions of the box transparent.""" + """Repeat the image in the y direction until the box is filled vertically.""" class PagePlatform(Enum): From 9785f07e3b4e97769f336f726550236d09d34b9a Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 11 Nov 2025 10:53:36 +0100 Subject: [PATCH 10/11] apply review suggestions --- .../controls/material/test_markdown.py | 2 -- .../controls/core/reorderable_draggable.py | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py b/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py index c8696b3714..0639487f9e 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py @@ -89,7 +89,6 @@ async def test_md_1(flet_app: ftt.FletTestApp, request): await flet_app.assert_control_screenshot( request.node.name, md, - similarity_threshold=97, ) @@ -99,5 +98,4 @@ async def test_md_2(flet_app: ftt.FletTestApp, request): await flet_app.assert_control_screenshot( request.node.name, md, - similarity_threshold=97, ) 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 3de091cb3e..165424d3cb 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 @@ -13,22 +13,22 @@ class ReorderableDraggable(LayoutControl, AdaptiveControl): 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) - ], - ) - ``` + ```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 From 7d75e70c23d546a44f256af0c6fa8ebd3b86e960 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 11 Nov 2025 09:16:01 -0800 Subject: [PATCH 11/11] Refactor markdown test to use local Markdown instances Replaces the shared 'md' Markdown instance with local instances in each test, and renames sample variables for clarity. This improves test isolation and readability. --- .../controls/material/test_markdown.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py b/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py index 0639487f9e..c0d3195a02 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_markdown.py @@ -3,7 +3,7 @@ import flet as ft import flet.testing as ftt -sample1 = """ +sample_1 = """ # Markdown Example Markdown allows you to easily include formatted text, images, and even formatted Dart code in your app. @@ -46,7 +46,7 @@ """ -sample2 = """ +sample_2 = """ ## Tables |Syntax |Result | @@ -77,25 +77,26 @@ ``` """ -md = ft.Markdown( - selectable=True, - extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, -) - @pytest.mark.asyncio(loop_scope="module") async def test_md_1(flet_app: ftt.FletTestApp, request): - md.value = sample1 await flet_app.assert_control_screenshot( request.node.name, - md, + ft.Markdown( + value=sample_1, + selectable=True, + extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, + ), ) @pytest.mark.asyncio(loop_scope="module") async def test_md_2(flet_app: ftt.FletTestApp, request): - md.value = sample2 await flet_app.assert_control_screenshot( request.node.name, - md, + ft.Markdown( + value=sample_2, + selectable=True, + extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, + ), )