Skip to main content

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

Fold awareness for the foldable iPhone and Android foldables: hinge angle and pose, fold regions, fold-aware layout widgets, a debug overlay and test fakes.

bifold #

pub package CI pub points likes platform license: MIT

Fold awareness for Flutter apps on the foldable iPhone and on Android foldables — book-style devices that open out into a tablet-sized display, and flip-style devices that close down into a square. Where the fold is, how far the device is open, which display you are on, and where hardware is in the way.

Adaptive, fold-aware layout without writing a hinge sensor integration or a Jetpack WindowManager binding yourself.

One API for both. The same FoldInfo, the same layout widgets and the same test fakes work on either platform, and your code never asks which one it is running on.

On every device without a fold — ordinary phones, older iOS, iPad, web and desktop — it reports a well-defined "no fold" state and never throws, so it is safe to adopt in an app that ships everywhere.

void main() => runApp(const BifoldScope(child: MyApp()));

// anywhere below:
final info = Bifold.of(context);
if (info.division != null) {
  // The display is creased right now.
}

Contents #

Install #

dependencies:
  bifold: ^0.1.0

Then wrap your app once, near the root:

import 'package:bifold/bifold.dart';

void main() => runApp(const BifoldScope(child: MyApp()));

Everything else reads from that scope. There is no other setup, no Info.plist entry, and no native code to add.

Which widget do I want? #

I want to… Use
know the fold state anywhere in my tree Bifold.of(context)
put two panes either side of the fold BifoldSplit
lay out a grid that never bends a tile BifoldGrid
move my navigation as the device opens BifoldScaffold
keep a dialog off the crease bifoldAnchorPoint
show content to the person I'm filming BifoldCaptureAccessory
see what the platform is actually reporting BifoldDebugOverlay
make Android-foldable packages work here BifoldDisplayFeatures
match the platform's own split exactly BifoldArrangement
test my layout with no device BifoldScope.fake + FoldInfoFakes

Reading fold state #

Bifold.of(context) returns a FoldInfo and rebuilds the caller whenever it changes.

Widget build(BuildContext context) {
  final info = Bifold.of(context);

  return Column(
    children: [
      Text('Pose: ${info.pose.name}'),
      if (info.hingeAngleDegrees case final degrees?)
        Text('Open to ${degrees.toStringAsFixed(0)}°'),
      Text('On the ${info.display.name} display'),
    ],
  );
}

Outside the widget tree, use Bifold.current for a one-shot read or Bifold.stream for updates:

final subscription = Bifold.stream.listen((info) {
  analytics.log('fold', {'pose': info.pose.name});
});

Bifold.maybeOf(context) returns null instead of throwing when there is no BifoldScope above, which is useful in a widget that may be used either way.

What can this device do? #

Two different questions, two different answers. FoldInfo says what the device is doing now. BifoldCapabilities says what it can ever do.

final can = Bifold.capabilitiesOf(context);

if (can.hasHingeAngle) {
  // Worth building something that reacts to the angle.
}

Keeping them apart matters. A foldable folded shut still has a fold. A hinge sensor that is present but silent is a missing reading, not a missing sensor.

False does not mean no #

Every capability is really one of three states, and the third is the reason this type exists:

switch (can.statusOf(FoldFeature.halfOpenedPosture)) {
  case CapabilityStatus.supported:    // proven yes
  case CapabilityStatus.unsupported:  // proven no
  case CapabilityStatus.unknown:      // nobody has said
}

hasHalfOpenedPosture is false for both unsupported and unknown. At startup, before the platform has answered, everything is unknown — so a layout that branches on hasFold being false will briefly treat a real foldable as an ordinary phone. Check isResolved, or branch on statusOf.

The rules it follows #

Rule Why
Observation is evidence for, never against A closed Android foldable reports no fold at all. Not seeing one proves nothing
supported is sticky Otherwise folding the device shut would look like it losing its hinge
Only a static platform query yields unsupported Everything else is unknown, which is an answer

Where an answer came from #

can.sourceOf(FoldFeature.fold);   // 'android.pm.FEATURE_SENSOR_HINGE_ANGLE'
await Bifold.diagnosticReport();  // the whole picture, for a bug report

diagnosticReport() builds a string and returns it. Nothing is transmitted — sending it anywhere is your decision.

Outside the widget tree #

await Bifold.capabilitiesReady;      // wait for the first answer
Bifold.capabilities.hasFold;         // then read the running best

Read Bifold.capabilities rather than the future's value: the future carries a snapshot from first resolution, and capabilities keep improving after it.

What each platform can tell you #

