diff --git a/example/storybook-nativewind/babel.config.js b/example/storybook-nativewind/babel.config.js index a55fcbf0f..59f28c28d 100644 --- a/example/storybook-nativewind/babel.config.js +++ b/example/storybook-nativewind/babel.config.js @@ -43,6 +43,10 @@ module.exports = function (api) { __dirname, '../../packages/unstyled/pin-input/src' ), + '@gluestack-ui/image-viewer': path.resolve( + __dirname, + '../../packages/unstyled/image-viewer/src' + ), '@gluestack-ui/tooltip': path.resolve( __dirname, '../../packages/unstyled/tooltip/src' diff --git a/example/storybook-nativewind/package.json b/example/storybook-nativewind/package.json index 1e208a5ff..3ec270e9e 100644 --- a/example/storybook-nativewind/package.json +++ b/example/storybook-nativewind/package.json @@ -52,8 +52,8 @@ "@react-native-community/slider": "4.2.4", "@react-stately/collections": "^3.6.0", "@react-stately/tree": "^3.5.0", - "@unitools/link": "^0.0.4", "@unitools/image": "^0.0.5", + "@unitools/link": "^0.0.4", "expo": "^47.0.0", "expo-linear-gradient": "^12.3.0", "expo-status-bar": "~1.4.2", diff --git a/example/storybook-nativewind/src/components/ImageViewer/ImageViewer.stories.tsx b/example/storybook-nativewind/src/components/ImageViewer/ImageViewer.stories.tsx new file mode 100644 index 000000000..2cba64f3d --- /dev/null +++ b/example/storybook-nativewind/src/components/ImageViewer/ImageViewer.stories.tsx @@ -0,0 +1,20 @@ +import type { ComponentMeta } from '@storybook/react-native'; +import ImageViewer from './ImageViewer'; + +const ImageViewerMeta: ComponentMeta = { + title: 'stories/ImageViewer', + component: ImageViewer, + // metaInfo is required for figma generation + // @ts-ignore + metaInfo: { + componentDescription: `The ImageViewer component provides a modal view for displaying and interacting with images, supporting features like pinch-to-zoom, double-tap zoom, and swipe up/down to dismiss.`, + }, + argTypes: {}, + args: { + images: [{ id: 1, url: 'https://picsum.photos/1000/1000' }], + }, +}; + +export default ImageViewerMeta; + +export { ImageViewer }; diff --git a/example/storybook-nativewind/src/components/ImageViewer/ImageViewer.tsx b/example/storybook-nativewind/src/components/ImageViewer/ImageViewer.tsx new file mode 100644 index 000000000..47a860790 --- /dev/null +++ b/example/storybook-nativewind/src/components/ImageViewer/ImageViewer.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Image, Pressable } from 'react-native'; +import { + ImageViewer, + ImageViewerBackdrop, + ImageViewerCloseButton, + ImageViewerContent, + ImageViewerImage, +} from '@/components/ui/image-viewer'; +import { Icon, CloseIcon } from '@/components/ui/icon'; + +const ImageViewerBasic = ({ ...props }: any) => { + const Images = [{ id: 1, url: 'https://picsum.photos/1000/1000' }]; + const [visible, setVisible] = useState(false); + return ( + <> + setVisible(true)}> + + + + setVisible(false)} + {...props} + > + + ( + + )} + > + + + + + + + + ); +}; + +ImageViewerBasic.description = 'This is a basic ImageViewer component example.'; + +export default ImageViewerBasic; + +export { ImageViewer }; diff --git a/example/storybook-nativewind/src/components/ImageViewer/index.nw.stories.mdx b/example/storybook-nativewind/src/components/ImageViewer/index.nw.stories.mdx new file mode 100644 index 000000000..0a195865c --- /dev/null +++ b/example/storybook-nativewind/src/components/ImageViewer/index.nw.stories.mdx @@ -0,0 +1,330 @@ +--- +title: ImageViewer | gluestack-ui | Installation, Usage, and API + +description: The ImageViewer component provides a modal view for displaying and interacting with images, supporting features like pinch-to-zoom, double-tap zoom, and swipe up/down to dismiss. + +pageTitle: ImageViewer + +pageDescription: The ImageViewer component provides a modal view for displaying and interacting with images, supporting features like pinch-to-zoom, double-tap zoom, and swipe up/down to dismiss. + +showHeader: true +--- + +import { Meta } from '@storybook/addon-docs'; + + + +import { + ImageViewer, + ImageViewerBackdrop, + ImageViewerCloseButton, + ImageViewerContent, + ImageViewerImage, + CloseIcon, + Icon, + Center, + Pressable, + Image +} from '../../core-components/nativewind'; +import { + Pressable, + Image +} from 'react-native'; +import { transformedCode } from '../../utils'; +import { useState } from 'react'; + +import { + CodePreview, + Table, + TableContainer, + AddIcon, + InfoIcon, + InlineCode, + Tabs +} from '@gluestack/design-system'; +import Wrapper from '../../core-components/nativewind/Wrapper'; +import { CollapsibleCode } from '@gluestack/design-system'; + +This is an illustration of **ImageViewer** component. + + + + setVisible(true)}> + + + setVisible(false)}> + + ()} > + + + + + + + + ); + } + `, + transformCode: (code) => { + return transformedCode(code, 'function', 'App'); + }, + scope: { + Wrapper, + ImageViewer, + ImageViewerBackdrop, + ImageViewerContent, + ImageViewerCloseButton, + ImageViewerImage, + CloseIcon, + Icon, + useState, + Pressable, + Image, + Center, + }, + argsType: {}, + }} + /> + + +
+ +## Installation + + + + + CLI + + + Manual + + + + +<> + +### Run the following command: + ```bash + npx gluestack-ui add image-viewer + ``` + + + +<> + +### Step 1: Install the following dependencies: +```bash +npm i @gluestack-ui/image-viewer +``` + +### Step 2: Copy and paste the following code into your project. + + +```jsx +%%-- File: core-components/nativewind/image-viewer/index.tsx --%% +``` + + +### Step 3: Update the import paths to match your project setup. + + + + + +## API Reference + +To use this component in your project, include the following import statement in your file. + +```jsx +import { ImageViewer, ImageViewerBackdrop, ImageViewerContent, ImageViewerCloseButton, ImageViewerImage } from '@/components/ui/image-viewer'; +``` + +```jsx +export default () => ( + + + + + + + + +); +``` + +### Component Props + +This section provides a comprehensive reference list for the component props, detailing descriptions, properties, types, and default behavior for easy project integration. + +#### ImageViewer +The`ImageViewer` component serves as the main container for displaying images in a modal view. It provides a user-friendly interface for viewing images with features like pinch-to-zoom, double-tap zoom, and swipe gestures for dismissal. It is built on top of React Native's [Modal](https://reactnative.dev/docs/modal) component, inheriting all its properties and behaviors. + + + + + + + + Prop + + + Type + + + Default + + + Description + + + Required + + + + + + + + isOpen + + + + boolean + + + - + + + {`If true, the image-viewer modal will open. Useful for controllable state behavior.`} + + + Yes + + + + + + onClose + + + + {`() => any`} + + + - + + + {`Callback invoked when the image-viewer modal is closed.`} + + + Yes + + + +
+
+
+ +#### ImageViewerContent + +The `ImageViewerContent` component is responsible for rendering the images within the `ImageViewer`. It supports gestures for zooming and panning, allowing users to interact with the images. This component leverages React Native's [Animated](https://reactnative.dev/docs/animated#props) & [View](https://reactnative.dev/docs/view) components, as well as gesture handling from the [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/) library. + + + + + + + + Prop + + + Type + + + Default + + + Description + + + Required + + + + + + + + images + + + + Array<{`{id: number, url: string}`}> + + + - + + + Array of image objects to display + + + Yes + + + + + + renderImages + + + + (item: any) => ReactNode + + + - + + + Function to render each image item + + + Yes + + + +
+
+
+ +#### ImageViewerCloseButton + +The `ImageViewerCloseButton` component provides a customizable button for closing the `ImageViewer`. It is typically placed within the `ImageViewerContent` and can be styled to match the application's design. It inherits properties from React Native's [View](https://reactnative.dev/docs/view) component. + +#### ImageViewerBackdrop + +The `ImageViewerBackdrop` component serves as the background layer of the `ImageViewer`, providing a dimmed or blurred effect behind the content. It enhances the focus on the images being viewed. This component is built using React Native's [Animated](https://reactnative.dev/docs/animated#props) & [View](https://reactnative.dev/docs/view) components, allowing for smooth transitions and animations. + +#### ImageViewerImage + +The `ImageViewerImage` component is used to display individual images within the `ImageViewerContent`. It supports all the properties of React Native's [Image](https://reactnative.dev/docs/image) component, making it easy to customize the appearance and behavior of the images. + + +> Note: Currently, the ImageViewer component is limited to only single image, multiple image support will be added in the future. + diff --git a/example/storybook-nativewind/src/core-components/nativewind/image-viewer/index.tsx b/example/storybook-nativewind/src/core-components/nativewind/image-viewer/index.tsx new file mode 100644 index 000000000..fd17523ef --- /dev/null +++ b/example/storybook-nativewind/src/core-components/nativewind/image-viewer/index.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { createImageViewer } from '@gluestack-ui/image-viewer'; +import { Image, Modal, Pressable } from 'react-native'; +import { tva } from '@gluestack-ui/nativewind-utils/tva'; +import { + GestureHandlerRootView, + GestureDetector, + Gesture, +} from 'react-native-gesture-handler'; + +import Animated from 'react-native-reanimated'; +import { VariantProps } from '@gluestack-ui/nativewind-utils/types'; + +const ImageViewerStyle = tva({ + base: 'flex-1 justify-center items-center ', +}); + +const ImageStyle = tva({ + base: 'w-[100vw] h-[100vh]', +}); + +const ContentStyle = tva({ + base: '', +}); + +const BackdropStyle = tva({ + base: 'flex-1 bg-background-dark', +}); + +const CloseButtonStyle = tva({ + base: 'absolute top-4 right-4 z-10 bg-white rounded-full w-8 h-8 justify-center items-center cursor-pointer', +}); + +const UIImageViewer = createImageViewer({ + Root: Modal, + Backdrop: Animated.View, + Content: GestureHandlerRootView, + Animated: Animated.View, + Gesture: Gesture as any, + GestureDetector: GestureDetector, + CloseButton: Pressable, +}); + +type IImageViewerProps = React.ComponentProps & + VariantProps & { className?: string }; + +type IImageViewerBackdropProps = React.ComponentProps< + typeof UIImageViewer.Backdrop +> & + VariantProps & { className?: string }; + +type IImageViewerContentProps = React.ComponentProps< + typeof UIImageViewer.Content +> & + VariantProps & { className?: string }; + +type IImageViewerCloseButtonProps = React.ComponentProps< + typeof UIImageViewer.CloseButton +> & + VariantProps & { className?: string }; + +const ImageViewer = React.forwardRef< + React.ElementRef, + IImageViewerProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +const ImageViewerBackdrop = React.forwardRef< + React.ElementRef, + IImageViewerBackdropProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +const ImageViewerContent = React.forwardRef< + React.ElementRef, + IImageViewerContentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +const ImageViewerCloseButton = React.forwardRef< + React.ElementRef, + IImageViewerCloseButtonProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +const ImageViewerImage = React.forwardRef( + ({ className, ...props }: any, ref) => { + return ( + + ); + } +); + +ImageViewer.displayName = 'ImageViewer'; +ImageViewerBackdrop.displayName = 'ImageViewerBackdrop'; +ImageViewerContent.displayName = 'ImageViewerContent'; +ImageViewerCloseButton.displayName = 'ImageViewerCloseButton'; +ImageViewerImage.displayName = 'ImageViewerImage'; + +export { + ImageViewer, + ImageViewerBackdrop, + ImageViewerContent, + ImageViewerCloseButton, + ImageViewerImage, +}; diff --git a/example/storybook-nativewind/src/core-components/nativewind/index.ts b/example/storybook-nativewind/src/core-components/nativewind/index.ts index 6247593aa..b87e7ef95 100644 --- a/example/storybook-nativewind/src/core-components/nativewind/index.ts +++ b/example/storybook-nativewind/src/core-components/nativewind/index.ts @@ -52,3 +52,4 @@ export * from './image-background'; export * from './skeleton'; export * from './bottomsheet'; export * from './drawer'; +export * from './image-viewer'; diff --git a/example/storybook-nativewind/tsconfig.json b/example/storybook-nativewind/tsconfig.json index de75060bb..8269b1e8b 100644 --- a/example/storybook-nativewind/tsconfig.json +++ b/example/storybook-nativewind/tsconfig.json @@ -29,6 +29,9 @@ "@gluestack-ui/tooltip": ["../../packages/unstyled/tooltip/src"], "@gluestack-ui/fab": ["../../packages/unstyled/fab/src"], "@gluestack-ui/progress": ["../../packages/unstyled/progress/src"], + "@gluestack-ui/image-viewer": [ + "../../packages/unstyled/image-viewer/src" + ], "@gluestack-ui/alert-dialog": [ "../../packages/unstyled/alert-dialog/src" ], diff --git a/packages/unstyled/image-viewer/.npmignore b/packages/unstyled/image-viewer/.npmignore new file mode 100644 index 000000000..187790b63 --- /dev/null +++ b/packages/unstyled/image-viewer/.npmignore @@ -0,0 +1,20 @@ +# Dotfiles +.babelrc +.eslintignore +.eslintrc.json +.gitattributes +_config.yml +.editorconfig + + +#Config files +babel.config.js + +# Documents +CONTRIBUTING.md +ISSUE_TEMPLATE.txt +img + +# Test cases +__tests__ +dist/__tests__ diff --git a/packages/unstyled/image-viewer/CHANGELOG.md b/packages/unstyled/image-viewer/CHANGELOG.md new file mode 100644 index 000000000..0c854049e --- /dev/null +++ b/packages/unstyled/image-viewer/CHANGELOG.md @@ -0,0 +1,17 @@ +# @gluestack-ui/image-viewer + +## 0.0.3 + +### Patch Changes + +- fix: updated package.json + +## 0.0.2 + +### Patch Changes + +- feat: New Component ImageViewer patch bump + +## 0.0.1 + +- Initial release diff --git a/packages/unstyled/image-viewer/README.md b/packages/unstyled/image-viewer/README.md new file mode 100644 index 000000000..6e2faa970 --- /dev/null +++ b/packages/unstyled/image-viewer/README.md @@ -0,0 +1,104 @@ +# @gluestack-ui/image-viewer + +## Table of Contents + +- [Installation](#installation) +- [Usage](#usage) +- [Customizing the ImageViewer](#customizing-the-imageviewer) +- [Component Props](#component-props) +- [Contributing](#contributing) +- [License](#license) + +## Installation + +To use `@gluestack-ui/image-viewer`, install the package using either Yarn or npm: + +```sh +$ yarn add @gluestack-ui/image-viewer + +# or + +$ npm i @gluestack-ui/image-viewer +``` + +## Usage + +The ImageViewer component provides a modal view for displaying and interacting with images, supporting features like pinch-to-zoom, double-tap zoom, and swipe up/down to dismiss. Here's an example of how to use this package: + +```jsx +import { createImageViewer } from '@gluestack-ui/image-viewer'; +import { Root, Backdrop, Content, CloseButton } from './styled-components'; + +export const ImageViewer = createImageViewer({ + Root, + Backdrop, + Content, + CloseButton, +}); +``` + +## Customizing the ImageViewer + +Default styling of all these components can be found in the components/core/image-viewer file. For reference, you can view the [source code](https://github.com/gluestack/gluestack-ui/blob/development/example/storybook/src/ui-components/ImageViewer/index.tsx) of the styled `ImageViewer` components. + +```jsx +// import the styles +import { + Root, + Backdrop, + Content, + CloseButton, +} from '../components/core/image-viewer/styled-components'; + +// import the createImageViewer function +import { createImageViewer } from '@gluestack-ui/image-viewer'; + +// Understanding the API +const ImageViewer = createImageViewer({ + Root, + Backdrop, + Content, + CloseButton, +}); + +// Using the ImageViewer component +export default () => ( + + + ( + + )} + /> + + +); +``` + +## Component Props + +### ImageViewer + +| Prop | Type | Default | Description | +| -------- | --------- | ------- | ------------------------------------------------------ | +| isOpen | boolean | false | If true, the image viewer modal will open | +| onClose | function | - | Callback invoked when the image viewer modal is closed | +| children | ReactNode | - | The content to be rendered inside the image viewer | + +### ImageViewerContent + +| Prop | Type | Default | Description | +| ------------ | -------------------------------- | ------- | ---------------------------------- | +| images | Array<{id: number, url: string}> | - | Array of image objects to display | +| renderImages | (item: any) => ReactNode | - | Function to render each image item | + +More guides on how to get started are available [here](https://ui.gluestack.io/docs/components/media-and-icons/image-viewer). + +## Contributing + +We welcome contributions to the `@gluestack-ui/image-viewer` package. If you have an idea for a new feature or a bug fix, please read our [contributing guide](https://github.com/gluestack/gluestack-ui/blob/main/CONTRIBUTING.md) for instructions on how to submit a pull request. + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/gluestack/gluestack-ui/blob/main/LICENSE) file for more details. diff --git a/packages/unstyled/image-viewer/babel.config.js b/packages/unstyled/image-viewer/babel.config.js new file mode 100644 index 000000000..b2d6e5249 --- /dev/null +++ b/packages/unstyled/image-viewer/babel.config.js @@ -0,0 +1,13 @@ +const path = require('path'); + +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + process.env.NODE_ENV !== 'production' + ? [] + : ['babel-plugin-react-docgen-typescript', { exclude: 'node_modules' }], + ], + }; +}; diff --git a/packages/unstyled/image-viewer/package.json b/packages/unstyled/image-viewer/package.json new file mode 100644 index 000000000..fcce29844 --- /dev/null +++ b/packages/unstyled/image-viewer/package.json @@ -0,0 +1,82 @@ +{ + "name": "@gluestack-ui/image-viewer", + "version": "0.0.3", + "main": "lib/index", + "module": "lib/index", + "types": "lib/index.d.ts", + "react-native": "src/index", + "source": "src/index", + "typings": "lib/index.d.ts", + "description": "A universal headless ImageViewer component for React Native, Next.js & React", + "keywords": [ + "react", + "native", + "react-native", + "image-viewer", + "gluestack-ui", + "universal", + "headless", + "typescript", + "component", + "android", + "ios", + "nextjs" + ], + "scripts": { + "prepare": "tsc", + "release": "release-it", + "watch": "tsc --watch", + "build": "tsc", + "clean": "rm -rf lib", + "dev:web": "cd example/native && yarn web --clear", + "storybook": "cd example/native/storybook && yarn web" + }, + "devDependencies": { + "@types/react": "^18.0.22", + "@types/react-native": "^0.72.3", + "babel-plugin-transform-remove-console": "^6.9.4", + "react": "^18.1.0", + "react-dom": "^18.1.0", + "react-native": "^0.72.4", + "react-native-builder-bob": "^0.20.1", + "react-native-web": "^0.19.9", + "tsconfig": "7", + "typescript": "^5.6.3" + }, + "dependencies": { + "react-native-gesture-handler": "^2.21.2", + "react-native-reanimated": "~3.6.2", + "@react-native-aria/focus": "^0.2.9", + "@react-native-aria/interactions": "0.2.13", + "@gluestack-ui/utils": "^0.1.14" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + }, + "homepage": "https://github.com/gluestack/gluestack-ui/tree/main/packages/unstyled/image-viewer#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/gluestack/gluestack-ui.git" + }, + "files": [ + "lib/", + "src/" + ], + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.js$": "/node_modules/react-native/jest/preprocessor.js" + }, + "modulePathIgnorePatterns": [ + "/example/*", + "/lib/" + ], + "transformIgnorePatterns": [ + "node_modules/(?!(@react-native|react-native|expo-asset|expo-constants|@unimodules|react-native-unimodules|expo-font|react-native-svg|@expo/vector-icons|react-native-vector-icons|@react-native-aria/checkbox|@react-native-aria/interactions|@react-native-aria/button|@react-native-aria/switch|@react-native-aria/toggle|@react-native-aria/utils|@react-native-aria/*))" + ], + "setupFiles": [ + "/src/jest/mock.ts" + ] + } +} diff --git a/packages/unstyled/image-viewer/src/ImageViewer.tsx b/packages/unstyled/image-viewer/src/ImageViewer.tsx new file mode 100644 index 000000000..b2ae941e8 --- /dev/null +++ b/packages/unstyled/image-viewer/src/ImageViewer.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from 'react'; +import { ImageViewerContext } from './ImageViewerContext'; +import type { ImageViewerProps } from './types'; + +const ImageViewer = (StyledRoot: any) => + forwardRef( + ( + { + children, + isOpen, + onClose, + ...props + }: ImageViewerProps & { children: React.ReactNode }, + ref?: any + ) => { + const [scale, setScale] = React.useState(1); + + const contextValue = React.useMemo(() => { + return { + onClose, + isOpen, + scale, + setScale, + }; + }, [onClose, isOpen, scale]); + + return ( + + + {children} + + + ); + } + ); + +export default ImageViewer; diff --git a/packages/unstyled/image-viewer/src/ImageViewerBackdrop.tsx b/packages/unstyled/image-viewer/src/ImageViewerBackdrop.tsx new file mode 100644 index 000000000..843e54407 --- /dev/null +++ b/packages/unstyled/image-viewer/src/ImageViewerBackdrop.tsx @@ -0,0 +1,26 @@ +import React, { forwardRef, useContext } from 'react'; +import { ImageViewerContext } from './ImageViewerContext'; +import { useAnimatedStyle } from 'react-native-reanimated'; + +const ImageViewerBackdrop = (StyledImageViewerBackdrop: any) => + forwardRef(({ children, ...props }: any, ref?: any) => { + const { scale } = useContext(ImageViewerContext); + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: scale, + }; + }); + + return ( + + {children} + + ); + }); + +export default ImageViewerBackdrop; diff --git a/packages/unstyled/image-viewer/src/ImageViewerCloseButton.tsx b/packages/unstyled/image-viewer/src/ImageViewerCloseButton.tsx new file mode 100644 index 000000000..f5cfead0e --- /dev/null +++ b/packages/unstyled/image-viewer/src/ImageViewerCloseButton.tsx @@ -0,0 +1,69 @@ +import React, { forwardRef } from 'react'; +import { useHover, usePress } from '@react-native-aria/interactions'; +import { composeEventHandlers } from '@gluestack-ui/utils'; +import { useFocusRing, useFocus } from '@react-native-aria/focus'; +import { ImageViewerContext } from './ImageViewerContext'; + +const ImageViewerCloseButton = (StyledImageViewerCloseButton: any) => + forwardRef((props: any, ref?: any) => { + const { hoverProps, isHovered } = useHover(); + const { pressProps, isPressed } = usePress({ + isDisabled: props.isDisabled, + }); + const { focusProps, isFocused } = useFocus(); + const { isFocusVisible, focusProps: focusRingProps }: any = useFocusRing(); + + const { + // _icon, + onPressIn, + onPressOut, + onHoverIn, + onHoverOut, + onFocus, + onBlur, + children, + ...resolvedProps + } = props; + const { onClose } = React.useContext(ImageViewerContext); + + return ( + + {children} + + ); + }); + +export default ImageViewerCloseButton; diff --git a/packages/unstyled/image-viewer/src/ImageViewerContent.tsx b/packages/unstyled/image-viewer/src/ImageViewerContent.tsx new file mode 100644 index 000000000..24ff15e8c --- /dev/null +++ b/packages/unstyled/image-viewer/src/ImageViewerContent.tsx @@ -0,0 +1,176 @@ +import React, { forwardRef, useContext } from 'react'; +import { ImageViewerContext } from './ImageViewerContext'; +import { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { Dimensions, StatusBar } from 'react-native'; +import type { ImageViewerContentProps } from './types'; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); +const DOUBLE_TAP_DELAY = 300; + +const ImageViewerContent = ( + StyledGestureHandlerRootView: any, + StyledGestureDetector: any, + StyledAnimated: any, + Gesture: any +) => + forwardRef( + ( + { + images, + renderImages, + children, + }: ImageViewerContentProps & { children: React.ReactNode }, + ref?: any + ) => { + const { onClose, setScale }: any = useContext(ImageViewerContext); + const scale = useSharedValue(1); + const savedScale = useSharedValue(1); + const translateX = useSharedValue(0); + const translateY = useSharedValue(0); + const focalX = useSharedValue(0); + const focalY = useSharedValue(0); + const lastTranslateX = useSharedValue(0); + const lastTranslateY = useSharedValue(0); + + const pinchGesture = Gesture.Pinch() + .onStart(() => { + savedScale.value = scale.value; + }) + .onUpdate((event: any) => { + // Apply the new scale based on the saved scale value + const newScale = savedScale.value * event.scale; + scale.value = Math.min(Math.max(newScale, 0.5), 3); + focalX.value = event.focalX; + focalY.value = event.focalY; + }) + .onEnd(() => { + if (scale.value < 1) { + scale.value = withSpring(1); + savedScale.value = 1; + } else { + savedScale.value = scale.value; + } + }); + + const doubleTapGesture = Gesture.Tap() + .numberOfTaps(2) + .maxDuration(DOUBLE_TAP_DELAY) + .onStart((event: any) => { + if (scale.value > 1) { + // If already zoomed in, reset to normal + scale.value = withTiming(1); + savedScale.value = 1; + translateX.value = withTiming(0); + translateY.value = withTiming(0); + } else { + // Zoom in to 2x at the tap location + scale.value = withTiming(2); + savedScale.value = 2; + + // Calculate the focal point for zooming + const centerX = SCREEN_WIDTH / 2; + const centerY = SCREEN_HEIGHT / 2; + const focusX = event.x - centerX; + const focusY = event.y - centerY; + + // Adjust translation to zoom into the tapped point + translateX.value = withTiming(-focusX); + translateY.value = withTiming(-focusY); + } + }); + + const panGesture = Gesture.Pan() + .onStart(() => { + // Store the current translation values when starting the pan + lastTranslateX.value = translateX.value; + lastTranslateY.value = translateY.value; + }) + .onUpdate((event: any) => { + if (scale.value > 1) { + // When zoomed in, allow panning within bounds + // Calculate new positions based on the start position plus the new translation + translateX.value = lastTranslateX.value + event.translationX; + translateY.value = lastTranslateY.value + event.translationY; + } else { + // Normal swipe behavior when not zoomed + if (Math.abs(event.translationY) > Math.abs(event.translationX)) { + translateY.value = event.translationY; + scale.value = Math.max( + 0.5, + 1 - Math.abs(event.translationY) / SCREEN_HEIGHT + ); + } + } + }) + .onEnd((event: any) => { + if (scale.value <= 1) { + if (Math.abs(event.translationY) > SCREEN_HEIGHT * 0.2) { + runOnJS(onClose)(); + } + } + + // Reset position if not zoomed + if (scale.value <= 1) { + translateX.value = 0; + translateY.value = withSpring(0); + scale.value = withSpring(1); + savedScale.value = 1; + } else { + // When zoomed, bound the pan values + const maxTranslateX = ((scale.value - 1) * SCREEN_WIDTH) / 2; + const maxTranslateY = ((scale.value - 1) * SCREEN_HEIGHT) / 2; + + translateX.value = withSpring( + Math.min( + Math.max(translateX.value, -maxTranslateX), + maxTranslateX + ) + ); + translateY.value = withSpring( + Math.min( + Math.max(translateY.value, -maxTranslateY), + maxTranslateY + ) + ); + } + }); + + const composedGesture = Gesture.Race( + doubleTapGesture, + Gesture.Simultaneous(pinchGesture, panGesture) + ); + + const animatedStyle = useAnimatedStyle(() => { + setScale(scale.value); + if (scale.value <= 1) { + } + return { + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value }, + { scale: scale.value }, + ], + }; + }); + + return ( + + + ); + } + ); + +export default ImageViewerContent; diff --git a/packages/unstyled/image-viewer/src/ImageViewerContext.ts b/packages/unstyled/image-viewer/src/ImageViewerContext.ts new file mode 100644 index 000000000..8bedf3e80 --- /dev/null +++ b/packages/unstyled/image-viewer/src/ImageViewerContext.ts @@ -0,0 +1,9 @@ +import React from 'react'; +import type { ImageViewerContext as ImageViewerContextType } from './types'; + +export const ImageViewerContext = React.createContext({ + onClose: () => {}, + isOpen: false, + scale: 1, + setScale: () => {}, +}); diff --git a/packages/unstyled/image-viewer/src/index.tsx b/packages/unstyled/image-viewer/src/index.tsx new file mode 100644 index 000000000..48e00136c --- /dev/null +++ b/packages/unstyled/image-viewer/src/index.tsx @@ -0,0 +1,53 @@ +import { default as ImageViewerMain } from './ImageViewer'; +import ImageViewerBackdrop from './ImageViewerBackdrop'; +import ImageViewerCloseButton from './ImageViewerCloseButton'; +import ImageViewerContent from './ImageViewerContent'; +import type { IImageViewerComponentType } from './types'; + +export { ImageViewerContext } from './ImageViewerContext'; +export const createImageViewer = < + ImageViewerProps, + GestureDetectorProps, + AnimatedProps, + GestureProps, + BackdropProps, + ContentProps, + CloseButtonProps +>({ + Root, + GestureDetector, + Animated, + Gesture, + Backdrop, + Content, + CloseButton, +}: { + Root: React.ComponentType; + GestureDetector: React.ComponentType; + Animated: React.ComponentType; + Gesture: React.ComponentType; + Backdrop: React.ComponentType; + Content: React.ComponentType; + CloseButton: React.ComponentType; +}) => { + const ImageViewer: any = ImageViewerMain(Root); + ImageViewer.Backdrop = ImageViewerBackdrop(Backdrop); + ImageViewer.Content = ImageViewerContent( + Content, + GestureDetector, + Animated, + Gesture + ); + ImageViewer.CloseButton = ImageViewerCloseButton(CloseButton); + + ImageViewer.displayName = 'ImageViewer'; + ImageViewer.Backdrop.displayName = 'ImageViewer.Backdrop'; + ImageViewer.Content.displayName = 'ImageViewer.Content'; + ImageViewer.CloseButton.displayName = 'ImageViewer.CloseButton'; + return ImageViewer as IImageViewerComponentType< + ImageViewerProps, + BackdropProps, + ContentProps, + CloseButtonProps + >; +}; diff --git a/packages/unstyled/image-viewer/src/types.ts b/packages/unstyled/image-viewer/src/types.ts new file mode 100644 index 000000000..57e5347e2 --- /dev/null +++ b/packages/unstyled/image-viewer/src/types.ts @@ -0,0 +1,48 @@ +export interface ImageViewerContext { + onClose: () => void; + isOpen: boolean | undefined; + scale: number | undefined; + setScale: (scale: number) => void; +} + +export interface ImageViewerProps { + /** + * If true, the modal will open. Useful for controllable state behavior. + */ + isOpen?: boolean; + /** + * Callback invoked when the modal is closed. + */ + onClose?: any; + /** + * If true, the modal will be opened by default. + */ +} + +export interface ImageViewerContentProps { + images: any; + renderImages: (item: any) => React.ReactNode; +} + +export type IImageViewerComponentType< + ImageViewerProps, + ImageViewerContentProps, + ImageViewerCloseButtonProps, + ImageViewerBackdropProps +> = React.ForwardRefExoticComponent< + React.PropsWithoutRef & + React.RefAttributes +> & { + Content: React.ForwardRefExoticComponent< + React.PropsWithoutRef & + React.RefAttributes + >; + CloseButton: React.ForwardRefExoticComponent< + React.PropsWithoutRef & + React.RefAttributes + >; + Backdrop: React.ForwardRefExoticComponent< + React.PropsWithoutRef & + React.RefAttributes + >; +}; diff --git a/packages/unstyled/image-viewer/tsconfig.json b/packages/unstyled/image-viewer/tsconfig.json new file mode 100644 index 000000000..c13c2a196 --- /dev/null +++ b/packages/unstyled/image-viewer/tsconfig.json @@ -0,0 +1,31 @@ +{ + "include": ["src"], + "exclude": ["node_modules", "example"], + "paths": {}, + "compilerOptions": { + "ignoreDeprecations": "5.0", + "noEmit": false, + "declaration": true, + "allowJs": true, + "allowUnreachableCode": false, + "allowUnusedLabels": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "jsx": "preserve", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUnusedLocals": false, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "outDir": "./lib" + } +} diff --git a/yarn.lock b/yarn.lock index 21635f71b..00ed1c232 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9251,10 +9251,10 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: - version "4.4.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" - integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== dependencies: ms "^2.1.3" @@ -9272,6 +9272,13 @@ debug@^3.0.0, debug@^3.1.0, debug@^3.2.5, debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.7: + version "4.4.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== + dependencies: + ms "^2.1.3" + decamelize-keys@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" @@ -17803,6 +17810,26 @@ react-native-css-interop@0.1.22: lightningcss "^1.27.0" semver "^7.6.3" +react-native-gesture-handler@^2.12.1: + version "2.20.0" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.0.tgz#2d9ec4e9bd22619ebe36269dda3ecb1173928276" + integrity sha512-rFKqgHRfxQ7uSAivk8vxCiW4SB3G0U7jnv7kZD4Y90K5kp6YrU8Q3tWhxe3Rx55BIvSd3mBe9ZWbWVJ0FsSHPA== + dependencies: + "@egjs/hammerjs" "^2.0.17" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + prop-types "^15.7.2" + +react-native-gesture-handler@^2.21.2: + version "2.21.2" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.21.2.tgz#824a098d7397212fbe51aa3a9df84833a4ea1c3f" + integrity sha512-HcwB225K9aeZ8e/B8nFzEh+2T4EPWTeamO1l/y3PcQ9cyCDYO2zja/G31ITpYRIqkip7XzGs6wI/gnHOQn1LDQ== + dependencies: + "@egjs/hammerjs" "^2.0.17" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + prop-types "^15.7.2" + react-native-gesture-handler@~2.14.0: version "2.14.1" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.14.1.tgz#930640231024b7921435ab476aa501dd4a6b2e01"