├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ └── stale.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .watchmanconfig ├── .yarnrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── demo.gif ├── example ├── App.js ├── app.json ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── metro.config.js ├── package.json ├── src │ ├── App.tsx │ ├── components │ │ ├── AnimatedCircle.tsx │ │ ├── ExpoImageZoom.tsx │ │ ├── ImageZoom.tsx │ │ └── Tabs.tsx │ ├── safeAreaContextProviderHOC.tsx │ ├── screens │ │ ├── CarouselTab.tsx │ │ ├── ExpoImageZoomTab.tsx │ │ ├── ImageZoomTab.tsx │ │ └── TabsScreen.tsx │ └── themes │ │ └── colors.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── lefthook.yml ├── package.json ├── scripts └── bootstrap.js ├── src ├── __tests__ │ └── index.test.tsx ├── components │ ├── ImageZoom.tsx │ └── Zoomable.tsx ├── hooks │ ├── useAnimationEnd.ts │ ├── useGestures.ts │ ├── useInteractionId.ts │ ├── usePanGestureCount.ts │ ├── useZoomable.ts │ ├── useZoomableHandle.ts │ └── useZoomableLayout.ts ├── index.ts ├── types.ts └── utils │ ├── clamp.ts │ ├── limits.ts │ └── sum.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please provide enough information so that others can review your pull request: 2 | 3 | ## Motivation 4 | 5 | Explain the **motivation** for making this change. What existing problem does the pull request solve? 6 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Setup Node.js and install dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup Node.js 8 | uses: actions/setup-node@v3 9 | with: 10 | node-version-file: .nvmrc 11 | 12 | - name: Cache dependencies 13 | id: yarn-cache 14 | uses: actions/cache@v3 15 | with: 16 | path: | 17 | **/node_modules 18 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/package.json') }} 19 | restore-keys: | 20 | ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 21 | ${{ runner.os }}-yarn- 22 | 23 | - name: Install dependencies 24 | if: steps.yarn-cache.outputs.cache-hit != 'true' 25 | run: | 26 | yarn install --cwd example --frozen-lockfile 27 | yarn install --frozen-lockfile 28 | shell: bash 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup 18 | uses: ./.github/actions/setup 19 | 20 | - name: Lint files 21 | run: yarn lint 22 | 23 | - name: Typecheck files 24 | run: yarn typecheck 25 | 26 | test: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | 32 | - name: Setup 33 | uses: ./.github/actions/setup 34 | 35 | - name: Run unit tests 36 | run: yarn test --maxWorkers=2 --coverage 37 | 38 | build-library: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v3 43 | 44 | - name: Setup 45 | uses: ./.github/actions/setup 46 | 47 | - name: Build package 48 | run: yarn prepack 49 | 50 | build-web: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v3 55 | 56 | - name: Setup 57 | uses: ./.github/actions/setup 58 | 59 | - name: Build example for Web 60 | run: | 61 | yarn example expo export:web 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: '32 10 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '27 18 * * *' 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | pull-requests: write 18 | 19 | steps: 20 | - uses: actions/stale@v5 21 | with: 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 15 days.' 24 | stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days.' 25 | close-issue-message: 'This issue was closed because it has been stalled for 15 days with no activity.' 26 | close-pr-message: 'This PR was closed because it has been stalled for 30 days with no activity.' 27 | days-before-issue-stale: 60 28 | days-before-pr-stale: 90 29 | days-before-issue-close: 15 30 | days-before-pr-close: 30 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | *.iml 42 | *.hprof 43 | android.iml 44 | 45 | # Cocoapods 46 | # 47 | example/ios/Pods 48 | 49 | # Ruby 50 | example/vendor/ 51 | 52 | # node.js 53 | # 54 | node_modules/ 55 | npm-debug.log 56 | yarn-debug.log 57 | yarn-error.log 58 | 59 | # BUCK 60 | buck-out/ 61 | \.buckd/ 62 | android/app/libs 63 | android/keystores/debug.keystore 64 | 65 | # Expo 66 | .expo/ 67 | 68 | # Turborepo 69 | .turbo/ 70 | 71 | # generated by bob 72 | lib/ 73 | 74 | # generated by npm 75 | likashefqet-react-native-image-zoom* 76 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci/ 2 | .git* 3 | .husky/ 4 | example/ 5 | lib/ 6 | node_modules/ 7 | scripts/ 8 | src/__tests__/ 9 | .editorconfig 10 | .npmignore 11 | .yarn* 12 | babel.config.js 13 | likashefqet-react-native-image-zoom* 14 | demo* 15 | tsconfig* 16 | yarn.lock 17 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # Override Yarn command so we can automatically setup the repo on running `yarn` 2 | 3 | yarn-path "scripts/bootstrap.js" 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | likashefi@gmail.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are always welcome, no matter how large or small! 4 | 5 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. Before contributing, please read the [code of conduct](./CODE_OF_CONDUCT.md). 6 | 7 | ## Development workflow 8 | 9 | To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: 10 | 11 | ```sh 12 | yarn 13 | ``` 14 | 15 | > While it's possible to use [`npm`](https://github.com/npm/cli), the tooling is built around [`yarn`](https://classic.yarnpkg.com/), so you'll have an easier time if you use `yarn` for development. 16 | 17 | While developing, you can run the [example app](/example/) to test your changes. Any changes you make in your library's JavaScript code will be reflected in the example app without a rebuild. If you change any native code, then you'll need to rebuild the example app. 18 | 19 | To start the packager: 20 | 21 | ```sh 22 | yarn example start 23 | ``` 24 | 25 | To run the example app on Android: 26 | 27 | ```sh 28 | yarn example android 29 | ``` 30 | 31 | To run the example app on iOS: 32 | 33 | ```sh 34 | yarn example ios 35 | ``` 36 | 37 | To run the example app on Web: 38 | 39 | ```sh 40 | yarn example web 41 | ``` 42 | 43 | Make sure your code passes TypeScript and ESLint. Run the following to verify: 44 | 45 | ```sh 46 | yarn typecheck 47 | yarn lint 48 | ``` 49 | 50 | To fix formatting errors, run the following: 51 | 52 | ```sh 53 | yarn lint --fix 54 | ``` 55 | 56 | Remember to add tests for your change if possible. Run the unit tests by: 57 | 58 | ```sh 59 | yarn test 60 | ``` 61 | 62 | 63 | ### Commit message convention 64 | 65 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: 66 | 67 | - `fix`: bug fixes, e.g. fix crash due to deprecated method. 68 | - `feat`: new features, e.g. add new method to the module. 69 | - `refactor`: code refactor, e.g. migrate from class components to hooks. 70 | - `docs`: changes into documentation, e.g. add usage example for the module.. 71 | - `test`: adding or updating tests, e.g. add integration tests using detox. 72 | - `chore`: tooling changes, e.g. change CI config. 73 | 74 | Our pre-commit hooks verify that your commit message matches this format when committing. 75 | 76 | ### Linting and tests 77 | 78 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) 79 | 80 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. 81 | 82 | Our pre-commit hooks verify that the linter and tests pass when committing. 83 | 84 | ### Publishing to npm 85 | 86 | We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. 87 | 88 | To publish new versions, run the following: 89 | 90 | ```sh 91 | yarn release 92 | ``` 93 | 94 | ### Scripts 95 | 96 | The `package.json` file contains various scripts for common tasks: 97 | 98 | - `yarn bootstrap`: setup project by installing all dependencies and pods. 99 | - `yarn typecheck`: type-check files with TypeScript. 100 | - `yarn lint`: lint files with ESLint. 101 | - `yarn test`: run unit tests with Jest. 102 | - `yarn example start`: start the Metro server for the example app. 103 | - `yarn example android`: run the example app on Android. 104 | - `yarn example ios`: run the example app on iOS. 105 | 106 | ### Sending a pull request 107 | 108 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). 109 | 110 | When you're sending a pull request: 111 | 112 | - Prefer small pull requests focused on one change. 113 | - Verify that linters and tests are passing. 114 | - Review the documentation to make sure it looks good. 115 | - Follow the pull request template when opening a pull request. 116 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shefqet Lika 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REACT NATIVE IMAGE ZOOM 2 | 3 | ![npm](https://img.shields.io/npm/v/@likashefqet/react-native-image-zoom) 4 | ![NPM](https://img.shields.io/npm/l/@likashefqet/react-native-image-zoom) 5 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/@likashefqet/react-native-image-zoom/peer/react-native-reanimated) 6 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/@likashefqet/react-native-image-zoom/peer/react-native-gesture-handler) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/min/@likashefqet/react-native-image-zoom) 8 | [![npm](https://img.shields.io/badge/types-included-blue)](https://github.com/likashefqet/react-native-image-zoom) 9 | ![npms.io (final)](https://img.shields.io/npms-io/maintenance-score/@likashefqet/react-native-image-zoom) 10 | ![GitHub issues](https://img.shields.io/github/issues/likashefqet/react-native-image-zoom) 11 | 12 | **A performant and customizable image zoom component 13 | built with Reanimated v2+ and TypeScript. 🌃 🚀** 14 | 15 | _Demo:_ 16 | 17 | ![React Native Image Zoom](https://raw.githubusercontent.com/likashefqet/react-native-image-zoom/main/demo.gif) 18 | 19 |
20 | Photo by Walling on Unsplash 21 |
22 | 23 | ## What's new 24 | 25 | - **Enhanced Pan Gesture Handling:** Improved the accuracy and responsiveness of pan gestures, ensuring smoother and more natural interactions when panning images. 26 | 27 | - **Refined Single Tap Detection:** The single tap gesture functionality has been enhanced to trigger more reliably, providing better consistency and control without interfering with other gestures. 28 | 29 | - **Updated Example Integration:** 30 | - Added new examples demonstrating how to leverage the scale value for custom animation effects. 31 | - Provided an example showcasing how to integrate the Image Zoom Component with react-native-reanimated-carousel, allowing for animated, zoomable image carousels. 32 | - **TypeScript Support for Animated Props:** Expanded TypeScript definitions to include support for animated props, ensuring better type safety and compatibility with Reanimated-based animations. 33 | 34 | ## Features 35 | 36 | - **Smooth Zooming Gestures:** Ensure smooth and responsive zooming functionality, allowing users to easily zoom in and out of images using intuitive pinch and pan gestures. 37 | 38 | - **Reset Zoom and Snap Back:** The component automatically resets zoom and snaps back to the initial position when the gesture ends. 39 | 40 | - **Double Tap to Zoom:** Enable a double tap gesture for users to seamlessly zoom in and out of images. When double tap functionality is enabled, the automatic Reset Zoom and Snap Back feature will be disabled, allowing users to maintain their desired zoom level without automatic resets. 41 | 42 | - **Single Tap Functionality:** Detect and process single tap gestures to trigger specific actions or functionality as needed within the component 43 | 44 | - **Customizable Zoom Settings:** Utilize `minScale`, `maxScale`, and `doubleTapScale` props for precise control over minimum, maximum, and double tap zoom levels, tailoring the zoom behavior to application requirements 45 | 46 | - **Customizable Functionality:** Enable or disable features such as panning (`isPanEnabled`), pinching (`isPinchEnabled`), single tap handling (`isSingleTapEnabled`), and double tap zoom (`isDoubleTapEnabled`) based on specific application needs. 47 | 48 | - **Access Scale Animated Value:** Provide a Reanimated shared value for the scale property, allowing you to access and utilize the current zoom scale in your own code. 49 | 50 | - **Interactive Callbacks:** The component provides interactive callbacks such as `onInteractionStart`, `onInteractionEnd`, `onPinchStart`, `onPinchEnd`, `onPanStart`, `onPanEnd`, `onSingleTap`, `onDoubleTap` and `onResetAnimationEnd` that allow you to handle image interactions. 51 | 52 | - **Access Last Values on Reset:** The `onResetAnimationEnd` callback returns the last zoom and position values when the component resets (zooms out), providing more control and feedback for custom logic. 53 | 54 | - **Ref Handle:** Customize the functionality further by utilizing the exposed `reset` and `zoom` methods. The 'reset' method allows you to programmatically reset the image zoom as a side effect to another user action or event, in addition to the default double tap and pinch functionalities. The 'zoom' method allows you to programmatically zoom in the image to a given point (x, y) at a given scale level. 55 | 56 | - **Reanimated Compatibility**: Compatible with `Reanimated v2` & `Reanimated v3`, providing optimized performance and smoother animations during image manipulations`. 57 | 58 | - **TypeScript Support:** Developed with `TypeScript` to enhance codebase maintainability and ensure type safety, reducing potential errors during development and refactoring processes 59 | 60 | - **Full React Native Image Props Support:** The component supports all React Native Image props, making it easy to integrate with existing code and utilize all the features that React Native Image provides. 61 | 62 | - **Zoomable Component:** This component makes any child elements zoomable, ensuring they behave like the image zoom component. This is particularly useful when you need to replace the default image component with alternatives like Expo Image (see example) or Fast Image. 63 | 64 | ## Getting Started 65 | 66 | To use the `ImageZoom` component, you first need to install the package via npm or yarn. Run either of the following commands: 67 | 68 | ```sh 69 | npm install @likashefqet/react-native-image-zoom 70 | ``` 71 | 72 | ```sh 73 | yarn add @likashefqet/react-native-image-zoom 74 | ``` 75 | 76 | > [!CAUTION] 77 | > 78 | > # 🚨 79 | > 80 | > ### Please note that this library is compatible with `Reanimated v2` & `Reanimated v3` and uses `GestureHandler v2`. 81 | > 82 | > ## If you haven't installed Reanimated and Gesture Handler yet, please follow the installation instructions for [Reanimated](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation) and [Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/docs/). 83 | 84 | > [!NOTE] 85 | > 86 | > ### [Usage with modals on Android](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation/#usage-with-modals-on-android) 87 | > 88 | > On Android RNGH does not work by default because modals are not located under React Native Root view in native hierarchy. To fix that, components need to be wrapped with gestureHandlerRootHOC (it's no-op on iOS and web). 89 | 90 | ## Usage 91 | 92 | First, import the `ImageZoom` component from the `@likashefqet/react-native-image-zoom` library: 93 | 94 | ```javascript 95 | import { ImageZoom } from '@likashefqet/react-native-image-zoom'; 96 | ``` 97 | 98 | To use the `ImageZoom` component, simply pass the uri prop with the URL of the image you want to zoom: 99 | 100 | ### Basic Example 101 | 102 | ```javascript 103 | 104 | ``` 105 | 106 | ### Customized Example 107 | 108 | ```javascript 109 | { 119 | console.log('onInteractionStart'); 120 | onZoom(); 121 | }} 122 | onInteractionEnd={() => console.log('onInteractionEnd')} 123 | onPanStart={() => console.log('onPanStart')} 124 | onPanEnd={() => console.log('onPanEnd')} 125 | onPinchStart={() => console.log('onPinchStart')} 126 | onPinchEnd={() => console.log('onPinchEnd')} 127 | onSingleTap={() => console.log('onSingleTap')} 128 | onDoubleTap={(zoomType) => { 129 | console.log('onDoubleTap', zoomType); 130 | onZoom(zoomType); 131 | }} 132 | onProgrammaticZoom={(zoomType) => { 133 | console.log('onZoom', zoomType); 134 | onZoom(zoomType); 135 | }} 136 | style={styles.image} 137 | onResetAnimationEnd={(finished, values) => { 138 | console.log('onResetAnimationEnd', finished); 139 | console.log('lastScaleValue:', values?.SCALE.lastValue); 140 | onAnimationEnd(finished); 141 | }} 142 | resizeMode="cover" 143 | /> 144 | ``` 145 | 146 | ### Zoomable with Expo Image Example 147 | 148 | ```javascript 149 | { 158 | console.log('onInteractionStart'); 159 | onZoom(); 160 | }} 161 | onInteractionEnd={() => console.log('onInteractionEnd')} 162 | onPanStart={() => console.log('onPanStart')} 163 | onPanEnd={() => console.log('onPanEnd')} 164 | onPinchStart={() => console.log('onPinchStart')} 165 | onPinchEnd={() => console.log('onPinchEnd')} 166 | onSingleTap={() => console.log('onSingleTap')} 167 | onDoubleTap={(zoomType) => { 168 | console.log('onDoubleTap', zoomType); 169 | onZoom(zoomType); 170 | }} 171 | onProgrammaticZoom={(zoomType) => { 172 | console.log('onZoom', zoomType); 173 | onZoom(zoomType); 174 | }} 175 | style={styles.image} 176 | onResetAnimationEnd={(finished, values) => { 177 | console.log('onResetAnimationEnd', finished); 178 | console.log('lastScaleValue:', values?.SCALE.lastValue); 179 | onAnimationEnd(finished); 180 | }} 181 | > 182 | 183 | 184 | ``` 185 | 186 | ## Properties 187 | 188 | ### ImageZoom Props 189 | 190 | All `React Native Image Props` & 191 | 192 | | Property | Type | Default | Description | 193 | | ------------------- | -------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 194 | | uri | String | `''` (empty string) | The image's URI, which can be overridden by the `source` prop. | 195 | | minScale | Number | `1` | The minimum scale allowed for zooming. | 196 | | maxScale | Number | `5` | The maximum scale allowed for zooming. | 197 | | doubleTapScale | Number | `3` | The value of the image scale when a double-tap gesture is detected. | 198 | | maxPanPointers | Number | `2` | The maximum number of pointers required to enable panning. | 199 | | isPanEnabled | Boolean | `true` | Determines whether panning is enabled within the range of the minimum and maximum pan pointers. | 200 | | isPinchEnabled | Boolean | `true` | Determines whether pinching is enabled. | 201 | | isSingleTapEnabled | Boolean | `false` | Enables or disables the single tap feature. | 202 | | isDoubleTapEnabled | Boolean | `false` | Enables or disables the double tap feature. When enabled, this feature prevents automatic reset of the image zoom to its initial position, allowing continuous zooming. To return to the initial position, double tap again or zoom out to a scale level less than 1. | 203 | | onInteractionStart | Function | `undefined` | A callback triggered when the image interaction starts. | 204 | | onInteractionEnd | Function | `undefined` | A callback triggered when the image interaction ends. | 205 | | onPinchStart | Function | `undefined` | A callback triggered when the image pinching starts. | 206 | | onPinchEnd | Function | `undefined` | A callback triggered when the image pinching ends. | 207 | | onPanStart | Function | `undefined` | A callback triggered when the image panning starts. | 208 | | onPanEnd | Function | `undefined` | A callback triggered when the image panning ends. | 209 | | onSingleTap | Function | `undefined` | A callback triggered when a single tap is detected. | 210 | | onDoubleTap | Function | `undefined` | A callback triggered when a double tap gesture is detected. | 211 | | onProgrammaticZoom | Function | `undefined` | A callback function that is invoked when a programmatic zoom event occurs. | 212 | | onResetAnimationEnd | Function | `undefined` | A callback triggered upon the completion of the reset animation. It accepts two parameters: `finished` and `values`. The `finished` parameter evaluates to true if all animation values have successfully completed the reset animation; otherwise, it is false, indicating interruption by another gesture or unforeseen circumstances. The `values` parameter provides additional detailed information for each animation value. | 213 | 214 | ### ImageZoom Ref 215 | 216 | | Property | Type | Description | 217 | | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | 218 | | reset | Function | Resets the image zoom, restoring it to its initial position and scale level. | 219 | | zoom | Function | Zoom in the image to a given point (x, y) at a given scale level. Calls the reset method if the given scale level is less or equal to 1. | 220 | 221 | ## Changelog 222 | 223 | Please refer to the [Releases](https://github.com/likashefqet/react-native-image-zoom/releases) section on the GitHub repository. Each release includes a detailed list of changes made to the library, including bug fixes, new features, and any breaking changes. We recommend reviewing these changes before updating to a new version of the library to ensure a smooth transition. 224 | 225 | # Troubleshooting 226 | 227 | Not working on android? 228 | 229 | > [Usage with modals on Android](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation/#usage-with-modals-on-android) 230 | 231 | ## Author 232 | 233 |



