diff --git a/package.json b/package.json index de2e861da5..bd3d7b3e36 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "react-responsive": "^9.0.2", "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.0", + "smitter": "^1.1.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 12b68bddc0..32552e60c4 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -7,16 +7,18 @@ import { Platform, Pressable, ScrollView, + TextInput, } from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } 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 {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' @@ -43,15 +45,31 @@ export function TagInput({ }) { const store = useStores() const model = React.useMemo(() => new TagsAutocompleteModel(store), [store]) - const sheet = React.useRef(null) const pal = usePalette('default') - const input = React.useRef(null) + const input = React.useRef(null) const [value, setValue] = React.useState('') const [tags, setTags] = React.useState([]) const [suggestions, setSuggestions] = React.useState([]) 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(() => { setValue('') model.setActive(false) @@ -83,10 +101,7 @@ export function TagInput({ addTags(uniq([...tags, tag]).slice(0, max)) } - setTimeout(() => { - setValue('') - input.current?.focus() - }, 1) + setValue('') }, [max, tags, setValue, addTags], ) @@ -102,8 +117,10 @@ export function TagInput({ if (key === 'Backspace' && value === '') { addTags(tags.slice(0, -1)) } else if (key === ' ') { - e.preventDefault() // prevents an additional space on web addTagAndReset(value) + setTimeout(() => { + setValue('') + }, 1) } }, [value, tags, addTags, addTagAndReset], @@ -127,31 +144,17 @@ export function TagInput({ [model, setValue], ) - const onCloseSheet = React.useCallback(() => { - reset() - setIsInitialLoad(true) - }, [reset, setIsInitialLoad]) - - 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], - ) + const openSheet = React.useCallback(() => { + sheet.index = 1 + input.current?.focus() + }, [sheet]) return ( { - sheet.current?.snapToIndex(0) - }}> + onPress={openSheet}> {tags.length ? ( @@ -181,76 +184,61 @@ export function TagInput({ - ( - - )} - handleIndicatorStyle={{backgroundColor: pal.text.color}} - handleStyle={{display: 'none'}} - onChange={onSheetChange} - onClose={onCloseSheet}> + + + - - + + + - {tags.map(tag => ( - - ))} + {tags.map(tag => ( + + ))} - - + + - - - - {isInitialLoad && } + + + + {isInitialLoad && } - {suggestions - .filter(s => !tags.find(t => t === s)) - .map(suggestion => { - return ( - - ) - })} - - - + {suggestions + .filter(s => !tags.find(t => t === s)) + .map(suggestion => { + return ( + + ) + })} + + + + diff --git a/src/view/com/sheets/Base.tsx b/src/view/com/sheets/Base.tsx index 75dd8f62ba..d1a381d274 100644 --- a/src/view/com/sheets/Base.tsx +++ b/src/view/com/sheets/Base.tsx @@ -10,11 +10,15 @@ export function Outer(props: React.PropsWithChildren<{}>) { <> - {props.children} + {props.children} ) } +export function Content(props: React.PropsWithChildren<{}>) { + return {props.children} +} + export function Handle() { const pal = usePalette('default') return ( @@ -28,7 +32,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 40, borderTopRightRadius: 40, height: Dimensions.get('window').height * 2, - zIndex: -1, + zIndex: 1, }, content: { paddingVertical: 40, @@ -36,6 +40,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 40, borderTopRightRadius: 40, overflow: 'hidden', + zIndex: 2, }, handle: { position: 'absolute', @@ -44,5 +49,6 @@ const styles = StyleSheet.create({ width: 80, height: 6, borderRadius: 10, + zIndex: 2, }, }) diff --git a/src/view/com/util/BottomSheet.tsx b/src/view/com/util/BottomSheet.tsx new file mode 100644 index 0000000000..af159a9820 --- /dev/null +++ b/src/view/com/util/BottomSheet.tsx @@ -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 +} + +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(), []) + 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) => { + 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) => { + 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) { + 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 ( + + + + + {children} + + + + + + + ) +} + +export function Backdrop({sheet}: {sheet: ReturnType}) { + 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 ( + + sheet.close()} + style={{ + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }} + /> + + ) +} diff --git a/yarn.lock b/yarn.lock index 1685200e13..c8dfc2c133 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16985,6 +16985,11 @@ slugify@^1.3.4: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" 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: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"