camera cutout area blur

This commit is contained in:
Boof2015
2026-08-02 22:58:25 -04:00
parent 4c6566b442
commit 9153d91ccf
9 changed files with 674 additions and 21 deletions
+1 -1
View File
@@ -87,7 +87,7 @@
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/components/selectionSlideMath.test.mts",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/components/selectionSlideMath.test.mts src/components/topFadeMath.test.mts",
"test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/components/library/detailHeroLayout.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
+8 -2
View File
@@ -15,6 +15,7 @@ import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { useTopBleedInset } from '@/components/screenTopBleed';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
@@ -580,6 +581,11 @@ function HomeBand({
export default function HomeScreen() {
const sceneBottomInset = useSceneBottomInset();
// `<Screen bleedTop>` hands this back to the content: the scroll frame runs
// to the top of the window so the masthead can travel behind the status bar,
// and the content container re-pays the inset so it still starts below it.
// Zero in a window too short to bleed, where `Screen` keeps paying it.
const topBleed = useTopBleedInset();
const styles = useStyles();
const router = useRouter();
const openLibrary = useHomeLibraryNavigation();
@@ -841,13 +847,13 @@ export default function HomeScreen() {
) : null;
return (
<Screen>
<Screen bleedTop>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
<PullSearchScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
overScrollMode="never"
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
contentContainerStyle={{ paddingTop: topBleed, paddingBottom: sceneBottomInset }}
onLayout={measureContent}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
+6 -2
View File
@@ -6,6 +6,7 @@ import {
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { useTopBleedInset } from '@/components/screenTopBleed';
import { Text } from '@/components/Text';
import {
formatFolderCount,
@@ -39,6 +40,9 @@ function formatEnabled(value: boolean): string {
export default function SettingsScreen() {
const showScreenTitle = useShellShowsScreenTitle();
const sceneBottomInset = useSceneBottomInset();
// Re-paid by the content because `<Screen bleedTop>` stopped paying it, and
// zero in a window too short to bleed — see `screenTopBleed`.
const topBleed = useTopBleedInset();
const styles = useStyles();
const colors = useColors();
const router = useRouter();
@@ -89,8 +93,8 @@ export default function SettingsScreen() {
: desktopRemoteSubtitle;
return (
<Screen>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={[styles.content, { paddingBottom: sceneBottomInset }]}>
<Screen bleedTop>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={[styles.content, { paddingTop: topBleed, paddingBottom: sceneBottomInset }]}>
{/* The rail names this destination itself; repeating it would just
spend a landscape window's scarce height on the same word. */}
{showScreenTitle ? (
+47 -15
View File
@@ -5,12 +5,26 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useShellRailPresent } from '@/navigation/shellRailContext';
import { useShellLayout } from '@/navigation/useShellLayout';
import { ScreenTopBleedContext, useTopFadeBand } from '@/components/screenTopBleed';
import { TopFadeScrim } from '@/components/TopFadeScrim';
import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
interface ScreenProps extends ViewProps {
/** Apply default horizontal padding. */
padded?: boolean;
/**
* Let content travel behind the status bar instead of stopping below it.
*
* Only for screens whose scroll surface starts at the very top — Home and
* Settings. On a screen with fixed chrome above its list (Library's switcher,
* a back row) this would just push the chrome under the cutout, since the
* list below it can never reach the status bar anyway.
*
* The scroll surface must re-pay the inset as `contentContainerStyle`
* padding; `useScreenTopBleed` is how it learns the amount.
*/
bleedTop?: boolean;
}
/**
@@ -24,29 +38,47 @@ interface ScreenProps extends ViewProps {
*
* Insets sit on the root and the content gutter on the inner view, so the
* gutter is measured from the safe edge rather than from the cutout.
*
* The top inset is the exception a screen can buy out of. Paying it here is
* what makes content stop dead at the status bar — the frame ends there, so a
* heading scrolling up is clipped mid-glyph. `bleedTop` hands the inset to the
* content instead, and `TopFadeScrim` washes the strip so the crossing reads as
* a dissolve rather than a cut.
*
* The library detail screens bleed too, but by overriding `paddingTop` in their
* own style rather than through this prop: they bleed so *artwork* runs behind
* the status bar, and `CollapsingDetail` brings its own scrim and collapsed
* bar. A `bgPrimary` wash on top of that would mute the art it exists to show.
*/
export function Screen({ children, style, padded = true, ...rest }: ScreenProps) {
export function Screen({ children, style, padded = true, bleedTop = false, ...rest }: ScreenProps) {
const styles = useStyles();
const insets = useSafeAreaInsets();
const railPresent = useShellRailPresent();
// The dock claims the trailing edge, so it pays that inset — the mirror of
// the rail taking the leading one. `sceneInsetRight` is the shell's answer.
const sceneInsetRight = useShellLayout().sceneInsetRight;
// A window too short to spend room on the fade doesn't bleed at all: content
// stops below the bar as it always did, rather than bleeding into a bare clip.
const fade = useTopFadeBand();
const topBleed = bleedTop && fade ? insets.top : 0;
return (
<View
style={[
styles.root,
{
paddingTop: insets.top,
paddingLeft: railPresent ? 0 : insets.left,
paddingRight: sceneInsetRight,
},
style,
]}
{...rest}
>
<View style={[styles.inner, padded && styles.padded]}>{children}</View>
</View>
<ScreenTopBleedContext.Provider value={topBleed}>
<View
style={[
styles.root,
{
paddingTop: insets.top - topBleed,
paddingLeft: railPresent ? 0 : insets.left,
paddingRight: sceneInsetRight,
},
style,
]}
{...rest}
>
<View style={[styles.inner, padded && styles.padded]}>{children}</View>
{bleedTop && fade ? <TopFadeScrim band={fade} /> : null}
</View>
</ScreenTopBleedContext.Provider>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import { Canvas, Fill, LinearGradient, vec } from '@shopify/react-native-skia';
import { type TopFadeBand } from '@/components/topFadeMath';
import { useColors } from '@/theme/themed';
/**
* The gradient that lands scrolling content into the status bar.
*
* The mirror of `MiniPlayerScrim` at the other end of the screen, and it has
* the same job: soften the seam where content runs into chrome. On a screen
* that bleeds under the status bar the scroll frame reaches y=0, so a heading
* riding up is clipped mid-glyph against the top of the window. This washes it
* to the screen background over the strip instead, so it ends rather than
* stops.
*
* Nothing here can affect layout — it is absolutely positioned and
* `pointerEvents="none"`. On a screen already sitting on `bgPrimary` with
* nothing scrolled up there it is invisible, which is the intended resting
* state: this should be doing its work without being noticed.
*
* The ramp itself lives in `topFadeMath` — it depends on the device's status
* bar height, so it is worth testing away from React.
*/
function withAlpha(hex: string, alpha: number): string {
return `${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`;
}
export function TopFadeScrim({ band }: { band: TopFadeBand }) {
const colors = useColors();
// `bgPrimary` is guaranteed 6-digit hex by the palette invariant, so the
// 8-digit alpha suffix is safe (same idiom as MiniPlayerScrim).
const gradient = useMemo(() => ({
colors: band.stops.map((stop) => withAlpha(colors.bgPrimary, stop.alpha)),
positions: band.stops.map((stop) => stop.at),
}), [band, colors.bgPrimary]);
return (
<View pointerEvents="none" style={[styles.band, { height: band.height }]}>
<Canvas style={StyleSheet.absoluteFill}>
<Fill>
<LinearGradient
start={vec(0, 0)}
end={vec(0, band.height)}
colors={gradient.colors}
positions={gradient.positions}
/>
</Fill>
</Canvas>
</View>
);
}
const styles = StyleSheet.create({
band: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
});
export default TopFadeScrim;
+56
View File
@@ -0,0 +1,56 @@
import { createContext, useContext, useMemo } from 'react';
import { useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { topFadeBand, type TopFadeBand } from '@/components/topFadeMath';
/**
* Whether this window lets content travel behind the status bar, and what that
* costs whoever has to pay for it.
*
* Three parties need the same answer and none of them can see each other:
* `Screen`, which stops paying its top inset; the scroll surface inside it,
* which starts; and `TopFadeScrim`, which draws the fade that makes the
* crossing legible. They agree by all deriving from `topFadeBand` rather than
* by passing the answer around — a screen that decided for itself would
* double-pay the inset the moment the window got too short to bleed.
*/
/**
* The top safe-area inset the surrounding `Screen` did *not* pay, and which its
* content therefore owes.
*
* For descendants that pin themselves to the top of a screen without knowing
* which screen they are on — `PullSearchGesture`'s chip is the live case, and
* it is shared between a screen that bleeds (Home) and one that doesn't
* (Library). Zero by default, so everything that has not opted in is untouched.
*/
export const ScreenTopBleedContext = createContext(0);
/** Zero on a screen whose container pays its own top inset. */
export function useScreenTopBleed(): number {
return useContext(ScreenTopBleedContext);
}
/**
* The fade this window gets, or null if it should not bleed at all.
*
* Asked *before* the inset is dropped, because bleeding without a fade is the
* bare clip the whole feature exists to remove.
*/
export function useTopFadeBand(): TopFadeBand | null {
const insetTop = useSafeAreaInsets().top;
const windowHeight = useWindowDimensions().height;
return useMemo(() => topFadeBand(insetTop, windowHeight), [insetTop, windowHeight]);
}
/**
* What a bleeding screen's scroll surface must re-pay as `paddingTop`.
*
* Zero when the window declined to bleed, which is what keeps a screen that
* passes `bleedTop` correct in landscape: `Screen` goes on paying the inset and
* the content must not pay it again.
*/
export function useTopBleedInset(): number {
const insetTop = useSafeAreaInsets().top;
return useTopFadeBand() ? insetTop : 0;
}
+8 -1
View File
@@ -31,6 +31,7 @@ import {
useSharedValue
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { useScreenTopBleed } from '@/components/screenTopBleed';
import {
radius,
spacing,
@@ -133,6 +134,7 @@ export function PullSearchGesture({
}) {
const styles = useStyles();
const colors = useColors();
const topBleed = useScreenTopBleed();
const [pull, setPull] = useState(0);
const [armed, setArmed] = useState(false);
const [dragging, setDragging] = useState(false);
@@ -242,6 +244,10 @@ export function PullSearchGesture({
const progress = clamp(pull / OPEN_THRESHOLD, 0, 1);
const indicatorStyle = {
// The chip pins to the top of whatever screen it's on, and on a screen that
// bleeds under the status bar (Home) that top is the top of the *window*.
// Zero on Library, which doesn't bleed, so nothing moves there.
top: topBleed + spacing.xs,
opacity: pull <= 0 ? 0 : Math.max(0.72, progress),
transform: [
{ translateY: -18 + (clamp(pull, 0, MAX_PULL) / MAX_PULL) * 38 },
@@ -273,8 +279,9 @@ const useStyles = createThemedStyles((colors) => ({
flex: 1,
},
indicator: {
// `top` is applied inline — it depends on whether the screen bleeds under
// the status bar.
position: 'absolute',
top: spacing.xs,
alignSelf: 'center',
zIndex: 20,
flexDirection: 'row',
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { topFadeBand } from './topFadeMath.ts';
/** A tall punch-hole phone (S22-ish) and a short-strip one, in dp. */
const TALL_INSET = 48;
const SHORT_INSET = 24;
/** Status bar insets across the real Android range, in dp. */
const REAL_INSETS = [28, 32, 36, 40, 44, 48, 52];
/** Phone in portrait, phone in landscape, tablet — window heights in dp. */
const TALL_WINDOW = 780;
const SHORT_WINDOW = 360;
const TABLET_WINDOW = 1180;
/** Alpha at an arbitrary depth into the band, the way Skia will interpolate it. */
function alphaAt(inset: number, depth: number): number {
const band = topFadeBand(inset, TALL_WINDOW);
assert.ok(band);
const at = depth / band.height;
const next = band.stops.findIndex((stop) => stop.at >= at);
if (next <= 0) return band.stops[0]!.alpha;
const from = band.stops[next - 1]!;
const to = band.stops[next]!;
return from.alpha + ((at - from.at) / (to.at - from.at)) * (to.alpha - from.alpha);
}
/** Ratio of the largest value to the smallest — how much a device changes it. */
function spread(values: number[]): number {
return Math.max(...values) / Math.min(...values);
}
/** Slope in alpha-per-dp between each adjacent pair of stops. */
function slopes(inset: number): number[] {
const band = topFadeBand(inset, TALL_WINDOW);
assert.ok(band);
const out: number[] = [];
for (let i = 1; i < band.stops.length; i += 1) {
const from = band.stops[i - 1]!;
const to = band.stops[i]!;
out.push((from.alpha - to.alpha) / ((to.at - from.at) * band.height));
}
return out;
}
test('does not mount a band when there is no strip to cover', () => {
// No top inset means content is not disappearing into anything, so a
// zero-height scrim would be pure cost.
assert.equal(topFadeBand(0, TALL_WINDOW), null);
assert.equal(topFadeBand(-1, TALL_WINDOW), null);
});
test('refuses a window with no height to spend on the fade', () => {
// Android keeps the status bar up in landscape — `insets.top` is not zero
// there, which `shellLayout` already budgets for — so without this the band
// would wash a quarter of a phone's landscape window. Refusing beats
// compressing: a squeezed curve re-steepens into the hard line the shape
// exists to avoid, and the caller answers by not bleeding at all.
assert.equal(topFadeBand(TALL_INSET, SHORT_WINDOW), null);
assert.equal(topFadeBand(SHORT_INSET, SHORT_WINDOW), null);
});
test('bleeds on a phone in portrait and on a tablet either way', () => {
// The cutoff has to fall between a phone's two orientations, not inside the
// set of devices that should all behave the same.
assert.ok(topFadeBand(TALL_INSET, TALL_WINDOW));
assert.ok(topFadeBand(TALL_INSET, TABLET_WINDOW));
// A tablet in landscape is still tall enough to be worth it.
assert.ok(topFadeBand(SHORT_INSET, 800));
});
test('gives a window that does bleed the same band whatever its height', () => {
// Window height is a gate, not a scale: once a window qualifies, the fade is
// the same physical size on a phone and on a tablet.
const phone = topFadeBand(TALL_INSET, TALL_WINDOW);
const tablet = topFadeBand(TALL_INSET, TABLET_WINDOW);
assert.ok(phone);
assert.ok(tablet);
assert.equal(phone.height, tablet.height);
assert.deepEqual(phone.stops, tablet.stops);
});
test('runs well past the status bar', () => {
// The first attempt ended ~12dp below the bar and still read as content
// meeting a line — the dissolve needs length to happen over.
const band = topFadeBand(TALL_INSET, TALL_WINDOW);
assert.ok(band);
assert.ok(band.height - TALL_INSET >= TALL_INSET);
});
test('anchors the curve to the status bar edge, not to a fixed height', () => {
// The whole point of computing this: the strip is a different height on every
// device, and the solid part has to cover exactly it, whatever it measures.
for (const inset of [SHORT_INSET, TALL_INSET]) {
const band = topFadeBand(inset, TALL_WINDOW);
assert.ok(band);
assert.equal(band.barAt, inset / band.height);
}
});
test('washes to the background at the top and to nothing at the bottom', () => {
const band = topFadeBand(TALL_INSET, TALL_WINDOW);
assert.ok(band);
const first = band.stops[0]!;
const last = band.stops[band.stops.length - 1]!;
assert.equal(first.at, 0);
assert.equal(last.at, 1);
// Short of fully opaque on purpose — a whisper of content behind the clock is
// what makes it read as passing behind rather than stopping at a slab.
assert.ok(first.alpha > 0.85 && first.alpha < 1);
assert.equal(last.alpha, 0);
});
test('fades in one direction only, with stops Skia will accept', () => {
// Skia requires ascending positions; a non-monotonic alpha would read as a
// band rather than a dissolve, and the monotone limiter exists to guarantee
// the cubic never overshoots into one.
for (const inset of REAL_INSETS) {
const band = topFadeBand(inset, TALL_WINDOW);
assert.ok(band);
for (let i = 1; i < band.stops.length; i += 1) {
const previous = band.stops[i - 1]!;
const current = band.stops[i]!;
assert.ok(current.at > previous.at, `positions ascend at inset ${inset}`);
assert.ok(current.alpha <= previous.alpha, `alpha never rises at inset ${inset}`);
assert.ok(current.alpha >= 0 && current.alpha <= 1, `alpha stays in range at ${inset}`);
}
}
});
test('has no crease anywhere along the ramp', () => {
// The regression this pins, and the reason the curve is a sampled cubic
// rather than a handful of stops: straight lines between control points meet
// at a derivative discontinuity, and the eye reads that as a Mach band — a
// visible "gradient line" across the middle of the fade. An earlier pass
// kinked by ~0.026 alpha/dp at a single point, which was plainly visible.
for (const inset of REAL_INSETS) {
const ramp = slopes(inset);
for (let i = 1; i < ramp.length; i += 1) {
assert.ok(
Math.abs(ramp[i]! - ramp[i - 1]!) < 0.01,
`kink of ${Math.abs(ramp[i]! - ramp[i - 1]!).toFixed(4)} alpha/dp at inset ${inset}`
);
}
}
});
test('never falls faster than the eye reads as an edge', () => {
// Smooth is not enough on its own — a steep enough ramp reads as a line even
// with no crease in it. This is what `SOLID_THROUGH` is really buying.
for (const inset of REAL_INSETS) {
assert.ok(Math.max(...slopes(inset)) < 0.025, `peak slope at inset ${inset}`);
}
});
test('keeps content behind the clock past the point of being legible', () => {
// Without a blur to destroy the letterforms, readable text behind the status
// bar reads as two things overlapping rather than as depth. Solid through the
// icon row's centre, and still a ghost at its lower edge.
for (const inset of REAL_INSETS) {
assert.ok(alphaAt(inset, inset * 0.5) >= 0.85, `clock centre at inset ${inset}`);
assert.ok(alphaAt(inset, inset * 0.65) >= 0.75, `icon lower edge at inset ${inset}`);
}
});
test('is still visibly fading where content clears the bar', () => {
// Half gone at the bar's bottom edge. A wash that finished inside the strip
// would let glyphs re-sharpen just before they disappeared.
assert.ok(alphaAt(TALL_INSET, TALL_INSET) > 0.35);
assert.ok(alphaAt(TALL_INSET, TALL_INSET) < 0.65);
});
test('lays a deliberate light wash over content resting below the bar', () => {
// A dissolve this long cannot also leave the top of a resting heading
// untouched, and that is the accepted trade: a soft vignette the content
// emerges from, not a dimmed strip. Pinned so it can't drift into one.
const restingTitle = alphaAt(TALL_INSET, TALL_INSET + 24);
assert.ok(restingTitle > 0.05 && restingTitle < 0.2, `resting wash ${restingTitle.toFixed(3)}`);
});
test('spends its last fifth effectively invisible', () => {
const band = topFadeBand(TALL_INSET, TALL_WINDOW);
assert.ok(band);
assert.ok(alphaAt(TALL_INSET, band.height * 0.8) <= 0.06);
assert.ok(alphaAt(TALL_INSET, band.height - 1) < 0.01);
});
test('looks the same on every phone, in both the ways that show', () => {
// Two things vary with the strip and both are visible, so both are pinned.
//
// The slope is how gradual the dissolve looks while scrolling. Hanging the
// interior points off the bar's bottom edge made a segment proportional to
// the strip and doubled the slope on a short one — a hard line again.
//
// The resting wash is how dimmed a heading looks when nothing is scrolled.
// Content sits at `insets.top` plus a fixed margin on every device, so a
// fixed-length ramp anchored only at its top slid content along the curve:
// 23% washed on a short-strip phone against 13% on a tall-strip one.
//
// Anchoring both ends to the strip and placing the interior points along the
// span between them is what holds both within a fraction of each other.
const peaks = REAL_INSETS.map((inset) => Math.max(...slopes(inset)));
const resting = REAL_INSETS.map((inset) => alphaAt(inset, inset + 24));
assert.ok(spread(peaks) < 1.2, `peak slope spread ${spread(peaks).toFixed(2)}`);
// Measured as a difference, not a ratio. What the eye compares between two
// phones is how much darker one heading looks than the other, and a ratio
// exaggerates that at small alphas — 10% against 15% is a fifth of a stop,
// not "50% worse". Anchoring only the top of the ramp gave 13% against 23%,
// which is the gap that showed.
const restingGap = Math.max(...resting) - Math.min(...resting);
assert.ok(restingGap < 0.06, `resting wash gap ${restingGap.toFixed(3)}`);
// And bounded in absolute terms on the worst device, not just the best.
assert.ok(Math.max(...peaks) < 0.025, `worst peak slope ${Math.max(...peaks).toFixed(4)}`);
assert.ok(Math.max(...resting) < 0.17, `worst resting wash ${Math.max(...resting).toFixed(3)}`);
});
test('puts the same landmarks at close to the same alpha on every phone', () => {
// The bar's bottom edge is where a heading finishes crossing into the strip.
// It cannot be identical everywhere — the strip is the thing that varies —
// but it must not swing far enough that one phone hides content the next one
// shows.
const barEdge = REAL_INSETS.map((inset) => alphaAt(inset, inset));
assert.ok(Math.min(...barEdge) > 0.4, `weakest bar edge ${Math.min(...barEdge).toFixed(2)}`);
assert.ok(Math.max(...barEdge) < 0.7, `strongest bar edge ${Math.max(...barEdge).toFixed(2)}`);
});
+253
View File
@@ -0,0 +1,253 @@
/**
* The gradient band that dissolves content on its way behind the status bar.
*
* A screen that bleeds under the status bar has its scroll frame reach y=0, so
* a heading riding up gets clipped mid-glyph at the top of the window. Clipping
* is what "cut off in the void" was: the letters do not end, they stop.
*
* The band's job is to make them *end*, and three things have to be true at
* once:
*
* 1. Nothing legible survives where the clock and icons are. Without a blur to
* destroy the letterforms, readable text up there reads as two things
* overlapping rather than as depth — so the strip stays effectively solid
* through `SOLID_THROUGH`, and the visible dissolve happens below it.
* 2. The dissolve gets real length to happen over. A short band was the first
* attempt and it failed the same way the clipping did: the fade happened
* over so little distance that content still read as meeting a line.
* 3. It has no crease anywhere. This is the subtle one. The alphas below are
* *control points*, and joining them with straight lines puts a derivative
* discontinuity at every one of them — which the eye picks up as a Mach
* band, a visible "gradient line" across the middle of the fade. So the
* curve through them is a monotone cubic, sampled densely enough that what
* Skia finally interpolates has no corner left in it.
*
* The control points are anchored to the status bar's bottom edge rather than
* set in dp, because the strip is 24dp on one device and 48dp on the next: the
* solid part always covers exactly the bar, whatever it measures, while the
* tail stays a constant length in dp because the content it fades is a constant
* size.
*/
/** One gradient stop: `at` is a 0..1 fraction of the band's height. */
export interface TopFadeStop {
at: number;
alpha: number;
}
export interface TopFadeBand {
height: number;
/** Where the status bar's bottom edge falls, as a fraction of `height`. */
barAt: number;
stops: TopFadeStop[];
}
/**
* Strength at the very top of the window. Deliberately short of 1 — a whisper
* of content still shows through behind the clock, which is the difference
* between content passing behind the status bar and content ending at a slab.
* Same reasoning, and nearly the same figure, as `MiniPlayerScrim`'s.
*/
const MAX_ALPHA = 0.95;
/**
* How far below the status bar the band ends, in dp.
*
* Both ends of the fade track the strip, and it took two wrong answers to get
* here. Every screen positions its content at `insets.top` plus its own margin,
* so a heading's resting distance below the bar is the same on every phone
* while the bar itself is anywhere from ~28dp to ~52dp. Anchoring only the
* *start* to the strip and giving the ramp a fixed length held the slope
* perfectly constant but slid content along the curve: the top of a resting
* title was washed 23% on a short-strip phone and 13% on a tall-strip one,
* which is the difference between looking dimmed and looking clean.
*
* The other wrong answer was hanging the interior points off the bar's bottom
* edge, which made a single segment proportional to the strip and compressed
* the whole ramp into ~11dp on a short one — a hard line again. So the interior
* points are fractions of the *span* between the two anchors, and the span
* varies by about a sixth across the range instead of doubling.
*
* This is the number to turn if the fade feels abrupt (raise) or if resting
* content near the top looks dimmed (lower).
*/
const TAIL = 58;
/**
* How far down the strip the wash stays effectively solid, as a fraction of it
* — about the middle of the clock and icon row.
*
* This is the smoothness knob, and it trades against the icons. Holding solid
* all the way to the bottom of the icons (0.7) forces the entire visible
* dissolve into the ~14dp below them, and that steepness alone reads as a line
* even with no crease left in it. Releasing at the icons' centre instead buys
* the ramp half again as much room; the cost is that content is a ~20% ghost at
* their lower edge, which is well short of legible.
*/
const SOLID_THROUGH = 0.55;
/**
* Alpha at `SOLID_THROUGH`. Past roughly this figure the wash is
* indistinguishable from solid `bgPrimary`, so this is the point where the
* gradient stops being background and starts being visible.
*/
const SOLID_ALPHA = 0.88;
/**
* The dissolve's shape, as fractions of the span between the two anchors.
*
* Convex: it sheds most of its strength in the first half and then spends the
* rest of its length creeping to zero, which is what keeps a band this long
* from reading as a deliberate dimmed strip (the mistake `MiniPlayerScrim`
* records making at the other end of the screen).
*
* The first point is placed so that on a typical phone the status bar's bottom
* edge lands at about half alpha — content clearing the bar is visibly still
* dissolving rather than snapping back to full strength.
*/
const DISSOLVE_POINTS: readonly (readonly [of: number, alpha: number])[] = [
[0.27, 0.52],
[0.52, 0.15],
[0.72, 0.05],
];
/**
* How many stops the curve is flattened into.
*
* Skia interpolates linearly between stops, so this is what decides whether the
* cubic survives as a smooth ramp or comes back as a polyline. Forty across a
* ~110dp band puts the remaining corners under 3dp apart, well below where a
* Mach band forms.
*/
const SAMPLES = 40;
/**
* The largest share of the window the band may occupy before the screen should
* stop bleeding altogether.
*
* `insets.top` is not zero in landscape — Android keeps the status bar up, and
* `shellLayout` already budgets for it — so without this a ~95dp band lands in
* a ~360dp-tall window and washes a quarter of the screen.
*
* Refusing beats compressing. Squeezing the curve into a short window
* re-steepens it into exactly the hard line the shape exists to avoid, and the
* honest answer for a window with no height to spare is not to bleed at all:
* `Screen` keeps paying its own inset and content stops below the bar, the same
* as on every screen that never opted in. There is no cut to soften, because
* there is no bleed.
*
* At 0.18 the cutoff lands near a 590dp window, which is within a few dp of
* `RAIL_MAX_WINDOW_HEIGHT` — the point where the shell independently decided
* height was scarce. Phones bleed in portrait and not in landscape; tablets
* bleed either way.
*/
const MAX_WINDOW_SHARE = 0.18;
/**
* Tangents for a monotone cubic (FritschCarlson) through the control points.
*
* Plain Catmull-Rom would be smooth but can overshoot, and an alpha that dips
* below zero or bulges above the point above it would show as a bright or dark
* ring inside the fade. This limiter is what rules that out.
*/
function monotoneTangents(xs: number[], ys: number[]): number[] {
const n = xs.length;
const secants: number[] = [];
for (let i = 0; i < n - 1; i += 1) {
secants.push((ys[i + 1]! - ys[i]!) / (xs[i + 1]! - xs[i]!));
}
const tangents: number[] = [secants[0]!];
for (let i = 1; i < n - 1; i += 1) {
const previous = secants[i - 1]!;
const next = secants[i]!;
tangents.push(previous * next <= 0 ? 0 : (previous + next) / 2);
}
tangents.push(secants[n - 2]!);
for (let i = 0; i < n - 1; i += 1) {
const secant = secants[i]!;
if (secant === 0) {
tangents[i] = 0;
tangents[i + 1] = 0;
continue;
}
const a = tangents[i]! / secant;
const b = tangents[i + 1]! / secant;
const magnitude = a * a + b * b;
if (magnitude > 9) {
const scale = 3 / Math.sqrt(magnitude);
tangents[i] = scale * a * secant;
tangents[i + 1] = scale * b * secant;
}
}
return tangents;
}
/** Cubic Hermite evaluation on the span containing `x`. */
function interpolate(xs: number[], ys: number[], tangents: number[], x: number): number {
let span = xs.length - 2;
for (let i = 0; i < xs.length - 1; i += 1) {
if (x <= xs[i + 1]!) {
span = i;
break;
}
}
const x0 = xs[span]!;
const x1 = xs[span + 1]!;
const h = x1 - x0;
const t = (x - x0) / h;
const t2 = t * t;
const t3 = t2 * t;
return (
ys[span]! * (2 * t3 - 3 * t2 + 1) +
h * tangents[span]! * (t3 - 2 * t2 + t) +
ys[span + 1]! * (-2 * t3 + 3 * t2) +
h * tangents[span + 1]! * (t3 - t2)
);
}
/**
* The band for a given window, or null when this window should not bleed.
*
* Null is a decision, not just a rendering skip: a screen must consult this
* before dropping its top inset, because bleeding without a fade is the hard
* clip the whole thing exists to remove. Two windows get it — one with no top
* inset at all (nothing for content to disappear into) and one too short to
* spend `MAX_WINDOW_SHARE` of itself on the fade.
*/
export function topFadeBand(insetTop: number, windowHeight: number): TopFadeBand | null {
if (insetTop <= 0) return null;
// The two anchors, both measured from the strip: the wash releases part-way
// down the icons, and the band ends a fixed distance below the bar — where
// resting content sits, on every device.
const solidEnd = insetTop * SOLID_THROUGH;
const height = insetTop + TAIL;
const span = height - solidEnd;
if (height > windowHeight * MAX_WINDOW_SHARE) return null;
const control: [at: number, alpha: number][] = [
[0, MAX_ALPHA],
[solidEnd, SOLID_ALPHA],
...DISSOLVE_POINTS.map(([of, alpha]): [number, number] => [solidEnd + span * of, alpha]),
[height, 0],
];
const xs = control.map(([at]) => at / height);
const ys = control.map(([, alpha]) => alpha);
const tangents = monotoneTangents(xs, ys);
const stops: TopFadeStop[] = [];
for (let i = 0; i <= SAMPLES; i += 1) {
const at = i / SAMPLES;
// Clamped because the limiter bounds overshoot but the endpoints still have
// to land exactly on 1 and 0 for the band to be seamless at both edges.
stops.push({ at, alpha: Math.min(1, Math.max(0, interpolate(xs, ys, tangents, at))) });
}
return { height, barAt: insetTop / height, stops };
}