Shefqet Lika
💻 commits
234 | 235 | 236 | 237 | ## Support 238 | 239 | For ongoing maintenance and updates, your support is greatly appreciated 240 | 241 | Buy Me A Coffee 242 | 243 | If you need further assistance, feel free to reach out to me by email at [@likashefi](mailto:likashefi@gmail.com). 244 | 245 | ## License 246 | 247 | The library is licensed under the [MIT](./LICENSE) License. 248 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/likashefqet/react-native-image-zoom/217981b5078c18b924bf8f6397e8c551cad26b3c/demo.gif -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | export { default } from './src/App'; 2 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "example", 4 | "slug": "example", 5 | "version": "1.0.0", 6 | "newArchEnabled": true, 7 | "orientation": "portrait", 8 | "icon": "./assets/icon.png", 9 | "userInterfaceStyle": "light", 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "assetBundlePatterns": ["**/*"], 16 | "ios": { 17 | "supportsTablet": true 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | } 24 | }, 25 | "web": { 26 | "favicon": "./assets/favicon.png" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/likashefqet/react-native-image-zoom/217981b5078c18b924bf8f6397e8c551cad26b3c/example/assets/adaptive-icon.png -------------------------------------------------------------------------------- /example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/likashefqet/react-native-image-zoom/217981b5078c18b924bf8f6397e8c551cad26b3c/example/assets/favicon.png -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/likashefqet/react-native-image-zoom/217981b5078c18b924bf8f6397e8c551cad26b3c/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/likashefqet/react-native-image-zoom/217981b5078c18b924bf8f6397e8c551cad26b3c/example/assets/splash.png -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pak = require('../package.json'); 3 | 4 | module.exports = function (api) { 5 | api.cache(true); 6 | 7 | return { 8 | presets: ['babel-preset-expo'], 9 | plugins: [ 10 | [ 11 | 'module-resolver', 12 | { 13 | extensions: ['.tsx', '.ts', '.js', '.json'], 14 | alias: { 15 | // For development, we want to alias the library to the source 16 | [pak.name]: path.join(__dirname, '..', pak.source), 17 | }, 18 | }, 19 | ], 20 | '@babel/plugin-proposal-export-namespace-from', 21 | [ 22 | 'react-native-reanimated/plugin', 23 | { 24 | relativeSourceLocation: true, 25 | }, 26 | ], 27 | ], 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const escape = require('escape-string-regexp'); 3 | const { getDefaultConfig } = require('@expo/metro-config'); 4 | const exclusionList = require('metro-config/src/defaults/exclusionList'); 5 | const pak = require('../package.json'); 6 | 7 | const root = path.resolve(__dirname, '..'); 8 | const modules = Object.keys({ ...pak.peerDependencies }); 9 | 10 | const defaultConfig = getDefaultConfig(__dirname); 11 | 12 | /** 13 | * Metro configuration 14 | * https://facebook.github.io/metro/docs/configuration 15 | * 16 | * @type {import('metro-config').MetroConfig} 17 | */ 18 | const config = { 19 | ...defaultConfig, 20 | 21 | projectRoot: __dirname, 22 | watchFolders: [root], 23 | 24 | // We need to make sure that only one version is loaded for peerDependencies 25 | // So we block them at the root, and alias them to the versions in example's node_modules 26 | resolver: { 27 | ...defaultConfig.resolver, 28 | 29 | blacklistRE: exclusionList( 30 | modules.map( 31 | (m) => 32 | new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`) 33 | ) 34 | ), 35 | 36 | extraNodeModules: modules.reduce((acc, name) => { 37 | acc[name] = path.join(__dirname, 'node_modules', name); 38 | return acc; 39 | }, {}), 40 | }, 41 | }; 42 | 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "yarn expo start", 7 | "android": "yarn expo start --android", 8 | "ios": "yarn expo start --ios", 9 | "web": "yarn expo start --web" 10 | }, 11 | "dependencies": { 12 | "@babel/plugin-proposal-export-namespace-from": "^7.18.9", 13 | "expo": "^52.0.7", 14 | "expo-image": "~2.0.0", 15 | "expo-status-bar": "~2.0.0", 16 | "react": "18.3.1", 17 | "react-dom": "18.3.1", 18 | "react-native": "0.76.2", 19 | "react-native-gesture-handler": "~2.20.2", 20 | "react-native-reanimated": "~3.16.1", 21 | "react-native-reanimated-carousel": "^3.5.1", 22 | "react-native-redash": "^18.1.3", 23 | "react-native-safe-area-context": "4.12.0", 24 | "react-native-svg": "15.8.0", 25 | "react-native-web": "~0.19.10" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.24.0", 29 | "@expo/webpack-config": "~19.0.1", 30 | "babel-loader": "^8.1.0", 31 | "babel-plugin-module-resolver": "^5.0.0" 32 | }, 33 | "private": true 34 | } 35 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TabScreen } from './screens/TabsScreen'; 3 | import safeAreaContextProviderHOC from './safeAreaContextProviderHOC'; 4 | import { gestureHandlerRootHOC } from 'react-native-gesture-handler'; 5 | 6 | function App() { 7 | return ; 8 | } 9 | 10 | export default safeAreaContextProviderHOC(gestureHandlerRootHOC(App)); 11 | -------------------------------------------------------------------------------- /example/src/components/AnimatedCircle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { StyleSheet, View, ViewProps } from 'react-native'; 3 | import Animated, { 4 | AnimateProps, 5 | SharedValue, 6 | useAnimatedProps, 7 | useDerivedValue, 8 | } from 'react-native-reanimated'; 9 | import { ReText } from 'react-native-redash'; 10 | import Svg, { Circle } from 'react-native-svg'; 11 | 12 | import { COLORS } from '../themes/colors'; 13 | 14 | const AnimatedSvgCircle = Animated.createAnimatedComponent(Circle); 15 | const AnimatedView = Animated.createAnimatedComponent(View); 16 | 17 | type AnimatedCircleProps = AnimateProps & { 18 | scale: SharedValue; 19 | minScale: number; 20 | maxScale: number; 21 | size: number; 22 | }; 23 | 24 | export const AnimatedCircle = ({ 25 | scale, 26 | minScale, 27 | maxScale, 28 | size, 29 | ...viewProps 30 | }: AnimatedCircleProps) => { 31 | const halfSize = size / 2; 32 | const circleLength = useMemo( 33 | () => 2 * Math.PI * (halfSize - 2.5), 34 | [halfSize] 35 | ); 36 | const text = useDerivedValue( 37 | () => `${(Math.round(scale.value * 10) / 10).toFixed(1)}` 38 | ); 39 | const animatedProps = useAnimatedProps(() => ({ 40 | strokeDashoffset: 41 | circleLength * (1 - (scale.value - minScale) / (maxScale - minScale)), 42 | })); 43 | 44 | return ( 45 | 46 | 47 | 57 | 68 | 69 | 73 | 74 | ); 75 | }; 76 | 77 | const styles = StyleSheet.create({ 78 | progressText: { 79 | position: 'absolute', 80 | top: 8, 81 | right: 8, 82 | bottom: 8, 83 | left: 8, 84 | fontSize: 16, 85 | color: COLORS.mainLightAlpha(), 86 | textAlign: 'center', 87 | verticalAlign: 'middle', 88 | backgroundColor: COLORS.mainDarkAlpha(0.16), 89 | zIndex: -1, 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /example/src/components/ExpoImageZoom.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ForwardRefRenderFunction, 3 | ForwardedRef, 4 | forwardRef, 5 | } from 'react'; 6 | import { StyleSheet } from 'react-native'; 7 | import Animated, { 8 | FadeIn, 9 | FadeOut, 10 | Layout, 11 | SharedValue, 12 | } from 'react-native-reanimated'; 13 | import { Image } from 'expo-image'; 14 | import { ZOOM_TYPE, Zoomable, ZoomableProps, ZoomableRef } from '../../../src'; 15 | 16 | const AnimatedImage = Animated.createAnimatedComponent(Image); 17 | 18 | const styles = StyleSheet.create({ 19 | image: { 20 | flex: 1, 21 | overflow: 'hidden', 22 | }, 23 | }); 24 | 25 | type Props = { 26 | uri: string; 27 | scale?: SharedValue; 28 | minScale?: number; 29 | maxScale?: number; 30 | ref: ForwardedRef; 31 | setIsZoomed: (value: boolean) => void; 32 | style?: ZoomableProps['style']; 33 | }; 34 | 35 | const ExpoImageZoom: ForwardRefRenderFunction = ( 36 | { uri, scale, minScale = 0.5, maxScale = 5, setIsZoomed, style }, 37 | ref 38 | ) => { 39 | const onZoom = (zoomType?: ZOOM_TYPE) => { 40 | if (!zoomType || zoomType === ZOOM_TYPE.ZOOM_IN) { 41 | setIsZoomed(true); 42 | } 43 | }; 44 | 45 | const onAnimationEnd = (finished?: boolean) => { 46 | if (finished) { 47 | setIsZoomed(false); 48 | } 49 | }; 50 | 51 | return ( 52 | { 64 | console.log('onInteractionStart'); 65 | onZoom(); 66 | }} 67 | onInteractionEnd={() => console.log('onInteractionEnd')} 68 | onPanStart={() => console.log('onPanStart')} 69 | onPanEnd={() => console.log('onPanEnd')} 70 | onPinchStart={() => console.log('onPinchStart')} 71 | onPinchEnd={() => console.log('onPinchEnd')} 72 | onSingleTap={() => console.log('onSingleTap')} 73 | onDoubleTap={(zoomType) => { 74 | console.log('onDoubleTap', zoomType); 75 | onZoom(zoomType); 76 | }} 77 | onProgrammaticZoom={(zoomType) => { 78 | console.log('onZoom', zoomType); 79 | onZoom(zoomType); 80 | }} 81 | style={[styles.image, style]} 82 | onResetAnimationEnd={(finished, values) => { 83 | console.log('onResetAnimationEnd', finished); 84 | console.log('lastScaleValue:', values?.SCALE.lastValue); 85 | onAnimationEnd(finished); 86 | }} 87 | > 88 | 96 | {/* Without Layout Animations 97 | 102 | */} 103 | 104 | ); 105 | }; 106 | 107 | export default forwardRef(ExpoImageZoom); 108 | -------------------------------------------------------------------------------- /example/src/components/ImageZoom.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ForwardRefRenderFunction, 3 | ForwardedRef, 4 | forwardRef, 5 | } from 'react'; 6 | import { StyleSheet } from 'react-native'; 7 | import { FadeIn, FadeOut, Layout, SharedValue } from 'react-native-reanimated'; 8 | import { 9 | ImageZoom as RNImagedZoom, 10 | ZOOM_TYPE, 11 | ImageZoomRef, 12 | ImageZoomProps, 13 | } from '../../../src'; 14 | 15 | const styles = StyleSheet.create({ 16 | image: { flex: 1 }, 17 | }); 18 | 19 | type Props = { 20 | uri: string; 21 | scale?: SharedValue; 22 | minScale?: number; 23 | maxScale?: number; 24 | ref: ForwardedRef; 25 | setIsZoomed: (value: boolean) => void; 26 | style?: ImageZoomProps['style']; 27 | }; 28 | const ImageZoom: ForwardRefRenderFunction = ( 29 | { uri, scale, minScale = 0.5, maxScale = 5, setIsZoomed, style }, 30 | ref 31 | ) => { 32 | const onZoom = (zoomType?: ZOOM_TYPE) => { 33 | if (!zoomType || zoomType === ZOOM_TYPE.ZOOM_IN) { 34 | setIsZoomed(true); 35 | } 36 | }; 37 | 38 | const onAnimationEnd = (finished?: boolean) => { 39 | if (finished) { 40 | setIsZoomed(false); 41 | } 42 | }; 43 | 44 | return ( 45 | { 58 | console.log('onInteractionStart'); 59 | onZoom(); 60 | }} 61 | onInteractionEnd={() => console.log('onInteractionEnd')} 62 | onPanStart={() => console.log('onPanStart')} 63 | onPanEnd={() => console.log('onPanEnd')} 64 | onPinchStart={() => console.log('onPinchStart')} 65 | onPinchEnd={() => console.log('onPinchEnd')} 66 | onSingleTap={() => console.log('onSingleTap')} 67 | onDoubleTap={(zoomType) => { 68 | console.log('onDoubleTap', zoomType); 69 | onZoom(zoomType); 70 | }} 71 | onProgrammaticZoom={(zoomType) => { 72 | console.log('onZoom', zoomType); 73 | onZoom(zoomType); 74 | }} 75 | style={[styles.image, style]} 76 | onResetAnimationEnd={(finished, values) => { 77 | console.log('onResetAnimationEnd', finished); 78 | console.log('lastScaleValue:', values?.SCALE.lastValue); 79 | onAnimationEnd(finished); 80 | }} 81 | resizeMode="cover" 82 | /> 83 | ); 84 | }; 85 | 86 | export default forwardRef(ImageZoom); 87 | -------------------------------------------------------------------------------- /example/src/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useState } from 'react'; 2 | import { Pressable, StyleSheet, Text } from 'react-native'; 3 | import Animated, { 4 | FadeOutUp, 5 | FadeInDown, 6 | Layout, 7 | } from 'react-native-reanimated'; 8 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 9 | import { COLORS } from '../themes/colors'; 10 | import { FadeOutDown } from 'react-native-reanimated'; 11 | 12 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 13 | 14 | type TabProps = { 15 | isActive: boolean; 16 | isMenuVisible: boolean; 17 | }; 18 | 19 | export const Tab = ({ 20 | isActive, 21 | isMenuVisible, 22 | children, 23 | }: PropsWithChildren) => { 24 | const { top } = useSafeAreaInsets(); 25 | 26 | if (!isActive) { 27 | return null; 28 | } 29 | 30 | return ( 31 | 43 | {children} 44 | 45 | ); 46 | }; 47 | 48 | type TabsProps = { 49 | tabs: { 50 | name: string; 51 | component: React.ReactNode; 52 | }[]; 53 | isMenuVisible: boolean; 54 | renderMenu?: () => React.ReactNode | undefined; 55 | }; 56 | 57 | export const Tabs = ({ tabs, isMenuVisible, renderMenu }: TabsProps) => { 58 | const { bottom } = useSafeAreaInsets(); 59 | const [activeIndex, setActiveIndex] = useState(0); 60 | 61 | return ( 62 | <> 63 | {tabs.map(({ component, name }, index) => ( 64 | 69 | {component} 70 | 71 | ))} 72 | 76 | {renderMenu && renderMenu()} 77 | {isMenuVisible && ( 78 | 87 | {tabs.map(({ name }, index) => ( 88 | setActiveIndex(index)} 94 | style={[ 95 | styles.itemPressable, 96 | index === activeIndex && styles.itemPressableActive, 97 | ]} 98 | > 99 | {name} 100 | 101 | ))} 102 | 103 | )} 104 | 105 | 106 | ); 107 | }; 108 | 109 | const styles = StyleSheet.create({ 110 | tabContainer: { 111 | flex: 1, 112 | overflow: 'hidden', 113 | }, 114 | tabContainerMenuActive: { 115 | marginHorizontal: 15, 116 | borderRadius: 30, 117 | borderWidth: 2, 118 | borderColor: COLORS.mainDark, 119 | }, 120 | itemsContainer: { 121 | flex: 1, 122 | justifyContent: 'space-between', 123 | paddingHorizontal: 16, 124 | paddingTop: 16, 125 | }, 126 | itemPressable: { 127 | borderWidth: 1, 128 | borderColor: COLORS.mainDark, 129 | alignItems: 'center', 130 | justifyContent: 'center', 131 | paddingHorizontal: 16, 132 | paddingVertical: 16, 133 | borderRadius: 25, 134 | }, 135 | itemPressableActive: { 136 | borderColor: COLORS.accent, 137 | }, 138 | itemText: { 139 | color: COLORS.white, 140 | }, 141 | }); 142 | -------------------------------------------------------------------------------- /example/src/safeAreaContextProviderHOC.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, StyleProp, ViewStyle } from 'react-native'; 3 | import { SafeAreaProvider } from 'react-native-safe-area-context'; 4 | 5 | export default function safeAreaContextProviderHOC

