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).
118 lines
4.8 KiB
TypeScript
118 lines
4.8 KiB
TypeScript
/**
|
|
* Ś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(' ');
|
|
}
|