Flutter桌面端添加ChangeNotifierProvider后DropdownMenuEntry鼠标点击失效
在Windows平台的Flutter桌面应用中,给main.dart添加ChangeNotifierProvider用于检测本地化变更后,未使用该Provider的视图里的DropdownMenuEntry无法响应鼠标点击,回滚到无该Provider的版本后问题消失。
应用内其他按钮点击正常,鼠标悬停DropdownMenuEntry时会显示手型指针(聚焦状态正常)。DropdownMenu支持键盘操作:通过键盘选中条目回车可正常触发,但禁用键盘输入后鼠标点击问题仍未解决。
简化后的示例代码
main.dart
import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:hub/app/app.bottomsheets.dart'; import 'package:hub/app/app.dialogs.dart'; import 'package:hub/app/app.locator.dart'; import 'package:hub/app/app.router.dart'; import 'package:hub/services/localization_service.dart'; import 'package:hub/ui/common/app_name.dart'; import 'package:hub/ui/common/app_notifier.dart'; import 'package:hub/ui/common/app_window.dart'; import 'package:hub/ui/common/app_themes.dart'; import 'package:responsive_builder/responsive_builder.dart'; import 'package:stacked_themes/stacked_themes.dart'; import 'package:url_strategy/url_strategy.dart'; Future<void> main(List<String> args) async { print(args.toString()); final appName = getAppName(); WidgetsFlutterBinding.ensureInitialized(); await ThemeManager.initialise(); setPathUrlStrategy(); await setupLocator(stackedRouter: stackedRouter); setupDialogUi(); setupBottomSheetUi(); await setupNotifications(appName); final lightTheme = await buildLightTheme(); final darkTheme = await buildDarkTheme(); runApp(MainApp( appName: appName, lightTheme: lightTheme, darkTheme: darkTheme, )); setupAppWindow(appName); } class MainApp extends StatelessWidget { final String appName; final ThemeData lightTheme; final ThemeData darkTheme; const MainApp({ required this.appName, required this.lightTheme, required this.darkTheme, super.key, }); @override Widget build(BuildContext context) { return ThemeBuilder( defaultThemeMode: ThemeMode.dark, lightTheme: lightTheme, darkTheme: darkTheme, // statusBarColorBuilder: (theme) => theme?.colorScheme.secondary, builder: (context, regularTheme, darkTheme, themeMode) => ResponsiveApp( preferDesktop: true, builder: (context) => ChangeNotifierProvider( create: (context) => LocalizationService(), child: Consumer<LocalizationService>( builder: (context, localization, child) { localization.getSavedLocale(appName); return MaterialApp.router( theme: regularTheme, darkTheme: darkTheme, themeMode: themeMode, debugShowCheckedModeBanner: false, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, routerDelegate: stackedRouter.delegate(), routeInformationParser: stackedRouter.defaultRouteParser(), locale: localization.locale, ); }, ), ), ), ); } }
含DropdownMenu的视图代码
import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:stacked/stacked.dart'; import 'package:stacked_services/stacked_services.dart'; import 'export_import_collection_dialog_model.dart'; class ExportImportCollectionDialog extends StackedView<ExportImportCollectionDialogModel> { final DialogRequest request; final Function(DialogResponse) completer; const ExportImportCollectionDialog({ required this.request, required this.completer, super.key, }); @override Widget builder(BuildContext context, ExportImportCollectionDialogModel viewModel, Widget? child) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); final myVector = ["one", "two", "three"]; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), insetPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), child: Container( width: 500, padding: const EdgeInsets.symmetric(vertical: 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ ListTile( leading: Text( l10n.exportAndImport, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w500, ), ), trailing: IconButton( onPressed: () => completer(DialogResponse(confirmed: true)), icon: const Icon(Icons.close), ), ), const SizedBox(height: 16), Column( children: [ DropdownMenu( label: Text(l10n.selectCollection), dropdownMenuEntries: myVector .map( (value) => DropdownMenuEntry( value: value, label: value, ), ) .toList(), menuHeight: 200.0, width: 400.0, enableFilter: true, enableSearch: true, onSelected: (value) => { print(value), }, ), ], ), ], ), ), ); } @override ExportImportCollectionDialogModel viewModelBuilder(BuildContext context) => ExportImportCollectionDialogModel(); }
1. 移除Consumer builder中的副作用操作
你现在在Consumer的builder方法里直接调用localization.getSavedLocale(appName),这会导致每次LocalizationService更新时都执行该方法,引发不必要的Widget重建,干扰DropdownMenu的状态稳定性。
把初始化操作移到Provider创建时完成:
// 修改MainApp中的ChangeNotifierProvider部分 ChangeNotifierProvider( create: (context) { final localization = LocalizationService(); localization.getSavedLocale(appName); // 在这里初始化,而非builder内 return localization; }, child: Consumer<LocalizationService>( builder: (context, localization, child) { return MaterialApp.router( // ... 原有配置 locale: localization.locale, ); }, ), )
2. 避免重建整个MaterialApp
当前MaterialApp被包裹在Consumer内部,每次本地化变更都会重建整个MaterialApp,这会导致子Widget上下文异常,尤其是桌面平台的弹出层组件。
可以将Consumer移到MaterialApp的builder参数中,只重建需要本地化的部分:
// 修改MainApp的build方法 @override Widget build(BuildContext context) { return ThemeBuilder( defaultThemeMode: ThemeMode.dark, lightTheme: lightTheme, darkTheme: darkTheme, builder: (context, regularTheme, darkTheme, themeMode) => ResponsiveApp( preferDesktop: true, builder: (context) => ChangeNotifierProvider( create: (context) { final localization = LocalizationService(); localization.getSavedLocale(appName); return localization; }, child: MaterialApp.router( theme: regularTheme, darkTheme: darkTheme, themeMode: themeMode, debugShowCheckedModeBanner: false, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, routerDelegate: stackedRouter.delegate(), routeInformationParser: stackedRouter.defaultRouteParser(), builder: (context, child) { return Consumer<LocalizationService>( builder: (context, localization, child) { return Localizations.override( context: context, locale: localization.locale, child: child!, ); }, ); }, ), ), ), ); }
3. 优化LocalizationService的通知逻辑
确保LocalizationService只有在locale真正变化时才调用notifyListeners(),避免不必要的通知触发Widget重建:
// LocalizationService中的示例实现 class LocalizationService extends ChangeNotifier { Locale? _locale; Locale? get locale => _locale; void getSavedLocale(String appName) { // 从存储读取保存的locale final savedLocale = // 你的读取逻辑 if (savedLocale != _locale) { _locale = savedLocale; notifyListeners(); // 仅当locale变化时通知 } } }
4. 手动管理DropdownMenu的焦点(Windows平台专属)
桌面平台的DropdownMenu偶尔会因为焦点层级问题导致点击无响应,可以手动添加焦点节点并管理:
// 修改含DropdownMenu的视图代码 class ExportImportCollectionDialog extends StackedView<ExportImportCollectionDialogModel> { final DialogRequest request; final Function(DialogResponse) completer; final FocusNode _dropdownFocusNode = FocusNode(); // 添加焦点节点 const ExportImportCollectionDialog({ required this.request, required this.completer, super.key, }); @override void initState() { super.initState(); // 对话框初始化后请求焦点 WidgetsBinding.instance.addPostFrameCallback((_) { _dropdownFocusNode.requestFocus(); }); } @override void dispose() { _dropdownFocusNode.dispose(); // 销毁焦点节点 super.dispose(); } @override Widget builder(BuildContext context, ExportImportCollectionDialogModel viewModel, Widget? child) { // ... 原有代码 return Dialog( // ... 原有代码 child: Container( // ... 原有代码 child: Column( // ... 原有代码 children: [ // ... 原有代码 DropdownMenu( focusNode: _dropdownFocusNode, // 绑定焦点节点 // ... 原有配置 ), ], ), ), ); } }
内容的提问来源于stack exchange,提问作者Pedro Ripper

