index.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236
  1. import {
  2. useImperativeHandle,
  3. type PointerEvent,
  4. forwardRef,
  5. useEffect,
  6. useState,
  7. useRef
  8. } from "react";
  9. import {
  10. type LayoutChangeEvent,
  11. useWindowDimensions,
  12. PanResponder,
  13. ScrollView,
  14. Platform,
  15. Animated,
  16. Easing,
  17. View
  18. } from "react-native";
  19. import type IBottomSheetProps from "./type";
  20. import type {
  21. IBottomSheetRef
  22. } from "./type";
  23. import stylesheet from "./stylesheet";
  24. import {
  25. NCoreUIKitTheme
  26. } from "../../core/hooks";
  27. import {
  28. useSafeAreaInsets
  29. } from "react-native-safe-area-context";
  30. import {
  31. type RefForwardingComponent,
  32. type SafePointerEvent,
  33. safeGlobal
  34. } from "../../types";
  35. import type {
  36. IModalRef
  37. } from "../modal/type";
  38. import {
  39. webStyle,
  40. uuid
  41. } from "../../utils";
  42. import Modal from "../modal";
  43. const BottomSheet: RefForwardingComponent<IBottomSheetRef, IBottomSheetProps> = ({
  44. isContentReady: isContentReadyProp = true,
  45. renderBottom: RenderBottomComponent,
  46. renderHeader: RenderHeaderComponent,
  47. isForceFullScreenOnSwipe = false,
  48. handleContainerBackgroundColor,
  49. handleHeight: handleHeightProp,
  50. isCanFullScreenOnSwipe = false,
  51. isWrapSafeAreaContext = true,
  52. backgroundColor = "default",
  53. isWorkAsFullScreen = false,
  54. scrollEndThreshold = 0.85,
  55. isCloseOnOverlay = true,
  56. isWorkWithPortal = true,
  57. handleContainerSpacing,
  58. isActive: isActiveProp,
  59. handleBackgroundColor,
  60. isAutoHeight = false,
  61. isShowHandle = true,
  62. isSwipeClose = true,
  63. isCanSwipe = true,
  64. onOverlayPressed,
  65. scrollViewProps,
  66. scrollViewStyle,
  67. customTheme,
  68. onScrollEnd,
  69. modalProps,
  70. portalName,
  71. customKey,
  72. id: outID,
  73. snapPoint,
  74. children,
  75. onClosed,
  76. onOpened,
  77. onClose,
  78. onOpen,
  79. style,
  80. ...props
  81. }, ref) => {
  82. const {
  83. radiuses,
  84. colors,
  85. spaces
  86. } = NCoreUIKitTheme.useContext(customTheme);
  87. const {
  88. bottom,
  89. top
  90. } = useSafeAreaInsets();
  91. const [
  92. isMeasured,
  93. setIsMeasured
  94. ] = useState(false);
  95. const [
  96. isActive,
  97. setIsActive
  98. ] = useState(isActiveProp === undefined ? false : isActiveProp);
  99. const {
  100. height: windowHeight,
  101. width: windowWidth
  102. } = useWindowDimensions();
  103. const id = useRef(outID ? outID : uuid());
  104. const bottomSheetKey = useRef(customKey ? customKey : uuid());
  105. let bottomSafeArea = isWrapSafeAreaContext ? bottom : 0;
  106. let topSafeArea = isWrapSafeAreaContext ? top : 0;
  107. if(isForceFullScreenOnSwipe) {
  108. topSafeArea = 0;
  109. }
  110. if(!isWorkWithPortal) {
  111. bottomSafeArea = 0;
  112. topSafeArea = 0;
  113. }
  114. const scrollViewRef = useRef<ScrollView>(null);
  115. const modalRef = useRef<IModalRef>(null);
  116. const containerHeightRef = useRef(windowHeight);
  117. const animatedTranslateY = useRef(new Animated.Value(snapPoint && !isWorkAsFullScreen ? snapPoint : containerHeightRef.current)).current;
  118. const animatedHeight = useRef(new Animated.Value(
  119. isAutoHeight ? 0 : snapPoint ?? 0
  120. )).current;
  121. const TOP_GRAB_AREA = 140;
  122. const maxHeight = useRef(isWorkAsFullScreen ? containerHeightRef.current - (isWrapSafeAreaContext ? topSafeArea : 0) : containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0));
  123. const heightValue = useRef(isWorkAsFullScreen ? containerHeightRef.current : snapPoint ?? 0);
  124. const initialTranslateY = useRef(0);
  125. const translateYValue = useRef(0);
  126. const contentHeight = useRef(-1);
  127. const initialHeight = useRef(0);
  128. const scrollViewContentHeight = useRef(-1);
  129. const scrollViewLayoutHeight = useRef(-1);
  130. const initialScrollOffset = useRef(0);
  131. const scrollOffset = useRef(0);
  132. const gestureStartY = useRef(0);
  133. const isCanFullScreenOnSwipeRef = useRef(isCanFullScreenOnSwipe);
  134. const isWorkAsFullScreenRef = useRef(isWorkAsFullScreen);
  135. const isSwipeCloseRef = useRef(isSwipeClose);
  136. const isCanSwipeRef = useRef(isCanSwipe);
  137. const isContentReadyRef = useRef(isContentReadyProp);
  138. if(!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  139. maxHeight.current = snapPoint;
  140. }
  141. useImperativeHandle(
  142. ref,
  143. () => ({
  144. close: (onClosed?) => {
  145. closeAnimation(undefined, onClosed);
  146. },
  147. setIsContentReady: (status) => {
  148. isContentReadyRef.current = status;
  149. },
  150. open: () => {
  151. setIsActive(true);
  152. }
  153. }),
  154. []
  155. );
  156. useEffect(() => {
  157. isCanSwipeRef.current = isCanSwipe;
  158. }, [
  159. isCanSwipe
  160. ]);
  161. useEffect(() => {
  162. isSwipeCloseRef.current = isSwipeClose;
  163. }, [
  164. isSwipeClose
  165. ]);
  166. useEffect(() => {
  167. isCanFullScreenOnSwipeRef.current = isCanFullScreenOnSwipe;
  168. }, [
  169. isCanFullScreenOnSwipe
  170. ]);
  171. useEffect(() => {
  172. isWorkAsFullScreenRef.current = isWorkAsFullScreen;
  173. }, [
  174. isWorkAsFullScreen
  175. ]);
  176. useEffect(() => {
  177. isContentReadyRef.current = isContentReadyProp;
  178. }, [
  179. isContentReadyProp
  180. ]);
  181. useEffect(() => {
  182. if (!isMeasured || contentHeight.current === -1) return;
  183. if (isCanFullScreenOnSwipe && !isWorkAsFullScreen) {
  184. maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : topSafeArea);
  185. } else if (isAutoHeight || !snapPoint) {
  186. maxHeight.current = contentHeight.current;
  187. }
  188. if (isActive) {
  189. open();
  190. }
  191. }, [
  192. isActive,
  193. isMeasured
  194. ]);
  195. if(isActiveProp !== undefined) {
  196. useEffect(() => {
  197. setIsActive(isActiveProp);
  198. }, [
  199. isActiveProp
  200. ]);
  201. }
  202. useEffect(() => {
  203. if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  204. maxHeight.current = snapPoint;
  205. } else if (!isWorkAsFullScreen) {
  206. maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0);
  207. } else {
  208. maxHeight.current = containerHeightRef.current - (isWrapSafeAreaContext ? topSafeArea : 0);
  209. }
  210. }, [
  211. isForceFullScreenOnSwipe,
  212. isCanFullScreenOnSwipe,
  213. isWrapSafeAreaContext,
  214. isWorkAsFullScreen,
  215. topSafeArea,
  216. snapPoint
  217. ]);
  218. useEffect(() => {
  219. if (!isActive) {
  220. const newSnapValue = isWorkAsFullScreen
  221. ? containerHeightRef.current
  222. : (snapPoint ?? contentHeight.current);
  223. animatedHeight.setValue(isAutoHeight ? 0 : newSnapValue);
  224. animatedTranslateY.setValue(newSnapValue);
  225. heightValue.current = newSnapValue;
  226. translateYValue.current = newSnapValue;
  227. initialHeight.current = newSnapValue;
  228. }
  229. if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  230. maxHeight.current = snapPoint;
  231. } else if (!isWorkAsFullScreen) {
  232. maxHeight.current = containerHeightRef.current - (
  233. isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0
  234. );
  235. } else {
  236. maxHeight.current = containerHeightRef.current - (
  237. isWrapSafeAreaContext ? topSafeArea : 0
  238. );
  239. }
  240. }, [
  241. snapPoint,
  242. isWorkAsFullScreen
  243. ]);
  244. useEffect(() => {
  245. const listenerAHeightId = animatedHeight.addListener(({
  246. value
  247. }) => {
  248. heightValue.current = value;
  249. });
  250. const listenerTYId = animatedTranslateY.addListener(({
  251. value
  252. }) => {
  253. translateYValue.current = value;
  254. });
  255. return () => {
  256. animatedHeight.removeListener(listenerAHeightId);
  257. animatedTranslateY.removeListener(listenerTYId);
  258. };
  259. }, []);
  260. const checkScrollThreshold = () => {
  261. if (!onScrollEnd) return;
  262. const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
  263. if (maxS <= 0) return;
  264. const ratio = scrollOffset.current / maxS;
  265. if (ratio >= scrollEndThreshold) {
  266. onScrollEnd();
  267. }
  268. };
  269. const openAnimation = (runIndex?: number) => {
  270. if(isContentReadyRef.current) {
  271. Animated.timing(animatedTranslateY, {
  272. useNativeDriver: false,
  273. duration: 300,
  274. toValue: 0
  275. }).start(({
  276. finished
  277. }) => {
  278. if(finished) {
  279. if(onOpened) onOpened();
  280. }
  281. });
  282. } else {
  283. if(!runIndex || runIndex < 10) {
  284. setTimeout(() => {
  285. openAnimation(runIndex ? runIndex + 1 : 1);
  286. }, 100);
  287. }
  288. }
  289. };
  290. const open = () => {
  291. if (translateYValue.current < 0) {
  292. const safeValue = snapPoint ?? contentHeight.current;
  293. animatedTranslateY.setValue(safeValue);
  294. translateYValue.current = safeValue;
  295. }
  296. resetState();
  297. if (isAutoHeight || !snapPoint) {
  298. animatedHeight.setValue(contentHeight.current);
  299. }
  300. if(onOpen) onOpen();
  301. setTimeout(() => {
  302. openAnimation();
  303. }, 100);
  304. };
  305. const closeAnimation = (toValue?: number, _onClosed?: (props: {
  306. id: string;
  307. }) => void) => {
  308. if(onClose) onClose({
  309. id: id.current
  310. });
  311. const currentSnapPoint = isWorkAsFullScreen
  312. ? containerHeightRef.current
  313. : (isAutoHeight || !snapPoint ? contentHeight.current : snapPoint);
  314. Animated.timing(animatedTranslateY, {
  315. toValue: toValue ?? currentSnapPoint,
  316. useNativeDriver: false,
  317. duration: 300
  318. }).start(({
  319. finished
  320. }) => {
  321. if(finished) {
  322. resetState();
  323. setIsActive(false);
  324. if(onClosed) onClosed({
  325. id: id.current
  326. });
  327. if(_onClosed) _onClosed({
  328. id: id.current
  329. });
  330. }
  331. });
  332. };
  333. const onLayout = (event: LayoutChangeEvent) => {
  334. const {
  335. height
  336. } = event.nativeEvent.layout;
  337. if (height === contentHeight.current) return;
  338. contentHeight.current = height;
  339. if(!isMeasured) {
  340. animatedHeight.setValue(height);
  341. setIsMeasured(true);
  342. }
  343. };
  344. const resetState = () => {
  345. animatedHeight.setOffset(0);
  346. animatedTranslateY.setOffset(0);
  347. scrollOffset.current = 0;
  348. initialScrollOffset.current = 0;
  349. const pivot = isWorkAsFullScreen
  350. ? containerHeightRef.current
  351. : (isAutoHeight ? 0 : (snapPoint ?? contentHeight.current));
  352. initialHeight.current = pivot;
  353. initialTranslateY.current = 0;
  354. heightValue.current = pivot;
  355. translateYValue.current = 0;
  356. };
  357. const panResponder = useRef(
  358. PanResponder.create({
  359. onPanResponderMove: (_, gestureState) => {
  360. if(!isCanSwipeRef.current) return;
  361. const {
  362. dy
  363. } = gestureState;
  364. const isAtTop = scrollOffset.current <= 0;
  365. const isAtTavan = heightValue.current >= maxHeight.current - 1;
  366. const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
  367. const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
  368. if (dy > 0 && isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
  369. const currentDelta = -dy;
  370. const initialS = initialScrollOffset.current;
  371. const usedForS = Math.max(currentDelta, -initialS);
  372. scrollOffset.current = initialS + usedForS;
  373. scrollViewRef.current?.scrollTo({
  374. y: scrollOffset.current,
  375. animated: false
  376. });
  377. return;
  378. }
  379. const delta = -dy;
  380. const pivot = snapPoint ?? contentHeight.current;
  381. const initialH = initialHeight.current;
  382. const initialS = initialScrollOffset.current;
  383. const initialT = initialTranslateY.current;
  384. const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
  385. if (dy < 0) {
  386. let currentDelta = delta;
  387. const effectiveInitialT = Math.max(0, initialT);
  388. const usedForT = Math.min(currentDelta, effectiveInitialT);
  389. animatedTranslateY.setValue(-usedForT);
  390. currentDelta -= usedForT;
  391. if (currentDelta > 0) {
  392. if (initialH < pivot) {
  393. const spaceToPivot = pivot - initialH;
  394. const usedForH = Math.min(currentDelta, spaceToPivot);
  395. animatedHeight.setValue(usedForH);
  396. currentDelta -= usedForH;
  397. }
  398. const isRestoringHeight = initialH < pivot;
  399. const canScroll = isCanFullScreenOnSwipeRef.current || isWorkAsFullScreenRef.current || !isRestoringHeight;
  400. if (currentDelta > 0 && canScroll) {
  401. const remainingScroll = maxS - initialS;
  402. if (remainingScroll > 0) {
  403. const usedForS = Math.min(currentDelta, remainingScroll);
  404. scrollOffset.current = initialS + usedForS;
  405. scrollViewRef.current?.scrollTo({
  406. y: scrollOffset.current,
  407. animated: false
  408. });
  409. currentDelta -= usedForS;
  410. animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
  411. }
  412. }
  413. if (currentDelta > 0) {
  414. if (isCanFullScreenOnSwipeRef.current) {
  415. const totalUsedBefore = (initialH < pivot ? (pivot - initialH) : 0);
  416. animatedHeight.setValue(totalUsedBefore + currentDelta);
  417. } else {
  418. animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
  419. }
  420. }
  421. }
  422. } else {
  423. let currentDelta = delta;
  424. if (initialH > pivot || initialS > 0) {
  425. if (initialS > 0) {
  426. const usedForS = Math.max(currentDelta, -initialS);
  427. scrollOffset.current = initialS + usedForS;
  428. scrollViewRef.current?.scrollTo({
  429. y: scrollOffset.current,
  430. animated: false
  431. });
  432. currentDelta -= usedForS;
  433. animatedHeight.setValue(initialH > pivot ? 0 : pivot - initialH);
  434. }
  435. if (currentDelta < 0 && initialH > pivot) {
  436. const distanceToPivot = pivot - initialH;
  437. const usedForH = Math.max(currentDelta, distanceToPivot);
  438. animatedHeight.setValue(usedForH);
  439. currentDelta -= usedForH;
  440. }
  441. }
  442. if (currentDelta < 0) {
  443. animatedHeight.setValue(initialH > pivot ? (pivot - initialH) : 0);
  444. animatedTranslateY.setValue(-currentDelta);
  445. scrollOffset.current = 0;
  446. scrollViewRef.current?.scrollTo({
  447. animated: false,
  448. y: 0
  449. });
  450. }
  451. }
  452. },
  453. onPanResponderEnd: (_, gestureState) => {
  454. if(!isCanSwipeRef.current) return;
  455. const isAtTop = scrollOffset.current <= 1;
  456. const isAtTavan = heightValue.current >= maxHeight.current - 1;
  457. const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
  458. const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
  459. if (isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
  460. const velocity = gestureState.vy;
  461. if (Math.abs(velocity) > 0.1) {
  462. const momentum = velocity * -350;
  463. const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
  464. const targetOffset = Math.max(0, Math.min(scrollOffset.current + momentum, maxS));
  465. const scrollAnim = new Animated.Value(scrollOffset.current);
  466. scrollAnim.addListener(({
  467. value
  468. }) => {
  469. scrollViewRef.current?.scrollTo({
  470. animated: false,
  471. y: value
  472. });
  473. scrollOffset.current = value;
  474. });
  475. Animated.timing(scrollAnim, {
  476. duration: Math.min(Math.abs(momentum) * 2, 600),
  477. easing: Easing.out(Easing.quad),
  478. useNativeDriver: false,
  479. toValue: targetOffset
  480. }).start(() => {
  481. scrollAnim.removeAllListeners();
  482. checkScrollThreshold();
  483. });
  484. } else {
  485. checkScrollThreshold();
  486. }
  487. return;
  488. }
  489. const currentH = (animatedHeight as Animated.Value & {
  490. __getValue(): number;
  491. }).__getValue();
  492. const currentT = (animatedTranslateY as Animated.Value & {
  493. __getValue(): number;
  494. }).__getValue();
  495. animatedHeight.flattenOffset();
  496. animatedTranslateY.flattenOffset();
  497. heightValue.current = currentH;
  498. translateYValue.current = currentT;
  499. const pivot = snapPoint ?? contentHeight.current;
  500. const tavan = maxHeight.current;
  501. const isFastSwipeDown = gestureState.vy > 0.5;
  502. const isFastSwipeUp = gestureState.vy < -0.5;
  503. if (currentT <= Math.max(pivot * 0.2, 60)) {
  504. let toValue = pivot;
  505. if (isFastSwipeUp && isCanFullScreenOnSwipeRef.current) {
  506. toValue = tavan;
  507. } else if (isFastSwipeDown) {
  508. toValue = pivot;
  509. } else {
  510. if (!isCanFullScreenOnSwipeRef.current) {
  511. toValue = pivot;
  512. } else {
  513. const totalRange = tavan - pivot;
  514. const distFromPivot = currentH - pivot;
  515. if (distFromPivot < totalRange * 0.33) {
  516. toValue = pivot;
  517. } else if (distFromPivot > totalRange * 0.66) {
  518. toValue = tavan;
  519. } else {
  520. toValue = (tavan - currentH) < (currentH - pivot) ? tavan : pivot;
  521. }
  522. }
  523. }
  524. Animated.spring(animatedHeight, {
  525. useNativeDriver: false,
  526. toValue: toValue,
  527. friction: 10,
  528. tension: 40
  529. }).start();
  530. Animated.spring(animatedTranslateY, {
  531. useNativeDriver: false,
  532. friction: 10,
  533. tension: 40,
  534. toValue: 0
  535. }).start();
  536. } else {
  537. let toValueT = 0;
  538. let isClosing = false;
  539. if (!isSwipeCloseRef.current) {
  540. Animated.spring(animatedHeight, {
  541. useNativeDriver: false,
  542. toValue: pivot,
  543. friction: 10,
  544. tension: 40
  545. }).start();
  546. Animated.timing(animatedTranslateY, {
  547. useNativeDriver: false,
  548. duration: 300,
  549. toValue: 0
  550. }).start();
  551. } else {
  552. if (isFastSwipeDown) {
  553. toValueT = pivot + 100;
  554. isClosing = true;
  555. } else {
  556. if (currentT > pivot * 0.5) {
  557. toValueT = pivot + 100;
  558. isClosing = true;
  559. } else {
  560. toValueT = 0;
  561. isClosing = false;
  562. }
  563. }
  564. if(onClose) onClose({
  565. id: id.current
  566. });
  567. Animated.timing(animatedTranslateY, {
  568. useNativeDriver: false,
  569. toValue: toValueT,
  570. duration: 300
  571. }).start(({
  572. finished
  573. }) => {
  574. if (finished && isClosing) {
  575. setIsActive(false);
  576. if (onClosed) onClosed({
  577. id: id.current
  578. });
  579. }
  580. });
  581. }
  582. }
  583. },
  584. onMoveShouldSetPanResponderCapture: (_, gestureState) => {
  585. const {
  586. dy
  587. } = gestureState;
  588. if(!isCanSwipeRef.current) {
  589. return false;
  590. }
  591. if(Math.abs(dy) < 20) {
  592. return false;
  593. }
  594. return true;
  595. },
  596. onPanResponderGrant: (evt) => {
  597. console.log("GRANT", evt.nativeEvent);
  598. if(!isCanSwipeRef.current) return;
  599. gestureStartY.current = evt.nativeEvent.pageY;
  600. animatedTranslateY.stopAnimation((currentY) => {
  601. translateYValue.current = currentY;
  602. });
  603. animatedHeight.stopAnimation((currentH) => {
  604. heightValue.current = currentH;
  605. });
  606. animatedTranslateY.flattenOffset();
  607. animatedHeight.flattenOffset();
  608. initialTranslateY.current = translateYValue.current;
  609. initialScrollOffset.current = scrollOffset.current;
  610. initialHeight.current = heightValue.current;
  611. animatedHeight.setOffset(heightValue.current);
  612. animatedHeight.setValue(0);
  613. animatedTranslateY.setOffset(translateYValue.current);
  614. animatedTranslateY.setValue(0);
  615. },
  616. onPanResponderTerminationRequest: () => false,
  617. onStartShouldSetPanResponder: () => false,
  618. onShouldBlockNativeResponder: () => true
  619. })
  620. ).current;
  621. const renderView = () => {
  622. return <Animated.View
  623. {...props}
  624. {...(Platform.OS === "web" ? {} : panResponder.panHandlers)}
  625. onPointerDown={Platform.OS === "web" ? (evt) => {
  626. const nativeEvt = evt as unknown as PointerEvent;
  627. const startY = nativeEvt.clientY;
  628. gestureStartY.current = nativeEvt.clientY;
  629. const initialT = translateYValue.current;
  630. const initialH = heightValue.current;
  631. const initialS = scrollOffset.current;
  632. animatedTranslateY.setOffset(initialT);
  633. animatedTranslateY.setValue(0);
  634. animatedHeight.setOffset(initialH);
  635. animatedHeight.setValue(0);
  636. let lastY = startY;
  637. let lastTime = Date.now();
  638. let velocityY = 0;
  639. const handlePointerMove = (moveEvt: SafePointerEvent) => {
  640. if (!isCanSwipeRef.current) return;
  641. const dy = moveEvt.clientY - startY;
  642. if (Math.abs(dy) < 5) return;
  643. const now = Date.now();
  644. const dt = now - lastTime;
  645. if (dt > 0) {
  646. velocityY = (moveEvt.clientY - lastY) / dt;
  647. }
  648. lastY = moveEvt.clientY;
  649. lastTime = now;
  650. const delta = -dy;
  651. const pivot = snapPoint ?? contentHeight.current;
  652. const isAtTavan = heightValue.current >= maxHeight.current - 1;
  653. const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
  654. const isAtTop = scrollOffset.current <= 0;
  655. const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
  656. const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
  657. if (dy > 0 && isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
  658. const usedForS = Math.max(-dy, -initialS);
  659. scrollOffset.current = initialS + usedForS;
  660. scrollViewRef.current?.scrollTo({
  661. y: scrollOffset.current,
  662. animated: false
  663. });
  664. return;
  665. }
  666. if (dy < 0) {
  667. let currentDelta = delta;
  668. const effectiveInitialT = Math.max(0, initialT);
  669. const usedForT = Math.min(currentDelta, effectiveInitialT);
  670. animatedTranslateY.setValue(-usedForT);
  671. currentDelta -= usedForT;
  672. if (currentDelta > 0) {
  673. if (initialH < pivot) {
  674. const spaceToPivot = pivot - initialH;
  675. const usedForH = Math.min(currentDelta, spaceToPivot);
  676. animatedHeight.setValue(usedForH);
  677. currentDelta -= usedForH;
  678. }
  679. const isRestoringHeight = initialH < pivot;
  680. const canScroll = isCanFullScreenOnSwipeRef.current || isWorkAsFullScreenRef.current || !isRestoringHeight;
  681. if (currentDelta > 0 && canScroll) {
  682. const remainingScroll = maxS - initialS;
  683. if (remainingScroll > 0) {
  684. const usedForS = Math.min(currentDelta, remainingScroll);
  685. scrollOffset.current = initialS + usedForS;
  686. scrollViewRef.current?.scrollTo({
  687. y: scrollOffset.current,
  688. animated: false
  689. });
  690. currentDelta -= usedForS;
  691. animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
  692. }
  693. }
  694. if (currentDelta > 0) {
  695. if (isCanFullScreenOnSwipeRef.current) {
  696. const totalUsedBefore = initialH < pivot ? (pivot - initialH) : 0;
  697. animatedHeight.setValue(totalUsedBefore + currentDelta);
  698. } else {
  699. animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
  700. }
  701. }
  702. }
  703. } else {
  704. let currentDelta = delta;
  705. if (initialH > pivot || initialS > 0) {
  706. if (initialS > 0) {
  707. const usedForS = Math.max(currentDelta, -initialS);
  708. scrollOffset.current = initialS + usedForS;
  709. scrollViewRef.current?.scrollTo({
  710. y: scrollOffset.current,
  711. animated: false
  712. });
  713. currentDelta -= usedForS;
  714. animatedHeight.setValue(initialH > pivot ? 0 : pivot - initialH);
  715. }
  716. if (currentDelta < 0 && initialH > pivot) {
  717. const distanceToPivot = pivot - initialH;
  718. const usedForH = Math.max(currentDelta, distanceToPivot);
  719. animatedHeight.setValue(usedForH);
  720. currentDelta -= usedForH;
  721. }
  722. }
  723. if (currentDelta < 0) {
  724. animatedHeight.setValue(initialH > pivot ? (pivot - initialH) : 0);
  725. animatedTranslateY.setValue(-currentDelta);
  726. scrollOffset.current = 0;
  727. scrollViewRef.current?.scrollTo({
  728. animated: false,
  729. y: 0
  730. });
  731. }
  732. }
  733. };
  734. const handlePointerUp = () => {
  735. safeGlobal.removeEventListener("pointermove", handlePointerMove);
  736. safeGlobal.removeEventListener("pointerup", handlePointerUp);
  737. animatedHeight.flattenOffset();
  738. animatedTranslateY.flattenOffset();
  739. const currentT = translateYValue.current;
  740. const currentH = heightValue.current;
  741. const pivot = snapPoint ?? contentHeight.current;
  742. const tavan = maxHeight.current;
  743. const isAtTavan = currentH >= tavan - 1;
  744. const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
  745. const isAtTop = scrollOffset.current <= 1;
  746. const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
  747. if (isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
  748. if (Math.abs(velocityY) > 0.1) {
  749. const momentum = velocityY * -350;
  750. const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
  751. const targetOffset = Math.max(0, Math.min(scrollOffset.current + momentum, maxS));
  752. const scrollAnim = new Animated.Value(scrollOffset.current);
  753. scrollAnim.addListener(({
  754. value
  755. }) => {
  756. scrollViewRef.current?.scrollTo({
  757. animated: false,
  758. y: value
  759. });
  760. scrollOffset.current = value;
  761. });
  762. Animated.timing(scrollAnim, {
  763. duration: Math.min(Math.abs(momentum) * 2, 600),
  764. easing: Easing.out(Easing.quad),
  765. toValue: targetOffset,
  766. useNativeDriver: false
  767. }).start(() => {
  768. scrollAnim.removeAllListeners();
  769. checkScrollThreshold();
  770. });
  771. } else {
  772. checkScrollThreshold();
  773. }
  774. return;
  775. }
  776. const isFastSwipeDown = velocityY > 0.5;
  777. const isFastSwipeUp = velocityY < -0.5;
  778. if (currentT <= Math.max(pivot * 0.2, 60)) {
  779. let toValue = pivot;
  780. if (isFastSwipeUp && isCanFullScreenOnSwipeRef.current) {
  781. toValue = tavan;
  782. } else if (isFastSwipeDown) {
  783. toValue = pivot;
  784. } else {
  785. if (!isCanFullScreenOnSwipeRef.current) {
  786. toValue = pivot;
  787. } else {
  788. const totalRange = tavan - pivot;
  789. const distFromPivot = currentH - pivot;
  790. if (distFromPivot < totalRange * 0.33) toValue = pivot;
  791. else if (distFromPivot > totalRange * 0.66) toValue = tavan;
  792. else toValue = (tavan - currentH) < (currentH - pivot) ? tavan : pivot;
  793. }
  794. }
  795. Animated.spring(animatedHeight, {
  796. useNativeDriver: false,
  797. friction: 10,
  798. tension: 40,
  799. toValue
  800. }).start();
  801. Animated.spring(animatedTranslateY, {
  802. useNativeDriver: false,
  803. friction: 10,
  804. tension: 40,
  805. toValue: 0
  806. }).start();
  807. } else {
  808. if (!isSwipeCloseRef.current) {
  809. Animated.spring(animatedHeight, {
  810. useNativeDriver: false,
  811. toValue: pivot,
  812. friction: 10,
  813. tension: 40
  814. }).start();
  815. Animated.timing(animatedTranslateY, {
  816. useNativeDriver: false,
  817. duration: 300,
  818. toValue: 0
  819. }).start();
  820. } else {
  821. let toValueT = 0;
  822. let isClosing = false;
  823. if (isFastSwipeDown) {
  824. toValueT = pivot + 100;
  825. isClosing = true;
  826. } else {
  827. if (currentT > pivot * 0.5) {
  828. toValueT = pivot + 100;
  829. isClosing = true;
  830. } else {
  831. toValueT = 0;
  832. isClosing = false;
  833. }
  834. }
  835. if (onClose) onClose({
  836. id: id.current
  837. });
  838. Animated.timing(animatedTranslateY, {
  839. useNativeDriver: false,
  840. toValue: toValueT,
  841. duration: 300
  842. }).start(({
  843. finished
  844. }) => {
  845. if (finished && isClosing) {
  846. setIsActive(false);
  847. if (onClosed) onClosed({
  848. id: id.current
  849. });
  850. }
  851. });
  852. }
  853. }
  854. };
  855. safeGlobal.addEventListener("pointermove", handlePointerMove);
  856. safeGlobal.addEventListener("pointerup", handlePointerUp);
  857. } : undefined}
  858. style={[
  859. {
  860. borderTopStartRadius: radiuses.lg,
  861. borderTopEndRadius: radiuses.lg
  862. },
  863. style,
  864. {
  865. height: (isAutoHeight || !snapPoint) && !isMeasured ? "auto" : animatedHeight,
  866. backgroundColor: colors.content.container[backgroundColor],
  867. paddingBottom: bottomSafeArea + spaces.spacingMd,
  868. opacity: isMeasured || !isAutoHeight ? 1 : 0,
  869. transform: [
  870. {
  871. translateY: animatedTranslateY
  872. }
  873. ],
  874. paddingRight: spaces.spacingMd,
  875. paddingLeft: spaces.spacingMd,
  876. paddingTop: spaces.spacingMd,
  877. maxHeight: maxHeight.current
  878. },
  879. stylesheet.container,
  880. webStyle({
  881. touchAction: "none",
  882. userSelect: "none",
  883. cursor: "grab"
  884. })
  885. ]}
  886. onLayout={onLayout}
  887. >
  888. {renderHeader()}
  889. <ScrollView
  890. {...scrollViewProps}
  891. onLayout={(e) => {
  892. scrollViewLayoutHeight.current = e.nativeEvent.layout.height;
  893. }}
  894. style={[
  895. scrollViewStyle,
  896. webStyle({
  897. maxWidth: windowWidth > 500 ? 500 : undefined,
  898. width: windowWidth > 500 ? 500 : "100%",
  899. alignSelf: "center"
  900. })
  901. ]}
  902. onStartShouldSetResponderCapture={() => false}
  903. onMoveShouldSetResponderCapture={() => false}
  904. onContentSizeChange={(_, h) => {
  905. scrollViewContentHeight.current = h;
  906. }}
  907. showsHorizontalScrollIndicator={false}
  908. showsVerticalScrollIndicator={false}
  909. scrollEnabled={!isCanSwipe}
  910. scrollEventThrottle={1}
  911. overScrollMode="never"
  912. ref={scrollViewRef}
  913. bounces={false}
  914. >
  915. {children}
  916. </ScrollView>
  917. {renderBottom()}
  918. {renderHandle()}
  919. </Animated.View>;
  920. };
  921. const renderHeader = () => {
  922. if(!RenderHeaderComponent) {
  923. return null;
  924. }
  925. if(Platform.OS === "web") {
  926. return <View
  927. style={[
  928. webStyle({
  929. maxWidth: windowWidth > 500 ? 500 : undefined,
  930. width: windowWidth > 500 ? 500 : "100%",
  931. alignSelf: "center"
  932. })
  933. ]}
  934. >
  935. {RenderHeaderComponent()}
  936. </View>;
  937. }
  938. return RenderHeaderComponent();
  939. };
  940. const renderBottom = () => {
  941. if(!RenderBottomComponent) {
  942. return null;
  943. }
  944. if(Platform.OS === "web") {
  945. return <View
  946. style={[
  947. webStyle({
  948. maxWidth: windowWidth > 500 ? 500 : undefined,
  949. width: windowWidth > 500 ? 500 : "100%",
  950. alignSelf: "center"
  951. })
  952. ]}
  953. >
  954. <RenderBottomComponent/>
  955. </View>;
  956. }
  957. return <RenderBottomComponent/>;
  958. };
  959. const renderHandle = () => {
  960. if(!isShowHandle || isWorkAsFullScreen) {
  961. return null;
  962. }
  963. const handleHeight = handleHeightProp ? handleHeightProp : 8;
  964. const handleContainerHeight = handleHeight + ((handleContainerSpacing ? spaces[handleContainerSpacing] : spaces.spacingSm) * 2);
  965. const handleBorderRadius = Math.ceil(handleHeight / 2);
  966. return <View
  967. style={[
  968. stylesheet.handleContainer,
  969. {
  970. transform: [
  971. {
  972. translateY: -handleContainerHeight * (Platform.OS === "web" ? 1.75 : 1)
  973. }
  974. ],
  975. backgroundColor: handleContainerBackgroundColor
  976. ? colors.content.container[handleContainerBackgroundColor]
  977. : "transparent",
  978. height: handleContainerHeight
  979. },
  980. webStyle({
  981. touchAction: "none !important",
  982. userSelect: "none",
  983. cursor: "grab"
  984. })
  985. ]}
  986. onStartShouldSetResponderCapture={() => Platform.OS === "web"}
  987. onMoveShouldSetResponderCapture={() => Platform.OS === "web"}
  988. >
  989. <View
  990. style={[
  991. stylesheet.handle,
  992. {
  993. backgroundColor: handleBackgroundColor
  994. ? colors.content.container[handleBackgroundColor]
  995. : colors.content.container.default,
  996. borderRadius: handleBorderRadius,
  997. width: windowWidth / 10,
  998. height: handleHeight
  999. }
  1000. ]}
  1001. />
  1002. </View>;
  1003. };
  1004. return <Modal
  1005. {...modalProps}
  1006. onContainerLayout={(e) => {
  1007. const containerLayoutHeight = e.nativeEvent.layout.height;
  1008. if (containerLayoutHeight && containerLayoutHeight !== containerHeightRef.current) {
  1009. containerHeightRef.current = containerLayoutHeight;
  1010. if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
  1011. maxHeight.current = snapPoint;
  1012. } else if (!isWorkAsFullScreen) {
  1013. maxHeight.current = containerLayoutHeight - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0);
  1014. } else {
  1015. maxHeight.current = containerLayoutHeight;
  1016. }
  1017. }
  1018. }}
  1019. portalName={portalName ? portalName : "bottomSheet-system"}
  1020. overlayProps={{
  1021. onStartShouldSetResponderCapture: () => false,
  1022. onMoveShouldSetResponderCapture: () => false
  1023. }}
  1024. onOverlayPress={() => {
  1025. if(onOverlayPressed) onOverlayPressed();
  1026. if(isCloseOnOverlay) {
  1027. closeAnimation();
  1028. }
  1029. }}
  1030. isWorkWithPortal={isWorkWithPortal}
  1031. key={`${bottomSheetKey}-modal`}
  1032. style={[
  1033. {
  1034. paddingTop: topSafeArea
  1035. }
  1036. ]}
  1037. isContentRequired={false}
  1038. isActive={isActive}
  1039. alignContent="free"
  1040. isAnimated={false}
  1041. id={id.current}
  1042. ref={modalRef}
  1043. >
  1044. {renderView()}
  1045. </Modal>;
  1046. };
  1047. export default forwardRef(BottomSheet);