[Gekidou] - Custom navigation header (#5820)

* Custom navigation header

* Add Search props to header

* Add Search props to header

* Simplify example

* Set large subtitle opacity to 80%

* Add extra spacing on large title
This commit is contained in:
Elias Nahum
2021-11-10 10:16:58 -03:00
committed by GitHub
parent 880cfbb7ac
commit 9af1c16147
15 changed files with 1079 additions and 44 deletions

View File

@@ -8,7 +8,7 @@ import {useTheme} from '@context/theme';
type Props = {
backgroundColor?: string;
borderColor: string;
borderColor?: string;
color?: string;
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
type?: 'Normal' | 'Small';

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
defaultHeight: number;
hasSearch: boolean;
isLargeTitle: boolean;
largeHeight: number;
scrollValue: Animated.SharedValue<number>;
theme: Theme;
top: number;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.sidebarBg,
height: 16,
position: 'absolute',
width: '100%',
},
content: {
backgroundColor: theme.centerChannelBg,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
flex: 1,
},
}));
const NavigationHeaderContext = ({
defaultHeight,
hasSearch,
isLargeTitle,
largeHeight,
scrollValue,
theme,
top,
}: Props) => {
const styles = getStyleSheet(theme);
const marginTop = useAnimatedStyle(() => {
const normal = defaultHeight + top;
const calculated = -(top + scrollValue.value);
const searchHeight = hasSearch ? defaultHeight + 9 : 0;
if (!isLargeTitle) {
return {marginTop: Math.max((normal + calculated), normal)};
}
return {marginTop: Math.max((-scrollValue.value + largeHeight + searchHeight), normal)};
}, [defaultHeight, largeHeight, isLargeTitle, hasSearch, top]);
return (
<Animated.View style={[styles.container, marginTop]}>
<Animated.View style={styles.content}/>
</Animated.View>
);
};
export default NavigationHeaderContext;

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Platform, Text} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
export type HeaderRightButton = {
borderless?: boolean;
buttonType?: 'native' | 'opacity' | 'highlight';
color?: string;
iconName: string;
onPress: () => void;
rippleRadius?: number;
testID?: string;
}
type Props = {
defaultHeight: number;
hasSearch: boolean;
isLargeTitle: boolean;
largeHeight: number;
leftComponent?: React.ReactElement;
onBackPress?: () => void;
rightButtons?: HeaderRightButton[];
scrollValue: Animated.SharedValue<number>;
showBackButton?: boolean;
subtitle?: string;
theme: Theme;
title?: string;
top: number;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
alignItems: 'center',
backgroundColor: theme.sidebarBg,
flexDirection: 'row',
justifyContent: 'flex-start',
paddingHorizontal: 16,
zIndex: 10,
},
subtitle: {
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
fontFamily: 'OpenSans',
fontSize: 12,
lineHeight: 12,
marginBottom: 8,
marginTop: 2,
},
titleContainer: {
alignItems: Platform.select({android: 'flex-start', ios: 'center'}),
justifyContent: 'center',
flex: 3,
height: '100%',
paddingHorizontal: 8,
},
leftContainer: {
alignItems: 'center',
flex: Platform.select({ios: 1}),
flexDirection: 'row',
height: '100%',
},
rightContainer: {
alignItems: 'center',
flex: Platform.select({ios: 1}),
flexDirection: 'row',
height: '100%',
justifyContent: 'flex-end',
},
rightIcon: {
marginLeft: 20,
},
title: {
color: theme.sidebarHeaderTextColor,
...typography('Heading', 300),
},
}));
const Header = ({
defaultHeight,
hasSearch,
isLargeTitle,
largeHeight,
leftComponent,
onBackPress,
rightButtons,
scrollValue,
showBackButton = true,
subtitle,
theme,
title,
top,
}: Props) => {
const styles = getStyleSheet(theme);
const opacity = useAnimatedStyle(() => {
if (!isLargeTitle) {
return {opacity: 1};
}
if (hasSearch) {
return {opacity: 0};
}
const barHeight = Platform.OS === 'ios' ? (largeHeight - defaultHeight - (top / 2)) : largeHeight - defaultHeight;
const val = (top + scrollValue.value);
return {
opacity: val >= barHeight ? withTiming(1, {duration: 250}) : 0,
};
}, [defaultHeight, largeHeight, top, isLargeTitle, hasSearch]);
const containerStyle = useMemo(() => {
return [styles.container, {height: defaultHeight + top, paddingTop: top}];
}, [top, defaultHeight, theme]);
const additionalTitleStyle = useMemo(() => ({
marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}),
}), [leftComponent, showBackButton, theme]);
return (
<Animated.View style={containerStyle}>
<Animated.View style={styles.leftContainer}>
{showBackButton &&
<TouchableWithFeedback
borderlessRipple={true}
onPress={onBackPress}
rippleRadius={20}
type={Platform.select({android: 'native', default: 'opacity'})}
testID='navigation.header.back'
>
<CompassIcon
size={24}
name={Platform.select({android: 'arrow-left', ios: 'arrow-back-ios'})!}
color={theme.sidebarHeaderTextColor}
/>
</TouchableWithFeedback>
}
{leftComponent}
</Animated.View>
<Animated.View style={[styles.titleContainer, additionalTitleStyle]}>
{!hasSearch &&
<Animated.Text
ellipsizeMode='tail'
numberOfLines={1}
style={[styles.title, opacity]}
testID='navigation.header.title'
>
{title}
</Animated.Text>
}
{!isLargeTitle &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.subtitle}
testID='navigation.header.subtitle'
>
{subtitle}
</Text>
}
</Animated.View>
<Animated.View style={styles.rightContainer}>
{Boolean(rightButtons?.length) &&
rightButtons?.map((r, i) => (
<TouchableWithFeedback
key={r.iconName}
borderlessRipple={r.borderless === undefined ? true : r.borderless}
onPress={r.onPress}
rippleRadius={r.rippleRadius || 20}
type={r.buttonType || Platform.select({android: 'native', default: 'opacity'})}
style={i > 0 ? styles.rightIcon : undefined}
testID={r.testID}
>
<CompassIcon
size={24}
name={r.iconName}
color={r.color || theme.sidebarHeaderTextColor}
/>
</TouchableWithFeedback>
))
}
</Animated.View>
</Animated.View>
);
};
export default Header;

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FlatList, ScrollView, SectionList} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTheme} from '@context/theme';
import useHeaderHeight from '@hooks/header';
import {makeStyleSheetFromTheme} from '@utils/theme';
import NavigationHeaderContext from './context';
import Header, {HeaderRightButton} from './header';
import NavigationHeaderLargeTitle from './large';
import NavigationSearch from './search';
import NavigationHeaderSearchContext from './search_context';
import type {SearchProps} from '@components/search';
type Props = SearchProps & {
forwardedRef?: React.RefObject<ScrollView | FlatList | SectionList>;
hasSearch?: boolean;
isLargeTitle?: boolean;
leftComponent?: React.ReactElement;
onBackPress?: () => void;
rightButtons?: HeaderRightButton[];
scrollValue: Animated.SharedValue<number>;
showBackButton?: boolean;
showHeaderInContext?: boolean;
subtitle?: string;
title?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.sidebarBg,
position: 'absolute',
width: '100%',
zIndex: 10,
},
}));
const NavigationHeader = ({
forwardedRef,
hasSearch = false,
isLargeTitle = false,
leftComponent,
onBackPress,
rightButtons,
scrollValue,
showBackButton,
showHeaderInContext = true,
subtitle,
title = '',
...searchProps
}: Props) => {
const theme = useTheme();
const insets = useSafeAreaInsets();
const styles = getStyleSheet(theme);
const {largeHeight, defaultHeight} = useHeaderHeight(isLargeTitle, Boolean(subtitle), hasSearch);
const containerHeight = useAnimatedStyle(() => {
const normal = defaultHeight + insets.top;
const calculated = -(insets.top + scrollValue.value);
return {height: Math.max((normal + calculated), normal)};
}, [defaultHeight, insets.top]);
return (
<>
<Animated.View style={[styles.container, containerHeight]}>
<Header
defaultHeight={defaultHeight}
hasSearch={hasSearch}
isLargeTitle={isLargeTitle}
largeHeight={largeHeight}
leftComponent={leftComponent}
onBackPress={onBackPress}
rightButtons={rightButtons}
scrollValue={scrollValue}
showBackButton={showBackButton}
subtitle={subtitle}
theme={theme}
title={title}
top={insets.top}
/>
{isLargeTitle &&
<NavigationHeaderLargeTitle
defaultHeight={defaultHeight}
hasSearch={hasSearch}
largeHeight={largeHeight}
scrollValue={scrollValue}
subtitle={subtitle}
theme={theme}
title={title}
top={insets.top}
/>
}
</Animated.View>
{hasSearch &&
<>
<NavigationSearch
{...searchProps}
defaultHeight={defaultHeight}
forwardedRef={forwardedRef}
largeHeight={largeHeight}
scrollValue={scrollValue}
theme={theme}
top={insets.top}
/>
<NavigationHeaderSearchContext
defaultHeight={defaultHeight}
largeHeight={largeHeight}
scrollValue={scrollValue}
theme={theme}
/>
</>
}
{showHeaderInContext &&
<NavigationHeaderContext
defaultHeight={defaultHeight}
hasSearch={hasSearch}
isLargeTitle={isLargeTitle}
largeHeight={largeHeight}
scrollValue={scrollValue}
theme={theme}
top={insets.top}
/>
}
</>
);
};
export default NavigationHeader;

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Text} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
defaultHeight: number;
hasSearch: boolean;
largeHeight: number;
scrollValue: Animated.SharedValue<number>;
subtitle?: string;
theme: Theme;
title: string;
top: number;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.sidebarBg,
paddingHorizontal: 20,
},
heading: {
...typography('Heading', 800),
color: theme.sidebarHeaderTextColor,
},
subHeading: {
...typography('Heading', 200, 'Regular'),
color: changeOpacity(theme.sidebarHeaderTextColor, 0.8),
},
}));
const NavigationHeaderLargeTitle = ({
defaultHeight,
largeHeight,
hasSearch,
scrollValue,
subtitle,
theme,
title,
top,
}: Props) => {
const styles = getStyleSheet(theme);
const transform = useAnimatedStyle(() => {
return {
transform: [{translateY: -(top + scrollValue.value)}],
};
}, [top]);
const containerStyle = useMemo(() => {
return [{height: largeHeight - defaultHeight}, styles.container];
}, [defaultHeight, largeHeight, theme]);
return (
<Animated.View style={[containerStyle, transform]}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.heading}
testID='navigation.large_header.title'
>
{title}
</Text>
{!hasSearch && Boolean(subtitle) &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.subHeading}
testID='navigation.large_header.subtitle'
>
{subtitle}
</Text>
}
</Animated.View>
);
};
export default NavigationHeaderLargeTitle;

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FlatList, Platform, ScrollView, SectionList, VirtualizedList} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import Search, {SearchProps} from '@components/search';
import {ANDROID_HEADER_SEARCH_INSET, IOS_HEADER_SEARCH_INSET, SEARCH_INPUT_HEIGHT, TABLET_HEADER_SEARCH_INSET} from '@constants/view';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = SearchProps & {
defaultHeight: number;
forwardedRef?: React.RefObject<ScrollView | FlatList | SectionList>;
largeHeight: number;
scrollValue: Animated.SharedValue<number>;
theme: Theme;
top: number;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.sidebarBg,
height: SEARCH_INPUT_HEIGHT + 5,
justifyContent: 'flex-start',
paddingHorizontal: 20,
position: 'absolute',
width: '100%',
zIndex: 10,
},
}));
const NavigationSearch = ({
defaultHeight,
forwardedRef,
largeHeight,
scrollValue,
theme,
top,
...searchProps
}: Props) => {
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
const searchTop = useAnimatedStyle(() => {
return {marginTop: Math.max((-scrollValue.value + largeHeight), top)};
}, [defaultHeight, largeHeight, top]);
const onFocus = useCallback((e) => {
const searchInset = isTablet ? TABLET_HEADER_SEARCH_INSET : IOS_HEADER_SEARCH_INSET;
const offset = Platform.select({android: largeHeight + ANDROID_HEADER_SEARCH_INSET, default: defaultHeight + searchInset});
if (forwardedRef?.current && Math.abs(scrollValue.value) <= top) {
if ((forwardedRef.current as ScrollView).scrollTo) {
(forwardedRef.current as ScrollView).scrollTo({y: offset, animated: true});
} else {
(forwardedRef.current as VirtualizedList<any>).scrollToOffset({
offset,
animated: true,
});
}
}
searchProps.onFocus?.(e);
}, [largeHeight, top]);
return (
<Animated.View style={[styles.container, searchTop]}>
<Search
{...searchProps}
onFocus={onFocus}
/>
</Animated.View>
);
};
export default NavigationSearch;

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {ANDROID_HEADER_SEARCH_INSET} from '@constants/view';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
defaultHeight: number;
largeHeight: number;
scrollValue: Animated.SharedValue<number>;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.sidebarBg,
height: 20,
position: 'absolute',
width: '100%',
},
}));
const NavigationHeaderSearchContext = ({
defaultHeight,
largeHeight,
scrollValue,
theme,
}: Props) => {
const styles = getStyleSheet(theme);
const marginTop = useAnimatedStyle(() => {
return {marginTop: (-scrollValue.value + largeHeight + defaultHeight) - ANDROID_HEADER_SEARCH_INSET};
}, [defaultHeight, largeHeight]);
return (
<Animated.View style={[styles.container, marginTop]}>
<Animated.View style={styles.content}/>
</Animated.View>
);
};
export default NavigationHeaderSearchContext;

View File

@@ -0,0 +1,183 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {ActivityIndicatorProps, Platform, StyleProp, TextInput, TextInputProps, TextStyle, TouchableOpacityProps, ViewStyle} from 'react-native';
import {SearchBar} from 'react-native-elements';
import CompassIcon from '@components/compass_icon';
import {SEARCH_INPUT_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
export type SearchProps = TextInputProps & {
cancelIcon?: React.ReactElement;
cancelButtonProps?: Partial<TouchableOpacityProps> & {
buttonStyle?: StyleProp<ViewStyle>;
buttonTextStyle?: StyleProp<TextStyle>;
color?: string;
buttonDisabledStyle?: StyleProp<ViewStyle>;
buttonDisabledTextStyle?: StyleProp<ViewStyle>;
};
cancelButtonTitle?: string;
clearIcon?: React.ReactElement;
containerStyle?: StyleProp<ViewStyle>;
inputContainerStyle?: StyleProp<ViewStyle>;
inputStyle?: StyleProp<TextStyle>;
loadingProps?: ActivityIndicatorProps;
leftIconContainerStyle?: StyleProp<ViewStyle>;
onCancel?(): void;
onClear?(): void;
rightIconContainerStyle?: StyleProp<ViewStyle>;
searchIcon?: React.ReactElement;
showCancel?: boolean;
showLoading?: boolean;
};
type SearchRef = {
blur: () => void;
cancel: () => void;
clear: () => void;
focus: () => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
containerStyle: {
backgroundColor: undefined,
height: undefined,
paddingTop: 0,
paddingBottom: 0,
},
inputContainerStyle: {
backgroundColor: changeOpacity(theme.sidebarText, 0.12),
borderRadius: 8,
height: SEARCH_INPUT_HEIGHT,
marginLeft: 0,
},
inputStyle: {
color: theme.sidebarText,
marginLeft: Platform.select({ios: 6, android: 14}),
top: Platform.select({android: 1}),
...typography('Body', 200, 'Regular'),
lineHeight: undefined,
},
}));
const Search = forwardRef<SearchRef, SearchProps>((props: SearchProps, ref) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const searchRef = useRef<TextInput>(null);
const [value, setValue] = useState(props.value || '');
const searchClearButtonTestID = `${props.testID}.search.clear.button`;
const searchCancelButtonTestID = `${props.testID}.search.cancel.button`;
const searchInputTestID = `${props.testID}.search.input`;
const onCancel = useCallback(() => {
setValue('');
props.onCancel?.();
}, []);
const onClear = useCallback(() => {
setValue('');
props.onClear?.();
}, []);
const onChangeText = useCallback((text: string) => {
setValue(text);
props.onChangeText?.(text);
}, []);
const cancelButtonProps = useMemo(() => ({
buttonTextStyle: {
color: changeOpacity(theme.sidebarText, 0.72),
...typography('Body', 100, 'Regular'),
},
}), [theme]);
const clearIcon = useMemo(() => {
return (
<CompassIcon
color={changeOpacity(theme.sidebarText, Platform.select({android: 0.56, default: 0.72}))}
name={Platform.select({android: 'close', default: 'close-circle'})}
onPress={searchRef.current?.clear}
size={Platform.select({android: 24, default: 18})}
testID={searchClearButtonTestID}
/>
);
}, [searchRef.current, theme]);
const searchIcon = useMemo(() => (
<CompassIcon
color={changeOpacity(theme.sidebarText, Platform.select({android: 0.56, default: 0.72}))}
name='magnify'
size={24}
/>
), [theme]);
const cancelIcon = useMemo(() => (
<CompassIcon
color={changeOpacity(theme.sidebarText, Platform.select({android: 0.56, default: 0.72}))}
name='arrow-left'
// @ts-expect-error cancel is not part of TextInput does exist in SearchBar
onPress={searchRef.current?.cancel}
size={24}
testID={searchCancelButtonTestID}
/>
), [searchRef.current, theme]);
useImperativeHandle(ref, () => ({
blur: () => {
searchRef.current?.blur();
},
cancel: () => {
// @ts-expect-error cancel is not part of TextInput does exist in SearchBar
searchRef.current?.cancel();
},
clear: () => {
searchRef.current?.clear();
},
focus: () => {
searchRef.current?.focus();
},
}), [searchRef]);
return (
<SearchBar
{...props}
cancelButtonProps={props.cancelButtonProps || cancelButtonProps}
cancelButtonTitle={props.cancelButtonTitle || intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
cancelIcon={cancelIcon}
// @ts-expect-error clearIcon definition does not include a ReactElement
clearIcon={clearIcon}
containerStyle={[styles.containerStyle, props.containerStyle]}
inputContainerStyle={[styles.inputContainerStyle, props.inputContainerStyle]}
inputStyle={[styles.inputStyle, props.inputStyle]}
returnKeyType='search'
onCancel={onCancel}
onClear={onClear}
// @ts-expect-error onChangeText type definition is wrong in elements
onChangeText={onChangeText}
placeholder={props.placeholder || intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
placeholderTextColor={props.placeholderTextColor || changeOpacity(theme.sidebarText, Platform.select({android: 0.56, default: 0.72}))}
platform={Platform.select({android: 'android', default: 'ios'})}
ref={searchRef}
// @ts-expect-error searchIcon definition does not include a ReactElement
searchIcon={searchIcon}
selectionColor={Platform.select({android: changeOpacity(theme.sidebarText, 0.24), default: theme.sidebarText})}
testID={searchInputTestID}
value={value}
/>
);
});
Search.displayName = 'SeachBar';
export default Search;

View File

@@ -8,14 +8,16 @@ import {Touchable, TouchableOpacity, TouchableWithoutFeedback, View, StyleProp,
import {TouchableNativeFeedback} from 'react-native-gesture-handler';
type TouchableProps = Touchable & {
testID: string;
children: React.ReactNode | React.ReactNode[];
underlayColor: string;
type: 'native' | 'opacity' | 'none';
borderlessRipple?: boolean;
rippleRadius?: number;
style?: StyleProp<ViewStyle>;
testID: string;
type: 'native' | 'opacity' | 'none';
underlayColor: string;
}
const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = 'native', ...props}: TouchableProps) => {
const TouchableWithFeedbackAndroid = ({borderlessRipple = false, children, rippleRadius, testID, type = 'native', underlayColor, ...props}: TouchableProps) => {
switch (type) {
case 'native':
return (
@@ -23,7 +25,7 @@ const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = '
testID={testID}
{...props}
style={[props.style]}
background={TouchableNativeFeedback.Ripple(underlayColor || '#fff', false)}
background={TouchableNativeFeedback.Ripple(underlayColor || '#fff', borderlessRipple, rippleRadius)}
>
<View>
{children}

View File

@@ -1,11 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
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, ios: 36})!;
export const TABLET_SIDEBAR_WIDTH = 320;
export const TEAM_SIDEBAR_WIDTH = 72;
export const TABLET_HEADER_HEIGHT = 44;
export const IOS_DEFAULT_HEADER_HEIGHT = 50;
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 IOS_HEADER_SEARCH_INSET = 20;
export const TABLET_HEADER_SEARCH_INSET = 28;
export const ANDROID_HEADER_SEARCH_INSET = 11;
export default {
BOTTOM_TAB_ICON_SIZE,
@@ -13,6 +25,16 @@ export default {
PROFILE_PICTURE_EMOJI_SIZE,
DATA_SOURCE_USERS: 'users',
DATA_SOURCE_CHANNELS: 'channels',
SEARCH_INPUT_HEIGHT,
TABLET_SIDEBAR_WIDTH,
TEAM_SIDEBAR_WIDTH,
TABLET_HEADER_HEIGHT,
IOS_DEFAULT_HEADER_HEIGHT,
ANDROID_DEFAULT_HEADER_HEIGHT,
LARGE_HEADER_TITLE,
HEADER_WITH_SEARCH_HEIGHT,
HEADER_WITH_SUBTITLE,
IOS_HEADER_SEARCH_INSET,
TABLET_HEADER_SEARCH_INSET,
ANDROID_HEADER_SEARCH_INSET,
};

139
app/hooks/header.ts Normal file
View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {NativeScrollEvent, Platform} from 'react-native';
import Animated, {scrollTo, useAnimatedRef, useAnimatedScrollHandler, useSharedValue} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import ViewConstants from '@constants/view';
import {useIsTablet} from '@hooks/device';
type HeaderScrollContext = {
momentum?: number;
start?: number;
};
export const useDefaultHeaderHeight = () => {
const isTablet = useIsTablet();
if (isTablet) {
return ViewConstants.TABLET_HEADER_HEIGHT;
}
if (Platform.OS === 'ios') {
return ViewConstants.IOS_DEFAULT_HEADER_HEIGHT;
}
return ViewConstants.ANDROID_DEFAULT_HEADER_HEIGHT;
};
export const useLargeHeaderHeight = (hasLargeTitle: boolean, hasSubtitle: boolean, hasSearch: boolean) => {
const defaultHeight = useDefaultHeaderHeight();
if (hasLargeTitle && hasSubtitle && !hasSearch) {
return defaultHeight + ViewConstants.LARGE_HEADER_TITLE + ViewConstants.HEADER_WITH_SUBTITLE;
} else if (hasLargeTitle && hasSearch) {
return defaultHeight + ViewConstants.LARGE_HEADER_TITLE + ViewConstants.HEADER_WITH_SEARCH_HEIGHT;
}
return defaultHeight + ViewConstants.LARGE_HEADER_TITLE;
};
export const useHeaderHeight = (hasLargeTitle: boolean, hasSubtitle: boolean, hasSearch: boolean) => {
const defaultHeight = useDefaultHeaderHeight();
const largeHeight = useLargeHeaderHeight(hasLargeTitle, hasSubtitle, hasSearch);
return useMemo(() => {
return {
defaultHeight,
largeHeight,
};
}, [defaultHeight, hasSearch, largeHeight]);
};
export const useCollapsibleHeader = <T>(isLargeTitle: boolean, hasSubtitle: boolean, hasSearch: boolean) => {
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const animatedRef = useAnimatedRef<Animated.ScrollView>();
const {largeHeight, defaultHeight} = useHeaderHeight(true, hasSubtitle, hasSearch);
const scrollValue = useSharedValue(0);
function snapIfNeeded(dir: string, offset: number) {
'worklet';
if (dir === 'up' && offset < defaultHeight) {
const diffHeight = largeHeight - defaultHeight;
let position = 0;
if (Platform.OS === 'ios') {
const searchInset = isTablet ? ViewConstants.TABLET_HEADER_SEARCH_INSET : ViewConstants.IOS_HEADER_SEARCH_INSET;
position = (diffHeight - (hasSearch ? -searchInset : insets.top));
} else {
position = hasSearch ? largeHeight + ViewConstants.ANDROID_HEADER_SEARCH_INSET : diffHeight;
}
scrollTo(animatedRef, 0, position!, true);
} else if (dir === 'down') {
let inset = 0;
if (Platform.OS === 'ios') {
const searchInset = isTablet ? ViewConstants.TABLET_HEADER_SEARCH_INSET : ViewConstants.IOS_HEADER_SEARCH_INSET;
inset = defaultHeight + (hasSearch ? searchInset : 0);
} else {
inset = largeHeight + (hasSearch ? ViewConstants.ANDROID_HEADER_SEARCH_INSET : 0);
}
if (offset < inset) {
scrollTo(animatedRef, 0, -insets.top, true);
}
}
}
const onScroll = useAnimatedScrollHandler({
onBeginDrag: (e: NativeScrollEvent, ctx: HeaderScrollContext) => {
ctx.start = e.contentOffset.y;
},
onScroll: (e) => {
scrollValue.value = e.contentOffset.y;
},
onEndDrag: (e, ctx) => {
if (ctx.start !== undefined && Platform.OS === 'ios') {
const dir = e.contentOffset.y < ctx.start ? 'down' : 'up';
const offset = Math.abs(e.contentOffset.y);
ctx.start = undefined;
snapIfNeeded(dir, offset);
}
},
onMomentumBegin: (e, ctx) => {
ctx.momentum = Platform.OS === 'ios' ? e.contentOffset.y : ctx.start;
},
onMomentumEnd: (e, ctx) => {
if (ctx.momentum !== undefined) {
const offset = Math.abs(e.contentOffset.y);
const searchInset = isTablet ? ViewConstants.TABLET_HEADER_SEARCH_INSET : ViewConstants.IOS_HEADER_SEARCH_INSET;
const dir = e.contentOffset.y < ctx.momentum ? 'down' : 'up';
ctx.momentum = undefined;
if (Platform.OS === 'android') {
// This avoids snapping to the defaultHeight when already at the top and scrolling down
if (dir === 'up' && offset === 0) {
return;
}
snapIfNeeded(dir, offset);
} else if (dir === 'down' && offset < (defaultHeight + (hasSearch ? searchInset : 0))) {
scrollTo(animatedRef, 0, -insets.top, true);
}
}
},
}, [insets, defaultHeight, largeHeight]);
let searchPadding = 0;
if (hasSearch) {
searchPadding = ViewConstants.SEARCH_INPUT_HEIGHT +
ViewConstants.IOS_HEADER_SEARCH_INSET +
ViewConstants.ANDROID_HEADER_SEARCH_INSET;
}
return {
scrollPaddingTop: (isLargeTitle ? largeHeight : defaultHeight) + searchPadding,
scrollRef: animatedRef as unknown as React.RefObject<T>,
scrollValue,
onScroll,
};
};
export default useHeaderHeight;

View File

@@ -2,14 +2,26 @@
// See LICENSE.txt for license information.
import {useIsFocused, useNavigation} from '@react-navigation/native';
import React from 'react';
import {Text} from 'react-native';
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, FlatList, View, Platform} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {SafeAreaView} from 'react-native-safe-area-context';
import Badge from '@components/badge';
import NavigationHeader from '@components/navigation_header';
import {useTheme} from '@context/theme';
import {useCollapsibleHeader} from '@hooks/header';
import type {HeaderRightButton} from '@components/navigation_header/header';
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const SearchScreen = () => {
const nav = useNavigation();
const isFocused = useIsFocused();
const theme = useTheme();
const intl = useIntl();
const searchScreenIndex = 1;
const stateIndex = nav.getState().index;
@@ -27,19 +39,109 @@ const SearchScreen = () => {
};
}, [isFocused, stateIndex]);
// Todo: Remove example
const isLargeTitle = true;
const subtitle = '';
const title = 'Search';
const hasSearch = true;
const showBackButton = false;
const addLeftComponent = false;
const addRightButtons = false;
let leftComponent;
let rightButtons: HeaderRightButton[] | undefined;
if (addLeftComponent) {
leftComponent = (
<View>
<Badge
type='Small'
visible={true}
value={1}
style={{top: 0, left: 2, position: 'relative'}}
borderColor='transparent'
/>
</View>
);
}
if (addRightButtons) {
rightButtons = [{
iconName: 'magnify',
onPress: () => true,
}, {
iconName: Platform.select({android: 'dots-vertical', default: 'dots-horizontal'}),
onPress: () => true,
rippleRadius: 15,
borderless: true,
buttonType: 'opacity',
}];
}
const {scrollPaddingTop, scrollRef, scrollValue, onScroll} = useCollapsibleHeader<FlatList<string>>(isLargeTitle, Boolean(subtitle), hasSearch);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop}), [scrollPaddingTop]);
const data = [
'Search Screen 1',
'Search Screen 2',
'Search Screen 3',
'Search Screen 4',
'Search Screen 5',
];
return (
<SafeAreaView
style={{flex: 1, backgroundColor: 'blue'}}
>
<Animated.View
style={[{flex: 1, justifyContent: 'center', alignItems: 'center'}, animated]}
<>
<NavigationHeader
isLargeTitle={isLargeTitle}
leftComponent={leftComponent}
onBackPress={() => {
// eslint-disable-next-line no-console
console.log('BACK');
}}
rightButtons={rightButtons}
showBackButton={showBackButton}
subtitle={subtitle}
title={title}
hasSearch={hasSearch}
scrollValue={scrollValue}
forwardedRef={scrollRef}
onChangeText={(text) => {
// eslint-disable-next-line no-console
console.log('Search for value', text);
}}
onSubmitEditing={() => {
// eslint-disable-next-line no-console
console.log('Execute search');
}}
blurOnSubmit={true}
placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})}
/>
<SafeAreaView
style={{flex: 1}}
edges={['bottom', 'left', 'right']}
>
{isFocused &&
<Text style={{fontSize: 20, color: '#fff'}}>{'Search Screen'}</Text>
}
</Animated.View>
</SafeAreaView>
<Animated.View style={[{flex: 1}, animated]}>
<AnimatedFlatList
contentContainerStyle={paddingTop}
data={data}
scrollToOverflowEnabled={true}
showsVerticalScrollIndicator={false}
indicatorStyle='black'
onScroll={onScroll}
scrollEventThrottle={16}
ref={scrollRef}
renderItem={({item, index}) => {
const height = index === data.length - 1 ? undefined : 400;
return (
<View style={{flex: 1, alignItems: 'center'}}>
<Text style={{fontSize: 20, color: theme.centerChannelColor, height}}>{item as string}</Text>
</View>
);
}}
/>
</Animated.View>
</SafeAreaView>
</>
);
};
export default SearchScreen;

