fix(ui): przesuwane podkreslenie zamiast ciekłej kropli w zakladkach
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 <noreply@anthropic.com>
This commit is contained in:
parent
0fed952c5c
commit
6d338219ea
5 changed files with 151 additions and 283 deletions
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-08-18c',
|
||||||
date: 'August 2026',
|
date: 'August 2026',
|
||||||
|
|
|
||||||
|
|
@ -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<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,
|
|
||||||
// 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 (
|
|
||||||
<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 },
|
|
||||||
});
|
|
||||||
137
mobile/src/components/TopTabsBar.tsx
Normal file
137
mobile/src/components/TopTabsBar.tsx
Normal file
|
|
@ -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 <View> 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<T extends string> {
|
||||||
|
tabs: readonly T[];
|
||||||
|
current: T;
|
||||||
|
onSelect: (tab: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopTabsBar<T extends string>({ tabs, current, onSelect }: Props<T>) {
|
||||||
|
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<Record<number, { x: number; w: number }>>({});
|
||||||
|
const [labelW, setLabelW] = React.useState<Record<number, number>>({});
|
||||||
|
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 (
|
||||||
|
<View style={styles.wrap}>
|
||||||
|
<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 }}
|
||||||
|
onLayout={onTabLayout(i)}
|
||||||
|
onPress={() => (active ? null : onSelect(t))}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
onLayout={onLabelLayout(i)}
|
||||||
|
style={[styles.label, active ? styles.labelActive : null]}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{ready ? (
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.underline, { width: underlineW, transform: [{ translateX }] }]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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 `<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,7 +4,7 @@ import {
|
||||||
useNavigationContainerRef,
|
useNavigationContainerRef,
|
||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import { BugReportFAB } from './components/BugReportFAB';
|
import { BugReportFAB } from './components/BugReportFAB';
|
||||||
import { LiquidTabs } from './components/LiquidTabs';
|
import { TopTabsBar } from './components/TopTabsBar';
|
||||||
import { GoonMark } from './components/GoonWordmark';
|
import { GoonMark } from './components/GoonWordmark';
|
||||||
import { GoonClient } from './api';
|
import { GoonClient } from './api';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
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.
|
* 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
|
* 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
|
* `navigation.replace(t)` — wymieniało cały ekran RAZEM z nagłówkiem, więc pasek zakładek
|
||||||
* montował się od nowa z kroplą już na docelowej pozycji (pierwsze ustawienie jest
|
* montował się od nowa ze wskaźnikiem już na docelowej pozycji i nie mógł się animować
|
||||||
* celowo bez animacji), więc animacja „przelewania" nie miała fizycznej możliwości się
|
* (user widział teleport, zgłoszenie 2026-08-18). Pasek musi przeżywać przełączenie,
|
||||||
* wykonać: user widział statyczną, teleportującą się kropkę (zgłoszenie 2026-08-18).
|
* żeby wskaźnik przejeżdżał, stąd jeden ekran-gospodarz.
|
||||||
* Pasek musi przeżywać przełączenie, żeby się animować, stąd jeden ekran.
|
|
||||||
*
|
*
|
||||||
* `key={tab}` na treści wymusza czysty remount przy przełączeniu — to samo, co robiło
|
* `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
|
* `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
|
{/* 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ą
|
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). */}
|
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). */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.bg,
|
backgroundColor: theme.card,
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -171,7 +167,7 @@ function HomeScreen({
|
||||||
<GoonMark size={28} />
|
<GoonMark size={28} />
|
||||||
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
|
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
|
||||||
<View style={{ flex: 1, minWidth: 170 }}>
|
<View style={{ flex: 1, minWidth: 170 }}>
|
||||||
<LiquidTabs tabs={tabs} current={tab} onSelect={setTab} />
|
<TopTabsBar tabs={tabs} current={tab} onSelect={setTab} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center', marginLeft: 6 }}>
|
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center', marginLeft: 6 }}>
|
||||||
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
|
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue