From 6d338219ea76ba8e3462e9b4315ea51bf041bc37 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Tue, 18 Aug 2026 11:22:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20przesuwane=20podkreslenie=20zamiast?= =?UTF-8?q?=20ciek=C5=82ej=20kropli=20w=20zakladkach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zgloszenie 2026-08-18: kropla "zrobila wiecej zlego niz dobrego". Trzy zarzuty, wszystkie trafne: - nie wygladala ladniej, - rysunek SVG mial 44 px i wystawal ponad pasek, wiec naglowek spuchl i rozjechalo sie wyrownanie wiersza do srodka, - morfowanie ksztaltu nie przypominalo wzoru. Research wzorcow z liderow (X/Twitter, YouTube, Reddit, App Store, Duolingo, apki bankowe): gorne zakladki to wszedzie plaski, powsciagliwy pasek STALEJ WYSOKOSCI z PRZESUWANYM wskaznikiem, nie morfowanym. Uzytkownik wybral podkreslenie (wzorzec dominujacy na Androidzie). TopTabsBar: aktywna zakladka = accentText + cienka kreska hugujaca tekst, przejezdza w 220 ms ease-out (translateX + width z pomiarow onLayout). Wysokosc = wiersz tekstu, wiec logo/zakladki/ikony znow sa w jednej linii. Naglowek wraca na theme.card (trik z theme.bg byl tylko po to, zeby kropla byla widoczna). Usuniete: LiquidTabs.tsx + lib/liquidTabPath.ts (caly aparat SVG-morph, martwy). Zweryfikowane na emulatorze nagraniem klatka po klatce: kreska przejezdza Anime->Scenes mijajac zakladki, kolory sie przelaczaja, laduje pod aktywna; wiersz wyrownany. Co-Authored-By: Claude Opus 5 --- mobile/src/changelog.ts | 7 ++ mobile/src/components/LiquidTabs.tsx | 154 --------------------------- mobile/src/components/TopTabsBar.tsx | 137 ++++++++++++++++++++++++ mobile/src/lib/liquidTabPath.ts | 118 -------------------- mobile/src/navigation.tsx | 18 ++-- 5 files changed, 151 insertions(+), 283 deletions(-) delete mode 100644 mobile/src/components/LiquidTabs.tsx create mode 100644 mobile/src/components/TopTabsBar.tsx delete mode 100644 mobile/src/lib/liquidTabPath.ts diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 5165b62..846c05c 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-18d', + date: 'August 2026', + items: [ + 'Reworked the top tabs. The blob from a few days ago sat too tall and threw the header out of line, and the animation never looked right. It is now a thin underline that slides to the tab you pick, the way most apps do it, and the row lines up again.', + ], + }, { id: '2026-08-18c', date: 'August 2026', diff --git a/mobile/src/components/LiquidTabs.tsx b/mobile/src/components/LiquidTabs.tsx deleted file mode 100644 index c1d2d14..0000000 --- a/mobile/src/components/LiquidTabs.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * 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, Easing, 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, - // Szybki start i miękkie dojście: kropla „dopływa" zamiast stawać jak wagon. - // Do 2026-08-18 ten kod nigdy nie działał (pasek montował się od nowa przy każdym - // przełączeniu, patrz HomeScreen w navigation.tsx), więc easing nie miał znaczenia. - duration: 260, - easing: Easing.out(Easing.cubic), - 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/components/TopTabsBar.tsx b/mobile/src/components/TopTabsBar.tsx new file mode 100644 index 0000000..7b50899 --- /dev/null +++ b/mobile/src/components/TopTabsBar.tsx @@ -0,0 +1,137 @@ +/** + * Górny pasek zakładek: aktywną wskazuje przesuwane podkreślenie, wzorzec z X/YouTube/ + * Reddit i większości Androida. + * + * Zastąpił „ciekłą kroplę" (SVG morph, 2026-08-14). Kropla była trzema błędami naraz + * (zgłoszenie 2026-08-18): rysunek miał 44 px i wystawał ponad pasek, więc nagłówek + * spuchł i rozjechało się wyrównanie wiersza; morfowanie kształtu nie wyglądało ładniej + * ani jak wzór; a cała technika (przeliczanie ścieżki SVG w JS, ~12 re-renderów na + * przejście) była droga za nic. Tu wskaźnik to jeden mały PRZESUWANY, nie + * morfowany. + * + * Zasady zerżnięte z liderów rynku, bo tam są sprawdzone: + * - STAŁA WYSOKOŚĆ: pasek jest wysoki jak wiersz tekstu, nie rozpycha nagłówka. To + * wprost naprawia wyrównanie, którego zabrakło przy kropli. + * - PRZESUWANIE, nie morfowanie: podkreślenie jedzie w poziomie (translateX, natywny + * sterownik), szerokość dopasowana do tekstu aktywnej zakładki. + * - SZYBKO I DYSKRETNIE: 220 ms ease-out. Ruch jest tłem, nie gwiazdą. + * - Robi to KOLOR: aktywna = accentText + kreska, reszta = wyszarzona. + * + * Pozycje mierzymy `onLayout` (szerokości etykiet różnią się: „JAV" vs „Scenes"), więc + * kreska huguje tekst, a nie całą komórkę. Do pierwszego pomiaru kreski nie rysujemy + * (zero mignięcia z lewej). + */ +import React from 'react'; +import { Animated, Easing, LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { fonts, theme } from '../theme'; + +interface Props { + tabs: readonly T[]; + current: T; + onSelect: (tab: T) => void; +} + +export function TopTabsBar({ tabs, current, onSelect }: Props) { + const activeIndex = Math.max(0, tabs.indexOf(current)); + + // Pozycja komórki (x, szerokość) i szerokość samej etykiety, per zakładka. Kreska + // huguje etykietę: left = x komórki + wyśrodkowanie, width = szerokość etykiety. + const [tabLayout, setTabLayout] = React.useState>({}); + const [labelW, setLabelW] = React.useState>({}); + const ready = tabs.every((_, i) => tabLayout[i] && labelW[i] != null); + + // Jeden driver = float indeks aktywnej. Bez native drivera, bo animujemy też `width` + // (layout prop), a to i tak jeden 2 px pasek, nie 12 ścieżek SVG jak w kropli. + const anim = React.useRef(new Animated.Value(activeIndex)).current; + React.useEffect(() => { + Animated.timing(anim, { + toValue: activeIndex, + duration: 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }, [activeIndex, anim]); + + const onTabLayout = (i: number) => (e: LayoutChangeEvent) => { + const { x, width } = e.nativeEvent.layout; + setTabLayout((p) => (p[i] && p[i].x === x && p[i].w === width ? p : { ...p, [i]: { x, w: width } })); + }; + const onLabelLayout = (i: number) => (e: LayoutChangeEvent) => { + const w = e.nativeEvent.layout.width; + setLabelW((p) => (p[i] === w ? p : { ...p, [i]: w })); + }; + + const idxRange = tabs.map((_, i) => i); + // interpolate wymaga >=2 punktów wejścia; przy 1 zakładce kreska stoi statycznie. + const canInterp = ready && idxRange.length >= 2; + // Liczone TYLKO gdy zmierzone: przed pomiarem tabLayout[i] jest undefined i mapowanie + // by się wywaliło (crash 2026-08-18 „Cannot read property 'x' of undefined"). + const leftOut = ready ? tabs.map((_, i) => tabLayout[i].x + (tabLayout[i].w - labelW[i]) / 2) : []; + const widthOut = ready ? tabs.map((_, i) => labelW[i]) : []; + const translateX = canInterp + ? anim.interpolate({ inputRange: idxRange, outputRange: leftOut }) + : ready + ? leftOut[0] + : 0; + const underlineW = canInterp + ? anim.interpolate({ inputRange: idxRange, outputRange: widthOut }) + : ready + ? widthOut[0] + : 0; + + return ( + + + {tabs.map((t, i) => { + const active = i === activeIndex; + return ( + (active ? null : onSelect(t))} + > + + {t} + + + ); + })} + + {ready ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + wrap: { justifyContent: 'center' }, + row: { flexDirection: 'row', alignItems: 'center' }, + tab: { flex: 1, alignItems: 'center', paddingVertical: 4 }, + label: { + color: theme.muted, + fontSize: 13, + fontFamily: fonts.medium, + letterSpacing: 0.3, + }, + labelActive: { color: theme.accentText, fontFamily: fonts.display }, + underline: { + position: 'absolute', + bottom: 0, + left: 0, + height: 2, + borderRadius: 1, + backgroundColor: theme.accentText, + }, +}); diff --git a/mobile/src/lib/liquidTabPath.ts b/mobile/src/lib/liquidTabPath.ts deleted file mode 100644 index 2eb3e26..0000000 --- a/mobile/src/lib/liquidTabPath.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Ś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 368845d..f6d4214 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -4,7 +4,7 @@ import { useNavigationContainerRef, } from '@react-navigation/native'; import { BugReportFAB } from './components/BugReportFAB'; -import { LiquidTabs } from './components/LiquidTabs'; +import { TopTabsBar } from './components/TopTabsBar'; import { GoonMark } from './components/GoonWordmark'; import { GoonClient } from './api'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -122,11 +122,10 @@ type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime'; * Jeden ekran-gospodarz dla pięciu zakładek. Zakładka to STAN LOKALNY, nie trasa. * * Poprzednio każda zakładka była osobnym ekranem native stacka, a przełączenie robiło - * `navigation.replace(t)` — czyli wymieniało cały ekran RAZEM z nagłówkiem. LiquidTabs - * montował się od nowa z kroplą już na docelowej pozycji (pierwsze ustawienie jest - * celowo bez animacji), więc animacja „przelewania" nie miała fizycznej możliwości się - * wykonać: user widział statyczną, teleportującą się kropkę (zgłoszenie 2026-08-18). - * Pasek musi przeżywać przełączenie, żeby się animować, stąd jeden ekran. + * `navigation.replace(t)` — wymieniało cały ekran RAZEM z nagłówkiem, więc pasek zakładek + * montował się od nowa ze wskaźnikiem już na docelowej pozycji i nie mógł się animować + * (user widział teleport, zgłoszenie 2026-08-18). Pasek musi przeżywać przełączenie, + * żeby wskaźnik przejeżdżał, stąd jeden ekran-gospodarz. * * `key={tab}` na treści wymusza czysty remount przy przełączeniu — to samo, co robiło * `replace` (reset wyszukiwania/scrolla). Bez key przełączenie Scenes→JAV reużyłoby @@ -153,12 +152,9 @@ function HomeScreen({ {/* Własny nagłówek zamiast natywnego (headerShown:false): natywny należy do ekranu, a ten pasek musi przeżywać przełączanie zakładek. Ikony po prawej są teraz jednakowe na każdej zakładce (wcześniej ♥ i ⚙ miał tylko Scenes). */} - {/* Tło nagłówka MUSI różnić się od wypełnienia paska (theme.card), inaczej - roztopiony kształt jest niewidzialny i z całego efektu zostaje sama kropka — - to była druga połowa zgłoszenia „statyczna kropka" (2026-08-18). */} - + navigation.navigate('Donate')} hitSlop={12}>