let's roll with custom bottom sheet for now

This commit is contained in:
Eric Bailey
2023-10-12 17:39:36 -05:00
parent 8aeb364014
commit b1843533eb
5 changed files with 489 additions and 95 deletions
+1
View File
@@ -152,6 +152,7 @@
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0", "rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.0", "sentry-expo": "~7.0.0",
"smitter": "^1.1.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tlds": "^1.234.0", "tlds": "^1.234.0",
"zeego": "^1.6.2", "zeego": "^1.6.2",
+81 -93
View File
@@ -7,16 +7,18 @@ import {
Platform, Platform,
Pressable, Pressable,
ScrollView, ScrollView,
TextInput,
} from 'react-native' } from 'react-native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetTextInput,
} from '@gorhom/bottom-sheet'
import {
useSheet,
Sheet as BottomSheet,
Backdrop as BottomSheetBackdrop,
} from 'view/com/util/BottomSheet'
import {Portal} from 'view/com/util/Portal' import {Portal} from 'view/com/util/Portal'
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@@ -43,15 +45,31 @@ export function TagInput({
}) { }) {
const store = useStores() const store = useStores()
const model = React.useMemo(() => new TagsAutocompleteModel(store), [store]) const model = React.useMemo(() => new TagsAutocompleteModel(store), [store])
const sheet = React.useRef<BottomSheet>(null)
const pal = usePalette('default') const pal = usePalette('default')
const input = React.useRef<HTMLInputElement>(null) const input = React.useRef<TextInput>(null)
const [value, setValue] = React.useState('') const [value, setValue] = React.useState('')
const [tags, setTags] = React.useState<string[]>([]) const [tags, setTags] = React.useState<string[]>([])
const [suggestions, setSuggestions] = React.useState<string[]>([]) const [suggestions, setSuggestions] = React.useState<string[]>([])
const [isInitialLoad, setIsInitialLoad] = React.useState(true) const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const sheet = useSheet({
index: 0,
snaps: [0, '90%'],
async onStateChange(state) {
if (state.index > 0) {
model.setActive(true)
await model.search('') // get default results
setSuggestions(model.suggestions)
setIsInitialLoad(false)
} else {
reset()
setIsInitialLoad(true)
input.current?.blur()
}
},
})
const reset = React.useCallback(() => { const reset = React.useCallback(() => {
setValue('') setValue('')
model.setActive(false) model.setActive(false)
@@ -83,10 +101,7 @@ export function TagInput({
addTags(uniq([...tags, tag]).slice(0, max)) addTags(uniq([...tags, tag]).slice(0, max))
} }
setTimeout(() => { setValue('')
setValue('')
input.current?.focus()
}, 1)
}, },
[max, tags, setValue, addTags], [max, tags, setValue, addTags],
) )
@@ -102,8 +117,10 @@ export function TagInput({
if (key === 'Backspace' && value === '') { if (key === 'Backspace' && value === '') {
addTags(tags.slice(0, -1)) addTags(tags.slice(0, -1))
} else if (key === ' ') { } else if (key === ' ') {
e.preventDefault() // prevents an additional space on web
addTagAndReset(value) addTagAndReset(value)
setTimeout(() => {
setValue('')
}, 1)
} }
}, },
[value, tags, addTags, addTagAndReset], [value, tags, addTags, addTagAndReset],
@@ -127,31 +144,17 @@ export function TagInput({
[model, setValue], [model, setValue],
) )
const onCloseSheet = React.useCallback(() => { const openSheet = React.useCallback(() => {
reset() sheet.index = 1
setIsInitialLoad(true) input.current?.focus()
}, [reset, setIsInitialLoad]) }, [sheet])
const onSheetChange = React.useCallback(
async (index: number) => {
if (index > -1) {
model.setActive(true)
await model.search('') // get default results
setSuggestions(model.suggestions)
setIsInitialLoad(false)
}
},
[model, setIsInitialLoad, setSuggestions],
)
return ( return (
<View> <View>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
style={styles.selectedTags} style={styles.selectedTags}
onPress={() => { onPress={openSheet}>
sheet.current?.snapToIndex(0)
}}>
<View style={[pal.viewLight, styles.button]}> <View style={[pal.viewLight, styles.button]}>
{tags.length ? ( {tags.length ? (
<Text type="md-medium" style={[pal.textLight]}> <Text type="md-medium" style={[pal.textLight]}>
@@ -181,76 +184,61 @@ export function TagInput({
</Pressable> </Pressable>
<Portal> <Portal>
<BottomSheet <BottomSheet sheet={sheet}>
ref={sheet} <BottomSheetBackdrop sheet={sheet} />
index={-1}
snapPoints={['90%']}
enablePanDownToClose
keyboardBehavior="extend"
backgroundStyle={{backgroundColor: 'transparent'}}
android_keyboardInputMode="adjustResize"
backdropComponent={props => (
<BottomSheetBackdrop
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
/>
)}
handleIndicatorStyle={{backgroundColor: pal.text.color}}
handleStyle={{display: 'none'}}
onChange={onSheetChange}
onClose={onCloseSheet}>
<Sheet.Outer> <Sheet.Outer>
<Sheet.Handle /> <Sheet.Handle />
<View style={styles.outer}> <Sheet.Content>
<FontAwesomeIcon <View style={styles.outer}>
icon="tags" <FontAwesomeIcon
size={14} icon="tags"
style={pal.textLight as FontAwesomeIconStyle} size={14}
/> style={pal.textLight as FontAwesomeIconStyle}
/>
{tags.map(tag => ( {tags.map(tag => (
<TagButton key={tag} value={tag} onClick={removeTag} /> <TagButton key={tag} value={tag} onClick={removeTag} />
))} ))}
<BottomSheetTextInput <TextInput
autoCapitalize="none" ref={input}
autoComplete="off" blurOnSubmit={false}
placeholder="Add tags..." autoCapitalize="none"
value={value} autoComplete="off"
style={[ placeholder="Add tags..."
styles.input, value={value}
{ style={[styles.input, {}]}
placeholderTextColor: pal.textLight.color, onChangeText={onChangeText}
}, onKeyPress={onKeyPress}
]} onSubmitEditing={onSubmitEditing}
onChangeText={onChangeText} accessibilityHint="Add tags to your post"
onKeyPress={onKeyPress} accessibilityLabel="Add tags to your post"
onSubmitEditing={onSubmitEditing} />
/> </View>
</View>
<View style={{marginHorizontal: -20}}> <View style={{marginHorizontal: -20}}>
<ScrollView horizontal> <ScrollView horizontal>
<View style={styles.suggestions}> <View style={styles.suggestions}>
{isInitialLoad && <ActivityIndicator />} {isInitialLoad && <ActivityIndicator />}
{suggestions {suggestions
.filter(s => !tags.find(t => t === s)) .filter(s => !tags.find(t => t === s))
.map(suggestion => { .map(suggestion => {
return ( return (
<TagButton <TagButton
key={suggestion} key={suggestion}
icon="plus" icon="plus"
value={suggestion} value={suggestion}
onClick={addTagAndReset} onClick={addTagAndReset}
/> />
) )
})} })}
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
</Sheet.Content>
</Sheet.Outer> </Sheet.Outer>
</BottomSheet> </BottomSheet>
</Portal> </Portal>
+8 -2
View File
@@ -10,11 +10,15 @@ export function Outer(props: React.PropsWithChildren<{}>) {
<> <>
<View style={[pal.view, styles.background]} /> <View style={[pal.view, styles.background]} />
<View style={styles.content}>{props.children}</View> {props.children}
</> </>
) )
} }
export function Content(props: React.PropsWithChildren<{}>) {
return <View style={styles.content}>{props.children}</View>
}
export function Handle() { export function Handle() {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
@@ -28,7 +32,7 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
borderTopRightRadius: 40, borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2, height: Dimensions.get('window').height * 2,
zIndex: -1, zIndex: 1,
}, },
content: { content: {
paddingVertical: 40, paddingVertical: 40,
@@ -36,6 +40,7 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
borderTopRightRadius: 40, borderTopRightRadius: 40,
overflow: 'hidden', overflow: 'hidden',
zIndex: 2,
}, },
handle: { handle: {
position: 'absolute', position: 'absolute',
@@ -44,5 +49,6 @@ const styles = StyleSheet.create({
width: 80, width: 80,
height: 6, height: 6,
borderRadius: 10, borderRadius: 10,
zIndex: 2,
}, },
}) })
+394
View File
@@ -0,0 +1,394 @@
import React from 'react'
import {View, Dimensions, Pressable} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
runOnJS,
} from 'react-native-reanimated'
import {smitter} from 'smitter'
type BottomSheetState = {
index: number
minIndex: number
maxIndex: number
position: number
pinned: boolean
offset: number
snaps: number[]
}
type BottomSheetProps = {
sheet: ReturnType<typeof useSheet>
}
type InternalEvents = {
syncState: BottomSheetState
}
export function useSheet({
index: initialIndex = 0,
minIndex: initialMinIndex = 0,
snaps,
onStateChange,
}: {
index?: number
minIndex?: number
maxIndex?: number
snaps: (number | string)[]
onStateChange?: (state: BottomSheetState) => void
}) {
const internal = React.useMemo(() => smitter<InternalEvents>(), [])
const dimensions = React.useMemo(() => Dimensions.get('window'), []) // TODO needs change?
const snapPoints = React.useMemo(() => {
return snaps.map(p => {
const px =
typeof p === 'number' ? p : (parseInt(p) / 100) * dimensions.height
return px
})
}, [snaps, dimensions.height])
const index = React.useRef(initialIndex)
const minIndex = React.useRef(Math.max(initialMinIndex, 0))
const maxIndex = React.useRef(snaps.length - 1)
const position = React.useRef(
index.current > -1 ? snapPoints[index.current] : 0,
)
const pinned = React.useRef(false)
const offset = React.useRef(0)
const getState = React.useCallback(() => {
return {
index: index.current,
minIndex: minIndex.current,
maxIndex: maxIndex.current,
position: position.current,
pinned: pinned.current,
offset: offset.current,
snaps: snapPoints,
}
}, [snapPoints])
const syncState = React.useCallback(
(state: Partial<BottomSheetState>) => {
if (state.minIndex !== undefined) {
minIndex.current = Math.max(state.minIndex, 0)
}
if (state.maxIndex !== undefined) {
maxIndex.current = Math.min(state.maxIndex, snapPoints.length - 1)
}
if (state.index !== undefined) {
index.current = Math.max(
Math.min(state.index, maxIndex.current),
minIndex.current,
)
position.current = snapPoints[index.current]
}
if (state.position !== undefined) {
position.current = Math.max(
Math.min(state.position, snapPoints[maxIndex.current]),
snapPoints[minIndex.current],
)
}
if (state.pinned !== undefined) {
pinned.current = state.pinned
}
if (state.offset !== undefined) {
offset.current = state.offset
}
onStateChange?.(getState())
},
[getState, onStateChange, snapPoints],
)
const setState = React.useCallback(
(state: Partial<BottomSheetState>) => {
syncState(state)
internal.emit('syncState', getState())
},
[syncState, getState, internal],
)
return {
get state() {
return getState()
},
open() {
setState({
index: 1,
})
},
close() {
setState({
index: 0,
minIndex: 0,
})
},
set index(value: number) {
setState({index: value})
},
get index() {
return index.current
},
set position(value: number | string) {
const position =
typeof value === 'number'
? value
: (parseInt(value) / 100) * dimensions.height
setState({position})
},
get position() {
return position.current
},
set minIndex(index: number) {
setState({minIndex: index})
},
get minIndex() {
return minIndex.current
},
set maxIndex(index: number) {
setState({maxIndex: index})
},
get maxIndex() {
return maxIndex.current
},
set pinned(value: boolean) {
setState({pinned: value})
},
get pinned() {
return pinned.current
},
set offset(offset: number) {
setState({offset})
},
get offset() {
return offset.current
},
events: {
internal,
},
_syncState: syncState,
}
}
export function Sheet({
children,
sheet,
}: React.PropsWithChildren<BottomSheetProps>) {
const state = useSharedValue(sheet.state)
state.value = sheet.state
const {index, snaps} = state.value
const dimensions = React.useMemo(() => Dimensions.get('window'), [])
const top = useSharedValue(index > -1 ? snaps[index] : 0)
const animatedSheetStyles = useAnimatedStyle(() => ({
transform: [{translateY: -top.value}],
}))
const offset = useSharedValue(dimensions.height)
const animatedOuterStyles = useAnimatedStyle(() => ({
transform: [{translateY: offset.value}],
}))
React.useEffect(() => {
function goToPosition(pos: number) {
top.value = withTiming(pos, {
duration: 500,
easing: Easing.out(Easing.exp),
})
}
sheet.events.internal.on('syncState', s => {
if (state.value.index != s.index) {
const pos = index > -1 ? snaps[s.index] : 0
goToPosition(pos)
}
if (state.value.position != s.position) {
goToPosition(s.position)
}
if (state.value.offset != s.offset) {
offset.value = withTiming(dimensions.height - s.offset, {
duration: 500,
easing: Easing.out(Easing.exp),
})
}
state.value = s
})
}, [
snaps,
dimensions.height,
index,
offset,
sheet.events.internal,
state,
top,
])
const pan = Gesture.Pan()
.onChange(e => {
top.value = top.value - e.changeY
})
.onFinalize(e => {
// ignore taps
if (Math.abs(e.translationY) < 5) return
let y = top.value // from the bottom
const dir = e.velocityY > 0 ? 1 : -1
let v = Math.abs(e.velocityY) / 100
let decayDistance = 0
while (v > 0.1) {
v *= 1 - 0.15
decayDistance += v
}
decayDistance = decayDistance * dir
y = y - decayDistance
let {index, minIndex, maxIndex, position, pinned} = state.value
let nextPosition = position
if (!pinned) {
for (let i = index; i < snaps.length; i++) {
const lower = snaps[i - 1] || snaps[0]
const curr = snaps[i]
const upper = snaps[i + 1] || snaps[snaps.length - 1]
const lowerThreshold = (curr - lower) / 2 + lower
const upperThreshold = (upper - curr) / 2 + curr
if (y < curr && y < lowerThreshold) {
index = Math.max(i - 1, minIndex)
break
} else if (
(y <= curr && // less than current snap point
y > lowerThreshold) || // more than half way to current snap point
(y >= curr && // more than current snap point
y < upperThreshold) // less than half way to upper snap point
) {
index = i
break
} else if (
y > upper && // less than upper snap point
y > upperThreshold // more than current snap point
) {
index = Math.min(i + 1, maxIndex)
break
}
}
nextPosition = index > 0 ? snaps[index] : 0
}
top.value = withTiming(nextPosition, {
duration: 500,
easing: Easing.out(Easing.exp),
})
// update UI thread state
state.value = {
...state.value,
index,
position: nextPosition,
}
// update JS thread state without cyclical emit
runOnJS(sheet._syncState)({index, position: nextPosition})
})
return (
<Animated.View
style={[
{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
},
animatedOuterStyles,
]}>
<GestureDetector gesture={pan}>
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
animatedSheetStyles,
]}>
<View
style={[
{
zIndex: 1,
height: snaps[snaps.length - 1],
},
]}>
{children}
</View>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 0,
height: dimensions.height * 2,
}}
/>
</Animated.View>
</GestureDetector>
</Animated.View>
)
}
export function Backdrop({sheet}: {sheet: ReturnType<typeof useSheet>}) {
const active = sheet.position > 0
const opacity = useSharedValue(0)
const style = useAnimatedStyle(() => ({
position: 'absolute',
top: '-200%',
bottom: '-200%',
left: 0,
right: 0,
backgroundColor: '#000',
zIndex: 0,
opacity: opacity.value,
display: opacity.value > 0 ? 'flex' : 'none',
}))
React.useEffect(() => {
opacity.value = withTiming(active ? 0.5 : 0, {
duration: 500,
easing: Easing.out(Easing.exp),
})
}, [active, opacity])
return (
<Animated.View style={style}>
<Pressable
accessibilityHint="Click here to close the bottom sheet"
accessibilityLabel="Click here to close the bottom sheet"
onPress={() => sheet.close()}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
}}
/>
</Animated.View>
)
}
+5
View File
@@ -16985,6 +16985,11 @@ slugify@^1.3.4:
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
smitter@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/smitter/-/smitter-1.1.1.tgz#cade535ccd3b2cc8ad274a9fe9b02937f50a316f"
integrity sha512-6AwxCy1VfHVBpCljZb/QCGUcRmZKL6s3o5NRjJfJKAQxtiC8GCJUpy1OFs3RcJinykoj/p7jIkPrM3Z3bYmgZg==
sockjs@^0.3.24: sockjs@^0.3.24:
version "0.3.24" version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"