mobile: skeleton grid while scene lists load (perceived perf)
Scene-list screens showed a small spinner while waiting on the API, so a slow list read felt like a blank stall. Replace the initial-load spinner on ScenesScreen and TagScenesScreen with a SceneGridSkeleton — a 2-col grid of pulsing placeholder tiles laid out 1:1 with SceneTile (16:9 thumb + title + meta lines). It paints instantly with zero data, so the screen feels responsive even when the query takes a moment, and the skeleton->content swap doesn't reflow. Pairs with the backend list-count fix (most filtered lists are now ~0.1s); the skeleton also masks the residual slow path (enormous tags) so it no longer reads as a freeze. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
983bf62416
commit
24fc790691
3 changed files with 91 additions and 3 deletions
81
mobile/src/components/SceneGridSkeleton.tsx
Normal file
81
mobile/src/components/SceneGridSkeleton.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* SceneGridSkeleton — placeholder grid pokazywany podczas ŁADOWANIA listy scen,
|
||||||
|
* zamiast spinnera. Rysuje się natychmiast (zero danych), więc ekran "ożywa" w ms
|
||||||
|
* nawet gdy backend liczy kilka sekund — percepcyjna szybkość jak w dużych apkach
|
||||||
|
* (YT/Netflix pokazują szkielety/placeholdery zanim dane dojdą).
|
||||||
|
*
|
||||||
|
* Layout 1:1 z SceneTile (2-col, thumb 16:9 + linia tytułu + linia meta), żeby
|
||||||
|
* przejście skeleton→content nie "skakało". Subtelny pulse (Animated opacity loop),
|
||||||
|
* bez zewnętrznych zależności.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Animated, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
|
import { theme } from '../theme';
|
||||||
|
|
||||||
|
function usepulse() {
|
||||||
|
const v = React.useRef(new Animated.Value(0.4)).current;
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(v, { toValue: 0.85, duration: 700, useNativeDriver: true }),
|
||||||
|
Animated.timing(v, { toValue: 0.4, duration: 700, useNativeDriver: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [v]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonTile({ opacity }: { opacity: Animated.Value }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.tile}>
|
||||||
|
<Animated.View style={[styles.thumb, { opacity }]} />
|
||||||
|
<Animated.View style={[styles.lineTitle, { opacity }]} />
|
||||||
|
<Animated.View style={[styles.lineMeta, { opacity }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `count` kafelków (parzysta liczba dla 2-col siatki). */
|
||||||
|
export function SceneGridSkeleton({ count = 8 }: { count?: number }) {
|
||||||
|
const opacity = usepulse();
|
||||||
|
const rows = Math.ceil(count / 2);
|
||||||
|
return (
|
||||||
|
<View style={styles.grid} pointerEvents="none">
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<View key={r} style={styles.row}>
|
||||||
|
<SkeletonTile opacity={opacity} />
|
||||||
|
<SkeletonTile opacity={opacity} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: { marginTop: 2 },
|
||||||
|
row: { flexDirection: 'row', gap: 10, marginBottom: 14 },
|
||||||
|
tile: { flex: 1 },
|
||||||
|
thumb: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: theme.bgElevated,
|
||||||
|
},
|
||||||
|
lineTitle: {
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: theme.bgElevated,
|
||||||
|
marginTop: 8,
|
||||||
|
width: '85%',
|
||||||
|
},
|
||||||
|
lineMeta: {
|
||||||
|
height: 9,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: theme.bgElevated,
|
||||||
|
marginTop: 6,
|
||||||
|
width: '55%',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { SceneTile } from '../components/SceneTile';
|
import { SceneTile } from '../components/SceneTile';
|
||||||
|
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
|
||||||
import { Thumb } from '../components/Thumb';
|
import { Thumb } from '../components/Thumb';
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
|
@ -119,9 +120,11 @@ export function ScenesScreen() {
|
||||||
<FavoritesButton />
|
<FavoritesButton />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isLoading && <ActivityIndicator color={theme.fg} />}
|
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<SceneGridSkeleton count={8} />
|
||||||
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(s) => s.id}
|
keyExtractor={(s) => s.id}
|
||||||
|
|
@ -145,6 +148,7 @@ export function ScenesScreen() {
|
||||||
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScenesFilterModal
|
<ScenesFilterModal
|
||||||
visible={filterOpen}
|
visible={filterOpen}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
FlatList,
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
|
@ -12,6 +11,7 @@ import {
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SceneTile } from '../components/SceneTile';
|
import { SceneTile } from '../components/SceneTile';
|
||||||
|
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
|
@ -41,9 +41,11 @@ export function TagScenesScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{isLoading && <ActivityIndicator color={theme.fg} />}
|
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<SceneGridSkeleton count={10} />
|
||||||
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data?.items ?? []}
|
data={data?.items ?? []}
|
||||||
keyExtractor={(s) => s.id}
|
keyExtractor={(s) => s.id}
|
||||||
|
|
@ -62,6 +64,7 @@ export function TagScenesScreen() {
|
||||||
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue