index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import {
  2. type PointerEvent,
  3. useEffect,
  4. useState,
  5. type FC,
  6. useRef
  7. } from "react";
  8. import {
  9. TouchableWithoutFeedback,
  10. useWindowDimensions,
  11. PanResponder,
  12. Platform,
  13. Animated,
  14. Easing,
  15. View
  16. } from "react-native";
  17. import type ISnackBarProps from "./type";
  18. import stylesheet, {
  19. getSnackBarType,
  20. useStyles
  21. } from "./stylesheet";
  22. import {
  23. NCoreUIKitTheme
  24. } from "../../core/hooks";
  25. import {
  26. useSafeAreaInsets
  27. } from "react-native-safe-area-context";
  28. import {
  29. type SafePointerEvent,
  30. safeGlobal
  31. } from "../../types";
  32. import {
  33. X as XIcon
  34. } from "lucide-react-native";
  35. import {
  36. Portal
  37. } from "../../helpers/portalize";
  38. import {
  39. windowHeight
  40. } from "../../utils";
  41. import Button from "../button";
  42. import Text from "../text";
  43. const SnackBar: FC<ISnackBarProps> = ({
  44. isCloseOnPressActionButton = true,
  45. closeAnimationDelay = 350,
  46. openAnimationDelay = 200,
  47. spreadBehaviour = "free",
  48. autoCloseDelay = 5000,
  49. contentContainerStyle,
  50. isCloseOnPress = true,
  51. isFullWidth = false,
  52. isShowAction = true,
  53. icon: CustomIcon,
  54. isInlineSafeArea,
  55. type = "neutral",
  56. customTheme,
  57. children,
  58. onClosed,
  59. subTitle,
  60. onPress,
  61. action,
  62. style,
  63. title,
  64. id
  65. }) => {
  66. const {
  67. radiuses,
  68. colors,
  69. spaces
  70. } = NCoreUIKitTheme.useContext(customTheme);
  71. const {
  72. top
  73. } = useSafeAreaInsets();
  74. const [
  75. isMeasured,
  76. setIsMeasured
  77. ] = useState(false);
  78. const {
  79. width: windowWidth
  80. } = useWindowDimensions();
  81. const contentHeight = useRef<number>(windowHeight);
  82. const transformAnim = useRef(new Animated.Value(-contentHeight.current + -top + -spaces.spacingSm)).current;
  83. const opacityAnim = useRef(new Animated.Value(0)).current;
  84. const currentType = getSnackBarType({
  85. type
  86. });
  87. const {
  88. contentContainer: contentContainerDynamicStyle,
  89. containerObject: containerObjectDynamicStyle,
  90. iconContainer: iconContainerDynamicStyle,
  91. container: containerDynamicStyle,
  92. subTitle: subTitleDynamicStyle,
  93. action: actionDynamicStyle,
  94. title: titleDynamicStyle
  95. } = useStyles({
  96. isInlineSafeArea,
  97. safeAreaTop: top,
  98. spreadBehaviour,
  99. currentType,
  100. isFullWidth,
  101. radiuses,
  102. colors,
  103. spaces,
  104. type
  105. });
  106. useEffect(() => {
  107. if(isMeasured) {
  108. transformAnim.setValue(-contentHeight.current + -top + -spaces.spacingSm);
  109. Animated.parallel([
  110. Animated.timing(opacityAnim, {
  111. duration: openAnimationDelay,
  112. easing: Easing.linear,
  113. useNativeDriver: true,
  114. toValue: 1
  115. }),
  116. Animated.timing(transformAnim, {
  117. duration: openAnimationDelay,
  118. easing: Easing.linear,
  119. useNativeDriver: true,
  120. toValue: 0
  121. })
  122. ]).start();
  123. setTimeout(() => {
  124. closeAnimation();
  125. }, autoCloseDelay);
  126. }
  127. }, [
  128. isMeasured
  129. ]);
  130. const panResponder = useRef(
  131. PanResponder.create({
  132. onMoveShouldSetPanResponderCapture: (_, gestureState) => {
  133. const {
  134. dy
  135. } = gestureState;
  136. if(Math.abs(dy) < 20) {
  137. return false;
  138. }
  139. return true;
  140. },
  141. onPanResponderTerminationRequest: () => false,
  142. onPanResponderMove: (_, gestureState) => {
  143. const {
  144. moveY,
  145. y0,
  146. dy
  147. } = gestureState;
  148. if(moveY > y0) {
  149. return;
  150. }
  151. const op = moveY / y0;
  152. opacityAnim.setValue(op);
  153. transformAnim.setValue(dy);
  154. },
  155. onStartShouldSetPanResponder: () => false,
  156. onPanResponderEnd: (_, gestureState) => {
  157. const {
  158. moveY,
  159. y0,
  160. vy
  161. } = gestureState;
  162. if(vy < -1) {
  163. closeAnimation();
  164. return;
  165. }
  166. if(y0 - moveY > 25) {
  167. closeAnimation();
  168. return;
  169. }
  170. Animated.parallel([
  171. Animated.timing(opacityAnim, {
  172. duration: openAnimationDelay,
  173. easing: Easing.linear,
  174. useNativeDriver: true,
  175. toValue: 1
  176. }),
  177. Animated.timing(transformAnim, {
  178. duration: openAnimationDelay,
  179. easing: Easing.linear,
  180. useNativeDriver: true,
  181. toValue: 0
  182. })
  183. ]).start();
  184. },
  185. onShouldBlockNativeResponder: () => true
  186. })
  187. ).current;
  188. const closeAnimation = (_onClosed?: (props: {
  189. id: string;
  190. }) => void) => {
  191. Animated.parallel([
  192. Animated.timing(transformAnim, {
  193. toValue: -contentHeight.current + -top + -spaces.spacingSm,
  194. duration: closeAnimationDelay,
  195. useNativeDriver: true,
  196. easing: Easing.linear
  197. }),
  198. Animated.timing(opacityAnim, {
  199. duration: closeAnimationDelay,
  200. easing: Easing.linear,
  201. useNativeDriver: true,
  202. toValue: 0
  203. })
  204. ]).start(({
  205. finished
  206. }) => {
  207. if(finished) {
  208. if(onClosed) onClosed({
  209. id
  210. });
  211. if(_onClosed) _onClosed({
  212. id
  213. });
  214. }
  215. });
  216. };
  217. const renderIcon = () => {
  218. if(!CustomIcon) {
  219. return null;
  220. }
  221. return <View
  222. style={[
  223. stylesheet.iconContainer,
  224. iconContainerDynamicStyle
  225. ]}
  226. >
  227. <CustomIcon
  228. color="default"
  229. size={18}
  230. />
  231. </View>;
  232. };
  233. const renderContent = () => {
  234. return <View
  235. style={[
  236. contentContainerStyle,
  237. stylesheet.contentContainer,
  238. contentContainerDynamicStyle
  239. ]}
  240. >
  241. <Text
  242. style={{
  243. ...stylesheet.title,
  244. ...titleDynamicStyle
  245. }}
  246. numberOfLines={3}
  247. >
  248. {title}
  249. </Text>
  250. {
  251. subTitle ?
  252. <Text
  253. style={{
  254. ...stylesheet.subTitle,
  255. ...subTitleDynamicStyle
  256. }}
  257. variant="labelSmallSize"
  258. numberOfLines={2}
  259. color="low"
  260. >
  261. {subTitle}
  262. </Text>
  263. :
  264. null
  265. }
  266. </View>;
  267. };
  268. const renderAction = () => {
  269. if(!isShowAction) {
  270. return null;
  271. }
  272. return <Button
  273. icon={({
  274. color
  275. }) => {
  276. if(action?.title) {
  277. return null;
  278. }
  279. return <XIcon
  280. color={colors.content.icon[color ? color : "default"]}
  281. size={18}
  282. />;
  283. }}
  284. title={action && action.title ? action.title : undefined}
  285. onPress={() => {
  286. if(isCloseOnPressActionButton) closeAnimation();
  287. if(action && action.onPress) action.onPress({
  288. closeAnimation: ({
  289. onClosed: _onClosed
  290. }) => closeAnimation(_onClosed)
  291. });
  292. }}
  293. style={{
  294. ...action?.style,
  295. ...actionDynamicStyle
  296. }}
  297. isCustomPadding={true}
  298. spreadBehaviour="free"
  299. variant="ghost"
  300. type="neutral"
  301. size="small"
  302. />;
  303. };
  304. const renderContainer = () => {
  305. return <View
  306. style={[
  307. stylesheet.containerObject,
  308. containerObjectDynamicStyle
  309. ]}
  310. >
  311. {renderIcon()}
  312. {renderContent()}
  313. {renderAction()}
  314. </View>;
  315. };
  316. return <Portal
  317. name="snack-bar-system"
  318. >
  319. <Animated.View
  320. {...(Platform.OS === "web" ? {} : panResponder.panHandlers)}
  321. style={[
  322. style,
  323. stylesheet.container,
  324. containerDynamicStyle,
  325. {
  326. width: isFullWidth ? windowWidth : (spreadBehaviour === "stretch" ? windowWidth - (spaces.spacingMd * 2) : undefined),
  327. transform: [
  328. {
  329. translateY: transformAnim
  330. }
  331. ],
  332. opacity: opacityAnim
  333. }
  334. ]}
  335. onPointerDown={Platform.OS === "web" ? (evt) => {
  336. const nativeEvt = evt as unknown as PointerEvent;
  337. const startY = nativeEvt.clientY;
  338. let lastY = startY;
  339. let lastTime = Date.now();
  340. let velocityY = 0;
  341. const handlePointerMove = (moveEvt: SafePointerEvent) => {
  342. const dy = moveEvt.clientY - startY;
  343. if (dy > 0) return; // sadece yukarı swipe
  344. const now = Date.now();
  345. const dt = now - lastTime;
  346. if (dt > 0) velocityY = (moveEvt.clientY - lastY) / dt;
  347. lastY = moveEvt.clientY;
  348. lastTime = now;
  349. const op = Math.max(0, 1 + (dy / contentHeight.current));
  350. opacityAnim.setValue(op);
  351. transformAnim.setValue(dy);
  352. };
  353. const handlePointerUp = () => {
  354. safeGlobal.removeEventListener("pointermove", handlePointerMove);
  355. safeGlobal.removeEventListener("pointerup", handlePointerUp);
  356. const dy = lastY - startY;
  357. if (velocityY < -0.5 || dy < -25) {
  358. closeAnimation();
  359. return;
  360. }
  361. Animated.parallel([
  362. Animated.timing(opacityAnim, {
  363. duration: openAnimationDelay,
  364. easing: Easing.linear,
  365. useNativeDriver: true,
  366. toValue: 1
  367. }),
  368. Animated.timing(transformAnim, {
  369. duration: openAnimationDelay,
  370. easing: Easing.linear,
  371. useNativeDriver: true,
  372. toValue: 0
  373. })
  374. ]).start();
  375. };
  376. safeGlobal.addEventListener("pointermove", handlePointerMove);
  377. safeGlobal.addEventListener("pointerup", handlePointerUp);
  378. } : undefined}
  379. onLayout={(event) => {
  380. const _contentHeight = event.nativeEvent.layout.height;
  381. contentHeight.current = _contentHeight;
  382. setIsMeasured(true);
  383. }}
  384. >
  385. <TouchableWithoutFeedback
  386. onPress={() => {
  387. if(onPress) onPress({
  388. id
  389. });
  390. if(isCloseOnPress) {
  391. closeAnimation();
  392. }
  393. }}
  394. >
  395. {children ? children : renderContainer()}
  396. </TouchableWithoutFeedback>
  397. </Animated.View>
  398. </Portal>;
  399. };
  400. export default SnackBar;