diff --git a/packages/flet/lib/src/controls/dropdown.dart b/packages/flet/lib/src/controls/dropdown.dart index eac4e5e15..7fdebd3cc 100644 --- a/packages/flet/lib/src/controls/dropdown.dart +++ b/packages/flet/lib/src/controls/dropdown.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flet/flet.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -16,21 +17,27 @@ class DropdownControl extends StatefulWidget { class _DropdownControlState extends State { late final FocusNode _focusNode; late final TextEditingController _controller; + String? _value; + bool _suppressTextChange = false; @override void initState() { super.initState(); - _focusNode = FocusNode(); - _controller = TextEditingController(text: widget.control.getString("text")); + // initialize controller + _value = widget.control.getString("value"); + final text = widget.control.getString("text") ?? _value ?? ""; + _controller = TextEditingController(text: text); + _controller.addListener(_onTextChange); + + _focusNode = FocusNode(); _focusNode.addListener(_onFocusChange); widget.control.addInvokeMethodListener(_invokeMethod); - - _controller.addListener(_onTextChange); } void _onTextChange() { - debugPrint("Typed text: ${_controller.text}"); + if (_suppressTextChange) return; + if (_controller.text != widget.control.getString("text")) { widget.control.updateProperties({"text": _controller.text}); widget.control.triggerEvent("text_change", _controller.text); @@ -41,6 +48,17 @@ class _DropdownControlState extends State { widget.control.triggerEvent(_focusNode.hasFocus ? "focus" : "blur"); } + /// Updates text without triggering a text change event. + void _updateControllerText(String text) { + _suppressTextChange = true; + _controller.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + _suppressTextChange = false; + widget.control.updateProperties({"text": text}, python: false); + } + @override void dispose() { _focusNode.removeListener(_onFocusChange); @@ -65,13 +83,12 @@ class _DropdownControlState extends State { debugPrint("DropdownMenu build: ${widget.control.id}"); var theme = Theme.of(context); - bool editable = widget.control.getBool("editable", false)!; - bool autofocus = widget.control.getBool("autofocus", false)!; + var editable = widget.control.getBool("editable", false)!; + var autofocus = widget.control.getBool("autofocus", false)!; var textSize = widget.control.getDouble("text_size"); var color = widget.control.getColor("color", context); - TextAlign textAlign = - widget.control.getTextAlign("text_align", TextAlign.start)!; + var textAlign = widget.control.getTextAlign("text_align", TextAlign.start)!; var fillColor = widget.control.getColor("fill_color", context); var borderColor = widget.control.getColor("border_color", context); @@ -85,7 +102,7 @@ class _DropdownControlState extends State { var bgColor = widget.control.getWidgetStateColor("bgcolor", theme); var elevation = widget.control.getWidgetStateDouble("elevation"); - FormFieldInputBorder inputBorder = widget.control + var inputBorder = widget.control .getFormFieldInputBorder("border", FormFieldInputBorder.outline)!; InputBorder? border; @@ -115,7 +132,7 @@ class _DropdownControlState extends State { ? BorderSide.none : BorderSide( color: borderColor ?? - theme.colorScheme.onSurface.withOpacity(0.38), + theme.colorScheme.onSurface.withValues(alpha: 0.38), width: borderWidth ?? 1.0)); } } @@ -176,30 +193,54 @@ class _DropdownControlState extends State { color: color ?? theme.colorScheme.onSurface); } - var items = widget.control + // build dropdown items + var options = widget.control .children("options") - .map>((Control itemCtrl) { - bool itemDisabled = widget.control.disabled || itemCtrl.disabled; - ButtonStyle? style = itemCtrl.getButtonStyle("style", theme); - - return DropdownMenuEntry( - enabled: !itemDisabled, - value: itemCtrl.getString("key") ?? - itemCtrl.getString("text") ?? - itemCtrl.id.toString(), - label: itemCtrl.getString("text") ?? - itemCtrl.getString("key") ?? - itemCtrl.id.toString(), - labelWidget: itemCtrl.buildWidget("content"), - leadingIcon: itemCtrl.buildIconOrWidget("leading_icon"), - trailingIcon: itemCtrl.buildIconOrWidget("trailing_icon"), - style: style, - ); - }).toList(); - - String? value = widget.control.getString("value"); - if (items.where((item) => item.value == value).isEmpty) { - value = null; + .map?>((Control itemCtrl) { + bool itemDisabled = widget.control.disabled || itemCtrl.disabled; + ButtonStyle? style = itemCtrl.getButtonStyle("style", theme); + + var optionKey = itemCtrl.getString("key"); + var optionText = itemCtrl.getString("text"); + + var optionValue = optionKey ?? optionText; + var optionLabel = optionText ?? optionKey; + if (optionValue == null || optionLabel == null) { + return null; + } + + return DropdownMenuEntry( + enabled: !itemDisabled, + value: optionValue, + label: optionLabel, + labelWidget: itemCtrl.buildWidget("content"), + leadingIcon: itemCtrl.buildIconOrWidget("leading_icon"), + trailingIcon: itemCtrl.buildIconOrWidget("trailing_icon"), + style: style, + ); + }) + .nonNulls + .toList(); + + var value = widget.control.getString("value"); + var selectedOption = options.firstWhereOrNull((o) => o.value == value); + value = selectedOption?.value; + + // keep controller text in sync with backend-driven value changes + if (_value != value) { + if (value == null) { + if (_value != null && _controller.text.isNotEmpty) { + // clears dropdown field + _updateControllerText(""); + } + } else { + final String entryLabel = + selectedOption?.label ?? widget.control.getString("text") ?? value; + if (_controller.text != entryLabel) { + _updateControllerText(entryLabel); + } + } + _value = value; } TextCapitalization textCapitalization = widget.control @@ -237,20 +278,16 @@ class _DropdownControlState extends State { errorText: widget.control.getString("error_text"), hintText: widget.control.getString("hint_text"), helperText: widget.control.getString("helper_text"), - // menuStyle: MenuStyle( - // backgroundColor: widget.control.getWidgetStateColor("bgcolor", theme), - // elevation: widget.control.getWidgetStateDouble("elevation"), - // fixedSize: WidgetStateProperty.all(Size.fromWidth(menuWidth)), - // ), menuStyle: menuStyle, inputDecorationTheme: inputDecorationTheme, onSelected: widget.control.disabled ? null - : (String? value) { - widget.control.updateProperties({"value": value}); - widget.control.triggerEvent("select", value); + : (String? selection) { + _value = selection; + widget.control.updateProperties({"value": selection}); + widget.control.triggerEvent("select", selection); }, - dropdownMenuEntries: items, + dropdownMenuEntries: options, ); var didAutoFocus = false; diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_0.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_0.png index 2b215ec82..bd328ed14 100644 Binary files a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_0.png and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_0.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_1.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_1.png index 27d6189b9..82857d688 100644 Binary files a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_1.png and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_1.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_2.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_2.png index d79b18764..8c3f8289a 100644 Binary files a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_2.png and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/basic_2.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_0.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_0.png index d9dca05bc..2309359bb 100644 Binary files a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_0.png and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_0.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_1.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_1.png index e7a6c6a44..70612bb06 100644 Binary files a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_1.png and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/dropdown/theme_1.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_dropdown.py b/sdk/python/packages/flet/integration_tests/controls/material/test_dropdown.py index 29864437d..7e9c4c693 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_dropdown.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_dropdown.py @@ -13,21 +13,21 @@ def flet_app(flet_app_function): @pytest.mark.asyncio(loop_scope="function") async def test_basic(flet_app: ftt.FletTestApp, request): - colors = [ft.Colors.RED, ft.Colors.BLUE, ft.Colors.GREEN] - dd = ft.Dropdown( - label="Color", - text="Select a color", - options=[ - ft.DropdownOption( - key=color.value, content=ft.Text(value=color.value, color=color) - ) - for color in colors - ], - key="dd", - ) flet_app.page.enable_screenshots = True - flet_app.resize_page(400, 600) - flet_app.page.add(dd) + flet_app.resize_page(350, 300) + + colors = ["red", "blue", "green"] + flet_app.page.add( + dd := ft.Dropdown( + key="dd", + label="Color", + text="Select a color", + options=[ + ft.DropdownOption(key=color, content=ft.Text(value=color, color=color)) + for color in colors + ], + ) + ) await flet_app.tester.pump_and_settle() # normal state @@ -48,10 +48,10 @@ async def test_basic(flet_app: ftt.FletTestApp, request): ), ) - blue_option = await flet_app.tester.find_by_text("blue") - assert blue_option.count == 2 - - await flet_app.tester.tap(blue_option.last) + # select red option + red_options = await flet_app.tester.find_by_text("red") + assert red_options.count == 2 # Flutter Finder bug - should be 1 + await flet_app.tester.tap(red_options.last) await flet_app.tester.pump_and_settle() flet_app.assert_screenshot( "basic_2", @@ -60,9 +60,22 @@ async def test_basic(flet_app: ftt.FletTestApp, request): ), ) + # clear value + dd.value = None + dd.update() + await flet_app.tester.pump_and_settle() + flet_app.assert_screenshot( + "basic_0", + await flet_app.page.take_screenshot( + pixel_ratio=flet_app.screenshots_pixel_ratio + ), + ) + @pytest.mark.asyncio(loop_scope="function") async def test_theme(flet_app: ftt.FletTestApp, request): + flet_app.page.enable_screenshots = True + flet_app.resize_page(350, 300) flet_app.page.theme = ft.Theme( dropdown_theme=ft.DropdownTheme( text_style=ft.TextStyle(color=ft.Colors.PURPLE, size=20), @@ -73,20 +86,19 @@ async def test_theme(flet_app: ftt.FletTestApp, request): ), ) ) + colors = [ft.Colors.RED, ft.Colors.BLUE, ft.Colors.GREEN] - dd = ft.Dropdown( - label="Color", - text="Select a color", - options=[ - ft.DropdownOption(key=color.value, content=ft.Text(value=color.value)) - for color in colors - ], - key="dd", + flet_app.page.add( + ft.Dropdown( + key="dd", + label="Color", + text="Select a color", + options=[ + ft.DropdownOption(key=color.value, content=ft.Text(value=color.value)) + for color in colors + ], + ) ) - flet_app.page.enable_screenshots = True - flet_app.resize_page(400, 600) - - flet_app.page.add(dd) await flet_app.tester.pump_and_settle() # normal state diff --git a/sdk/python/packages/flet/src/flet/controls/material/dropdown.py b/sdk/python/packages/flet/src/flet/controls/material/dropdown.py index a336a9ee5..3219f1e24 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/dropdown.py +++ b/sdk/python/packages/flet/src/flet/controls/material/dropdown.py @@ -28,22 +28,27 @@ @control("DropdownOption") class DropdownOption(Control): """ - Represents an item in a dropdown. Either `key` or `text` must be specified, else an - A `ValueError` will be raised. + Represents an item in a dropdown. """ key: Optional[str] = None """ - Option's key. If not specified [`text`][(c).] will - be used as fallback. + Option's key. + + If not specified [`text`][(c).] will be used as fallback. + + Raises: + ValueError: If neither `key` nor [`text`][(c).] are provided. """ text: Optional[str] = None """ - Option's display text. If not specified `key` will be used as fallback. + Option's display text. + + If not specified [`key`][(c).] will be used as fallback. Raises: - ValueError: If neither [`key`][(c).] nor [`text`][(c).] are provided. + ValueError: If neither [`key`][(c).] nor `text` are provided. """ content: Optional[Control] = None @@ -79,16 +84,17 @@ def before_update(self): @control("Dropdown") class Dropdown(LayoutControl): """ - A dropdown control that allows users to select a single option from a list of - options. + A dropdown control that allows users to select a single option + from a list of [`options`][(c).]. + Example: ```python ft.Dropdown( width=220, value="alice", options=[ - ft.dropdown.Option(key="alice", text="Alice"), - ft.dropdown.Option(key="bob", text="Bob"), + ft.DropdownOption(key="alice", text="Alice"), + ft.DropdownOption(key="bob", text="Bob"), ], ) ``` @@ -96,7 +102,8 @@ class Dropdown(LayoutControl): value: Optional[str] = None """ - [`key`][(c).] value of the selected option. + The [`key`][flet.DropdownOption.] of the dropdown [`options`][(c).] + corresponding to the selected option. """ options: list[DropdownOption] = field(default_factory=list)