flutter_pip_view 0.0.2
flutter_pip_view: ^0.0.2 copied to clipboard
A reusable, production-ready Picture-in-Picture (PiP) floating view engine for Flutter. Turn any widget into a draggable, resizable, edge-snapping floating window with a few lines of code.
import 'package:flutter/material.dart';
import 'package:flutter_pip_view/flutter_pip_view.dart';
void main() => runApp(const PiPExampleApp());
/// Demonstrates `flutter_pip_view`. The important bit is `PiPView` wrapping
/// the whole app once, in `MaterialApp.builder`, so any screen can call
/// `PiPController.show(...)` and have the floating window appear on top of
/// whatever is currently displayed — including across navigation.
class PiPExampleApp extends StatefulWidget {
const PiPExampleApp({super.key});
@override
State<PiPExampleApp> createState() => _PiPExampleAppState();
}
class _PiPExampleAppState extends State<PiPExampleApp> {
ThemeMode _themeMode = ThemeMode.light;
void _toggleTheme() {
setState(() {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'flutter_pip_view example',
debugShowCheckedModeBanner: false,
themeMode: _themeMode,
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.light,
useMaterial3: true,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
useMaterial3: true,
),
builder: (context, child) => PiPView(child: child!),
home: HomeScreen(themeMode: _themeMode, onToggleTheme: _toggleTheme),
);
}
}
/// Generic placeholder content shown inside the PiP window. Deliberately
/// not video-player-specific — any widget works here (a chat thread, a map,
/// a mini dashboard, ...).
class DemoContent extends StatelessWidget {
const DemoContent({super.key, required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
color: color,
alignment: Alignment.center,
// `FittedBox` scales the content down as needed — this same widget
// is shown both at the configured expanded size and, unchanged, in
// the small minimized bubble, so it must degrade gracefully rather
// than overflow when squeezed.
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.videocam_rounded, color: Colors.white, size: 32),
const SizedBox(height: 8),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
decoration: TextDecoration.none,
),
),
],
),
),
),
);
}
}
/// The distinct widget shown while fullscreen — handed to the engine via
/// `PiPController.show(fullscreenChild: ...)` rather than just letting the
/// small [DemoContent] preview stretch. This is where a real app would put
/// its actual call screen, complete with its own interactive controls;
/// `end call` below is just `PiPController.hide()`, the same generic call
/// any consumer of this package uses to dismiss the window.
class FullCallScreen extends StatelessWidget {
const FullCallScreen({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFF12132E),
child: SafeArea(
child: Column(
children: [
// `Expanded` + `FittedBox` (not a fixed-size `Spacer` sandwich):
// this guarantees no overflow no matter how little vertical
// space is actually available, the same defensive pattern
// `DemoContent` uses for the minimized bubble.
Expanded(
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.account_circle_rounded,
color: Colors.white54,
size: 72,
),
const SizedBox(height: 12),
const Text(
'You\'re in full screen',
style: TextStyle(color: Colors.white, fontSize: 18),
),
const Text(
'A real call screen, not a stretched preview',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54),
),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CallButton(icon: Icons.mic_rounded, onPressed: () {}),
_CallButton(
icon: Icons.call_end_rounded,
background: Colors.redAccent,
size: 56,
// App code, not a package API: ending the call is just
// hiding the window.
onPressed: PiPController.hide,
),
_CallButton(icon: Icons.videocam_rounded, onPressed: () {}),
],
),
),
],
),
),
);
}
}
class _CallButton extends StatelessWidget {
const _CallButton({
required this.icon,
required this.onPressed,
this.background = const Color(0x33FFFFFF),
this.size = 44,
});
final IconData icon;
final VoidCallback onPressed;
final Color background;
final double size;
@override
Widget build(BuildContext context) {
return Material(
color: background,
shape: const CircleBorder(),
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: SizedBox(
width: size,
height: size,
child: Icon(icon, color: Colors.white, size: size * 0.5),
),
),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.themeMode,
required this.onToggleTheme,
});
final ThemeMode themeMode;
final VoidCallback onToggleTheme;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
bool _snapToEdge = true;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: scheme.surfaceContainerLowest,
appBar: AppBar(
title: const Text(
'flutter_pip_view',
style: TextStyle(fontWeight: FontWeight.w700),
),
actions: [
IconButton(
tooltip: 'Toggle light/dark theme',
icon: Icon(
widget.themeMode == ThemeMode.light
? Icons.dark_mode_outlined
: Icons.light_mode_outlined,
),
onPressed: widget.onToggleTheme,
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
_FeatureCard(
icon: Icons.picture_in_picture_alt_rounded,
title: 'Floating preview',
description:
'A small window that floats on top of your app. Tap it to '
'go full screen, just like a video call bubble.',
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.smart_display_rounded),
label: const Text('Show PiP'),
onPressed: () => PiPController.show(
context,
child: const DemoContent(
label: 'Tap me to go\nfullscreen',
color: Colors.indigo,
),
fullscreenChild: const FullCallScreen(),
),
),
),
),
const SizedBox(height: 16),
_FeatureCard(
icon: Icons.open_with_rounded,
title: 'Drag & snap',
description:
'Move the window anywhere you like. Let go near an edge '
'and it glides into place on its own.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SettingRow(
label: 'Snap to nearest edge on release',
value: _snapToEdge,
onChanged: (v) => setState(() => _snapToEdge = v),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
icon: const Icon(Icons.open_with_rounded),
label: const Text('Show draggable PiP'),
onPressed: () => PiPController.show(
context,
child: const DemoContent(
label: 'Drag me',
color: Colors.teal,
),
config: PiPConfig(snapToEdge: _snapToEdge),
),
),
),
],
),
),
],
),
);
}
}
/// A single demo entry: icon badge + title + description + whatever
/// interactive controls the demo needs, on a soft-shadowed, rounded card —
/// the same "feature card" shape used across the flutter_pip_view example
/// and sibling DashStack package examples.
class _FeatureCard extends StatelessWidget {
const _FeatureCard({
required this.icon,
required this.title,
required this.description,
required this.child,
});
final IconData icon;
final String title;
final String description;
final Widget child;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: scheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
boxShadow: [
BoxShadow(
color: scheme.shadow.withValues(alpha: 0.06),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: scheme.onPrimaryContainer),
),
const SizedBox(width: 14),
Expanded(
child: Text(
title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 12),
Text(
description,
style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 16),
child,
],
),
);
}
}
/// A settings-style toggle row (label + switch) on a subtle tinted
/// background, instead of a bare `SwitchListTile` — reads as part of the
/// card rather than a plain list item.
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onChanged(!value),
child: Padding(
padding: const EdgeInsets.only(left: 14, right: 6),
child: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Switch(value: value, onChanged: onChanged),
],
),
),
),
);
}
}