Rework native autocomplete (#5521)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user