foldable 0.2.0 copy "foldable: ^0.2.0" to clipboard
foldable: ^0.2.0 copied to clipboard

PlatformiOS

iPhone Duo hinge angle, fold posture and fold/camera regions for Flutter. Apple foldable iPhone support that stays safe on every non-foldable device.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:ui' show DisplayFeature;

import 'package:flutter/material.dart';
import 'package:foldable/foldable.dart';

void main() => runApp(const FoldableExampleApp());

/// Demonstrates the package, and doubles as a fold simulator so the layout
/// behaviour can be exercised on an ordinary iPhone simulator, where no
/// foldable device profile exists.
class FoldableExampleApp extends StatefulWidget {
  const FoldableExampleApp({super.key});

  @override
  State<FoldableExampleApp> createState() => _FoldableExampleAppState();
}

class _FoldableExampleAppState extends State<FoldableExampleApp> {
  final StreamController<FoldableData> _simulator =
      StreamController<FoldableData>.broadcast();

  bool _simulate = true;
  DisplayFeatureBridgeMode _bridgeMode = DisplayFeatureBridgeMode.none;
  HingeStatus _status = HingeStatus.fullyOpen;
  double _angle = 180;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) => _pushSimulated());
  }

  @override
  void dispose() {
    _simulator.close();
    super.dispose();
  }

  void _pushSimulated() {
    if (!_simulate || _simulator.isClosed) return;
    final Size size = MediaQuery.sizeOf(context);
    const double thickness = 40;

    _simulator.add(
      FoldableData(
        capabilities: const FoldableCapabilities(
          supportLevel: FoldableSupportLevel.available,
          isFoldable: true,
          hingeApiPresent: true,
          regionApiPresent: true,
          angleUnitVerified: false,
          strategy: 'simulated',
        ),
        status: _status,
        angleDegrees: _angle,
        regions: <ReservedRegion>[
          ReservedRegion(
            kind: ReservedRegionKind.division,
            bounds: Rect.fromLTWH(
              (size.width - thickness) / 2,
              0,
              thickness,
              size.height,
            ),
            // A fold is inactive, and zero-width, while the device is flat.
            isActive: _status == HingeStatus.partiallyOpen,
          ),
        ],
        displayFeatures: const <DisplayFeature>[],
        // Mirrors how iOS classifies width: the cover display is compact, the
        // inner display regular.
        horizontalSizeClass: size.width >= 600
            ? SizeClass.regular
            : SizeClass.compact,
        verticalSizeClass: SizeClass.regular,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'foldable',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: FoldableProvider(
        bridgeMode: _bridgeMode,
        debugData: _simulate ? _simulator.stream : null,
        child: _HomePage(
          simulate: _simulate,
          bridgeMode: _bridgeMode,
          status: _status,
          angle: _angle,
          onSimulateChanged: (bool value) {
            setState(() => _simulate = value);
            _pushSimulated();
          },
          onBridgeModeChanged: (DisplayFeatureBridgeMode mode) =>
              setState(() => _bridgeMode = mode),
          onStatusChanged: (HingeStatus status) {
            setState(() {
              _status = status;
              _angle = switch (status) {
                HingeStatus.closed => 0,
                HingeStatus.partiallyOpen => 95,
                HingeStatus.fullyOpen => 180,
                HingeStatus.unknown => _angle,
              };
            });
            _pushSimulated();
          },
          onAngleChanged: (double angle) {
            setState(() {
              _angle = angle;
              _status = angle <= 5
                  ? HingeStatus.closed
                  : angle >= 175
                  ? HingeStatus.fullyOpen
                  : HingeStatus.partiallyOpen;
            });
            _pushSimulated();
          },
        ),
      ),
    );
  }
}

class _HomePage extends StatelessWidget {
  const _HomePage({
    required this.simulate,
    required this.bridgeMode,
    required this.status,
    required this.angle,
    required this.onSimulateChanged,
    required this.onBridgeModeChanged,
    required this.onStatusChanged,
    required this.onAngleChanged,
  });

  final bool simulate;
  final DisplayFeatureBridgeMode bridgeMode;
  final HingeStatus status;
  final double angle;
  final ValueChanged<bool> onSimulateChanged;
  final ValueChanged<DisplayFeatureBridgeMode> onBridgeModeChanged;
  final ValueChanged<HingeStatus> onStatusChanged;
  final ValueChanged<double> onAngleChanged;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('foldable'),
        actions: <Widget>[
          IconButton(
            tooltip: 'Native API dump',
            icon: const Icon(Icons.bug_report_outlined),
            onPressed: () => Navigator.of(context).push(
              MaterialPageRoute<void>(builder: (_) => const _DumpPage()),
            ),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          const _DeviceCard(),
          const SizedBox(height: 16),
          const _AngleCard(),
          const SizedBox(height: 16),
          const _FoldAwareCard(),
          const SizedBox(height: 16),
          _BridgeCard(
            bridgeMode: bridgeMode,
            onChanged: onBridgeModeChanged,
          ),
          const SizedBox(height: 16),
          _SimulatorCard(
            simulate: simulate,
            status: status,
            angle: angle,
            onSimulateChanged: onSimulateChanged,
            onStatusChanged: onStatusChanged,
            onAngleChanged: onAngleChanged,
          ),
        ],
      ),
    );
  }
}

