diff --git a/packages/flet/lib/src/controls/context_menu.dart b/packages/flet/lib/src/controls/context_menu.dart new file mode 100644 index 0000000000..189fc01353 --- /dev/null +++ b/packages/flet/lib/src/controls/context_menu.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flet/src/utils/numbers.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../extensions/control.dart'; +import '../models/control.dart'; +import '../utils/popup_menu.dart'; +import '../utils/transforms.dart'; +import '../widgets/error.dart'; +import 'base_controls.dart'; + +class ContextMenuControl extends StatefulWidget { + final Control control; + + const ContextMenuControl({super.key, required this.control}); + + @override + State createState() => _ContextMenuControlState(); +} + +class _ContextMenuControlState extends State { + ContextMenuTrigger _primaryTrigger = ContextMenuTrigger.disabled; + ContextMenuTrigger _secondaryTrigger = ContextMenuTrigger.down; + ContextMenuTrigger _tertiaryTrigger = ContextMenuTrigger.down; + + Future? _pendingMenu; + + @override + void initState() { + super.initState(); + // Allow backend code to invoke methods on this control instance. + widget.control.addInvokeMethodListener(_invokeMethod); + } + + @override + void dispose() { + widget.control.removeInvokeMethodListener(_invokeMethod); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("ContextMenu build: ${widget.control.id}"); + + var content = widget.control.buildWidget("content"); + if (content == null) { + return const ErrorControl("ContextMenu.content must be visible"); + } + + _primaryTrigger = parseContextMenuTrigger( + widget.control.getString("primary_trigger"), + ContextMenuTrigger.disabled)!; + _secondaryTrigger = parseContextMenuTrigger( + widget.control.getString("secondary_trigger"), + ContextMenuTrigger.down)!; + _tertiaryTrigger = parseContextMenuTrigger( + widget.control.getString("tertiary_trigger"), ContextMenuTrigger.down)!; + + Widget result = GestureDetector( + behavior: HitTestBehavior.deferToChild, + onLongPressStart: _primaryTrigger == ContextMenuTrigger.longPress + ? (LongPressStartDetails details) => _handleLongPress( + _MouseButton.primary, + details.globalPosition, + details.localPosition, + ) + : null, + onSecondaryLongPressStart: + _secondaryTrigger == ContextMenuTrigger.longPress + ? (LongPressStartDetails details) => _handleLongPress( + _MouseButton.secondary, + details.globalPosition, + details.localPosition, + ) + : null, + onTertiaryLongPressStart: _tertiaryTrigger == ContextMenuTrigger.longPress + ? (LongPressStartDetails details) => _handleLongPress( + _MouseButton.tertiary, + details.globalPosition, + details.localPosition, + ) + : null, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _handlePointerDown, + child: content, + ), + ); + + return LayoutControl(control: widget.control, child: result); + } + + /// Handles pointer down events to determine if a context menu should be shown. + /// Only responds to mouse events and triggers the menu if the configured trigger is `down`. + void _handlePointerDown(PointerDownEvent event) { + if (event.kind != PointerDeviceKind.mouse) return; + + final button = _mouseButtonFromEvent(event.buttons); + if (button == null) return; + + final trigger = _getTriggerFromButton(button); + if (trigger != ContextMenuTrigger.down) return; + + _showMenu( + button: button, + globalPosition: event.position, + localPosition: event.localPosition, + ); + } + + void _handleLongPress( + _MouseButton button, Offset globalPosition, Offset localPosition) { + final trigger = _getTriggerFromButton(button); + if (trigger != ContextMenuTrigger.longPress) return; + + _showMenu( + button: button, + globalPosition: globalPosition, + localPosition: localPosition, + ); + } + + ContextMenuTrigger? _getTriggerFromButton(_MouseButton? button) { + switch (button) { + case _MouseButton.primary: + return _primaryTrigger; + case _MouseButton.secondary: + return _secondaryTrigger; + case _MouseButton.tertiary: + return _tertiaryTrigger; + default: + return null; + } + } + + /// Returns the corresponding [_MouseButton] based on the + /// given button bitmask, and `null` if no recognized button is pressed. + _MouseButton? _mouseButtonFromEvent(int buttons) { + if ((buttons & kPrimaryButton) != 0) { + return _MouseButton.primary; + } else if ((buttons & kSecondaryMouseButton) != 0) { + return _MouseButton.secondary; + } else if ((buttons & kTertiaryButton) != 0) { + return _MouseButton.tertiary; + } + return null; + } + + /// Picks popup menu items configured for the provided button, falling back + /// to the shared `items` collection when a button-specific list is empty. + List _getPopupItemsFromButton(_MouseButton? button) { + switch (button) { + case _MouseButton.primary: + return widget.control.children("primary_items"); + case _MouseButton.secondary: + return widget.control.children("secondary_items"); + case _MouseButton.tertiary: + return widget.control.children("tertiary_items"); + default: + return widget.control.children("items"); + } + } + + /// Serialises menu event data to a compact payload sent to Python handlers. + Map _eventPayload( + _MouseButton? button, Offset globalPosition, Offset? localPosition, + {int? itemId, int? itemIndex, int? itemCount}) { + return { + "b": button?.name, + "tr": _getTriggerFromButton(button)?.name, + "id": itemId, + "idx": itemIndex, + "ic": itemCount, + "g": {"x": globalPosition.dx, "y": globalPosition.dy}, + "l": localPosition != null + ? {"x": localPosition.dx, "y": localPosition.dy} + : null, + }; + } + + /// Opens the context menu for a specific button at the requested position. + Future _showMenu( + {required Offset globalPosition, + Offset? localPosition, + _MouseButton? button}) async { + // If a menu is already open, close it and wait for it to finish. + if (_pendingMenu != null) { + Navigator.of(context).pop(); + await _pendingMenu; + if (!mounted) return; + } + + // Get the overlay state and its render box for positioning the menu. + final overlayState = Overlay.of(context, rootOverlay: true); + final overlayRenderBox = + overlayState.context.findRenderObject() as RenderBox?; + if (overlayRenderBox == null || !overlayRenderBox.hasSize) return; + + // Calculate the position for the popup menu relative to the overlay. + final overlayOffset = overlayRenderBox.globalToLocal(globalPosition); + final position = RelativeRect.fromLTRB( + overlayOffset.dx, + overlayOffset.dy, + overlayRenderBox.size.width - overlayOffset.dx, + overlayRenderBox.size.height - overlayOffset.dy, + ); + + // Build popup menu entries. + final popupItems = _getPopupItemsFromButton(button).toList(growable: false); + final entries = buildPopupMenuEntries(popupItems, context); + + // Prepare event payload for menu events. + final basePayload = _eventPayload(button, globalPosition, localPosition, + itemCount: entries.length); + + // If there are no menu entries, send dismiss event. + if (entries.isEmpty) { + widget.control.triggerEvent("dismiss", basePayload); + return; + } + + // Show the popup menu and wait for user selection. + final menuFuture = showMenu( + context: context, + position: position, + items: entries, + ); + _pendingMenu = menuFuture; + final selection = await menuFuture; + + if (!mounted) return; + _pendingMenu = null; + + // Handle the user's selection or dismissal. + if (selection != null) { + final selectedControl = popupItems + .firstWhereOrNull((item) => item.id.toString() == selection); + widget.control.triggerEvent( + "select", + _eventPayload( + button, + globalPosition, + localPosition, + itemId: parseInt(selection), + itemCount: popupItems.length, + itemIndex: selectedControl != null + ? popupItems.indexOf(selectedControl) + : null, + )); + } else { + widget.control.triggerEvent( + "dismiss", + _eventPayload( + button, + globalPosition, + localPosition, + itemCount: popupItems.length, + )); + } + } + + Future _invokeMethod(String name, dynamic args) async { + switch (name) { + case "open": + // Get the render box for positioning the context menu. + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) { + throw StateError( + "ContextMenu render box is not ready to display a menu."); + } + + var globalPosition = parseOffset(args["global_position"]); + var localPosition = parseOffset(args["local_position"]); + + // If only local position is provided, obtain global position from it. + if (globalPosition == null && localPosition != null) { + globalPosition = renderBox.localToGlobal(localPosition); + } + // If only global position is provided, obtain local position from it. + else if (globalPosition != null && localPosition == null) { + localPosition = renderBox.globalToLocal(globalPosition); + } + + // Default to center of the render box if positions are missing. + localPosition ??= renderBox.size.center(Offset.zero); + globalPosition ??= renderBox.localToGlobal(localPosition); + + // Show the context menu at the calculated position. + _showMenu(globalPosition: globalPosition, localPosition: localPosition); + return null; + default: + throw ArgumentError("Unsupported method: $name"); + } + } +} + +enum _MouseButton { primary, secondary, tertiary } + +enum ContextMenuTrigger { disabled, down, longPress } + +ContextMenuTrigger? parseContextMenuTrigger(String? value, + [ContextMenuTrigger? defaultValue]) { + if (value == null) return defaultValue; + return ContextMenuTrigger.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase()) ?? + defaultValue; +} diff --git a/packages/flet/lib/src/controls/popup_menu_button.dart b/packages/flet/lib/src/controls/popup_menu_button.dart index 4936326ab1..6b0c21e32a 100644 --- a/packages/flet/lib/src/controls/popup_menu_button.dart +++ b/packages/flet/lib/src/controls/popup_menu_button.dart @@ -1,4 +1,3 @@ -import 'package:flet/src/utils/text.dart'; import 'package:flutter/material.dart'; import '../extensions/control.dart'; @@ -10,8 +9,8 @@ import '../utils/buttons.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/misc.dart'; -import '../utils/mouse.dart'; import '../utils/numbers.dart'; +import '../utils/popup_menu.dart'; import 'base_controls.dart'; class PopupMenuButtonControl extends StatelessWidget { @@ -51,60 +50,8 @@ class PopupMenuButtonControl extends StatelessWidget { control.triggerEvent("select", selection), onCanceled: () => control.triggerEvent("cancel"), onOpened: () => control.triggerEvent("open"), - itemBuilder: (BuildContext context) => control - .children("items") - .where((i) => i.type == "PopupMenuItem") - .map((item) { - var checked = item.getBool("checked"); - var height = item.getDouble("height", 48.0)!; - var padding = item.getPadding("padding"); - var itemContent = item.buildTextOrWidget("content"); - var itemIcon = item.buildIconOrWidget("icon"); - var mouseCursor = item.getMouseCursor("mouse_cursor"); - var labelTextStyle = item.getWidgetStateTextStyle( - "label_text_style", Theme.of(context)); - - Widget? child; - if (itemContent != null && itemIcon == null) { - child = itemContent; - } else if (itemContent == null && itemIcon != null) { - child = itemIcon; - } else if (itemContent != null && itemIcon != null) { - child = Row(children: [ - itemIcon, - const SizedBox(width: 8), - itemContent - ]); - } - - var result = checked != null - ? CheckedPopupMenuItem( - value: item.id.toString(), - checked: checked, - height: height, - padding: padding, - enabled: !item.disabled, - mouseCursor: mouseCursor, - labelTextStyle: labelTextStyle, - onTap: () => item.triggerEvent("click", !checked), - child: child, - ) - : PopupMenuItem( - value: item.id.toString(), - height: height, - padding: padding, - labelTextStyle: labelTextStyle, - enabled: !item.disabled, - mouseCursor: mouseCursor, - onTap: () { - item.triggerEvent("click"); - }, - child: child); - - return child != null - ? result - : const PopupMenuDivider() as PopupMenuEntry; - }).toList(), + itemBuilder: (BuildContext context) => + buildPopupMenuEntries(control.children("items"), context), child: content); return LayoutControl( diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index 04d20cc3c0..fe04f49fb8 100644 --- a/packages/flet/lib/src/flet_core_extension.dart +++ b/packages/flet/lib/src/flet_core_extension.dart @@ -20,6 +20,7 @@ import 'controls/chip.dart'; import 'controls/circle_avatar.dart'; import 'controls/column.dart'; import 'controls/container.dart'; +import 'controls/context_menu.dart'; import 'controls/cupertino_action_sheet.dart'; import 'controls/cupertino_action_sheet_action.dart'; import 'controls/cupertino_activity_indicator.dart'; @@ -172,6 +173,8 @@ class FletCoreExtension extends FletExtension { return ColumnControl(key: key, control: control); case "Container": return ContainerControl(key: key, control: control); + case "ContextMenu": + return ContextMenuControl(key: key, control: control); case "CupertinoActionSheet": return CupertinoActionSheetControl(key: key, control: control); case "CupertinoActionSheetAction": diff --git a/packages/flet/lib/src/utils/popup_menu.dart b/packages/flet/lib/src/utils/popup_menu.dart new file mode 100644 index 0000000000..94425f4da8 --- /dev/null +++ b/packages/flet/lib/src/utils/popup_menu.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../extensions/control.dart'; +import '../models/control.dart'; +import 'edge_insets.dart'; +import 'mouse.dart'; +import 'numbers.dart'; +import 'text.dart'; + +/// Builds a list of [PopupMenuEntry] widgets from a collection of Flet controls. +/// +/// Only controls with type `PopupMenuItem` are converted. Controls without any +/// visible content are treated as menu dividers. +List> buildPopupMenuEntries( + Iterable items, BuildContext context) { + return items.where((item) => item.type == "PopupMenuItem").map((item) { + var checked = item.getBool("checked"); + var height = item.getDouble("height", 48.0)!; + var padding = item.getPadding("padding"); + var itemContent = item.buildTextOrWidget("content"); + var itemIcon = item.buildIconOrWidget("icon"); + var mouseCursor = item.getMouseCursor("mouse_cursor"); + var labelTextStyle = + item.getWidgetStateTextStyle("label_text_style", Theme.of(context)); + + Widget? child; + if (itemContent != null && itemIcon == null) { + child = itemContent; + } else if (itemContent == null && itemIcon != null) { + child = itemIcon; + } else if (itemContent != null && itemIcon != null) { + child = Row(children: [ + itemIcon, + const SizedBox(width: 8), + itemContent, + ]); + } + + var entry = checked != null + ? CheckedPopupMenuItem( + value: item.id.toString(), + checked: checked, + height: height, + padding: padding, + enabled: !item.disabled, + mouseCursor: mouseCursor, + labelTextStyle: labelTextStyle, + onTap: () => item.triggerEvent("click", !checked), + child: child, + ) + : PopupMenuItem( + value: item.id.toString(), + height: height, + padding: padding, + labelTextStyle: labelTextStyle, + enabled: !item.disabled, + mouseCursor: mouseCursor, + onTap: () => item.triggerEvent("click"), + child: child, + ); + + return child != null + ? entry + : const PopupMenuDivider() as PopupMenuEntry; + }).toList(); +} diff --git a/sdk/python/examples/controls/context_menu/__init__.py b/sdk/python/examples/controls/context_menu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/examples/controls/context_menu/custom_trigger.py b/sdk/python/examples/controls/context_menu/custom_trigger.py new file mode 100644 index 0000000000..e433df487b --- /dev/null +++ b/sdk/python/examples/controls/context_menu/custom_trigger.py @@ -0,0 +1,42 @@ +import flet as ft + + +def main(page: ft.Page): + async def open_menu(e: ft.TapEvent[ft.GestureDetector]): + await menu.open( + local_position=e.local_position, + global_position=e.global_position, + ) + + page.add( + menu := ft.ContextMenu( + expand=True, + items=[ + ft.PopupMenuItem( + content="Cut", + on_click=lambda e: print(f"{e.control.content}"), + ), + ft.PopupMenuItem( + content="Copy", + on_click=lambda e: print(f"{e.control.content}"), + ), + ft.PopupMenuItem( + content="Paste", + on_click=lambda e: print(f"{e.control.content}"), + ), + ], + content=ft.GestureDetector( + expand=True, + on_double_tap_down=open_menu, + content=ft.Container( + content=ft.Text("Double-click to open the context menu."), + bgcolor=ft.Colors.BLUE, + alignment=ft.Alignment.CENTER, + ), + ), + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/context_menu/programmatic_open.py b/sdk/python/examples/controls/context_menu/programmatic_open.py new file mode 100644 index 0000000000..62e63e266c --- /dev/null +++ b/sdk/python/examples/controls/context_menu/programmatic_open.py @@ -0,0 +1,38 @@ +import flet as ft + + +def main(page: ft.Page): + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + page.vertical_alignment = ft.MainAxisAlignment.CENTER + + def handle_select(e: ft.ContextMenuSelectEvent): + action = e.item.content + page.show_dialog(ft.SnackBar(f"Item '{action}' selected.")) + + async def open_menu(e: ft.Event[ft.Button]): + await menu.open() + + page.add( + menu := ft.ContextMenu( + on_select=handle_select, + content=ft.Button("Click to open menu", on_click=open_menu), + items=[ + ft.PopupMenuItem( + content="Item 1", + on_click=lambda e: print(f"{e.control.content}"), + ), + ft.PopupMenuItem( + content="Item 2", + on_click=lambda e: print(f"{e.control.content}"), + ), + ft.PopupMenuItem( + content="Item 3", + on_click=lambda e: print(f"{e.control.content}"), + ), + ], + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/context_menu/triggers.py b/sdk/python/examples/controls/context_menu/triggers.py new file mode 100644 index 0000000000..9ad3e619fb --- /dev/null +++ b/sdk/python/examples/controls/context_menu/triggers.py @@ -0,0 +1,45 @@ +import flet as ft + + +async def main(page: ft.Page): + # on web, disable default browser context menu + if page.web: + await page.browser_context_menu.disable() + + def handle_item_click(e: ft.Event[ft.PopupMenuItem]): + action = e.control.content + page.show_dialog(ft.SnackBar(content=f"Item '{action}' selected.")) + + page.add( + ft.ContextMenu( + primary_items=[ + ft.PopupMenuItem(content="Primary 1", on_click=handle_item_click), + ft.PopupMenuItem(content="Primary 2", on_click=handle_item_click), + ], + primary_trigger=ft.ContextMenuTrigger.DOWN, + secondary_items=[ + ft.PopupMenuItem(content="Secondary 1", on_click=handle_item_click), + ft.PopupMenuItem(content="Secondary 2", on_click=handle_item_click), + ], + secondary_trigger=ft.ContextMenuTrigger.DOWN, + tertiary_items=[ + ft.PopupMenuItem(content="Tertiary 1", on_click=handle_item_click), + ft.PopupMenuItem(content="Tertiary 2", on_click=handle_item_click), + ], + tertiary_trigger=ft.ContextMenuTrigger.DOWN, + on_select=lambda e: print(f"Selected item: {e.item.content}"), + on_dismiss=lambda e: print("Menu dismissed"), + expand=True, + content=ft.Container( + expand=True, + bgcolor=ft.Colors.BLUE, + alignment=ft.Alignment.CENTER, + border_radius=ft.BorderRadius.all(12), + content=ft.Text("Left/middle/right click to open a context menu."), + ), + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/contextmenu.md b/sdk/python/packages/flet/docs/controls/contextmenu.md new file mode 100644 index 0000000000..9b86b016b7 --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/contextmenu.md @@ -0,0 +1,31 @@ +--- +class_name: flet.ContextMenu +examples: ../../examples/controls/context_menu +example_images: ../test-images/examples/material/golden/macos/context_menu +--- + +{{ class_summary(class_name, example_images + "/image_for_docs.png", image_caption="Basic ContextMenu") }} + +## Examples + +### Triggers + +```python +--8<-- "{{ examples }}/triggers.py" +``` + +## Programmatic open + +```python +--8<-- "{{ examples }}/programmatic_open.py" +``` + +{{ image(example_images + "/programmatic_open.png", width="80%") }} + +## Programmatic open with custom trigger + +```python +--8<-- "{{ examples }}/custom_trigger.py" +``` + +{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/types/contextmenudismissevent.md b/sdk/python/packages/flet/docs/types/contextmenudismissevent.md new file mode 100644 index 0000000000..52eb242a3d --- /dev/null +++ b/sdk/python/packages/flet/docs/types/contextmenudismissevent.md @@ -0,0 +1 @@ +{{ class_all_options("flet.ContextMenuDismissEvent") }} diff --git a/sdk/python/packages/flet/docs/types/contextmenuselectevent.md b/sdk/python/packages/flet/docs/types/contextmenuselectevent.md new file mode 100644 index 0000000000..9530295882 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/contextmenuselectevent.md @@ -0,0 +1 @@ +{{ class_all_options("flet.ContextMenuSelectEvent") }} diff --git a/sdk/python/packages/flet/docs/types/contextmenutrigger.md b/sdk/python/packages/flet/docs/types/contextmenutrigger.md new file mode 100644 index 0000000000..1b23c4ccd8 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/contextmenutrigger.md @@ -0,0 +1 @@ +{{ class_all_options("flet.ContextMenuTrigger", separate_signature=False) }} diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_1.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_1.png new file mode 100644 index 0000000000..3e32905bff Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_1.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_2.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_2.png new file mode 100644 index 0000000000..3e32905bff Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/context_menu/programmatic_open_2.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py b/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py index 119dbdefa6..6d2efa8650 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_alert_dialog.py @@ -30,7 +30,7 @@ async def test_alert_dialog_basic(flet_app: ftt.FletTestApp, request): await flet_app.tester.pump_and_settle() flet_app.assert_screenshot( - "alert_dialog_basic", + request.node.name, await flet_app.page.take_screenshot( pixel_ratio=flet_app.screenshots_pixel_ratio ), diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py new file mode 100644 index 0000000000..d4c59d55b4 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_context_menu.py @@ -0,0 +1,38 @@ +import pytest + +import flet as ft +import flet.testing as ftt + + +@pytest.mark.asyncio(loop_scope="module") +async def test_programmatic_open(flet_app: ftt.FletTestApp, request): + flet_app.page.enable_screenshots = True + await flet_app.resize_page(250, 250) + flet_app.page.add( + menu := ft.ContextMenu( + content=ft.IconButton(ft.Icons.MENU), + items=[ + ft.PopupMenuItem("Item 1"), + ft.PopupMenuItem("Item 2"), + ft.PopupMenuItem("Item 3"), + ], + ) + ) + await flet_app.tester.pump_and_settle() + + await menu.open() + await flet_app.tester.pump_and_settle() + flet_app.assert_screenshot( + "programmatic_open_1", + await flet_app.page.take_screenshot( + pixel_ratio=flet_app.screenshots_pixel_ratio + ), + ) + + await flet_app.tester.pump_and_settle() + flet_app.assert_screenshot( + "programmatic_open_2", + await flet_app.page.take_screenshot( + pixel_ratio=flet_app.screenshots_pixel_ratio + ), + ) diff --git a/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/image_for_docs.png b/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/image_for_docs.png new file mode 100644 index 0000000000..f5d6def08e Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/image_for_docs.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/programmatic_open.png b/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/programmatic_open.png new file mode 100644 index 0000000000..1f516099d4 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/material/golden/macos/context_menu/programmatic_open.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_card.py b/sdk/python/packages/flet/integration_tests/examples/material/test_card.py index 9dfda7bf06..908fef0629 100644 --- a/sdk/python/packages/flet/integration_tests/examples/material/test_card.py +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_card.py @@ -32,9 +32,9 @@ async def test_image_for_docs(flet_app_function: ftt.FletTestApp, request): indirect=True, ) @pytest.mark.asyncio(loop_scope="function") -async def test_basic(flet_app_function: ftt.FletTestApp): +async def test_music_info(flet_app_function: ftt.FletTestApp): flet_app_function.assert_screenshot( - "music_info", + test_music_info.__name__, await flet_app_function.take_page_controls_screenshot(), similarity_threshold=98.4, ) diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py b/sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py new file mode 100644 index 0000000000..9d10905da8 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_context_menu.py @@ -0,0 +1,57 @@ +import pytest + +import flet as ft +import flet.testing as ftt +from examples.controls.context_menu import programmatic_open + + +@pytest.mark.asyncio(loop_scope="function") +async def test_image_for_docs(flet_app_function: ftt.FletTestApp, request): + flet_app_function.page.enable_screenshots = True + await flet_app_function.resize_page(250, 200) + flet_app_function.page.add( + menu := ft.ContextMenu( + content=ft.IconButton(ft.Icons.MENU), + items=[ + ft.PopupMenuItem("Rename"), + ft.PopupMenuItem("Duplicate"), + ], + ) + ) + await flet_app_function.tester.pump_and_settle() + + # open menu + await menu.open() + await flet_app_function.tester.pump_and_settle() + + flet_app_function.assert_screenshot( + request.node.name, + await flet_app_function.page.take_screenshot( + pixel_ratio=flet_app_function.screenshots_pixel_ratio + ), + ) + + +@pytest.mark.parametrize( + "flet_app_function", + [{"flet_app_main": programmatic_open.main}], + indirect=True, +) +@pytest.mark.asyncio(loop_scope="function") +async def test_programmatic_open(flet_app_function: ftt.FletTestApp): + flet_app_function.page.enable_screenshots = True + await flet_app_function.resize_page(300, 300) + flet_app_function.page.update() + + # click button to open menu + await flet_app_function.tester.tap( + await flet_app_function.tester.find_by_text("Click to open menu") + ) + await flet_app_function.tester.pump_and_settle() + + flet_app_function.assert_screenshot( + test_programmatic_open.__name__, + await flet_app_function.page.take_screenshot( + pixel_ratio=flet_app_function.screenshots_pixel_ratio + ), + ) diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_expansion_tile.py b/sdk/python/packages/flet/integration_tests/examples/material/test_expansion_tile.py index 0b8f576c0d..85dd1972fc 100644 --- a/sdk/python/packages/flet/integration_tests/examples/material/test_expansion_tile.py +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_expansion_tile.py @@ -31,6 +31,6 @@ async def test_image_for_docs(flet_app_function: ftt.FletTestApp, request): @pytest.mark.asyncio(loop_scope="function") async def test_basic(flet_app_function: ftt.FletTestApp): flet_app_function.assert_screenshot( - "basic", + test_basic.__name__, await flet_app_function.take_page_controls_screenshot(), ) diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 4325f267de..d22146d102 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -306,6 +306,7 @@ nav: - CircleAvatar: controls/circleavatar.md - Column: controls/column.md - Container: controls/container.md + - ContextMenu: controls/contextmenu.md - CupertinoActionSheet: - controls/cupertinoactionsheet/index.md - CupertinoActionSheetAction: controls/cupertinoactionsheetaction.md @@ -761,6 +762,7 @@ nav: - CardVariant: types/cardvariant.md - ClipBehavior: types/clipbehavior.md - Colors: types/colors.md + - ContextMenuTrigger: types/contextmenutrigger.md - CrossAxisAlignment: types/crossaxisalignment.md - CupertinoButtonSize: types/cupertinobuttonsize.md - CupertinoColors: types/cupertinocolors.md @@ -832,6 +834,8 @@ nav: - AppLifecycleStateChangeEvent: types/applifecyclestatechangeevent.md - AutoCompleteSelectEvent: types/autocompleteselectevent.md - CanvasResizeEvent: types/canvasresizeevent.md + - ContextMenuDismissEvent: types/contextmenudismissevent.md + - ContextMenuSelectEvent: types/contextmenuselectevent.md - DataColumnSortEvent: types/datacolumnsortevent.md - DatePickerEntryModeChangeEvent: types/datepickerentrymodechangeevent.md - DismissibleDismissEvent: types/dismissibledismissevent.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index d710d17611..a16828e7f6 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -265,6 +265,12 @@ from flet.controls.material.chip import Chip from flet.controls.material.circle_avatar import CircleAvatar from flet.controls.material.container import Container +from flet.controls.material.context_menu import ( + ContextMenu, + ContextMenuDismissEvent, + ContextMenuSelectEvent, + ContextMenuTrigger, +) from flet.controls.material.datatable import ( DataCell, DataColumn, @@ -606,6 +612,10 @@ "ConstrainedControl", "Container", "Context", + "ContextMenu", + "ContextMenuDismissEvent", + "ContextMenuSelectEvent", + "ContextMenuTrigger", "ContinuousRectangleBorder", "Control", "ControlEvent", diff --git a/sdk/python/packages/flet/src/flet/components/memo.py b/sdk/python/packages/flet/src/flet/components/memo.py index 0968c09969..2a931938f3 100644 --- a/sdk/python/packages/flet/src/flet/components/memo.py +++ b/sdk/python/packages/flet/src/flet/components/memo.py @@ -6,24 +6,23 @@ def memo(fn): Lets you skip re-rendering a component when its props are unchanged. Example: + ```python + import flet as ft - ```python - import flet as ft + @ft.component + def MyComponent(x, y): + return ft.Text(f"x={x}, y={y}") - @ft.component - def MyComponent(x, y): - return ft.Text(f"x={x}, y={y}") + MemoizedMyComponent = ft.memo(MyComponent) - MemoizedMyComponent = ft.memo(MyComponent) - - flet.run( - lambda page: page.render( - lambda: MemoizedMyComponent(x=1, y=2), - ), - ) - ``` + flet.run( + lambda page: page.render( + lambda: MemoizedMyComponent(x=1, y=2), + ), + ) + ``` """ def memo_wrapper(*args, **kwargs): diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 011e39f859..4ed66dd786 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -23,18 +23,17 @@ def observable(cls): the `@dataclass` decorator. Example: + ```python + from dataclasses import dataclass + import flet as ft - ```python - from dataclasses import dataclass - import flet as ft - - @ft.observable - @dataclass - class MyDataClass: - x: int - y: int - ``` + @ft.observable + @dataclass + class MyDataClass: + x: int + y: int + ``` """ if Observable in cls.__mro__: return cls @@ -73,30 +72,29 @@ class Observable: Mixin: notifies when fields change; auto-wraps lists/dicts to be observable. Example: - - ```python - import flet as ft - from dataclasses import dataclass + ```python + import flet as ft + from dataclasses import dataclass - @ft.observable - @dataclass - class MyDataClass: - x: int - y: int + @ft.observable + @dataclass + class MyDataClass: + x: int + y: int - obj = MyDataClass(1, 2) + obj = MyDataClass(1, 2) - def listener(sender, field): - print(f"Changed: {field} in {sender}") + def listener(sender, field): + print(f"Changed: {field} in {sender}") - obj.subscribe(listener) - obj.x = 3 - obj.y = 4 - ``` + obj.subscribe(listener) + obj.x = 3 + obj.y = 4 + ``` """ __version__ = 0 # optional version counter diff --git a/sdk/python/packages/flet/src/flet/controls/base_page.py b/sdk/python/packages/flet/src/flet/controls/base_page.py index e59bbd90cf..6196ef772b 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_page.py +++ b/sdk/python/packages/flet/src/flet/controls/base_page.py @@ -177,15 +177,16 @@ class BasePage(AdaptiveControl): on_resize: Optional[EventHandler["PageResizeEvent"]] = None """ - Called when a user resizes a browser or native OS window containing Flet app, for - example: + Called when a user resizes a browser or native OS window containing Flet app - ```python - def page_resize(e): - print("New page size:", page.window.width, page.window_height) + Example: + ```python + def main(page: ft.Page): + def handle_page_size(e): + print("New page size:", page.window.width, page.window_height) - page.on_resize = page_resize - ``` + page.on_resize = handle_page_size + ``` """ on_media_change: Optional[EventHandler[PageMediaData]] = None diff --git a/sdk/python/packages/flet/src/flet/controls/context.py b/sdk/python/packages/flet/src/flet/controls/context.py index e31053bc86..a55181a202 100644 --- a/sdk/python/packages/flet/src/flet/controls/context.py +++ b/sdk/python/packages/flet/src/flet/controls/context.py @@ -21,12 +21,11 @@ def page(self) -> "Page": """ Returns the current [`Page`][flet.] associated with the context. - For example: - - ```python - # take page width anywhere in the app - width = ft.context.page.width - ``` + Example: + ```python + # take page width anywhere in the app + width = ft.context.page.width + ``` Returns: The current page. @@ -47,25 +46,24 @@ def enable_auto_update(self): """ Enables auto-update behavior for the current context. - For example: - - ```python - import flet as ft + Example: + ```python + import flet as ft - # disable auto-update globally for the app - ft.context.disable_auto_update() + # disable auto-update globally for the app + ft.context.disable_auto_update() - def main(page: ft.Page): - # enable auto-update just inside main - ft.context.enable_auto_update() + def main(page: ft.Page): + # enable auto-update just inside main + ft.context.enable_auto_update() - page.controls.append(ft.Text("Hello, world!")) - # page.update() - we don't need to call it explicitly + page.controls.append(ft.Text("Hello, world!")) + # page.update() - we don't need to call it explicitly - ft.run(main) - ``` + ft.run(main) + ``` """ _update_behavior_context_var.get()._auto_update_enabled = True @@ -73,27 +71,26 @@ def disable_auto_update(self): """ Disables auto-update behavior for the current context. - For example: - - ```python - import flet as ft + Example: + ```python + import flet as ft - def main(page: ft.Page): - def button_click(): - ft.context.disable_auto_update() - b.content = "Button clicked!" - # update just the button - b.update() + def main(page: ft.Page): + def button_click(): + ft.context.disable_auto_update() + b.content = "Button clicked!" + # update just the button + b.update() - page.controls.append(ft.Text("This won't appear")) - # no page.update() will be called here + page.controls.append(ft.Text("This won't appear")) + # no page.update() will be called here - page.controls.append(b := ft.Button("Action!", on_click=button_click)) - # page.update() - auto-update is enabled by default + page.controls.append(b := ft.Button("Action!", on_click=button_click)) + # page.update() - auto-update is enabled by default - ft.run(main) + ft.run(main) ``` """ _update_behavior_context_var.get()._auto_update_enabled = False diff --git a/sdk/python/packages/flet/src/flet/controls/material/context_menu.py b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py new file mode 100644 index 0000000000..6e73e0e02e --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/material/context_menu.py @@ -0,0 +1,205 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from flet.controls.base_control import control +from flet.controls.control import Control +from flet.controls.control_event import Event, EventHandler +from flet.controls.layout_control import LayoutControl +from flet.controls.material.popup_menu_button import PopupMenuItem +from flet.controls.transform import Offset, OffsetValue + +__all__ = [ + "ContextMenu", + "ContextMenuDismissEvent", + "ContextMenuSelectEvent", + "ContextMenuTrigger", +] + + +class ContextMenuTrigger(Enum): + """Defines how a menu is shown for a specific mouse button.""" + + DOWN = "down" + """ + Represents a trigger mode where the menu is shown + when the mouse button is pressed down. + """ + + LONG_PRESS = "longPress" + """ + Represents a trigger mode where the menu is shown + after a long press of the mouse button. + """ + + +@dataclass(kw_only=True) +class ContextMenuDismissEvent(Event["ContextMenu"]): + """Event fired when a [`ContextMenu`][flet.] is dismissed.""" + + global_position: Offset = field(metadata={"data_field": "g"}) + """ + Global pointer position in logical pixels. + """ + + local_position: Optional[Offset] = field(default=None, metadata={"data_field": "l"}) + """ + Local pointer position relative to the wrapped control. + """ + + button: Optional[str] = field(default=None, metadata={"data_field": "b"}) + """ + Mouse button that triggered the menu. + + If a string, can be one of: + `"primary"` (linked to [`ContextMenu.primary_items`][flet.]), + `"secondary"` (linked to [`ContextMenu.secondary_items`][flet.]), + or `"tertiary"` (linked to [`ContextMenu.tertiary_items`][flet.]). + """ + + trigger: Optional[ContextMenuTrigger] = field( + default=None, metadata={"data_field": "tr"} + ) + """ + The trigger mode that opened the menu. + """ + + item_count: Optional[int] = field(default=None, metadata={"data_field": "ic"}) + """ + Total number of entries displayed in the corresponding context menu. + """ + + +@dataclass(kw_only=True) +class ContextMenuSelectEvent(ContextMenuDismissEvent): + """Event fired when a [`ContextMenu`][flet.] item is selected.""" + + item_id: Optional[int] = field(default=None, metadata={"data_field": "id"}) + """ + Internal numeric identifier of the selected menu item. + """ + + item_index: Optional[int] = field(default=None, metadata={"data_field": "idx"}) + """ + Index of the selected menu entry within the rendered list. + """ + + @property + def item(self) -> Optional[PopupMenuItem]: + """The selected menu item.""" + return self.page.get_control(self.item_id) + + +@control("ContextMenu") +class ContextMenu(LayoutControl): + """ + Wraps its [`content`][(c).] and displays contextual + menus for specific mouse events. + + Tip: + On web, call [`disable()`][flet.BrowserContextMenu.disable] method of + [`Page.browser_context_menu`][flet.] to suppress the default browser + context menu before relying on custom menus. + """ + + content: Control + """ + The child control that listens for mouse interaction. + + Raises: + ValueError: If not visible. + """ + + items: list[PopupMenuItem] = field(default_factory=list) + """ + A list of menu items to display in the context menu, + when [`open()`][(c).open] is called. + """ + + primary_items: list[PopupMenuItem] = field(default_factory=list) + """ + A list of menu items to display in the context menu, + for primary (usually left) mouse button actions. + + These items are displayed when the corresponding + [`primary_trigger`][(c).] is activated. + """ + + secondary_items: list[PopupMenuItem] = field(default_factory=list) + """ + A list of menu items to display in the context menu + for secondary (usually right) mouse button actions. + + These items are displayed when the corresponding + [`secondary_trigger`][(c).] is activated. + """ + + tertiary_items: list[PopupMenuItem] = field(default_factory=list) + """ + A list of menu items to display in the context menu + for tertiary (usually middle) mouse button actions. + + These items are displayed when the corresponding + [`tertiary_trigger`][(c).] is activated. + """ + + primary_trigger: Optional[ContextMenuTrigger] = None + """ + Defines a trigger mode for the display of [`primary_items`][(c).]. + + If set to `None`, the trigger is disabled. + """ + + secondary_trigger: Optional[ContextMenuTrigger] = ContextMenuTrigger.DOWN + """ + Defines a trigger mode for the display of [`secondary_items`][(c).]. + + If set to `None`, the trigger is disabled. + """ + + tertiary_trigger: Optional[ContextMenuTrigger] = ContextMenuTrigger.DOWN + """ + Defines a trigger mode for the display of [`tertiary_items`][(c).]. + + If set to `None`, the trigger is disabled. + """ + + on_select: Optional[EventHandler[ContextMenuSelectEvent]] = None + """ + Fires when a context menu item is selected. + """ + + on_dismiss: Optional[EventHandler[ContextMenuDismissEvent]] = None + """ + Fires when the menu is dismissed without a selection, + or when an attempt is made to open the menu but no items are available. + """ + + async def open( + self, + global_position: Optional[OffsetValue] = None, + local_position: Optional[OffsetValue] = None, + ) -> None: + """ + Opens the context menu programmatically, and displays [`items`][(c).]. + + Args: + global_position: A global coordinate describing where the menu + should appear. If omitted, `local_position` or the center of the + [`content`][(c).] is used. + local_position: A local coordinate relative to the [`content`][(c).]. + When provided without `global_position`, the coordinate is translated + to global space automatically. + """ + await self._invoke_method( + "open", + { + "global_position": global_position, + "local_position": local_position, + }, + ) + + def before_update(self): + super().before_update() + if not self.content.visible: + raise ValueError("content must be visible") diff --git a/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py b/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py index 98d44a6b74..428f89d84d 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/popup_menu_button.py @@ -28,6 +28,10 @@ class PopupMenuPosition(Enum): @control("PopupMenuItem") class PopupMenuItem(Control): + """ + A popup menu item. + """ + content: Optional[StrOrControl] = None """ A `Control` representing custom content of this menu item. diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 6c358a1974..5b677905b6 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -453,19 +453,12 @@ def get_control(self, id: int) -> Optional[BaseControl]: Get a control by its `id`. Example: - - ```python - import flet as ft - - - def main(page: ft.Page): - x = ft.IconButton(ft.Icons.ADD) - page.add(x) - print(type(page.get_control(x.uid))) - - - ft.run(main) - ``` + ```python + def main(page: ft.Page): + x = ft.IconButton(ft.Icons.ADD) + page.add(x) + print(type(page.get_control(x._i))) + ``` """ return self.session.index.get(id) @@ -609,59 +602,58 @@ async def push_route(self, route: str, **kwargs: Any) -> None: handler. Example: + ```python + import flet as ft + import asyncio - ```python - import flet as ft - import asyncio - - - def main(page: ft.Page): - page.title = "Push Route Example" - - def route_change(e): - page.views.clear() - page.views.append( - ft.View( - route="/", - controls=[ - ft.AppBar(title=ft.Text("Flet app")), - ft.ElevatedButton( - "Visit Store", - on_click=lambda _: asyncio.create_task( - page.push_route("/store") - ), - ), - ], - ) - ) - if page.route == "/store": + + def main(page: ft.Page): + page.title = "Push Route Example" + + def route_change(e): + page.views.clear() page.views.append( ft.View( - route="/store", - can_pop=True, + route="/", controls=[ - ft.AppBar(title=ft.Text("Store")), + ft.AppBar(title=ft.Text("Flet app")), ft.ElevatedButton( - "Go Home", + "Visit Store", on_click=lambda _: asyncio.create_task( - page.push_route("/") + page.push_route("/store") ), ), ], ) ) + if page.route == "/store": + page.views.append( + ft.View( + route="/store", + can_pop=True, + controls=[ + ft.AppBar(title=ft.Text("Store")), + ft.ElevatedButton( + "Go Home", + on_click=lambda _: asyncio.create_task( + page.push_route("/") + ), + ), + ], + ) + ) - async def view_pop(e): - page.views.pop() - top_view = page.views[-1] - await page.push_route(top_view.route) + async def view_pop(e): + page.views.pop() + top_view = page.views[-1] + await page.push_route(top_view.route) - page.on_route_change = route_change - page.on_view_pop = view_pop + page.on_route_change = route_change + page.on_view_pop = view_pop - ft.run(main) - ``` + ft.run(main) + ``` Args: route: New navigation route. @@ -681,18 +673,17 @@ def get_upload_url(self, file_name: str, expires: int) -> str: * `file_name` - a relative to upload storage path. * `expires` - a URL time-to-live in seconds. - For example: - - ```python - upload_url = page.get_upload_url("dir/filename.ext", 60) - ``` + Example: + ```python + upload_url = page.get_upload_url("dir/filename.ext", 60) + ``` - To enable built-in upload storage provide `upload_dir` argument to `flet.app()` - call: + To enable built-in upload storage, provide the `upload_dir ` + argument to `ft.run()` call: - ```python - ft.run(main, upload_dir="uploads") - ``` + ```python + ft.run(main, upload_dir="uploads") + ``` """ return self.session.connection.get_upload_url(file_name, expires) @@ -839,15 +830,15 @@ async def can_launch_url(self, url: str) -> bool: Returns: `True` if it is possible to verify that there is a handler available. - `False` if there is no handler available, - or the application does not have permission to check. For example: - - - On recent versions of Android and iOS, this will always return `False` - unless the application has been configuration to allow querying the - system for launch support. - - In web mode, this will always return `False` except for a few specific - schemes that are always assumed to be supported (such as http(s)), - as web pages are never allowed to query installed applications. + `False` if there is no handler available, or the application does not + have permission to check. For example: + + - On recent versions of Android and iOS, this will always return `False` + unless the application has been configuration to allow querying the + system for launch support. + - In web mode, this will always return `False` except for a few specific + schemes that are always assumed to be supported (such as http(s)), + as web pages are never allowed to query installed applications. """ return await self.url_launcher.can_launch_url(url) diff --git a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py index f65607f7be..f216907eb2 100644 --- a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py +++ b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py @@ -48,25 +48,25 @@ class ScrollableControl(Control): scroll: Optional[ScrollMode] = None """ Enables a vertical scrolling for the Column to prevent its content overflow. - - Defaults to `ScrollMode.None`. """ auto_scroll: bool = False """ - `True` if scrollbar should automatically move its position to the end when children - updated. Must be `False` for `scroll_to()` method to work. + Whether the scrollbar should automatically move its position to the end when + children updated. + + Note: + Must be `False` for [`scroll_to()`][(c).scroll_to] method to work. """ scroll_interval: Number = 10 """ - Throttling in milliseconds for `on_scroll` event. + Throttling in milliseconds for [`on_scroll`][(c).] event. """ on_scroll: Optional[EventHandler[OnScrollEvent]] = None """ Called when scroll position is changed by a user. - class. """ async def scroll_to( @@ -74,77 +74,34 @@ async def scroll_to( offset: Optional[float] = None, delta: Optional[float] = None, scroll_key: Union[ScrollKey, str, int, float, bool, None] = None, - duration: Optional[DurationValue] = None, - curve: Optional[AnimationCurve] = None, + duration: DurationValue = 0, + curve: AnimationCurve = AnimationCurve.EASE, ): """ - Moves scroll position to either absolute `offset`, relative `delta` or jump to - the control with specified `key`. - - `offset` is an absolute value between minimum and maximum extents of a - scrollable control, for example: - - ```python - await products.scroll_to(offset=100, duration=1000) - ``` - - `offset` could be a negative to scroll from the end of a scrollable. For - example, to scroll to the very end: - - ```python - await products.scroll_to(offset=-1, duration=1000) - ``` - - `delta` allows moving scroll relatively to the current position. Use positive - `delta` to scroll forward and negative `delta` to scroll backward. For example, - to move scroll on 50 pixels forward: - - ```python - await products.scroll_to(delta=50) - ``` - - `key` allows moving scroll position to a control with specified `key`. Most of - Flet controls have `key` property which is translated to Flutter as - "global key". `key` must be unique for the entire page/view. For example: - - ```python - import flet as ft - - - def main(page: ft.Page): - cl = ft.Column( - spacing=10, - height=200, - width=200, - scroll=ft.ScrollMode.ALWAYS, - ) - for i in range(0, 50): - cl.controls.append(ft.Text(f"Text line {i}", key=str(i))) - - async def scroll_to_key(e): - await cl.scroll_to(scroll_key="20", duration=1000) - - page.add( - ft.Container(cl, border=ft.border.all(1)), - ft.Button("Scroll to key '20'", on_click=scroll_to_key), - ) - - - ft.run(main) - ``` - - Note: - `scroll_to()` method won't work with `ListView` and `GridView` controls - building their items dynamically. - - `duration` is scrolling animation duration in milliseconds. Defaults to `0` - - no animation. - - `curve` configures animation curve. Property value is - [`AnimationCurve`][flet.] - enum. - - Defaults to `AnimationCurve.EASE`. + Moves the scroll position. + + Args: + offset: Absolute scroll target in pixels. A negative value is interpreted + relative to the end (e.g. `-1` to jump to the very end). + delta: Relative scroll change in pixels. Positive values scroll forward, + negative values scroll backward. + scroll_key: Key of the target control to scroll to. + duration: The scroll animation duration. + curve: The scroll animation curve. + + Notes: + - Exactly one of `offset`, `delta` or `scroll_key` should be provided. + - [`auto_scroll`][(c).] must be `False`. + - This method is ineffective for controls (e.g. + [`ListView`][(c).], [`GridView`][(c).]) that build items dynamically. + + Examples: + ```python + await products.scroll_to(offset=100, duration=1000) + await products.scroll_to(offset=-1, duration=1000) # to the end + await products.scroll_to(delta=50) # forward 50px + await products.scroll_to(scroll_key="item_20", duration=500) + ``` """ await self._invoke_method(