View File

@@ -227,8 +227,10 @@
"mobile.routes.sso": "Single Sign-On",
"mobile.routes.table": "Table",
"mobile.routes.user_profile": "Profile",
"mobile.server_identifier.exists": "You are already connected to this server.",
"mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.",
"mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.",
"mobile.server_name.exists": "You are using this name for another server.",
"mobile.server_ping_failed": "Cannot connect to the server.",
"mobile.server_requires_client_certificate": "Server requires client certificate for authentication.",
"mobile.server_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.",
@@ -293,6 +295,7 @@
"post_info.system": "System",
"post_message_view.edited": "(edited)",
"posts_view.newMsg": "New Messages",
"screen.search.placeholder": "Search messages & files",
"search_bar.search": "Search",
"signup.email": "Email and Password",
"signup.google": "Google Apps",

View File

@@ -4,8 +4,6 @@ PODS:
- BVLinearGradient (2.5.6):
- React
- DoubleConversion (1.1.6)
- EXErrorRecovery (3.0.3):
- ExpoModulesCore
- EXFileSystem (13.0.3):
- ExpoModulesCore
- Expo (43.0.2):
@@ -474,7 +472,6 @@ DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXErrorRecovery (from `../node_modules/expo-error-recovery/ios`)
- EXFileSystem (from `../node_modules/expo-file-system/ios`)
- Expo (from `../node_modules/expo/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core/ios`)
@@ -583,8 +580,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-linear-gradient"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXErrorRecovery:
:path: "../node_modules/expo-error-recovery/ios"
EXFileSystem:
:path: "../node_modules/expo-file-system/ios"
Expo:
@@ -751,7 +746,6 @@ SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234
BVLinearGradient: e3aad03778a456d77928f594a649e96995f1c872
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
EXErrorRecovery: ac2622400a32be84604591f70d0efff416e4b9a2
EXFileSystem: 99aac7962c11c680681819dd9cbca24e20e5b1e7
Expo: b66c9661cf2514b4adf67b8367178e8ed3eb9801
ExpoModulesCore: c353a3bed60c3b83dbe63f134aaa7bf4733ffa16

