如何在Flutter中实现类YouTube的跨平台Picture in Picture功能?
实现Flutter双模式画中画(应用内悬浮+系统全局PIP)
要同时实现应用内可拖拽悬浮窗和系统级全局画中画,你需要把这两个功能拆成独立模块来做——系统原生PIP本来就是为应用后台场景设计的,没法直接实现应用内悬浮,得自己用Flutter组件搭应用内悬浮层,再和系统PIP逻辑做切换整合。
一、应用内悬浮窗口实现
不用依赖第三方包,直接用Flutter原生的Stack+Draggable就能快速实现可拖拽的悬浮Widget,完全可控:
class InAppFloatingWidget extends StatefulWidget { final Widget content; final bool isActive; const InAppFloatingWidget({ super.key, required this.content, required this.isActive, }); @override State<InAppFloatingWidget> createState() => _InAppFloatingWidgetState(); } class _InAppFloatingWidgetState extends State<InAppFloatingWidget> { Offset _position = const Offset(20, 20); final _windowSize = const Size(300, 180); @override Widget build(BuildContext context) { if (!widget.isActive) return widget.content; return Stack( fit: StackFit.expand, children: [ // 点击背景关闭悬浮窗(可选) GestureDetector( onTap: () => setState(() => widget.isActive = false), child: const ColoredBox(color: Colors.black38), ), // 可拖拽悬浮窗 Positioned( left: _position.dx, top: _position.dy, child: Draggable( feedback: _buildFloatingWindow(), childWhenDragging: const SizedBox.shrink(), onDragEnd: (details) { setState(() { _position = Offset( details.offset.dx.clamp(0, MediaQuery.of(context).size.width - _windowSize.width), details.offset.dy.clamp(0, MediaQuery.of(context).size.height - _windowSize.height), ); }); }, child: _buildFloatingWindow(), ), ), ], ); } Widget _buildFloatingWindow() { return Container( width: _windowSize.width, height: _windowSize.height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: Colors.black, boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)], ), clipBehavior: Clip.antiAlias, child: widget.content, ); } }
使用时,在你的页面里用一个布尔值控制isActive,比如点击视频右上角的"悬浮"按钮,切换这个状态就能显示/隐藏应用内悬浮窗。
二、系统全局PIP整合(安卓+iOS)
系统级PIP需要原生代码配合,和应用内悬浮完全独立,当用户需要把应用最小化保留悬浮时,触发系统PIP逻辑:
安卓端配置与实现
- 修改
AndroidManifest.xml的主Activity标签,添加PIP支持:
<activity android:name=".MainActivity" android:supportsPictureInPicture="true" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation">
- 在
MainActivity.kt里添加进入PIP的方法:
class MainActivity : FlutterActivity() { private val CHANNEL = "com.your.app/pip" override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> if (call.method == "enterSystemPIP") { enterSystemPIP() result.success(null) } else { result.notImplemented() } } } private fun enterSystemPIP() { val aspectRatio = Rational(16, 9) val params = PictureInPictureParams.Builder() .setAspectRatio(aspectRatio) .build() enterPictureInPictureMode(params) // 手动让应用退到后台(可选,部分系统会自动处理) moveTaskToBack(true) } }
iOS端配置与实现
iOS的系统PIP要求必须关联AVPlayer(非视频场景可以用空的AVPlayer占位):
- 修改
Info.plist添加权限:
<key>UIBackgroundModes</key> <array> <string>audio</string> </array> <key>UIPictureInPictureEnabled</key> <true/>
- 在
AppDelegate.swift里添加PIP触发方法:
import AVKit @UIApplicationMain class AppDelegate: FlutterAppDelegate { private let CHANNEL = "com.your.app/pip" override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let controller : FlutterViewController = window?.rootViewController as! FlutterViewController let channel = FlutterMethodChannel(name: CHANNEL, binaryMessenger: controller.binaryMessenger) channel.setMethodCallHandler { [weak self] call, result in if call.method == "enterSystemPIP" { self?.enterSystemPIP() result.success(nil) } else { result.notImplemented() } } GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } private func enterSystemPIP() { guard let vc = window?.rootViewController else { return } let player = AVPlayer() let playerLayer = AVPlayerLayer(player: player) let contentSource = AVPlayerLayerPictureInPictureControllerContentSource( playerLayer: playerLayer, playbackDelegate: self ) if let pipController = AVPictureInPictureController(contentSource: contentSource) { pipController.startPictureInPicture() } // 退到后台 UIApplication.shared.perform(#selector(NSApplication.terminate(_:))) } } extension AppDelegate: AVPictureInPictureControllerDelegate {}
三、双模式切换逻辑
在Flutter层用状态管理统一控制两个模式的切换,避免冲突:
class PiPController extends ChangeNotifier { bool _isInAppFloating = false; bool get isInAppFloating => _isInAppFloating; void toggleInAppFloating() { _isInAppFloating = !_isInAppFloating; notifyListeners(); } Future<void> switchToSystemPIP() async { // 先关闭应用内悬浮 if (_isInAppFloating) { _isInAppFloating = false; notifyListeners(); } // 调用原生方法进入系统PIP const channel = MethodChannel('com.your.app/pip'); try { await channel.invokeMethod('enterSystemPIP'); } catch (e) { debugPrint("进入系统PIP失败: $e"); } } }
在你的页面里,用Consumer监听状态,渲染对应的UI:
Consumer<PiPController>( builder: (context, controller, child) { return InAppFloatingWidget( isActive: controller.isInAppFloating, content: YourVideoPlayerWidget(), // 你的目标Widget(比如视频播放器) ); }, )
关键注意事项
- 应用内悬浮和系统PIP是完全独立的两个机制,不能用同一个第三方包同时实现,必须分开处理
- 系统PIP只有在应用退到后台时才会显示全局悬浮,所以调用系统PIP后需要让应用进入后台
- iOS的系统PIP严格绑定AVPlayer,如果你的内容不是视频,要么用AVPlayer占位,要么申请
UIBackgroundModes的picture-in-picture权限(部分场景需要审核)
内容的提问来源于stack exchange,提问作者Hassun
相关产品推荐
相关产品推荐

