chore(mobile): usun gorny pasek zakladek, zastapiony dolnym

This commit is contained in:
goon-foss 2026-09-07 11:23:56 +02:00
parent b847b300bf
commit dce738b8ab

View file

@ -1,137 +0,0 @@
/**
* 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,
},
});