index.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. import {
  2. useImperativeHandle,
  3. type ComponentRef,
  4. forwardRef,
  5. useEffect,
  6. useState,
  7. useRef
  8. } from "react";
  9. import {
  10. type LayoutChangeEvent,
  11. PanResponder,
  12. ScrollView,
  13. Animated,
  14. View
  15. } from "react-native";
  16. import type IBottomSheetProps from "./type";
  17. import type {
  18. IBottomSheetRef
  19. } from "./type";
  20. import stylesheet from "./stylesheet";
  21. import {
  22. NCoreUIKitTheme
  23. } from "../../core/hooks";
  24. import {
  25. useSafeAreaInsets
  26. } from "react-native-safe-area-context";
  27. import type {
  28. RefForwardingComponent
  29. } from "../../types";
  30. import type {
  31. IModalRef
  32. } from "../modal/type";
  33. import {
  34. windowHeight
  35. } from "../../utils";
  36. import Modal from "../modal";
  37. const BottomSheet: RefForwardingComponent<IBottomSheetRef, IBottomSheetProps> = ({
  38. renderHeader: RenderHeaderComponent,
  39. renderBottom: RenderBottomComponent,
  40. isForceFullScreenOnSwipe = false,
  41. isCanFullScreenOnSwipe = false,
  42. handleContainerBackgroundColor,
  43. handleHeight: handleHeightProp,
  44. isWrapSafeareaContext = true,
  45. backgroundColor = "default",
  46. isWorkAsFullScreen = false,
  47. isWorkWithPortal = true,
  48. isCloseOnOverlay = true,
  49. handleContainerSpacing,
  50. isActive: isActiveProp,
  51. handleBackgroundColor,
  52. isAutoHeight = false,
  53. isShowHandle = true,
  54. isSwipeClose = true,
  55. isCanSwipe = true,
  56. onOverlayPressed,
  57. snapPoint,
  58. children,
  59. onClosed,
  60. onOpened,
  61. onClose,
  62. onOpen,
  63. style,
  64. key,
  65. ...props
  66. }, ref) => {
  67. const {
  68. colors,
  69. spaces
  70. } = NCoreUIKitTheme.useContext();
  71. const {
  72. bottom,
  73. top
  74. } = useSafeAreaInsets();
  75. const [
  76. isMeasured,
  77. setIsMeasured
  78. ] = useState(false);
  79. const [
  80. isActive,
  81. setIsActive
  82. ] = useState(isActiveProp === undefined ? false : isActiveProp);
  83. let bottomSafeArea = isWrapSafeareaContext ? bottom : 0;
  84. let topSafeArea = isWrapSafeareaContext ? top : 0;
  85. if(isForceFullScreenOnSwipe) {
  86. topSafeArea = 0;
  87. }
  88. if(!isWorkWithPortal) {
  89. bottomSafeArea = 0;
  90. topSafeArea = 0;
  91. }
  92. const scrollViewRef = useRef<ComponentRef<ScrollView>>(null);
  93. const modalRef = useRef<IModalRef>(null);
  94. const containerHeightRef = useRef(windowHeight);
  95. const animatedTranslateY = useRef(new Animated.Value(snapPoint && !isWorkAsFullScreen ? snapPoint : containerHeightRef.current)).current;
  96. const animatedHeight = useRef(new Animated.Value(
  97. isAutoHeight ? 0 : snapPoint ?? 0
  98. )).current;
  99. const TOP_GRAB_AREA = 140;
  100. const maxHeight = useRef(isWorkAsFullScreen ? containerHeightRef.current - (isWrapSafeareaContext ? topSafeArea : 0) : containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0));
  101. const heightValue = useRef(isWorkAsFullScreen ? containerHeightRef.current : snapPoint ?? 0);
  102. const initialTranslateY = useRef(0);
  103. const translateYValue = useRef(0);
  104. const contentHeight = useRef(-1);
  105. const initialHeight = useRef(0);
  106. const scrollViewContentHeight = useRef(-1);
  107. const scrollViewLayoutHeight = useRef(-1);
  108. const initialScrollOffset = useRef(0);
  109. const scrollOffset = useRef(0);
  110. const gestureStartY = useRef(0);
  111. const isCanFullScreenOnSwipeRef = useRef(isCanFullScreenOnSwipe);
  112. const isWorkAsFullScreenRef = useRef(isWorkAsFullScreen);
  113. const isSwipeCloseRef = useRef(isSwipeClose);
  114. const isCanSwipeRef = useRef(isCanSwipe);
  115. if(!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  116. maxHeight.current = snapPoint;
  117. }
  118. useImperativeHandle(
  119. ref,
  120. () => ({
  121. close: () => {
  122. closeAnimation();
  123. },
  124. open: () => {
  125. setIsActive(true);
  126. }
  127. }),
  128. []
  129. );
  130. useEffect(() => {
  131. isCanSwipeRef.current = isCanSwipe;
  132. }, [isCanSwipe]);
  133. useEffect(() => {
  134. isSwipeCloseRef.current = isSwipeClose;
  135. }, [isSwipeClose]);
  136. useEffect(() => {
  137. isCanFullScreenOnSwipeRef.current = isCanFullScreenOnSwipe;
  138. }, [isCanFullScreenOnSwipe]);
  139. useEffect(() => {
  140. isWorkAsFullScreenRef.current = isWorkAsFullScreen;
  141. }, [isWorkAsFullScreen]);
  142. useEffect(() => {
  143. if(isMeasured && contentHeight.current !== -1) {
  144. if(isCanFullScreenOnSwipe && !isWorkAsFullScreen) {
  145. maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : topSafeArea);
  146. } else if(isAutoHeight || !snapPoint) {
  147. maxHeight.current = contentHeight.current;
  148. }
  149. }
  150. }, [isMeasured]);
  151. useEffect(() => {
  152. if(isActive && isMeasured) {
  153. openAnimation();
  154. }
  155. }, [
  156. isActive,
  157. isMeasured
  158. ]);
  159. useEffect(() => {
  160. if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  161. maxHeight.current = snapPoint;
  162. } else if (!isWorkAsFullScreen) {
  163. maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0);
  164. } else {
  165. maxHeight.current = containerHeightRef.current - (isWrapSafeareaContext ? topSafeArea : 0);
  166. }
  167. }, [
  168. isForceFullScreenOnSwipe,
  169. isCanFullScreenOnSwipe,
  170. isWrapSafeareaContext,
  171. isWorkAsFullScreen,
  172. topSafeArea,
  173. snapPoint
  174. ]);
  175. useEffect(() => {
  176. const listenerAHeightId = animatedHeight.addListener(({
  177. value
  178. }) => {
  179. heightValue.current = value;
  180. });
  181. const listenerTYId = animatedTranslateY.addListener(({
  182. value
  183. }) => {
  184. translateYValue.current = value;
  185. });
  186. return () => {
  187. animatedHeight.removeListener(listenerAHeightId);
  188. animatedTranslateY.removeListener(listenerTYId);
  189. };
  190. }, []);
  191. const openAnimation = () => {
  192. resetState();
  193. if(onOpen) onOpen();
  194. Animated.timing(animatedTranslateY, {
  195. useNativeDriver: false,
  196. duration: 300,
  197. toValue: 0
  198. }).start(({
  199. finished
  200. }) => {
  201. if(finished) {
  202. if(onOpened) onOpened();
  203. }
  204. });
  205. };
  206. const closeAnimation = (toValue?: number) => {
  207. if(onClose) onClose();
  208. Animated.timing(animatedTranslateY, {
  209. toValue: toValue ? toValue : isAutoHeight || !snapPoint ? contentHeight.current : snapPoint,
  210. useNativeDriver: false,
  211. duration: 300
  212. }).start(({
  213. finished
  214. }) => {
  215. if(finished) {
  216. resetState();
  217. setIsActive(false);
  218. if(onClosed) onClosed();
  219. }
  220. });
  221. };
  222. const onLayout = (event: LayoutChangeEvent) => {
  223. if (isMeasured) return;
  224. const {
  225. height
  226. } = event.nativeEvent.layout;
  227. animatedHeight.setValue(height);
  228. contentHeight.current = height;
  229. setIsMeasured(true);
  230. };
  231. const resetState = () => {
  232. animatedHeight.setOffset(0);
  233. animatedTranslateY.setOffset(0);
  234. scrollOffset.current = 0;
  235. initialScrollOffset.current = 0;
  236. initialHeight.current = snapPoint ?? contentHeight.current;
  237. initialTranslateY.current = 0;
  238. heightValue.current = snapPoint ?? contentHeight.current;
  239. translateYValue.current = 0;
  240. };
  241. const panResponder = useRef(
  242. PanResponder.create({
  243. onStartShouldSetPanResponder: () => false,
  244. onMoveShouldSetPanResponderCapture: () => isCanSwipeRef.current ? true : false,
  245. onPanResponderGrant: (evt) => {
  246. if(!isCanSwipeRef.current) return;
  247. gestureStartY.current = evt.nativeEvent.pageY;
  248. animatedTranslateY.stopAnimation((currentY) => {
  249. translateYValue.current = currentY;
  250. });
  251. animatedHeight.stopAnimation((currentH) => {
  252. heightValue.current = currentH;
  253. });
  254. animatedTranslateY.flattenOffset();
  255. animatedHeight.flattenOffset();
  256. initialTranslateY.current = translateYValue.current;
  257. initialScrollOffset.current = scrollOffset.current;
  258. initialHeight.current = heightValue.current;
  259. animatedHeight.setOffset(heightValue.current);
  260. animatedHeight.setValue(0);
  261. animatedTranslateY.setOffset(translateYValue.current);
  262. animatedTranslateY.setValue(0);
  263. },
  264. onPanResponderMove: (_, gestureState) => {
  265. if(!isCanSwipeRef.current) return;
  266. const {
  267. dy
  268. } = gestureState;
  269. const isAtTop = scrollOffset.current <= 0;
  270. const isAtTavan = heightValue.current >= maxHeight.current - 1;
  271. const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
  272. const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
  273. if (dy > 0 && isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
  274. const currentDelta = -dy;
  275. const initialS = initialScrollOffset.current;
  276. const usedForS = Math.max(currentDelta, -initialS);
  277. scrollOffset.current = initialS + usedForS;
  278. scrollViewRef.current?.scrollTo({
  279. y: scrollOffset.current,
  280. animated: false
  281. });
  282. return;
  283. }
  284. const delta = -dy;
  285. const pivot = snapPoint ?? contentHeight.current;
  286. const initialH = initialHeight.current;
  287. const initialS = initialScrollOffset.current;
  288. const initialT = initialTranslateY.current;
  289. const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
  290. if (dy < 0) {
  291. let currentDelta = delta;
  292. const effectiveInitialT = Math.max(0, initialT);
  293. const usedForT = Math.min(currentDelta, effectiveInitialT);
  294. animatedTranslateY.setValue(-usedForT);
  295. currentDelta -= usedForT;
  296. if (currentDelta > 0) {
  297. if (initialH < pivot) {
  298. const spaceToPivot = pivot - initialH;
  299. const usedForH = Math.min(currentDelta, spaceToPivot);
  300. animatedHeight.setValue(usedForH);
  301. currentDelta -= usedForH;
  302. }
  303. const isRestoringHeight = initialH < pivot;
  304. const canScroll = isCanFullScreenOnSwipeRef.current || isWorkAsFullScreenRef.current || !isRestoringHeight;
  305. if (currentDelta > 0 && canScroll) {
  306. const remainingScroll = maxS - initialS;
  307. if (remainingScroll > 0) {
  308. const usedForS = Math.min(currentDelta, remainingScroll);
  309. scrollOffset.current = initialS + usedForS;
  310. scrollViewRef.current?.scrollTo({
  311. y: scrollOffset.current,
  312. animated: false
  313. });
  314. currentDelta -= usedForS;
  315. animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
  316. }
  317. }
  318. if (currentDelta > 0) {
  319. if (isCanFullScreenOnSwipeRef.current) {
  320. const totalUsedBefore = (initialH < pivot ? (pivot - initialH) : 0);
  321. animatedHeight.setValue(totalUsedBefore + currentDelta);
  322. } else {
  323. animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
  324. }
  325. }
  326. }
  327. } else {
  328. let currentDelta = delta;
  329. if (initialH > pivot || initialS > 0) {
  330. if (initialS > 0) {
  331. const usedForS = Math.max(currentDelta, -initialS);
  332. scrollOffset.current = initialS + usedForS;
  333. scrollViewRef.current?.scrollTo({
  334. y: scrollOffset.current,
  335. animated: false
  336. });
  337. currentDelta -= usedForS;
  338. animatedHeight.setValue(initialH > pivot ? 0 : pivot - initialH);
  339. }
  340. if (currentDelta < 0 && initialH > pivot) {
  341. const distanceToPivot = pivot - initialH;
  342. const usedForH = Math.max(currentDelta, distanceToPivot);
  343. animatedHeight.setValue(usedForH);
  344. currentDelta -= usedForH;
  345. }
  346. }
  347. if (currentDelta < 0) {
  348. animatedHeight.setValue(initialH > pivot ? (pivot - initialH) : 0);
  349. animatedTranslateY.setValue(-currentDelta);
  350. scrollOffset.current = 0;
  351. scrollViewRef.current?.scrollTo({
  352. y: 0,
  353. animated: false
  354. });
  355. }
  356. }
  357. },
  358. onPanResponderEnd: (_, gestureState) => {
  359. if(!isCanSwipeRef.current) return;
  360. const isAtTop = scrollOffset.current <= 1;
  361. const isAtTavan = heightValue.current >= maxHeight.current - 1;
  362. const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
  363. const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
  364. if (isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
  365. return;
  366. }
  367. const currentH = (animatedHeight as Animated.Value & {
  368. __getValue(): number;
  369. }).__getValue();
  370. const currentT = (animatedTranslateY as Animated.Value & {
  371. __getValue(): number;
  372. }).__getValue();
  373. animatedHeight.flattenOffset();
  374. animatedTranslateY.flattenOffset();
  375. heightValue.current = currentH;
  376. translateYValue.current = currentT;
  377. const pivot = snapPoint ?? contentHeight.current;
  378. const tavan = maxHeight.current;
  379. const isFastSwipeDown = gestureState.vy > 0.5;
  380. const isFastSwipeUp = gestureState.vy < -0.5;
  381. if (currentT <= 0.5) {
  382. let toValue = pivot;
  383. if (isFastSwipeUp && isCanFullScreenOnSwipeRef.current) {
  384. toValue = tavan;
  385. } else if (isFastSwipeDown) {
  386. toValue = pivot;
  387. } else {
  388. if (!isCanFullScreenOnSwipeRef.current) {
  389. toValue = pivot;
  390. } else {
  391. const totalRange = tavan - pivot;
  392. const distFromPivot = currentH - pivot;
  393. if (distFromPivot < totalRange * 0.33) {
  394. toValue = pivot;
  395. } else if (distFromPivot > totalRange * 0.66) {
  396. toValue = tavan;
  397. } else {
  398. toValue = (tavan - currentH) < (currentH - pivot) ? tavan : pivot;
  399. }
  400. }
  401. }
  402. Animated.spring(animatedHeight, {
  403. useNativeDriver: false,
  404. toValue: toValue,
  405. friction: 10,
  406. tension: 40
  407. }).start();
  408. } else {
  409. let toValueT = 0;
  410. let isClosing = false;
  411. if (!isSwipeCloseRef.current) {
  412. Animated.spring(animatedHeight, {
  413. useNativeDriver: false,
  414. toValue: pivot,
  415. friction: 10,
  416. tension: 40
  417. }).start();
  418. Animated.timing(animatedTranslateY, {
  419. useNativeDriver: false,
  420. duration: 300,
  421. toValue: 0
  422. }).start();
  423. } else {
  424. if (isFastSwipeDown) {
  425. toValueT = pivot + 100;
  426. isClosing = true;
  427. } else {
  428. if (currentT > pivot * 0.5) {
  429. toValueT = pivot + 100;
  430. isClosing = true;
  431. } else {
  432. toValueT = 0;
  433. isClosing = false;
  434. }
  435. }
  436. Animated.timing(animatedTranslateY, {
  437. useNativeDriver: false,
  438. toValue: toValueT,
  439. duration: 300
  440. }).start(({
  441. finished
  442. }) => {
  443. if (finished && isClosing) {
  444. setIsActive(false);
  445. if (onClosed) onClosed();
  446. }
  447. });
  448. }
  449. }
  450. },
  451. onPanResponderTerminationRequest: () => false,
  452. onShouldBlockNativeResponder: () => true
  453. })
  454. ).current;
  455. const renderView = () => {
  456. return <Animated.View
  457. {...props}
  458. {...panResponder.panHandlers}
  459. onLayout={onLayout}
  460. style={[
  461. {
  462. height: (isAutoHeight || !snapPoint) && !isMeasured ? "auto" : animatedHeight,
  463. backgroundColor: colors.content.container[backgroundColor],
  464. paddingBottom: bottomSafeArea + spaces.spacingMd,
  465. opacity: isMeasured || !isAutoHeight ? 1 : 0,
  466. paddingRight: spaces.spacingMd,
  467. paddingLeft: spaces.spacingMd,
  468. paddingTop: spaces.spacingMd,
  469. maxHeight: maxHeight.current,
  470. transform: [{
  471. translateY: animatedTranslateY
  472. }]
  473. },
  474. stylesheet.container,
  475. style
  476. ]}
  477. >
  478. {renderHeader()}
  479. <ScrollView
  480. onContentSizeChange={(w, h) => {
  481. scrollViewContentHeight.current = h;
  482. }}
  483. onLayout={(e) => {
  484. scrollViewLayoutHeight.current = e.nativeEvent.layout.height;
  485. }}
  486. onStartShouldSetResponderCapture={() => false}
  487. onMoveShouldSetResponderCapture={() => false}
  488. showsHorizontalScrollIndicator={false}
  489. showsVerticalScrollIndicator={false}
  490. scrollEnabled={!isCanSwipe}
  491. scrollEventThrottle={1}
  492. overScrollMode="never"
  493. ref={scrollViewRef}
  494. bounces={false}
  495. >
  496. {children}
  497. </ScrollView>
  498. {renderBottom()}
  499. {renderHandle()}
  500. </Animated.View>;
  501. };
  502. const renderHeader = () => {
  503. if(!RenderHeaderComponent) {
  504. return null;
  505. }
  506. return <RenderHeaderComponent/>;
  507. };
  508. const renderBottom = () => {
  509. if(!RenderBottomComponent) {
  510. return null;
  511. }
  512. return <RenderBottomComponent/>;
  513. };
  514. const renderHandle = () => {
  515. if(!isShowHandle || isWorkAsFullScreen) {
  516. return null;
  517. }
  518. const handleHeight = handleHeightProp ? handleHeightProp : 8;
  519. const handleContainerHeight = handleHeight + ((handleContainerSpacing ? spaces[handleContainerSpacing] : spaces.spacingSm) * 2);
  520. const handleBorderRadius = Math.ceil(handleHeight / 2);
  521. return <View
  522. {...panResponder.panHandlers}
  523. onStartShouldSetResponderCapture={() => false}
  524. onMoveShouldSetResponderCapture={() => false}
  525. style={[
  526. stylesheet.handleContainer,
  527. {
  528. backgroundColor: handleContainerBackgroundColor
  529. ? colors.content.container[handleContainerBackgroundColor]
  530. : "transparent",
  531. height: handleContainerHeight,
  532. transform: [{
  533. translateY: -handleContainerHeight
  534. }]
  535. }
  536. ]}
  537. >
  538. <View
  539. style={[
  540. stylesheet.handle,
  541. {
  542. backgroundColor: handleBackgroundColor
  543. ? colors.content.container[handleBackgroundColor]
  544. : colors.content.container.default,
  545. borderRadius: handleBorderRadius,
  546. height: handleHeight
  547. }
  548. ]}
  549. />
  550. </View>;
  551. };
  552. return <Modal
  553. isWorkWithPortal={isWorkWithPortal}
  554. isContentRequired={false}
  555. key={`${key}-modal`}
  556. isActive={isActive}
  557. alignContent="free"
  558. isAnimated={false}
  559. ref={modalRef}
  560. onContainerLayout={(e) => {
  561. const containerLayoutHeight = e.nativeEvent.layout.height;
  562. if (containerLayoutHeight && containerLayoutHeight !== containerHeightRef.current) {
  563. containerHeightRef.current = containerLayoutHeight;
  564. if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  565. maxHeight.current = snapPoint;
  566. } else if (!isWorkAsFullScreen) {
  567. maxHeight.current = containerLayoutHeight - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0);
  568. } else {
  569. maxHeight.current = containerLayoutHeight;
  570. }
  571. }
  572. }}
  573. overlayProps={{
  574. onStartShouldSetResponderCapture: () => false,
  575. onMoveShouldSetResponderCapture: () => false
  576. }}
  577. onOverlayPress={() => {
  578. if(onOverlayPressed) onOverlayPressed();
  579. if(isCloseOnOverlay) {
  580. closeAnimation();
  581. }
  582. }}
  583. style={[
  584. {
  585. paddingTop: topSafeArea
  586. }
  587. ]}
  588. >
  589. {renderView()}
  590. </Modal>;
  591. };
  592. export default forwardRef(BottomSheet);