From e074229bbba695f2109bc002fc783c2dcb69adee Mon Sep 17 00:00:00 2001 From: goon-foss Date: Fri, 14 Aug 2026 10:13:30 +0200 Subject: [PATCH] feat(ui): pasek zakladek roztapia sie wokol aktywnej pozycji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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). --- mobile/src/changelog.ts | 7 ++ mobile/src/components/LiquidTabs.tsx | 150 +++++++++++++++++++++++++++ mobile/src/lib/liquidTabPath.ts | 118 +++++++++++++++++++++ mobile/src/navigation.tsx | 44 +++----- 4 files changed, 287 insertions(+), 32 deletions(-) create mode 100644 mobile/src/components/LiquidTabs.tsx create mode 100644 mobile/src/lib/liquidTabPath.ts diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 2aae77d..be8c5a6 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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', diff --git a/mobile/src/components/LiquidTabs.tsx b/mobile/src/components/LiquidTabs.tsx new file mode 100644 index 0000000..90a399b --- /dev/null +++ b/mobile/src/components/LiquidTabs.tsx @@ -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 { + tabs: readonly T[]; + current: T; + onSelect: (tab: T) => void; +} + +export function LiquidTabs({ tabs, current, onSelect }: Props) { + 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 ( + { + const w = Math.round(e.nativeEvent.layout.width); + if (w > 0 && w !== width) setWidth(w); + }} + > + {width > 0 ? ( + + {/* Pasek i kropla to JEDEN kształt, więc nie ma szwu na styku. */} + + {cx > 0 ? ( + + ) : null} + + ) : null} + + + {tabs.map((t, i) => { + const active = i === activeIndex; + return ( + (active ? null : onSelect(t))} + > + + {t} + + + ); + })} + + + ); +} + +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 }, +}); diff --git a/mobile/src/lib/liquidTabPath.ts b/mobile/src/lib/liquidTabPath.ts new file mode 100644 index 0000000..2eb3e26 --- /dev/null +++ b/mobile/src/lib/liquidTabPath.ts @@ -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 ``, + * 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 ``: 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(' '); +} diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index 1df1a53..a6134e4 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -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 ( - - {/* 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. */} - - - {tabs.map((t) => { - const active = t === current; - return ( - (active ? null : onNavigate(t))} hitSlop={12}> - - {t} - - - ); - })} - + // 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. + + + + + + + ); }