Skip to content

Commit

Permalink
feat: InteractiveViewer programmatic transformations (#4451)
Browse files Browse the repository at this point in the history
* move durationFromString to utils/time.dart

* add new methods: reset, save_state, restore_state, zoom, pan

* run poetry lock --no-update

* update id typing in page.get_control()
  • Loading branch information
ndonkoHenri authored Nov 29, 2024
1 parent 38e19e0 commit ab58bf9
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 40 deletions.
137 changes: 109 additions & 28 deletions packages/flet/lib/src/controls/interactive_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import '../flet_control_backend.dart';
import '../models/control.dart';
import '../utils/alignment.dart';
import '../utils/edge_insets.dart';
import '../utils/numbers.dart';
import '../utils/others.dart';
import '../utils/time.dart';
import 'create_control.dart';
import 'error.dart';

class InteractiveViewerControl extends StatelessWidget {
class InteractiveViewerControl extends StatefulWidget {
final Control? parent;
final Control control;
final List<Control> children;
Expand All @@ -27,35 +29,110 @@ class InteractiveViewerControl extends StatelessWidget {
required this.parentAdaptive,
required this.backend});

@override
State<InteractiveViewerControl> createState() =>
_InteractiveViewerControlState();
}

class _InteractiveViewerControlState extends State<InteractiveViewerControl>
with SingleTickerProviderStateMixin {
final TransformationController _transformationController =
TransformationController();
late AnimationController _animationController;
Animation<Matrix4>? _animation;
Matrix4? _savedMatrix;

@override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: Duration.zero);

widget.backend.subscribeMethods(widget.control.id,
(methodName, args) async {
switch (methodName) {
case "zoom":
var factor = parseDouble(args["factor"]);
if (factor != null) {
_transformationController.value =
_transformationController.value.scaled(factor, factor);
}
break;
case "pan":
var dx = parseDouble(args["dx"]);
var dy = parseDouble(args["dy"]);
if (dx != null && dy != null) {
_transformationController.value =
_transformationController.value.clone()..translate(dx, dy);
}
break;
case "reset":
var duration = durationFromString(args["duration"]);
if (duration == null) {
_transformationController.value = Matrix4.identity();
} else {
_animationController.duration = duration;
_animation = Matrix4Tween(
begin: _transformationController.value,
end: Matrix4.identity(),
).animate(_animationController)
..addListener(() {
_transformationController.value = _animation!.value;
});
_animationController.forward(from: 0);
}
break;
case "save_state":
_savedMatrix = _transformationController.value.clone();
break;
case "restore_state":
if (_savedMatrix != null) {
_transformationController.value = _savedMatrix!;
}
break;
}
return null;
});
}

@override
void dispose() {
_transformationController.dispose();
_animationController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
debugPrint("InteractiveViewer build: ${control.id}");
debugPrint("InteractiveViewer build: ${widget.control.id}");

var contentCtrls = children.where((c) => c.isVisible);
bool? adaptive = control.isAdaptive ?? parentAdaptive;
bool disabled = control.isDisabled || parentDisabled;
var contentCtrls = widget.children.where((c) => c.isVisible);
bool? adaptive = widget.control.isAdaptive ?? widget.parentAdaptive;
bool disabled = widget.control.isDisabled || widget.parentDisabled;

var interactiveViewer = InteractiveViewer(
panEnabled: control.attrBool("panEnabled", true)!,
scaleEnabled: control.attrBool("scaleEnabled", true)!,
transformationController: _transformationController,
panEnabled: widget.control.attrBool("panEnabled", true)!,
scaleEnabled: widget.control.attrBool("scaleEnabled", true)!,
trackpadScrollCausesScale:
control.attrBool("trackpadScrollCausesScale", false)!,
constrained: control.attrBool("constrained", true)!,
maxScale: control.attrDouble("maxScale", 2.5)!,
minScale: control.attrDouble("minScale", 0.8)!,
interactionEndFrictionCoefficient:
control.attrDouble("interactionEndFrictionCoefficient", 0.0000135)!,
scaleFactor: control.attrDouble("scaleFactor", 200)!,
widget.control.attrBool("trackpadScrollCausesScale", false)!,
constrained: widget.control.attrBool("constrained", true)!,
maxScale: widget.control.attrDouble("maxScale", 2.5)!,
minScale: widget.control.attrDouble("minScale", 0.8)!,
interactionEndFrictionCoefficient: widget.control
.attrDouble("interactionEndFrictionCoefficient", 0.0000135)!,
scaleFactor: widget.control.attrDouble("scaleFactor", 200)!,
clipBehavior:
parseClip(control.attrString("clipBehavior"), Clip.hardEdge)!,
alignment: parseAlignment(control, "alignment"),
parseClip(widget.control.attrString("clipBehavior"), Clip.hardEdge)!,
alignment: parseAlignment(widget.control, "alignment"),
boundaryMargin:
parseEdgeInsets(control, "boundaryMargin", EdgeInsets.zero)!,
parseEdgeInsets(widget.control, "boundaryMargin", EdgeInsets.zero)!,
onInteractionStart: !disabled
? (ScaleStartDetails details) {
debugPrint("InteractiveViewer ${control.id} onInteractionStart");
backend.triggerControlEvent(
control.id,
debugPrint(
"InteractiveViewer ${widget.control.id} onInteractionStart");
widget.backend.triggerControlEvent(
widget.control.id,
"interaction_start",
jsonEncode({
"pc": details.pointerCount,
Expand All @@ -68,9 +145,10 @@ class InteractiveViewerControl extends StatelessWidget {
: null,
onInteractionEnd: !disabled
? (ScaleEndDetails details) {
debugPrint("InteractiveViewer ${control.id} onInteractionEnd");
backend.triggerControlEvent(
control.id,
debugPrint(
"InteractiveViewer ${widget.control.id} onInteractionEnd");
widget.backend.triggerControlEvent(
widget.control.id,
"interaction_end",
jsonEncode({
"pc": details.pointerCount,
Expand All @@ -80,9 +158,10 @@ class InteractiveViewerControl extends StatelessWidget {
: null,
onInteractionUpdate: !disabled
? (ScaleUpdateDetails details) {
debugPrint("InteractiveViewer ${control.id} onInteractionUpdate");
backend.triggerControlEvent(
control.id,
debugPrint(
"InteractiveViewer ${widget.control.id} onInteractionUpdate");
widget.backend.triggerControlEvent(
widget.control.id,
"interaction_update",
jsonEncode({
"pc": details.pointerCount,
Expand All @@ -98,11 +177,13 @@ class InteractiveViewerControl extends StatelessWidget {
}
: null,
child: contentCtrls.isNotEmpty
? createControl(control, contentCtrls.first.id, disabled,
? createControl(widget.control, contentCtrls.first.id, disabled,
parentAdaptive: adaptive)
: const ErrorControl(
"InteractiveViewer.content must be provided and visible"),
);
return constrainedControl(context, interactiveViewer, parent, control);

return constrainedControl(
context, interactiveViewer, widget.parent, widget.control);
}
}
6 changes: 6 additions & 0 deletions packages/flet/lib/src/utils/time.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ Duration? durationFromJSON(dynamic json, [Duration? defaultValue]) {
milliseconds: parseInt(json["milliseconds"], 0)!,
microseconds: parseInt(json["microseconds"], 0)!);
}

Duration? durationFromString(String? duration, [Duration? defaultValue]) {
return duration != null
? durationFromJSON(json.decode(duration), defaultValue)
: defaultValue;
}
6 changes: 0 additions & 6 deletions packages/flet_map/lib/src/map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,6 @@ class _MapControlState extends State<MapControl>
super.dispose();
}

Duration? durationFromString(String? duration, [Duration? defaultValue]) {
return duration != null
? durationFromJSON(json.decode(duration), defaultValue)
: defaultValue;
}

@override
Widget build(BuildContext context) {
debugPrint("Map build: ${widget.control.id} (${widget.control.hashCode})");
Expand Down
34 changes: 30 additions & 4 deletions sdk/python/packages/flet/src/flet/core/interactive_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@
from flet.core.event_handler import EventHandler
from flet.core.ref import Ref
from flet.core.tooltip import TooltipValue
from flet.core.types import (ClipBehavior, MarginValue, Offset, OffsetValue,
OptionalControlEventCallable,
OptionalEventCallable, ResponsiveNumber,
RotateValue, ScaleValue)
from flet.core.types import (
ClipBehavior,
DurationValue,
MarginValue,
Number,
Offset,
OffsetValue,
OptionalControlEventCallable,
OptionalEventCallable,
ResponsiveNumber,
RotateValue,
ScaleValue,
)


class InteractiveViewerInteractionStartEvent(ControlEvent):
Expand Down Expand Up @@ -193,6 +202,23 @@ def before_update(self):
def _get_children(self):
return [self.__content]

def reset(self, animation_duration: Optional[DurationValue] = None):
self.invoke_method(
"reset", arguments={"duration": self._convert_attr_json(animation_duration)}
)

def save_state(self):
self.invoke_method("save_state")

def restore_state(self):
self.invoke_method("restore_state")

def zoom(self, factor: Number):
self.invoke_method("zoom", arguments={"factor": str(factor)})

def pan(self, dx: Number, dy: Number):
self.invoke_method("pan", arguments={"dx": str(dx), "dy": str(dy)})

# min_scale
@property
def min_scale(self) -> float:
Expand Down
2 changes: 1 addition & 1 deletion sdk/python/packages/flet/src/flet/core/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ def convert_page_media_change_event(e):

_session_page.set(self)

def get_control(self, id: int) -> Control:
def get_control(self, id: str) -> Control:
return self._index.get(id)

def before_update(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion sdk/python/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ab58bf9

Please sign in to comment.