feat(ui): pasek zakladek roztapia sie wokol aktywnej pozycji
Wzorowane na reelu _code_and_chill_ ("the bar melts around it"), ale
technika wzieta z jego drugiego zdania: "every socket is solved for
tangency, so the liquid never creases". Czyli sciezka LICZONA, nie
rozmywana.
Filtr "goo" (rozmycie + prog alfy) byl pierwszym pomyslem i zostal
ODRZUCONY po zajrzeniu w zrodlo react-native-svg. Filtry na Androidzie
dzialaja (GroupView extends RenderableView, ktory obsluguje mFilter), ale
RenderableView.java:376 przy KAZDYM rysowaniu robi Bitmap.createBitmap(
canvas.getWidth(), canvas.getHeight(), ARGB_8888), rysuje do niej element,
naklada programowy filtr i wrzuca wynik z powrotem. Przy animowanej kropli
to pelnoekranowa alokacja na klatke, w nagłowku KAZDEGO ekranu.
liquidTabPath: gniazdo to dwa luki styczne jednoczesnie do krawedzi paska
(srodek na y=top+k) i do kropli (odleglosc srodkow = r+k), stad
dx = sqrt((r+k)^2 - (top+k-cy)^2). Jedna <Path>, zero alokacji.
Weryfikacja liczbowa (nie wizualna): trzy niezmienniki stycznosci trzymaja
sie do 1e-9, piec przypadkow brzegowych OK. Test wylapal brakujacy warunek
`cy - r < top`: bez niego wzor ma rozwiazanie takze gdy kropla siedzi CALA
w pasku (top=26, cy=45, r=17, k=12 -> dx≈28,1) i powstawaloby gniazdo, w
ktore nie ma sie co wtopic.
Animacja: Animated.Value + listener przepisujacy cx do stanu, bo sciezka
to string i nie da sie jej podac Animated wprost (brak Reanimated w
projekcie). ~12 re-renderow SVG na 200 ms przejscia, poza tym komponent stoi.
useNativeDriver=false swiadomie: animujemy geometrie, nie transform.
Etykieta aktywnej zakladki siedzi na wypelnieniu akcentu — jasny tekst daje
tam 5,03:1, jedyny wariant tego koloru przechodzacy AA (patrz theme.ts).
This commit is contained in:
parent
2a163cbf2c
commit
e074229bbb
4 changed files with 287 additions and 32 deletions
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
|||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
id: '2026-08-14',
|
||||
date: 'August 2026',
|
||||
items: [
|
||||
'New look for the tabs at the top: the bar now melts around the tab you are on instead of sliding a pill across it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2026-08-13e',
|
||||
date: 'August 2026',
|
||||
|
|
|
|||
150
mobile/src/components/LiquidTabs.tsx
Normal file
150
mobile/src/components/LiquidTabs.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Górny pasek zakładek, w którym aktywna pozycja jest kroplą wtopioną w pasek.
|
||||
*
|
||||
* Kształt liczy `lib/liquidTabPath` (łuki styczne), NIE filtr „goo" — uzasadnienie
|
||||
* wydajnościowe siedzi w docstringu tamtego pliku i sprowadza się do tego, że
|
||||
* `react-native-svg` na Androidzie realizuje filtry przez pełnoekranową bitmapę na
|
||||
* każde rysowanie, a ten pasek wisi w nagłówku każdego ekranu.
|
||||
*
|
||||
* **Animacja jest krótka i celowo ograniczona.** `Animated.Value` z listenerem
|
||||
* przelicza ścieżkę w JS, więc na czas przejścia (200 ms) leci ~12 re-renderów samego
|
||||
* SVG. Poza przejściem komponent stoi. Nie używamy `useNativeDriver`, bo animujemy
|
||||
* geometrię ścieżki, a nie transform — natywny sterownik tego nie obsługuje.
|
||||
*
|
||||
* Szerokość bierzemy z `onLayout`, bo pasek dzieli nagłówek z ikonami po prawej i jego
|
||||
* realna szerokość zależy od telefonu. Do pierwszego pomiaru rysujemy sam pasek.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
import { liquidTabPath, plainBar } from '../lib/liquidTabPath';
|
||||
import { fonts, theme } from '../theme';
|
||||
|
||||
/** Wysokość rysunku. Kropla wystaje ponad pasek, stąd zapas u góry. */
|
||||
const H = 44;
|
||||
/** Górna i dolna krawędź samego paska. */
|
||||
const BAR_TOP = 22;
|
||||
const BAR_BOTTOM = 40;
|
||||
const BLOB_R = 16;
|
||||
/** Środek kropli: wystaje ~10 px ponad pasek. */
|
||||
const BLOB_CY = 28;
|
||||
const FILLET = 11;
|
||||
const BAR_RADIUS = 9;
|
||||
|
||||
interface Props<T extends string> {
|
||||
tabs: readonly T[];
|
||||
current: T;
|
||||
onSelect: (tab: T) => void;
|
||||
}
|
||||
|
||||
export function LiquidTabs<T extends string>({ tabs, current, onSelect }: Props<T>) {
|
||||
const [width, setWidth] = React.useState(0);
|
||||
const activeIndex = Math.max(0, tabs.indexOf(current));
|
||||
const slot = width > 0 ? width / tabs.length : 0;
|
||||
const targetCx = slot * activeIndex + slot / 2;
|
||||
|
||||
// Pozycja kropli jako Animated.Value + listener przepisujący ją do stanu. Ścieżka to
|
||||
// string, więc nie da się jej podać Animated wprost; to jedyny sposób bez Reanimated.
|
||||
const anim = React.useRef(new Animated.Value(0)).current;
|
||||
const [cx, setCx] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = anim.addListener(({ value }) => setCx(value));
|
||||
return () => anim.removeListener(id);
|
||||
}, [anim]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slot <= 0) return;
|
||||
// Pierwsze ustawienie po pomiarze szerokości: bez animacji, żeby pasek nie
|
||||
// „przyjeżdżał" z lewej przy każdym wejściu na ekran.
|
||||
if (cx === 0) {
|
||||
anim.setValue(targetCx);
|
||||
setCx(targetCx);
|
||||
return;
|
||||
}
|
||||
const a = Animated.timing(anim, {
|
||||
toValue: targetCx,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
});
|
||||
a.start();
|
||||
return () => a.stop();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetCx, slot]);
|
||||
|
||||
const d =
|
||||
width > 0 && cx > 0
|
||||
? liquidTabPath({
|
||||
width,
|
||||
top: BAR_TOP,
|
||||
bottom: BAR_BOTTOM,
|
||||
cx,
|
||||
cy: BLOB_CY,
|
||||
r: BLOB_R,
|
||||
fillet: FILLET,
|
||||
barRadius: BAR_RADIUS,
|
||||
})
|
||||
: plainBar(Math.max(width, 1), BAR_TOP, BAR_BOTTOM, BAR_RADIUS);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.wrap}
|
||||
onLayout={(e) => {
|
||||
const w = Math.round(e.nativeEvent.layout.width);
|
||||
if (w > 0 && w !== width) setWidth(w);
|
||||
}}
|
||||
>
|
||||
{width > 0 ? (
|
||||
<Svg width={width} height={H} style={StyleSheet.absoluteFill}>
|
||||
{/* Pasek i kropla to JEDEN kształt, więc nie ma szwu na styku. */}
|
||||
<Path d={d} fill={theme.card} />
|
||||
{cx > 0 ? (
|
||||
<Path
|
||||
d={`M ${cx - BLOB_R} ${BLOB_CY} a ${BLOB_R} ${BLOB_R} 0 1 0 ${BLOB_R * 2} 0 a ${BLOB_R} ${BLOB_R} 0 1 0 ${-BLOB_R * 2} 0`}
|
||||
fill={theme.accent}
|
||||
/>
|
||||
) : null}
|
||||
</Svg>
|
||||
) : null}
|
||||
|
||||
<View style={styles.row}>
|
||||
{tabs.map((t, i) => {
|
||||
const active = i === activeIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={t}
|
||||
style={styles.tab}
|
||||
hitSlop={8}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={() => (active ? null : onSelect(t))}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.label, active ? styles.labelActive : null]}
|
||||
>
|
||||
{t}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { height: H, justifyContent: 'flex-end' },
|
||||
row: { flexDirection: 'row', height: BAR_BOTTOM - BAR_TOP, marginTop: BAR_TOP },
|
||||
tab: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
label: {
|
||||
color: theme.muted,
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.medium,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
// Etykieta aktywnej siedzi NA wypełnieniu akcentu: jasny tekst daje tam 5,03:1,
|
||||
// czyli jedyny wariant tego koloru, który przechodzi próg AA (patrz theme.ts).
|
||||
labelActive: { color: theme.fg, fontFamily: fonts.display },
|
||||
});
|
||||
118
mobile/src/lib/liquidTabPath.ts
Normal file
118
mobile/src/lib/liquidTabPath.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Ścieżka paska zakładek, który „roztapia się" wokół aktywnej pozycji.
|
||||
*
|
||||
* Technika wzięta z reela `_code_and_chill_`: aktywna zakładka nie jest przesuwającą się
|
||||
* pigułką, tylko kroplą wyrastającą z paska, a przejście między nimi to **łuki styczne**
|
||||
* do jednego i drugiego. Autor pisze „every socket is solved for tangency, so the liquid
|
||||
* never creases" i to jest dosłownie opis tej funkcji.
|
||||
*
|
||||
* **Dlaczego nie filtr „goo" (rozmycie + próg alfy).** Byłby o rząd wielkości prostszy,
|
||||
* ale na Androidzie `react-native-svg` stosuje filtry przez pełną bitmapę: w
|
||||
* `RenderableView.java` każde rysowanie robi `Bitmap.createBitmap(canvas.getWidth(),
|
||||
* canvas.getHeight(), ARGB_8888)`, rysuje do niej element, nakłada programowy filtr
|
||||
* i wrzuca wynik z powrotem. Przy animowanej kropli to alokacja pełnoekranowej bitmapy
|
||||
* NA KLATKĘ, w nagłówku każdego ekranu. Ścieżka liczona analitycznie to jeden `<Path>`,
|
||||
* bez alokacji i bez filtra.
|
||||
*
|
||||
* Geometria (oś Y rośnie w dół, jak w SVG):
|
||||
*
|
||||
* ╭─ kropla (cx, cy, r)
|
||||
* ───╯ ╰─── ← łuki styczne (fillet) o promieniu k, siedzące W pasku
|
||||
* ───────── ← górna krawędź paska, y = top
|
||||
*
|
||||
* Łuk styczny ma środek na `y = top + k` (styczność do krawędzi paska) i jest stycznie
|
||||
* zewnętrznie do kropli, czyli odległość jego środka od środka kropli wynosi `r + k`.
|
||||
* Stąd pozioma odległość: `dx = sqrt((r+k)² - (top + k - cy)²)`.
|
||||
*
|
||||
* Gdy kropla siedzi zbyt głęboko w pasku, wyrażenie pod pierwiastkiem schodzi poniżej
|
||||
* zera i styczność nie istnieje — wtedy oddajemy sam pasek, bo lepszy prosty prostokąt
|
||||
* niż `NaN` w ścieżce (RN rysuje wtedy nic i pasek znika).
|
||||
*/
|
||||
|
||||
export interface LiquidTabPathOpts {
|
||||
/** Szerokość paska. */
|
||||
width: number;
|
||||
/** Górna krawędź paska (Y). */
|
||||
top: number;
|
||||
/** Dolna krawędź paska (Y). */
|
||||
bottom: number;
|
||||
/** Środek kropli w poziomie. */
|
||||
cx: number;
|
||||
/** Środek kropli w pionie (zwykle powyżej `top`). */
|
||||
cy: number;
|
||||
/** Promień kropli. */
|
||||
r: number;
|
||||
/** Promień łuku stycznego. Im większy, tym „bardziej ciekły" przelew. */
|
||||
fillet: number;
|
||||
/** Zaokrąglenie końców paska. */
|
||||
barRadius: number;
|
||||
}
|
||||
|
||||
/** Bezpieczny pierwiastek: ujemne pod pierwiastkiem → brak styczności. */
|
||||
function safeSqrt(v: number): number | null {
|
||||
return v > 0 ? Math.sqrt(v) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca `d` dla pojedynczej `<Path>`: pasek z wtopioną w niego kroplą.
|
||||
* Kształt jest domknięty, więc nadaje się do wypełnienia jednym kolorem.
|
||||
*/
|
||||
export function liquidTabPath(o: LiquidTabPathOpts): string {
|
||||
const { width, top, bottom, cx, cy, r, fillet: k, barRadius: br } = o;
|
||||
|
||||
// Kropla musi WYSTAWAĆ ponad krawędź paska. Bez tego warunku wzór na styczność ma
|
||||
// rozwiązanie także wtedy, gdy kropla siedzi w całości w pasku: dla top=26, cy=45,
|
||||
// r=17, k=12 wychodzi dx≈28,1, choć kropla sięga tylko do y=28, czyli jest pod
|
||||
// krawędzią. Powstałoby gniazdo, w które nie ma się co wtopić. Widać to dopiero
|
||||
// przy sprawdzeniu przypadków brzegowych, nie po samym wzorze.
|
||||
const protrudes = cy - r < top;
|
||||
const dxSq = (r + k) * (r + k) - (top + k - cy) * (top + k - cy);
|
||||
const dx = protrudes ? safeSqrt(dxSq) : null;
|
||||
|
||||
// Brak styczności albo kropla nie wystaje → sam pasek, bez gniazda.
|
||||
if (dx === null) {
|
||||
return plainBar(width, top, bottom, br);
|
||||
}
|
||||
|
||||
// Środki łuków stycznych, po obu stronach kropli.
|
||||
const fLeftX = cx - dx;
|
||||
const fRightX = cx + dx;
|
||||
const fy = top + k;
|
||||
|
||||
// Punkty styczności łuk↔kropla: na prostej środek-kropli → środek-łuku, w odległości r.
|
||||
const tp = (fx: number) => {
|
||||
const vx = fx - cx;
|
||||
const vy = fy - cy;
|
||||
const len = Math.hypot(vx, vy) || 1;
|
||||
return { x: cx + (vx / len) * r, y: cy + (vy / len) * r };
|
||||
};
|
||||
const tLeft = tp(fLeftX);
|
||||
const tRight = tp(fRightX);
|
||||
|
||||
// Rysujemy zgodnie z ruchem wskazówek: górna krawędź od lewej, gniazdo, prawa krawędź,
|
||||
// dół paska z powrotem. `sweepFlag` łuków gniazda = 0 (wklęsłe), kropli = 1 (wypukłe).
|
||||
return [
|
||||
`M ${br} ${top}`,
|
||||
`L ${fLeftX} ${top}`,
|
||||
`A ${k} ${k} 0 0 0 ${tLeft.x} ${tLeft.y}`,
|
||||
`A ${r} ${r} 0 0 1 ${tRight.x} ${tRight.y}`,
|
||||
`A ${k} ${k} 0 0 0 ${fRightX} ${top}`,
|
||||
`L ${width - br} ${top}`,
|
||||
`A ${br} ${br} 0 0 1 ${width - br} ${bottom}`,
|
||||
`L ${br} ${bottom}`,
|
||||
`A ${br} ${br} 0 0 1 ${br} ${top}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Sam pasek, bez gniazda. Używane też jako stan spoczynku. */
|
||||
export function plainBar(width: number, top: number, bottom: number, br: number): string {
|
||||
return [
|
||||
`M ${br} ${top}`,
|
||||
`L ${width - br} ${top}`,
|
||||
`A ${br} ${br} 0 0 1 ${width - br} ${bottom}`,
|
||||
`L ${br} ${bottom}`,
|
||||
`A ${br} ${br} 0 0 1 ${br} ${top}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
|
@ -4,11 +4,12 @@ import {
|
|||
useNavigationContainerRef,
|
||||
} from '@react-navigation/native';
|
||||
import { BugReportFAB } from './components/BugReportFAB';
|
||||
import { LiquidTabs } from './components/LiquidTabs';
|
||||
import { GoonMark } from './components/GoonWordmark';
|
||||
import { GoonClient } from './api';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import React from 'react';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
||||
import { BlacklistScreen } from './screens/BlacklistScreen';
|
||||
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
||||
|
|
@ -117,38 +118,17 @@ function TopTabs({
|
|||
onNavigate: (tab: TopTab) => void;
|
||||
}) {
|
||||
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
|
||||
// Piąta zakładka (Anime) nie mieści się na wąskich telefonach obok akcji po prawej,
|
||||
// więc pasek przewija się w poziomie zamiast nachodzić. `flexShrink` trzyma go w swojej
|
||||
// połowie headera, bez tego ScrollView rozpycha się na całą szerokość.
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ flexGrow: 0, flexShrink: 1 }}
|
||||
contentContainerStyle={{ flexDirection: 'row', gap: 10, paddingHorizontal: 10, alignItems: 'center' }}
|
||||
>
|
||||
{/* Logo (mark), spójne z ekranem logowania; węższe od wordmarku, więc Scenes/Movies/Sites
|
||||
+ akcje po prawej mieszczą się bez nachodzenia na wąskich telefonach. */}
|
||||
<GoonMark size={30} />
|
||||
<View style={{ width: 1, height: 18, backgroundColor: theme.border, marginRight: 2 }} />
|
||||
{tabs.map((t) => {
|
||||
const active = t === current;
|
||||
return (
|
||||
<Pressable key={t} onPress={() => (active ? null : onNavigate(t))} hitSlop={12}>
|
||||
<Text
|
||||
style={{
|
||||
color: active ? theme.accent : theme.muted,
|
||||
fontSize: 13,
|
||||
fontFamily: active ? fonts.display : fonts.medium,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
// Logo + separator zostają statyczne, przewija się (i „roztapia") tylko pasek.
|
||||
// `flexShrink` trzyma całość w swojej połowie nagłówka: bez tego pasek rozpycha
|
||||
// się na całą szerokość i nachodzi na ikony po prawej.
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingLeft: 10, flexShrink: 1 }}>
|
||||
<GoonMark size={28} />
|
||||
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
|
||||
<View style={{ flex: 1, minWidth: 170 }}>
|
||||
<LiquidTabs tabs={tabs} current={current} onSelect={onNavigate} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue