├── .nvmrc ├── .watchmanconfig ├── example ├── App.js ├── assets │ ├── icon.png │ ├── splash.png │ ├── favicon.png │ └── adaptive-icon.png ├── tsconfig.json ├── babel.config.js ├── app.json ├── src │ ├── App.tsx │ └── third-party │ │ └── gorhom-bottom-sheet │ │ └── index.tsx ├── webpack.config.js ├── package.json └── metro.config.js ├── .gitattributes ├── tsconfig.build.json ├── babel.config.js ├── src ├── withSmartScroll.tsx ├── index.tsx ├── mergeRefs.ts ├── useOrientation.ts ├── ViewWrapper.tsx ├── useKeyboard.ts ├── TextInput.tsx ├── useFormSmartScroll.tsx └── Provider.tsx ├── lefthook.yml ├── .editorconfig ├── .yarnrc.yml ├── tsconfig.json ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── package.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── README.md └── .yarn └── plugins └── @yarnpkg └── plugin-workspace-tools.cjs /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | export { default } from './src/App'; 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "exclude": ["example"] 4 | } 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppAndFlow/react-native-magic-scroll/HEAD/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppAndFlow/react-native-magic-scroll/HEAD/example/assets/splash.png -------------------------------------------------------------------------------- /example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppAndFlow/react-native-magic-scroll/HEAD/example/assets/favicon.png -------------------------------------------------------------------------------- /example/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppAndFlow/react-native-magic-scroll/HEAD/example/assets/adaptive-icon.png -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | // Avoid expo-cli auto-generating a tsconfig 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/withSmartScroll.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SmartScrollView from './Provider'; 3 | 4 | export const withSmartScroll = (child: (props?: any) => React.ReactElement) => { 5 | return (props?: any) => {child(props)}; 6 | }; 7 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | lint: 5 | glob: "*.{js,ts,jsx,tsx}" 6 | run: npx eslint {staged_files} 7 | types: 8 | glob: "*.{js,ts, jsx, tsx}" 9 | run: npx tsc --noEmit 10 | commit-msg: 11 | parallel: true 12 | commands: 13 | commitlint: 14 | run: npx commitlint --edit 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | nmHoistingLimits: workspaces 3 | 4 | plugins: 5 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 6 | spec: "@yarnpkg/plugin-interactive-tools" 7 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 8 | spec: "@yarnpkg/plugin-workspace-tools" 9 | 10 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import SmartScrollView, { useFormSmartScroll, ScrollView } from './Provider'; 2 | import { TextInput } from './TextInput'; 3 | import ViewWrapper from './ViewWrapper'; 4 | import { withSmartScroll } from './withSmartScroll'; 5 | 6 | export const MagicScroll = { 7 | useFormSmartScroll, 8 | ScrollView, 9 | ViewWrapper, 10 | TextInput, 11 | withSmartScroll, 12 | ChainingProvider: SmartScrollView, 13 | }; 14 | -------------------------------------------------------------------------------- /src/mergeRefs.ts: -------------------------------------------------------------------------------- 1 | import type * as React from 'react'; 2 | 3 | export function mergeRefs( 4 | refs: Array | React.LegacyRef | undefined | null> 5 | ): React.RefCallback { 6 | return (value) => { 7 | refs.forEach((ref) => { 8 | if (typeof ref === 'function') { 9 | ref(value); 10 | } else if (ref != null) { 11 | (ref as React.MutableRefObject).current = value; 12 | } 13 | }); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /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 | ], 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "example", 4 | "slug": "example", 5 | "version": "1.0.0", 6 | "icon": "./assets/icon.png", 7 | "userInterfaceStyle": "light", 8 | "newArchEnabled": true, 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": ["**/*"], 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | }, 23 | "softwareKeyboardLayoutMode": "pan" 24 | }, 25 | "web": { 26 | "favicon": "./assets/favicon.png" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { StyleSheet } from 'react-native'; 4 | import { GorhomBottomSheetExample } from './third-party/gorhom-bottom-sheet'; 5 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 6 | import { SafeAreaProvider } from 'react-native-safe-area-context'; 7 | 8 | export default function App() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | flex: 1, 21 | }, 22 | box: { 23 | width: 60, 24 | height: 60, 25 | marginVertical: 20, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "paths": { 5 | "@appandflow/rn-magic-scroll": ["./src/index"] 6 | }, 7 | "allowUnreachableCode": false, 8 | "allowUnusedLabels": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react", 12 | "lib": ["esnext"], 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitReturns": true, 17 | "noImplicitUseStrict": false, 18 | "noStrictGenericChecks": false, 19 | "noUncheckedIndexedAccess": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "resolveJsonModule": true, 23 | "skipLibCheck": true, 24 | "strict": true, 25 | "target": "esnext", 26 | "verbatimModuleSyntax": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('**/package.json', '!node_modules/**') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 22 | ${{ runner.os }}-yarn- 23 | 24 | - name: Install dependencies 25 | if: steps.yarn-cache.outputs.cache-hit != 'true' 26 | run: yarn install --immutable 27 | shell: bash 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/useOrientation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import * as ScreenOrientation from 'expo-screen-orientation'; 3 | 4 | export function useOrientation(): ScreenOrientation.Orientation { 5 | const [orientation, setOrientation] = useState( 6 | ScreenOrientation.Orientation.PORTRAIT_UP 7 | ); 8 | 9 | const getInitialOrientation = async () => { 10 | const initial = await ScreenOrientation.getOrientationAsync(); 11 | 12 | setOrientation(initial); 13 | }; 14 | 15 | useEffect(() => { 16 | getInitialOrientation(); 17 | }, []); 18 | 19 | useEffect(() => { 20 | const listener = ScreenOrientation.addOrientationChangeListener((event) => { 21 | setOrientation(event.orientationInfo.orientation); 22 | }); 23 | 24 | return () => { 25 | ScreenOrientation.removeOrientationChangeListener(listener); 26 | }; 27 | }, []); 28 | 29 | return orientation; 30 | } 31 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@appandflow/react-native-magic-scroll-example", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "dependencies": { 12 | "@gorhom/bottom-sheet": "^4.6.3", 13 | "expo": "~52.0.38", 14 | "expo-screen-orientation": "~8.0.4", 15 | "expo-status-bar": "~2.0.1", 16 | "react": "18.3.1", 17 | "react-dom": "18.3.1", 18 | "react-native": "0.76.7", 19 | "react-native-gesture-handler": "~2.20.2", 20 | "react-native-reanimated": "~3.16.1", 21 | "react-native-safe-area-context": "4.12.0", 22 | "react-native-web": "~0.19.13" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.25.2", 26 | "@expo/webpack-config": "^18.0.1", 27 | "babel-loader": "^8.1.0", 28 | "babel-plugin-module-resolver": "^5.0.0" 29 | }, 30 | "private": true 31 | } 32 | -------------------------------------------------------------------------------- /.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 | android.iml 42 | 43 | # Cocoapods 44 | # 45 | example/ios/Pods 46 | 47 | # Ruby 48 | example/vendor/ 49 | 50 | # node.js 51 | # 52 | node_modules/ 53 | npm-debug.log 54 | yarn-debug.log 55 | yarn-error.log 56 | 57 | # BUCK 58 | buck-out/ 59 | \.buckd/ 60 | android/app/libs 61 | android/keystores/debug.keystore 62 | 63 | # Yarn 64 | .yarn/* 65 | !.yarn/patches 66 | !.yarn/plugins 67 | !.yarn/releases 68 | !.yarn/sdks 69 | !.yarn/versions 70 | 71 | # Expo 72 | .expo/ 73 | 74 | # Turborepo 75 | .turbo/ 76 | 77 | # generated by bob 78 | lib/ 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Emanuel Quimper 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /.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 prepare 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/ViewWrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import React, { useCallback, useRef } from 'react'; 3 | import { View } from 'react-native'; 4 | import type { LayoutChangeEvent, ViewStyle } from 'react-native'; 5 | import { useSmartScrollContext } from './Provider'; 6 | 7 | type Props = PropsWithChildren<{ 8 | name: string; 9 | style?: ViewStyle; 10 | }>; 11 | 12 | const ViewWrapper = ({ name, style, children }: Props) => { 13 | const { wrapperRef, elements, setElements } = useSmartScrollContext(); 14 | 15 | const ref = useRef(null); 16 | 17 | const onLayout = useCallback( 18 | ({ nativeEvent }: LayoutChangeEvent) => { 19 | const element = elements[name]; 20 | if (wrapperRef.current && !elements[name]) { 21 | ref.current?.measureLayout(wrapperRef.current, (_, y, _w, h) => { 22 | setElements((s) => ({ 23 | ...s, 24 | [name]: { 25 | ...s[name], 26 | isFocus: false, 27 | position: y, 28 | height: h, 29 | name: name, 30 | }, 31 | })); 32 | }); 33 | } else if (element) { 34 | setElements((s) => ({ 35 | ...s, 36 | [name]: { 37 | ...element, 38 | height: nativeEvent.layout.height, 39 | }, 40 | })); 41 | } 42 | }, 43 | [name, wrapperRef, elements] 44 | ); 45 | 46 | return ( 47 | 48 | {children} 49 | 50 | ); 51 | }; 52 | 53 | export default ViewWrapper; 54 | -------------------------------------------------------------------------------- /src/useKeyboard.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { 3 | Keyboard, 4 | type KeyboardEventListener, 5 | type KeyboardMetrics, 6 | } from 'react-native'; 7 | 8 | const emptyCoordinates = Object.freeze({ 9 | screenX: 0, 10 | screenY: 0, 11 | width: 0, 12 | height: 0, 13 | }); 14 | 15 | const initialValue = { 16 | start: emptyCoordinates, 17 | end: emptyCoordinates, 18 | }; 19 | 20 | export function useKeyboard() { 21 | const [shown, setShown] = useState(false); 22 | const [coordinates, setCoordinates] = useState<{ 23 | start: undefined | KeyboardMetrics; 24 | end: KeyboardMetrics; 25 | }>(initialValue); 26 | const [keyboardHeight, setKeyboardHeight] = useState(0); 27 | 28 | const handleKeyboardWillShow: KeyboardEventListener = (event) => { 29 | setCoordinates({ 30 | start: event.startCoordinates, 31 | end: event.endCoordinates, 32 | }); 33 | }; 34 | 35 | const handleKeyboardDidShow: KeyboardEventListener = (event) => { 36 | setShown(true); 37 | setCoordinates({ 38 | start: event.startCoordinates, 39 | end: event.endCoordinates, 40 | }); 41 | setKeyboardHeight(event.endCoordinates.height); 42 | }; 43 | 44 | const handleKeyboardWillHide: KeyboardEventListener = (event) => { 45 | setCoordinates({ 46 | start: event.startCoordinates, 47 | end: event.endCoordinates, 48 | }); 49 | }; 50 | 51 | const handleKeyboardDidHide: KeyboardEventListener = (event) => { 52 | setShown(false); 53 | if (event) { 54 | setCoordinates({ 55 | start: event.startCoordinates, 56 | end: event.endCoordinates, 57 | }); 58 | } else { 59 | setCoordinates(initialValue); 60 | setKeyboardHeight(0); 61 | } 62 | }; 63 | 64 | useEffect(() => { 65 | const subscriptions = [ 66 | Keyboard.addListener('keyboardWillShow', handleKeyboardWillShow), 67 | Keyboard.addListener('keyboardDidShow', handleKeyboardDidShow), 68 | Keyboard.addListener('keyboardWillHide', handleKeyboardWillHide), 69 | Keyboard.addListener('keyboardDidHide', handleKeyboardDidHide), 70 | ]; 71 | 72 | return () => { 73 | subscriptions.forEach((subscription) => subscription.remove()); 74 | }; 75 | }, []); 76 | 77 | return { 78 | keyboardShown: shown, 79 | coordinates, 80 | keyboardHeight, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /src/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { TextInputProps } from 'react-native'; 3 | import { TextInput as RNTextInput } from 'react-native'; 4 | import ViewWrapper from './ViewWrapper'; 5 | import { useFormSmartScroll } from './Provider'; 6 | import type { ViewStyle } from 'react-native'; 7 | import { mergeRefs } from './mergeRefs'; 8 | 9 | type Props = { 10 | containerStyle?: ViewStyle; 11 | textInputProps?: TextInputProps; 12 | name?: string; 13 | renderTop?: () => React.ReactElement; 14 | renderBottom?: () => React.ReactElement; 15 | renderInput?: ( 16 | args: TextInputProps & { 17 | ref: React.LegacyRef | undefined; 18 | } 19 | ) => React.ReactElement; 20 | chainTo?: string; 21 | ref?: React.RefObject; 22 | }; 23 | 24 | export const TextInput = React.forwardRef( 25 | (props, inputRef) => { 26 | const id = React.useId(); 27 | const name = React.useRef(props.name ?? id); 28 | const textInputRef = React.useRef(null); 29 | 30 | const ref = mergeRefs([textInputRef, inputRef]); 31 | 32 | const { onBlur, onFocus, onSubmitEditing, ...textInputProps } = 33 | props.textInputProps ?? {}; 34 | 35 | const { registerInput, baseTextInputProps, chainInput } = 36 | useFormSmartScroll(); 37 | 38 | React.useEffect(() => { 39 | registerInput(name.current, textInputRef); 40 | }, []); 41 | 42 | return ( 43 | 44 | {typeof props.renderInput === 'function' ? ( 45 | props.renderInput({ 46 | ...(textInputProps ?? {}), 47 | onSubmitEditing: (e) => { 48 | if (props.chainTo) { 49 | chainInput(props.chainTo); 50 | } 51 | 52 | onSubmitEditing?.(e); 53 | }, 54 | ...baseTextInputProps(name.current, { 55 | onFocus, 56 | onBlur, 57 | }), 58 | ref, 59 | }) 60 | ) : ( 61 | <> 62 | {props.renderTop?.()} 63 | { 68 | if (props.chainTo) { 69 | chainInput(props.chainTo); 70 | } 71 | 72 | onSubmitEditing?.(e); 73 | }} 74 | /> 75 | {props.renderBottom?.()} 76 | 77 | )} 78 | 79 | ); 80 | } 81 | ); 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@appandflow/react-native-magic-scroll", 3 | "version": "0.1.36", 4 | "description": "Library to help managing keyboard interaction in form", 5 | "main": "lib/commonjs/index", 6 | "module": "lib/module/index", 7 | "types": "lib/typescript/src/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 | "!ios/build", 18 | "!android/build", 19 | "!android/gradle", 20 | "!android/gradlew", 21 | "!android/gradlew.bat", 22 | "!android/local.properties", 23 | "!**/__tests__", 24 | "!**/__fixtures__", 25 | "!**/__mocks__", 26 | "!**/.*" 27 | ], 28 | "scripts": { 29 | "example": "yarn workspace @appandflow/react-native-magic-scroll-example", 30 | "test": "jest", 31 | "typecheck": "tsc --noEmit", 32 | "lint": "eslint \"**/*.{js,ts,tsx}\"", 33 | "clean": "del-cli lib", 34 | "prepare": "bob build", 35 | "release": "release-it" 36 | }, 37 | "keywords": [ 38 | "react-native", 39 | "ios", 40 | "android" 41 | ], 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/AppAndFlow/react-native-magic-scroll.git" 45 | }, 46 | "author": "Emanuel Quimper (https://github.com/AppAndFlow/react-native-magic-scroll)", 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/AppAndFlow/react-native-magic-scroll/issues" 50 | }, 51 | "homepage": "https://github.com/AppAndFlow/react-native-magic-scroll#readme", 52 | "publishConfig": { 53 | "registry": "https://registry.npmjs.org/", 54 | "access": "public" 55 | }, 56 | "devDependencies": { 57 | "@commitlint/config-conventional": "^17.0.2", 58 | "@evilmartians/lefthook": "^1.5.0", 59 | "@react-native/eslint-config": "^0.73.1", 60 | "@release-it/conventional-changelog": "^5.0.0", 61 | "@types/jest": "^29.5.5", 62 | "@types/react": "^18.2.44", 63 | "commitlint": "^17.0.2", 64 | "del-cli": "^5.1.0", 65 | "eslint": "^8.51.0", 66 | "eslint-config-prettier": "^9.0.0", 67 | "eslint-plugin-prettier": "^5.0.1", 68 | "expo-screen-orientation": "^8.0.4", 69 | "jest": "^29.7.0", 70 | "prettier": "^3.0.3", 71 | "react": "18.3.1", 72 | "react-native": "0.76.7", 73 | "react-native-builder-bob": "^0.37.0", 74 | "react-native-reanimated": "^3.16.1", 75 | "react-native-safe-area-context": "^4.10.5", 76 | "release-it": "^17.0.0", 77 | "typescript": "^5.2.2" 78 | }, 79 | "resolutions": { 80 | "@types/react": "^18.2.44" 81 | }, 82 | "peerDependencies": { 83 | "expo-screen-orientation": "*", 84 | "react": "*", 85 | "react-native": "*", 86 | "react-native-reanimated": ">=2.0.0", 87 | "react-native-safe-area-context": "*" 88 | }, 89 | "workspaces": [ 90 | "example" 91 | ], 92 | "packageManager": "yarn@3.6.1", 93 | "jest": { 94 | "preset": "react-native", 95 | "modulePathIgnorePatterns": [ 96 | "/example/node_modules", 97 | "/lib/" 98 | ] 99 | }, 100 | "commitlint": { 101 | "extends": [ 102 | "@commitlint/config-conventional" 103 | ] 104 | }, 105 | "release-it": { 106 | "git": { 107 | "commitMessage": "chore: release ${version}", 108 | "tagName": "v${version}" 109 | }, 110 | "npm": { 111 | "publish": true 112 | }, 113 | "github": { 114 | "release": true 115 | }, 116 | "plugins": { 117 | "@release-it/conventional-changelog": { 118 | "preset": "angular" 119 | } 120 | } 121 | }, 122 | "eslintConfig": { 123 | "root": true, 124 | "extends": [ 125 | "@react-native", 126 | "prettier" 127 | ], 128 | "rules": { 129 | "prettier/prettier": [ 130 | "error", 131 | { 132 | "quoteProps": "consistent", 133 | "singleQuote": true, 134 | "tabWidth": 2, 135 | "trailingComma": "es5", 136 | "useTabs": false 137 | } 138 | ], 139 | "react-hooks/exhaustive-deps": "off" 140 | } 141 | }, 142 | "eslintIgnore": [ 143 | "node_modules/", 144 | "lib/" 145 | ], 146 | "prettier": { 147 | "quoteProps": "consistent", 148 | "singleQuote": true, 149 | "tabWidth": 2, 150 | "trailingComma": "es5", 151 | "useTabs": false 152 | }, 153 | "react-native-builder-bob": { 154 | "source": "src", 155 | "output": "lib", 156 | "targets": [ 157 | "commonjs", 158 | "module", 159 | [ 160 | "typescript", 161 | { 162 | "project": "tsconfig.build.json" 163 | } 164 | ] 165 | ] 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /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 | This project is a monorepo managed using [Yarn workspaces](https://yarnpkg.com/features/workspaces). It contains the following packages: 10 | 11 | - The library package in the root directory. 12 | - An example app in the `example/` directory. 13 | 14 | To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: 15 | 16 | ```sh 17 | yarn 18 | ``` 19 | 20 | > Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development. 21 | 22 | The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. 23 | 24 | It is configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example app. Changes to the library's JavaScript code will be reflected in the example app without a rebuild, but native code changes will require a rebuild of the example app. 25 | 26 | You can use various commands from the root directory to work with the project. 27 | 28 | To start the packager: 29 | 30 | ```sh 31 | yarn example start 32 | ``` 33 | 34 | To run the example app on Android: 35 | 36 | ```sh 37 | yarn example android 38 | ``` 39 | 40 | To run the example app on iOS: 41 | 42 | ```sh 43 | yarn example ios 44 | ``` 45 | 46 | To run the example app on Web: 47 | 48 | ```sh 49 | yarn example web 50 | ``` 51 | 52 | Make sure your code passes TypeScript and ESLint. Run the following to verify: 53 | 54 | ```sh 55 | yarn typecheck 56 | yarn lint 57 | ``` 58 | 59 | To fix formatting errors, run the following: 60 | 61 | ```sh 62 | yarn lint --fix 63 | ``` 64 | 65 | Remember to add tests for your change if possible. Run the unit tests by: 66 | 67 | ```sh 68 | yarn test 69 | ``` 70 | 71 | ### Commit message convention 72 | 73 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: 74 | 75 | - `fix`: bug fixes, e.g. fix crash due to deprecated method. 76 | - `feat`: new features, e.g. add new method to the module. 77 | - `refactor`: code refactor, e.g. migrate from class components to hooks. 78 | - `docs`: changes into documentation, e.g. add usage example for the module.. 79 | - `test`: adding or updating tests, e.g. add integration tests using detox. 80 | - `chore`: tooling changes, e.g. change CI config. 81 | 82 | Our pre-commit hooks verify that your commit message matches this format when committing. 83 | 84 | ### Linting and tests 85 | 86 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) 87 | 88 | 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. 89 | 90 | Our pre-commit hooks verify that the linter and tests pass when committing. 91 | 92 | ### Publishing to npm 93 | 94 | 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. 95 | 96 | To publish new versions, run the following: 97 | 98 | ```sh 99 | yarn release 100 | ``` 101 | 102 | ### Scripts 103 | 104 | The `package.json` file contains various scripts for common tasks: 105 | 106 | - `yarn`: setup project by installing dependencies. 107 | - `yarn typecheck`: type-check files with TypeScript. 108 | - `yarn lint`: lint files with ESLint. 109 | - `yarn test`: run unit tests with Jest. 110 | - `yarn example start`: start the Metro server for the example app. 111 | - `yarn example android`: run the example app on Android. 112 | - `yarn example ios`: run the example app on iOS. 113 | 114 | ### Sending a pull request 115 | 116 | > **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). 117 | 118 | When you're sending a pull request: 119 | 120 | - Prefer small pull requests focused on one change. 121 | - Verify that linters and tests are passing. 122 | - Review the documentation to make sure it looks good. 123 | - Follow the pull request template when opening a pull request. 124 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 125 | -------------------------------------------------------------------------------- /src/useFormSmartScroll.tsx: -------------------------------------------------------------------------------- 1 | import { useState, type RefObject, useCallback, useMemo } from 'react'; 2 | import type { TextInput, View } from 'react-native'; 3 | import Animated, { 4 | scrollTo, 5 | useAnimatedRef, 6 | useAnimatedScrollHandler, 7 | useAnimatedStyle, 8 | useDerivedValue, 9 | useSharedValue, 10 | withTiming, 11 | } from 'react-native-reanimated'; 12 | import { useKeyboard } from './useKeyboard'; 13 | import AnimatedView from 'react-native-reanimated'; 14 | import type { ScrollViewProps } from 'react-native'; 15 | import type { TextInputProps } from 'react-native'; 16 | 17 | interface FormType { 18 | [name: string]: any; 19 | } 20 | 21 | interface Props { 22 | padding?: number; 23 | } 24 | 25 | export type RefType = TextInput | View | AnimatedView.View | null; 26 | 27 | export const useFormSmartScroll = ({ 28 | padding = 0, 29 | }: Props = {}) => { 30 | const scrollY = useSharedValue(0); 31 | const scrollRef = useAnimatedRef(); 32 | 33 | const _keyboard = useKeyboard(); 34 | 35 | const scrollHandler = useAnimatedScrollHandler({ 36 | onScroll: (event) => { 37 | scrollY.value = event.contentOffset.y; 38 | }, 39 | }); 40 | 41 | type Elements = { 42 | [key in keyof T]: { 43 | isFocus: boolean; 44 | position: number; 45 | height: number; 46 | ref: RefObject; 47 | }; 48 | }; 49 | 50 | type Inputs = { 51 | [key in keyof T]: RefObject; 52 | }; 53 | 54 | const [state, setState] = useState({} as Elements); 55 | const [inputs, setInputs] = useState({} as Inputs); 56 | 57 | const translateStyle = useAnimatedStyle(() => { 58 | const currentFocus = Object.keys(state) 59 | .map((key) => state[key]) 60 | .find((el) => el?.isFocus); 61 | 62 | function value(): number { 63 | if (!currentFocus) return 0; 64 | 65 | if ( 66 | currentFocus.position > 67 | _keyboard.coordinates.end.screenY - 80 + scrollY.value 68 | ) { 69 | const diff = Math.abs( 70 | _keyboard.coordinates.end.screenY - 71 | currentFocus.position - 72 | currentFocus.height - 73 | padding + 74 | scrollY.value 75 | ); 76 | 77 | return -diff; 78 | } 79 | 80 | return 0; 81 | } 82 | 83 | return { 84 | transform: [{ translateY: withTiming(value()) }], 85 | }; 86 | }); 87 | 88 | const registerElement = (name: keyof T) => (ref: RefType) => { 89 | if (!state[name]) { 90 | ref?.measureInWindow((_, y, _w, h) => { 91 | setState((s) => ({ 92 | ...s, 93 | [name]: { 94 | isFocus: false, 95 | position: y, 96 | height: h, 97 | }, 98 | })); 99 | }); 100 | } 101 | }; 102 | 103 | const registerInput = 104 | (name: keyof T) => (ref: RefObject) => { 105 | if (!inputs[name]) { 106 | setInputs((s) => ({ 107 | ...s, 108 | [name]: ref, 109 | })); 110 | } 111 | }; 112 | 113 | const chainInput = useCallback( 114 | (name: keyof T) => { 115 | const input = inputs[name]; 116 | 117 | if (!input) return; 118 | 119 | input.current?.focus(); 120 | }, 121 | [inputs] 122 | ); 123 | 124 | useDerivedValue(() => { 125 | scrollTo(scrollRef, 0, scrollY.value, true); 126 | }); 127 | 128 | const onFocus = useCallback( 129 | (name: keyof T) => () => { 130 | setState((s) => ({ 131 | ...s, 132 | [name]: { 133 | ...s[name], 134 | isFocus: true, 135 | }, 136 | })); 137 | }, 138 | [] 139 | ); 140 | 141 | const onBlur = useCallback( 142 | (name: keyof T) => () => { 143 | setState((s) => ({ 144 | ...s, 145 | [name]: { 146 | ...s[name], 147 | isFocus: false, 148 | }, 149 | })); 150 | }, 151 | [] 152 | ); 153 | 154 | /** 155 | * Base props object for the ScrollView|Animated.ScrollView with good props to pass 156 | */ 157 | const baseScrollViewProps: ScrollViewProps = useMemo(() => { 158 | return { 159 | keyboardShouldPersistTaps: 'handled', 160 | }; 161 | }, []); 162 | 163 | /** 164 | * Base props object for the TextInput with good props to pass 165 | */ 166 | const baseTextInputProps = useCallback( 167 | (name: keyof T) => { 168 | return { 169 | onFocus: onFocus(name), 170 | onBlur: onBlur(name), 171 | blurOnSubmit: false, 172 | } as TextInputProps; 173 | }, 174 | [onBlur, onFocus] 175 | ); 176 | 177 | return { 178 | onFocus, 179 | onBlur, 180 | registerElement, 181 | registerInput, 182 | chainInput, 183 | translateStyle, 184 | scrollHandler, 185 | scrollRef, 186 | baseScrollViewProps, 187 | baseTextInputProps, 188 | }; 189 | }; 190 | -------------------------------------------------------------------------------- /example/src/third-party/gorhom-bottom-sheet/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef } from 'react'; 2 | import { View, StyleSheet, Button } from 'react-native'; 3 | import { 4 | BottomSheetModal, 5 | BottomSheetView, 6 | BottomSheetModalProvider, 7 | BottomSheetBackdrop, 8 | type BottomSheetBackdropProps, 9 | } from '@gorhom/bottom-sheet'; 10 | import { MagicScroll } from '../../../../src'; 11 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 12 | 13 | const Example = () => { 14 | const insets = useSafeAreaInsets(); 15 | const bottomSheetModalRef = useRef(null); 16 | 17 | const snapPoints = useMemo(() => ['25%', '80%'], []); 18 | 19 | const handlePresentModalPress = useCallback(() => { 20 | bottomSheetModalRef.current?.present(); 21 | }, []); 22 | 23 | const renderBackdrop = useCallback( 24 | (props: BottomSheetBackdropProps) => ( 25 | 31 | ), 32 | [] 33 | ); 34 | 35 | return ( 36 | 37 |