Skip to main content

harbor 0.9.1 copy "harbor: ^0.9.1" to clipboard
harbor: ^0.9.1 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 #

Every edge of the screen, claimed once.

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.

Every edge of the screen is claimed once, by the layer that owns it, and everything inside gets what is left. The platform claims the system edges and the keyboard, your bars claim theirs, and your content gets the room.

The harbour illustration beside a phone running harbor. An overlay bar, drawn as a pier, is the header rows scroll under; a solid bar, drawn as a quay, is the tab bar the list stops at.

Left, the harbour illustration. 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 system edges to widget-test 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 gauge to bring the keyboard in and out.

Depends on the Flutter SDK only. 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   # trials, for widget tests

MIT licensed.

Renamed in 0.9 #

0.9 renames the API after the model above; the 0.8 names still work, deprecated until 1.0, and dart fix --apply followed by dart format migrates your code. The main renames:

0.8 0.9
HarborSea HarborRoot
Harbor HarborFrame
Harbor(newPort: true) HarborFrame(isPage: true), which dart fix writes; HarborFrame.page(...) says the same and reads better
HarborDock.pier / .quay / .reserve HarborBar.overlay / .solid / .reserve
HarborDockState, HarborDockKind.pier / .quay HarborBarState, HarborBarKind.overlay / .solid
HarborCoast, coast: HarborSystemEdges, systemEdges:
HarborCoastStance HarborSystemEdgeStance
HarborTide.of / .isInOf HarborKeyboard.of / .isShownOf
HarborTideState.highWater / .isIn HarborKeyboardState.settledHeight / .isShown
HarborTideStance.float / .pilings / .dryDock, a bar's tide: HarborKeyboardStance.rides / .stays / .reserves, a bar's keyboard:
tide: on HarborMoored, HarborFairway, HarborFairwaySliver, HarborCastOff, paddingOf clearsKeyboard:
bars: (0.8.0) on the same, and HarborCastOff(docks:) clearsBars:
HarborDryDock(showsChildAtHighTide:), HarborTideSource HarborKeyboardSpace(showsChildWithKeyboard:), HarborKeyboardSource
bodyClearsTide: bodyClearsKeyboard:
HarborWake, wake: HarborEdgeEffect, edgeEffect: (edgeEffects: on a scroll view)
HarborWakeMask(wakes:), .alphaWake, wakePainter: HarborFadeMask(bands:), .alphaFade, edgeEffectPainter:
HarborWatersData.wakes / .wakeOf, HarborWakeBand HarborRoomData.fadeBands / .fadeBandOf, HarborFadeBand
HarborController.clearWater HarborController.room
HarborWaters, HarborWatersData HarborRoom, HarborRoomData
HarborWaters.dockFaceOf / .steadyCoastOf HarborRoom.barFaceOf / .steadySystemEdgeOf
HarborMoored HarborClear
HarborMooringLine, mooringLine: HarborClear.rowInsets(child:), rowInsets:
HarborCastOff HarborHandled
HarborFairway(startsInOpenWater:), HarborFairwaySliver HarborScrollView(startsUnderBars:), HarborSliverClear
HarborSliverDock HarborSliverBar
HarborOpenWater(builder:) a Builder and HarborRoom.of(context)
HarborMakeWay, makeWay HarborHideBar, hideBar
HarborPontoon(dock:), addPontoon HarborAttachedBar(bar:), attachBar
HarborBuoy, buoys: HarborFloat, floats:
HarborPortalBuoy(buoyBuilder:) HarborPopover(popoverBuilder:)
HarborFlares.raise, entry.lower() HarborToasts.show, entry.hide()
HarborFlareSlot, HarborFlareEntry, HarborFlareTarget.sea HarborToastSlot, HarborToastEntry, HarborToastTarget.root
showHarborSheet(breakwater:, keepsTopCoast:) showHarborSheet(coversPage:, reachesTop:)
showHarborDialog(inheritClearWater:) showHarborDialog(inheritRoom:)
HarborBeacon, HarborLighthouse, HarborLighthouseRegion HarborKeepInSight, HarborKeepInSightRegion
HarborChart, HarborChartOverlay HarborInspector, HarborInspectorOverlay
HarborFleet, HarborFleetObserver, controller.fleet HarborFrameRegistry, HarborFrameObserver, controller.registry
HarborScaleModel HarborScaledScreen
pumpSeaTrial, raiseTide(), waterline, isInClearWater pumpHarborTrial, showKeyboard(), keyboardTop, isInRoom

HarborOpenWater is retired rather than renamed, and has no automatic fix. The HarborClear enum is removed so the widget can take its name: clear: HarborClear.coast is clearsBars: false, and HarborClear.everything was the default. HarborMooringLine(child:) becomes HarborClear.rowInsets(child:), which builds a HarborClear, so a test that found rows with find.byType(HarborMooringLine) finds none after the fix: give the row a Key, or find it with find.byWidgetPredicate((w) => w is HarborClear && w.rowInsets). The complete table, every member and parameter, is doc/design/naming.md; the CHANGELOG lists the names not renamed yet.

The one rule #

A frame has layers: the body (your content) at the bottom, bars above it, floats, popovers and toasts above those, and the edges (the system edges and the keyboard) above everything. A system edge is an inset at an edge of the frame (the status bar at the top); HarborEdge is the side of the frame itself.

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

  • A solid bar (HarborBar.solid): the body stops at it, as at Scaffold.appBar. It sits beside the body rather than over it, so it takes its space and the body starts where it ends, like a Column.
  • An overlay bar (HarborBar.overlay): the body runs under it, as under an app bar with extendBodyBehindAppBar, and the body 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. What no edge and no bar covers is the room. HarborRoom.of(context) describes what bounds the room at a point in the tree: how far the system edges, the bars and their fades reach in on each edge, and the margin.

