1114 字
6 分钟
flutter-sync-transition-in-multi-windows
2025-10-12
无标签

关于如何在多窗口 Flutter 桌面应用体验更顺滑:在窗口之间实现共享元素(类似 Hero)的过渡,并同步主题设置


跨窗口流畅过渡 + 统一主题#

思路:模拟跨窗口的 “Hero” 效果——(1) 捕获源窗口中组件的快照,(2) 在目标窗口展示一个覆盖层动画,(3) 动画结束前隐藏真实内容。与此同时,通过共享的 Provider 同步浅色 / 深色 / 品牌色,使所有窗口的主题保持一致。

1. 多窗口#

  • macOS / Windows:使用窗口管理 + 新窗口生成工具(例如 flutter_multi_windowsuper_drag_and_drop + 自定义平台通道)。
  • 为每个窗口分配唯一的 windowId,以便能够按需通信。
main.dart
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 共享主题状态#

保持单一数据源,并通过 MethodChannelIsolateNameServer 将更新广播到所有窗口。

theme_provider.dart
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;
}
});
}

在应用中使用它:

app_root.dart
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” 的跨窗口过渡#

技巧:捕获源窗口组件的图像,将图像与其全局位置发送到目标窗口,在目标窗口中进行动画,然后再显示真实内容。

捕获并启动:

source_tile.dart
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),
),
);
}
}

在目标窗口中执行动画:

detail_bootstrap.dart
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/
作者
lollipopkit
发布于
2025-10-12
许可协议
CC BY-NC-SA 4.0