mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
camera cutout area blur
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
@@ -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 (Fritsch–Carlson) 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user