iOS Android
hasFold runtime detection FEATURE_SENSOR_HINGE_ANGLE — answers even when shut
hasHingeAngle UIHingeInteraction Sensor.TYPE_HINGE_ANGLE (API 30+)
hasHalfOpenedPosture from the OS only once observed, so unknown until then
hasRearDisplay capture accessory WindowAreaController
hasCoverDisplay supported unknown, probably always — whether an app may run on a cover display is OEM policy with no public query
formFactor unknown book or flip, corrected for screen rotation

Layout #

Two panes across the fold — BifoldSplit #

BifoldSplit(
  start: PageView(controller: leftController),
  end: PageView(controller: rightController),
)

When the device is part-way open and an active division crosses the widget, the panes are placed either side of the crease, clear of its margins. Otherwise they fall back to fallback:

BifoldSplitFallback behaviour
stack (default) one above the other
sideBySide side by side, split evenly
startOnly show start, drop end
BifoldSplit(
  fallback: BifoldSplitFallback.sideBySide,
  spacing: 16,
  start: const MessageList(),
  end: const MessageDetail(),
)

A grid that never bends a tile — BifoldGrid #

BifoldGrid(
  tileExtent: 160,
  spacing: 8,
  padding: const EdgeInsets.all(12),
  children: photos.map(PhotoTile.new).toList(),
)

Tiles fill the space before the crease, then resume after it. No tile is drawn across the fold. With nothing to avoid it behaves as an ordinary wrapping grid, so it is safe to use unconditionally.

BifoldScaffold(
  appBar: AppBar(title: const Text('Inbox')),
  selectedIndex: index,
  onDestinationSelected: (i) => setState(() => index = i),
  destinations: const [
    BifoldDestination(icon: Icon(Icons.inbox), label: 'Inbox'),
    BifoldDestination(icon: Icon(Icons.send), label: 'Sent'),
  ],
  body: const MessageList(),
)

A bottom NavigationBar on a compact layout — the outer display, or any ordinary phone — and a NavigationRail on a regular one, which is what the inner display reports. The rail goes on whichever edge the platform prefers for its own vertical bar, so app navigation and system UI share a side rather than bracketing your content.

With fewer than two destinations the navigation is omitted rather than rendered as a single useless item.

Keep dialogs off the crease — bifoldAnchorPoint #

showDialog<void>(
  context: context,
  anchorPoint: bifoldAnchorPoint(context),
  builder: (context) => const AlertDialog(title: Text('Saved')),
);

Places the dialog in whichever half has more room. Returns null when there is nothing to avoid, which is exactly what showDialog expects by default — so pass it unconditionally.

Camera #

Content on the outer display — BifoldCaptureAccessory #

With the device open and a capture session running, the system can show your content on the outer display, facing whoever is being filmed — a framing preview, or a teleprompter.

The accessory renders in its own Flutter engine, so it needs its own entrypoint:

@pragma('vm:entry-point')
void captureAccessoryMain() => runApp(const SubjectView());

void main() => runApp(const BifoldScope(child: MyCameraApp()));

Register it once your camera UI is up, and follow what the system decides:

if (await BifoldCaptureAccessory.isSupported) {
  await BifoldCaptureAccessory.register(entrypoint: 'captureAccessoryMain');
  await BifoldCaptureAccessory.setEnabled(true);
}

BifoldCaptureAccessory.availability.listen((available) {
  // The system decides whether and where it appears.
});

// when the camera closes:
await BifoldCaptureAccessory.unregister();

setEnabled(true) says your app would like the accessory shown; availability reports whether the system says it can be. It will not appear without an active capture session, and your app must stay fully functional when it never appears.

Diagnostics #

See what the platform is reporting — BifoldDebugOverlay #

MaterialApp(
  builder: (context, child) => BifoldDebugOverlay(child: child!),
  home: const MyHomePage(),
)

Draws every reserved region: a filled band for the hardware, an outline for the hardware plus its clearance, solid when active and faint when not. That active/inactive distinction is usually the answer to "why didn't my layout react?". Ignores pointer events, so the app underneath stays usable.

Wire enabled to a debug switch rather than removing the widget, so the tree shape does not change when you toggle it.

Bridge to MediaQuery.displayFeatures — BifoldDisplayFeatures #

Flutter populates displayFeatures only on Android, so packages written for Android foldables — and Flutter's own dialog positioning — see nothing on iOS. Opt in and they work:

MaterialApp(
  builder: (context, child) => BifoldDisplayFeatures(child: child!),
  home: const MyHomePage(),
)

The widget stands down automatically if the engine ever populates the field itself, so it will not fight a future Flutter release.

Match the platform's own split — BifoldArrangement #

BifoldSplit positions panes from the reported fold. This asks the platform where it would put them, measured from a real arrangement rather than modelled:

final measured = await BifoldArrangement.measure(
  size: const Size(871, 669),
  axis: ArrangementAxis.vertical,
);
if (measured != null && measured.isSplit) {
  // Mirror measured.primary.bounds and measured.secondary.bounds.
}
await BifoldArrangement.release();

Measuring attaches an empty, non-interactive view controller for the duration. Call release() when finished.

Report a bug — Bifold.debugDescribeNativeApi #

final report = await Bifold.debugDescribeNativeApi();

Returns what the running OS actually exposes — real selector names, type encodings and class members — which is far more useful in an issue than a version number.

Testing #

This is the part most fold packages leave out. BifoldScope.fake and FoldInfoFakes exercise a fold-aware layout on any machine: no simulator, no iOS SDK, no channel mocking.

testWidgets('reader splits across the fold', (tester) async {
  await tester.pumpWidget(
    BifoldScope.fake(
      info: FoldInfoFakes.partiallyOpen(viewSize: const Size(800, 1000)),
      child: const MyReader(),
    ),
  );

  expect(find.text('Page 2'), findsOneWidget);
});

FoldInfoFakes.poseMatrix(size) returns every pose for table-driven tests, including the two that catch real bugs — a flat display that still reports an inactive division, and a crease with zero thickness:

for (final entry in FoldInfoFakes.poseMatrix(size).entries) {
  testWidgets('lays out when ${entry.key}', (tester) async {
    await tester.pumpWidget(
      BifoldScope.fake(info: entry.value, child: const MyReader()),
    );
    expect(tester.takeException(), isNull);
  });
}

Individual fakes: FoldInfoFakes.closed, .fullyOpen(), .partiallyOpen(), .partiallyOpenVertical(), .unsupported.

Faking what a device can do #

BifoldCapabilityFakes supplies capability profiles, named after device shapes rather than products because they model a class of hardware:

BifoldScope.fake(
  info: FoldInfoFakes.closed,
  capabilities: BifoldCapabilityFakes.unopenedFoldable(),
  child: const MyApp(),
);

.book(), .flip(), .flat, .unopenedFoldable() and .unresolved. That last pair are the ones that catch real bugs: a foldable seen only while shut knows it folds but not how, and an unresolved profile makes a layout that treats hasFold == false as a proven "no" visibly flicker.

Omit capabilities and the scope infers a coherent set from the fold state, so a layout test does not have to restate both.

Driving the stream #

BifoldScope.fake covers a fixed state, and pumping a new one covers a transition. For the stream itself — ordering, late arrivals, a capability resolving after the first frame — use FakeBifoldPlatform:

final platform = FakeBifoldPlatform();
BifoldPlatform.instance = platform;
addTearDown(platform.dispose);

await tester.pumpWidget(const BifoldScope(child: MyApp()));
platform.emit(FoldInfoFakes.partiallyOpen(viewSize: size));
await tester.pump();

API reference #

FoldInfo #

member type meaning
isFoldable bool the device has a fold this package can report on
pose FoldPose closed, partiallyOpen, fullyOpen, unknown
hingeAngle double? hinge angle in radians, null if unreported
hingeAngleDegrees double? the same value in degrees
display FoldDisplay outer, inner, none
regions List<FoldRegion> every reserved region, active or not
division FoldRegion? the active fold, or null when not creased
activeRegions Iterable<FoldRegion> regions whose hardware is in use
horizontalSizeClass FoldSizeClass compact, regular, unspecified
verticalSizeClass FoldSizeClass as above
isRegular bool both axes regular — what the inner display reports
verticalBarEdge VerticalBarEdge leading, trailing, unspecified
isResolved bool whether the platform has answered yet
FoldInfo.unsupported FoldInfo nothing has answered — not "no fold"
FoldInfo.none FoldInfo answered: this device has no fold

FoldRegion #

member type meaning
kind RegionKind division (the fold) or occlusion (e.g. a camera)
frame Rect the whole area to avoid — margins included
reservedRect Rect the hardware itself, frame minus margins
margins EdgeInsets how much of frame is clearance
isActive bool whether the hardware is currently in use
isSeparating(Size) bool whether it actually divides the display
isHorizontal bool whether it runs across rather than down

BifoldCapabilities #

member type meaning
hasFold … hasFoldOcclusion bool true only for supported
statusOf(FoldFeature) CapabilityStatus supported, unsupported, unknown
evidenceOf(FoldFeature) CapabilityEvidence the status plus its source
sourceOf(FoldFeature) String? the platform signal, for diagnostics
formFactor FoldFormFactor book, flip, dualScreen, none, unknown
rearDisplayModes Set<RearDisplayMode> presentation and/or transfer
isResolved bool whether the platform has answered at all
raw Map<String, Object?> the platform payload, for capabilities newer than this package
BifoldCapabilities.unresolved nothing established
BifoldCapabilities.none established: no fold support of any kind