/// What the platform reports about this device.
class _DeviceCard extends StatelessWidget {
  const _DeviceCard();

  @override
  Widget build(BuildContext context) {
    final FoldableData data = DuoMediaQuery.of(context);
    final FoldableCapabilities caps = data.capabilities;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text('Device', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            _Row('isFoldable', '${data.isFoldable}'),
            _Row('support', caps.supportLevel.name),
            _Row('status', data.status.name),
            _Row('strategy', caps.strategy),
            _Row('hinge API', '${caps.hingeApiPresent}'),
            _Row('region API', '${caps.regionApiPresent}'),
            _Row('angle verified', '${caps.angleUnitVerified}'),
            _Row('regions', '${data.regions.length}'),
            _Row('size class H', data.horizontalSizeClass.name),
            _Row('size class V', data.verticalSizeClass.name),
          ],
        ),
      ),
    );
  }
}

/// The live hinge angle, driven straight from the stream.
///
/// Deliberately a `StreamBuilder` rather than a `DuoMediaQuery` dependency:
/// the raw angle updates continuously, and only this widget should rebuild
/// with it.
class _AngleCard extends StatelessWidget {
  const _AngleCard();

  @override
  Widget build(BuildContext context) {
    final double? angle = DuoMediaQuery.angleOf(context);

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'Hinge angle',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 12),
            Row(
              children: <Widget>[
                Text(
                  angle == null ? '--' : '${angle.toStringAsFixed(1)}°',
                  style: Theme.of(context).textTheme.displaySmall,
                ),
                const SizedBox(width: 24),
                Expanded(
                  child: Transform.rotate(
                    angle: ((angle ?? 180) - 180) * 3.1415926535 / 180 / 2,
                    alignment: Alignment.centerLeft,
                    child: Container(
                      height: 6,
                      decoration: BoxDecoration(
                        color: Theme.of(context).colorScheme.primary,
                        borderRadius: BorderRadius.circular(3),
                      ),
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              '0° closed, 180° flat',
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }
}

/// Layout that adapts to the fold.
class _FoldAwareCard extends StatelessWidget {
  const _FoldAwareCard();

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'FoldAwareBuilder',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 12),
            SizedBox(
              height: 120,
              child: FoldAwareBuilder(
                builder:
                    (
                      BuildContext context,
                      BoxConstraints constraints,
                      FoldInfo fold,
                    ) {
                      if (!fold.spansDivision) {
                        return _Pane(
                          label: fold.isRegularWidth
                              ? 'Single pane, regular width'
                              : 'Single pane, compact width',
                          detail: 'no fold crosses this area',
                        );
                      }
                      return Row(
                        children: const <Widget>[
                          Expanded(
                            child: _Pane(label: 'Leading', detail: 'left of fold'),
                          ),
                          SizedBox(width: 24),
                          Expanded(
                            child: _Pane(
                              label: 'Trailing',
                              detail: 'right of fold',
                            ),
                          ),
                        ],
                      );
                    },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _Pane extends StatelessWidget {
  const _Pane({required this.label, required this.detail});

  final String label;
  final String detail;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.secondaryContainer,
        borderRadius: BorderRadius.circular(12),
      ),
      alignment: Alignment.center,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Text(label, style: Theme.of(context).textTheme.titleSmall),
          Text(detail, style: Theme.of(context).textTheme.bodySmall),
        ],
      ),
    );
  }
}

/// Shows what bridging to `MediaQuery.displayFeatures` actually does.
class _BridgeCard extends StatelessWidget {
  const _BridgeCard({required this.bridgeMode, required this.onChanged});

  final DisplayFeatureBridgeMode bridgeMode;
  final ValueChanged<DisplayFeatureBridgeMode> onChanged;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'MediaQuery bridge',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 4),
            Text(
              'With "full" and a partially open device, Flutter confines every '
              'dialog, sheet and picker to one side of the fold. Open the '
              'dialog in each mode to see it.',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 12),
            SegmentedButton<DisplayFeatureBridgeMode>(
              segments: const <ButtonSegment<DisplayFeatureBridgeMode>>[
                ButtonSegment<DisplayFeatureBridgeMode>(
                  value: DisplayFeatureBridgeMode.none,
                  label: Text('none'),
                ),
                ButtonSegment<DisplayFeatureBridgeMode>(
                  value: DisplayFeatureBridgeMode.cutoutsOnly,
                  label: Text('cutouts'),
                ),
                ButtonSegment<DisplayFeatureBridgeMode>(
                  value: DisplayFeatureBridgeMode.full,
                  label: Text('full'),
                ),
              ],
              selected: <DisplayFeatureBridgeMode>{bridgeMode},
              onSelectionChanged: (Set<DisplayFeatureBridgeMode> s) =>
                  onChanged(s.first),
            ),
            const SizedBox(height: 12),
            FilledButton.tonal(
              onPressed: () => showDialog<void>(
                context: context,
                builder: (BuildContext context) => AlertDialog(
                  title: const Text('Dialog'),
                  content: Text(
                    'Width: '
                    '${MediaQuery.sizeOf(context).width.toStringAsFixed(0)}pt '
                    'available',
                  ),
                  actions: <Widget>[
                    TextButton(
                      onPressed: () => Navigator.pop(context),
                      child: const Text('Close'),
                    ),
                  ],
                ),
              ),
              child: const Text('Open a dialog'),
            ),
          ],
        ),
      ),
    );
  }
}

/// Drives the provider from synthetic data.
class _SimulatorCard extends StatelessWidget {
  const _SimulatorCard({
    required this.simulate,
    required this.status,
    required this.angle,
    required this.onSimulateChanged,
    required this.onStatusChanged,
    required this.onAngleChanged,
  });

  final bool simulate;
  final HingeStatus status;
  final double angle;
  final ValueChanged<bool> onSimulateChanged;
  final ValueChanged<HingeStatus> onStatusChanged;
  final ValueChanged<double> onAngleChanged;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                Text(
                  'Simulator',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                Switch(value: simulate, onChanged: onSimulateChanged),
              ],
            ),
            Text(
              'No foldable simulator profile exists yet, so this feeds '
              'synthetic readings to exercise the layout.',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 12),
            SegmentedButton<HingeStatus>(
              segments: const <ButtonSegment<HingeStatus>>[
                ButtonSegment<HingeStatus>(
                  value: HingeStatus.closed,
                  label: Text('closed'),
                ),
                ButtonSegment<HingeStatus>(
                  value: HingeStatus.partiallyOpen,
                  label: Text('partial'),
                ),
                ButtonSegment<HingeStatus>(
                  value: HingeStatus.fullyOpen,
                  label: Text('full'),
                ),
              ],
              selected: <HingeStatus>{
                status == HingeStatus.unknown ? HingeStatus.fullyOpen : status,
              },
              onSelectionChanged: simulate
                  ? (Set<HingeStatus> s) => onStatusChanged(s.first)
                  : null,
            ),
            Slider(
              value: angle.clamp(0, 180),
              max: 180,
              divisions: 180,
              label: '${angle.toStringAsFixed(0)}°',
              onChanged: simulate ? onAngleChanged : null,
            ),
          ],
        ),
      ),
    );
  }
}

/// Dumps the Objective-C runtime shape of the hinge APIs.
///
/// Apple's reference documentation for `UIHingeInteraction` is not published,
/// so this is how the real selectors get confirmed on real hardware.
class _DumpPage extends StatelessWidget {
  const _DumpPage();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Native API dump')),
      body: FutureBuilder<Map<String, Object?>>(
        future: Foldable.debugDumpNativeApi(),
        builder:
            (
              BuildContext context,
              AsyncSnapshot<Map<String, Object?>> snapshot,
            ) {
              if (!snapshot.hasData) {
                return const Center(child: CircularProgressIndicator());
              }
              return SingleChildScrollView(
                padding: const EdgeInsets.all(16),
                child: SelectableText(
                  const JsonEncoder.withIndent('  ').convert(snapshot.data),
                  style: const TextStyle(fontFamily: 'Menlo', fontSize: 11),
                ),
              );
            },
      ),
    );
  }
}

class _Row extends StatelessWidget {
  const _Row(this.label, this.value);

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 2),
      child: Row(
        children: <Widget>[
          SizedBox(width: 130, child: Text(label)),
          Expanded(
            child: Text(
              value,
              style: const TextStyle(fontFamily: 'Menlo', fontSize: 12),
            ),
          ),
        ],
      ),
    );
  }
}
1
likes
160
points
--
downloads

Documentation

API reference

Publisher

verified publishermedialyra.com

Weekly Downloads

iPhone Duo hinge angle, fold posture and fold/camera regions for Flutter. Apple foldable iPhone support that stays safe on every non-foldable device.

Repository (GitHub)
View/report issues

Topics

#foldable #iphone-duo #ios #responsive #layout

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on foldable

Packages that implement foldable