Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ const CONST = {
ANIMATED_PROGRESS_BAR_DELAY: 300,
ANIMATED_PROGRESS_BAR_OPACITY_DURATION: 300,
ANIMATED_PROGRESS_BAR_DURATION: 750,
PULSE_ANIMATION: {
FADE_OUT_DURATION: 400,
FADE_IN_DURATION: 350,
PAUSE_DURATION: 250,
RECOVERY_DURATION: 150,
},
ANIMATION_IN_TIMING: 100,
COMPOSER_FOCUS_DELAY: 150,
ANIMATION_DIRECTION: {
Expand Down
111 changes: 111 additions & 0 deletions src/components/PulsingView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, {useEffect} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Animated, {cancelAnimation, Easing, useAnimatedReaction, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming} from 'react-native-reanimated';
import type {AnimatedStyle, SharedValue} from 'react-native-reanimated';
import shouldRenderOffscreen from '@libs/shouldRenderOffscreen';
import CONST from '@src/CONST';

const {FADE_OUT_DURATION, FADE_IN_DURATION, PAUSE_DURATION, RECOVERY_DURATION} = CONST.PULSE_ANIMATION;

const EASING_OUT = Easing.out(Easing.quad);
const EASING_IN = Easing.in(Easing.quad);

type PulsingViewProps = {
/** Whether the view should pulse */
shouldPulse: boolean;

/** Content to render */
children: React.ReactNode;

/**
* Array of style objects
* @default []
*/
style?: StyleProp<AnimatedStyle<ViewStyle>>;

/**
* The minimum opacity reached during the pulse
* @default 0.5
*/
minOpacity?: number;

/** Whether the view needs to be rendered offscreen (for Android only) */
needsOffscreenAlphaCompositing?: boolean;

/** Style applied to a non-animated outer wrapper, useful for opaque backgrounds that shouldn't pulse */
wrapperStyle?: StyleProp<ViewStyle>;
};

function startPulse(opacity: SharedValue<number>, minOpacity: number) {
'worklet';

opacity.set(
withRepeat(
withSequence(
withTiming(minOpacity, {duration: FADE_OUT_DURATION, easing: EASING_OUT}),
withTiming(1, {duration: FADE_IN_DURATION, easing: EASING_IN}),
withDelay(PAUSE_DURATION, withTiming(1, {duration: 0})),
),
-1,
false,
),
);
}

function stopPulse(opacity: SharedValue<number>) {
'worklet';

cancelAnimation(opacity);
opacity.set(withTiming(1, {duration: RECOVERY_DURATION, easing: EASING_OUT}));
}

function PulsingView({shouldPulse, children, style = [], minOpacity = 0.5, needsOffscreenAlphaCompositing = false, wrapperStyle}: PulsingViewProps) {
const shouldPulseShared = useSharedValue(shouldPulse);
useEffect(() => {
shouldPulseShared.set(shouldPulse);
}, [shouldPulse, shouldPulseShared]);

const minOpacityShared = useSharedValue(minOpacity);
useEffect(() => {
minOpacityShared.set(minOpacity);
}, [minOpacity, minOpacityShared]);

const opacity = useSharedValue(1);

useAnimatedReaction(
() => shouldPulseShared.get(),
(isPulsing, wasPulsing) => {
if (isPulsing === wasPulsing) {
return;
}

if (isPulsing) {
startPulse(opacity, minOpacityShared.get());
} else {
stopPulse(opacity);
}
},
);

const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.get(),
}));

const animatedContent = (
<Animated.View
style={[animatedStyle, style]}
needsOffscreenAlphaCompositing={shouldRenderOffscreen ? needsOffscreenAlphaCompositing : undefined}
>
{children}
</Animated.View>
);

if (wrapperStyle) {
return <View style={wrapperStyle}>{animatedContent}</View>;
}

return animatedContent;
}

export default PulsingView;
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import SearchInputSelectionSkeleton from '@components/Skeletons/SearchInputSelec
// Once initialized, subsequent mounts are fast, so we only show the skeleton once per app session.
let isAutocompleteInputInitialized = false;

function SearchInputSelectionWrapper({ref, ...props}: SearchAutocompleteInputProps) {
const [showSkeleton, setShowSkeleton] = useState(!isAutocompleteInputInitialized);
function SearchInputSelectionWrapper({ref, skipSkeleton, ...props}: SearchAutocompleteInputProps & {skipSkeleton?: boolean}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can skipSkeleton be a part of SearchAutocompleteInputProps?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately no - SearchAutocompleteInput itself doesn't use skipSkeleton, so adding it to SearchAutocompleteInputProps triggers react/no-unused-prop-types. It's only consumed by the wrapper layer, so the intersection type is the correct approach here.

const [showSkeleton, setShowSkeleton] = useState(!skipSkeleton && !isAutocompleteInputInitialized);

useEffect(() => {
if (isAutocompleteInputInitialized) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import SearchAutocompleteInput from '@components/Search/SearchAutocompleteInput';
import type {SearchAutocompleteInputProps} from '@components/Search/SearchAutocompleteInput';

function SearchInputSelectionWrapper({selection, ref, ...props}: SearchAutocompleteInputProps) {
function SearchInputSelectionWrapper({selection, ref, skipSkeleton: _skipSkeleton, ...props}: SearchAutocompleteInputProps & {skipSkeleton?: boolean}) {
return (
<SearchAutocompleteInput
selection={selection}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// NOTE: The narrow-layout rendering of this component has a static twin in
// SearchStaticList (src/components/Search/SearchStaticList.tsx) used for fast
// perceived performance. If you change the narrow-layout UI here, verify the
// static version still looks visually identical.
import React, {useRef} from 'react';
import type {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// NOTE: This component has a static twin in SearchStaticList
// (src/components/Search/SearchStaticList.tsx) used for fast perceived
// performance. If you change the UI here, verify the static version still
// looks visually identical.
import React from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// NOTE: This component has a static twin in SearchPageNarrow/StaticFiltersBar.tsx
// used for fast perceived performance. If you change the UI here, verify the
// static version still looks visually identical.
import React, {useRef} from 'react';
import {FlatList, View} from 'react-native';
import Button from '@components/Button';
Expand Down
16 changes: 15 additions & 1 deletion src/components/Search/SearchPageHeader/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// NOTE: The narrow-layout rendering of this component has a static twin in
// SearchPageNarrow/StaticSearchPageHeader.tsx used for fast perceived
// performance. If you change the narrow-layout UI here, verify the static
// version still looks visually identical.
import React from 'react';
import type {SearchQueryJSON} from '@components/Search/types';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand All @@ -13,11 +17,20 @@ type SearchPageHeaderProps = {
onSearchRouterFocus?: () => void;
handleSearch: (value: string) => void;
isMobileSelectionModeEnabled: boolean;
skipInputSkeleton?: boolean;
};

type SearchHeaderOptionValue = DeepValueOf<typeof CONST.SEARCH.BULK_ACTION_TYPES> | undefined;

function SearchPageHeader({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, handleSearch, isMobileSelectionModeEnabled}: SearchPageHeaderProps) {
function SearchPageHeader({
queryJSON,
searchRouterListVisible,
hideSearchRouterList,
onSearchRouterFocus,
handleSearch,
isMobileSelectionModeEnabled,
skipInputSkeleton,
}: SearchPageHeaderProps) {
const {shouldUseNarrowLayout} = useResponsiveLayout();

if (shouldUseNarrowLayout && isMobileSelectionModeEnabled) {
Expand All @@ -31,6 +44,7 @@ function SearchPageHeader({queryJSON, searchRouterListVisible, hideSearchRouterL
queryJSON={queryJSON}
hideSearchRouterList={hideSearchRouterList}
handleSearch={handleSearch}
skipInputSkeleton={skipInputSkeleton}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ type SearchPageHeaderInputProps = {
hideSearchRouterList?: () => void;
onSearchRouterFocus?: () => void;
handleSearch: (value: string) => void;
skipInputSkeleton?: boolean;
};

function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, handleSearch}: SearchPageHeaderInputProps) {
function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, handleSearch, skipInputSkeleton}: SearchPageHeaderInputProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
Expand Down Expand Up @@ -283,6 +284,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
wrapperFocusedStyle={styles.searchAutocompleteInputResultsFocused}
ref={textInputRef}
onKeyPress={handleKeyPress}
skipSkeleton={skipInputSkeleton}
/>
</Animated.View>
</View>
Expand Down
Loading
Loading