Skip to content

Commit 104ed60

Browse files
MikitasKclaude
andcommitted
fix: apply Reanimated styles passed to Searchbar's style prop
Broadening the `style` type wasn't enough on its own: `StyleSheet.flatten` silently drops the opaque handle `useAnimatedStyle` returns, so the animated values never reached a view. Now that `Surface` is Reanimated-based (#5078), split `style` into the entries that can be read during render and the ones that can't. Static entries keep being flattened and split between the wrapper and the `Surface` as before; animated entries — the `useAnimatedStyle` handle, or an object holding shared values — are forwarded untouched to the outer `Reanimated.View`, which is where their static counterparts (layout, margins, transform, opacity) already go. `style` is typed as `StyleProp<SurfaceStyle>` so the properties `Surface` now takes as props are rejected at the type level instead of being ignored. One caveat, documented on the prop: an animated horizontal margin can't be read during render, so it doesn't replace the built-in focus margin the way a static one does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CXgTWJSnvLKQZ2Kkw1Qhkj
1 parent 2d3e072 commit 104ed60

2 files changed

Lines changed: 117 additions & 10 deletions

File tree

‎src/components/Searchbar/Searchbar.tsx‎

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import type {
1010
} from 'react-native';
1111

1212
import Reanimated, {
13+
isSharedValue,
1314
ReduceMotion,
1415
useAnimatedStyle,
1516
useSharedValue,
1617
withSpring,
1718
} from 'react-native-reanimated';
19+
import type { AnimatedStyle } from 'react-native-reanimated';
1820

1921
import { SearchbarTokens } from './tokens';
2022
import { getSearchbarColors, getSearchbarInputFont } from './utils';
@@ -31,6 +33,7 @@ import type { IconSource } from '../Icon';
3133
import IconButton from '../IconButton/IconButton';
3234
import MaterialCommunityIcon from '../MaterialCommunityIcon';
3335
import Surface from '../Surface';
36+
import type { SurfaceStyle } from '../Surface';
3437