In Flutter's terms #

Where harbor resembles a Flutter widget, this is what is different.

harbor Closest Flutter concept The difference that matters
HarborRoot MaterialApp.builder: one per app, above the Navigator, inside the ScaffoldMessenger that MaterialApp wraps around the builder's output It holds the system edges, the keyboard reading and the toasts every route shares. It moves nothing out of the keyboard's way itself
HarborFrame Scaffold Any number of bars on all four edges, each measured. A Scaffold has one app bar, capped at its preferredSize, and one bottom bar
HarborFrame.page A route, which reads the MediaQuery from above the Navigator A frame that does not start a page takes the bars of the frame around it as part of its system edges
HarborSystemEdges MediaQuery.padding and viewPadding The same insets, read from MediaQuery, plus a TV's title-safe band (HarborSystemEdges.titleSafe) or fixed edges (HarborSystemEdges.fixed, and HarborSystemEdges.none for goldens)
HarborKeyboard MediaQuery.viewInsets.bottom; Scaffold.resizeToAvoidBottomInset It adds a phase, a settled height, and how much of it still reaches this point (remaining). A resizing Scaffold moves the whole body; here each bar decides. The keyboard stays in viewInsets, never in padding
HarborBar.solid Scaffold.appBar and bottomNavigationBar: the body starts where they end Measured rather than declared, and on any edge: a start bar holds a NavigationRail as a Row would. Several stack
HarborBar.overlay 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. An overlay bar does it on any edge, and for a stack of bars
HarborEdgeEffect 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
HarborClear SafeArea SafeArea reads MediaQuery.padding alone, so it misses the keyboard. HarborClear keeps clear of it at the bottom too, can clear the system edges alone (clearsBars: false) or the bars at rest (follow:), and takes directional edges. Both mark what they cleared as handled, and minimum: is a floor on both
HarborClear.rowInsets 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 frame's margin, and only the rows that ask get it, so the list itself still runs to the frame's edge
HarborScrollView ListView, CustomScrollView A ListView with no padding pads its ends by MediaQuery.padding but not by the keyboard, and a CustomScrollView pads nothing. A HarborScrollView clears both ends, keyboard included (unless clearsKeyboard: false), and widens every reveal by what covers its edges. HarborSliverClear is the SliverSafeArea of a scroll view you build yourself
HarborSliverBar PinnedHeaderSliver, SliverAppBar(pinned: true) It pins at the bars' face rather than the viewport's edge, several stack, and reveals keep clear of it
HarborRoom.of(context) Content outside any SafeArea that reads MediaQuery.padding itself The room data splits each edge into system edges and bars, which MediaQuery.padding adds together
HarborHandled MediaQuery.removePadding removePadding lowers viewPadding only by the padding it removes; HarborHandled zeroes it, and with clearsKeyboard: the keyboard, and zeroes harbor's own room data, so harbor widgets beneath read zero too. clearsBars: false handles the system edges alone and leaves the bars, which removePadding cannot tell apart
HarborKeyboardStance.rides / .stays Rides: the bottom of a resizing Scaffold's body. Stays: Scaffold.bottomNavigationBar, which the keyboard covers Chosen per bar, so a composer can ride the keyboard while the tab bar under it stays
HarborKeyboardStance.reserves 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
HarborHideBar Rebuilding the Scaffold without its bottomNavigationBar Asked for from deep in the page and counted. HarborYield.dark keeps the bar's space as Visibility(maintainSize: true) does; HarborYield.withdraw slides it out and gives the space back
HarborAttachedBar ScaffoldState.showBottomSheet, which puts a widget into an ancestor's frame from deep in the tree An attached bar is a bar: it takes its space (or the body runs under it), and leaves with the widget that added it
HarborFloat Scaffold.floatingActionButton; a Stack with Positioned It sits in the room, so it clears the system edges, every bar and the keyboard. A modal float has a barrier, as ModalBarrier does, but it is not a route, so keyboard focus is not trapped
HarborPopover OverlayPortal (it is one), as MenuAnchor and RawMenuAnchor use Placement only: it keeps the popover in the room 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
HarborToasts.show SnackBar, through ScaffoldMessenger.showSnackBar Toasts at one slot take turns, as snack bars do, and a toast's time counts only while it is in sight; toasts at different slots show together. It builds any widget, at one of four heights (HarborToastSlot), clear of the bars of the page that showed it. Both are live regions
showHarborSheet showModalBottomSheet; HarborSheet.draggable is built on DraggableScrollableSheet; barrier: HarborSheetBarrier.none is showBottomSheet Its header and footer are bars, so the body runs under the header and the footer rides the keyboard. harbor imports no Material, so a Material app passes in its theme's pieces (Sheets and dialogs)
showHarborSheet(coversPage: true) None A Scaffold lifts its floating action button over a bottom sheet but leaves the body under it. A sheet that covers the page tells the page that opened it how far it covers, and the page's content keeps clear
HarborKeepInSight Scrollable.ensureVisible, RenderObject.showOnScreen, TextField.scrollPadding A reveal clears the bars and the keyboard of every HarborScrollView it passes through. HarborKeepInSight(onObscured:), how much of a widget the header covers, and HarborKeepInSightRegion, lifting content that does not scroll, have no Flutter equivalent
HarborScaledScreen A FittedBox around a MediaQuery with a fixed size The real screen's insets are re-based into the reference screen's coordinates. Under a bare FittedBox, content still reads the real screen's MediaQuery
HarborInspector debugPaintSizeEnabled It draws who holds each edge, at which layer, and the room, and serves the same as data (HarborInspector.snapshot)
pumpHarborTrial 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 room, not numbers

Two names end in State without being a State: HarborKeyboardState is an immutable snapshot of the keyboard, as MediaQueryData is, and HarborBarState is an enum, as AnimationStatus is.

Getting started #