View File

@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js b/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js
index 1bfd2b4..820ccbc 100644
index 1bfd2b4..436e870 100644
--- a/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js
+++ b/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js
@@ -10,7 +10,7 @@ var __rest = (this && this.__rest) || function (s, e) {
@@ -11,7 +11,14 @@ index 1bfd2b4..820ccbc 100644
import { renderNode } from '../helpers';
import Input from '../input/Input';
import Icon from '../icons/Icon';
@@ -74,18 +74,11 @@ class SearchBar extends Component {
@@ -68,24 +68,17 @@ class SearchBar extends Component {
};
this.onBlur = (event) => {
this.props.onBlur(event);
- this.setState({ hasFocus: false });
+ this.setState({ hasFocus: false, isEmpty: this.props.value === '' });
};
this.onChangeText = (text) => {
this.props.onChangeText(text);
this.setState({ isEmpty: text === '' });
};
@@ -31,7 +38,7 @@ index 1bfd2b4..820ccbc 100644
render() {
var _a;
diff --git a/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js b/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js
index 8fe90be..bb0e071 100644
index 8fe90be..3daf517 100644
--- a/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js
+++ b/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js
@@ -85,6 +85,11 @@ class SearchBar extends Component {
@@ -46,20 +53,3 @@ index 8fe90be..bb0e071 100644
render() {
var _a, _b, _c, _d, _e, _f, _g;
const _h = this.props, { theme, cancelButtonProps, cancelButtonTitle, clearIcon, containerStyle, leftIconContainerStyle, rightIconContainerStyle, inputContainerStyle, inputStyle, placeholderTextColor, showLoading, loadingProps, searchIcon, showCancel } = _h, attributes = __rest(_h, ["theme", "cancelButtonProps", "cancelButtonTitle", "clearIcon", "containerStyle", "leftIconContainerStyle", "rightIconContainerStyle", "inputContainerStyle", "inputStyle", "placeholderTextColor", "showLoading", "loadingProps", "searchIcon", "showCancel"]);
@@ -167,7 +172,6 @@ const styles = StyleSheet.create({
paddingBottom: 13,
paddingTop: 13,
flexDirection: 'row',
- overflow: 'hidden',
alignItems: 'center',
},
input: {
@@ -177,7 +181,7 @@ const styles = StyleSheet.create({
inputContainer: {
borderBottomWidth: 0,
borderRadius: 9,
- minHeight: 36,
+ minHeight: 30,
marginLeft: 8,
marginRight: 8,
},