1114 字
6 分钟
flutter-sync-transition-in-multi-windows
关于如何在多窗口 Flutter 桌面应用体验更顺滑:在窗口之间实现共享元素(类似 Hero)的过渡,并同步主题设置
跨窗口流畅过渡 + 统一主题
思路:模拟跨窗口的 “Hero” 效果——(1) 捕获源窗口中组件的快照,(2) 在目标窗口展示一个覆盖层动画,(3) 动画结束前隐藏真实内容。与此同时,通过共享的 Provider 同步浅色 / 深色 / 品牌色,使所有窗口的主题保持一致。
1. 多窗口
- macOS / Windows:使用窗口管理 + 新窗口生成工具(例如
flutter_multi_window
或super_drag_and_drop
+ 自定义平台通道)。 - 为每个窗口分配唯一的
windowId
,以便能够按需通信。
void main(List<String> args) { WidgetsFlutterBinding.ensureInitialized(); final windowId = args.isNotEmpty ? args.first : 'main'; runApp(ProviderScope( overrides: [currentWindowId.overrideWithValue(windowId)], child: const AppRoot(), ));}
2. 通过 Riverpod 共享主题状态
保持单一数据源,并通过 MethodChannel
或 IsolateNameServer
将更新广播到所有窗口。
final themeModeProvider = StateProvider<ThemeMode>((_) => ThemeMode.system);
// 主题变化时广播:void broadcastTheme(ThemeMode mode) { const ch = MethodChannel('app/theme'); ch.invokeMethod('setThemeMode', {'mode': mode.name});}
// 每个窗口中的监听器:void initThemeListener(WidgetRef ref) { const ch = MethodChannel('app/theme'); ch.setMethodCallHandler((call) async { if (call.method == 'setThemeMode') { final mode = ThemeMode.values.firstWhere( (m) => m.name == call.arguments['mode'], orElse: () => ThemeMode.system, ); ref.read(themeModeProvider.notifier).state = mode; } });}
在应用中使用它:
class AppRoot extends ConsumerWidget { const AppRoot({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final mode = ref.watch(themeModeProvider); return MaterialApp( theme: ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xFF6A74FF)), darkTheme: ThemeData.dark(useMaterial3: true), themeMode: mode, home: const HomePage(), ); }}
3. “类 Hero” 的跨窗口过渡
技巧:捕获源窗口组件的图像,将图像与其全局位置发送到目标窗口,在目标窗口中进行动画,然后再显示真实内容。
捕获并启动:
class SourceTile extends ConsumerStatefulWidget { const SourceTile({super.key, required this.childId}); final String childId;
@override ConsumerState<SourceTile> createState() => _SourceTileState();}
class _SourceTileState extends ConsumerState<SourceTile> { final repaintKey = GlobalKey();
Future<void> openInNewWindow(BuildContext context) async { // 1) 捕获快照 final boundary = repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: MediaQuery.devicePixelRatioOf(context)); final bytes = (await image.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
// 2) 获取全局坐标 final box = repaintKey.currentContext!.findRenderObject() as RenderBox; final origin = box.localToGlobal(Offset.zero); final rect = Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height);
// 3) 携带数据启动目标窗口 const ch = MethodChannel('app/windows'); await ch.invokeMethod('openChildWindow', { 'windowId': 'detail_${widget.childId}', 'route': '/detail', 'payload': { 'id': widget.childId, 'heroPng': base64Encode(bytes), 'fromRect': {'x': rect.left, 'y': rect.top, 'w': rect.width, 'h': rect.height}, }, }); }
@override Widget build(BuildContext context) { return RepaintBoundary( key: repaintKey, child: ListTile( title: Text('Item ${widget.childId}'), onTap: () => openInNewWindow(context), ), ); }}
在目标窗口中执行动画:
class DetailBootstrap extends StatefulWidget { const DetailBootstrap({super.key, required this.payload}); final Map<String, dynamic> payload; // 通过参数传入
@override State<DetailBootstrap> createState() => _DetailBootstrapState();}
class _DetailBootstrapState extends State<DetailBootstrap> with SingleTickerProviderStateMixin { late final AnimationController c; late final Animation<double> t;
@override void initState() { super.initState(); c = AnimationController(vsync: this, duration: const Duration(milliseconds: 260)); t = CurvedAnimation(parent: c, curve: Curves.easeOutCubic); WidgetsBinding.instance.addPostFrameCallback((_) => c.forward()); }
@override void dispose() { c.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { final heroPng = base64Decode(widget.payload['heroPng'] as String); final from = widget.payload['fromRect'] as Map<String, dynamic>; final fromRect = Rect.fromLTWH( (from['x'] as num).toDouble(), (from['y'] as num).toDouble(), (from['w'] as num).toDouble(), (from['h'] as num).toDouble(), );
// 布局完成后计算目标矩形 return LayoutBuilder(builder: (context, constraints) { final targetRect = _targetRect(constraints); // 卡片的最终位置 return Stack(children: [ // 1) 动画结束前隐藏真实内容 Opacity( opacity: t.value.clamp(0.0, 0.0001), child: DetailPage(id: widget.payload['id'] as String), ), // 2) 覆盖层动画 AnimatedBuilder( animation: t, builder: (_, __) { final rect = Rect.lerp(fromRect, targetRect, t.value)!; return Positioned( left: rect.left, top: rect.top, width: rect.width, height: rect.height, child: ClipRRect( borderRadius: BorderRadius.circular(12 * (1 - t.value)), child: Image.memory(heroPng, fit: BoxFit.cover), ), ); }, ), // 3) 动画完成后再展示真实内容 if (t.isCompleted) const SizedBox.shrink(), ]); }); }
Rect _targetRect(BoxConstraints c) { const pad = 24.0; final w = (c.maxWidth - pad * 2); final h = (c.maxHeight - pad * 2) * 0.35; return Rect.fromLTWH(pad, pad, w, h); }}
Notes
- 之所以可行,是因为两个窗口能够通过通道协同工作。Flutter 内置的 Hero 仅限同一导航栈内的页面,这里是利用覆盖层与定时来“伪造”。
- 若要从详情窗口返回列表窗口,执行相反的流程:在详情窗口中截取头部,发送给列表窗口,并在列表项的矩形中播放动画。
4. 窗口主题同步体验
- 通过同一个通道,把新的
ThemeMode
和可选的ColorSchemeSeed
广播给所有窗口:
final colorSeedProvider = StateProvider<Color>((_) => const Color(0xFF6A74FF));
void broadcastColor(Color c) { const ch = MethodChannel('app/theme'); ch.invokeMethod('setColorSeed', {'argb': c.value});}
// 监听器:ch.setMethodCallHandler((call) async { if (call.method == 'setColorSeed') { ref.read(colorSeedProvider.notifier).state = Color(call.arguments['argb'] as int); }});
应用它:
final seed = ref.watch(colorSeedProvider);return MaterialApp( theme: ThemeData(useMaterial3: true, colorSchemeSeed: seed), // ...);
5. 边缘打磨
- 高分屏:使用
devicePixelRatio
做缩放。 - 焦点 / 激活:覆盖层准备好动画之前,避免新窗口提前获取焦点。
- 延迟:压缩 PNG 或发送较低质量的 JPEG,加快跨窗口传递。
- 无障碍:尊重 “减少动效” 设置;此时跳过覆盖层,直接展示目标内容。
flutter-sync-transition-in-multi-windows
https://blog.lpkt.cn/posts/flutter-sync-transition-in-multi-windows/