Mount the root once, above your Navigator:

import 'package:harbor/harbor.dart';

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

Every inset harbor takes on more than one edge (a frame's margin and minimum, a scroll view's padding, fixed system edges, a toast's margin) is an EdgeInsetsGeometry, as Padding's is: EdgeInsets keeps to the side it names, and EdgeInsetsDirectional follows the reading direction. A bar's minimum, for its one edge, is a number.

Build a page from a frame:

HarborFrame(
  top: [
    HarborBar.overlay(
      edgeEffect: const HarborEdgeEffect.fade(length: 12, blurSigma: 20),
      child: const InboxHeader(),
    ),
  ],
  bottom: [HarborBar.solid(child: const InboxTabs())],
  body: HarborScrollView(
    slivers: [
      SliverList.builder(
        itemCount: 40,
        itemBuilder: (context, i) => HarborClear.rowInsets(child: MessageRow(i)),
      ),
    ],
  ),
)

What each piece does:

  • The header is an overlay bar. It pads itself below the status bar and frosts the content beneath it.
  • The tab bar is a solid bar. It pads itself above the home indicator, and the body ends at its top.
  • The scroll view runs the full height of the body, under the header. Its first row comes to rest past the header's fade, 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 row insets, the 16 margin plus any side cutout. The list itself still runs to the frame's edge, so a carousel inside it can too.

Bars #

HarborBar.overlay(child: header)        // the body runs under it (extendBodyBehindAppBar)
HarborBar.solid(child: tabBar)          // the body stops at it (Scaffold.appBar)

Bars on one edge are listed in reading order (top and bottom top to bottom, start and end in reading order) and stack, so the first top bar and the last bottom bar are the ones against the edge. Bars on the same layer add up; an overlay bar over a solid bar reaches past it. List solid bars nearer the edge than overlay bars: a solid bar listed inside an overlay bar is reported as an error in debug builds.

A bar takes the system edge on its own side: a top bar pads its child below the status bar, and a bottom bar pads it above the home indicator. Its backdrop is painted under the whole of its space, system edge included. The insets across it (a top bar's sides) stay in the child's MediaQuery for the child to handle, except on a side a start or end bar holds: that bar took the system edge there.

A bar sizes its child the way a Row or Column does: the child spans its edge (for a top or bottom bar, the part between any side bars) and picks its own depth. A NavigationRail in a start bar is as wide as it is in a Row, and an AppBar in a top bar 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. A PreferredSizeWidget in a top or bottom bar is held to its own height, as Scaffold holds its appBar, so an AppBar with a bottom: (a TabBar) is its toolbar and its bottom high, below the status bar, and keeps a toolbar height that comes from AppBarTheme. That height is the child's intrinsic height, or its preferredSize where the child holds a LayoutBuilder or a ListView, which cannot be asked. A side bar's child is left alone: a preferred size is a height.

HarborBar.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, system edge included, or 80 past the bars outside it, paints nothing and takes no taps. It is an overlay bar by default (kind: HarborBarKind.solid stops the body at it) and takes a keyboard: like any bar.

A HarborFrame fills the space it is given, as a page does. sizing: HarborSizing.hugBody makes it as tall as its body and bars, as a content-sized sheet is, and maxExtentFraction: caps that at a share of the space above the keyboard (of the whole height when bodyClearsKeyboard is false). systemEdges: replaces the system edges a frame reads from MediaQuery (HarborSystemEdges.fixed(...), HarborSystemEdges.none for goldens, HarborSystemEdges.titleSafe(...) on a TV), on a HarborRoot or any HarborFrame, and minimum: is a floor under what its body keeps clear of on each edge.

The side bars own the corners. A start or end bar runs the frame's full height, and a top or bottom bar 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 system edge the rail took, only the system edge 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
edgeEffect: HarborEdgeEffect.fade(length:, blurSigma:, restsAt:) Content fades as it passes under; it rests past the fade (HarborRest.fadeEnd) or at the bar (.barFace)
edgeEffect: HarborEdgeEffect.fade(faceOpacity:, curve:) The fade's ramp: how opaque content is at the bar's face (a quarter by default) and the curve out to the fade's end (a straight line). faceOpacity: 1 is a band that counts toward where content rests but fades nothing past the bar. A fade is an alpha mask, so it takes no colour: tint the background or the bar's backdrop, or paint a scrim with HarborFrame(edgeEffectPainter:)
edgeEffect: HarborEdgeEffect.hairline() A line on the bar's inner face, for a bar content scrolls up to
keyboard: HarborKeyboardStance.rides 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
keyboard: HarborKeyboardStance.stays Stays put; the keyboard covers it: a tab bar (the default)
keyboard: HarborKeyboardStance.reserves Keeps the keyboard's space whether the keyboard is up or down, so nothing moves: a styling panel. It holds that space at the settled keyboard height either way, so its child's HarborKeyboardSpace fills that space when the keyboard is down. It runs to the screen's edge: no system edge, no minimum
state: HarborBarState.dark Not drawn and not tappable, but it keeps its space
state: HarborBarState.withdrawn Slides out and gives its space back
extentPolicy: How a withdrawing bar gives its space 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. Its name is unchanged in 0.9
restingExtent: The size to hold at rest for a bar that grows, like a rail that opens on focus
minimum: 16 At least this much room on the edge, system edge or not, for the bar outermost on its edge (the one that takes the system edge)
systemEdges: HarborSystemEdgeStance.live / .steady / .none How it takes the system edge on its side: as it is now (gone at the bottom while the keyboard is up; the default for a bar that rides the keyboard), as it is with the keyboard down (the default for one that stays), or not at all (the default for one that reserves)
hitTestBehavior: Opaque by default, so taps on the header never reach rows under it

Content #

Role Widget Use it for
Keeps clear HarborClear(edges:, bars:, follow:, keyboard:, rowInsets:, minimum:, padding:) Forms, fixed buttons, static blocks
Keeps clear of one edge HarborClear(edges: {HarborEdge.bottom}) A form footer under a page header: it clears the system edge and the keyboard at the bottom, and leaves the header to the rest of the page
Row insets HarborClear.rowInsets(child:) A row that lines up with the page margin
Scroll view HarborScrollView(slivers:, minimum:, bars:, keyboard:) / HarborScrollView.box(child:) Lists, grids, carousels (scrollDirection: Axis.horizontal)
One sliver HarborSliverClear(sliver:, minimum:, bars:, keyboard:) A sliver in your own CustomScrollView
Pinned header HarborSliverBar(child:) A header or tab strip inside the scroll that pins at the bars' face and stacks
Sticky HarborSticky(child:) A pill that rides with its item, then sticks below the bars and pinned headers
Centered HarborCenter(overlapBudget:) Controls centered in the frame that may overlap the bars by at most a budget
Ignores the bars Builder(builder: (context) { final room = HarborRoom.of(context); ... }) Backgrounds, heroes; room.systemEdges, .bars, .fadeBands, .frameSize

Each one marks what it cleared as handled, so nothing beneath clears it again. HarborHandled does the same for a layer you pad by hand, and HarborScrollView.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 frame's body, the larger of the system edges and the bars on every edge. What it misses is the fade, reveal widening, the row insets, minimum and, under bodyClearsKeyboard: false, the keyboard. Give it HarborScrollView.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 room data, so nothing drifts from harbor's measurements. harbor itself never puts the keyboard into padding.

A scroll view under a header it does not own, which already cleared the top, goes in HarborHandled(edges: {HarborEdge.top}): on that edge MediaQuery and the room data read zero, so the scroll view's first row, the cover its pinned sliver bars 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 startsUnderBars (below).

A layer that pads by the system edges alone (a page's safe-area margin, read from HarborRoom.of(context).systemEdges) handles the system edges and keeps the bars with HarborHandled(edges: {HarborEdge.bottom}, clearsBars: false). A list inside it still rests on a frosted tab bar: beneath, the bars, their fades and MediaQuery.padding are measured from the layer's edge. A scroll view needs no such option across its scroll: it handles only its own ends, so a carousel's items are handed the bars above and below it as they are.

HarborScrollView(clearsBars: false) rests its ends clear of the system edges alone, not the bars: 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 handle the system edges alone, as HarborHandled(clearsBars: false) does, so its rows still read how far the bars reach, measured from the scroll view's edge, and can pad themselves under them. Pinned sliver bars still pin at the bars' face, reveals still bring a row clear of the bars, and the bottom end still keeps clear of the keyboard while keyboard is true. HarborSliverClear and HarborScrollView.paddingOf take the same clearsBars:.

HarborScrollView(clearsKeyboard: false) leaves the keyboard to someone else, as HarborClear(clearsKeyboard: 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 mark the keyboard handled, so a HarborClear block inside can still keep clear of it, and the scroll view holds still while the keyboard moves. The sliver and paddingOf take it too.

HarborRoom.barFaceOf(context, HarborEdge.top) is how far the header reaches, to its inner face, whether or not it has an edge effect: for artwork that lines up under a frosted header. It holds still while the keyboard moves. It reads zero inside a scroll view, which has handled its ends; start the scroll view under the bars to lay artwork under the header.

HarborClear(clearsBars: false) keeps clear of the system edges alone, without the bars and, unlike a scroll view, without the keyboard whatever its keyboard: a hero title under a translucent header that must not touch the status bar. To keep clear of the system edges and the keyboard but not a header, keep clear of 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 bar grows over it. HarborScrollView(startsUnderBars: true) starts its first sliver at the frame's edge, under the bars, 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 handled. minimum: on a scroll view or a HarborSliverClear 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. HarborScrollView.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 HarborScrollView 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 bars, and the center sliver starts clear of the leading ones. anchor: is the one that reads differently: it is a fraction of the room between the bars, 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.

Scroll views also draw the edge effect: their content fades as it passes under a bar with a fade, while content that ignores the bars (a background, a hero) is left as it is. Give a HarborFrame an edgeEffectPainter to fade its whole body instead (edgeEffectPainter: HarborFadeMask.alphaFade), or to paint the edge effect your own way (a progressive blur shader).

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

The keyboard #

HarborFrame(bodyClearsKeyboard: true, ...)   // default: the body ends at the keyboard's top
HarborFrame(bodyClearsKeyboard: false, ...)  // the body runs under; content reads it
HarborKeyboard.of(context)                   // height, remaining, settledHeight, phase
HarborKeyboard.isShownOf(context)            // whether it is shown, 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. HarborKeyboard.of(context).height is still readable after a frame has moved out of the keyboard's way: for information, never for layout. settledHeight is the last settled keyboard height in this orientation, and it falls as well as rises. Its phase is hidden, opening, shown or closing.

Readers rebuild only for what they read. HarborKeyboard.of follows every frame drawn while the keyboard moves; HarborKeyboard.isShownOf hears it come and go, and HarborRoom.of(context, aspect: HarborRoomAspect.bars) holds still while it moves, and so does the systemEdges aspect, on a phone with a home indicator too. harbor's own content reads the same way: row insets, a horizontal scroll view, a HarborRoom.of reader or a HarborKeyboardSpace in a page the keyboard runs under is not rebuilt as it opens. Only what lays out against it is.

HarborRoom.steadySystemEdgeOf(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 keyboard 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 solid bar that took the system edge, and below anything that marked the edge handled. 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 keyboard: 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 root 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: HarborRoot(child: child!),
    );
  },
);

HarborKeyboardSource does the same from a ValueListenable<double>, rebuilding only its MediaQuery when the height changes:

HarborKeyboardSource(height: imeHeight, child: HarborRoot(child: child!)) // imeHeight: a ValueNotifier<double>

Bars, the keyboard reading, scroll views and Flutter's own widgets then all see the same keyboard: a composer that rides it rides it, a tab bar that stays 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 count as shown: report zero for it, as the platform does.
  • Above a scaled screen. Put it outside HarborScaledScreen, so the height is re-based into the reference screen's coordinates with the rest of the insets.

HarborFrame and Scaffold #

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

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

A resizing Scaffold shrinks the frame before the frame ever sees the keyboard. Nothing is counted twice, but every bar then rides up above the keyboard: a tab bar that stays is lifted instead of covered, and bodyClearsKeyboard: false has nothing to run under. Leave the Scaffold's appBar, bottomNavigationBar and floatingActionButton empty and use bars and floats instead.

Talking to the frame #

HarborHideBar(edge: HarborEdge.bottom, mode: HarborYield.withdraw, child: panel)
HarborAttachedBar(edge: HarborEdge.bottom, bar: HarborBar.overlay(child: unsavedBar), child: form)
HarborFrame.of(context).hideBar(HarborEdge.top, mode: HarborYield.dark) // returns a claim; release() it

HarborFrame.of(context) is the nearest frame's handle, as Scaffold.of is the nearest ScaffoldState. It throws a FlutterError when there is no frame above the context, in release builds too, as Scaffold.of does; HarborFrame.maybeOf returns null instead. The frame makes its handle and runs its lifecycle, so the handle carries only what content asks of it: claims, attached bars, page covers and the room. Claims are counted and go to the nearest frame that has a bar on that edge. A HarborHideBar takes its claim while it is built, after its frame, so the bar hides in the next frame drawn; a claim taken with hideBar in the tap that opens a mode lands in the next frame drawn, and a page that opens without the bar builds it with state: HarborBarState.withdrawn. An attached bar joins the frame's bars on the next frame drawn; one added by hand with attachBar returns a HarborAttachedBarHandle to update or remove it by.

Floats, popovers and toasts #

HarborFrame(
  floats: [
    HarborFloat(placement: const HarborAligned(alignment: AlignmentDirectional.bottomEnd), child: fab),
    HarborFloat(placement: HarborAnchored(launchAnchor, side: HarborAnchorSide.above, overlap: 6), child: bubble),
    HarborFloat(modal: true, onDismiss: closeQuickActions, child: quickActions), // a barrier over the page
  ],
  bottom: [HarborBar.solid(child: InboxTabs(launch: HarborAnchorPoint(anchor: launchAnchor, child: launchButton)))],
  body: ...,
)

HarborToasts.show(context, slot: HarborToastSlot.low, builder: (_) => Toast('Saved'));
HarborToasts.show(context, placement: const HarborAligned(alignment: Alignment(0, -0.8)), builder: (_) => Toast('Saved'));
HarborToasts.show(context, placement: HarborAnchored(copyAnchor, side: HarborAnchorSide.above), builder: (_) => Toast('Copied'));
final undo = HarborToasts.show(context, persist: true, builder: (_) => UndoToast(onUndo: restore));
final HarborToastClosedReason why = await undo.closed;    // hide, dismiss, timeout or remove
HarborToasts.show(
  context,
  transitionBuilder: (context, animation, child) => SlideTransition(
    position: Tween(begin: const Offset(0, 1), end: Offset.zero).animate(animation),
    child: child,
  ),
  builder: (_) => Toast('Saved'),
);

Floats sit in the room: the rectangle no system edge, bar or keyboard covers. Where in it is the float's placement, a HarborPlacement, which toasts and popovers take too, so a placement means the same everywhere:

  • HarborAligned(alignment:, margin:) puts it at a point of the room, margin in from its edges: bottom centre and 16 by default.
  • HarborAnchored(anchor, side:, gap:, overlap:, crossAlignment:, crossOffset:, flips:, margin:) puts it by an anchor: below it, 8 away, centred on it and 8 in from the room's edges by default. overlap reaches back over the anchor, as a speech bubble's tail sits over its button.

An anchored float 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. 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. A float and a toast keep their side; a popover flips to the other when its side has no room, unless its placement says flips: false. While its anchor is not in the tree, an anchored float 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 drawn, 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 drawn, as two leaders on one LayerLink are. A placement's alignment and margin take directional values, so AlignmentDirectional.bottomEnd puts a button where a right-to-left reader expects it. On a dual-screen device a float keeps to one screen and keeps its margin off the hinge, as it does off the room's edges. A modal float is modal: a barrier (clear unless you give it a barrierColor) keeps taps off the page and its bars and tells screen readers to leave them alone, a tap beside the float, back or Escape calls its onDismiss, and the floats 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 float, unless focus is on a page of a Navigator nested in the frame's body, whose route answers Escape before the frame does. A toast is shown at a slot (top, high, middle, low) or by a placement: at an exact point (HarborAligned), placed as a float there would be. An AlignmentDirectional follows the reading direction of the page that showed 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 hidden toast stays at least 300 ms, so its own exit can run. A toast goes to the page on top (a sheet over a page over the root), so a low toast clears that sheet's footer, and it also stays clear of the bars of the frame it was shown from (a tab's own header). If its frame leaves, the toast moves to the page now on top.

A toast shown with a HarborAnchored placement sits by a HarborAnchorPoint instead, as an anchored float does: on its side (below by default), gap away (8), lined up by its crossAlignment (centred), and kept in the room: "Copied" by the button that copied. Show it from the page that holds the anchor: it is shown by that page, not the one on top. Toasts at one anchor and side take turns. While the anchor is out of the tree the toast 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 page now on top: it is hidden, and closed reports remove.

Toasts shown at the same slot or alignment of one page 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 toast that is up, hide() it; one hidden while it waits leaves without being shown. Toasts at different slots show together. queue: HarborToastQueue.replace takes the turn at once instead: the toast goes first and the one up leaves, closed with HarborToastClosedReason.replaced, for an Undo whose window is tied to a pending action. HarborFlarePolicy(queue:, child:) (its 0.8 name, kept until it folds into HarborDefaultsScope) sets that for a subtree, such as a remote-driven TV screen where a newer "Saved" should replace the one up rather than arrive late. closed completes once a toast has left, with why. entry.phase (waiting, shown, closed) and entry.wasShown say where it is synchronously, before closed completes: a toast hidden while it waits is closed by the time hide() returns, and wasShown is false, so an analytics count or an onDismiss can answer on the spot.

A toast 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 hidden, as SnackBar(persist:) does. Give it to a toast 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 until you release() the HarborToastHold it returns: call it from the toast's own Listener when a finger goes down, or when focus moves in; HarborToastEntry.maybeOf(context) inside the toast's builder is its own entry. harbor never holds a toast by itself. Holds are counted, the time starts over at the last release, and entry.held says whether one is on. countsEntrance: true counts the duration from the start of the entrance, so a 4 s toast is on screen 4 s, and setting entry.duration after it is shown starts the time over with the new one (longer once a screen reader turns on, say). countsWhileCovered: true keeps the time running while another page covers the toast's, so a "Saved" toast is gone when the user comes back; a hold still stops it. A toast with neither a slot nor an alignment goes to HarborToastSlot.high, and target: HarborToastTarget.root sends it to the outermost frame, clear of the system edges alone.

A toast keeps 16 in from the room's edges (8 by an anchor). Its placement's margin sets that, and HarborAligned(alignment: HarborToastSlot.low.alignment, margin: EdgeInsets.zero) gives a bar the width of the screen, a fixed SnackBar's look, still above the bars and off the system edges. reachesSystemEdges: true, for a toast at a slot or an alignment, lets it run over the system edges it is against (under the home indicator at the bottom, the status bar at the top, the side insets always), still off the bars and the keyboard, and hands its content the system edges it covers as MediaQuery.padding to keep its text clear: SnackBarBehavior.fixed's look.

An anchored toast never flips, so its transition knows its side: HarborToastEntry.maybeOf(context) in a transitionBuilder gives its anchor and side, and HarborAnchorClip(anchor:, side:, child:) clips at the anchor's edge, for a toast that slides out from under the field it hangs from.

A toast shown with no frame above it (a widget test that pumps a bare MaterialApp, a preview, a screen not yet built from a frame) still shows: it goes to the nearest Overlay, at its slot and clear of MediaQuery.padding and viewInsets. Without a HarborRoot, the outermost frame of the page is its page: a toast shown from a frame 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 toast's timers stop when it is hidden or when nothing is left to show it, so a test that ends with one up has no timer pending.

Toasts were called signals through 0.3.0 and flares through 0.8; both sets of old names still work, deprecated, until 1.0.

Through 0.6, floats, popovers and toasts took their placement as loose parameters (HarborBuoy(alignment:, margin:), HarborBuoy.anchored(anchor:, side:, gap:, ...), HarborPortalBuoy(side:, ...), HarborFlares.raise(alignment:, anchor:, ...)), and the sides and cross alignments were HarborBuoySide and HarborBuoyCrossAlignment. Those still work, deprecated, with the defaults they had, and go at 1.0; dart fix --apply rewrites them to placements.

final menu = OverlayPortalController();

HarborPopover(                          // in a list row, anywhere below a frame
  controller: menu,
  placement: const HarborAnchored(        // by its child: no anchor of its own
    null,
    side: HarborAnchorSide.below,
    crossAlignment: HarborCrossAlignment.start, // under the row's leading edge
  ),
  onDismiss: menu.hide,                   // a tap outside, Escape or back
  consumeOutsideTaps: true,               // and that tap presses nothing else
  popoverBuilder: (context) => const RowMenu(),
  child: GestureDetector(onTap: menu.toggle, child: row),
)

A popover is an anchored float opened from where it is used rather than listed in HarborFrame.floats: a menu from a list row, a popover from a button in another package. It is an OverlayPortal, so it builds with the row's themes and sits in the nearest Overlay, placed in the room of the frame around the row by its placement, anchored to its child when the placement has no anchor. Until that anchor is in the tree, it is not shown, takes no taps and is not read out. Given no placement it sits above its child, as it did before placements; HarborAnchored's own side is below. When its side has no room, it flips to the other side of the anchor (unless flips: false), 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 room. HarborPopover.sideOf(context) in the popover is the side it landed on, so a popover can point its arrow at the anchor after a flip. The popover is placed as it paints, so it hears of a flip on the next frame drawn. To have it open in the frame drawn as it is built (a menu opened by building it), call controller.show() in your widget's initState: an OverlayPortalController keeps a show() made before its portal is attached, and the portal shows as it attaches.

With an onDismiss, a popover closes as a MenuAnchor does: the popover 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 float, a tap on the barrier while the popover is open calls both onDismisses, and in a dialog it calls the popover's and closes the dialog, as it does with a MenuAnchor open in a dialog.

A popover 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 popover is a focus scope of its own that becomes the first focus of the scope around its child (the page's, or a modal float'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 popover. It needs an onDismiss, so Escape from inside it can close it; that Escape closes the popover before a modal float it was opened from.

Sheets and dialogs #

showHarborSheet(context, builder: (_) => HarborSheet(header: title, footer: actions, body: form));
showHarborSheet(context, builder: (_) => HarborSheet.draggable(
  header: title,
  extent: const HarborSheetExtent(rest: 0.5, max: 0.88, min: 0.25), // the defaults
  builder: (context, controller) => HarborScrollView(controller: controller, slivers: [...]),
));
showHarborSheet(context, coversPage: true, barrier: HarborSheetBarrier.none, builder: ...);
showHarborDialog(context, inheritRoom: true, builder: ...);

A sheet starts a page of its own. It takes the themes and text style of the page that opened it, and stops short of the status bar. Its header is an overlay bar with a fade (on a draggable sheet, dragging the header drags the sheet), and its footer is a solid bar that rides the keyboard and keeps 16 off the edge. It keeps the home indicator in its system edges, 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 sheet opened with coversPage: true 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 drawn as the sheet, as it slides in and out and as it is dragged.

showHarborSheet(reachesTop: 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(clearsTopSystemEdge: false) (on HarborSheet.draggable too): the sheet keeps the height reachesTop gave it, and the top system edge is handled for its content, which reads zero there. Toasts and floats in the sheet still keep clear of the status bar. Without reachesTop it changes nothing: the sheet stops short of the status bar and is handed no top system edge 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 system edges on the left and right and marks them handled, so nothing beneath clears them 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 system edges in MediaQuery.padding, and so is the body, which clears them only where it keeps clear. 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(bodyClearsKeyboard: false) leaves the keyboard to the body, as HarborFrame(bodyClearsKeyboard: 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 footer that rides the keyboard still rides up on it, and maxExtentFraction (0.9 of the space above the keyboard by default), 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 toasts and floats keep to the screen that holds them, never across the hinge. A flat fold, which has no width, may still be spanned.

Which hinges and folds they keep off is a policy, HarborHinge, modelled on Material 3's HingePolicy:

HarborHinge Keeps off
avoidSeparating (default) A hinge with width, and a fold that is half open: what separates the screen
avoidOccluding Only a hinge with width, which hides content; a half-open fold is still one screen
always Every hinge and fold, a flat one too
never Nothing: the inner screen is one wide, unbroken screen, for a canvas or a map

Set the app's default on HarborRoot(hinge:), a page's on HarborFrame(hinge:), and one overlay's on showHarborSheet(hinge:), showHarborDialog(hinge:), HarborToasts.show(hinge:) or HarborFloat(hinge:); the nearest wins, and a toast takes the policy of the page that showed it. A popover (a row's menu) keeps to the screen that holds its anchor, as a Material menu keeps to its button's, and flips within it; HarborPopover(hinge:) sets its own. A sheet a hinge keeps to one screen can fill that screen, top to bottom, with showHarborSheet(fillsScreenAtHinge: true), as Microsoft suggests for a partial overlay on a dual-screen device. It changes only where harbor puts things: MediaQuery.displayFeatures stays as the platform reports it, so a two-pane layout or a video player beneath still sees the hinge. anchorPoint: picks the side.

Bars keep off a hinge the page's policy keeps off, as both Microsoft and Google ask: a header or a tab bar across a vertical hinge keeps to the screen where reading starts (the left one, or the right one right to left), and a rail down a horizontal hinge keeps to the top screen. The body still runs across, so a page is one canvas; split it into panes for two. Only a frame as big as the screen reads the hinge. HarborFrame(hinge: HarborHinge.never) lays bars across it, and the page's overlays too; HarborFrame(barHinge: HarborHinge.never) lays only the bars across it, so its sheets, dialogs, toasts and menus still keep off.

HarborPanes(start:, end:) is two panes, modelled on Windows' TwoPaneView: either side of a hinge the policy keeps off (side by side for a vertical one, start on top for a horizontal one), side by side when the width reaches minWideWidth (840 by default, Material's expanded width), one above the other when the height reaches minTallHeight (off by default), and otherwise priority's pane alone. Each pane is built as a screen of its own, with its size, the system edges only on its outer edges and only the display features inside it, so each can be a HarborFrame with bars of its own: list and detail, two pages of a book, a canvas and its tools. HarborPanes.modeOf(context) tells a list whether its detail is on screen, so a tap can fill the end pane or open a page.

HarborPanes(
  start: HarborFrame(top: [HarborBar.overlay(child: header)], body: list),
  end: HarborFrame(body: detail),
  priority: HarborPanePriority.start, // the list, on a phone
)

HarborPosture.of(context) is how a foldable is held: flat, tabletop (half open with the fold across the screen) or book (half open with the fold down the middle). On a table, a HarborPanes puts its start pane above the fold and its end pane below, so a video player sits above its controls, as Android's tabletop guidance has it; in a book, the two panes are its pages.

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), and it leaves when its page is replaced or removed. In a page built from a frame, the page draws it, just above itself: a dialog, a modal sheet or a menu opened over the page sits over the sheet too, which stays in view behind its barrier and out of reach, as a Scaffold's persistent bottom sheet does. In a page with no frame it has nothing to be drawn by, so it hides while anything is over the page. Escape closes it as back does, from focus in the sheet or in a frame 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 float, a popover 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 float, popover or barrier-less sheet
      return;
    }
    tabs.back();
  },
  child: HarborFrame(...),
)

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. close() and remove() do nothing once the sheet has left, however it went, so a widget can close the sheet it opened from its own dispose without asking first. 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 inheritRoom: true, its room, both read as the route is pushed rather than when it is made. Inheriting the room, the dialog treats the opening page's bars as system edges, so a menu stays between that page's header and its composer.

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 edge effect 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. A fling down faster than 1200 (or closeFlingVelocity, if higher) closes it from any 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.

Keeping in sight #

HarborKeepInSight(onObscured: (covered) => titleOpacity.value = covered, child: heroTitle); // covered: 0 to 1
HarborKeepInSight(keepInSight: true, child: field);       // re-reveals as the keyboard opens or focus moves in
HarborKeepInSightRegion(child: canvas)                    // + HarborKeepInSight(lift: true, clearance: 80)
HarborKeepInSight.reveal(context, clearance: 24);

A focused field reveals its own caret, as EditableText does. A HarborKeepInSight with keepInSight: true reveals all of itself: when the keyboard opens, 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 widgets 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.

Accessibility #

What harbor hides is hidden from everyone: a dark or withdrawn bar is skipped by keyboard focus and by screen readers, not only by taps, and a float whose anchor is not in the tree is not read out. Toasts are live regions, so screen readers announce them, with a dismiss action that hides them, as a SnackBar is. A toast whose widget is already its own live region (a SnackBar-like widget from your design library) is shown with liveRegion: false, so harbor adds no second, unlabelled one around it. A toast with a button is shown with persist: true, as a SnackBar with an action persists, so it is still there when a screen reader reaches it. Sheets and toasts keep the themes of the page they came from, and so do dialogs. With reduced motion (MediaQuery.disableAnimations) bars, toasts, sheets and dialogs appear and leave without moving, and keep-in-sight reveals and lifts jump into place. On iOS a tap on the status bar scrolls a page built from a frame 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.