( 6 | Component: React.ComponentType

, 7 | containerStyles?: StyleProp 8 | ): React.ComponentType

{ 9 | function Wrapper(props: P) { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | Wrapper.displayName = `safeAreaContextProviderHOC(${ 18 | Component.displayName || Component.name 19 | })`; 20 | 21 | return Wrapper; 22 | } 23 | 24 | const styles = StyleSheet.create({ 25 | container: { flex: 1, backgroundColor: 'black' }, 26 | }); 27 | -------------------------------------------------------------------------------- /example/src/screens/CarouselTab.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, useWindowDimensions, View } from 'react-native'; 3 | import Animated, { 4 | interpolate, 5 | interpolateColor, 6 | useAnimatedStyle, 7 | } from 'react-native-reanimated'; 8 | import Carousel from 'react-native-reanimated-carousel'; 9 | import ImageZoom from '../components/ImageZoom'; 10 | import { COLORS } from '../themes/colors'; 11 | 12 | const DEFAULT_IMAGE_URI = 13 | 'https://images.unsplash.com/photo-1596003906949-67221c37965c'; // https://unsplash.com/@walling 14 | const IMAGE_URLS = [ 15 | 'https://images.unsplash.com/photo-1502318217862-aa4e294ba657', // https://unsplash.com/@n8rayfield 16 | 'https://images.unsplash.com/photo-1489549132488-d00b7eee80f1', // https://unsplash.com/@jdiegoph 17 | 'https://images.unsplash.com/photo-1522441815192-d9f04eb0615c', // https://unsplash.com/@resul 18 | 'https://images.unsplash.com/photo-1534229317157-f846a08d8b73', // https://unsplash.com/@mischievous_penguins 19 | 'https://images.unsplash.com/photo-1516690553959-71a414d6b9b6', // https://unsplash.com/@jordansteranka 20 | 'https://images.unsplash.com/photo-1545243424-0ce743321e11', // https://unsplash.com/@therawhunter 21 | ]; 22 | 23 | const data = IMAGE_URLS; 24 | 25 | interface ItemProps { 26 | index: number; 27 | animationValue: Animated.SharedValue; 28 | setIsZoomed: (value: boolean) => void; 29 | } 30 | 31 | const CustomItem: React.FC = ({ 32 | index, 33 | animationValue, 34 | setIsZoomed, 35 | }) => { 36 | const maskStyle = useAnimatedStyle(() => { 37 | const backgroundColor = interpolateColor( 38 | animationValue.value, 39 | [-1, 0, 1], 40 | ['#000000dd', 'transparent', '#000000dd'] 41 | ); 42 | 43 | return { 44 | backgroundColor, 45 | }; 46 | }, [animationValue]); 47 | 48 | return ( 49 | 50 | 54 | 58 | 59 | ); 60 | }; 61 | export const CarouselTab = () => { 62 | const { width } = useWindowDimensions(); 63 | const [isZoomed, setIsZoomed] = React.useState(false); 64 | const customAnimation = React.useCallback( 65 | (value: number) => { 66 | 'worklet'; 67 | 68 | const zIndex = interpolate(value, [-1, 0, 1], [10, 20, 30]); 69 | const translateX = interpolate(value, [-2, 0, 1], [-width, 0, width]); 70 | 71 | return { 72 | transform: [{ translateX }], 73 | zIndex, 74 | }; 75 | }, 76 | [width] 77 | ); 78 | 79 | return ( 80 | { 87 | return ( 88 | 94 | ); 95 | }} 96 | customAnimation={customAnimation} 97 | scrollAnimationDuration={1200} 98 | enabled={!isZoomed} 99 | /> 100 | ); 101 | }; 102 | 103 | const styles = StyleSheet.create({ 104 | container: { 105 | flex: 1, 106 | backgroundColor: COLORS.black, 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /example/src/screens/ExpoImageZoomTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { StyleSheet, View, Pressable, Text, Alert } from 'react-native'; 3 | import Animated, { 4 | useSharedValue, 5 | FadeIn, 6 | FadeOut, 7 | Layout, 8 | useAnimatedStyle, 9 | } from 'react-native-reanimated'; 10 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 11 | import { ZoomableRef } from '../../../src'; 12 | import { AnimatedCircle } from '../components/AnimatedCircle'; 13 | import ExpoImageZoom from '../components/ExpoImageZoom'; 14 | import { COLORS } from '../themes/colors'; 15 | 16 | // Photo by Walling [https://unsplash.com/photos/XLqiL-rz4V8] on Unsplash [https://unsplash.com/] 17 | const IMAGE_URI = 18 | 'https://images.unsplash.com/photo-1596003906949-67221c37965c'; 19 | const MIN_SCALE = 0.5; 20 | const MAX_SCALE = 5; 21 | const ZOOM_IN_X = 146; 22 | const ZOOM_IN_Y = 491; 23 | 24 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 25 | 26 | export const ExpoImageZoomTab = () => { 27 | const zoomableRef = useRef(null); 28 | const { top, bottom } = useSafeAreaInsets(); 29 | 30 | const scale = useSharedValue(1); 31 | 32 | const [isZoomed, setIsZoomed] = useState(false); 33 | 34 | const zoomIn = () => { 35 | zoomableRef?.current?.zoom({ scale: 5, x: ZOOM_IN_X, y: ZOOM_IN_Y }); 36 | }; 37 | const zoomOut = () => { 38 | zoomableRef?.current?.reset(); 39 | }; 40 | 41 | const getInfo = () => { 42 | const info = zoomableRef?.current?.getInfo(); 43 | Alert.alert('Info', JSON.stringify(info, null, 2)); 44 | }; 45 | 46 | const animatedStyle = useAnimatedStyle( 47 | () => ({ 48 | borderRadius: 30 / scale.value, 49 | }), 50 | [scale] 51 | ); 52 | 53 | return ( 54 | 55 | 64 | {isZoomed ? ( 65 | <> 66 | 73 | Zoom Out 74 | 75 | 85 | 86 | ) : ( 87 | <> 88 | 89 | 96 | Zoom In the 🟡 Circle 97 | 98 | 99 | )} 100 | 107 | Info 108 | 109 | 110 | ); 111 | }; 112 | 113 | const styles = StyleSheet.create({ 114 | container: { 115 | flex: 1, 116 | }, 117 | button: { 118 | position: 'absolute', 119 | zIndex: 10, 120 | right: 8, 121 | height: 40, 122 | justifyContent: 'center', 123 | backgroundColor: COLORS.mainDarkAlpha(0.16), 124 | paddingHorizontal: 16, 125 | paddingVertical: 8, 126 | borderWidth: 2, 127 | borderRadius: 20, 128 | borderColor: COLORS.accent, 129 | }, 130 | leftButton: { 131 | right: undefined, 132 | left: 8, 133 | }, 134 | zoomInCircle: { 135 | position: 'absolute', 136 | top: ZOOM_IN_Y, 137 | left: ZOOM_IN_X, 138 | width: 24, 139 | height: 24, 140 | borderWidth: 2, 141 | borderRadius: 12, 142 | borderColor: COLORS.accent, 143 | transform: [{ translateX: -12 }, { translateY: -12 }], 144 | }, 145 | buttonText: { 146 | fontWeight: 'bold', 147 | color: COLORS.white, 148 | }, 149 | progressCircle: { 150 | position: 'absolute', 151 | left: 16, 152 | bottom: 16, 153 | }, 154 | }); 155 | -------------------------------------------------------------------------------- /example/src/screens/ImageZoomTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { StyleSheet, View, Pressable, Text, Alert } from 'react-native'; 3 | import Animated, { 4 | useSharedValue, 5 | FadeIn, 6 | FadeOut, 7 | useAnimatedStyle, 8 | Layout, 9 | } from 'react-native-reanimated'; 10 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 11 | import { ImageZoomRef } from '../../../src'; 12 | import { AnimatedCircle } from '../components/AnimatedCircle'; 13 | import ImageZoom from '../components/ImageZoom'; 14 | import { COLORS } from '../themes/colors'; 15 | 16 | // Photo by Walling [https://unsplash.com/photos/XLqiL-rz4V8] on Unsplash [https://unsplash.com/] 17 | const IMAGE_URI = 18 | 'https://images.unsplash.com/photo-1596003906949-67221c37965c'; 19 | const MIN_SCALE = 0.5; 20 | const MAX_SCALE = 5; 21 | const ZOOM_IN_X = 146; 22 | const ZOOM_IN_Y = 491; 23 | 24 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 25 | 26 | export const ImageZoomTab = () => { 27 | const imageZoomRef = useRef(null); 28 | const { top, bottom } = useSafeAreaInsets(); 29 | 30 | const scale = useSharedValue(1); 31 | 32 | const [isZoomed, setIsZoomed] = useState(false); 33 | 34 | const zoomIn = () => { 35 | imageZoomRef?.current?.zoom({ scale: 5, x: ZOOM_IN_X, y: ZOOM_IN_Y }); 36 | }; 37 | const zoomOut = () => { 38 | imageZoomRef?.current?.reset(); 39 | }; 40 | 41 | const getInfo = () => { 42 | const info = imageZoomRef?.current?.getInfo(); 43 | Alert.alert('Info', JSON.stringify(info, null, 2)); 44 | }; 45 | 46 | const animatedStyle = useAnimatedStyle( 47 | () => ({ 48 | borderRadius: 30 / scale.value, 49 | }), 50 | [scale] 51 | ); 52 | 53 | return ( 54 | 55 | 64 | {isZoomed ? ( 65 | <> 66 | 73 | Zoom Out 74 | 75 | 85 | 86 | ) : ( 87 | <> 88 | 89 | 96 | Zoom In the 🟡 Circle 97 | 98 | 99 | )} 100 | 107 | Info 108 | 109 | 110 | ); 111 | }; 112 | 113 | const styles = StyleSheet.create({ 114 | container: { 115 | flex: 1, 116 | }, 117 | button: { 118 | position: 'absolute', 119 | zIndex: 10, 120 | right: 8, 121 | height: 40, 122 | justifyContent: 'center', 123 | backgroundColor: COLORS.mainDarkAlpha(0.16), 124 | paddingHorizontal: 16, 125 | paddingVertical: 8, 126 | borderWidth: 2, 127 | borderRadius: 20, 128 | borderColor: COLORS.accent, 129 | }, 130 | leftButton: { 131 | right: undefined, 132 | left: 8, 133 | }, 134 | zoomInCircle: { 135 | position: 'absolute', 136 | top: ZOOM_IN_Y, 137 | left: ZOOM_IN_X, 138 | width: 24, 139 | height: 24, 140 | borderWidth: 2, 141 | borderRadius: 12, 142 | borderColor: COLORS.accent, 143 | transform: [{ translateX: -12 }, { translateY: -12 }], 144 | }, 145 | buttonText: { 146 | fontWeight: 'bold', 147 | color: COLORS.white, 148 | }, 149 | progressCircle: { 150 | position: 'absolute', 151 | left: 16, 152 | bottom: 16, 153 | }, 154 | }); 155 | -------------------------------------------------------------------------------- /example/src/screens/TabsScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Pressable, StyleSheet, Text } from 'react-native'; 3 | import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated'; 4 | import { Tabs } from '../components/Tabs'; 5 | import { COLORS } from '../themes/colors'; 6 | import { CarouselTab } from './CarouselTab'; 7 | import { ExpoImageZoomTab } from './ExpoImageZoomTab'; 8 | import { ImageZoomTab } from './ImageZoomTab'; 9 | 10 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 11 | 12 | export const TabScreen = () => { 13 | const [isMenuVisible, setIsMenuVisible] = useState(false); 14 | const toggleMenu = () => { 15 | setIsMenuVisible((current) => !current); 16 | }; 17 | return ( 18 | <> 19 | , 24 | }, 25 | { 26 | name: 'Expo Image Zoom', 27 | component: , 28 | }, 29 | { 30 | name: 'Carousel', 31 | component: , 32 | }, 33 | ]} 34 | renderMenu={() => ( 35 | 42 | {isMenuVisible ? '×' : '≡'} 43 | 44 | )} 45 | isMenuVisible={isMenuVisible} 46 | /> 47 | 48 | ); 49 | }; 50 | 51 | const styles = StyleSheet.create({ 52 | menuPressable: { 53 | width: 50, 54 | height: 50, 55 | alignItems: 'center', 56 | justifyContent: 'center', 57 | backgroundColor: COLORS.mainDarkAlpha(0.16), 58 | borderWidth: 2, 59 | borderRadius: 25, 60 | borderColor: COLORS.white, 61 | marginTop: 16, 62 | marginRight: 16, 63 | alignSelf: 'flex-end', 64 | }, 65 | menuText: { 66 | fontSize: 28, 67 | lineHeight: 28, 68 | color: COLORS.white, 69 | verticalAlign: 'middle', 70 | includeFontPadding: false, 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /example/src/themes/colors.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | /** #FFFF00 Yellow */ 3 | accent: 'yellow', 4 | /** #FFFFFF White */ 5 | white: 'white', 6 | /** #000000 Black */ 7 | black: 'black', 8 | /** #1D1E2C Raisin Black */ 9 | mainDark: '#1D1E2C', 10 | /** 11 | * #F1FFFA Mint Cream 12 | * @param alpha `number` 13 | * @defaultValue `1` 14 | * @returns `rgba(242, 255, 250, ${alpha})` 15 | */ 16 | mainLightAlpha: (alpha = 1) => `rgba(242, 255, 250, ${alpha})`, 17 | /** 18 | * #1D1E2C Raisin Black 19 | * @param alpha `number` 20 | * @defaultValue `1` 21 | * @returns `rgba(29, 30, 44, ${alpha})` 22 | */ 23 | mainDarkAlpha: (alpha = 1) => `rgba(29, 30, 44, ${alpha})`, 24 | }; 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | // Avoid expo-cli auto-generating a tsconfig 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const createExpoWebpackConfigAsync = require('@expo/webpack-config'); 3 | const { resolver } = require('./metro.config'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const node_modules = path.join(__dirname, 'node_modules'); 7 | 8 | module.exports = async function (env, argv) { 9 | const config = await createExpoWebpackConfigAsync(env, argv); 10 | 11 | config.module.rules.push({ 12 | test: /\.(js|jsx|ts|tsx)$/, 13 | include: path.resolve(root, 'src'), 14 | use: 'babel-loader', 15 | }); 16 | 17 | // We need to make sure that only one version is loaded for peerDependencies 18 | // So we alias them to the versions in example's node_modules 19 | Object.assign(config.resolve.alias, { 20 | ...resolver.extraNodeModules, 21 | 'react-native-web': path.join(node_modules, 'react-native-web'), 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | lint: 5 | files: git diff --name-only @{push} 6 | glob: "*.{js,ts,jsx,tsx}" 7 | run: npx eslint {files} 8 | types: 9 | files: git diff --name-only @{push} 10 | glob: "*.{js,ts, jsx, tsx}" 11 | run: npx tsc --noEmit 12 | commit-msg: 13 | parallel: true 14 | commands: 15 | commitlint: 16 | run: npx commitlint --edit 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@likashefqet/react-native-image-zoom", 3 | "version": "4.2.1", 4 | "description": "A performant zoomable image written in Reanimated v2+ 🚀", 5 | "main": "lib/commonjs/index", 6 | "module": "lib/module/index", 7 | "types": "lib/typescript/index.d.ts", 8 | "react-native": "src/index", 9 | "source": "src/index", 10 | "files": [ 11 | "src", 12 | "lib", 13 | "android", 14 | "ios", 15 | "cpp", 16 | "*.podspec", 17 | "!lib/typescript/example", 18 | "!ios/build", 19 | "!android/build", 20 | "!android/gradle", 21 | "!android/gradlew", 22 | "!android/gradlew.bat", 23 | "!android/local.properties", 24 | "!**/__tests__", 25 | "!**/__fixtures__", 26 | "!**/__mocks__", 27 | "!**/.*" 28 | ], 29 | "scripts": { 30 | "test": "jest", 31 | "typecheck": "tsc --noEmit", 32 | "lint": "eslint \"**/*.{js,ts,tsx}\"", 33 | "prepack": "bob build", 34 | "release": "release-it", 35 | "example": "yarn --cwd example", 36 | "bootstrap": "yarn example && yarn install" 37 | }, 38 | "keywords": [ 39 | "photo", 40 | "image", 41 | "picture", 42 | "zoom", 43 | "pinch", 44 | "pan", 45 | "reanimated", 46 | "gesture", 47 | "instagram", 48 | "react", 49 | "react-native", 50 | "react-native-image-zoom", 51 | "react-native-zoom", 52 | "react-native-image", 53 | "image-zoom", 54 | "zoom-image", 55 | "zoomable-image", 56 | "zoomable", 57 | "javascript", 58 | "ui-lib", 59 | "rn", 60 | "likashefqet", 61 | "likashefi" 62 | ], 63 | "repository": { 64 | "type": "git", 65 | "url": "git+https://github.com/likashefqet/react-native-image-zoom.git" 66 | }, 67 | "author": "Shefqet Lika (https://github.com/likashefqet)", 68 | "license": "MIT", 69 | "bugs": { 70 | "url": "https://github.com/likashefqet/react-native-image-zoom/issues" 71 | }, 72 | "homepage": "https://github.com/likashefqet/react-native-image-zoom#readme", 73 | "publishConfig": { 74 | "registry": "https://registry.npmjs.org/" 75 | }, 76 | "devDependencies": { 77 | "@commitlint/config-conventional": "^17.0.2", 78 | "@evilmartians/lefthook": "^1.8.2", 79 | "@react-native-community/eslint-config": "^3.0.2", 80 | "@release-it/conventional-changelog": "^5.0.0", 81 | "@types/jest": "^28.1.2", 82 | "@types/react": "~17.0.21", 83 | "@types/react-native": "0.70.0", 84 | "commitlint": "^17.0.2", 85 | "del-cli": "^5.0.0", 86 | "eslint": "^8.4.1", 87 | "eslint-config-prettier": "^8.5.0", 88 | "eslint-plugin-prettier": "^4.0.0", 89 | "jest": "^28.1.1", 90 | "pod-install": "^0.1.0", 91 | "prettier": "^2.0.5", 92 | "react": "18.2.0", 93 | "react-native": "0.72.3", 94 | "react-native-builder-bob": "^0.20.0", 95 | "react-native-gesture-handler": "^2.12.1", 96 | "react-native-reanimated": "~3.3.0", 97 | "release-it": "^15.0.0", 98 | "typescript": "^5.0.2" 99 | }, 100 | "resolutions": { 101 | "@types/react": "17.0.21" 102 | }, 103 | "peerDependencies": { 104 | "react": ">=16.x.x", 105 | "react-native": ">=0.62.x", 106 | "react-native-gesture-handler": ">=2.x.x", 107 | "react-native-reanimated": ">=2.x.x" 108 | }, 109 | "engines": { 110 | "node": ">= 16.0.0" 111 | }, 112 | "jest": { 113 | "preset": "react-native", 114 | "modulePathIgnorePatterns": [ 115 | "/example/node_modules", 116 | "/lib/" 117 | ] 118 | }, 119 | "commitlint": { 120 | "extends": [ 121 | "@commitlint/config-conventional" 122 | ] 123 | }, 124 | "release-it": { 125 | "git": { 126 | "commitMessage": "chore: release ${version}", 127 | "tagName": "v${version}" 128 | }, 129 | "npm": { 130 | "publish": true 131 | }, 132 | "github": { 133 | "release": true 134 | }, 135 | "plugins": { 136 | "@release-it/conventional-changelog": { 137 | "preset": "angular" 138 | } 139 | } 140 | }, 141 | "eslintConfig": { 142 | "root": true, 143 | "extends": [ 144 | "@react-native-community", 145 | "prettier" 146 | ], 147 | "rules": { 148 | "prettier/prettier": [ 149 | "error", 150 | { 151 | "quoteProps": "consistent", 152 | "singleQuote": true, 153 | "tabWidth": 2, 154 | "trailingComma": "es5", 155 | "useTabs": false 156 | } 157 | ] 158 | } 159 | }, 160 | "eslintIgnore": [ 161 | "node_modules/", 162 | "lib/" 163 | ], 164 | "prettier": { 165 | "quoteProps": "consistent", 166 | "singleQuote": true, 167 | "tabWidth": 2, 168 | "trailingComma": "es5", 169 | "useTabs": false 170 | }, 171 | "react-native-builder-bob": { 172 | "source": "src", 173 | "output": "lib", 174 | "targets": [ 175 | "commonjs", 176 | "module", 177 | [ 178 | "typescript", 179 | { 180 | "project": "tsconfig.build.json" 181 | } 182 | ] 183 | ] 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /scripts/bootstrap.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const child_process = require('child_process'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const args = process.argv.slice(2); 7 | const options = { 8 | cwd: process.cwd(), 9 | env: process.env, 10 | stdio: 'inherit', 11 | encoding: 'utf-8', 12 | }; 13 | 14 | if (os.type() === 'Windows_NT') { 15 | options.shell = true; 16 | } 17 | 18 | let result; 19 | 20 | if (process.cwd() !== root || args.length) { 21 | // We're not in the root of the project, or additional arguments were passed 22 | // In this case, forward the command to `yarn` 23 | result = child_process.spawnSync('yarn', args, options); 24 | } else { 25 | // If `yarn` is run without arguments, perform bootstrap 26 | result = child_process.spawnSync('yarn', ['bootstrap'], options); 27 | } 28 | 29 | process.exitCode = result.status; 30 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | it.todo('write a test'); 2 | -------------------------------------------------------------------------------- /src/components/ImageZoom.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, ForwardRefRenderFunction } from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import { GestureDetector } from 'react-native-gesture-handler'; 4 | import Animated from 'react-native-reanimated'; 5 | import { useZoomable } from '../hooks/useZoomable'; 6 | import type { ImageZoomProps, ImageZoomRef } from '../types'; 7 | 8 | const styles = StyleSheet.create({ 9 | image: { 10 | flex: 1, 11 | }, 12 | }); 13 | 14 | const Zoomable: ForwardRefRenderFunction = ( 15 | { 16 | uri = '', 17 | minScale, 18 | maxScale, 19 | scale, 20 | doubleTapScale, 21 | maxPanPointers, 22 | isPanEnabled, 23 | isPinchEnabled, 24 | isSingleTapEnabled, 25 | isDoubleTapEnabled, 26 | onInteractionStart, 27 | onInteractionEnd, 28 | onPinchStart, 29 | onPinchEnd, 30 | onPanStart, 31 | onPanEnd, 32 | onSingleTap, 33 | onDoubleTap, 34 | onProgrammaticZoom, 35 | onResetAnimationEnd, 36 | onLayout, 37 | style = {}, 38 | ...props 39 | }, 40 | ref 41 | ) => { 42 | const { animatedStyle, gestures, onZoomableLayout } = useZoomable({ 43 | minScale, 44 | maxScale, 45 | scale, 46 | doubleTapScale, 47 | maxPanPointers, 48 | isPanEnabled, 49 | isPinchEnabled, 50 | isSingleTapEnabled, 51 | isDoubleTapEnabled, 52 | onInteractionStart, 53 | onInteractionEnd, 54 | onPinchStart, 55 | onPinchEnd, 56 | onPanStart, 57 | onPanEnd, 58 | onSingleTap, 59 | onDoubleTap, 60 | onProgrammaticZoom, 61 | onResetAnimationEnd, 62 | onLayout, 63 | ref, 64 | }); 65 | 66 | return ( 67 | 68 | 75 | 76 | ); 77 | }; 78 | 79 | export default forwardRef(Zoomable); 80 | -------------------------------------------------------------------------------- /src/components/Zoomable.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, ForwardRefRenderFunction } from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import { GestureDetector } from 'react-native-gesture-handler'; 4 | import Animated from 'react-native-reanimated'; 5 | import { useZoomable } from '../hooks/useZoomable'; 6 | import type { ZoomableProps, ZoomableRef } from '../types'; 7 | 8 | const styles = StyleSheet.create({ 9 | container: { 10 | flex: 1, 11 | }, 12 | }); 13 | 14 | const Zoomable: ForwardRefRenderFunction = ( 15 | { 16 | minScale, 17 | maxScale, 18 | scale, 19 | doubleTapScale, 20 | maxPanPointers, 21 | isPanEnabled, 22 | isPinchEnabled, 23 | isSingleTapEnabled, 24 | isDoubleTapEnabled, 25 | onInteractionStart, 26 | onInteractionEnd, 27 | onPinchStart, 28 | onPinchEnd, 29 | onPanStart, 30 | onPanEnd, 31 | onSingleTap, 32 | onDoubleTap, 33 | onProgrammaticZoom, 34 | onResetAnimationEnd, 35 | onLayout, 36 | style = {}, 37 | children, 38 | ...props 39 | }, 40 | ref 41 | ) => { 42 | const { animatedStyle, gestures, onZoomableLayout } = useZoomable({ 43 | minScale, 44 | maxScale, 45 | scale, 46 | doubleTapScale, 47 | maxPanPointers, 48 | isPanEnabled, 49 | isPinchEnabled, 50 | isSingleTapEnabled, 51 | isDoubleTapEnabled, 52 | onInteractionStart, 53 | onInteractionEnd, 54 | onPinchStart, 55 | onPinchEnd, 56 | onPanStart, 57 | onPanEnd, 58 | onSingleTap, 59 | onDoubleTap, 60 | onProgrammaticZoom, 61 | onResetAnimationEnd, 62 | onLayout, 63 | ref, 64 | }); 65 | 66 | return ( 67 | 68 | 73 | {children} 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default forwardRef(Zoomable); 80 | -------------------------------------------------------------------------------- /src/hooks/useAnimationEnd.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { 3 | AnimatableValue, 4 | AnimationCallback, 5 | runOnJS, 6 | useSharedValue, 7 | } from 'react-native-reanimated'; 8 | import { ANIMATION_VALUE, type OnResetAnimationEndCallback } from '../types'; 9 | 10 | export type OnAnimationEndCallback = AnimationCallback extends ( 11 | ...a: infer I 12 | ) => infer O 13 | ? ( 14 | interactionId: string, 15 | value: ANIMATION_VALUE, 16 | lastValue: number, 17 | ...a: I 18 | ) => O 19 | : never; 20 | 21 | type EndValues = Record< 22 | ANIMATION_VALUE, 23 | { 24 | lastValue: number; 25 | finished?: boolean; 26 | current?: AnimatableValue; 27 | } 28 | >; 29 | type PartialEndValues = Partial; 30 | type InteractionEndValues = Record; 31 | 32 | const ANIMATION_VALUES = [ 33 | ANIMATION_VALUE.SCALE, 34 | ANIMATION_VALUE.FOCAL_X, 35 | ANIMATION_VALUE.FOCAL_Y, 36 | ANIMATION_VALUE.TRANSLATE_X, 37 | ANIMATION_VALUE.TRANSLATE_Y, 38 | ]; 39 | 40 | const isAnimationComplete = ( 41 | endValues: PartialEndValues 42 | ): endValues is EndValues => { 43 | 'worklet'; 44 | return ANIMATION_VALUES.every((item) => !!endValues[item]); 45 | }; 46 | 47 | export const useAnimationEnd = ( 48 | onResetAnimationEnd?: OnResetAnimationEndCallback 49 | ) => { 50 | const endValues = useSharedValue({}); 51 | 52 | const onAnimationEnd: OnAnimationEndCallback = useCallback( 53 | (interactionId, value, lastValue, finished, current) => { 54 | 'worklet'; 55 | if (onResetAnimationEnd) { 56 | const currentEndValues = endValues.value[interactionId] || {}; 57 | currentEndValues[value] = { lastValue, finished, current }; 58 | if (isAnimationComplete(currentEndValues)) { 59 | const completed = !Object.values(currentEndValues).some( 60 | (item) => !item.finished 61 | ); 62 | runOnJS(onResetAnimationEnd)(completed, currentEndValues); 63 | delete endValues.value[interactionId]; 64 | } else { 65 | endValues.value[interactionId] = currentEndValues; 66 | } 67 | } 68 | }, 69 | [onResetAnimationEnd, endValues] 70 | ); 71 | 72 | return { onAnimationEnd }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/hooks/useGestures.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | import { Gesture } from 'react-native-gesture-handler'; 3 | import { 4 | Easing, 5 | runOnJS, 6 | useAnimatedStyle, 7 | useSharedValue, 8 | withDecay, 9 | withTiming, 10 | WithTimingConfig, 11 | } from 'react-native-reanimated'; 12 | import { clamp } from '../utils/clamp'; 13 | import { limits } from '../utils/limits'; 14 | import { ANIMATION_VALUE, ZOOM_TYPE } from '../types'; 15 | import type { 16 | GetInfoCallback, 17 | OnPanEndCallback, 18 | OnPanStartCallback, 19 | OnPinchEndCallback, 20 | OnPinchStartCallback, 21 | ProgrammaticZoomCallback, 22 | ZoomableUseGesturesProps, 23 | } from '../types'; 24 | import { useAnimationEnd } from './useAnimationEnd'; 25 | import { useInteractionId } from './useInteractionId'; 26 | import { usePanGestureCount } from './usePanGestureCount'; 27 | import { sum } from '../utils/sum'; 28 | 29 | const withTimingConfig: WithTimingConfig = { 30 | easing: Easing.inOut(Easing.quad), 31 | }; 32 | 33 | export const useGestures = ({ 34 | width, 35 | height, 36 | center, 37 | minScale = 1, 38 | maxScale = 5, 39 | scale: scaleValue, 40 | doubleTapScale = 3, 41 | maxPanPointers = 2, 42 | isPanEnabled = true, 43 | isPinchEnabled = true, 44 | isSingleTapEnabled = false, 45 | isDoubleTapEnabled = false, 46 | onInteractionStart, 47 | onInteractionEnd, 48 | onPinchStart, 49 | onPinchEnd, 50 | onPanStart, 51 | onPanEnd, 52 | onSingleTap = () => {}, 53 | onDoubleTap = () => {}, 54 | onProgrammaticZoom = () => {}, 55 | onResetAnimationEnd, 56 | }: ZoomableUseGesturesProps) => { 57 | const isInteracting = useRef(false); 58 | const isPinching = useRef(false); 59 | const { isPanning, startPan, endPan } = usePanGestureCount(); 60 | 61 | const savedScale = useSharedValue(1); 62 | const internalScaleValue = useSharedValue(1); 63 | const scale = scaleValue ?? internalScaleValue; 64 | const initialFocal = { x: useSharedValue(0), y: useSharedValue(0) }; 65 | const savedFocal = { x: useSharedValue(0), y: useSharedValue(0) }; 66 | const focal = { x: useSharedValue(0), y: useSharedValue(0) }; 67 | const savedTranslate = { x: useSharedValue(0), y: useSharedValue(0) }; 68 | const translate = { x: useSharedValue(0), y: useSharedValue(0) }; 69 | 70 | const { getInteractionId, updateInteractionId } = useInteractionId(); 71 | const { onAnimationEnd } = useAnimationEnd(onResetAnimationEnd); 72 | 73 | const reset = useCallback(() => { 74 | 'worklet'; 75 | const interactionId = getInteractionId(); 76 | 77 | savedScale.value = 1; 78 | const lastScaleValue = scale.value; 79 | scale.value = withTiming(1, withTimingConfig, (...args) => 80 | onAnimationEnd( 81 | interactionId, 82 | ANIMATION_VALUE.SCALE, 83 | lastScaleValue, 84 | ...args 85 | ) 86 | ); 87 | initialFocal.x.value = 0; 88 | initialFocal.y.value = 0; 89 | savedFocal.x.value = 0; 90 | savedFocal.y.value = 0; 91 | const lastFocalXValue = focal.x.value; 92 | focal.x.value = withTiming(0, withTimingConfig, (...args) => 93 | onAnimationEnd( 94 | interactionId, 95 | ANIMATION_VALUE.FOCAL_X, 96 | lastFocalXValue, 97 | ...args 98 | ) 99 | ); 100 | const lastFocalYValue = focal.y.value; 101 | focal.y.value = withTiming(0, withTimingConfig, (...args) => 102 | onAnimationEnd( 103 | interactionId, 104 | ANIMATION_VALUE.FOCAL_Y, 105 | lastFocalYValue, 106 | ...args 107 | ) 108 | ); 109 | savedTranslate.x.value = 0; 110 | savedTranslate.y.value = 0; 111 | const lastTranslateXValue = translate.x.value; 112 | translate.x.value = withTiming(0, withTimingConfig, (...args) => 113 | onAnimationEnd( 114 | interactionId, 115 | ANIMATION_VALUE.TRANSLATE_X, 116 | lastTranslateXValue, 117 | ...args 118 | ) 119 | ); 120 | const lastTranslateYValue = translate.y.value; 121 | translate.y.value = withTiming(0, withTimingConfig, (...args) => 122 | onAnimationEnd( 123 | interactionId, 124 | ANIMATION_VALUE.TRANSLATE_Y, 125 | lastTranslateYValue, 126 | ...args 127 | ) 128 | ); 129 | }, [ 130 | savedScale, 131 | scale, 132 | initialFocal.x, 133 | initialFocal.y, 134 | savedFocal.x, 135 | savedFocal.y, 136 | focal.x, 137 | focal.y, 138 | savedTranslate.x, 139 | savedTranslate.y, 140 | translate.x, 141 | translate.y, 142 | getInteractionId, 143 | onAnimationEnd, 144 | ]); 145 | 146 | const moveIntoView = () => { 147 | 'worklet'; 148 | if (scale.value > 1) { 149 | const rightLimit = limits.right(width, scale); 150 | const leftLimit = -rightLimit; 151 | const bottomLimit = limits.bottom(height, scale); 152 | const topLimit = -bottomLimit; 153 | const totalTranslateX = sum(translate.x, focal.x); 154 | const totalTranslateY = sum(translate.y, focal.y); 155 | 156 | if (totalTranslateX > rightLimit) { 157 | translate.x.value = withTiming(rightLimit, withTimingConfig); 158 | focal.x.value = withTiming(0, withTimingConfig); 159 | } else if (totalTranslateX < leftLimit) { 160 | translate.x.value = withTiming(leftLimit, withTimingConfig); 161 | focal.x.value = withTiming(0, withTimingConfig); 162 | } 163 | 164 | if (totalTranslateY > bottomLimit) { 165 | translate.y.value = withTiming(bottomLimit, withTimingConfig); 166 | focal.y.value = withTiming(0, withTimingConfig); 167 | } else if (totalTranslateY < topLimit) { 168 | translate.y.value = withTiming(topLimit, withTimingConfig); 169 | focal.y.value = withTiming(0, withTimingConfig); 170 | } 171 | } else { 172 | reset(); 173 | } 174 | }; 175 | 176 | const zoom: ProgrammaticZoomCallback = (event) => { 177 | 'worklet'; 178 | if (event.scale > 1) { 179 | runOnJS(onProgrammaticZoom)(ZOOM_TYPE.ZOOM_IN); 180 | scale.value = withTiming(event.scale, withTimingConfig); 181 | focal.x.value = withTiming( 182 | (center.x - event.x) * (event.scale - 1), 183 | withTimingConfig 184 | ); 185 | focal.y.value = withTiming( 186 | (center.y - event.y) * (event.scale - 1), 187 | withTimingConfig 188 | ); 189 | } else { 190 | runOnJS(onProgrammaticZoom)(ZOOM_TYPE.ZOOM_OUT); 191 | reset(); 192 | } 193 | }; 194 | 195 | const onInteractionStarted = () => { 196 | if (!isInteracting.current) { 197 | isInteracting.current = true; 198 | onInteractionStart?.(); 199 | updateInteractionId(); 200 | } 201 | }; 202 | 203 | const onInteractionEnded = () => { 204 | if (isInteracting.current && !isPinching.current && !isPanning()) { 205 | if (isDoubleTapEnabled) { 206 | moveIntoView(); 207 | } else { 208 | reset(); 209 | } 210 | isInteracting.current = false; 211 | onInteractionEnd?.(); 212 | } 213 | }; 214 | 215 | const onPinchStarted: OnPinchStartCallback = (event) => { 216 | onInteractionStarted(); 217 | isPinching.current = true; 218 | onPinchStart?.(event); 219 | }; 220 | 221 | const onPinchEnded: OnPinchEndCallback = (...args) => { 222 | isPinching.current = false; 223 | onPinchEnd?.(...args); 224 | onInteractionEnded(); 225 | }; 226 | 227 | const onPanStarted: OnPanStartCallback = (event) => { 228 | onInteractionStarted(); 229 | startPan(); 230 | onPanStart?.(event); 231 | }; 232 | 233 | const onPanEnded: OnPanEndCallback = (...args) => { 234 | endPan(); 235 | onPanEnd?.(...args); 236 | onInteractionEnded(); 237 | }; 238 | 239 | const panWhilePinchingGesture = Gesture.Pan() 240 | .enabled(isPanEnabled) 241 | .averageTouches(true) 242 | .enableTrackpadTwoFingerGesture(true) 243 | .minPointers(2) 244 | .maxPointers(maxPanPointers) 245 | .onStart((event) => { 246 | runOnJS(onPanStarted)(event); 247 | savedTranslate.x.value = translate.x.value; 248 | savedTranslate.y.value = translate.y.value; 249 | }) 250 | .onUpdate((event) => { 251 | translate.x.value = savedTranslate.x.value + event.translationX; 252 | translate.y.value = savedTranslate.y.value + event.translationY; 253 | }) 254 | .onEnd((event, success) => { 255 | const rightLimit = limits.right(width, scale); 256 | const leftLimit = -rightLimit; 257 | const bottomLimit = limits.bottom(height, scale); 258 | const topLimit = -bottomLimit; 259 | 260 | if (scale.value > 1 && isDoubleTapEnabled) { 261 | translate.x.value = withDecay( 262 | { 263 | velocity: event.velocityX, 264 | velocityFactor: 0.6, 265 | rubberBandEffect: true, 266 | rubberBandFactor: 0.9, 267 | clamp: [leftLimit - focal.x.value, rightLimit - focal.x.value], 268 | }, 269 | () => { 270 | if (event.velocityX >= event.velocityY) { 271 | runOnJS(onPanEnded)(event, success); 272 | } 273 | } 274 | ); 275 | translate.y.value = withDecay( 276 | { 277 | velocity: event.velocityY, 278 | velocityFactor: 0.6, 279 | rubberBandEffect: true, 280 | rubberBandFactor: 0.9, 281 | clamp: [topLimit - focal.y.value, bottomLimit - focal.y.value], 282 | }, 283 | () => { 284 | if (event.velocityY > event.velocityX) { 285 | runOnJS(onPanEnded)(event, success); 286 | } 287 | } 288 | ); 289 | } else { 290 | runOnJS(onPanEnded)(event, success); 291 | } 292 | }); 293 | 294 | const panOnlyGesture = Gesture.Pan() 295 | .enabled(isPanEnabled) 296 | .averageTouches(true) 297 | .enableTrackpadTwoFingerGesture(true) 298 | .minPointers(1) 299 | .maxPointers(1) 300 | .onTouchesDown((_, manager) => { 301 | if (scale.value <= 1) { 302 | manager.fail(); 303 | } 304 | }) 305 | .onStart((event) => { 306 | runOnJS(onPanStarted)(event); 307 | savedTranslate.x.value = translate.x.value; 308 | savedTranslate.y.value = translate.y.value; 309 | }) 310 | .onUpdate((event) => { 311 | translate.x.value = savedTranslate.x.value + event.translationX; 312 | translate.y.value = savedTranslate.y.value + event.translationY; 313 | }) 314 | .onEnd((event, success) => { 315 | const rightLimit = limits.right(width, scale); 316 | const leftLimit = -rightLimit; 317 | const bottomLimit = limits.bottom(height, scale); 318 | const topLimit = -bottomLimit; 319 | 320 | if (scale.value > 1 && isDoubleTapEnabled) { 321 | translate.x.value = withDecay( 322 | { 323 | velocity: event.velocityX, 324 | velocityFactor: 0.6, 325 | rubberBandEffect: true, 326 | rubberBandFactor: 0.9, 327 | clamp: [leftLimit - focal.x.value, rightLimit - focal.x.value], 328 | }, 329 | () => { 330 | if (event.velocityX >= event.velocityY) { 331 | runOnJS(onPanEnded)(event, success); 332 | } 333 | } 334 | ); 335 | translate.y.value = withDecay( 336 | { 337 | velocity: event.velocityY, 338 | velocityFactor: 0.6, 339 | rubberBandEffect: true, 340 | rubberBandFactor: 0.9, 341 | clamp: [topLimit - focal.y.value, bottomLimit - focal.y.value], 342 | }, 343 | () => { 344 | if (event.velocityY > event.velocityX) { 345 | runOnJS(onPanEnded)(event, success); 346 | } 347 | } 348 | ); 349 | } else { 350 | runOnJS(onPanEnded)(event, success); 351 | } 352 | }); 353 | 354 | const pinchGesture = Gesture.Pinch() 355 | .enabled(isPinchEnabled) 356 | .onStart((event) => { 357 | runOnJS(onPinchStarted)(event); 358 | savedScale.value = scale.value; 359 | savedFocal.x.value = focal.x.value; 360 | savedFocal.y.value = focal.y.value; 361 | initialFocal.x.value = event.focalX; 362 | initialFocal.y.value = event.focalY; 363 | }) 364 | .onUpdate((event) => { 365 | scale.value = clamp(savedScale.value * event.scale, minScale, maxScale); 366 | focal.x.value = 367 | savedFocal.x.value + 368 | (center.x - initialFocal.x.value) * (scale.value - savedScale.value); 369 | focal.y.value = 370 | savedFocal.y.value + 371 | (center.y - initialFocal.y.value) * (scale.value - savedScale.value); 372 | }) 373 | .onEnd((...args) => { 374 | runOnJS(onPinchEnded)(...args); 375 | }); 376 | 377 | const doubleTapGesture = Gesture.Tap() 378 | .enabled(isDoubleTapEnabled) 379 | .numberOfTaps(2) 380 | .maxDuration(250) 381 | .onStart((event) => { 382 | if (scale.value === 1) { 383 | runOnJS(onDoubleTap)(ZOOM_TYPE.ZOOM_IN); 384 | scale.value = withTiming(doubleTapScale, withTimingConfig); 385 | focal.x.value = withTiming( 386 | (center.x - event.x) * (doubleTapScale - 1), 387 | withTimingConfig 388 | ); 389 | focal.y.value = withTiming( 390 | (center.y - event.y) * (doubleTapScale - 1), 391 | withTimingConfig 392 | ); 393 | } else { 394 | runOnJS(onDoubleTap)(ZOOM_TYPE.ZOOM_OUT); 395 | reset(); 396 | } 397 | }); 398 | 399 | const singleTapGesture = Gesture.Tap() 400 | .enabled(isSingleTapEnabled) 401 | .numberOfTaps(1) 402 | .maxDistance(24) 403 | .onStart((event) => { 404 | runOnJS(onSingleTap)(event); 405 | }); 406 | 407 | const animatedStyle = useAnimatedStyle( 408 | () => ({ 409 | transform: [ 410 | { translateX: translate.x.value }, 411 | { translateY: translate.y.value }, 412 | { translateX: focal.x.value }, 413 | { translateY: focal.y.value }, 414 | { scale: scale.value }, 415 | ], 416 | }), 417 | [translate.x, translate.y, focal.x, focal.y, scale] 418 | ); 419 | 420 | const getInfo: GetInfoCallback = () => { 421 | const totalTranslateX = translate.x.value + focal.x.value; 422 | const totalTranslateY = translate.y.value + focal.y.value; 423 | return { 424 | container: { 425 | width, 426 | height, 427 | center, 428 | }, 429 | scaledSize: { 430 | width: width * scale.value, 431 | height: height * scale.value, 432 | }, 433 | visibleArea: { 434 | x: Math.abs(totalTranslateX - (width * (scale.value - 1)) / 2), 435 | y: Math.abs(totalTranslateY - (height * (scale.value - 1)) / 2), 436 | width, 437 | height, 438 | }, 439 | transformations: { 440 | translateX: totalTranslateX, 441 | translateY: totalTranslateY, 442 | scale: scale.value, 443 | }, 444 | }; 445 | }; 446 | 447 | const pinchPanGestures = Gesture.Simultaneous( 448 | pinchGesture, 449 | panWhilePinchingGesture 450 | ); 451 | const tapGestures = Gesture.Exclusive(doubleTapGesture, singleTapGesture); 452 | const gestures = 453 | isDoubleTapEnabled || isSingleTapEnabled 454 | ? Gesture.Race(pinchPanGestures, panOnlyGesture, tapGestures) 455 | : pinchPanGestures; 456 | 457 | return { gestures, animatedStyle, zoom, reset, getInfo }; 458 | }; 459 | -------------------------------------------------------------------------------- /src/hooks/useInteractionId.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useSharedValue } from 'react-native-reanimated'; 3 | 4 | export const useInteractionId = () => { 5 | const interactionId = useSharedValue(''); 6 | 7 | const getInteractionId = useCallback(() => { 8 | 'worklet'; 9 | return interactionId.value; 10 | }, [interactionId]); 11 | 12 | const updateInteractionId = useCallback(() => { 13 | interactionId.value = `${new Date().valueOf()}`; 14 | }, [interactionId]); 15 | 16 | return { getInteractionId, updateInteractionId }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/usePanGestureCount.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | export const usePanGestureCount = () => { 4 | const panGestureCount = useRef(0); 5 | 6 | const isPanning = () => panGestureCount.current > 0; 7 | const startPan = () => panGestureCount.current++; 8 | const endPan = () => panGestureCount.current > 0 && panGestureCount.current--; 9 | 10 | return { isPanning, startPan, endPan }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/hooks/useZoomable.ts: -------------------------------------------------------------------------------- 1 | import { useGestures } from '../hooks/useGestures'; 2 | import { useZoomableLayout } from '../hooks/useZoomableLayout'; 3 | import { useZoomableHandle } from '../hooks/useZoomableHandle'; 4 | import type { UseZoomableProps } from '../types'; 5 | 6 | export const useZoomable = ({ 7 | minScale, 8 | maxScale, 9 | scale, 10 | doubleTapScale, 11 | maxPanPointers, 12 | isPanEnabled, 13 | isPinchEnabled, 14 | isSingleTapEnabled, 15 | isDoubleTapEnabled, 16 | onInteractionStart, 17 | onInteractionEnd, 18 | onPinchStart, 19 | onPinchEnd, 20 | onPanStart, 21 | onPanEnd, 22 | onSingleTap, 23 | onDoubleTap, 24 | onProgrammaticZoom, 25 | onResetAnimationEnd, 26 | onLayout, 27 | ref, 28 | }: UseZoomableProps) => { 29 | const { width, height, center, onZoomableLayout } = useZoomableLayout({ 30 | onLayout, 31 | }); 32 | const { animatedStyle, gestures, reset, zoom, getInfo } = useGestures({ 33 | width, 34 | height, 35 | center, 36 | minScale, 37 | maxScale, 38 | scale, 39 | doubleTapScale, 40 | maxPanPointers, 41 | isPanEnabled, 42 | isPinchEnabled, 43 | isSingleTapEnabled, 44 | isDoubleTapEnabled, 45 | onInteractionStart, 46 | onInteractionEnd, 47 | onPinchStart, 48 | onPinchEnd, 49 | onPanStart, 50 | onPanEnd, 51 | onSingleTap, 52 | onDoubleTap, 53 | onProgrammaticZoom, 54 | onResetAnimationEnd, 55 | }); 56 | useZoomableHandle(ref, reset, zoom, getInfo); 57 | 58 | return { animatedStyle, gestures, onZoomableLayout }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/hooks/useZoomableHandle.ts: -------------------------------------------------------------------------------- 1 | import { Ref, useImperativeHandle } from 'react'; 2 | import type { 3 | GetInfoCallback, 4 | ProgrammaticZoomCallback, 5 | ZoomableRef, 6 | } from '../types'; 7 | 8 | export const useZoomableHandle = ( 9 | ref: Ref | undefined, 10 | reset: () => void, 11 | zoom: ProgrammaticZoomCallback, 12 | getInfo: GetInfoCallback 13 | ) => { 14 | useImperativeHandle( 15 | ref, 16 | (): ZoomableRef => ({ 17 | reset() { 18 | reset(); 19 | }, 20 | zoom(event) { 21 | zoom(event); 22 | }, 23 | getInfo() { 24 | return getInfo(); 25 | }, 26 | }), 27 | [reset, zoom, getInfo] 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/hooks/useZoomableLayout.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import type { LayoutChangeEvent } from 'react-native'; 3 | import type { ZoomableLayoutState, ZoomableUseLayoutProps } from '../types'; 4 | 5 | export const useZoomableLayout = ({ onLayout }: ZoomableUseLayoutProps) => { 6 | const [state, setState] = useState({ 7 | x: 0, 8 | y: 0, 9 | width: 0, 10 | height: 0, 11 | center: { x: 0, y: 0 }, 12 | }); 13 | 14 | const onZoomableLayout = (event: LayoutChangeEvent) => { 15 | const { layout } = event.nativeEvent; 16 | const { x, y, width, height } = layout; 17 | const center = { 18 | x: x + width / 2, 19 | y: y + height / 2, 20 | }; 21 | 22 | if (typeof onLayout === 'function') { 23 | onLayout(event); 24 | } 25 | 26 | setState({ ...layout, center }); 27 | }; 28 | 29 | return { ...state, onZoomableLayout }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ImageZoom } from './components/ImageZoom'; 2 | export { default as Zoomable } from './components/Zoomable'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ForwardedRef } from 'react'; 2 | import type { 3 | ImageProps, 4 | ImageSourcePropType, 5 | LayoutRectangle, 6 | ViewProps, 7 | } from 'react-native'; 8 | import type { 9 | GestureStateChangeEvent, 10 | PanGestureHandlerEventPayload, 11 | PinchGestureHandlerEventPayload, 12 | TapGestureHandlerEventPayload, 13 | } from 'react-native-gesture-handler'; 14 | import { 15 | AnimatableValue, 16 | AnimateProps, 17 | SharedValue, 18 | } from 'react-native-reanimated'; 19 | 20 | export type OnPinchStartCallback = ( 21 | event: GestureStateChangeEvent 22 | ) => void; 23 | 24 | export type OnPinchEndCallback = ( 25 | event: GestureStateChangeEvent, 26 | success: boolean 27 | ) => void; 28 | 29 | export type OnPanStartCallback = ( 30 | event: GestureStateChangeEvent 31 | ) => void; 32 | 33 | export type OnPanEndCallback = ( 34 | event: GestureStateChangeEvent, 35 | success: boolean 36 | ) => void; 37 | 38 | export type OnSingleTapCallback = ( 39 | event: GestureStateChangeEvent 40 | ) => void; 41 | 42 | export enum ZOOM_TYPE { 43 | ZOOM_IN = 'ZOOM_IN', 44 | ZOOM_OUT = 'ZOOM_OUT', 45 | } 46 | 47 | export type ProgrammaticZoomCallback = (event: { 48 | scale: number; 49 | x: number; 50 | y: number; 51 | }) => void; 52 | 53 | export type OnDoubleTapCallback = (zoomType: ZOOM_TYPE) => void; 54 | export type OnProgrammaticZoomCallback = (zoomType: ZOOM_TYPE) => void; 55 | 56 | export type GetInfoCallback = () => { 57 | container: { 58 | width: number; 59 | height: number; 60 | center: { x: number; y: number }; 61 | }; 62 | scaledSize: { 63 | width: number; 64 | height: number; 65 | }; 66 | visibleArea: { 67 | x: number; 68 | y: number; 69 | width: number; 70 | height: number; 71 | }; 72 | transformations: { 73 | translateX: number; 74 | translateY: number; 75 | scale: number; 76 | }; 77 | }; 78 | 79 | export enum ANIMATION_VALUE { 80 | SCALE = 'SCALE', 81 | FOCAL_X = 'FOCAL_X', 82 | FOCAL_Y = 'FOCAL_Y', 83 | TRANSLATE_X = 'TRANSLATE_X', 84 | TRANSLATE_Y = 'TRANSLATE_Y', 85 | } 86 | 87 | export type OnResetAnimationEndCallback = ( 88 | finished?: boolean, 89 | values?: Record< 90 | ANIMATION_VALUE, 91 | { 92 | lastValue: number; 93 | finished?: boolean; 94 | current?: AnimatableValue; 95 | } 96 | > 97 | ) => void; 98 | 99 | export type ZoomProps = { 100 | /** 101 | * The minimum scale allowed for zooming. 102 | * @default 1 103 | */ 104 | minScale?: number; 105 | /** 106 | * The maximum scale allowed for zooming. 107 | * @default 5 108 | */ 109 | maxScale?: number; 110 | /** 111 | * The `scale` property allows you to provide your own Reanimated shared value for scale. 112 | * This shared value will be updated as the zoom level changes, enabling you to use the 113 | * current scale in your own code. 114 | * @default useSharedValue(1) 115 | */ 116 | scale?: SharedValue; 117 | /** 118 | * The value of the scale when a double-tap gesture is detected. 119 | * @default 3 120 | */ 121 | doubleTapScale?: number; 122 | /** 123 | * The maximum number of pointers required to enable panning. 124 | * @default 2 125 | */ 126 | maxPanPointers?: number; 127 | /** 128 | * Determines whether panning is enabled within the range of the minimum and maximum pan pointers. 129 | * @default true 130 | */ 131 | isPanEnabled?: boolean; 132 | /** 133 | * Determines whether pinching is enabled. 134 | * @default true 135 | */ 136 | isPinchEnabled?: boolean; 137 | /** 138 | * Enables or disables the single tap feature. 139 | * @default false 140 | */ 141 | isSingleTapEnabled?: boolean; 142 | /** 143 | * Enables or disables the double tap feature. 144 | * When enabled, this feature prevents automatic reset of the zoom to its initial position, allowing continuous zooming. 145 | * To return to the initial position, double tap again or zoom out to a scale level less than 1. 146 | * @default false 147 | */ 148 | isDoubleTapEnabled?: boolean; 149 | /** 150 | * A callback triggered when the interaction starts. 151 | */ 152 | onInteractionStart?: () => void; 153 | /** 154 | * A callback triggered when the interaction ends. 155 | */ 156 | onInteractionEnd?: () => void; 157 | /** 158 | * A callback triggered when the pinching starts. 159 | */ 160 | onPinchStart?: OnPinchStartCallback; 161 | /** 162 | * A callback triggered when the pinching ends. 163 | */ 164 | onPinchEnd?: OnPinchEndCallback; 165 | /** 166 | * A callback triggered when the panning starts. 167 | */ 168 | onPanStart?: OnPanStartCallback; 169 | /** 170 | * A callback triggered when the panning ends. 171 | */ 172 | onPanEnd?: OnPanEndCallback; 173 | /** 174 | * A callback triggered when a single tap is detected. 175 | */ 176 | onSingleTap?: OnSingleTapCallback; 177 | /** 178 | * A callback triggered when a double tap gesture is detected. 179 | */ 180 | onDoubleTap?: OnDoubleTapCallback; 181 | /** 182 | * A callback function that is invoked when a programmatic zoom event occurs. 183 | */ 184 | onProgrammaticZoom?: OnProgrammaticZoomCallback; 185 | /** 186 | * A callback triggered upon the completion of the reset animation. It accepts two parameters: finished and values. 187 | * The finished parameter evaluates to true if all animation values have successfully completed the reset animation; 188 | * otherwise, it is false, indicating interruption by another gesture or unforeseen circumstances. 189 | * The values parameter provides additional detailed information for each animation value. 190 | */ 191 | onResetAnimationEnd?: OnResetAnimationEndCallback; 192 | }; 193 | 194 | export type ZoomableProps = AnimateProps & ZoomProps; 195 | 196 | export type UseZoomableProps = ZoomProps & { 197 | ref: ForwardedRef; 198 | /** 199 | * Invoked on mount and layout changes with 200 | * 201 | * {nativeEvent: { layout: {x, y, width, height}}}. 202 | */ 203 | onLayout?: ZoomableProps['onLayout']; 204 | }; 205 | 206 | export type ImageZoomProps = Omit, 'source'> & 207 | ZoomProps & { 208 | /** 209 | * The image's URI, which can be overridden by the `source` prop. 210 | * @default '' 211 | */ 212 | uri?: string; 213 | /** 214 | * @see https://facebook.github.io/react-native/docs/image.html#source 215 | * @default undefined 216 | */ 217 | source?: ImageSourcePropType; 218 | }; 219 | 220 | export type ZoomableUseLayoutProps = Pick; 221 | 222 | export type ZoomableLayoutState = LayoutRectangle & { 223 | /** 224 | * An object containing the x and y coordinates of the center point of the view, relative to the top-left corner of the container. 225 | */ 226 | center: { 227 | /** 228 | * The x-coordinate of the center point of the view. 229 | */ 230 | x: number; 231 | /** 232 | * The y-coordinate of the center point of the view. 233 | */ 234 | y: number; 235 | }; 236 | }; 237 | 238 | export type ZoomableUseGesturesProps = Pick< 239 | ZoomableLayoutState, 240 | 'width' | 'height' | 'center' 241 | > & 242 | Pick< 243 | ZoomableProps, 244 | | 'minScale' 245 | | 'maxScale' 246 | | 'scale' 247 | | 'doubleTapScale' 248 | | 'maxPanPointers' 249 | | 'isPanEnabled' 250 | | 'isPinchEnabled' 251 | | 'isSingleTapEnabled' 252 | | 'isDoubleTapEnabled' 253 | | 'onInteractionStart' 254 | | 'onInteractionEnd' 255 | | 'onPinchStart' 256 | | 'onPinchEnd' 257 | | 'onPanStart' 258 | | 'onPanEnd' 259 | | 'onSingleTap' 260 | | 'onDoubleTap' 261 | | 'onProgrammaticZoom' 262 | | 'onResetAnimationEnd' 263 | >; 264 | 265 | export type ZoomableRef = { 266 | /** 267 | * Resets the zoom level to its original scale. 268 | */ 269 | reset: () => void; 270 | /** 271 | * Triggers a zoom event to the specified coordinates (x, y) at the defined scale level. 272 | */ 273 | zoom: ProgrammaticZoomCallback; 274 | /** 275 | * Retrieves detailed information about the zoomable component, including container dimensions, 276 | * scaled size, visible area (relative to the scaled component), and transformation values. 277 | * 278 | * @returns An object containing: 279 | * - `container`: The original container's dimensions and center point. 280 | * - `width`: The width of the container. 281 | * - `height`: The height of the container. 282 | * - `center`: The center coordinates of the container. 283 | * - `scaledSize`: The dimensions of the component after applying the current scale. 284 | * - `width`: The scaled width. 285 | * - `height`: The scaled height. 286 | * - `visibleArea`: The visible region of the scaled component. 287 | * - `x`: The x-coordinate of the top-left corner of the visible area (relative to the scaled component). 288 | * - `y`: The y-coordinate of the top-left corner of the visible area (relative to the scaled component). 289 | * - `width`: The width of the visible area (matches container width). 290 | * - `height`: The height of the visible area (matches container height). 291 | * - `transformations`: The current transformation values. 292 | * - `translateX`: The horizontal translation value (including focal point adjustment). 293 | * - `translateY`: The vertical translation value (including focal point adjustment). 294 | * - `scale`: The current scale factor. 295 | */ 296 | getInfo: GetInfoCallback; 297 | }; 298 | 299 | export type ImageZoomRef = ZoomableRef; 300 | -------------------------------------------------------------------------------- /src/utils/clamp.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (value: number, min: number, max: number): number => { 2 | 'worklet'; 3 | 4 | return Math.min(Math.max(min, value), max); 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/limits.ts: -------------------------------------------------------------------------------- 1 | import { SharedValue } from 'react-native-reanimated'; 2 | 3 | const right = (width: number, scale: SharedValue) => { 4 | 'worklet'; 5 | return (width * (scale.value - 1)) / 2; 6 | }; 7 | 8 | const left = (width: number, scale: SharedValue) => { 9 | 'worklet'; 10 | return -right(width, scale); 11 | }; 12 | 13 | const bottom = (height: number, scale: SharedValue) => { 14 | 'worklet'; 15 | return (height * (scale.value - 1)) / 2; 16 | }; 17 | 18 | const top = (height: number, scale: SharedValue) => { 19 | 'worklet'; 20 | return -bottom(height, scale); 21 | }; 22 | 23 | export const limits = { 24 | right, 25 | left, 26 | top, 27 | bottom, 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/sum.ts: -------------------------------------------------------------------------------- 1 | import { SharedValue } from 'react-native-reanimated'; 2 | 3 | export const sum = (...animatedValues: SharedValue[]) => { 4 | 'worklet'; 5 | 6 | return animatedValues.reduce( 7 | (result, animatedValue) => result + animatedValue.value, 8 | 0 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "exclude": ["example"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@likashefqet/react-native-image-zoom": [ 6 | "./src/index" 7 | ] 8 | }, 9 | "allowUnreachableCode": false, 10 | "allowUnusedLabels": false, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "jsx": "react", 14 | "lib": [ 15 | "esnext" 16 | ], 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "noFallthroughCasesInSwitch": true, 20 | "noImplicitReturns": true, 21 | "noImplicitUseStrict": false, 22 | "noStrictGenericChecks": false, 23 | "noUncheckedIndexedAccess": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "resolveJsonModule": true, 27 | "skipLibCheck": true, 28 | "strict": true, 29 | "target": "esnext", 30 | }, 31 | } 32 | --------------------------------------------------------------------------------