CapabilityResolver applies the stickiness and evidence rules across reports. It is exported so the behaviour can be tested directly; apps read capabilities rather than building them.

ArrangementMeasurement #

primary and secondary are ArrangementPanes, each with bounds and isVisible; axis is an ArrangementAxis; isSplit is true when the platform shows two panes rather than collapsing to one.

Requirements #

Flutter 3.27.0+
Dart 3.6.0+
iOS deployment target 15.0
iOS fold reporting iOS 27.1+ on a foldable device
Xcode to build 26.x or newer — 27.1 is not required
Android minSdk 24
Android fold reporting a device Jetpack WindowManager reports a fold for
Android hinge angle API 30+, on a device with a hinge angle sensor

The Android side uses androidx.window 1.2.0, pinned to the version Flutter's own Android embedding already resolves, so adding bifold cannot pull your app into a version conflict with the engine. It asks for no permissions.

The fold symbols ship in the iOS 27.1 SDK, but bifold resolves them through the Objective-C runtime rather than calling them directly. One binary builds on an older Xcode and still reports fold state on a device that has the APIs. That matters in practice: a package needing 27.1 to compile is uninstallable for anyone whose CI has not upgraded.

Gotchas #

Never cache regions. They arrive after the first layout pass and change as the device folds. Read them from the current FoldInfo on every build.

frame already includes margins. The platform reports the frame with its clearance built in, so laying out against frame is correct — do not inflate it again. reservedRect is the bare hardware if you need it.

Prefer pose over hingeAngle for layout. Apple documents the angle's "rate and granularity" as system policy that can change, so the platform's own classification is the more stable signal.

Lay out against size classes, not orientation. On iOS the inner display does not honour an app's supported interface orientations. On Android there are no UIKit-style size classes at all, so bifold derives them from the current window against the Material breakpoints — 600dp wide, 480dp tall — which is why they are reported rather than unspecified.

Pose and angle arrive a moment after the stream is first listened to. The platform delivers the initial hinge update asynchronously, so a one-shot Bifold.current at launch may have neither. Until the hinge reports, pose is derived from the regions instead — never wrong, only occasionally late. Bifold.of(context) updates when it lands.

FoldPose mirrors the platform, and never guesses. Postures like "book" or "tabletop" are vocabulary neither platform reports, and deriving them from an angle is the app's decision, not this package's.

On Android, pose is unknown when the device is shut — never closed. FoldingFeature has only FLAT and HALF_OPENED, and a closed device reports no folding feature at all, which is indistinguishable from a device that does not fold. isFoldable still answers correctly there, because it comes from a static device feature rather than from an observation.

On Android, region margins are zero. The platform reports hardware bounds with no clearance around them, so frame and reservedRect are the same rectangle. On iOS the frame arrives with clearance already built in.

Verification #

What is verified where #

iOS Android
Native symbols checked against SDK/artifacts ✅ ✅
Fold state on a simulator / emulator ✅ ✅
Open, half-open and closed ✅ ✅
Hinge angle ✅ ✅ (injected)
Size classes ✅ ✅
Physical hardware ❌ ❌

Nothing in this package has run on a physical foldable, on either platform. The foldable iPhone has not shipped, and the Android side has been exercised on emulators only. Treat the behaviour as verified where the table says so and unverified everywhere else, and please file a report if a real device disagrees — Bifold.debugDescribeNativeApi() prints what to paste in.

iOS #

Every Apple symbol was verified by two independent passes that agree: Objective-C runtime introspection on a booted iPhone Duo simulator running iOS 27.1, and the iOS 27.1 SDK headers. Exact type encodings and header quotes are in API_NOTES.md, and both passes are reproducible — the runtime probe ships as integration_test/native_api_probe_test.dart.

Android #

Every symbol was verified by javap over the artifacts themselves rather than from documentation: window-1.2.0.aar, window-java-1.2.0.aar and android.jar. Behaviour was then checked on a pixel_9_pro_fold emulator running the android-37.2 system image, across open, half-open and closed, with hinge angles injected. The observed values are recorded in API_NOTES.md, along with what remains unverified.

The other half of the promise — that a platform with no fold support reports FoldInfo.unsupported and stays quiet — is verified too, on an ordinary non-foldable Android emulator.

doc/manual-tests.md has the checklist for both platforms, with the commands to drive each state.

License #

MIT. See LICENSE.

0
likes
160
points
620
downloads

Documentation

API reference

Publisher

verified publisheraayou.sh

Weekly Downloads

Fold awareness for the foldable iPhone and Android foldables: hinge angle and pose, fold regions, fold-aware layout widgets, a debug overlay and test fakes.

Repository (GitHub)
View/report issues
Contributing

Topics

#foldable #ios #android #adaptive-layout #hinge

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on bifold

Packages that implement bifold