Skip to main content

unfold 0.2.0 copy "unfold: ^0.2.0" to clipboard
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.

example/lib/main.dart

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'),
        ),
      ),
    );
  }
}
0
likes
160
points
204
downloads
screenshot

Documentation

API reference

Publisher

verified publisherdvillegas.com

Weekly Downloads

iPhone Duo and foldable layouts for Flutter, from UIKit's own APIs: reserved regions, vertical bars, size classes and UIHinge. Android foldables work too.

Repository (GitHub)
View/report issues

Topics

#foldable #iphone-duo #adaptive #layout #responsive

License

MIT (license)

Dependencies

flutter

More

Packages that depend on unfold

Packages that implement unfold