3538
interface Style {
3639
marginRight: number;
@@ -83,6 +86,54 @@ const HORIZONTAL_MARGIN_KEYS = [
8386
'marginInlineEnd',
8487
] as const;
8588

89+
/**
90+
* Whether a style entry hides its values from the JS thread, i.e. whether it is
91+
* a Reanimated animated style: either the opaque handle returned by
92+
* `useAnimatedStyle`, or a plain object holding shared values. Such an entry
93+
* cannot be read during render and `StyleSheet.flatten` would silently drop it,
94+
* so it has to be forwarded to a Reanimated view untouched.
95+
*/
96+
function isAnimatedStyle(style: object) {
97+
return 'viewDescriptors' in style || Object.values(style).some(isSharedValue);
98+
}
99+
100+
type StyleEntry = StyleProp<SurfaceStyle> | readonly StyleProp<SurfaceStyle>[];
101+
102+
/**
103+
* Split the `style` prop into the part that can be inspected during render and
104+
* the animated entries that can't, keeping the latter intact.
105+
*/
106+
function splitAnimatedStyles(
107+
style: StyleProp<SurfaceStyle>
108+
): [ViewStyle, StyleProp<AnimatedStyle<ViewStyle>>[]] {
109+
const staticStyles: ViewStyle[] = [];
110+
const animatedStyles: StyleProp<AnimatedStyle<ViewStyle>>[] = [];
111+
112+
const collect = (entry: StyleEntry) => {
113+
if (!entry) {
114+
return;
115+
}
116+
117+
if (Array.isArray(entry)) {
118+
entry.forEach(collect);
119+
return;
120+
}
121+
122+
if (typeof entry === 'object' && isAnimatedStyle(entry)) {
123+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
124+
animatedStyles.push(entry as StyleProp<AnimatedStyle<ViewStyle>>);
125+
return;
126+
}
127+
128+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
129+
staticStyles.push(entry as ViewStyle);
130+
};
131+
132+
collect(style);
133+
134+
return [StyleSheet.flatten(staticStyles), animatedStyles];
135+
}
136+
86137
export type Props = Omit<TextInputProps, 'style'> & {
87138
/**
88139
* Hint text shown when the input is empty.
@@ -175,7 +226,18 @@ export type Props = Omit<TextInputProps, 'style'> & {
175226
* Set style of the TextInput component inside the searchbar
176227
*/
177228
inputStyle?: StyleProp<TextStyle>;
178-
style?: StyleProp<ViewStyle>;
229+
/**
230+
* Style of the Searchbar.
231+
*
232+
* Layout and margin properties are applied to the outer wrapper, the rest to
233+
* the `Surface` that draws the bar. Background color and border radius should
234+
* be set through the `theme` instead.
235+
*
236+
* Reanimated styles are supported and are applied to the outer wrapper. Their
237+
* values can't be read during render, so an animated horizontal margin does
238+
* not replace the built-in focus margin the way a static one does.
239+
*/
240+
style?: StyleProp<SurfaceStyle>;
179241
/**
180242
* Custom flag for replacing clear button with activity indicator.
181243
*/
@@ -343,27 +405,29 @@ const Searchbar = ({
343405
marginRight: focusMargin.value,
344406
}));
345407

408+
const [staticStyle, animatedStyles] = splitAnimatedStyles(style);
409+
346410
// A consumer-provided horizontal margin wins over the built-in one and
347411
// disables the focus transition.
348-
const flatStyle = StyleSheet.flatten<ViewStyle>(style);
349412
const hasCustomHorizontalMargin = HORIZONTAL_MARGIN_KEYS.some(
350-
(key) => flatStyle?.[key] !== undefined
413+
(key) => staticStyle[key] !== undefined
351414
);
352415
const applyFocusMargin = isContained && !hasCustomHorizontalMargin;
353416
const [surfaceStyle, wrapperStyle] = splitStyles(
354-
flatStyle || {},
417+
staticStyle,
355418
(key) => OUTER_LAYOUT_STYLE_KEYS.includes(key) || key.startsWith('margin')
356419
);
357420
const shouldFillWrapper = VERTICAL_FILL_STYLE_KEYS.some(
358-
(key) => flatStyle?.[key] !== undefined
421+
(key) => staticStyle[key] !== undefined
359422
);
360423
const hasWrapperStyle = Object.keys(wrapperStyle).length > 0;
424+
const outerStyle =
425+
hasWrapperStyle || animatedStyles.length > 0
426+
? [wrapperStyle, ...animatedStyles]
427+
: null;
361428

362429
return (
363-
<Reanimated.View
364-
style={hasWrapperStyle ? wrapperStyle : null}
365-
testID={`${testID}-wrapper`}
366-
>
430+
<Reanimated.View style={outerStyle} testID={`${testID}-wrapper`}>
367431
<Reanimated.View
368432
style={applyFocusMargin ? containedMarginStyle : null}
369433
testID={`${testID}-focus-wrapper`}

‎src/components/__tests__/Searchbar.test.tsx‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,39 @@
1-
import { Text } from 'react-native';
1+
import { Pressable, Text } from 'react-native';
22

33
import { expect, it, jest } from '@jest/globals';
44
import { fireEvent, userEvent } from '@testing-library/react-native';
5+
import {
6+
getAnimatedStyle,
7+
useAnimatedStyle,
8+
useSharedValue,
9+
} from 'react-native-reanimated';
510

611
import { getTheme } from '../../core/theming';
712
import { render, screen } from '../../test-utils';
813
import * as Avatar from '../Avatar/Avatar';
914
import Searchbar from '../Searchbar';
1015

16+
const AnimatedSearchbar = () => {
17+
const opacity = useSharedValue(0);
18+
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
19+
20+
return (
21+
<>
22+
<Pressable
23+
testID="animate-search-bar"
24+
onPress={() => {
25+
opacity.value = 1;
26+
}}
27+
/>
28+
<Searchbar
29+
testID="search-bar"
30+
value={''}
31+
style={[{ padding: 8 }, animatedStyle]}
32+
/>
33+
</>
34+
);
35+
};
36+
1137
it('renders with placeholder', async () => {
1238
const tree = (
1339
await render(<Searchbar placeholder="Search" value="" />)
@@ -271,6 +297,23 @@ it('keeps layout styles on the outermost element', async () => {
271297
});
272298
});
273299

300+
it('applies Reanimated styles from the style prop to the outer wrapper', async () => {
301+
await render(<AnimatedSearchbar />);
302+
303+
const wrapper = screen.getByTestId('search-bar-wrapper');
304+
305+
expect(getAnimatedStyle(wrapper)).toMatchObject({ opacity: 0 });
306+
// Static entries are still split off and land on the Surface.
307+
expect(screen.getByTestId('search-bar-container')).toHaveStyle({
308+
padding: 8,
309+
});
310+
311+
await userEvent.press(screen.getByTestId('animate-search-bar'));
312+
await jest.runAllTimersAsync();
313+
314+
expect(getAnimatedStyle(wrapper)).toMatchObject({ opacity: 1 });
315+
});
316+
274317
it('forwards onFocus and onBlur to the input', async () => {
275318
const onFocus = jest.fn();
276319
const onBlur = jest.fn();

0 commit comments

Comments
 (0)