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

Keeps widgets clear of whatever covers each screen edge: system bars, the keyboard, and your own headers, tab bars and sheets. No inset arithmetic.

harbor #

A harbor for your widgets.

Every screen is a rectangle, and something is always pushing in from its edges: the status bar, the home indicator, a notch, a TV's overscan, the keyboard, and your own headers, tab bars and composers. harbor gives each of them a place and a rule, so your content stops doing inset arithmetic.

Docks claim the edges. Everything else moors clear of them, sails under them, or is open water. The tide (the keyboard) rises over whatever doesn't float.

An illustrated harbor beside a phone running harbor. A pier is the header rows scroll under, a quay is the tab bar the list stops at, and the tide is the keyboard: a dock on pilings is covered, a floating one rides up.

Left, a harbor. Right, a phone running the real package, driven by the same clock. (Watch the video.)

Depends on the Flutter SDK only. Sea trials for widget tests come in a package of their own, harbor_test, so the test framework stays out of your app's dependencies.

Installing #

flutter pub add harbor
flutter pub add dev:harbor_test   # sea trials, for widget tests

MIT licensed.

The one rule #

A harbor has layers: the body (your content) at the bottom, docks above it, buoys above those, and the coast and tide (the system's insets and the keyboard) above everything.

Something on the same layer takes space. Something on a higher layer becomes padding for everything beneath it.

  • A quay is a dock built on the shore. It takes its ground, and the body starts where it ends, like a Column.
  • A pier is a dock built out over the water. The body runs under it and is told, through MediaQuery.padding, how far it reaches.

That's the whole difference between a header your list scrolls under and a header your list starts below.

Glossary #

Harbor What it is API
Sea The root every harbor floats on HarborSea
Harbor A frame with docked edges: a page, a tab, a sheet Harbor
New port A harbor that starts fresh: a route, a sheet, a dialog Harbor(newPort: true)
Coast What the platform takes: status bar, home indicator, cutouts, TV title-safe HarborCoast
Tide The keyboard HarborTide
Dock Something built against an edge, measured, never declared HarborDock.pier / .quay
Wake The fade where content passes under a dock HarborWake
Moored Content kept clear of everything in its way HarborMoored
Mooring line The page margin, applied by the content that asks for it Harbor(margin:), HarborMooringLine
Fairway A scroll view that runs under the docks and rests clear of them HarborFairway
Open water Content that ignores the docks: backgrounds, maps, heroes HarborOpenWater
Float / pilings / dry dock How a dock meets the tide HarborTideStance
Make way Content asking a dock to go dark or withdraw HarborMakeWay
Pontoon A dock moored from deep in the tree HarborPontoon
Buoy Something afloat in the clear water: a menu, a bubble HarborBuoy
Portal buoy A buoy opened from anywhere: a row's menu, a button's popover HarborPortalBuoy
Signal A transient buoy: a toast HarborSignals.raise
Breakwater A sheet reporting how much of the page it covers showHarborSheet(breakwater: true)
Lighthouse Keeps things in sight: reveal, lift, coverage HarborLighthouse, HarborBeacon
Scale model A fixed reference screen scaled to fit (TV) HarborScaleModel
Chart Who holds which edge, at which layer HarborChart, HarborChartOverlay
Sea trials Widget-test devices and tide control (harbor_test) pumpSeaTrial

Getting started #

Mount the sea once, above your Navigator:

MaterialApp(
  builder: (context, child) => HarborSea(
    margin: const EdgeInsetsDirectional.symmetric(horizontal: 16),
    child: child!,
  ),
  home: const InboxPage(),
);

Build a page from a harbor:

Harbor(
  top: [
    HarborDock.pier(
      wake: const HarborWake.fade(length: 12, blurSigma: 20),
      child: const InboxHeader(),
    ),
  ],
  bottom: [HarborDock.quay(child: const TabBar())],
  body: HarborFairway(
    slivers: [
      SliverList.builder(
        itemCount: 40,
        itemBuilder: (context, i) => HarborMooringLine(child: MessageRow(i)),
      ),
    ],
  ),
)

What each piece does:

  • The header is a pier. It pads itself below the status bar and frosts the water beneath it.
  • The tab bar is a quay. It pads itself above the home indicator, and the body ends at its top.
  • The fairway runs the full height of the body, under the header. Its first row comes to rest past the header's wake, and its last row rests clear of the tab bar. Whatever sizes the header and the tab bar laid out at, nobody declared a height.
  • The rows keep the mooring line, the 16 margin plus any side cutout. The list itself still runs to the frame's edge, so a carousel inside it can too.

Docks #

HarborDock.pier(child: header)          // content sails under it
HarborDock.quay(child: tabBar)          // content starts where it ends

Docks on one edge are listed in reading order (top and bottom top to bottom, start and end in reading order) and stack. Docks on the same layer add up; a pier over a quay reaches past it. Quays are always nearer the edge than piers.

A dock absorbs the coast on its own edge: a top dock pads its child below the status bar, and a bottom dock pads it above the home indicator. Its backdrop is painted under the whole of its ground, coast included. The insets across it (a top dock's sides) stay in the child's MediaQuery for the child to handle.

A dock sizes its child the way a Row or Column does: the child spans the edge and picks its own depth. A NavigationRail in a start dock is as wide as it is in a Row, and an AppBar in a top dock is as tall as its toolbar. Something that fills whatever it is given, like a ListView, needs a size, just as it would in a Row.

Option Use it for
wake: HarborWake.fade(length:, blurSigma:, restsAt:) Content fades as it passes under; it rests past the fade (HarborRest.wakeEnd) or at the dock (.dockEdge)
wake: HarborWake.hairline() A line on the dock's inner face, for a bar content scrolls up to
tide: HarborTideStance.float Rides up on the keyboard: a composer, a sheet's footer. It sits on the home indicator until the keyboard is taller than it, so it never dips
tide: HarborTideStance.pilings Stays put while the keyboard covers it: a tab bar (the default)
tide: HarborTideStance.dryDock Keeps the keyboard's ground at high-water height either way, so its child's HarborDryDock fills that ground when the keyboard is down. It runs to the screen's edge: no coast, no minimum
state: HarborDockState.dark Not drawn and not tappable, but it keeps its ground
state: HarborDockState.withdrawn Slides out and gives its ground back
extentPolicy: How a withdrawing dock gives its ground back: hold (once it's gone, the default), follow, release
withdrawsAtHighTide: true Leaves while the keyboard is up: a tool strip
restingExtent: The size to hold at rest for a dock that grows, like a rail that opens on focus
minimum: 16 At least this much room on the edge, coast or not
hitTestBehavior: Opaque by default, so taps on the header never reach rows under it

Content #

Stance Widget Use it for
Moored HarborMoored(edges:, clear:, follow:, tide:, mooringLine:, minimum:, extra:) Forms, fixed buttons, static blocks
Moored to one edge HarborMoored(edges: {HarborEdge.bottom}) A form footer under a page header: it clears the coast and the keyboard at the bottom, and leaves the header to the rest of the page
Mooring line HarborMooringLine(child:) A row that lines up with the page margin
Fairway HarborFairway(slivers:, minimum:) / HarborFairway.box(child:) Lists, grids, carousels (scrollDirection: Axis.horizontal)
One sliver HarborFairwaySliver(sliver:, minimum:) A sliver in your own CustomScrollView
Pinned header HarborSliverDock(child:) A header or tab strip inside the scroll that pins at the docks' face and stacks
Sticky HarborSticky(child:) A pill that rides with its item, then sticks below the docks and pinned headers
Centered HarborCenter(overlapBudget:) Controls centered in the frame that may overlap the docks by at most a budget
Open water HarborOpenWater(builder: (context, waters) => ...) Backgrounds, heroes; waters.coast, .docks, .wakes, .frameSize

Each one casts off what it cleared, so nothing beneath clears it again. HarborCastOff does the same for a layer you pad by hand, and HarborFairway.paddingOf gives a third-party list the right scroll padding.

HarborMoored(clear: HarborClear.coast) keeps clear of the coast alone: a hero title under a translucent header that must not touch the status bar. follow: HarborFollow.resting holds still while a dock grows over it. HarborFairway(startsInOpenWater: true) starts its first sliver at the frame's edge, under the docks, for a hero that runs under a translucent header. minimum: on a fairway or a fairway sliver is a floor on each end, as on SafeArea: the end rests clear of whatever is in the way or the minimum, whichever is larger, so a phone with a home button still keeps 16 under the last row. HarborFairway.box given no bound across the scroll, as a horizontal one is in a Column, is as thick as its child: a row of chips as tall as the chips, its ends still clear.

clear: HarborClear.coast keeps clear of the coast alone, without the keyboard. To keep clear of the coast and the keyboard but not a header, moor the bottom edge alone: the header is on the top edge, so it is left to the page.

Fairways also draw the wake: their content fades as it sails under a dock with a fade wake, while open water (a background, a hero) is left as it is. Give a Harbor a wakePainter to wake its whole body instead, or to paint the wake your own way (a progressive blur shader).

Fairways widen every reveal by what covers their trailing edge. A focused field, Scrollable.ensureVisible and focus traversal all bring a row clear of the docks and the keyboard with no extra code.

The tide #

Harbor(bodyClearsTide: true, ...)   // default: the body ends at the waterline
Harbor(bodyClearsTide: false, ...)  // the body runs under; content reads it
HarborTide.of(context)              // height, remaining, highWater, phase
HarborTide.isInOf(context)          // whether it is in, rebuilding only when that flips

MediaQuery.padding never carries the keyboard. That stays in viewInsets, as Flutter has it, and harbor widgets add it where they keep clear of the bottom. HarborTide.of(context).height is still readable after a harbor has moved out of the keyboard's way: for information, never for layout. highWater is the last settled keyboard height in this orientation, and it falls as well as rises.

Readers rebuild only for what they read. HarborTide.of follows every frame of the keyboard moving; HarborTide.isInOf hears it come and go, and HarborWaters.of(context, aspect: HarborWatersAspect.docks) holds still while it moves. Harbor's own content reads the same way: a mooring line, a horizontal fairway, open water or a dry dock in a page the keyboard runs under is not rebuilt as it rises. Only what lays out against it is.

HarborWaters.steadyCoastOf(context, HarborEdge.bottom) is the home indicator's height, held while the keyboard is up, as viewPadding is in Flutter: for a footer that keeps its size while the keyboard animates. A body that clears the tide has no viewPadding left at the bottom while the keyboard is up, so read it here. It is zero below a quay that absorbed the coast, and below anything that cast the edge off.

Harbor and Scaffold #

A harbor does what a Scaffold does for the edges, so a page built from a harbor does not need one. Keep a Scaffold only where Material needs it, for its surface or to host SnackBars, and then give it the harbor as its body and turn off its resizing:

Scaffold(
  resizeToAvoidBottomInset: false, // the harbor handles the keyboard
  body: Harbor(top: [...], bottom: [...], body: ...),
)

A resizing Scaffold shrinks the harbor before the harbor ever sees the keyboard. Nothing is counted twice, but every dock then rides up above the keyboard: a tab bar on pilings is lifted instead of covered, and bodyClearsTide: false has nothing to run under. Leave the Scaffold's appBar, bottomNavigationBar and floatingActionButton empty and use docks and buoys instead.

Accessibility #

What harbor hides is hidden from everyone: a dark or withdrawn dock is skipped by keyboard focus and by screen readers, not only by taps. Signals are live regions, so screen readers announce them. Sheets and signals keep the themes of the page they came from, and so do dialogs. With reduced motion (MediaQuery.disableAnimations) docks, signals, sheets and dialogs appear and leave without moving. On iOS a tap on the status bar scrolls a harbor page to the top, as it does under a Scaffold.

Talking to the harbor #

HarborMakeWay(edge: HarborEdge.bottom, mode: HarborYield.withdraw, child: panel)
HarborPontoon(edge: HarborEdge.bottom, dock: HarborDock.pier(child: unsavedBar), child: form)
HarborController.of(context).makeWay(HarborEdge.top, mode: HarborYield.dark) // returns a claim; release() it

Claims are counted and go to the nearest harbor that has a dock on that edge. A pontoon joins the harbor's docks on the next frame.

Buoys and signals #

Harbor(
  buoys: [
    HarborBuoy(alignment: AlignmentDirectional.bottomEnd, child: fab),
    HarborBuoy.anchored(anchor: launchAnchor, side: HarborBuoySide.above, overlap: 6, child: bubble),
    HarborBuoy(modal: true, onDismiss: closeQuickActions, child: quickActions), // a barrier over the page
  ],
  bottom: [HarborDock.quay(child: TabBar(launch: HarborAnchorPoint(anchor: launchAnchor, child: launchButton)))],
  body: ...,
)

HarborSignals.raise(context, slot: HarborSignalSlot.low, builder: (_) => Toast('Saved'));

Buoys float in the clear water: the rectangle no coast, dock or tide covers. An anchored buoy sits on its side of its anchor; before and after are in reading order, so before is on the right under right-to-left. alignment and margin take directional values, so AlignmentDirectional.bottomEnd puts a button where a right-to-left reader expects it. A modal buoy is modal: a barrier (clear unless you give it a barrierColor) keeps taps off the page and its docks and tells screen readers to leave them alone, a tap beside the buoy or back calls its onDismiss, and the buoys listed before it are hidden while it is up. Unlike a route, it does not trap keyboard focus. A signal goes to the port on top (a sheet over a page over the sea), so a low signal clears that sheet's footer, and it also stays clear of the docks of the harbor it was raised from (a tab's own header). If its harbor leaves, the signal moves to the one now on top.

A signal raised with no harbor above it (a widget test that pumps a bare MaterialApp, a preview, a screen not yet built from a harbor) still shows: it goes to the nearest Overlay, at its slot and clear of MediaQuery.padding and viewInsets. With no overlay either, it is reported through FlutterError.reportError, in release builds too. A signal's timers stop when it is lowered or when nothing is left to show it, so a test that ends with one up has no timer pending.

final menu = OverlayPortalController();

HarborPortalBuoy(                       // in a list row, anywhere below a harbor
  controller: menu,
  side: HarborBuoySide.below,
  buoyBuilder: (context) => const RowMenu(),
  child: GestureDetector(onTap: menu.toggle, child: row),
)

A portal buoy is an anchored buoy opened from where it is used rather than listed in Harbor.buoys: a menu from a list row, a popover from a button in another package. It is an OverlayPortal, so its buoy builds with the row's themes and floats in the nearest Overlay, placed in the clear water of the harbor around the row by its child (or by an anchor). When its side has no room, it flips to the other side of the anchor, so a menu from a row just above the tab bar or the keyboard opens above the row; when neither side has room, it is held inside the clear water.

Sheets and dialogs #

showHarborSheet(context, builder: (_) => HarborSheet(header: title, footer: actions, body: form));
showHarborSheet(context, builder: (_) => HarborSheet.draggable(
  header: title,
  builder: (context, controller) => HarborFairway(controller: controller, slivers: [...]),
));
showHarborSheet(context, breakwater: true, barrier: HarborSheetBarrier.none, builder: ...);
showHarborDialog(context, inheritClearWater: true, builder: ...);

A sheet is a new port. It takes the themes and text style of the page that opened it, and stops short of the status bar. Its header is a pier with a wake (on a draggable sheet, also a drag handle), and its footer is a quay that floats on the tide and keeps 16 off the edge. It keeps the home indicator in its coast, so its footer clears it exactly once. A draggable sheet's heights are fractions of the space between the status bar and the keyboard. A breakwater sheet reports how far it covers the page that opened it, and that page's content keeps clear of it while it's up.

On a dual-screen device, sheets and dialogs keep to one screen, as Material's do, and signals and buoys keep to the screen that holds them, never across the hinge. A flat fold, which has no width, may still be spanned.

A sheet with barrier: HarborSheetBarrier.none is not a route of its own, so it is tied to the page that opened it: back (and a pop) closes it before the page, the iOS back swipe stands aside while it is up, it hides while another page is on top, and it leaves when its page is replaced or removed. A PopScope inside such a sheet has no route to register with; put it around the page instead.

A sheet with a barrier is a route, as a modal bottom sheet is. routeSettings: reach your navigator observers and route-name analytics, and barrierLabel: is what a screen reader announces for the barrier ('Close sheet' when none is given). It spans the screen unless you give it a maxWidth.

harbor imports no design library: it sits on Flutter's widgets layer, and since Flutter 3.47 Material and Cupertino are packages of their own. So a Material app passes Material's pieces in, three lines that showModalBottomSheet would have filled in for it:

showHarborSheet(
  context,
  routeSettings: const RouteSettings(name: 'reply'),
  barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
  maxWidth: Theme.of(context).bottomSheetTheme.constraints?.maxWidth ?? 640,
  builder: (_) => HarborSheet(
    contentBuilder: (context, content) => Material( // text fields and ink work in it
      type: MaterialType.transparency,
      textStyle: DefaultTextStyle.of(context).style,
      child: content,
    ),
    clip: const RoundedRectangleBorder(             // a photo at the top keeps the corners
      borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
    ),
    dragToClose: true,                              // drag it down to close
    surface: const ColoredBox(color: Colors.white),
    body: composer,
  ),
);

dragToClose: is off by default. With it on, a content-sized sheet follows the finger down by any part that doesn't scroll, and closes on a fling or when let go under half shown. A draggable sheet always closes below its floor, and its HarborSheetExtent(snapSizes:) are the heights it snaps to (by default its rest and its ceiling). A draggable sheet opened some other way, by showModalBottomSheet or showGeneralDialog, closes that route instead.

The lighthouse #

HarborBeacon(onObscured: (covered) => titleOpacity.value = covered, child: heroTitle);
HarborBeacon(keepInSight: true, child: field);            // re-reveals as the keyboard rises
HarborLighthouseRegion(child: canvas)                     // + HarborBeacon(lift: true, clearance: 80)
HarborLighthouse.reveal(context, clearance: 24);

TV #

MaterialApp(
  builder: (context, child) => HarborScaleModel(
    referenceSize: const Size(1200, 675),
    coast: const HarborCoast.titleSafe(HarborTitleSafe.fraction(0.05)),
    child: HarborSea(child: child!),
  ),
);

A scale model lays out on a reference screen and scales to fit, re-basing the real insets into its coordinates. Title-safe is part of the coast, so docks absorb it and moored content keeps clear of it like any other inset. A rail is a start dock with a restingExtent, and pages choose HarborFollow.live (move with it) or .resting (let it open over them).

The chart #

HarborChartOverlay(child:) (around HarborSea in MaterialApp.builder, or anywhere below it) draws every dock's ground, each harbor's clear water and the tide. Each dock is labelled with its extent; labelStyle: sets the labels' font, so they read in widget tests and goldens rather than as flutter_test's boxes. HarborChart.snapshot(context) returns the same as data. In debug and profile builds the ext.harbor.chart VM-service extension serves it as JSON, for tools that drive the app.

Two fields are reserved and not yet read: HarborCoastFeature.hinge (sheets, dialogs, signals and buoys keep off a hinge through MediaQuery.displayFeatures, not through the coast) and HarborController.isPort (signals find their port by route instead).

Sea trials #

Sea trials are in harbor_test, a dev dependency beside harbor (flutter pub add dev:harbor_test). It brings flutter_test, which harbor itself does not depend on.

import 'package:harbor_test/harbor_test.dart';

testWidgets('the composer rides the keyboard', (tester) async {
  final trial = await tester.pumpSeaTrial(app, device: HarborTrialDevice.androidThreeButton);
  await trial.raiseTide();
  expect(tester.getRect(find.byType(Composer)).bottom, trial.waterline);
});

Devices: iPhone17, iPhoneSE, androidThreeButton, androidGesture, iPhone17Landscape, foldableOpen (a flat fold), dualScreenCover, dualScreenOpen (a hinge), television, plus the phones and all lists. device.displayFeatures puts a device's folds and hinges on the view. trial.clearWaterAround(finder) and isInClearWater assert where something sits relative to everything in the way, not to a number.

package:harbor/testing.dart, where sea trials used to be, is now empty and deprecated: importing it points to harbor_test. It will be removed in a later release.

Example #

example/ is a small harbor game that exercises every pattern above. Toggle the chart in its Harbor Office to see the layers.

The video at the top of this page is example/lib/showcase/: run it with fvm flutter run -t lib/showcase_main.dart. example/tool/render_showcase.sh records it frame by frame on the test clock, so it comes out the same every time, and example/test/showcase_test.dart checks each caption against the real page on the phone.

The video is narrated. example/tool/narrate.py voices each line of example/lib/showcase/narration.tsv, times every word, and checks that each clip says what the script says. The showcase's timeline is built from those timings, so the keyboard rises as "comes in" is spoken. Narration: the Kokoro-82M voice am_liam, generated on device by Kass.

The Harbor Field Guide (the book button on the game's first page, or fvm flutter run -t lib/field_guide_main.dart) has a page for every class, named as it is in code, with a drawing of the real thing it's named after and a pretend phone to try it on: its own status bar, home indicator and keyboard, a tide gauge to drag the keyboard in and out, the class's options, live readings of MediaQuery and the waters, and the code for what's on the phone.

example/integration_test/play_through_test.dart plays through every scene on a real device or simulator, with real insets and the real software keyboard, taking a screenshot at each step and checking where everything landed:

cd example
PLAY_SHOTS=build/play fvm flutter drive --driver=test_driver/integration_test.dart \
  --target=integration_test/play_through_test.dart -d <simulator id>

To run it on a phone, copy example/ios/Flutter/Local.xcconfig.example to Local.xcconfig (it isn't checked in) and fill in your own team and a bundle ID you own: the example ships as com.example.harborExample, which Apple won't sign.

Turn off the simulator's hardware keyboard first (I/O ▸ Keyboard ▸ Connect Hardware Keyboard) so the software keyboard comes up. Screenshots show Flutter's own surface, so the keyboard itself is not in them.

The field guide's pages are checked by widget tests (example/test/field_guide_*_test.dart): each page's options are set one by one and where things land is measured on its pretend phone.

0
likes
0
points
58
downloads

Publisher

unverified uploader

Weekly Downloads

Keeps widgets clear of whatever covers each screen edge: system bars, the keyboard, and your own headers, tab bars and sheets. No inset arithmetic.

Repository (GitHub)
View/report issues

Topics

#layout #safe-area #keyboard #insets #widget

License

unknown (license)

Dependencies

flutter

More

Packages that depend on harbor