Skip to content

Commit cae8574

Browse files
feat: add pressable scale animations to drawer selectors (#16)
* feat: add pressable scale animations to drawer selectors - Install pressto package - Replace Pressable with PressableScale in all mobile menu selectors - Add animated border transition for theme color picker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use useDerivedValue for color option animation Replace useSharedValue + useEffect with useDerivedValue for cleaner, more idiomatic Reanimated code. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: prevent panel layout flash on web initial render On web, useWindowDimensions returns 0 on first render before actual dimensions are available. This caused isMobile to be true initially, then switch to false, causing a visual jump from mobile to desktop layout. Now defaults to desktop layout when width is 0 on web. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: prevent panel layout flash on web initial render" This reverts commit d85628d. * fix: prevent panel layout flash on web with FadeIn animation - Wait for screen dimensions before rendering panel on web - Use FadeIn layout animation for smooth initial appearance - Simplify menu open/close animation with direct shared value updates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 37e53b0 commit cae8574

9 files changed

Lines changed: 111 additions & 59 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

example/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"expo-linking": "~8.0.0",
2929
"expo-router": "~6.0.23",
3030
"expo-status-bar": "~3.0.0",
31+
"pressto": "^0.6.1",
3132
"react": "19.1.0",
3233
"react-dom": "19.1.0",
3334
"react-native": "0.81.5",

example/src/components/panel/index.tsx

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, { useCallback, useState, useEffect, useRef } from 'react';
1+
import React, { useCallback, useState } from 'react';
22
import { StyleSheet, View, Linking } from 'react-native';
33
import Animated, {
44
useSharedValue,
55
useAnimatedStyle,
66
withTiming,
7+
FadeIn,
78
type SharedValue,
89
} from 'react-native-reanimated';
910
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -23,18 +24,15 @@ import { HoverPressable } from '../hover-pressable';
2324
import { useResponsive } from '../../hooks/use-responsive';
2425
import { qrcodeState$ } from '../../states';
2526
import { FeatureFlags } from '../../constants';
26-
import {
27-
Colors,
28-
Spacing,
29-
Sizes,
30-
BorderRadius,
31-
} from '../../design-tokens';
27+
import { Colors, Spacing, Sizes, BorderRadius } from '../../design-tokens';
3228

3329
const Separator = () => <View style={styles.separator} />;
3430

3531
const GitHubButton = () => {
3632
const onPress = useCallback(() => {
37-
Linking.openURL('https://github.com/enzomanuelmangano/react-native-qrcode-skia');
33+
Linking.openURL(
34+
'https://github.com/enzomanuelmangano/react-native-qrcode-skia'
35+
);
3836
}, []);
3937

4038
return (
@@ -55,36 +53,40 @@ interface PanelProps {
5553

5654
export const Panel = ({ onURLButtonPress, drawerProgress }: PanelProps) => {
5755
const insets = useSafeAreaInsets();
58-
const { isMobile } = useResponsive();
56+
const { isMobile, isReady } = useResponsive();
5957
const [menuVisible, setMenuVisible] = useState(false);
6058
const panelAnimation = useSharedValue(1);
61-
const isFirstRender = useRef(true);
62-
63-
useEffect(() => {
64-
if (isFirstRender.current) {
65-
isFirstRender.current = false;
66-
return;
67-
}
68-
panelAnimation.value = withTiming(menuVisible ? 0 : 1, TimingPresets.panelFade);
69-
}, [menuVisible, panelAnimation]);
7059

7160
const panelStyle = useAnimatedStyle(() => ({
7261
opacity: panelAnimation.value,
7362
transform: [{ scale: 0.95 + panelAnimation.value * 0.05 }],
7463
}));
7564

7665
const openMenu = useCallback(() => {
66+
panelAnimation.value = withTiming(0, TimingPresets.panelFade);
7767
setMenuVisible(true);
78-
}, []);
68+
}, [panelAnimation]);
7969

8070
const closeMenu = useCallback(() => {
71+
panelAnimation.value = withTiming(1, TimingPresets.panelFade);
8172
setMenuVisible(false);
82-
}, []);
73+
}, [panelAnimation]);
74+
75+
if (!isReady) {
76+
return null;
77+
}
8378

8479
if (isMobile) {
8580
return (
8681
<>
87-
<Animated.View style={[styles.container, { bottom: Math.max(insets.bottom, 16) }, panelStyle]}>
82+
<Animated.View
83+
entering={FadeIn}
84+
style={[
85+
styles.container,
86+
{ bottom: Math.max(insets.bottom, 16) },
87+
panelStyle,
88+
]}
89+
>
8890
<View style={styles.mobilePanel}>
8991
<HoverPressable
9092
style={styles.iconButton}
@@ -101,13 +103,20 @@ export const Panel = ({ onURLButtonPress, drawerProgress }: PanelProps) => {
101103
</View>
102104
</View>
103105
</Animated.View>
104-
<MobileMenu visible={menuVisible} onClose={closeMenu} progress={drawerProgress} />
106+
<MobileMenu
107+
visible={menuVisible}
108+
onClose={closeMenu}
109+
progress={drawerProgress}
110+
/>
105111
</>
106112
);
107113
}
108114

109115
return (
110-
<View style={[styles.container, { bottom: Math.max(insets.bottom, 24) }]}>
116+
<Animated.View
117+
entering={FadeIn}
118+
style={[styles.container, { bottom: Math.max(insets.bottom, 24) }]}
119+
>
111120
<View style={styles.panel}>
112121
<View style={styles.controls}>
113122
<URLButton onPress={onURLButtonPress} />
@@ -129,7 +138,7 @@ export const Panel = ({ onURLButtonPress, drawerProgress }: PanelProps) => {
129138
<GitHubButton />
130139
</View>
131140
</View>
132-
</View>
141+
</Animated.View>
133142
);
134143
};
135144

example/src/components/panel/mobile-menu/eye-selector.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useCallback } from 'react';
2-
import { View, Pressable } from 'react-native';
2+
import { View } from 'react-native';
3+
import { PressableScale } from 'pressto';
34
import Svg, { Path } from 'react-native-svg';
45
import * as Burnt from '../../../utils/toast';
56
import { useSelector } from '@legendapp/state/react';
@@ -35,15 +36,15 @@ export const EyeSelector = () => {
3536
const isSelected = shape === currentShape;
3637
const shapePath = getPathFromShape(shape, 16);
3738
return (
38-
<Pressable
39+
<PressableScale
3940
key={shape}
4041
onPress={() => handleSelect(shape)}
4142
style={[styles.shapeOption, isSelected && { backgroundColor: themeColor }]}
4243
>
4344
<Svg width={16} height={16} viewBox="0 0 16 16">
4445
<Path d={shapePath} fill={Colors.textPrimary} />
4546
</Svg>
46-
</Pressable>
47+
</PressableScale>
4748
);
4849
})}
4950
</View>

