fluid_glass 0.1.24
fluid_glass: ^0.1.24 copied to clipboard
Liquid Glass backdrop effects for Flutter, with refraction, chromatic dispersion, blur, highlights and continuous-corner shapes.
Changelog #
0.1.24 #
Changed #
- A press travels across a navigation bar, and so does the tap. Each island used to run its own slop-free tap: press the back button, slide onto the title, lift — and the light had crossed the body while nothing fired, because only the island that saw the down could. The bar now owns the one finger: the island under it lights up and leans, sliding onto a neighbour moves the light and the press to that neighbour, and lifting fires whichever island the finger is on by then. Lifting in a gap fires nothing. The islands stay opaque hit targets, so a tap on one still never reaches the content it is drawn over. A body of glass that carries a press from one island to the next has to carry the commit with it, or the light is lying about what the finger can do.
ProgressiveBluris rebuilt as a stack of the engine's own Gaussian blurs rather than one shader with a per-fragment radius. That shader sampled a fixed number of taps whatever the radius: a 24-pixel radius on a 4× display is a sigma of ~56 device pixels, 25 taps across it are 6 pixels apart, and the result was not a blur but the content copied several times at small offsets — a ghost, on every device where the effect mattered. Now eachbandsband adds just enough sigma to reach a geometrically growing target, every band is the engine's downsampled Gaussian (which never undersamples), and a one-sample shader inside each band's chain fades its input along a ramp before the blur so the bands slope into each other instead of stepping. A third of the samples, no aliasing, and a hard-edged fallback without runtime shaders instead of a uniform slab.- An island answers a finger whether or not it does anything. The title island
took a press only when
onTitlePressedmade it a button, so a bar whose two ends squashed and glowed under a finger had a dead middle — which reads as a bug rather than as restraint. It now deforms and carries the light in every case; with no callback nothing is claimed and nothing fires.
Fixed #
LiquidNavigationBarno longer paints a black slab when handed a fully transparentscrimColor. The scrim ramp was built withwithValues(alpha: 0.94)on the colour as given, andColors.transparentis black at alpha 0 — so an app that had moved the scrim elsewhere (a progressive blur under the bar) got 94% black darkening towards the islands' bottom edge, and nothing threw. The ramp's alphas now scale the caller's alpha, and zero draws nothing.- A fused body and a
ProgressiveBlurboth measure again after a page transition. Both bail out oflocalToGlobalwhile an ancestor is still awaiting layout — the route's transform on the frame a page first appears — and neither had a reason to try again once the page settled: the body kept the box-only geometry it fell back to, which on a navigation bar showed as the islands vanishing after every push while a cold start on the same page drew them. Each now asks for one more go on the next frame until the measurement succeeds. - A navigation bar's title island can grow past
titleMinWidth. The island's rectangle was computed from the width its content reported, and the content reported the width it had been given — a fixed point: an island handed 144 laid out in 144, reported 144, and was handed 144 again. A long title was ellipsised to the minimum for ever, and atitleMinWidthof zero collapsed the island out of existence entirely. It now reports the width its content wants, which the bar still clamps to the room between the side actions. - A fused body no longer throws while a page transition is starting.
LiquidFusionworks out how much room it has on screen from inside a layout callback, and it askedlocalToGlobalfor where it sits. That walks the ancestor chain collecting paint transforms, and a transform that scales or translates about its own centre has to read its own size to answer — which assertshasSizewhile it is still awaiting layout, exactly what a route's transform is on the frame its page first appears. Where the body sits is not knowable on such a frame, so it now falls back to its own box and loses only the headroom outside it, for one frame, mid-transition. The assert reached any app whose chrome is a fused body: navigating back threw a framework error every time.
Added #
-
ProgressiveBlur— a blur that grows from nothing at one edge tomaxBlurRadiusat the other, so chrome dissolves into the content under it instead of ending on a line: the effect Apple's own chrome gets from its privatevariableBlurfilter, with the samestartOffsetthat holds the blurred end at full radius before the ramp begins. Built as a stack of the engine's Gaussian blurs with shader-faded seams (see Changed), so it never undersamples and costs a handful of downsampled blur passes.Neither construction people reach for first can do this, and neither throws. Fading a full-strength
BackdropFilterbehind a gradientShaderMaskfades that layer's opacity, so the middle of the ramp shows half a blurred image over half a sharp one — a double exposure that separates further the harder the blur. And aBackdropFilternested in aShaderMaskfilters that mask's own empty save layer rather than the scene, which draws as a dark rectangle. Mixing only ever happens here between two images blurred by similar amounts, which is what a double exposure cannot survive. -
LiquidInteractionGroupandLiquidInteraction.onTap— one finger, many controls. EveryLiquidInteractionunder the group is a member: the one under the finger lights up and follows it, sliding onto a neighbour moves the light and the press, and lifting fires theonTapof whichever member the finger is on by then. A scroll of the list the members sit in cancels the press, since the group observes the pointer without claiming it. Outside a group,onTapmakes aLiquidInteractiona slop-free control of its own, firing on a lift inside its bounds however far the finger wandered — the glow having stayed and followed the whole way, unlike a Material ink, which dies at the first 18 pixels of travel. -
LiquidNavigationBar.blurRadius— how hard the chrome blurs what it sits over, which until now was the fixedchromeBlurof 8. It is the dial to reach for first when chrome does not read against a particular app's content: legibility comes from the blur under the glass, not the paint over it, so widening the sample costs a wider read where raising the tint's alpha costs the glass itself. A bar over running text wants more of it than a bar over photographs does. -
LiquidNavigationBar.titleAlignmentandtitlePadding. A title that names a page belongs in the middle of its island; an identity does not. An avatar and a name read as one object starting at the same edge, and dropping the leading inset to zero lets an avatar of exactly the island's height share its circle with the island's own rounded cap, rather than floating inside it. -
LiquidNavigationBar.isInteractiveandLiquidNavigationAction.isInteractive, the flagLiquidButtonalready had. An island's press was unconditional, so an app honouring a platform's reduced-motion setting could turn off the deformation everywhere except its own chrome. False keeps the tap and drops the squash, the lean and the glow the fused body carries.
0.1.23 #
Added #
LiquidFusion— several shapes drawn as one body of glass. The silhouette is a field rather than an outline: each shape contributes a signed distance, the shader takes their smooth minimum, and the result bulges towards its neighbour before the two touch, grows a concave neck, and swallows it.smoothingis how far apart that reaching starts, and its default of 10 is measured — a fused field closes over a gap of about half the smoothing, which leaves a row of controls eight apart reading as a row until something moves. It is also one glass element for the group: one capture of the backdrop and one shader pass where each shape apart pays for its own.LiquidBlobis a shape,LiquidFusionPressis the press the body is under, and the newshaders/fusion.fragdraws all of it.LiquidNavigationBarandLiquidNavigationAction— top chrome as three islands of glass over a scrim of the page colour, drawn as one fused body. Islands only cover what they hold, so content keeps scrolling visibly between them; the scrim is opaque behind the status bar and gone below them, so the gaps never read as holes. The geometry is computed from the bar's own layout rather than measured off the widgets in it, which is what keeps the glass from lagging the icons on it by a frame.LiquidInteraction— the finger-following press feedback the liquid components use, for a control that is not made of glass. It observes pointers without claiming their gestures.activePositionlets a parent that owns the gesture say where the finger is, which is what a menu does for its rows.LiquidInkHighlight— a splash factory, so every Material ink control in an app gets the same soft press glow without being wrapped.InteractiveHighlight.glowPosition, for something other than the element itself drawing its glow.
Changed #
- A menu now tracks a drag across its rows: press the anchor and slide, and the row under the finger is the one that commits on release. Releasing outside a row cancels, and a cancelled gesture never commits.
- A segmented control's drag can start on any segment, and a cancelled drag restores the selection it started from rather than leaving the thumb where the finger left it.
Fixed #
- A menu row's press glow no longer sits in the middle of the row whatever the finger is doing. The row is activated by the menu, not by its own pointer, so it had no pointer to follow; the menu now hands it the one it is already tracking.
0.1.22 #
Fixed #
- A pressed toggle no longer exposes rectangular capture edges inside its
glass knob.
WrappedBackdropnow inverse-maps the requested sampling bounds through its canvas transform beforeLayerBackdropcrops the source. This preserves Kyant's original track scaling and lens parameters, including the collapsed track at rest, and also works for nested wrappers.
0.1.21 #
Added #
LayerBackdrop.extendEdges— whether the capture's outermost row and column are stretched outwards when an effect reads past them. True, the default, is right for a source that fills its bounds: a page, a wallpaper, a feed, where a blur reading past the capture would otherwise mix in transparency and leave a dark fringe. Pass false for a source whose content is not its bounds.
Fixed #
- The toggle's knob no longer shows a square edge on a shape that has none. Its
track is a capsule inside a 64x28 box, so the capture's outermost column
holds one coloured pixel at the capsule's widest point with transparency
above and below it; stretching that column outwards drew the pixel as a
straight band, and the knob — a round lens looking straight into it — showed
the band. The track now captures with
extendEdges: false. - Reverts 0.1.20's change to the same knob, which addressed the symptom from the wrong end.
0.1.20 #
Fixed #
- The toggle's knob no longer shows a square through a round piece of glass. It refracts a copy of its own track, and that copy was squashed to 0.75 at full press — which pulls the copy's straight ends into the knob's view. The slider's thumb has always drawn its copy at full size for exactly this reason; the toggle now matches it.
0.1.19 #
Added #
LiquidSheet.plainandLiquidSheet.blurSigma— drops the glass and blurs with Flutter's ownBackdropFilterinstead, keeping the handle, title, rows and selection mark. The rim, lens and shadow are what make a small element read as a bead of glass; a half-screen sheet carrying rows of text needs a heavy tint for those rows to stay readable over whatever it covers, and behind such a tint there is nothing left for a lens to bend — the rim is then the only glass still visible, a bright seam along the top edge that reads as a misplaced highlight rather than as material.
Fixed #
- The bottom tabs' selection pill no longer fills with white at full press. The captured accent copy painted the press glow a third time — after the bar's own and the pill's own — and that copy is what the lens magnifies, so the glow was blown up with it and the blurred page the rest of the bar shows vanished from under the pill.
- The toggle's knob and the slider's thumb no longer square off their sampling. Both refract a scaled copy of their own track, which does not fill the element's layer; a clamped blur that starts the chain is given no extra room — the right call when the layer is full of the thing being blurred, wrong here, because it clamps against the layer's straight edges. The bottom-tabs pill already raised its padding for the same reason.
0.1.18 #
Added #
LiquidSegmentedControl.thumbColor— the wash painted over the thumb's refraction. It has always been white, which reads as raised on the grey track iOS gives these controls; on a palette whose panel is already near white, the thumb had nothing to separate it from the track and the control said nothing about which segment was selected — the one thing it exists to say. Null keeps the old white exactly, down to the alphas, so nothing that does not pass the parameter changes.LiquidSlider.divisions— snaps the reported value to equal steps while the thumb keeps sliding continuously under the finger. A device volume of 0–16 on a finger-width track resolves to about three pixels a step, and a continuous slider makes an exact setting a matter of luck.LiquidSlider.onChangeEnd— fires once when the interaction ends (the finger lifts after a drag, or a tap on the track lands).onValueChangedfires continuously while dragging, so a caller that debounces its writes had no way to know the user had stopped, and had to either wait out the debounce window after the finger was already gone or write on every frame.
0.1.17 #
Fixed #
- A live backdrop no longer freezes while an Android overscroll springs back.
On every Impeller backend Flutter's
StretchEffectstretches withImageFilter.shader, and a fragment shader reads the size of the texture the pass rasterised it into. Capturing one strip of the source — which is what "capture only what is read" has done since 0.1.14 — evaluated that shader against the strip's extent, so a bar pinned over a list showed a page stretched by some other amount for the whole spring-back. A shader filter found in the captured layer tree now takes the whole source for that generation; a blur, a matrix or a dilate still take a strip, since Impeller can invert those to work out what they read. The cost is paid only on the frames such a filter is actually on screen, which for an overscroll is the overscroll.flutter_testruns on Skia, whereStretchEffectfalls back to a plainTransformandImageFilter.shadercannot be constructed at all, which is why every widget test passed throughout; the end-to-end measurement isexample/lib/probe_stretch_capture.dart. LiquidBottomTabs' selection pill no longer wears a ring of half-blurred, darkened backdrop. The pill declared onlylens(...)as its effect, andlenslowers the sampled margin rather than raising it, so the element sampled exactly its own bounds — while its backdrop blurs the page by 8 logical pixels inside that layer. The blur ran out of pixels at the border. It is most visible at rest, when the pill's own refraction is off and nothing covers for it. Measured over hard stripes on Impeller, the surviving unblurred contrast in the ring dropped from 69/255 to a flat 18, which is the capsule's own anti-aliased edge.
0.1.16 #
Added #
LiquidDialog.titleStyle,.messageStyleand.actionStyle, merged over the built-in type. An app whose design system owns its typeface can re-letter the dialog by passing afontFamilyalone: the fields left null keep the sizes and weights, so the layout does not move.
0.1.15 #
Added #
LiquidDialogandshowLiquidDialog: a centred modal panel — title, message or free-form content, and the actions that answer it. It defaults tonativeBackdrop, which is what a dialog usually wants: it sits over what it filters, so the compositor does the work with no capture, and the barrier's dim comes free. Pass aLayerBackdropfor the refraction instead — with the dim inside what that layer captures, or the dialog reads as a lit window over a darkened page. Actions never dismiss on their own: a dialog's actions do not all end it (one may open a policy to read first), soonPressedowns that. Up to two actions share a row, three or more stack, and the axis can be forced either way.LiquidMagnifier: a loupe. The magnification is where the glass samples from, not an effect over it — the backdrop is drawn through a scaled canvas before the lens runs, so the rim refracts already-magnified pixels instead of framing a picture-in-picture.focalOffsetsays what it looks at relative to where it sits, so a text loupe can float above the finger.LiquidAdaptivePanelandBackdropLuminance: glass that measures the average luminance of what it sits on and retunes its brightness, contrast and blur to stay legible over it, handing the caller the content colour that reading can carry. The sampler records one small thumbnail per sample rather than per frame, and reads it back on a cancellable timer.LiquidPanel.depthEffect, which blends the edge normal towards a radial one. Worth it on a large panel a reader looks straight at; too subtle to tell apart on a bar, so it stays off by default.
Changed #
- The catalog's dialog, magnifier and adaptive-luminance pages now use the
components above instead of hand-rolling them over
DrawBackdrop. The dialog page also shows the modal route, so both backdrops a dialog can have are on screen next to each other.
0.1.14 #
Performance #
-
Sampled glass records its pixels during composition rather than during paint. A glass element used to redraw its whole stack — capture, blur, lens, highlight — whenever anything told it its backdrop had changed, and it could only be told once the frame that changed it had been drawn, so it also ran a frame late. The element now keeps a composited layer that re-records only when what it samples actually differs: the source's capture generation, or where the element sits. Two things follow. A source that repaints behind a repaint boundary is picked up in the same frame instead of the next one, so
livenessis no longer what closes that gap — it is left for custom rendering state that changes without replacing a picture or a layer property, and for glass that sits inside a captured source. And glass that is merely marked dirty — by a sibling repainting, by an ancestor rebuilding — reuses the pixels it already has. -
Captures follow the regions consumers actually read, rather than one rectangle drawn around all of them. A header and a footer over the same source no longer force capturing everything between them: their requests stay disjoint, both for choosing what to capture and for deciding whether a change in the source is one the glass can see. Passing the region budget now takes the combined envelope of the outstanding requests instead of the whole source, and the choice is remembered so the next generation starts from one image.
-
Glass that moves or grows across its source latches to a single whole-source capture instead of paying a second
toImageSyncevery frame. The envelope predicted for a consumer comes from the previous generation's requests, so a moving consumer outruns it, misses, and captures again — every frame, for as long as the motion lasts. A miss that overlaps what is already held is now read as exactly that, while a miss disjoint from everything held still keeps its own small capture.
Fixed #
-
The layer fingerprint — what notices a source changing behind a repaint boundary — was blind to several kinds of change, and glass over them kept drawing a frozen backdrop: an animating
ImageFilter(which is whatImageFiltered, and aTransformgiven afilterQuality, become — Android's overscroll stretch is built from both), aShaderMask, aClipPathor a clip behaviour that changes, aCompositedTransformFollowermoving, and aBackdropFilterinside the source. ABackdropFilteralso reads pixels from outside its children's bounds, so while one is present a change is never dismissed for landing where no consumer reads. -
Leaves are compared in order, so two retained layers swapping stacking order counts as a change.
-
A change that lands outside every sampled region no longer leaves a wider capture behind it. Held images covering the changed area are retired, so a consumer that moves into that area later cannot draw stale pixels out of one.
-
The baked decoration cache rebuilt its image after a single repeated key once it had started thrashing, instead of waiting out the window it uses to decide the key has settled. A decoration alternating between two keys paid a
toImageSyncevery other frame rather than none.
0.1.13 #
Added #
LiquidPressDeformation: the displacement law the pressable components move by — the swell while an element is held, thetanh-bounded lean towards the finger, the stretch along the axis being pulled — as four plain numbers, with atransformmatrix for aTransformaligned to the centre.LiquidButtonandLiquidButtonGroupresolve it and write it onto their glass layer; it is public so that something which is not made of glass can move by exactly the same law rather than by a copy of it that drifts. Nothing about the glass components' motion changes: the internalapplyDragDeformationis now the one caller that writes those numbers onto aGlassLayer.
0.1.12 #
Performance #
LiquidBottomTabsno longer captures its accent-tinted tab copy on every frame the selection pill moves. The copy used to be a second glass element of its own inside aBackdropLayer, and since its lens and rim follow the press, the pill re-captured it — anOffsetLayer.toImageSync, a pipeline flush — on every frame of every tab switch. The pill's backdrop now draws the copy's glass itself: the same blur and lens, resolved for the whole bar's geometry but run in a save-layer the size of the pill's window onto it, so the lens still bends along the bar's edge exactly where it did. The accent tab row is still a captured subtree, but nothing in it moves any more — its press scale is applied where it is drawn — so it is captured once and read every frame. What a tab switch costs is now the panel and the pill, with no capture in between.- The bar's animated values — the pill's position, the panel's give under an over-drag, the tab press scale — are read during paint. Nothing in the bar is rebuilt or laid out per frame.
Added #
BackdropEffectScope.resolvetakeslayerRect: the window of the padded element the filters will actually run over.BackdropEffectGeometry.layerRectcarries it to shader effects, whoselayerSizeandoffsetthen describe the window rather than the whole layer.BackdropLayer.changeMargin: how far outside what the glass reads a repaint inside the source is still taken to reach it. A change is placed by the box of the render object that repainted, and a render object may paint past its box, so the default allows 64 logical pixels. An app whose widgets overflow less than that can lower it, and a card deck or a list row animating just above a glass bar then stops costing the bar a capture on every frame.BackdropDrawContext.quality: the tier the consuming element draws at, for aBackdropthat resolves effects of its own.InteractiveHighlight.paintOverlay, the press glow as a canvas call.LayerBackdrop.source, the attached source, for a backdrop that draws it in the source's own coordinates.
Changed #
LiquidBottomTabScalestill marks the accent copy of the tabs, but the scale it reports there is now always 1.0 and its notifier never fires: the copy is scaled at paint time inside the pill.
0.1.10 #
Three ways a live backdrop now costs less, none of which changes a pixel at
rest. Every capture is an OffsetLayer.toImageSync — a rasterisation of the
source that flushes the pipeline mid-frame and scales with the pixels it
covers — and all three are about taking fewer of them, or smaller ones, only
where nobody can see the difference.
Performance #
-
Glass elements reading overlapping strips share one capture a frame. Every region a consumer asks for is remembered for a generation, and a request is captured together with any region it overlapped last frame. A bottom tab bar and the accent copy its pill magnifies read the same strip through paddings that differ by a few pixels, so the first capture never quite contained the second request and every frame of a scroll cost two captures of almost the same strip; it costs one. Strips that do not overlap — a top bar and a bottom bar — are still captured separately, since one capture spanning both would cover more pixels than two.
-
A change that lands nowhere under any glass no longer costs a re-capture. The layer watch records where every leaf draws, in the source's own coordinates, and reports a change only if a leaf that is new, gone or altered lies within 64 logical pixels of something a consumer reads. A scroll notification is placed the same way, from the scrollable that sent it. A progress spinner, a marquee or a carousel at the top of a page used to keep the glass bar at the bottom re-capturing and repainting on every frame; for a bar over a page like that the idle cost is now nothing. Leaves are matched by signature — the picture's identity hashed with what every container above does to it — rather than by position, so a list item scrolling into view shifts nothing. A change whose placement cannot be known (under a
LeaderLayer, aFollowerLayeror a custom container) is still taken to be anywhere. -
BackdropLayer.motionPixelRatio: a second capture resolution used only while the source is in motion — re-captured on consecutive frames — with one full-resolution capture taken the frame after it stops. A one-off repaint never drops. The frames on which the capture is paid every frame are also the frames on which a softer capture cannot be seen, and glass that blurs what it samples hides the difference outright; at rest, where a soft capture would show, the capture is sharp. Leave it null for glass that shows the source unblurred and magnified while the source itself moves. -
RenderBackdropLayer.debugLastCapturePixelRatioanddebugIgnoredChangesjoindebugCaptureCount, for tests that pin what a frame costs.
Changed #
LayerBackdrop.invalidateSourcetakes an optionalwithinrender object, for a change confined to one subtree. Without it the change is taken to be anywhere, as before.
0.1.9 #
Added #
-
nativeBackdrop: a backdrop that is whatever the compositor has already painted beneath the element, so the whole chain becomes aBackdropFilterLayer— Flutter's ownBackdropFilter— with no capture at all. The right instrument for a surface that sits over what it filters, which is most modal chrome: a sheet, a dialog, a selection toolbar, a button on a collapsing header.It removes three things, not just cost. The capture: a full-screen
toImageSyncis a texture the size of the screen — 18 MB on a 1440x3168 phone — allocated and rasterised every time the surface opens. The staleness: a capture freezes at the moment it was taken, so anything moving behind the surface stops moving inside it. The dim bookkeeping: a capture has to have the modal barrier's dim painted into it by hand or the surface reads as a lit window over a dimmed page, while a compositor filter is above the barrier already.There is a fourth, which is what prompted it: a captured source can stop existing. A button on a collapsing app bar refracting the header image behind it draws nothing once the header collapses away — a transparent hole with the page scrolling through it.
nativeBackdropfilters whatever is behind at that moment, which is the header while it is there and the bar's own surface after, and is correct in both without the element knowing which.An element on it is pinned to
GlassQuality.plainwhatever the device could afford — there is no texture for the lens or the shaded rim to bend, so sampling would draw nothing.Backdrop.isCompositorOnlyis the new flag that says so, and it outranks an explicitDrawBackdrop.qualitypin, since pinning cannot conjure a texture either.
0.1.8 #
Changed #
LiquidSheet.childis no longer wrapped in a scroll view.itemsstill are — rows of a known height have an obvious overflow — but free-form content usually brings its own scrolling, and nesting two scrollables inside a sheet gets you a list that refuses to move. Wrap it yourself if it needs to scroll.
0.1.7 #
Added #
-
LiquidSheetandshowLiquidSheet: the half-screen sheet, rounded at the top two corners, with a grab handle, an optional title and rows that carry a detail line and a trailing check. On a phone this is the form a single choice out of several belongs in — rows tall enough to read, a title saying what is being chosen — and it was the one common iOS surface the package had no component for, so every app rebuilt it by hand onLiquidPanel.Three things it does that are easy to get wrong by hand: the framework's own bottom-sheet surface has to be made transparent so the glass panel is the only one, and its drag handle turned off with it — that handle paints in the surface that just became transparent, so it ends up floating outside the glass;
isScrollControlledhas to be set or the framework caps the sheet at 9/16 of the screen and simply cuts the rows off; and the bottom two corners must stay square, since a rounded corner against the screen edge shows a gap.The check is at the trailing edge, unlike
LiquidMenuItem's leading one: a sheet row is wide and its label is what is being read, so a mark at the start pushes every label out of alignment with the ones above it. Rows are mutually-exclusive selectables to a screen reader, labelled with the detail line folded in.surfaceColoraccepts an opaque colour, which drops the glass — what an app should pass when it could not capture a backdrop, so the sheet degrades without a second layout.
0.1.6 #
Added #
-
LiquidMenu.margin,LiquidMenu.rootOverlay, and a vertical counterpart to the horizontal edge avoidance the menu always had.sideis now the preferred side: a menu low on the screen asked to openbelowopensaboveinstead of hanging off the bottom, andmarginsays how close to the overlay's edges the panel may come — widen an edge to keep it clear of a safe area, or of a bar drawn over the overlay it lives in. When neither side fits (a panel taller than the overlay, which flipping cannot rescue) the preference is kept, so the result stays predictable.rootOverlayputs the panel in the rootOverlayrather than the nearest one. The nearest is often a nested navigator's, and anything drawn as that navigator's sibling — an app's own bottom bar — paints over it; a menu that has to cover such a bar belongs in the root overlay.
0.1.5 #
Fixed #
-
A glass element whose paint is harvested as somebody else's backdrop no longer takes the native
BackdropFilterLayerpath. That layer filters whatever is already beneath it, andOffsetLayer.toImageSyncrasterises the captured subtree on its own — nothing is beneath it there — so the element contributed only its plain draws to the picture the capture handed out, and every glass element sampling that capture showed the source straight through, unfiltered.LiquidBottomTabsis where this surfaced. The accent-tinted copy of the tabs it captures for the selection pill to magnify drew correctly on screen, so the pill was the only thing wrong: a crisp, unfiltered hole in an otherwise frosted bar. Only on the cheap tier, which is the one that uses the native path — andGlassDeviceTierputs every device with fewer than six processors there, including the stock Android emulator, so it was easy to hit and easy to misread as an integration mistake.The condition is read off the ancestor chain when the element attaches, not during paint:
alwaysNeedsCompositingand the backdrop subscription both consult the same decision outside paint, and a value that flipped mid-paint would leave them disagreeing with what was drawn. Re-parenting detaches and re-attaches a render object, so inserting or removing aBackdropLayerabove one is covered.test/bottom_tabs_pill_test.dartpins each tier in turn and measures the luminance spread over black-and-white stripes inside the pill against the same band under the panel; before the fix the pill's spread was ~4700 against the panel's 0.25.
0.1.4 #
Added #
LiquidGlassThemeandLiquidGlassColors, so the components can be drawn in an app's own palette. Every component used to inline iOS's values — the blue accent, the near-white container tint, the green of a switch — which made them unusable in an app whose brand colour is fixed: there was no parameter, and re-colouring from outside is not possible, since aColorFilteredover a glass element tints the refracted backdrop along with it. The defaults are bit-identical to what was inlined and are resolved off the enclosingTheme's brightness, so an app that supplies nothing sees no change. Override a field or two offLiquidGlassColors.forBrightness(...)withcopyWith; a per-elementsurfaceColorstill wins over the theme.LiquidButton.height,.paddingand.spacing. The box was hard-coded to 48 tall with 16 either side, so the component could not be the 40px circular action button that sits on a photo — a sizeLiquidSegmentedControlandLiquidButtonGroupalready exposed. A square button is a circle, since the shape is a capsule.
Changed #
LiquidButton.onPressedis nullable, and null disables the button: no gestures, no press deformation, no highlight, and no press animation left running. The appearance stays the caller's — dim the children — and the element is inert rather than absorbing, so a tap over a disabled button reaches what is behind it, as a disabled control does elsewhere in Flutter.
0.1.3 #
Added #
- The eight liquid-glass widgets that used to live only in the example app are
now part of the package:
LiquidPanel,LiquidButton,LiquidButtonGroup,LiquidMenu,LiquidBottomTabs,LiquidSegmentedControl,LiquidSliderandLiquidToggle. They were demo code with no importable path; now they are API, with their tests moved into the package's own suite. - The machinery behind them is exported as well, so a component of your own can
be built in the same idiom:
SpringValue,SpringOffset,springOfandTweenColor(the Flutter counterparts of Compose'sAnimatableandspring()),DampedDragAnimation,DragInspectorandInteractiveHighlight. - The press-highlight fragment program moved from the example into the package,
so
FluidGlass.ensureInitialized()now preloads it with the rest. GlassQuality, two settings for how much of the liquid-glass look an element draws:liquid(refraction and a shaded rim) andplain(a plain Gaussian blur behind the tint, with a flat rim, and no fragment shaders at all). Effects read the tier offBackdropEffectScopeand step aside themselves, so no component branches on it.GlassDeviceTier, which picks the tier from the device once, synchronously, before the first frame — so there is no warm-up at the wrong tier and the glass never changes appearance mid-session. It reads runtime shader support (decisive), whether the process is 32-bit, and the processor count; an unknown count or architecture is not held against the device.describe()says why.GlassDeviceTier.classifierreplaces that decision with the app's own, for a project that has real device information, andpinnedQualitybypasses it. Both take effect immediately.GlassQualityScopepins a tier for a subtree andDrawBackdrop.qualityfor one element. All of it is clamped by what the backend can draw: without runtime shaders nothing aboveplainis reachable.- Catalog: a Quality tiers & device screen showing both tiers side by side
with the classification and its evidence, and an App chrome over a live
feed screen — a header and tab bar pinned over a scrolling feed, which is
the expensive case the rest of the catalog does not cover: the backdrop
snapshot is invalidated and re-captured every frame of the scroll. It carries
a
BackdropLayer.pixelRatiocontrol to show what that lever buys. Plus a Live background screen for the two live cases that are not a scroll: an aurora repainting inside aRepaintBoundary, and a photo you pan and pinch, with pinned glass over both.
Changed #
-
GlassQuality.plainno longer samples the backdrop at all — it hands the effect chain to Flutter's ownBackdropFilter. Dropping the refraction was only ever half a fallback: the lens is a fragment pass over the element's own texture, while the capture is anOffsetLayer.toImageSyncof the whole source that flushes the pipeline mid-frame, and for a backdrop that changes every frame the capture is the cost. So the cheap tier stops sampling: when the backdrop is content already painted behind the element, the chain becomes oneBackdropFilterLayerand the engine filters what is behind in place.No capture, no pipeline stall, no texture held alive, and nothing to invalidate — the frozen-backdrop class of bug cannot occur on this path at all. The blur is the engine's own separable, downsampled Gaussian, which is the fastest one reachable from Dart; a hand-written blur would have to go through
ImageFilter.shader, a per-pixel fragment program with neither separability nor downsampling, and is exactly what this tier exists to avoid. Elements on this path also drop their subscription to theLayerBackdrop, so a source with no other consumer stops capturing itself entirely.It cannot replace the liquid tier: a fragment shader inside a backdrop filter is handed the whole screen rather than the element's texture, so the lens would have no geometry to anchor to. The tier that has given up the shaders is exactly the tier that can use it. It also steps aside for anything the compositor cannot do — a
CanvasBackdroporWrappedBackdrop, which the element has to draw itself, anonDrawBackdropthat transforms the drawing, or anexportedBackdropthat has to be handed back as a picture — and those keep sampling on every tier. Sibling glass inside aBackdropGroupshares one read of the backdrop rather than each taking its own.
Fixed #
-
A rectangular glass element bled its blur outside itself. The clip was skipped whenever the outline was a rectangle covering the element — true of the element, false of what gets drawn, since the backdrop goes into a layer inflated by the blur radius so the blur has pixels to reach for. Without the clip that layer smeared whatever was behind it for
radiuslogical pixels on every side. -
Blur was starved where its source ended. The capture covers the source and no more, so a blur reading past it mixed in transparent black — a dark fringe along every edge where glass met the end of its source, which for app chrome is the edge of the screen. The capture's outermost row and column are now extended outwards, as
TileMode.clampwould. -
A
LayerBackdropwhose source scrolled showed a frozen capture.RenderBackdropLayerinvalidated its snapshot, and told its consumers to repaint, only from inside its ownpaint— which assumes that a source repainting means an ancestor repaints. It does not:RenderViewport.isRepaintBoundaryis true, so a list scrolling inside aBackdropLayerrepaints without its ancestors repainting at all. Nothing marked the glass over it dirty either, so pinned chrome over a scrolling feed kept drawing a stale capture — the backdrop stood still while the content moved. Every other case in the catalog puts glass over a still wallpaper, where a stale capture is the correct answer, which is why it survived this long.BackdropLayernow watches for scroll notifications bubbling out of its own subtree and invalidates on them, and takes an optionallivenesslistenable for a source that changes behind some other repaint boundary — a video, aRepaintBoundary-wrapped animation. Invalidation is deliberately driven by those signals rather than by the frame counter: re-capturing every frame would be correct and would also undo the point of caching, so glass animating over a still source still costs no re-capture. Both halves are covered by tests. -
A background that repainted behind a repaint boundary of its own still froze. Scroll notifications cover a scrolling list; nothing covered a
RepaintBoundary-wrapped animation, a custom painter on its own ticker, a Rive or Lottie scene.markNeedsPaintstops at the nearest repaint boundary, so those repaint whileRenderBackdropLayersleeps through it, and the only way out was for the app to know aboutlivenessand pass it. The captured layers are now watched instead: a repaint replaces theui.Pictureof every layer it touches and a retained subtree keeps the same ones, which makes a walk of the layer tree an exact answer to "did anything in here repaint" — for the price of visiting a few dozen layers on frames that were happening anyway. It costs one frame of latency, solivenessis still worth passing when something already knows; it is no longer required.The watch is careful not to duplicate the mechanisms that already work: a frame a scroll notification, a
livenesstick or the source's own repaint already accounted for is not re-reported. Pinned glass over a scrolling feed still costs exactly one capture per glass strip per frame, which is pinned by a test that counts them. -
Glass over a source that was scaled, rotated or zoomed sampled the wrong pixels. The capture is taken in the source's own coordinates and was placed with the offset between the two origins, which is only the whole story while both sit under plain translations. Under an
InteractiveViewer, aFittedBoxor a page mid-transition it is not: the glass refracted a wrongly-scaled copy of what it covered. The full transform between consumer and source is used now, so glass over a pinched photo magnifies by exactly as much as the photo does. That also subsumes the element's ownlayerBlock, which used to need a second, separate correction — and it lets an element with alayerBlockask for the region it actually reads instead of forcing a whole-source capture. -
A source that only moved left the glass on stale coordinates. A page sliding in or a viewer being panned changes nothing inside the source, so its capture stays valid and nothing repaints; what changes is where each consumer has to read it. Glass insulated by a repaint boundary of its own never found out. The source's own placement is watched now, and consumers are told to re-place what they sample without the capture being thrown away.
-
Glass inside its own
BackdropLayerproduced a black or garbled backdrop and pinned the frame rate.BackdropLayer(child: everything)with the glass somewhere ineverythingis the natural thing to write and cannot work: the capture is taken while the source is halfway through painting, so its layer holds no finished picture yet, and the glass marking itself dirty marks the source dirty too — the two then repaint each other every frame, forever. It is now detected exactly (the source knows when it is inside its ownpaint), reported once with the composition that does work, and stopped rather than left spinning. -
A
BackdropLayerholding a texture or platform view — a video, a camera preview, a native map — now says so in debug. Capturing a layer tree does not include content the platform draws, so glass over one refracts a hole; that was silent before. -
Glass nested inside an element that exports its own backdrop and scales itself refracted a wrongly-sized copy of it.
PictureBackdropSourcerecords where it sits as a full transform now rather than as an offset. -
The cheap tier still ran a fragment program on every press.
GlassQuality.plainis defined as running none, and the lens and the rim's directional shading both honour that; the press glow under a finger did not. It was gated only on whether its program had loaded, so a device that gave up the refraction to keep its frame budget still paid a shader pass whenever aLiquidButton,LiquidBottomTabsorLiquidSegmentedControlwas touched. It now resolves the tier the same wayDrawBackdropdoes — element pin, thenGlassQualityScope, thenGlassDeviceTier, clamped by the backend — and falls back to the flat brighten it already had for backends without shaders.GlassQuality.hasShadersnames the contract, and a test asserts that no paint a plain-tier component makes carries a shader, pressed or not. -
A glass element that scaled and faded at the same time painted its child at full size for as long as its
alphawas below 1.RenderGlassTransformapplied theGlassLayermatrix straight to the canvas whenever the subtree did not otherwise need compositing, but the fade went throughPaintingContext.pushOpacity, which appends a layer to the enclosing container layer — and a layer never sees a canvas matrix. The child therefore snapped to full size the instant the fade began and snapped back when it ended. ALiquidMenublooming out of its anchor flashed twice per open/close because of it, at the two moments its alpha crossed 1.0. The transform is now promoted to a real layer whenever the fade needs one. -
Catalog: a
LiquidMenurow stayed live to taps while the panel was animating away. The panel stays mounted for the whole closing spring and the dismiss barrier steps aside as soon as the close starts, so a tap aimed at whatever the menu had been covering selected a row instead. -
Catalog: switching between two
LiquidMenus cost two taps — the first was spent on the dismiss barrier and the second anchor never saw it. The barrier still absorbs the press, so dismissing never doubles as pressing, but it now resolves a press on a sibling menu's anchor itself and opens that menu.
0.1.2 #
Roughly 40% off the raster time of an animating glass element, with the rendered pixels held fixed. Measured on a scripted bottom-tabs drag (Windows, Impeller, 6 s, three alternating runs against the previous release): raster mean 5.15–5.46 ms → 3.02–3.09 ms, p90 7.08–8.04 ms → 4.42–4.56 ms, p99 9.71–11.23 ms → 6.35–6.75 ms.
Every change below was verified by rendering all fifteen catalog screens before and after and comparing them pixel by pixel. What survives is a maximum of 6–17 (out of 765, summed across RGB) on a fraction of a percent of pixels, confined to the soft gradient of a drop shadow.
Performance #
- The drop shadow and inner shadow are baked into cached GPU textures and
re-baked only when their geometry changes; an animating
alphanow only modulates the cached image. Skia caches blurred masks by path and sigma, so redrawing a soft shadow each frame used to be nearly free — Impeller has no such cache and ran the full Gaussian every frame. The cache steps aside automatically when a key churns (an inner shadow whose radius follows a press, say), because re-baking every frame is worse than not caching at all. - The highlight rim is deliberately not baked. It is a hairline, and both baking it and dropping its save-layer were measured to shift its pixels.
- The resolved outline and corner radii are memoised per (shape, size, text
direction). A continuous-curvature outline is twelve cubics solved from
scratch, and building a fresh
Pathevery frame also missed Impeller's tessellation cache. - With no compositing descendant, the shape clip goes straight onto the canvas
instead of through
PaintingContext.pushClipPath, which copied the whole path engine-side every frame. DrawBackdroptakesisolateSurface(default true). A surface that only paints src-over produces identical pixels without the isolating save-layer. The isolating layer is also no longer raised foronDrawBehindalone, which paints under the backdrop where src-over is associative.- An exported backdrop is only re-recorded when something is sampling it.
- Zero-alpha highlights, shadows and inner shadows are skipped entirely.
- Catalog: the invisible accent copy behind the tab pill is clipped away
rather than filtered away, which drops an offscreen pass and stops
ColorFiltered'salwaysNeedsCompositingfrom promoting the glass element's shape clip to a compositing layer. Pixel-identical. - Catalog: flat
CanvasBackdrops are cached by colour instead of rebuilt from an inline closure each frame, which had made every consumer unsubscribe, resubscribe and repaint on every pointer move. - Catalog: the adaptive-luminance screen no longer restarts a one-second tween every second when the measured luminance has not changed, which kept a ticker alive on an idle screen.
Fidelity with the Compose original #
- The velocity spring's threshold was 50–500× too large, which made it settle
on its first tick — so the squash-and-stretch tracked the raw velocity
tracker with no lag, no overshoot and no ring-down. Compose's live threshold
is the animation spec's
visibilityThreshold * 10f; the port had copied theAnimatable(0f, 5f)constructor argument, which Compose only uses to build the default spec for calls that do not pass one. - Presses are no longer cancelled by Flutter's 18 px touch slop. Compose's
clickablehas no distance test, so pushing a glass button around under a finger and releasing still counts as a press;TapGestureRecognizerself-rejected instead. Taps now come from the same slop-free inspector that drives the press highlight. LiquidBottomTabsreports a selection only when the index actually changes. Compose reports through asnapshotFlow, which does not emit on a no-op release.DragInspectornow learns when another competitor claims the pointer, via a non-competing arena member, so a press releases as soon as an enclosing scrollable takes over — Compose sees this asisConsumed. It also hands the drag to a surviving finger when the tracked one lifts, instead of ending it.- Fixed the demo's bottom navigation bar border being sheared vertically at
either end when seen through the dragged pill. The panel-offset translate
sat inside the accent copy's
BackdropLayer, andOffsetLayer.toImageSyncclips hard at the layer's bounds — unlike Compose, which records the copy's draw commands unclipped — so the give the panel takes on while dragging pushed the copy's end cap outside the captured region. The translate now sits outside theBackdropLayer, so the capture box moves with the panel. - Zero-alpha highlights, shadows and inner shadows are skipped entirely instead of costing a save-layer each.
- The demo's interactive highlight reuses one cached
FragmentShaderinstead of creating and disposing one per paint.
New catalog components #
LiquidPanel— the glass surface everything else is built from, split out so it can be used on its own: vibrancy, blur and refraction over a backdrop, a container tint, a rim and a drop shadow. Itsrevealis read at paint time and ramps the refraction depth, rim and shadow together, so an appearing panel reads as glass thickening into place rather than a picture fading in.LiquidMenu— a pop-up menu that blooms out of its anchor as aLiquidPanel, with screen-edge avoidance, tap-outside dismissal and a slop-free row press. The panel lives in theOverlayrather than in the anchor's own box, because Flutter bounds-checks every ancestor while hit-testing and a panel merely drawn outside its parent would render but never receive a tap. Two motion details were found by measuring on-device and are worth recording: the opening spring must be critically damped (an underdamped one bloomed to 102.8% and eased back over ~175 ms, which on an opaque panel reads as a second opening animation), and the portal must not be re-shown while already showing (OverlayPortalController.showassigns a fresh z-order slot unconditionally, which remounts the overlay child and replays the bloom).LiquidButtonGroup— a capsule holding several actions, for a back cluster, a toolbar pair or a Cancel/OK bar. It carriesLiquidButton's drag physics exactly: the glass follows the finger through a boundedtanh, stretches along the direction of travel, and springs home on release.LiquidSegmentedControl— a compact picker whose thumb rides the same damped-drag physics as the bottom-tabs pill, over a capsule-shapedLiquidPanel.
0.1.1 #
- Fixed the demo's bottom navigation bar being visibly sliced at either end
while dragging. Flutter's
Stackclips as soon as a positioned child overflows, and the selection pill's position lands on the panel's bounds exactly at both ends, so rounding decided whether the clip engaged -- and when it did it sheared the panel's rounded cap into a straight edge. EveryStackthat stands in for an unclipped ComposeBoxnow setsclipBehavior: Clip.none. - Added screenshots to the package and the README.
- The capture harness takes
FLUID_GLASS_SHOT_SCALEfor the pixel ratio it renders at.
0.1.0 #
First release. A port of Kyant0/AndroidLiquidGlass 2.0.0 and Kyant0/Shapes 1.2.0 to Flutter.
DrawBackdrop/DrawBackdrop.plainglass elements, drawing in order: shadow, backdrop, surface, child, highlight, inner shadow.- Effects:
blur,lens(refraction, depth, chromatic dispersion),colorControls,vibrancy,opacity,colorFilterEffect, plusimageFilterEffectandfragmentShaderEffectfor custom ones. - Backdrops:
LayerBackdrop+BackdropLayer,CanvasBackdrop,CombinedBackdrop,WrappedBackdrop,emptyBackdrop, and exported backdrops. Highlight(plain / directional / ambient),GlassShadow,GlassInnerShadow.- Continuous-curvature shapes:
Rectangle,RoundedRectangle,Capsule,UnevenRoundedRectangle, withGlassShapeClipperandGlassShapeBorderadapters. - The full Backdrop Catalog demo, all fourteen screens.
