diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx index 7bf31a996c..66099285be 100644 --- a/app/components/navigation_header/header.tsx +++ b/app/components/navigation_header/header.tsx @@ -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; + lockValue?: Animated.SharedValue; 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}), diff --git a/app/components/navigation_header/index.tsx b/app/components/navigation_header/index.tsx index 8fab83257e..7b2183013a 100644 --- a/app/components/navigation_header/index.tsx +++ b/app/components/navigation_header/index.tsx @@ -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; + lockValue?: Animated.SharedValue; 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 ( <> @@ -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 && } {hasSearch && - + } diff --git a/app/components/navigation_header/large.tsx b/app/components/navigation_header/large.tsx index 51ddb0ac06..7ab2a6a6c3 100644 --- a/app/components/navigation_header/large.tsx +++ b/app/components/navigation_header/large.tsx @@ -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; subtitle?: string; theme: Theme; title: string; + translateY: Animated.DerivedValue; } 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 ( {title} diff --git a/app/components/navigation_header/search.tsx b/app/components/navigation_header/search.tsx index b4e7eaca39..6cb20421f0 100644 --- a/app/components/navigation_header/search.tsx +++ b/app/components/navigation_header/search.tsx @@ -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; + topStyle: AnimatedStyleProp; 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) => { hideHeader?.(); searchProps.onFocus?.(e); @@ -89,7 +72,7 @@ const NavigationSearch = ({ }, []); return ( - + { + 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 = (isLargeTitle: boolean, onSnap?: (offset: number) => void) => { const insets = useSafeAreaInsets(); const animatedRef = useAnimatedRef(); - const {largeHeight, defaultHeight} = useHeaderHeight(); + const {largeHeight, defaultHeight, headerOffset} = useHeaderHeight(); const scrollValue = useSharedValue(0); + const lockValue = useSharedValue(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 = (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 = (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 = (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, scrollValue, onScroll, hideHeader, + lockValue, + unlock, headerHeight, + headerOffset, + scrollEnabled, + setAutoScroll, }; }; diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx index e51864e834..2d38ea8455 100644 --- a/app/products/calls/components/floating_call_container.tsx +++ b/app/products/calls/components/floating_call_container.tsx @@ -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: { diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index f29e6921eb..9e04cbd81d 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -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 diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index b019628fbb..2f05b7f716 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -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) { diff --git a/app/screens/gallery/header/index.tsx b/app/screens/gallery/header/index.tsx index 84daa02d45..09e87ed153 100644 --- a/app/screens/gallery/header/index.tsx +++ b/app/screens/gallery/header/index.tsx @@ -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]); diff --git a/app/screens/global_threads/index.tsx b/app/screens/global_threads/index.tsx index 33deb4e03c..56f0510e64 100644 --- a/app/screens/global_threads/index.tsx +++ b/app/screens/global_threads/index.tsx @@ -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('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(); diff --git a/app/screens/home/recent_mentions/recent_mentions.tsx b/app/screens/home/recent_mentions/recent_mentions.tsx index e01c476afd..24f5a5f8fa 100644 --- a/app/screens/home/recent_mentions/recent_mentions.tsx +++ b/app/screens/home/recent_mentions/recent_mentions.tsx @@ -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>(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' /> diff --git a/app/screens/home/saved_messages/saved_messages.tsx b/app/screens/home/saved_messages/saved_messages.tsx index 80b50175f7..9572b678fd 100644 --- a/app/screens/home/saved_messages/saved_messages.tsx +++ b/app/screens/home/saved_messages/saved_messages.tsx @@ -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>(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' /> diff --git a/app/screens/home/search/initial/initial.tsx b/app/screens/home/search/initial/initial.tsx index d02dcd94e8..8f0b1630a9 100644 --- a/app/screens/home/search/initial/initial.tsx +++ b/app/screens/home/search/initial/initial.tsx @@ -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; 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 ( <> {Boolean(recentSearches.length) && { }; type Props = { + scrollEnabled: Animated.SharedValue; 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(); 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 ( { const serverUrl = useServerUrl(); const searchTerm = (nav.getState().routes[stateIndex].params as any)?.searchTerm; - const [cursorPosition, setCursorPosition] = useState(searchTerm?.length); - const [searchValue, setSearchValue] = useState(searchTerm); + const clearRef = useRef(false); + const cancelRef = useRef(false); + const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0); + const [searchValue, setSearchValue] = useState(searchTerm || ''); const [searchTeamId, setSearchTeamId] = useState(teamId); const [selectedTab, setSelectedTab] = useState(TabTypes.MESSAGES); const [filter, setFilter] = useState(FileFilters.ALL); @@ -96,19 +99,42 @@ const SearchScreen = ({teamId}: Props) => { const [fileInfos, setFileInfos] = useState(emptyFileResults); const [fileChannelIds, setFileChannelIds] = useState([]); - 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(true, onSnap); + const { + headerHeight, + headerOffset, + hideHeader, + lockValue, + onScroll, + scrollEnabled, + scrollPaddingTop, + scrollRef, + scrollValue, + setAutoScroll, + unlock, + } = useCollapsibleHeader(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(() => ( - - ), [theme, scrollPaddingTop]); - - const initialComponent = useMemo(() => ( - - ), [searchValue, searchTeamId, handleRecentSearch, handleTextChange]); - - const renderItem = useCallback(() => { - if (loading) { - return loadingComponent; - } - return initialComponent; - }, [ - loading && loadingComponent, - initialComponent, - ]); + const renderInitialOrLoadingItem = useCallback(() => { + return loading ? ( + + ) : ( + + ); + }, [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 = ( -
- ); - } - 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(() => ( { /> ), [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 ( { 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} /> { onLayout={onLayout} > - + - {header} + {lastSearchedValue && !loading && +
+ } {!showResults && { 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} /> }