TV #

MaterialApp(
  builder: (context, child) => HarborScaledScreen(
    referenceSize: const Size(1200, 675),
    systemEdges: const HarborSystemEdges.titleSafe(HarborTitleSafe.fraction(0.05)),
    child: HarborRoot(child: child!),
  ),
);

A scaled screen lays out on a reference screen and scales to fit, re-basing the real insets into its coordinates. Title-safe is one of the system edges, so bars take it and HarborClear keeps clear of it like any other inset. A rail is a start bar with a restingExtent, and pages choose HarborFollow.live (move with it) or .resting (let it open over them).

The inspector #

HarborInspectorOverlay(child:) (around HarborRoot in MaterialApp.builder, or anywhere below it) draws every bar's space, each frame's room and the keyboard. Each bar 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. HarborInspector.snapshot(context) returns the same as data, and HarborInspector.nearest(context) the nearest frame alone: its frame, body, room and bars in global coordinates, the way a test or a tool reads a frame without reaching into it. In debug and profile builds the ext.harbor.inspector VM-service extension serves it as JSON, for tools that drive the app. Its 0.8 name, ext.harbor.chart, is still served, and the JSON still carries the 0.8 keys (clearWater, docks, tide) beside the new ones, until 1.0.

Tooling that runs outside the widget tree (a debug panel, a logger, an automation driver) reads HarborInspector.snapshotAll(): every frame of every live root, with no context. A root mounted inside another frame (a phone drawn in a page, a preview) is listed too, and each of its entries says isolated: true; ext.harbor.inspector leaves those out unless it is called with isolated=true. A root is taken off the inspector when it is disposed.

To hear frames come and go, as a NavigatorObserver hears routes, give the root a HarborFrameObserver:

class HarborLog extends HarborFrameObserver {
  @override
  void didJoin(HarborController frame) => debugPrint('joined ${frame.debugLabel}');
  @override
  void didLeave(HarborController frame) => debugPrint('left ${frame.debugLabel}');
}

HarborRoot(observers: [HarborLog()], child: child!)
HarborFrame.of(context).registry.addObserver(log); // or on a registry you already have; removeObserver(log)

A frame joins as it is built, in the middle of a frame drawn, so both events arrive after that frame drawn, in order: by then the frame has laid out and the inspector can read it. A frame that joins and leaves within one frame drawn is reported to no one. The layout records behind the inspector stay internal; read a frame through HarborInspector.snapshotOf(frame.registry) or HarborInspector.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 HarborFrame lists its bars and floats, and a bar reads as HarborBar.overlay(keyboard: rides, debugLabel: "composer"). The values (HarborBar, HarborFloat, HarborEdgeEffect, HarborSystemEdges, 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: HarborSystemEdgeFeature.hinge (sheets, dialogs, toasts and floats keep off a hinge through MediaQuery.displayFeatures, not through the system edges) and HarborController.isPage, which records whether the controller's frame was built as a page (HarborFrame.page, or isPage: true); toasts find their page by route instead.

Trials #

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.pumpHarborTrial(app, device: HarborTrialDevice.androidThreeButton);
  await trial.showKeyboard();
  expect(tester.getRect(find.byType(Composer)).bottom, trial.keyboardTop);
});

showKeyboard() pumps 600 ms, long enough for the frame to follow; showKeyboard(pumpFor: Duration.zero) stops at the first frame drawn after the keyboard arrives, and settle: true pumps until nothing is animating. hideKeyboard() takes the same.

Devices: iPhone17, iPhoneSE, androidThreeButton, androidGesture, iPhone17Landscape, androidThreeButtonLandscape, androidGestureLandscape, 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. pumpHarborTrial(textScaleFactor:) grows the system text size, so bars are measured at the size their text grew to. trial.roomAround(finder) returns the room around a widget, and isInRoom asserts against it, so a test checks where something sits relative to everything in the way, not against a number: expect(find.byType(Menu), isInRoom(trial.roomAround(find.byType(Menu)))). trial.barsAround(finder) lists the bars of the frame around a widget. Both read HarborInspector.nearest, so a test of your own can too.

package:harbor/testing.dart, where 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 inspector in its Harbor Office to see the layers.

This repository pins Flutter with fvm, hence fvm flutter below; plain flutter works too. 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 harbour thing it illustrates, and each runs a real harbor page on the phone: sheets, dialogs and toasts open on the phone's own navigator as the narrator names them. Narration: the Kokoro-82M voice am_liam, generated on device by Kass. In this release the videos still narrate the 0.8 harbour words (pier, quay, tide and the rest); they are re-voiced in the new names later.

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, titled by its name in code, with a harbour drawing to illustrate it (a quay, like a solid bar, is where the body stops) and a pretend phone to try it on: its own status bar, home indicator and keyboard, a gauge to drag the keyboard in and out, the class's options, live readings of MediaQuery and the room data, 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
2.38k
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