harbor 0.4.3
harbor: ^0.4.3 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.
Left, a harbor. Right, a phone running the real package, driven by the same clock. The GIF is the opening; the narrated video (three and a half minutes) tours everything in the package on a phone, from the coast to sea trials. The wide-format video (one minute) shows what changes on other screens: a rail on a tablet, right-to-left, a phone on its side, a dual-screen hinge, and a television's title-safe band.
Try every class in your browser: the Harbor Field Guide, with a pretend phone for each class, its options, and a tide gauge to bring the keyboard in and out.
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 (pronounced "key"): the body stops at it, as at
Scaffold.appBar. It is a dock built on the shore. It takes its ground, and the body starts where it ends, like aColumn. - A pier: the body runs under it, as under an app bar with
extendBodyBehindAppBar. It is a dock built out over the water, and the body is told, throughMediaQuery.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 keyboard: rides up on it, stays under it, or keeps its space | 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 |
| Flare | A transient buoy: a toast | HarborFlares.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 |
In Flutter's terms #
If you know the Flutter widget, this is where to look in harbor, and what is different.
| Harbor | Closest Flutter concept | The difference that matters |
|---|---|---|
| Sea | MaterialApp.builder: one per app, above the Navigator, inside the ScaffoldMessenger that MaterialApp wraps around the builder's output |
It holds the coast, the tide gauge and the flares every route shares. It moves nothing out of the keyboard's way itself |
| Harbor | Scaffold |
Any number of docks on all four edges, each measured. A Scaffold has one app bar, capped at its preferredSize, and one bottom bar |
| New port | A route, which reads the MediaQuery from above the Navigator |
A harbor that is not a new port takes the docks of the harbor around it as part of its coast |
| Coast | MediaQuery.padding and viewPadding |
The same insets, read from MediaQuery, plus a TV's title-safe band (HarborCoast.titleSafe) or a fixed coast (HarborCoast.fixed, and HarborCoast.none for goldens) |
| Tide | MediaQuery.viewInsets.bottom; Scaffold.resizeToAvoidBottomInset |
It adds a phase, a high-water mark, and how much of it still reaches this point (remaining). A resizing Scaffold moves the whole body; here each dock decides. The keyboard stays in viewInsets, never in padding |
Quay (HarborDock.quay) |
Scaffold.appBar and bottomNavigationBar: the body starts where they end |
Measured, never declared, and on any edge: a start dock holds a NavigationRail as a Row would. Several stack |
Pier (HarborDock.pier) |
An app bar under Scaffold(extendBodyBehindAppBar: true), a bottom bar under extendBody: true |
The same mechanism: the body runs under it and its MediaQuery.padding says how far. A pier does it on any edge, and for a stack of docks |
| Wake | A ShaderMask fade, with a BackdropFilter frost under the bar |
The fade's length counts toward where content rests, so the band and the first row's resting line never drift apart |
| Moored | SafeArea |
SafeArea reads MediaQuery.padding alone, so it misses the keyboard. A moored widget keeps clear of it at the bottom too, can clear the coast alone (clear:) or the docks at rest (follow:), and takes directional edges. Both cast off what they cleared, and minimum: is a floor on both |
| Mooring line | Horizontal page padding: a Padding on each row |
It adds whatever is in the way on the sides (a side cutout, a rail) to the harbor's margin, and only the rows that ask get it, so the list itself still runs to the frame's edge |
| Fairway | ListView, CustomScrollView |
A ListView with no padding pads its ends by MediaQuery.padding but not by the keyboard, and a CustomScrollView pads nothing. A fairway clears both ends, keyboard included (unless tide: false), and widens every reveal by what covers its edges. HarborFairwaySliver is the SliverSafeArea of a scroll view you build yourself |
Pinned header (HarborSliverDock) |
PinnedHeaderSliver, SliverAppBar(pinned: true) |
It pins at the docks' face rather than the viewport's edge, several stack, and reveals keep clear of it |
| Open water | Content outside any SafeArea that reads MediaQuery.padding itself |
waters splits each edge into coast and docks, which MediaQuery.padding adds together |
Cast off (HarborCastOff) |
MediaQuery.removePadding |
removePadding lowers viewPadding only by the padding it removes; a cast-off zeroes it, and with tide: the keyboard, and zeroes harbor's own waters, so harbor widgets beneath read zero too. docks: false casts off the coast alone and leaves the docks, which removePadding cannot tell apart |
| Float / pilings | Float: the bottom of a resizing Scaffold's body. Pilings: Scaffold.bottomNavigationBar, which the keyboard covers |
Chosen per dock, so a composer can float while the tab bar under it stays on pilings |
| Dry dock | None | It reserves the keyboard's height whether the keyboard is up or not, so a panel can trade places with it and nothing moves |
| Make way | Rebuilding the Scaffold without its bottomNavigationBar |
Asked for from deep in the page and counted. HarborYield.dark keeps the dock's ground as Visibility(maintainSize: true) does; HarborYield.withdraw slides it out and gives the ground back |
| Pontoon | ScaffoldState.showBottomSheet, which puts a widget into an ancestor's frame from deep in the tree |
A pontoon is a dock: it takes its ground (or the body sails under it), and leaves with the widget that added it |
| Buoy | Scaffold.floatingActionButton; a Stack with Positioned |
It sits in the clear water, so it clears the coast, every dock and the keyboard. A modal buoy has a barrier, as ModalBarrier does, but it is not a route, so keyboard focus is not trapped |
| Portal buoy | OverlayPortal (it is one), as MenuAnchor and RawMenuAnchor use |
Placement only: it keeps the buoy in the clear water and flips it when its side has no room. It brings no menu semantics, keyboard navigation or tap-outside dismissal; your controller opens and closes it |
| Flare | SnackBar, through ScaffoldMessenger.showSnackBar |
Flares at one slot take turns, as snack bars do, and a flare's time counts only while it is in sight; flares at different slots show together. It builds any widget, at one of four heights (HarborFlareSlot), clear of the docks of the page that raised it. Both are live regions |
Sheet (showHarborSheet) |
showModalBottomSheet; HarborSheet.draggable is built on DraggableScrollableSheet; barrier: HarborSheetBarrier.none is showBottomSheet |
Its header and footer are docks, so the body sails under the header and the footer floats on the keyboard. harbor imports no Material, so a Material app passes in its theme's pieces (Sheets and dialogs) |
| Breakwater | None | A Scaffold lifts its floating action button over a bottom sheet but leaves the body under it. A breakwater sheet tells the page that opened it how far it covers, and the page's content keeps clear |
| Lighthouse | Scrollable.ensureVisible, RenderObject.showOnScreen, TextField.scrollPadding |
A reveal clears the docks and the keyboard of every fairway it passes through. HarborBeacon(onObscured:), how much of a widget the header covers, and HarborLighthouseRegion, lifting content that does not scroll, have no Flutter equivalent |
| Scale model | A FittedBox around a MediaQuery with a fixed size |
The real screen's insets are re-based into the model's coordinates. Under a bare FittedBox, content still reads the real screen's MediaQuery |
| Chart | debugPaintSizeEnabled |
It draws who holds each edge, at which layer, and the clear water, and serves the same as data (HarborChart.snapshot) |
| Sea trials | tester.view.padding, viewPadding and viewInsets, set with FakeViewPadding |
Devices come with their status bar, home indicator, keyboard height and folds already set, and assertions are about the clear water, not numbers |
Two names end in State without being a State: HarborTideState is an immutable snapshot
of the keyboard, as MediaQueryData is, and HarborDockState is an enum, as AnimationStatus is.
Pronouncing them: a quay is pronounced "key", as harbours have always said it. A buoy is pronounced "BOO-ee" in American English and "boy" in British; both are right.
Getting started #
Mount the sea once, above your Navigator:
MaterialApp(
builder: (context, child) => HarborSea(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: child!,
),
home: const InboxPage(),
);
Every inset harbor takes (a margin, a minimum, a fairway's padding, a fixed
coast) is an EdgeInsetsGeometry, as Padding's is: EdgeInsets keeps to the
side it names, and EdgeInsetsDirectional follows the reading direction.
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) // the body runs under it (extendBodyBehindAppBar)
HarborDock.quay(child: tabBar) // the body stops at it (Scaffold.appBar)
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.
HarborDock.reserve(extent: 80) holds an edge for something drawn elsewhere
(a footer in an overlay, a bar a parent paints): it reaches 80 in from the
edge, coast included, or 80 past the docks outside it, paints nothing and takes
no taps. It is a pier by default (kind: HarborDockKind.quay stops the body
at it) and takes a tide: like any dock.
The side docks own the corners. A start or end dock runs the frame's
full height, and a top or bottom dock runs between them, as a tablet's
NavigationRail sits beside its AppBar. A header beside a rail starts where
the rail ends: its title lines up with the rows below, and it is not handed the
coast the rail took, only the coast on the side no rail holds. A rail that
withdraws gives its corners back as it goes; one that widens over the page on
focus (restingExtent:) holds the header at its resting width.
| 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.fade(dockOpacity:, curve:) |
The fade's ramp: how opaque content is at the dock's face (a quarter by default) and the curve out to the wake's end (a straight line). dockOpacity: 1 is a band that counts toward where content rests but fades nothing past the dock. A fade is an alpha mask, so it takes no colour: tint the background or the dock's backdrop, or paint a scrim with Harbor(wakePainter:) |
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, staying on top of it: 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; the keyboard covers it: a tab bar (the default) |
tide: HarborTideStance.dryDock |
Keeps the keyboard's space whether the keyboard is up or down, so nothing moves: a styling panel. It holds that 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 |
animationStyle: |
How it moves, as AnimationStyle sets it on Flutter's routes: duration and curve to return or light up, reverseDuration and reverseCurve to withdraw or go dark, AnimationStyle.noAnimation for none. It overrides duration: and curve: |
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, for the dock outermost on its edge (the one that takes the coast) |
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:, clear:, tide:) / HarborFairway.box(child:) |
Lists, grids, carousels (scrollDirection: Axis.horizontal) |
| One sliver | HarborFairwaySliver(sliver:, minimum:, clear:, tide:) |
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.
A third-party list that reads only MediaQuery.padding (a chat list, a feed)
already gets, in a harbor's body, the larger of the coast and the docks on every
edge. What it misses is the wake, reveal widening, the mooring line, minimum
and, under bodyClearsTide: false, the keyboard. Give it
HarborFairway.paddingOf(context) if it takes a padding; otherwise rewrite
MediaQuery around it yourself, with MediaQuery.of(context).copyWith(padding: ...).
That rewrites Flutter's MediaQuery, not harbor's waters, so nothing drifts from
harbor's measurements. Harbor itself never puts the keyboard into padding.
A fairway under a header it does not own, which already cleared the top, goes
in HarborCastOff(edges: {HarborEdge.top}): on that edge MediaQuery and the
waters read zero, so the fairway's first row, the cover its pinned sliver docks
pin at and its reveals all leave the top alone, as a Scaffold's body does
under its app bar. {HarborEdge.bottom} does the same for a list that stops
short of the bottom of the screen. To start the first sliver under the header
instead, use startsInOpenWater (below).
A layer that pads by the coast alone (a page's safe-area margin, read from
HarborWaters.of(context).coast) casts off the coast and keeps the docks with
HarborCastOff(edges: {HarborEdge.bottom}, docks: false). A list inside it
still rests on a frosted tab bar: beneath, the docks, their wakes and
MediaQuery.padding are measured from the layer's edge. A fairway needs no
such option across its scroll: it casts off only its own ends, so a carousel's
items are handed the docks above and below it as they are.
HarborFairway(clear: HarborClear.coast) rests its ends clear of the coast
alone: a list that keeps its last row off the home indicator but scrolls on
under a frosted tab bar, or a carousel that runs under a rail. Its ends cast off
the coast alone, as HarborCastOff(docks: false) does, so its rows still read
how far the docks reach, measured from the fairway's edge, and can pad
themselves under them. Pinned sliver docks still pin at the docks' face, reveals
still bring a row clear of the docks, and the bottom end still keeps clear of
the keyboard. HarborFairwaySliver and HarborFairway.paddingOf take the same
clear:.
HarborFairway(tide: false) leaves the keyboard to someone else, as
HarborMoored(tide: false) does: a list in a host whose body already ends at
the keyboard, or one that stays put while a keyboard opens over it. Its bottom
end and its reveals leave the keyboard out, and it does not cast the keyboard
off, so a moored block inside can still keep clear of it, and the fairway
holds still while the keyboard moves. The sliver and paddingOf take it too.
HarborWaters.dockFaceOf(context, HarborEdge.top) is how far the header
reaches, to its inner face, whether or not it has a wake: for artwork that
lines up under a frosted header. It holds still while the keyboard moves. It
reads zero inside a fairway, which has cast its ends off; start the fairway in
open water to lay artwork under the header.
HarborMoored(clear: HarborClear.coast) keeps clear of the coast alone, without
the keyboard: a hero title under a translucent header that must not touch the
status bar. 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.
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. That
sliver is handed back the leading end alone; the trailing end and the keyboard
stay cast off.
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.
A fairway takes the rest of a CustomScrollView's parameters, with the same
names and defaults (the box form those of a SingleChildScrollView):
restorationId: brings its scroll position back after the app is restarted, and
keyboardDismissBehavior: left unset follows the app's ScrollBehavior. With a
center:, both ends of the scroll still rest clear of the docks, and the center
sliver starts clear of the leading ones. anchor: is the one that reads
differently: it is a fraction of the water between the docks, not of the
viewport that runs under them, so anchor: 1 puts the center on a composer's
face and lifts it with the keyboard.
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
(wakePainter: HarborWakeMask.alphaWake), 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, and so does the coast aspect, on a phone with a home indicator too.
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 rebuilds its reader when the view padding changes,
as MediaQuery.viewPaddingOf does, but not on every frame of the keyboard. It
is zero below a quay that absorbed the coast, and below anything that cast the
edge off. A footer that sits on the keyboard instead, giving the home
indicator's room back while the keyboard covers it, reads
MediaQuery.viewPaddingOf in a body that clears the tide: the indicator with
the keyboard down, 0 with it up.
A keyboard the platform does not report #
harbor reads the keyboard from MediaQuery.viewInsets.bottom, as Scaffold and
EditableText do, so that is the one place to feed it a keyboard the platform
does not report: one drawn by a plugin or a platform view, a TV's on-screen
keyboard reported over a channel. Rebuild MediaQuery above the sea with the
larger of the two:
MaterialApp(
builder: (context, child) {
final MediaQueryData data = MediaQuery.of(context);
final double keyboard = math.max(data.viewInsets.bottom, imeHeight); // logical px
return MediaQuery(
data: data.copyWith(viewInsets: data.viewInsets.copyWith(bottom: keyboard)),
child: HarborSea(child: child!),
);
},
);
HarborTideSource does the same from a ValueListenable<double>, rebuilding
only its MediaQuery when the height changes:
HarborTideSource(height: imeHeight, child: HarborSea(child: child!)) // imeHeight: a ValueNotifier<double>
Docks, the tide gauge, fairways and Flutter's own widgets then all see the same keyboard: a floating composer rides it, a tab bar on pilings is covered. Mind:
- Logical pixels. A channel usually reports physical ones; divide by
MediaQuery.devicePixelRatioOf(context). - The larger value, not the sum, so a keyboard the platform does report is not counted twice.
- A floating keyboard (an iPad's, a split one) covers no edge and should not raise the tide: report zero for it, as the platform does.
- Above a scale model. Put it outside
HarborScaleModel, so the height is re-based into the model's coordinates with the rest of the insets.
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, and a buoy whose
anchor is not in the tree is not read out. Flares are live
regions, so screen readers announce them, with a dismiss action that lowers
them, as a SnackBar is. A flare whose widget is already its own live region
(a SnackBar-like widget from your design library) is raised with
liveRegion: false, so harbor adds no second, unlabelled one around it. A
flare with a button is raised with persist: true, as a SnackBar with an
action persists, so it is still there when a screen reader reaches it. Sheets and flares keep the themes of
the page they came from, and so do dialogs. With reduced motion
(MediaQuery.disableAnimations) docks, flares, sheets and dialogs appear and
leave without moving, and the lighthouse's reveals and lifts jump into place. On iOS a tap on the
status bar scrolls a harbor page to the top, as it does under a Scaffold, and
as there only the page whose status bar band is on top at the screen's top left:
a page under a route in an outer navigator, under an overlay, or in the
right-hand pane of a split stays where it is.
Talking to the harbor #
HarborMakeWay(edge: HarborEdge.bottom, mode: HarborYield.withdraw, child: panel)
HarborPontoon(edge: HarborEdge.bottom, dock: HarborDock.pier(child: unsavedBar), child: form)
Harbor.of(context).makeWay(HarborEdge.top, mode: HarborYield.dark) // returns a claim; release() it
Harbor.of(context) is the nearest harbor's handle, as Scaffold.of is the
nearest ScaffoldState. It throws a FlutterError when there is no harbor
above the context, in release builds too, as Scaffold.of does;
Harbor.maybeOf returns null instead. The harbor makes its handle and runs
its lifecycle, so the handle carries only what content asks of it: claims,
pontoons, breakwaters and the clear water. Claims are counted and go to the
nearest harbor that has a dock on that edge. A HarborMakeWay takes its claim
while it is built, after its harbor, so the dock makes way a frame later; a
claim taken with makeWay in the tap that opens a mode lands in the next frame
drawn, and a page that opens without the dock builds it with state: HarborDockState.withdrawn. A pontoon joins the harbor's
docks on the next frame; one added by hand with addPontoon returns a
HarborPontoonHandle to update or remove it by.
Buoys and flares #
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: ...,
)
HarborFlares.raise(context, slot: HarborFlareSlot.low, builder: (_) => Toast('Saved'));
HarborFlares.raise(context, alignment: const Alignment(0, -0.8), builder: (_) => Toast('Saved'));
HarborFlares.raise(context, anchor: copyAnchor, side: HarborBuoySide.above, builder: (_) => Toast('Copied'));
final undo = HarborFlares.raise(context, persist: true, builder: (_) => UndoToast(onUndo: restore));
final HarborFlareClosedReason why = await undo.closed; // lower, dismiss, timeout or remove
HarborFlares.raise(
context,
transitionBuilder: (context, animation, child) => SlideTransition(
position: Tween(begin: const Offset(0, 1), end: Offset.zero).animate(animation),
child: child,
),
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; start and end are in
reading order, as in AlignmentDirectional, so start is on the right under
right-to-left. (before and after, their names until 0.2.0, still work and are
deprecated.) Across that side it is centred on its anchor unless its
crossAlignment says start or end, the anchor's edges in reading order (or
its top and bottom beside it), as a dropdown lines up under its button's leading
edge; crossOffset moves it on from there, toward the reading end. While its
anchor is not in the tree, an anchored buoy is not shown, takes no taps and is
not read out by screen readers. It is placed again in every frame that is drawn,
so it moves with its anchor in the same frame, a row scrolling under an open
menu included. A HarborAnchor refers to one HarborAnchorPoint, so give each
row of a list its own; in debug builds two points left on one anchor are
reported after the frame, as two leaders on one LayerLink are.
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, back or Escape calls its onDismiss, and the buoys
listed before it are hidden while it is up. Its barrierLabel ('Dismiss' when
none is given) is what a screen reader announces for the barrier; a Material app
passes MaterialLocalizations.of(context).modalBarrierDismissLabel. It holds
keyboard focus as a dialog does: it takes focus when it opens, Tab goes round
inside it and the arrow keys (a TV remote's D-pad) stop at its edges, and focus
goes back to where it was when it closes. requestFocus: false leaves focus on
the page, as it does for a route; Escape from there still closes the buoy, unless
focus is on a page of a Navigator nested in the harbor's body, whose route
answers Escape before the harbor does.
A flare is raised at a slot (top, high, middle, low) or at an exact
alignment, placed as a buoy at that alignment would be. An
AlignmentDirectional follows the reading direction of the page that raised it.
It fades and scales in over animationStyle (220 ms each way by default).
A transitionBuilder brings your own entrance and exit, run on harbor's
animation, and AnimationStyle.noAnimation shows a widget that animates
itself as it is, as showSnackBar(snackBarAnimationStyle:) does. A lowered
flare stays at least 300 ms, so its own exit can run.
A flare goes to the port on top (a sheet over a page over the sea), so a low
flare 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
flare moves to the one now on top.
A flare raised with an anchor sits by a HarborAnchorPoint instead, as an
anchored buoy does: on its side (below by default), gap away (8), lined up
by its crossAlignment (centred), and kept in the clear water: "Copied" by the
button that copied. Give it the anchor alone, without a slot or an alignment,
and raise it from the page that holds the anchor: it is shown by that page's
port, not the one on top. Flares at one anchor and side take turns. While the
anchor is out of the tree the flare is not shown, takes no taps and is not read
out, and its time stops. If its page is popped, it does not move to the port now
on top: it is lowered, and closed reports remove.
Flares raised at the same slot or alignment of one port take turns, as a
ScaffoldMessenger shows its snack bars: the next comes in once the one before
it has run its exit, so "Copied" tapped twice is never drawn over itself. To
replace the flare that is up, lower() it; one lowered while it waits leaves
without being shown. Flares at different slots show together. closed
completes once a flare has left, with why.
A flare stays 4 s, as a SnackBar does, and its duration counts only while it
is in sight: from the end of its entrance, and not while another route covers
its page (the time starts over when that route leaves). persist: true keeps it
up until it is lowered, as SnackBar(persist:) does. Give it to a flare with a
button, an Undo: a screen-reader user moving through the page needs longer than
4 s to reach it. entry.hold() stops the time while a finger is on the flare or
focus is in it, and release() on the hold starts it over; holds are counted.
A flare keeps 16 in from the clear water's edges (8 by an anchor). margin:
sets that, and EdgeInsets.zero gives a bar the width of the screen, a fixed
SnackBar's look, still above the docks and off the coast. A bar that paints
under the home indicator is a dock, not a flare: moor it with a HarborPontoon.
A flare 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. Without a HarborSea, the outermost harbor of the page is its
port: a flare raised from a harbor inside a header goes to the page, not the
header. With no overlay either, it is reported through
FlutterError.reportError, in release builds too. A flare'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.
Flares were called signals until 0.3.0. The old names (HarborSignals,
HarborSignalSlot, HarborSignalEntry and the rest) still work and are
deprecated; they go at 1.0.
final menu = OverlayPortalController();
HarborPortalBuoy( // in a list row, anywhere below a harbor
controller: menu,
side: HarborBuoySide.below,
crossAlignment: HarborBuoyCrossAlignment.start, // under the row's leading edge
onDismiss: menu.hide, // a tap outside, Escape or back
consumeOutsideTaps: true, // and that tap presses nothing else
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). Until that anchor is
in the tree, it is not shown, takes no taps and is not read out. 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. HarborPortalBuoy.sideOf(context) in
the buoy is the side it landed on, so a popover can point its arrow at the
anchor after a flip. The buoy is placed as it paints, so it hears of a flip on
the next frame.
With an onDismiss, a portal buoy closes as a MenuAnchor does: its buoy and
its child are one TapRegion group, so a tap outside both calls onDismiss
while a tap on the row that opened it is left to the row, and so do Escape with
focus in either and back (before it reaches the page). The tap goes on to what
is under it, as a MenuAnchor's does, unless consumeOutsideTaps is set. It
puts up no barrier and leaves the page to screen readers, as a menu does. So in
a modal buoy, a tap on the barrier while the portal buoy is open calls both
onDismisses, and in a dialog it calls the portal buoy's and closes the
dialog, as it does with a MenuAnchor open in a dialog.
A portal buoy leaves focus where it was when it opens, as a MenuAnchor does.
requestFocus: true makes it take focus as a sheet with no barrier does: the buoy
is a focus scope of its own that becomes the first focus of the scope around its
child (the page's, or a modal buoy's or a dialog's), so a control in it with
autofocus: true takes it from there. It is not modal, so Tab past its last control
does what it does at a route's edge rather than going round inside it. When it
closes, focus goes back to where it was, if focus is still in the buoy. It needs an
onDismiss, so Escape from inside it can close it; that Escape closes the portal buoy
before a modal buoy it was opened from.
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: in the same frame as the sheet is drawn, as it slides in and out and as it is dragged.
showHarborSheet(keepsTopCoast: true) lets a sheet reach the top of the
screen, and hands its header and body the status bar to clear, as a page's
are. A header that paints under the status bar and pads its own title would
pad twice, so it passes HarborSheet(clearsTopCoast: false) (on
HarborSheet.draggable too): the sheet keeps the height keepsTopCoast gave
it, and the top coast is cast off for its content, which reads zero there.
Without keepsTopCoast it changes nothing: the sheet stops short of the status
bar and is handed no top coast to clear.
On a phone on its side, or with a cutout on a side edge, HarborSheet(clearsSides: true)
keeps the header, body and footer clear of the coast on the left and right and
casts it off, so nothing beneath clears it again, while the surface still runs
edge to edge. It is off by default, as showModalBottomSheet's useSafeArea
is: then the header and footer run the sheet's full width and are handed the
side coast in MediaQuery.padding, and so is the body, which clears it only
where it moors. useSafeArea insets the whole sheet, its surface
and its top too; clearsSides insets only what is over the surface, and only
the sides.
A sheet keeps its body above the keyboard and caps its height by the space
above it. HarborSheet(bodyClearsTide: false) leaves the keyboard to the body,
as Harbor(bodyClearsTide: false) does a page's: for an editing toolbar that
holds a keyboard-sized space so nothing jumps when a field takes focus, or an
inspector the keyboard may cover. The body runs under the keyboard and reads it
in MediaQuery.viewInsets, a floating footer still rides up on it, and the
height cap (or a draggable sheet's extents) is a share of the whole height.
On a dual-screen device, sheets and dialogs keep to one screen, as Material's do, and flares 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 (from the first frame of that page's push until its pop has
finished, since the sheet is drawn above every page rather than inside its
own), and it leaves when its page is replaced or removed. Escape closes it as
back does, from focus in the sheet or in a harbor on its page, and is left to
the widgets above while no such sheet is up. It is not modal, as a persistent
bottom sheet is not: it is a focus scope of its own, as a route is, so Tab goes
through the sheet in order and then on to the page, and it leaves focus where it
was when it opens unless you pass requestFocus: true. Then focus goes back to
the page when it closes. A
PopScope inside such a sheet has no route to register with; put it around
the page instead.
A page that handles back itself with PopScope(canPop: false) (a tab bar that
goes back a tab) keeps back from a modal buoy, a portal buoy and a sheet with no
barrier, as it does from a Drawer or a persistent bottom sheet:
Navigator.maybePop sees canPop: false before it looks at the page's local
history, which is where harbor ties them, so only your handler hears back. Close
them from that handler first, with Navigator.pop, which removes the newest
entry of that history:
PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (ModalRoute.of(context)!.willHandlePopInternally) {
Navigator.of(context).pop(); // closes the top buoy, portal buoy or barrier-less sheet
return;
}
tabs.back();
},
child: Harbor(...),
)
Each back press then closes one of them, the newest first, and only once they are
all closed does it reach tabs.back().
final HarborSheetController nowPlaying = HarborSheetController();
showHarborSheet(context, barrier: HarborSheetBarrier.none, controller: nowPlaying, builder: ...);
nowPlaying.close(); // from a button on the page: it slides out
nowPlaying.remove(); // it goes at once
A HarborSheetController closes a sheet from outside it, as a
PersistentBottomSheetController closes Scaffold.showBottomSheet's: close(),
a closed future, setState to rebuild it, and its slide as animation.
remove() takes it away with no slide, as removeCurrentSnackBar does a snack
bar. It is attached while its sheet is up (isAttached) and tells its listeners
when that changes, so a button can show whether it opens or closes. It works
for a sheet with a barrier too. transitionAnimationController: slides the
sheet by a controller of your own in place of its 280 ms slide, as on
showModalBottomSheet; you dispose it.
final Folder? folder = await showHarborSheet<Folder>(
context,
builder: (context) => HarborSheet(
body: FolderList(onPick: (folder) => HarborSheet.close(context, folder)),
),
);
HarborSheet.close(context, result) closes the sheet context is in and
completes the future that opened it with result, as Navigator.pop(context, result) does for a modal bottom sheet. It works whatever the barrier, and on a
sheet opened by showModalBottomSheet or showGeneralDialog, where it pops that
route. A sheet with no barrier is not a route, so back and Navigator.pop close
it with no result: it returns its value only through HarborSheet.close.
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 ('Dismiss' when none is given), with
barrierOnTapHint: saying what tapping it does. Like a modal bottom sheet, the
sheet is a semantics scope of its own, and screen readers announce its
semanticLabel: as it opens. The barrier's semantics end at the sheet's top, as
a modal bottom sheet's do, and follow it as it slides or is dragged, so touch
exploration over the sheet finds its content rather than the barrier. It spans
the screen unless you give it a maxWidth.
A dialog is a popup route, as one from showDialog is: a Hero does not fly
into it, an observer of page routes does not count it as a screen, a draggable sheet
inside it closes it, and its content is a route of its own for screen readers, named by semanticLabel:. It
takes showDialog's route options: routeSettings:, barrierLabel: ('Dismiss'
when none is given), anchorPoint: (which screen of a dual-screen
device it opens on), traversalEdgeBehavior:, requestFocus: and
animationStyle: (its fade, 180 ms by default). transitionBuilder: brings your own
entrance and exit in place of the fade, as showGeneralDialog's does: it is handed the
route's animation curved by animationStyle, and under reduced motion an animation that
is already complete, so the dialog is simply there.
showHarborDialog pushes a HarborDialogRoute, as showDialog pushes a DialogRoute.
Push one yourself to keep the route or to choose the navigator:
final HarborDialogRoute<bool> confirm = HarborDialogRoute<bool>(context: context, builder: (_) => const ConfirmDelete());
final bool? delete = await Navigator.of(context).push(confirm);
It takes the same options. The page at context lends it its themes and, with
inheritClearWater: true, its clear water, both read as the route is pushed rather than
when it is made.
Three more options are named and behave as showModalBottomSheet's. isDismissible: false
makes a sheet the user has to answer: a tap on the barrier does nothing, and
back still closes it. requestFocus: false leaves focus in the page.
anchorPoint: picks which screen of a dual-screen device it opens on.
What harbor draws on its own is drawn as the widgets layer draws it, with no
theme: sheet and dialog barriers are showGeneralDialog's half-black
(0x80000000), and a hairline wake is a translucent black (0x1F000000), a
shade of whatever bar it is on, as BorderSide's default is black; a dark bar
passes its own color:. Every barrier harbor puts up is announced with the
label it is given and otherwise with 'Dismiss', the English that Material and
Cupertino fall back to, since WidgetsLocalizations has none to offer.
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, the lines that showModalBottomSheet would have
filled in for it:
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
showHarborSheet(
context,
routeSettings: const RouteSettings(name: 'reply'),
barrierLabel: localizations.modalBarrierDismissLabel,
barrierOnTapHint: localizations.scrimOnTapHint(localizations.bottomSheetLabel),
semanticLabel: localizations.dialogLabel, // on iOS Material leaves it unnamed: pass null there
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, where showModalBottomSheet's enableDrag is
on, so a Material app that wants its sheets to follow a downward drag turns it on,
as above. With it on, a content-sized sheet follows the
finger down by any part that doesn't scroll, and closes on a fling faster than
closeFlingVelocity: (700, as a modal bottom sheet) or when let go under half shown. A draggable sheet closes at its floor, and its
HarborSheetExtent(snapSizes:) are the heights it snaps to (by default its
rest and its ceiling). A fling down on its header from its lowest height goes
to the floor and closes it when it is faster than 700 logical px/s, the speed
at which a Material BottomSheet closes; a slower one goes back to that height.
closeFlingVelocity: sets that speed, on either kind of sheet
(closeFlingVelocity: 400 is the speed harbor used before 0.3.1). A fling on its list is
DraggableScrollableSheet's own, as in a modal bottom sheet: from the lowest
height it closes the sheet at any speed. HarborSheetExtent(shouldCloseOnMinExtent: false) rests at the floor
instead. A draggable sheet opened some other way, by showModalBottomSheet or
showGeneralDialog, closes that route instead; in a modal bottom sheet give it
expand: false, as you would a DraggableScrollableSheet, so a tap above it
reaches the barrier.
A draggable sheet takes a DraggableScrollableController, so the page or the
sheet's own content can read and move it:
final DraggableScrollableController comments = DraggableScrollableController();
HarborSheet.draggable(controller: comments, header: title, builder: ...);
comments.animateTo(0.88, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic); // its field took focus
Rebuilt with a new rest before it is dragged, a sheet moves there, as a
DraggableScrollableSheet does with a new initialChildSize; after a drag,
move it with the controller.
sheetAnimationStyle: takes an AnimationStyle, as showModalBottomSheet
does: its duration and curve set how the sheet opens, reverseDuration and
reverseCurve how it closes, and AnimationStyle.noAnimation opens and closes
it at once. A dragged sheet stays under the finger whatever the curve, and
reduced motion still wins.
The lighthouse #
HarborBeacon(onObscured: (covered) => titleOpacity.value = covered, child: heroTitle);
HarborBeacon(keepInSight: true, child: field); // re-reveals as the keyboard rises or focus moves in
HarborLighthouseRegion(child: canvas) // + HarborBeacon(lift: true, clearance: 80)
HarborLighthouse.reveal(context, clearance: 24);
A focused field reveals its own caret, as EditableText does. A beacon kept in
sight reveals all of itself: when the keyboard rises, and when focus moves into
it from outside, by a tap or the keyboard's next action. So a field and the
button under it come up together, clearance clear of the keyboard.
onlyWhileFocused: true keeps the rest of a form's beacons still.
A region lifts over 280 ms and settles back the same way. Give it an
animationStyle: to change that: its duration and curve are for the lift,
its reverseDuration and reverseCurve for settling back, and
AnimationStyle.noAnimation moves the content at once.
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,
and HarborChart.nearest(context) the nearest harbor alone: its frame, body,
clear water and docks in global coordinates, the way a test or a tool reads a
harbor without reaching into it.
In debug and profile builds the ext.harbor.chart VM-service extension serves
it as JSON, for tools that drive the app.
Tooling that runs outside the widget tree (a debug panel, a logger, an
automation driver) reads HarborChart.snapshotAll(): every harbor of every live
sea, with no context. A sea mounted inside another harbor (a phone drawn in a
page, a preview) is listed too, and each of its entries says isolated: true;
ext.harbor.chart leaves those out unless it is called with isolated=true. A
sea is taken off the chart when it is disposed.
To hear harbors come and go, as a NavigatorObserver hears routes, give the sea
a HarborFleetObserver:
class HarborLog extends HarborFleetObserver {
@override
void didJoin(HarborController harbor) => debugPrint('joined ${harbor.debugLabel}');
@override
void didLeave(HarborController harbor) => debugPrint('left ${harbor.debugLabel}');
}
HarborSea(observers: [HarborLog()], child: child!)
Harbor.of(context).fleet.addObserver(log); // or on a fleet you already have; removeObserver(log)
A harbor joins as it is built, in the middle of a frame, so both events arrive
after that frame, in order: by then the harbor has laid out and the chart can
read it. A harbor that joins and leaves within one frame is reported to no one.
The layout records behind the chart stay internal; read a harbor through
HarborChart.snapshotOf(harbor.fleet) or HarborChart.nearest.
The widget inspector and debugDumpApp show each harbor widget's settings, as
they do a SafeArea's or a ListView's, leaving out the ones at their
defaults: a Harbor lists its docks and buoys, and a dock reads as
HarborDock.pier(tide: float, debugLabel: "composer"). The values (HarborDock,
HarborBuoy, HarborWake, HarborCoast, HarborTitleSafe, HarborSheetExtent)
are Diagnosticable, so they print the same way in a test failure or a log.
Two fields are reserved and not yet read: HarborCoastFeature.hinge (sheets,
dialogs, flares and buoys keep off a hinge through MediaQuery.displayFeatures,
not through the coast) and HarborController.isPort (flares 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);
});
raiseTide() pumps 600 ms, long enough for the harbor to follow;
raiseTide(pumpFor: Duration.zero) stops at the first frame after the keyboard
arrives, and settle: true pumps until nothing is animating.
Devices: iPhone17, iPhoneSE, androidThreeButton, androidGesture,
iPhone17Landscape, foldableOpen (a flat fold), dualScreenCover,
dualScreenOpen (a hinge), television, plus the phones and all lists.
Each device goes on the view at its own devicePixelRatio, and
device.displayFeatures puts its folds and hinges there.
pumpSeaTrial(textScaleFactor:) grows the system text size, so docks are
measured at the size their text grew to. trial.clearWaterAround(finder) and isInClearWater
assert where something sits relative to everything in the way, not to a number.
trial.docksAround(finder) lists the docks of the harbor around a widget. Both
read HarborChart.nearest, so a test of your own can too.
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 videos at the top of this page are example/lib/showcase/ (the phone tour,
fvm flutter run -t lib/showcase_main.dart) and example/lib/showcase_wide/ (the
wide-format one, fvm flutter run -t lib/showcase_wide_main.dart; render it with
render_showcase.sh showcase_wide). 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. Chapters the
panorama was not drawn for show the Field Guide's drawing of the real thing,
and each runs a real harbor page on the phone: sheets, dialogs and flares open
on the phone's own navigator as the narrator names them. Narration: the Kokoro-82M
voice am_liam, generated on device by Kass.
The Harbor Field Guide (in your browser, 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.
