unfold 0.2.0
unfold: ^0.2.0 copied to clipboard
iPhone Duo and foldable layouts for Flutter, from UIKit's own APIs: reserved regions, vertical bars, size classes and UIHinge. Android foldables work too.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:unfold/unfold.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'unfold',
theme: ThemeData(colorSchemeSeed: Colors.teal),
builder: (context, child) =>
DeviceGeometryScope(bridge: DisplayFeatureBridge.full, child: child!),
home: const Home(),
);
}
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
int _tab = 0;
bool _verticalBar = true;
Future<void> _toggleVerticalBar() async {
try {
await Unfold.setVerticalBarEnabled(!_verticalBar);
setState(() => _verticalBar = !_verticalBar);
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.message ?? e.code)));
}
}
}
void _showDialog() => showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('One side of the fold'),
content: const Text(
'With the bridge on, dialogs stay on one side while the device is '
'partially open.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
@override
Widget build(BuildContext context) => AdaptiveNavigationScaffold(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
title: const Text('unfold'),
actions: [
AdaptiveAction(
icon: Icons.chat_bubble_outline,
label: 'Show a dialog',
priority: 1,
onPressed: _showDialog,
),
AdaptiveAction(
icon: _verticalBar ? Icons.view_sidebar : Icons.view_sidebar_outlined,
label: _verticalBar ? 'Turn the vertical bar off' : 'Turn it on',
onPressed: _toggleVerticalBar,
),
],
destinations: const [
AdaptiveDestination(
icon: Icons.straighten_outlined,
selectedIcon: Icons.straighten,
label: 'Geometry',
),
AdaptiveDestination(
icon: Icons.vertical_split_outlined,
selectedIcon: Icons.vertical_split,
label: 'Split',
badge: 2,
),
AdaptiveDestination(
icon: Icons.laptop_outlined,
selectedIcon: Icons.laptop,
label: 'Tabletop',
),
],
body: switch (_tab) {
0 => const GeometryPage(),
1 => const SplitPage(),
_ => const TabletopPage(),
},
);
}
class GeometryPage extends StatelessWidget {
const GeometryPage({super.key});
@override
Widget build(BuildContext context) {
final g = context.deviceGeometry;
final size = MediaQuery.sizeOf(context);
final strip = g.sideStrip(size);
final fold = g.fold;
Widget line(String label, Object? value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('$label: ${value ?? 'none'}'),
);
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
'Window ${size.width.round()} × ${size.height.round()}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
line('UIKit regions', g.supported),
line(
'Size classes',
'${g.horizontalSizeClass.name} × ${g.verticalSizeClass.name}',
),
line('Hinge', g.hinge?.name),
HingeAngleBuilder(
builder: (context, angle, _) =>
line('Angle', angle == null ? null : '${angle.round()}°'),
),
line('Fold', fold),
if (fold != null)
line(
'Posture',
fold.isTabletop ? 'tabletop' : (fold.isBook ? 'book' : 'flat'),
),
line('Division line', g.divisionLine),
line('Vertical bar edge', g.barEdge?.name),
line(
'Side strip',
strip == null
? null
: '${strip.side.name}, ${strip.width} wide from y=${strip.top}, padding ${strip.padding}',
),
line('Safe area', MediaQuery.paddingOf(context)),
const Divider(height: 32),
for (final r in g.occlusions)
line('Occlusion${r.active ? '' : ' (inactive)'}', r.frame),
for (final e in g.sideBars.entries)
line('Bar region ${e.key.name}', e.value),
for (final r in g.divisions)
line('Division${r.active ? '' : ' (inactive)'}', r.frame),
for (final f in g.displayFeatures)
line('Display feature ${f.type.name} ${f.state.name}', f.bounds),
],
),
);
}
}
class SplitPage extends StatefulWidget {
const SplitPage({super.key});
@override
State<SplitPage> createState() => _SplitPageState();
}
class _SplitPageState extends State<SplitPage> {
int _selected = 0;
@override
Widget build(BuildContext context) {
final columns = context.deviceGeometry.evenColumns(3);
return SafeArea(
child: FoldSplit(
primary: ListView.builder(
itemCount: 20,
itemBuilder: (_, i) => ListTile(
selected: i == _selected,
title: Text('Item ${i + 1}'),
onTap: () => setState(() => _selected = i),
),
),
secondary: ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: GridView.count(
crossAxisCount: columns,
padding: const EdgeInsets.all(16),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: [
for (var i = 0; i < 12; i++)
Card(child: Center(child: Text('${_selected + 1}.${i + 1}'))),
],
),
),
),
);
}
}
class TabletopPage extends StatelessWidget {
const TabletopPage({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: TabletopSplit(
top: ColoredBox(
color: scheme.primaryContainer,
child: const Center(
child: Text('Content: a video, a terminal, a game'),
),
),
bottom: Container(
height: 140,
color: scheme.surfaceContainerHighest,
alignment: Alignment.center,
child: const Text('Controls: below the fold in tabletop posture'),
),
),
);
}
}