[Gekidou MM-46229] Add hideAndLock, showAndUnlock callbacks for Search (#6677)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Jason Frerich
2022-10-27 22:04:26 -05:00
committed by GitHub
parent 7cf2bff658
commit c627fb8df9
17 changed files with 292 additions and 230 deletions

View File

@@ -4,9 +4,11 @@
import React, {useMemo} from 'react';
import {Platform, Text, View} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import ViewConstants from '@constants/view';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -24,18 +26,18 @@ type Props = {
defaultHeight: number;
hasSearch: boolean;
isLargeTitle: boolean;
largeHeight: number;
heightOffset: number;
leftComponent?: React.ReactElement;
onBackPress?: () => void;
onTitlePress?: () => void;
rightButtons?: HeaderRightButton[];
scrollValue?: Animated.SharedValue<number>;
lockValue?: Animated.SharedValue<number | null>;
showBackButton?: boolean;
subtitle?: string;
subtitleCompanion?: React.ReactElement;
theme: Theme;
title?: string;
top: number;
}
const hitSlop = {top: 20, bottom: 20, left: 20, right: 20};
@@ -127,20 +129,21 @@ const Header = ({
defaultHeight,
hasSearch,
isLargeTitle,
largeHeight,
heightOffset,
leftComponent,
onBackPress,
onTitlePress,
rightButtons,
scrollValue,
lockValue,
showBackButton = true,
subtitle,
subtitleCompanion,
theme,
title,
top,
}: Props) => {
const styles = getStyleSheet(theme);
const insets = useSafeAreaInsets();
const opacity = useAnimatedStyle(() => {
if (!isLargeTitle) {
@@ -151,8 +154,7 @@ const Header = ({
return {opacity: 0};
}
const largeTitleLabelHeight = 60;
const barHeight = (largeHeight - defaultHeight) - largeTitleLabelHeight;
const barHeight = heightOffset - ViewConstants.LARGE_HEADER_TITLE_HEIGHT;
const val = (scrollValue?.value ?? 0);
const showDuration = 200;
const hideDuration = 50;
@@ -161,11 +163,15 @@ const Header = ({
return {
opacity: withTiming(opacityValue, {duration}),
};
}, [defaultHeight, largeHeight, isLargeTitle, hasSearch]);
}, [heightOffset, isLargeTitle, hasSearch]);
const containerStyle = useMemo(() => {
return [styles.container, {height: defaultHeight + top, paddingTop: top}];
}, [defaultHeight, theme]);
const containerAnimatedStyle = useAnimatedStyle(() => ({
height: defaultHeight,
paddingTop: insets.top,
}), [defaultHeight, lockValue]);
const containerStyle = useMemo(() => (
[styles.container, containerAnimatedStyle]), [styles, containerAnimatedStyle]);
const additionalTitleStyle = useMemo(() => ({
marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}),

View File

@@ -2,11 +2,12 @@
// See LICENSE.txt for license information.
import React from 'react';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
import {SEARCH_INPUT_HEIGHT, SEARCH_INPUT_MARGIN} from '@constants/view';
import {useTheme} from '@context/theme';
import useHeaderHeight, {MAX_OVERSCROLL} from '@hooks/header';
import {clamp} from '@utils/gallery';
import {makeStyleSheetFromTheme} from '@utils/theme';
import Header, {HeaderRightButton} from './header';
@@ -23,6 +24,7 @@ type Props = SearchProps & {
onTitlePress?: () => void;
rightButtons?: HeaderRightButton[];
scrollValue?: Animated.SharedValue<number>;
lockValue?: Animated.SharedValue<number | null>;
hideHeader?: () => void;
showBackButton?: boolean;
subtitle?: string;
@@ -47,6 +49,7 @@ const NavigationHeader = ({
onTitlePress,
rightButtons,
scrollValue,
lockValue,
showBackButton,
subtitle,
subtitleCompanion,
@@ -55,21 +58,37 @@ const NavigationHeader = ({
...searchProps
}: Props) => {
const theme = useTheme();
const insets = useSafeAreaInsets();
const styles = getStyleSheet(theme);
const {largeHeight, defaultHeight} = useHeaderHeight();
const {largeHeight, defaultHeight, headerOffset} = useHeaderHeight();
const containerHeight = useAnimatedStyle(() => {
const minHeight = defaultHeight + insets.top;
const minHeight = defaultHeight;
const value = -(scrollValue?.value || 0);
const height = ((isLargeTitle ? largeHeight : defaultHeight)) + value + insets.top;
const calculatedHeight = (isLargeTitle ? largeHeight : defaultHeight) + value;
const height = lockValue?.value ? lockValue.value : calculatedHeight;
return {
height: Math.max(height, minHeight),
minHeight,
maxHeight: largeHeight + insets.top + MAX_OVERSCROLL,
maxHeight: largeHeight + MAX_OVERSCROLL,
};
});
const minScrollValue = useDerivedValue(() => scrollValue?.value || 0, [scrollValue]);
const translateY = useDerivedValue(() => (
lockValue?.value ? -lockValue.value : Math.min(-minScrollValue.value, headerOffset)
), [lockValue, minScrollValue, headerOffset]);
const searchTopStyle = useAnimatedStyle(() => {
const margin = clamp(-minScrollValue.value, -headerOffset, headerOffset);
const marginTop = (lockValue?.value ? -lockValue?.value : margin) - SEARCH_INPUT_HEIGHT - SEARCH_INPUT_MARGIN;
return {marginTop};
}, [lockValue, headerOffset, scrollValue]);
const heightOffset = useDerivedValue(() => (
lockValue?.value ? lockValue.value : headerOffset
), [lockValue, headerOffset]);
return (
<>
<Animated.View style={[styles.container, containerHeight]}>
@@ -77,40 +96,36 @@ const NavigationHeader = ({
defaultHeight={defaultHeight}
hasSearch={hasSearch}
isLargeTitle={isLargeTitle}
largeHeight={largeHeight}
heightOffset={heightOffset.value}
leftComponent={leftComponent}
onBackPress={onBackPress}
onTitlePress={onTitlePress}
rightButtons={rightButtons}
lockValue={lockValue}
scrollValue={scrollValue}
showBackButton={showBackButton}
subtitle={subtitle}
subtitleCompanion={subtitleCompanion}
theme={theme}
title={title}
top={insets.top}
/>
{isLargeTitle &&
<NavigationHeaderLargeTitle
defaultHeight={defaultHeight}
heightOffset={heightOffset.value}
hasSearch={hasSearch}
largeHeight={largeHeight}
scrollValue={scrollValue}
subtitle={subtitle}
theme={theme}
title={title}
translateY={translateY}
/>
}
{hasSearch &&
<NavigationSearch
{...searchProps}
defaultHeight={defaultHeight}
largeHeight={largeHeight}
scrollValue={scrollValue}
hideHeader={hideHeader}
theme={theme}
top={0}
/>
<NavigationSearch
{...searchProps}
hideHeader={hideHeader}
theme={theme}
topStyle={searchTopStyle}
/>
}
</Animated.View>
</>

View File

@@ -9,13 +9,12 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
defaultHeight: number;
heightOffset: number;
hasSearch: boolean;
largeHeight: number;
scrollValue?: Animated.SharedValue<number>;
subtitle?: string;
theme: Theme;
title: string;
translateY: Animated.DerivedValue<number>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@@ -34,33 +33,29 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
const NavigationHeaderLargeTitle = ({
defaultHeight,
largeHeight,
heightOffset,
hasSearch,
scrollValue,
subtitle,
theme,
title,
translateY,
}: Props) => {
const styles = getStyleSheet(theme);
const transform = useAnimatedStyle(() => {
const value = scrollValue?.value || 0;
return {
transform: [{translateY: Math.min(-value, largeHeight - defaultHeight)}],
};
});
const transform = useAnimatedStyle(() => (
{transform: [{translateY: translateY.value}]}
), [translateY?.value]);
const containerStyle = useMemo(() => {
return [{height: largeHeight - defaultHeight}, styles.container];
}, [defaultHeight, largeHeight, theme]);
return [{height: heightOffset}, styles.container];
}, [heightOffset, theme]);
return (
<Animated.View style={[containerStyle, transform]}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.heading}
style={[styles.heading]}
testID='navigation.large_header.title'
>
{title}

View File

@@ -2,35 +2,26 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo} from 'react';
import {DeviceEventEmitter, Keyboard, NativeSyntheticEvent, Platform, TextInputFocusEventData} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {DeviceEventEmitter, Keyboard, NativeSyntheticEvent, Platform, TextInputFocusEventData, ViewStyle} from 'react-native';
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
import Search, {SearchProps} from '@components/search';
import {Events} from '@constants';
import {HEADER_SEARCH_HEIGHT} from '@constants/view';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = SearchProps & {
defaultHeight: number;
largeHeight: number;
scrollValue?: Animated.SharedValue<number>;
topStyle: AnimatedStyleProp<ViewStyle>;
hideHeader?: () => void;
theme: Theme;
top: number;
}
const INITIAL_TOP = -45;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.sidebarBg,
height: HEADER_SEARCH_HEIGHT,
justifyContent: 'center',
paddingHorizontal: 20,
width: '100%',
zIndex: 10,
top: INITIAL_TOP,
},
inputContainerStyle: {
backgroundColor: changeOpacity(theme.sidebarText, 0.12),
@@ -41,11 +32,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
const NavigationSearch = ({
defaultHeight,
largeHeight,
scrollValue,
hideHeader,
theme,
topStyle,
...searchProps
}: Props) => {
const styles = getStyleSheet(theme);
@@ -58,12 +47,6 @@ const NavigationSearch = ({
color: theme.sidebarText,
}), [theme]);
const searchTop = useAnimatedStyle(() => {
const value = scrollValue?.value || 0;
const min = (largeHeight - defaultHeight);
return {marginTop: Math.min(-Math.min((value), min), min)};
}, [largeHeight, defaultHeight]);
const onFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
hideHeader?.();
searchProps.onFocus?.(e);
@@ -89,7 +72,7 @@ const NavigationSearch = ({
}, []);
return (
<Animated.View style={[styles.container, searchTop]}>
<Animated.View style={[styles.container, topStyle]}>
<Search
{...searchProps}
cancelButtonProps={cancelButtonProps}

View File

@@ -75,7 +75,7 @@ function PostDraft({
ios: (keyboardHeight ? keyboardHeight - keyboardAdjustment : (postInputTop + insetsAdjustment)),
default: postInputTop + insetsAdjustment,
});
const autocompleteAvailableSpace = containerHeight - autocompletePosition - (isChannelScreen ? headerHeight + insets.top : 0);
const autocompleteAvailableSpace = containerHeight - autocompletePosition - (isChannelScreen ? headerHeight : 0);
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);

View File

@@ -7,21 +7,19 @@ export const BOTTOM_TAB_HEIGHT = 52;
export const BOTTOM_TAB_ICON_SIZE = 31.2;
export const PROFILE_PICTURE_SIZE = 32;
export const PROFILE_PICTURE_EMOJI_SIZE = 28;
export const SEARCH_INPUT_HEIGHT = Platform.select({android: 40, default: 36});
export const TEAM_SIDEBAR_WIDTH = 72;
export const TABLET_HEADER_HEIGHT = 44;
export const TABLET_SIDEBAR_WIDTH = 320;
export const IOS_STATUS_BAR_HEIGHT = 20;
export const IOS_DEFAULT_HEADER_HEIGHT = 44;
export const ANDROID_DEFAULT_HEADER_HEIGHT = 56;
export const LARGE_HEADER_TITLE = 60;
export const HEADER_WITH_SEARCH_HEIGHT = -16;
export const HEADER_WITH_SUBTITLE = 24;
export const DEFAULT_HEADER_HEIGHT = Platform.select({android: 56, default: 44});
export const LARGE_HEADER_TITLE_HEIGHT = 60;
export const SUBTITLE_HEIGHT = 24;
export const KEYBOARD_TRACKING_OFFSET = 72;
export const HEADER_SEARCH_HEIGHT = SEARCH_INPUT_HEIGHT + 5;
export const HEADER_SEARCH_BOTTOM_MARGIN = 10;
export const SEARCH_INPUT_HEIGHT = Platform.select({android: 40, default: 36});
export const SEARCH_INPUT_MARGIN = 5;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74;
@@ -41,14 +39,10 @@ export default {
TEAM_SIDEBAR_WIDTH,
TABLET_HEADER_HEIGHT,
IOS_STATUS_BAR_HEIGHT,
IOS_DEFAULT_HEADER_HEIGHT,
ANDROID_DEFAULT_HEADER_HEIGHT,
LARGE_HEADER_TITLE,
HEADER_WITH_SEARCH_HEIGHT,
HEADER_WITH_SUBTITLE,
DEFAULT_HEADER_HEIGHT,
LARGE_HEADER_TITLE_HEIGHT,
SUBTITLE_HEIGHT,
KEYBOARD_TRACKING_OFFSET,
HEADER_SEARCH_HEIGHT,
HEADER_SEARCH_BOTTOM_MARGIN,
QUICK_OPTIONS_HEIGHT,
};

View File

@@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {NativeScrollEvent, Platform} from 'react-native';
import Animated, {runOnJS, scrollTo, useAnimatedRef, useAnimatedScrollHandler, useDerivedValue, useSharedValue} from 'react-native-reanimated';
import {NativeScrollEvent} from 'react-native';
import Animated, {runOnJS, scrollTo, useAnimatedRef, useAnimatedScrollHandler, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import ViewConstants from '@constants/view';
@@ -18,52 +18,52 @@ type HeaderScrollContext = {
export const MAX_OVERSCROLL = 80;
export const useDefaultHeaderHeight = () => {
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
let headerHeight = ViewConstants.DEFAULT_HEADER_HEIGHT;
if (isTablet) {
return ViewConstants.TABLET_HEADER_HEIGHT;
headerHeight = ViewConstants.TABLET_HEADER_HEIGHT;
}
if (Platform.OS === 'ios') {
return ViewConstants.IOS_DEFAULT_HEADER_HEIGHT;
}
return ViewConstants.ANDROID_DEFAULT_HEADER_HEIGHT;
return headerHeight + insets.top;
};
export const useLargeHeaderHeight = () => {
const defaultHeight = useDefaultHeaderHeight();
return defaultHeight + ViewConstants.LARGE_HEADER_TITLE + ViewConstants.HEADER_WITH_SUBTITLE;
let largeHeight = useDefaultHeaderHeight();
largeHeight += ViewConstants.LARGE_HEADER_TITLE_HEIGHT;
largeHeight += ViewConstants.SUBTITLE_HEIGHT;
return largeHeight;
};
export const useHeaderHeight = () => {
const defaultHeight = useDefaultHeaderHeight();
const largeHeight = useLargeHeaderHeight();
return useMemo(() => {
return {
defaultHeight,
largeHeight,
};
}, [defaultHeight, largeHeight]);
const headerOffset = largeHeight - defaultHeight;
return useMemo(() => ({
defaultHeight,
largeHeight,
headerOffset,
}), [defaultHeight, largeHeight]);
};
export const useCollapsibleHeader = <T>(isLargeTitle: boolean, onSnap?: (offset: number) => void) => {
const insets = useSafeAreaInsets();
const animatedRef = useAnimatedRef<Animated.ScrollView>();
const {largeHeight, defaultHeight} = useHeaderHeight();
const {largeHeight, defaultHeight, headerOffset} = useHeaderHeight();
const scrollValue = useSharedValue(0);
const lockValue = useSharedValue<number | null>(null);
const autoScroll = useSharedValue(false);
const snapping = useSharedValue(false);
const scrollEnabled = useSharedValue(true);
const headerHeight = useDerivedValue(() => {
const minHeight = defaultHeight + insets.top;
const value = -(scrollValue?.value || 0);
const header = (isLargeTitle ? largeHeight : defaultHeight);
const height = header + value + insets.top;
if (height > header + (insets.top * 2)) {
return Math.min(height, largeHeight + insets.top + MAX_OVERSCROLL);
const heightWithScroll = (isLargeTitle ? largeHeight : defaultHeight) + value;
let height = Math.max(heightWithScroll, defaultHeight);
if (value > insets.top) {
height = Math.min(heightWithScroll, largeHeight + MAX_OVERSCROLL);
}
return Math.max(height, minHeight);
return height;
});
function snapIfNeeded(dir: string, offset: number) {
@@ -72,27 +72,35 @@ export const useCollapsibleHeader = <T>(isLargeTitle: boolean, onSnap?: (offset:
snapping.value = true;
if (dir === 'down' && offset < largeHeight) {
runOnJS(onSnap)(0);
} else if (dir === 'up' && offset < (defaultHeight + insets.top)) {
runOnJS(onSnap)((largeHeight - defaultHeight));
} else if (dir === 'up' && offset < (defaultHeight)) {
runOnJS(onSnap)(headerOffset);
}
snapping.value = false;
snapping.value = Boolean(withTiming(0, {duration: 100}));
}
}
const setAutoScroll = (enabled: boolean) => {
autoScroll.value = enabled;
};
const onScroll = useAnimatedScrollHandler({
onBeginDrag: (e: NativeScrollEvent, ctx: HeaderScrollContext) => {
ctx.start = e.contentOffset.y;
ctx.dragging = true;
},
onScroll: (e, ctx) => {
if (ctx.dragging || autoScroll.value) {
if (!scrollEnabled.value) {
scrollTo(animatedRef, 0, headerOffset, false);
return;
}
if (ctx.dragging || autoScroll.value || snapping.value) {
scrollValue.value = e.contentOffset.y;
} else {
// here we want to ensure that the scroll position
// always start at 0 if the user has not dragged
// the scrollview manually
scrollValue.value = 0;
scrollTo(animatedRef, 0, 0, false);
scrollTo(animatedRef, 0, scrollValue.value, false);
}
},
onEndDrag: (e, ctx) => {
@@ -119,8 +127,12 @@ export const useCollapsibleHeader = <T>(isLargeTitle: boolean, onSnap?: (offset:
},
}, [insets, defaultHeight, largeHeight, animatedRef]);
const hideHeader = useCallback(() => {
const offset = largeHeight - defaultHeight;
const hideHeader = useCallback((lock = false) => {
if (lock) {
lockValue.value = defaultHeight;
}
const offset = headerOffset;
if (animatedRef?.current && Math.abs((scrollValue?.value || 0)) <= insets.top) {
autoScroll.value = true;
if ('scrollTo' in animatedRef.current) {
@@ -136,15 +148,24 @@ export const useCollapsibleHeader = <T>(isLargeTitle: boolean, onSnap?: (offset:
}
}, [largeHeight, defaultHeight]);
const unlock = useCallback(() => {
lockValue.value = null;
}, []);
return {
defaultHeight,
largeHeight,
scrollPaddingTop: (isLargeTitle ? largeHeight : defaultHeight) + insets.top,
scrollPaddingTop: (isLargeTitle ? largeHeight : defaultHeight),
scrollRef: animatedRef as unknown as React.RefObject<T>,
scrollValue,
onScroll,
hideHeader,
lockValue,
unlock,
headerHeight,
headerOffset,
scrollEnabled,
setAutoScroll,
};
};

View File

@@ -5,12 +5,9 @@ import React from 'react';
import {View, Platform, StyleSheet} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {ANDROID_DEFAULT_HEADER_HEIGHT, IOS_DEFAULT_HEADER_HEIGHT} from '@constants/view';
import {DEFAULT_HEADER_HEIGHT} from '@constants/view';
let topBarHeight = ANDROID_DEFAULT_HEADER_HEIGHT;
if (Platform.OS === 'ios') {
topBarHeight = IOS_DEFAULT_HEADER_HEIGHT;
}
const topBarHeight = DEFAULT_HEADER_HEIGHT;
const style = StyleSheet.create({
wrapper: {

View File

@@ -92,7 +92,7 @@ const Channel = ({
return () => back?.remove();
}, [componentId, isTablet]);
const marginTop = defaultHeight + (isTablet ? insets.top : 0);
const marginTop = defaultHeight + (isTablet ? 0 : -insets.top);
useEffect(() => {
// This is done so that the header renders
// and the screen does not look totally blank

View File

@@ -4,7 +4,6 @@
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
@@ -75,13 +74,12 @@ const ChannelHeader = ({
const theme = useTheme();
const styles = getStyleSheet(theme);
const defaultHeight = useDefaultHeaderHeight();
const insets = useSafeAreaInsets();
const callsAvailable = callsEnabledInChannel && !callsFeatureRestricted;
const isDMorGM = isTypeDMorGM(channelType);
const contextStyle = useMemo(() => ({
top: defaultHeight + insets.top,
}), [defaultHeight, insets.top]);
top: defaultHeight,
}), [defaultHeight]);
const leftComponent = useMemo(() => {
if (isTablet || !channelId || !teamId) {

View File

@@ -43,8 +43,9 @@ const edges: Edge[] = ['left', 'right'];
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
const Header = ({index, onClose, style, total}: Props) => {
const insets = useSafeAreaInsets();
const {width} = useWindowDimensions();
const height = useDefaultHeaderHeight();
const height = useDefaultHeaderHeight() - insets.top;
const {top} = useSafeAreaInsets();
const topContainerStyle = useMemo(() => [{height: top, backgroundColor: '#000'}], [top]);
const containerStyle = useMemo(() => [styles.container, {height}], [height]);

View File

@@ -4,7 +4,7 @@
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
@@ -30,7 +30,6 @@ const styles = StyleSheet.create({
const GlobalThreads = ({componentId}: Props) => {
const appState = useAppState();
const intl = useIntl();
const insets = useSafeAreaInsets();
const switchingTeam = useTeamSwitch();
const isTablet = useIsTablet();
@@ -39,13 +38,13 @@ const GlobalThreads = ({componentId}: Props) => {
const [tab, setTab] = useState<GlobalThreadsTab>('all');
const containerStyle = useMemo(() => {
const marginTop = defaultHeight + insets.top;
const marginTop = defaultHeight;
return {flex: 1, marginTop};
}, [defaultHeight, insets.top]);
}, [defaultHeight]);
const contextStyle = useMemo(() => ({
top: defaultHeight + insets.top,
}), [defaultHeight, insets.top]);
top: defaultHeight,
}), [defaultHeight]);
const onBackPress = useCallback(() => {
Keyboard.dismiss();

View File

@@ -4,9 +4,9 @@
import {useIsFocused, useRoute} from '@react-navigation/native';
import React, {useCallback, useState, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {ActivityIndicator, DeviceEventEmitter, FlatList, ListRenderItemInfo, Platform, StyleSheet, View} from 'react-native';
import {ActivityIndicator, DeviceEventEmitter, FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {SafeAreaView, Edge, useSafeAreaInsets} from 'react-native-safe-area-context';
import {SafeAreaView, Edge} from 'react-native-safe-area-context';
import {fetchRecentMentions} from '@actions/remote/search';
import NavigationHeader from '@components/navigation_header';
@@ -14,7 +14,6 @@ import DateSeparator from '@components/post_list/date_separator';
import PostWithChannelInfo from '@components/post_with_channel_info';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Events, Screens} from '@constants';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useCollapsibleHeader} from '@hooks/header';
@@ -40,7 +39,6 @@ const styles = StyleSheet.create({
},
container: {
flex: 1,
marginBottom: Platform.select({ios: BOTTOM_TAB_HEIGHT}),
},
empty: {
alignItems: 'center',
@@ -53,7 +51,6 @@ const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Pr
const theme = useTheme();
const route = useRoute();
const isFocused = useIsFocused();
const insets = useSafeAreaInsets();
const {formatMessage} = useIntl();
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
@@ -87,8 +84,7 @@ const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Pr
}, [serverUrl, isFocused]);
const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight} = useCollapsibleHeader<FlatList<string>>(true, onSnap);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop - insets.top, flexGrow: 1}), [scrollPaddingTop, insets.top]);
const scrollViewStyle = useMemo(() => ({top: insets.top}), [insets.top]);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]);
const posts = useMemo(() => selectOrderedPosts(mentions, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [mentions]);
const animated = useAnimatedStyle(() => {
@@ -195,7 +191,6 @@ const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Pr
renderItem={renderItem}
removeClippedSubviews={true}
onViewableItemsChanged={onViewableItemsChanged}
style={scrollViewStyle}
testID='recent_mentions.post_list.flat_list'
/>
</Animated.View>

View File

@@ -4,9 +4,9 @@
import {useIsFocused, useRoute} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, FlatList, ListRenderItemInfo, Platform, StyleSheet, View} from 'react-native';
import {DeviceEventEmitter, FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {fetchSavedPosts} from '@actions/remote/post';
import Loading from '@components/loading';
@@ -15,7 +15,6 @@ import DateSeparator from '@components/post_list/date_separator';
import PostWithChannelInfo from '@components/post_with_channel_info';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Events, Screens} from '@constants';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useCollapsibleHeader} from '@hooks/header';
@@ -41,7 +40,6 @@ const styles = StyleSheet.create({
},
container: {
flex: 1,
marginBottom: Platform.select({ios: BOTTOM_TAB_HEIGHT}),
},
empty: {
alignItems: 'center',
@@ -58,7 +56,6 @@ function SavedMessages({posts, currentTimezone, isTimezoneEnabled}: Props) {
const serverUrl = useServerUrl();
const route = useRoute();
const isFocused = useIsFocused();
const insets = useSafeAreaInsets();
const params = route.params as {direction: string};
const toLeft = params.direction === 'left';
@@ -88,8 +85,7 @@ function SavedMessages({posts, currentTimezone, isTimezoneEnabled}: Props) {
}, [serverUrl, isFocused]);
const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight} = useCollapsibleHeader<FlatList<string>>(true, onSnap);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop - insets.top, flexGrow: 1}), [scrollPaddingTop, insets.top]);
const scrollViewStyle = useMemo(() => ({top: insets.top}), [insets.top]);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]);
const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]);
const animated = useAnimatedStyle(() => {
@@ -196,7 +192,6 @@ function SavedMessages({posts, currentTimezone, isTimezoneEnabled}: Props) {
onScroll={onScroll}
removeClippedSubviews={true}
onViewableItemsChanged={onViewableItemsChanged}
style={scrollViewStyle}
testID='saved_messages.post_list.flat_list'
/>
</Animated.View>

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import Animated from 'react-native-reanimated';
import Modifiers from './modifiers';
import RecentSearches from './recent_searches';
@@ -10,6 +11,7 @@ import type TeamSearchHistoryModel from '@typings/database/models/servers/team_s
type Props = {
recentSearches: TeamSearchHistoryModel[];
scrollEnabled: Animated.SharedValue<boolean>;
searchValue?: string;
setRecentValue: (value: string) => void;
setSearchValue: (value: string) => void;
@@ -18,7 +20,7 @@ type Props = {
teamName: string;
}
const Initial = ({setRecentValue, recentSearches, searchValue, teamId, teamName, setTeamId, setSearchValue}: Props) => {
const Initial = ({recentSearches, scrollEnabled, searchValue, setRecentValue, teamId, teamName, setTeamId, setSearchValue}: Props) => {
return (
<>
<Modifiers
@@ -26,6 +28,7 @@ const Initial = ({setRecentValue, recentSearches, searchValue, teamId, teamName,
setSearchValue={setSearchValue}
setTeamId={setTeamId}
teamId={teamId}
scrollEnabled={scrollEnabled}
/>
{Boolean(recentSearches.length) &&
<RecentSearches

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {View} from 'react-native';
import Animated, {useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
@@ -73,34 +73,51 @@ const getModifiersSectionsData = (intl: IntlShape): ModifierItem[] => {
};
type Props = {
scrollEnabled: Animated.SharedValue<boolean>;
setSearchValue: (value: string) => void;
searchValue?: string;
setTeamId: (id: string) => void;
teamId: string;
}
const Modifiers = ({searchValue, setSearchValue, setTeamId, teamId}: Props) => {
const Modifiers = ({scrollEnabled, searchValue, setSearchValue, setTeamId, teamId}: Props) => {
const theme = useTheme();
const intl = useIntl();
const [showMore, setShowMore] = useState(false);
const show = useSharedValue(3 * MODIFIER_LABEL_HEIGHT);
const data = useMemo(() => getModifiersSectionsData(intl), [intl]);
const timeoutRef = useRef<NodeJS.Timeout | undefined>();
const styles = getStyleFromTheme(theme);
const animatedStyle = useAnimatedStyle(() => (
{
width: '100%',
height: withTiming(show.value, {duration: 300}),
overflow: 'hidden',
}
));
const animatedStyle = useAnimatedStyle(() => ({
width: '100%',
height: withTiming(show.value, {duration: 300}),
overflow: 'hidden',
}), []);
const handleShowMore = useCallback(() => {
const nextShowMore = !showMore;
setShowMore(nextShowMore);
scrollEnabled.value = false;
show.value = (nextShowMore ? data.length : 3) * MODIFIER_LABEL_HEIGHT;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setTimeout(() => {
scrollEnabled.value = true;
}, 350);
}, [showMore]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
scrollEnabled.value = true;
clearTimeout(timeoutRef.current);
}
};
}, []);
const renderModifier = (item: ModifierItem) => {
return (
<Modifier

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {useIsFocused, useNavigation} from '@react-navigation/native';
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, LayoutChangeEvent, Platform, StyleSheet, ViewProps} from 'react-native';
import Animated, {useAnimatedStyle, useDerivedValue, withTiming} from 'react-native-reanimated';
@@ -11,6 +11,7 @@ import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-cont
import {getPosts} from '@actions/local/post';
import {addSearchToTeamSearchHistory} from '@actions/local/team';
import {searchPosts, searchFiles} from '@actions/remote/search';
import useDidUpdate from '@app/hooks/did_update';
import Autocomplete from '@components/autocomplete';
import FreezeScreen from '@components/freeze_screen';
import Loading from '@components/loading';
@@ -81,8 +82,10 @@ const SearchScreen = ({teamId}: Props) => {
const serverUrl = useServerUrl();
const searchTerm = (nav.getState().routes[stateIndex].params as any)?.searchTerm;
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length);
const [searchValue, setSearchValue] = useState<string>(searchTerm);
const clearRef = useRef<boolean>(false);
const cancelRef = useRef<boolean>(false);
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0);
const [searchValue, setSearchValue] = useState<string>(searchTerm || '');
const [searchTeamId, setSearchTeamId] = useState<string>(teamId);
const [selectedTab, setSelectedTab] = useState<TabType>(TabTypes.MESSAGES);
const [filter, setFilter] = useState<FileFilter>(FileFilters.ALL);
@@ -96,19 +99,42 @@ const SearchScreen = ({teamId}: Props) => {
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
const onSnap = (offset: number) => {
scrollRef.current?.scrollToOffset({offset, animated: true});
const onSnap = (offset: number, animated = true) => {
scrollRef.current?.scrollToOffset({offset, animated});
};
const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight, hideHeader} = useCollapsibleHeader<FlatList>(true, onSnap);
const {
headerHeight,
headerOffset,
hideHeader,
lockValue,
onScroll,
scrollEnabled,
scrollPaddingTop,
scrollRef,
scrollValue,
setAutoScroll,
unlock,
} = useCollapsibleHeader<FlatList>(true, onSnap);
const handleCancelAndClearSearch = useCallback(() => {
const resetToInitial = useCallback(() => {
setShowResults(false);
setSearchValue('');
setLastSearchedValue('');
setFilter(FileFilters.ALL);
setShowResults(false);
}, []);
const handleClearSearch = useCallback(() => {
clearRef.current = true;
resetToInitial();
}, [resetToInitial]);
const handleCancelSearch = useCallback(() => {
cancelRef.current = true;
resetToInitial();
onSnap(0);
}, [resetToInitial]);
const handleTextChange = useCallback((newValue: string) => {
setSearchValue(newValue);
setCursorPosition(newValue.length);
@@ -121,9 +147,10 @@ const SearchScreen = ({teamId}: Props) => {
const handleSearch = useCallback(async (newSearchTeamId: string, term: string) => {
const searchParams = getSearchParams(term);
if (!searchParams.terms) {
handleCancelAndClearSearch();
handleClearSearch();
return;
}
hideHeader(true);
handleLoading(true);
setFilter(FileFilters.ALL);
setLastSearchedValue(term);
@@ -141,7 +168,7 @@ const SearchScreen = ({teamId}: Props) => {
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
handleLoading(false);
setShowResults(true);
}, [handleCancelAndClearSearch, handleLoading, showResults]);
}, [handleClearSearch, handleLoading]);
const onSubmit = useCallback(() => {
handleSearch(searchTeamId, searchValue);
@@ -167,38 +194,33 @@ const SearchScreen = ({teamId}: Props) => {
handleSearch(newTeamId, lastSearchedValue);
}, [lastSearchedValue, handleSearch]);
const containerStyle = useMemo(() => {
const justifyContent = (resultsLoading || loading) ? 'center' : 'flex-start';
return {paddingTop: scrollPaddingTop, flexGrow: 1, justifyContent} as ViewProps;
const initialContainerStyle = useMemo(() => {
return {
paddingTop: scrollPaddingTop,
flexGrow: 1,
justifyContent: (resultsLoading || loading) ? 'center' : 'flex-start',
} as ViewProps;
}, [loading, resultsLoading, scrollPaddingTop]);
const loadingComponent = useMemo(() => (
<Loading
containerStyle={[styles.loading, {paddingTop: scrollPaddingTop}]}
color={theme.buttonBg}
size='large'
/>
), [theme, scrollPaddingTop]);
const initialComponent = useMemo(() => (
<Initial
searchValue={searchValue}
setRecentValue={handleRecentSearch}
setSearchValue={handleTextChange}
setTeamId={setSearchTeamId}
teamId={searchTeamId}
/>
), [searchValue, searchTeamId, handleRecentSearch, handleTextChange]);
const renderItem = useCallback(() => {
if (loading) {
return loadingComponent;
}
return initialComponent;
}, [
loading && loadingComponent,
initialComponent,
]);
const renderInitialOrLoadingItem = useCallback(() => {
return loading ? (
<Loading
containerStyle={[styles.loading, {paddingTop: scrollPaddingTop}]}
color={theme.buttonBg}
size='large'
/>
) : (
<Initial
scrollEnabled={scrollEnabled}
searchValue={searchValue}
setRecentValue={handleRecentSearch}
setSearchValue={handleTextChange}
setTeamId={setSearchTeamId}
teamId={searchTeamId}
/>
);
}, [handleRecentSearch, handleTextChange, loading,
scrollPaddingTop, searchTeamId, searchValue, theme]);
const animated = useAnimatedStyle(() => {
if (isFocused) {
@@ -211,37 +233,20 @@ const SearchScreen = ({teamId}: Props) => {
return {
opacity: withTiming(0, {duration: 150}),
flex: 1,
transform: [{translateX: withTiming(stateIndex < searchScreenIndex ? 25 : -25, {duration: 150})}],
};
}, [isFocused, stateIndex]);
const top = useAnimatedStyle(() => {
return {
top: headerHeight.value,
zIndex: lastSearchedValue ? 10 : 0,
};
}, [headerHeight.value, lastSearchedValue]);
const headerTopStyle = useAnimatedStyle(() => ({
top: lockValue.value ? lockValue.value : headerHeight.value,
zIndex: lastSearchedValue ? 10 : 0,
}), [headerHeight, lastSearchedValue, lockValue]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
let header = null;
if (lastSearchedValue && !loading) {
header = (
<Header
teamId={searchTeamId}
setTeamId={handleResultsTeamChange}
onTabSelect={setSelectedTab}
onFilterChanged={handleFilterChange}
numberMessages={posts.length}
selectedTab={selectedTab}
numberFiles={fileInfos.length}
selectedFilter={filter}
/>
);
}
const autocompleteMaxHeight = useDerivedValue(() => {
const iosAdjust = keyboardHeight ? keyboardHeight - BOTTOM_TAB_HEIGHT : insets.bottom;
const autocompleteRemoveFromHeight = headerHeight.value + (Platform.OS === 'ios' ? iosAdjust : 0);
@@ -250,7 +255,7 @@ const SearchScreen = ({teamId}: Props) => {
const autocompletePosition = useDerivedValue(() => {
return headerHeight.value - AutocompletePaddingTop;
}, [containerHeight]);
}, [headerHeight]);
const autocomplete = useMemo(() => (
<Autocomplete
@@ -267,6 +272,31 @@ const SearchScreen = ({teamId}: Props) => {
/>
), [cursorPosition, handleTextChange, searchValue, autocompleteMaxHeight, autocompletePosition, searchTeamId]);
// when clearing the input from the search results, scroll the initial view
// back to the top so the header is in the collapsed state
const onFlatLayout = useCallback(() => {
if (clearRef.current || cancelRef.current) {
unlock();
}
if (clearRef.current) {
onSnap(headerOffset, false);
clearRef.current = false;
} else if (cancelRef.current) {
onSnap(0);
cancelRef.current = false;
}
}, [headerOffset, scrollRef]);
useDidUpdate(() => {
if (isFocused) {
setTimeout(() => {
setAutoScroll(true);
}, 300);
} else {
setAutoScroll(false);
}
}, [isFocused]);
return (
<FreezeScreen freeze={!isFocused}>
<NavigationHeader
@@ -275,13 +305,14 @@ const SearchScreen = ({teamId}: Props) => {
title={intl.formatMessage({id: 'screen.search.title', defaultMessage: 'Search'})}
hasSearch={true}
scrollValue={scrollValue}
lockValue={lockValue}
hideHeader={hideHeader}
onChangeText={handleTextChange}
onSubmitEditing={onSubmit}
blurOnSubmit={true}
placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})}
onClear={handleCancelAndClearSearch}
onCancel={handleCancelAndClearSearch}
onClear={handleClearSearch}
onCancel={handleCancelSearch}
defaultValue={searchValue}
/>
<SafeAreaView
@@ -290,14 +321,26 @@ const SearchScreen = ({teamId}: Props) => {
onLayout={onLayout}
>
<Animated.View style={animated}>
<Animated.View style={top}>
<Animated.View style={headerTopStyle}>
<RoundedHeaderContext/>
{header}
{lastSearchedValue && !loading &&
<Header
teamId={searchTeamId}
setTeamId={handleResultsTeamChange}
onTabSelect={setSelectedTab}
onFilterChanged={handleFilterChange}
numberMessages={posts.length}
selectedTab={selectedTab}
numberFiles={fileInfos.length}
selectedFilter={filter}
/>
}
</Animated.View>
{!showResults &&
<AnimatedFlatList
onLayout={onFlatLayout}
data={dummyData}
contentContainerStyle={containerStyle}
contentContainerStyle={initialContainerStyle}
keyboardShouldPersistTaps='handled'
keyboardDismissMode={'interactive'}
nestedScrollEnabled={true}
@@ -308,7 +351,7 @@ const SearchScreen = ({teamId}: Props) => {
scrollToOverflowEnabled={true}
overScrollMode='always'
ref={scrollRef}
renderItem={renderItem}
renderItem={renderInitialOrLoadingItem}
/>
}
{showResults && !loading &&
@@ -318,7 +361,7 @@ const SearchScreen = ({teamId}: Props) => {
searchValue={lastSearchedValue}
posts={posts}
fileInfos={fileInfos}
scrollPaddingTop={scrollPaddingTop}
scrollPaddingTop={lockValue.value}
fileChannelIds={fileChannelIds}
/>
}