Rework native autocomplete (#5521)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Hailey
2024-09-27 15:26:28 -07:00
committed by GitHub
parent 4b5d6e6efb
commit 587c0c6257
4 changed files with 105 additions and 120 deletions
+9 -8
View File
@@ -13,17 +13,19 @@ import {isNative} from '#/platform/detection'
const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1 const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export function PressableScale({ export function PressableScale({
targetScale = DEFAULT_TARGET_SCALE, targetScale = DEFAULT_TARGET_SCALE,
children, children,
contentContainerStyle, style,
onPressIn, onPressIn,
onPressOut, onPressOut,
...rest ...rest
}: { }: {
targetScale?: number targetScale?: number
contentContainerStyle?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} & Exclude<PressableProps, 'onPressIn' | 'onPressOut'>) { } & Exclude<PressableProps, 'onPressIn' | 'onPressOut' | 'style'>) {
const scale = useSharedValue(1) const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
@@ -31,7 +33,7 @@ export function PressableScale({
})) }))
return ( return (
<Pressable <AnimatedPressable
accessibilityRole="button" accessibilityRole="button"
onPressIn={e => { onPressIn={e => {
'worklet' 'worklet'
@@ -49,10 +51,9 @@ export function PressableScale({
cancelAnimation(scale) cancelAnimation(scale)
scale.value = withTiming(1, {duration: 100}) scale.value = withTiming(1, {duration: 100})
}} }}
style={[animatedStyle, style]}
{...rest}> {...rest}>
<Animated.View style={[animatedStyle, contentContainerStyle]}> {children}
{children as React.ReactNode} </AnimatedPressable>
</Animated.View>
</Pressable>
) )
} }
@@ -245,7 +245,11 @@ export const TextInput = forwardRef(function TextInputImpl(
multiline multiline
scrollEnabled={false} scrollEnabled={false}
numberOfLines={4} numberOfLines={4}
style={[inputTextStyle, a.w_full, {textAlignVertical: 'top'}]} style={[
inputTextStyle,
a.w_full,
{textAlignVertical: 'top', minHeight: 60},
]}
{...props}> {...props}>
{textDecorated} {textDecorated}
</PasteInput> </PasteInput>
@@ -1,13 +1,17 @@
import React, {useEffect, useRef} from 'react' import React, {useRef} from 'react'
import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' import {View} from 'react-native'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {Trans} from '@lingui/macro'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {useGrapheme} from '../hooks/useGrapheme'
export function Autocomplete({ export function Autocomplete({
prefix, prefix,
@@ -16,8 +20,8 @@ export function Autocomplete({
prefix: string prefix: string
onSelect: (item: string) => void onSelect: (item: string) => void
}) { }) {
const pal = usePalette('default') const t = useTheme()
const positionInterp = useAnimatedValue(0)
const {getGraphemeString} = useGrapheme() const {getGraphemeString} = useGrapheme()
const isActive = !!prefix const isActive = !!prefix
const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
@@ -28,108 +32,85 @@ export function Autocomplete({
suggestionsRef.current = suggestions suggestionsRef.current = suggestions
} }
useEffect(() => { if (!isActive) return null
Animated.timing(positionInterp, {
toValue: isActive ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start()
}, [positionInterp, isActive])
const topAnimStyle = {
transform: [
{
translateY: positionInterp.interpolate({
inputRange: [0, 1],
outputRange: [200, 0],
}),
},
],
}
return ( return (
<Animated.View style={topAnimStyle}> <Animated.View
{isActive ? ( entering={FadeInDown.duration(200)}
<View style={[pal.view, styles.container, pal.border]}> exiting={FadeOut.duration(100)}
{suggestionsRef.current?.length ? ( style={[
suggestionsRef.current.slice(0, 5).map(item => { t.atoms.bg,
// Eventually use an average length a.mt_sm,
const MAX_CHARS = 40 a.border,
const MAX_HANDLE_CHARS = 20 a.rounded_sm,
t.atoms.border_contrast_high,
{marginLeft: -62},
]}>
{suggestionsRef.current?.length ? (
suggestionsRef.current.slice(0, 5).map((item, index, arr) => {
// Eventually use an average length
const MAX_CHARS = 40
const MAX_HANDLE_CHARS = 20
// Using this approach because styling is not respecting // Using this approach because styling is not respecting
// bounding box wrapping (before converting to ellipsis) // bounding box wrapping (before converting to ellipsis)
const {name: displayHandle, remainingCharacters} = const {name: displayHandle, remainingCharacters} = getGraphemeString(
getGraphemeString(item.handle, MAX_HANDLE_CHARS) item.handle,
MAX_HANDLE_CHARS,
)
const {name: displayName} = getGraphemeString( const {name: displayName} = getGraphemeString(
item.displayName ?? item.handle, item.displayName || item.handle,
MAX_CHARS - MAX_CHARS -
MAX_HANDLE_CHARS + MAX_HANDLE_CHARS +
(remainingCharacters > 0 ? remainingCharacters : 0), (remainingCharacters > 0 ? remainingCharacters : 0),
) )
return ( return (
<TouchableOpacity <View
testID="autocompleteButton" style={[
key={item.handle} index !== arr.length - 1 && a.border_b,
style={[pal.border, styles.item]} t.atoms.border_contrast_high,
onPress={() => onSelect(item.handle)} a.px_sm,
accessibilityLabel={`Select ${item.handle}`} a.py_md,
accessibilityHint=""> ]}
<View style={styles.avatarAndHandle}> key={item.handle}>
<UserAvatar <PressableScale
avatar={item.avatar ?? null} testID="autocompleteButton"
size={24} style={[
type={item.associated?.labeler ? 'labeler' : 'user'} a.flex_row,
/> a.gap_sm,
<Text type="md-medium" style={pal.text}> a.justify_between,
{displayName} a.align_center,
</Text> ]}
</View> onPress={() => onSelect(item.handle)}
<Text type="sm" style={pal.textLight} numberOfLines={1}> accessibilityLabel={`Select ${item.handle}`}
@{displayHandle} accessibilityHint="">
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<UserAvatar
avatar={item.avatar ?? null}
size={24}
type={item.associated?.labeler ? 'labeler' : 'user'}
/>
<Text
style={[a.text_md, a.font_bold]}
emoji={true}
numberOfLines={1}>
{sanitizeDisplayName(displayName)}
</Text> </Text>
</TouchableOpacity> </View>
) <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}>
}) {sanitizeHandle(displayHandle, '@')}
) : ( </Text>
<Text type="sm" style={[pal.text, pal.border, styles.noResults]}> </PressableScale>
{isFetching ? ( </View>
<Trans>Loading...</Trans> )
) : ( })
<Trans>No result</Trans> ) : (
)} <Text style={[a.text_md, a.px_sm, a.py_md]}>
</Text> {isFetching ? <Trans>Loading...</Trans> : <Trans>No result</Trans>}
)} </Text>
</View> )}
) : null}
</Animated.View> </Animated.View>
) )
} }
const styles = StyleSheet.create({
container: {
marginLeft: -50, // Composer avatar width
top: 10,
borderTopWidth: 1,
},
item: {
borderBottomWidth: 1,
paddingVertical: 12,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 6,
},
avatarAndHandle: {
display: 'flex',
flexDirection: 'row',
gap: 6,
alignItems: 'center',
},
noResults: {
paddingVertical: 12,
},
})
+3 -4
View File
@@ -351,17 +351,16 @@ function Btn({
return ( return (
<PressableScale <PressableScale
testID={testID} testID={testID}
style={styles.ctrl} style={[styles.ctrl, a.flex_1]}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
accessible={accessible} accessible={accessible}
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
targetScale={0.8} targetScale={0.8}>
contentContainerStyle={[a.flex_1]}>
{icon} {icon}
{notificationCount ? ( {notificationCount ? (
<View style={[styles.notificationCount, {top: -5}]}> <View style={[styles.notificationCount]}>
<Text style={styles.notificationCountLabel}>{notificationCount}</Text> <Text style={styles.notificationCountLabel}>{notificationCount}</Text>
</View> </View>
) : undefined} ) : undefined}