example/src/components/panel/mobile-menu/gap-selector.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useCallback } from 'react';
2-
import { View, Text, Pressable } from 'react-native';
2+
import { View, Text } from 'react-native';
3+
import { PressableScale } from 'pressto';
34
import * as Burnt from '../../../utils/toast';
45
import { useSelector } from '@legendapp/state/react';
56
import { qrcodeState$, GapSizes, type GapSize } from '../../../states';
@@ -27,7 +28,7 @@ export const GapSelector = () => {
2728
{GapSizes.map((size) => {
2829
const isSelected = size === currentGap;
2930
return (
30-
<Pressable
31+
<PressableScale
3132
key={size}
3233
onPress={() => handleSelect(size)}
3334
style={[
@@ -38,7 +39,7 @@ export const GapSelector = () => {
3839
<Text style={[styles.gapText, isSelected && styles.gapTextSelected]}>
3940
{size}
4041
</Text>
41-
</Pressable>
42+
</PressableScale>
4243
);
4344
})}
4445
</View>

example/src/components/panel/mobile-menu/logo-selector.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useCallback } from 'react';
2-
import { View, Text, Pressable } from 'react-native';
2+
import { View, Text } from 'react-native';
3+
import { PressableScale } from 'pressto';
34
import * as Burnt from '../../../utils/toast';
45
import { useSelector } from '@legendapp/state/react';
56
import { qrcodeState$, LogoEmojis } from '../../../states';
@@ -27,13 +28,13 @@ export const LogoSelector = () => {
2728
{LogoEmojis.map((emoji, index) => {
2829
const isSelected = emoji === selectedLogo;
2930
return (
30-
<Pressable
31+
<PressableScale
3132
key={index}
3233
onPress={() => handleSelect(emoji)}
3334
style={[styles.logoOption, isSelected && { backgroundColor: themeColor }]}
3435
>
3536
<Text style={styles.logoEmoji}>{emoji || '—'}</Text>
36-
</Pressable>
37+
</PressableScale>
3738
);
3839
})}
3940
</View>

example/src/components/panel/mobile-menu/shape-selector.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useCallback } from 'react';
2-
import { View, Pressable } from 'react-native';
2+
import { View } from 'react-native';
3+
import { PressableScale } from 'pressto';
34
import Svg, { Path } from 'react-native-svg';
45
import * as Burnt from '../../../utils/toast';
56
import { useSelector } from '@legendapp/state/react';
@@ -35,15 +36,15 @@ export const ShapeSelector = () => {
3536
const isSelected = shape === currentShape;
3637
const shapePath = getPathFromShape(shape, 16);
3738
return (
38-
<Pressable
39+
<PressableScale
3940
key={shape}
4041
onPress={() => handleSelect(shape)}
4142
style={[styles.shapeOption, isSelected && { backgroundColor: themeColor }]}
4243
>
4344
<Svg width={16} height={16} viewBox="0 0 16 16">
4445
<Path d={shapePath} fill={Colors.textPrimary} />
4546
</Svg>
46-
</Pressable>
47+
</PressableScale>
4748
);
4849
})}
4950
</View>
Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import React, { useCallback } from 'react';
2-
import { View, Pressable } from 'react-native';
2+
import { View } from 'react-native';
3+
import { PressableScale } from 'pressto';
34
import { LinearGradient } from 'expo-linear-gradient';
5+
import Animated, {
6+
useDerivedValue,
7+
useAnimatedStyle,
8+
withTiming,
9+
interpolateColor,
10+
} from 'react-native-reanimated';
411
import * as Burnt from '../../../utils/toast';
512
import { useSelector } from '@legendapp/state/react';
613
import { qrcodeState$ } from '../../../states';
@@ -10,6 +17,43 @@ import { styles } from './styles';
1017
const formatThemeName = (name: string) =>
1118
name.charAt(0).toUpperCase() + name.slice(1);
1219

20+
const AnimatedPressableScale = Animated.createAnimatedComponent(PressableScale);
21+
22+
interface ColorOptionProps {
23+
themeName: ThemeName;
24+
isSelected: boolean;
25+
onPress: () => void;
26+
}
27+
28+
const ColorOption = ({ themeName, isSelected, onPress }: ColorOptionProps) => {
29+
const theme = Themes[themeName];
30+
const progress = useDerivedValue(() => {
31+
return withTiming(isSelected ? 1 : 0, { duration: 200 });
32+
}, [isSelected]);
33+
34+
const animatedStyle = useAnimatedStyle(() => ({
35+
borderColor: interpolateColor(
36+
progress.value,
37+
[0, 1],
38+
['transparent', theme.colors[0]]
39+
),
40+
}));
41+
42+
return (
43+
<AnimatedPressableScale
44+
onPress={onPress}
45+
style={[styles.colorOption, animatedStyle]}
46+
>
47+
<LinearGradient
48+
colors={[theme.colors[0], theme.colors[1]]}
49+
start={{ x: 0, y: 0 }}
50+
end={{ x: 1, y: 1 }}
51+
style={styles.colorCircle}
52+
/>
53+
</AnimatedPressableScale>
54+
);
55+
};
56+
1357
export const ThemeSelector = () => {
1458
const currentTheme = useSelector(qrcodeState$.currentTheme);
1559

@@ -26,27 +70,14 @@ export const ThemeSelector = () => {
2670

2771
return (
2872
<View style={styles.optionsRow}>
29-
{(Object.keys(Themes) as ThemeName[]).map((themeName) => {
30-
const theme = Themes[themeName];
31-
const isSelected = themeName === currentTheme;
32-
return (
33-
<Pressable
34-
key={themeName}
35-
onPress={() => handleSelect(themeName)}
36-
style={[
37-
styles.colorOption,
38-
isSelected && { borderColor: theme.colors[0] },
39-
]}
40-
>
41-
<LinearGradient
42-
colors={[theme.colors[0], theme.colors[1]]}
43-
start={{ x: 0, y: 0 }}
44-
end={{ x: 1, y: 1 }}
45-
style={styles.colorCircle}
46-
/>
47-
</Pressable>
48-
);
49-
})}
73+
{(Object.keys(Themes) as ThemeName[]).map((themeName) => (
74+
<ColorOption
75+
key={themeName}
76+
themeName={themeName}
77+
isSelected={themeName === currentTheme}
78+
onPress={() => handleSelect(themeName)}
79+
/>
80+
))}
5081
</View>
5182
);
5283
};

example/src/hooks/use-responsive.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useWindowDimensions } from 'react-native';
1+
import { useWindowDimensions, Platform } from 'react-native';
22

33
const BREAKPOINTS = {
44
// lg breakpoint - use compact layout below this
@@ -8,10 +8,14 @@ const BREAKPOINTS = {
88
export const useResponsive = () => {
99
const { width } = useWindowDimensions();
1010

11+
// On web, useWindowDimensions can return 0 on first render
12+
const isReady = Platform.OS !== 'web' || width > 0;
13+
1114
return {
1215
// Use compact layout below lg breakpoint
1316
isMobile: width < BREAKPOINTS.lg,
1417
isDesktop: width >= BREAKPOINTS.lg,
1518
width,
19+
isReady,
1620
};
1721
};

0 commit comments

Comments
 (0)