├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── demo.gif ├── .gitignore ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── @types └── react-native.d.ts ├── LICENSE ├── README.md ├── example ├── .watchmanconfig ├── App.tsx ├── app.json ├── assets │ ├── icon.png │ └── splash.png ├── babel.config.js ├── metro.config.js ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── KeyboardAvoidingContainer.tsx ├── KeyboardAvoidingFlatList.tsx ├── KeyboardAvoidingScrollView.tsx ├── KeyboardAvoidingSectionList.tsx ├── index.ts └── utils │ ├── EventEmitter.ts │ ├── hijackTextInputEvents.ts │ ├── measureInWindow.ts │ ├── react.ts │ └── utility-types.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | commands: 4 | yarn_install: 5 | description: Install Javascript dependencies using Yarn. This command correctly configures the cache for any number of package.json and yarn.lock files. 6 | steps: 7 | - run: 8 | name: Create cache checksum file 9 | command: | 10 | mkdir -p ~/.tmp/checksumfiles 11 | find . -type f -name 'package.json' -not -path "*node_modules*" -exec cat {} + >> ~/.tmp/checksumfiles/package.json 12 | find . -type f -name 'yarn.lock' -not -path "*node_modules*" -exec cat {} + >> ~/.tmp/checksumfiles/yarn.lock 13 | - restore_cache: 14 | name: Restore Yarn cache 15 | keys: 16 | - yarn-cache-{{ arch }}-{{ checksum "~/.tmp/checksumfiles/package.json" }}-{{ checksum "~/.tmp/checksumfiles/yarn.lock" }}-{{ .Environment.CACHE_VERSION }} 17 | - run: 18 | name: Install dependencies with Yarn 19 | command: yarn install --non-interactive --cache-folder ~/.cache/yarn 20 | - save_cache: 21 | name: Save Yarn cache 22 | paths: 23 | - ~/.cache/yarn 24 | key: | 25 | yarn-cache-{{ arch }}-{{ checksum "~/.tmp/checksumfiles/package.json" }}-{{ checksum "~/.tmp/checksumfiles/yarn.lock" }}-{{ .Environment.CACHE_VERSION }} 26 | executors: 27 | node_10: 28 | docker: 29 | - image: circleci/node:10 30 | environment: 31 | - PATH: '/opt/yarn/yarn-v1.5.1/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' 32 | 33 | jobs: 34 | test_node_10: 35 | executor: node_10 36 | steps: 37 | - checkout 38 | - yarn_install 39 | - run: 40 | name: Remove example directory 41 | command: rm -rf example 42 | - run: 43 | name: Run tests 44 | command: yarn test 45 | release: 46 | executor: node_10 47 | steps: 48 | - checkout 49 | - yarn_install 50 | - run: 51 | name: Release the package 52 | command: yarn semantic-release 53 | 54 | workflows: 55 | version: 2 56 | test_and_release: 57 | jobs: 58 | - test_node_10 59 | - release: 60 | requires: 61 | - test_node_10 62 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Unignore dotfiles 2 | !.* 3 | 4 | # Node.js 5 | **/node_modules/ 6 | 7 | # Compilation artifacts 8 | /dist/ 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@alkafinance/eslint-config/eslint').ESLintConfig} */ 2 | module.exports = { 3 | extends: [ 4 | '@alkafinance/eslint-config', 5 | '@alkafinance/eslint-config-typescript', 6 | '@alkafinance/eslint-config-react/native', 7 | '@alkafinance/eslint-config-typescript/react-native', 8 | ], 9 | rules: { 10 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin 11 | '@typescript-eslint/no-magic-numbers': 'off', 12 | // https://github.com/benmosher/eslint-plugin-import 13 | 'import/extensions': 'off', 14 | 'import/no-unresolved': 'off', 15 | // https://github.com/Intellicode/eslint-plugin-react-native 16 | 'react-native/no-color-literals': 'off', 17 | }, 18 | overrides: [ 19 | { 20 | files: ['*.js', '**/.*.js'], 21 | ...require('@alkafinance/eslint-config/script'), 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /.github/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alkafinance/react-native-keyboard-avoiding-scroll-view/a6cf6f29bc6900cc14a531e8b034a702115b98d8/.github/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | **/.DS_Store 3 | 4 | # Node.js 5 | **/node_modules/ 6 | **/.yarn-integrity 7 | 8 | # Logs 9 | /logs/ 10 | **/*.log 11 | **/npm-debug.log* 12 | **/yarn-debug.log* 13 | **/yarn-error.log* 14 | 15 | # Compilation artifacts 16 | /dist/ 17 | /example/.expo/ 18 | 19 | # ESLint 20 | /.eslintcache 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | **/node_modules/ 3 | **/package.json 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "editorconfig.editorconfig", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | "editor.insertSpaces": true, 4 | "editor.rulers": [80], 5 | "editor.tabSize": 2, 6 | "[javascript]": { 7 | "editor.formatOnSave": true 8 | }, 9 | "[javascriptreact]": { 10 | "editor.formatOnSave": true 11 | }, 12 | "[typescript]": { 13 | "editor.formatOnSave": true 14 | }, 15 | "[typescriptreact]": { 16 | "editor.formatOnSave": true 17 | }, 18 | "[json]": { 19 | "editor.formatOnSave": true 20 | }, 21 | "[jsonc]": { 22 | "editor.formatOnSave": true 23 | }, 24 | "[yaml]": { 25 | "editor.formatOnSave": true 26 | }, 27 | 28 | "files.exclude": { 29 | "**/.git/": true, 30 | "**/.DS_Store": true, 31 | ".eslintcache": true 32 | }, 33 | "search.exclude": { 34 | "**/node_modules/": true 35 | }, 36 | 37 | "eslint.nodePath": "./node_modules", 38 | "eslint.options": { 39 | "extensions": [".js", ".ts", ".tsx"], 40 | "rules": { 41 | "padding-line-between-statements": "off" 42 | } 43 | }, 44 | "eslint.validate": [ 45 | "javascript", 46 | "javascriptreact", 47 | { 48 | "language": "typescript", 49 | "autoFix": true 50 | }, 51 | { 52 | "language": "typescriptreact", 53 | "autoFix": true 54 | } 55 | ], 56 | 57 | "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, 58 | "javascript.implicitProjectConfig.checkJs": true, 59 | "javascript.suggestionActions.enabled": false, 60 | "javascript.validate.enable": true, 61 | "typescript.tsdk": "./node_modules/typescript/lib", 62 | "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, 63 | "typescript.disableAutomaticTypeAcquisition": true, 64 | "typescript.format.enable": false, 65 | "typescript.preferences.quoteStyle": "single", 66 | "typescript.preferences.renameShorthandProperties": true, 67 | 68 | "emmet.showExpandedAbbreviation": "never" 69 | } 70 | -------------------------------------------------------------------------------- /@types/react-native.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-native/Libraries/vendor/emitter/EventEmitter' { 2 | import {EventEmitter} from 'react-native'; 3 | 4 | const EventEmitter: EventEmitter; 5 | 6 | export = EventEmitter; 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Alka, Inc (https://alka.app) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-keyboard-avoiding-scroll-view 2 | 3 | [![npm version](https://img.shields.io/npm/v/react-native-keyboard-avoiding-scroll-view.svg)](https://www.npmjs.org/package/react-native-keyboard-avoiding-scroll-view) 4 | [![CircleCI Status](https://img.shields.io/circleci/project/github/alkafinance/react-native-keyboard-avoiding-scroll-view/master.svg)](https://circleci.com/gh/alkafinance/workflows/react-native-keyboard-avoiding-scroll-view/tree/master) 5 | ![license: MIT](https://img.shields.io/npm/l/react-native-keyboard-avoiding-scroll-view.svg) 6 | ![Supports Android and iOS](https://img.shields.io/badge/platforms-android%20|%20ios-lightgrey.svg) 7 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 8 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 9 | 10 | React Native ScrollView extension that prevents inputs from being covered by the keyboard. 11 | 12 | 13 | 14 | ## Getting started 15 | 16 | `$ npm install react-native-keyboard-avoiding-scroll-view --save` 17 | 18 | ## Usage 19 | 20 | Import `KeyboardAvoidingScrollView`, `KeyboardAvoidingFlatList`, or `KeyboardAvoidingSectionList` and use them like the regular `ScrollView`, `FlatList` or `SectionList` components from React Native core. Internally, these components are wrapped in another custom component called `KeyboardAvoidingContainer`, which is also exported for advanced use cases. 21 | 22 | ```javascript 23 | import {KeyboardAvoidingScrollView} from 'react-native-keyboard-avoiding-scroll-view'; 24 | 25 | function MyComponent() { 26 | return ( 27 | }> 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | ``` 35 | 36 | ## Props 37 | 38 | - [Inherited `ScrollView` props...](https://facebook.github.io/react-native/docs/scrollview.html#props) 39 | - or [inherited `FlatList` props...](https://facebook.github.io/react-native/docs/flatlist#props) 40 | - or [inherited `SectionList` props...](https://facebook.github.io/react-native/docs/sectionlist#props) 41 | 42 | - [`stickyFooter`](#stickyFooter) 43 | - [`containerStyle`](#containerStyle) 44 | 45 | --- 46 | 47 | # Reference 48 | 49 | ## Props 50 | 51 | ### `stickyFooter` 52 | 53 | Used to display a fixed view under the scrollable content. Sticky footer is always shown above the keyboard, which could, for example, be the desired behaviour for a submit button. 54 | 55 | | Type | Required | 56 | | ----------------- | -------- | 57 | | `React.ReactNode` | No | 58 | 59 | --- 60 | 61 | ### `containerStyle` 62 | 63 | Used to style the container component. 64 | 65 | | Type | Required | 66 | | ---------------------- | -------- | 67 | | `StyleProp` | No | 68 | 69 | ## License 70 | 71 | [MIT License](./LICENSE) © Alka, Inc 72 | -------------------------------------------------------------------------------- /example/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /example/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {SafeAreaView, StyleSheet, View} from 'react-native' 3 | import {Button, Colors, TextInput, Title} from 'react-native-paper' 4 | import {KeyboardAvoidingScrollView} from '../src' 5 | 6 | export default function App() { 7 | return ( 8 | 9 | 14 | 21 | 22 | }> 23 | Sign up with your email address 24 | 29 | 34 | 39 | 44 | 49 | 54 | 59 | 64 | 69 | 74 | 75 | 76 | ) 77 | } 78 | 79 | const styles = StyleSheet.create({ 80 | container: { 81 | flex: 1, 82 | backgroundColor: Colors.white, 83 | }, 84 | content: { 85 | padding: 16, 86 | }, 87 | title: {}, 88 | textInput: { 89 | marginTop: 16, 90 | }, 91 | footer: { 92 | padding: 16, 93 | backgroundColor: Colors.white, 94 | }, 95 | }) 96 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Example", 4 | "slug": "example", 5 | "privacy": "public", 6 | "sdkVersion": "33.0.0", 7 | "platforms": ["ios", "android", "web"], 8 | "version": "1.0.0", 9 | "orientation": "portrait", 10 | "icon": "./assets/icon.png", 11 | "splash": { 12 | "image": "./assets/splash.png", 13 | "resizeMode": "contain", 14 | "backgroundColor": "#ffffff" 15 | }, 16 | "updates": { 17 | "fallbackToCacheTimeout": 0 18 | }, 19 | "assetBundlePatterns": ["**/*"], 20 | "ios": { 21 | "supportsTablet": true 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alkafinance/react-native-keyboard-avoiding-scroll-view/a6cf6f29bc6900cc14a531e8b034a702115b98d8/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alkafinance/react-native-keyboard-avoiding-scroll-view/a6cf6f29bc6900cc14a531e8b034a702115b98d8/example/assets/splash.png -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache(true); 3 | 4 | return { 5 | presets: ['babel-preset-expo'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | resolver: { 5 | extraNodeModules: { 6 | '@babel/runtime': path.resolve(__dirname, 'node_modules/@babel/runtime'), 7 | react: path.resolve(__dirname, 'node_modules/react'), 8 | 'react-native': path.resolve(__dirname, 'node_modules/react-native'), 9 | }, 10 | }, 11 | watchFolders: [path.resolve(__dirname, '../src')], 12 | }; 13 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject" 9 | }, 10 | "dependencies": { 11 | "react-native-keyboard-avoiding-scroll-view": "file:../src", 12 | "expo": "^33.0.0", 13 | "react": "16.8.3", 14 | "react-dom": "^16.8.6", 15 | "react-native": "https://github.com/expo/react-native/archive/sdk-33.0.0.tar.gz", 16 | "react-native-paper": "^2.16.0", 17 | "react-native-web": "^0.11.4" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "16.8.23", 21 | "@types/react-native": "0.60.2", 22 | "babel-preset-expo": "^5.1.1", 23 | "expo-cli": "^3.0.4", 24 | "typescript": "^3.4.5" 25 | }, 26 | "private": true 27 | } 28 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-keyboard-avoiding-scroll-view", 3 | "version": "0.0.0-development", 4 | "description": "React Native ScrollView extension that prevents inputs from being covered by the keyboard", 5 | "keywords": [ 6 | "react native", 7 | "keyboard", 8 | "keyboard avoiding", 9 | "keyboard aware", 10 | "scrollview", 11 | "alka" 12 | ], 13 | "homepage": "https://github.com/alkafinance/react-native-keyboard-avoiding-scroll-view#readme", 14 | "bugs": { 15 | "url": "https://github.com/alkafinance/react-native-keyboard-avoiding-scroll-view/issues" 16 | }, 17 | "license": "MIT", 18 | "author": "Ayan Yenbekbay ", 19 | "files": [ 20 | "dist/" 21 | ], 22 | "main": "dist/index.js", 23 | "react-native": "dist/index.js", 24 | "types": "dist/index.d.ts", 25 | "sideEffects": false, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/alkafinance/react-native-keyboard-avoiding-scroll-view.git" 29 | }, 30 | "publishConfig": { 31 | "registry": "https://registry.npmjs.org/" 32 | }, 33 | "scripts": { 34 | "typecheck": "tsc --noEmit --pretty", 35 | "lint": "eslint --ext .js,.ts,.tsx --cache .", 36 | "test": "run-p --silent --print-label typecheck lint", 37 | "bootstrap": "yarn --cwd example && yarn", 38 | "build": "rm -rf dist && yarn tsc --pretty --declaration --outDir dist", 39 | "example": "yarn --cwd example", 40 | "prepublishOnly": "yarn build", 41 | "presemantic-release": "yarn build", 42 | "semantic-release": "semantic-release" 43 | }, 44 | "prettier": { 45 | "bracketSpacing": false, 46 | "jsxBracketSameLine": true, 47 | "printWidth": 80, 48 | "semi": false, 49 | "singleQuote": true, 50 | "tabWidth": 2, 51 | "trailingComma": "all", 52 | "useTabs": false 53 | }, 54 | "husky": { 55 | "hooks": { 56 | "pre-commit": "lint-staged", 57 | "commit-msg": "commitlint -e", 58 | "pre-push": "yarn test" 59 | } 60 | }, 61 | "lint-staged": { 62 | "**/*.{js,ts,tsx,json,yml,yaml}": [ 63 | "prettier --write", 64 | "git add" 65 | ], 66 | "**/*.{js,ts,tsx}": [ 67 | "eslint --ext .js,.ts,.tsx --cache --fix", 68 | "git add" 69 | ] 70 | }, 71 | "commitlint": { 72 | "extends": [ 73 | "@commitlint/config-conventional" 74 | ] 75 | }, 76 | "peerDependencies": { 77 | "react": "*", 78 | "react-native": "*" 79 | }, 80 | "dependencies": {}, 81 | "devDependencies": { 82 | "@alkafinance/eslint-config": "^1.0.1", 83 | "@alkafinance/eslint-config-react": "^1.0.0", 84 | "@alkafinance/eslint-config-typescript": "^1.0.0", 85 | "@commitlint/cli": "^8.1.0", 86 | "@commitlint/config-conventional": "^8.1.0", 87 | "@types/react": "16.8.23", 88 | "@types/react-native": "0.60.2", 89 | "eslint": "5.10.0", 90 | "husky": "^3.0.1", 91 | "lint-staged": "^9.2.1", 92 | "npm-run-all": "^4.1.5", 93 | "prettier": "^1.18.2", 94 | "semantic-release": "^15.13.18", 95 | "typescript": "^3.5.3", 96 | "utility-types": "^3.7.0" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/KeyboardAvoidingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 | import { 3 | Dimensions, 4 | findNodeHandle, 5 | Keyboard, 6 | KeyboardEvent, 7 | LayoutAnimation, 8 | LayoutChangeEvent, 9 | NativeScrollEvent, 10 | NativeSyntheticEvent, 11 | Platform, 12 | SafeAreaView, 13 | ScreenRect, 14 | ScrollView, 15 | ScrollViewProps, 16 | StyleProp, 17 | StyleSheet, 18 | TextInput as NativeTextInput, 19 | View, 20 | ViewProps, 21 | ViewStyle, 22 | } from 'react-native' 23 | import {NoInfer} from './utils/utility-types' 24 | import {genericMemo} from './utils/react' 25 | import {measureInWindow} from './utils/measureInWindow' 26 | import {hijackTextInputEvents} from './utils/hijackTextInputEvents' 27 | 28 | const {height: SCREEN_HEIGHT} = Dimensions.get('window') 29 | const KEYBOARD_PADDING = 48 30 | 31 | export interface ExternalKeyboardAvoidingContainerProps { 32 | stickyFooter?: React.ReactNode 33 | containerStyle?: StyleProp 34 | } 35 | export interface InternalKeyboardAvoidingContainerProps< 36 | TScrollViewProps extends ScrollViewProps 37 | > { 38 | ScrollViewComponent: React.ComponentType> 39 | scrollViewRef: React.Ref> 40 | scrollViewProps: TScrollViewProps 41 | stickyFooterRef: React.Ref 42 | stickyFooterProps: ViewProps 43 | } 44 | export interface KeyboardAvoidingContainerProps< 45 | TScrollViewProps extends ScrollViewProps 46 | > 47 | extends ExternalKeyboardAvoidingContainerProps, 48 | InternalKeyboardAvoidingContainerProps {} 49 | 50 | export const KeyboardAvoidingContainer = genericMemo( 51 | ({ 52 | stickyFooter, 53 | containerStyle, 54 | ScrollViewComponent, 55 | scrollViewRef, 56 | scrollViewProps, 57 | stickyFooterRef, 58 | stickyFooterProps, 59 | }: KeyboardAvoidingContainerProps) => { 60 | return ( 61 | 62 | 63 | {stickyFooter && ( 64 | 65 | {stickyFooter} 66 | 67 | )} 68 | 69 | ) 70 | }, 71 | ) 72 | 73 | export function useKeyboardAvoidingContainerProps< 74 | TScrollViewProps extends ScrollViewProps 75 | >({ 76 | stickyFooter, 77 | containerStyle, 78 | 79 | onScroll, 80 | contentContainerStyle: contentContainerStyleProp, 81 | style: styleProp, 82 | ...passthroughScrollViewProps 83 | }: TScrollViewProps & ExternalKeyboardAvoidingContainerProps): Omit< 84 | KeyboardAvoidingContainerProps, 85 | 'ScrollViewComponent' 86 | > { 87 | const scrollViewRef = useRef>(null) 88 | const stickyFooterRef = useRef(null) 89 | 90 | const scrollPositionRef = useRef(0) 91 | const scrollViewOffsetRef = useRef(0) 92 | const keyboardLayoutRef = useRef(null) 93 | const stickyFooterLayoutRef = useRef(null) 94 | const focusedTextInputLayoutRef = useRef(null) 95 | const layoutAnimationConfiguredRef = useRef(false) 96 | 97 | const [scrollViewOffset, setScrollViewOffset] = useState(0) 98 | const [ 99 | scrollViewContentBottomInset, 100 | setScrollViewContentBottomInset, 101 | ] = useState(0) 102 | const [scrollViewBottomInset, setScrollViewBottomInset] = useState(0) 103 | const [stickyFooterOffset, setStickyFooterOffset] = useState(0) 104 | 105 | useEffect(() => { 106 | requestAnimationFrame(() => { 107 | if (scrollViewRef.current) { 108 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 109 | const scrollResponder = (scrollViewRef.current as any).getScrollResponder() as ScrollView 110 | 111 | scrollResponder.scrollTo({ 112 | y: 113 | scrollPositionRef.current + 114 | (scrollViewOffset - scrollViewOffsetRef.current), 115 | animated: true, 116 | }) 117 | scrollViewOffsetRef.current = scrollViewOffset 118 | } 119 | }) 120 | }, [scrollViewOffset]) 121 | 122 | const handleScroll = useCallback( 123 | (event: NativeSyntheticEvent) => { 124 | scrollPositionRef.current = event.nativeEvent.contentOffset.y 125 | 126 | if (onScroll) { 127 | onScroll(event) 128 | } 129 | }, 130 | [onScroll], 131 | ) 132 | const handleStickyFooterLayout = useCallback((event: LayoutChangeEvent) => { 133 | setScrollViewBottomInset(event.nativeEvent.layout.height) 134 | }, []) 135 | 136 | const updateOffsets = useCallback( 137 | ({keyboardEvent}: {keyboardEvent?: KeyboardEvent} = {}) => { 138 | const keyboardAbsoluteTop = keyboardLayoutRef.current 139 | ? keyboardLayoutRef.current.screenY 140 | : SCREEN_HEIGHT 141 | const keyboardAbsoluteTopWithPadding = 142 | keyboardAbsoluteTop - KEYBOARD_PADDING 143 | const keyboardHeight = keyboardLayoutRef.current 144 | ? keyboardLayoutRef.current.height 145 | : 0 146 | const focusedTextInputAbsoluteBottom = focusedTextInputLayoutRef.current 147 | ? focusedTextInputLayoutRef.current.screenY + 148 | focusedTextInputLayoutRef.current.height 149 | : keyboardAbsoluteTopWithPadding 150 | const stickyFooterAbsoluteBottom = stickyFooterLayoutRef.current 151 | ? stickyFooterLayoutRef.current.screenY + 152 | stickyFooterLayoutRef.current.height 153 | : keyboardAbsoluteTopWithPadding 154 | const stickyFooterHeight = stickyFooterLayoutRef.current 155 | ? stickyFooterLayoutRef.current.height 156 | : 0 157 | 158 | const newScrollViewOffset = Math.max( 159 | 0, 160 | focusedTextInputAbsoluteBottom - 161 | keyboardAbsoluteTopWithPadding + 162 | stickyFooterHeight, 163 | ) 164 | const newScrollViewBottomInset = 165 | KEYBOARD_PADDING + stickyFooterHeight + keyboardHeight 166 | const newStickyFooterOffset = Math.max( 167 | 0, 168 | stickyFooterAbsoluteBottom - keyboardAbsoluteTop, 169 | ) 170 | 171 | if ( 172 | !layoutAnimationConfiguredRef.current && 173 | (newScrollViewBottomInset !== scrollViewContentBottomInset || 174 | newStickyFooterOffset !== stickyFooterOffset) 175 | ) { 176 | LayoutAnimation.configureNext( 177 | keyboardEvent && keyboardEvent.duration > 10 178 | ? { 179 | duration: keyboardEvent.duration, 180 | update: { 181 | duration: keyboardEvent.duration, 182 | type: 183 | (keyboardEvent.easing != null && 184 | LayoutAnimation.Types[keyboardEvent.easing]) || 185 | 'keyboard', 186 | }, 187 | } 188 | : LayoutAnimation.Presets.easeInEaseOut, 189 | ) 190 | requestAnimationFrame(() => { 191 | setTimeout( 192 | () => { 193 | layoutAnimationConfiguredRef.current = false 194 | }, 195 | keyboardEvent && keyboardEvent.duration > 10 196 | ? keyboardEvent.duration 197 | : LayoutAnimation.Presets.easeInEaseOut.duration, 198 | ) 199 | }) 200 | layoutAnimationConfiguredRef.current = true 201 | } 202 | setScrollViewOffset(newScrollViewOffset) 203 | setScrollViewContentBottomInset(newScrollViewBottomInset) 204 | setStickyFooterOffset(newStickyFooterOffset) 205 | }, 206 | [scrollViewContentBottomInset, stickyFooterOffset], 207 | ) 208 | 209 | useEffect(() => { 210 | const keyboardWillShowSub = Keyboard.addListener( 211 | 'keyboardWillShow', 212 | // Right before the keyboard is shown, we know that a text input is being 213 | // focused on. 214 | // Therefore, we calculate the layout of the text input and the layout 215 | // of the sticky footer and update offsets accordingly. 216 | async event => { 217 | // Prevent race conditions 218 | if (keyboardLayoutRef.current) return 219 | 220 | const {endCoordinates: newKeyboardLayout} = event 221 | const newFocusedTextInputNodeHandle = NativeTextInput.State.currentlyFocusedField() 222 | const newStickyFooterNodeHandle = findNodeHandle( 223 | stickyFooterRef.current, 224 | ) 225 | const [ 226 | newFocusedTextInputLayout, 227 | newStickyFooterLayout, 228 | ] = await Promise.all([ 229 | newFocusedTextInputNodeHandle 230 | ? measureInWindow(newFocusedTextInputNodeHandle) 231 | : Promise.resolve(null), 232 | newStickyFooterNodeHandle 233 | ? measureInWindow(newStickyFooterNodeHandle) 234 | : Promise.resolve(null), 235 | ]) 236 | 237 | keyboardLayoutRef.current = newKeyboardLayout 238 | focusedTextInputLayoutRef.current = newFocusedTextInputLayout 239 | stickyFooterLayoutRef.current = newStickyFooterLayout 240 | 241 | updateOffsets({keyboardEvent: event}) 242 | }, 243 | ) 244 | const keyboardWillChangeFrameSub = Keyboard.addListener( 245 | 'keyboardWillChangeFrame', 246 | event => { 247 | // Avoid overlap with `keyboardWillShow` 248 | if (!keyboardLayoutRef.current) return 249 | 250 | const {endCoordinates: newKeyboardLayout} = event 251 | // Avoid overlap with `keyboardWillHide` 252 | if ( 253 | newKeyboardLayout.height === keyboardLayoutRef.current.height || 254 | newKeyboardLayout.height === 0 255 | ) { 256 | return 257 | } 258 | 259 | keyboardLayoutRef.current = newKeyboardLayout 260 | }, 261 | ) 262 | const keyboardWillHideSub = Keyboard.addListener( 263 | 'keyboardWillHide', 264 | // Right before the keyboard is hidden, we know that a text input is being 265 | // blurred. 266 | // Therefore, we reset the layouts and update the offsets accordingly. 267 | event => { 268 | keyboardLayoutRef.current = null 269 | focusedTextInputLayoutRef.current = null 270 | stickyFooterLayoutRef.current = null 271 | 272 | updateOffsets({keyboardEvent: event}) 273 | }, 274 | ) 275 | 276 | return () => { 277 | keyboardWillShowSub.remove() 278 | keyboardWillChangeFrameSub.remove() 279 | keyboardWillHideSub.remove() 280 | } 281 | }, [updateOffsets]) 282 | 283 | useEffect(() => { 284 | const textInputEvents = hijackTextInputEvents() 285 | // We watch for the switch between two text inputs and update offsets 286 | // accordingly. 287 | // A switch between two text inputs happens when a keyboard is shown 288 | // and another text input is currently being focused on. 289 | const sub = textInputEvents.addListener( 290 | 'textInputDidFocus', 291 | newFocusedTextInputNodeHandle => { 292 | requestAnimationFrame(async () => { 293 | if ( 294 | !keyboardLayoutRef.current || 295 | !focusedTextInputLayoutRef.current 296 | ) { 297 | return 298 | } 299 | 300 | const newFocusedTextInputLayout = newFocusedTextInputNodeHandle 301 | ? await measureInWindow(newFocusedTextInputNodeHandle) 302 | : null 303 | 304 | focusedTextInputLayoutRef.current = newFocusedTextInputLayout 305 | ? { 306 | ...newFocusedTextInputLayout, 307 | screenY: 308 | newFocusedTextInputLayout.screenY + 309 | scrollViewOffsetRef.current, 310 | } 311 | : newFocusedTextInputLayout 312 | 313 | updateOffsets() 314 | }) 315 | }, 316 | ) 317 | 318 | return () => { 319 | sub.remove() 320 | } 321 | }, [scrollViewOffset, updateOffsets]) 322 | 323 | const scrollViewContentContainerStyle = useMemo(() => { 324 | const flatContentContainerStyleProp = 325 | StyleSheet.flatten(contentContainerStyleProp) || {} 326 | 327 | let scrollViewContentBottomInsetProp = 0 328 | if ('paddingBottom' in flatContentContainerStyleProp) { 329 | if (typeof flatContentContainerStyleProp.paddingBottom === 'number') { 330 | scrollViewContentBottomInsetProp = 331 | flatContentContainerStyleProp.paddingBottom 332 | } 333 | } else if ('padding' in flatContentContainerStyleProp) { 334 | if (typeof flatContentContainerStyleProp.padding === 'number') { 335 | scrollViewContentBottomInsetProp = flatContentContainerStyleProp.padding 336 | } 337 | } 338 | 339 | return { 340 | paddingBottom: 341 | scrollViewContentBottomInset + scrollViewContentBottomInsetProp, 342 | } 343 | }, [contentContainerStyleProp, scrollViewContentBottomInset]) 344 | const scrollViewStyle = useMemo(() => { 345 | const flatStyleProp = StyleSheet.flatten(styleProp) || {} 346 | 347 | let scrollViewBottomInsetProp = 0 348 | if ('marginBottom' in flatStyleProp) { 349 | if (typeof flatStyleProp.marginBottom === 'number') { 350 | scrollViewBottomInsetProp = flatStyleProp.marginBottom 351 | } 352 | } else if ('margin' in flatStyleProp) { 353 | if (typeof flatStyleProp.margin === 'number') { 354 | scrollViewBottomInsetProp = flatStyleProp.margin 355 | } 356 | } 357 | 358 | return { 359 | marginBottom: scrollViewBottomInset + scrollViewBottomInsetProp, 360 | } 361 | }, [scrollViewBottomInset, styleProp]) 362 | 363 | const scrollViewProps = useMemo( 364 | () => 365 | // eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion 366 | ({ 367 | keyboardDismissMode: Platform.OS === 'ios' ? 'interactive' : 'on-drag', 368 | keyboardShouldPersistTaps: 'handled', 369 | ...passthroughScrollViewProps, 370 | onScroll: handleScroll, 371 | contentContainerStyle: [ 372 | contentContainerStyleProp, 373 | scrollViewContentContainerStyle, 374 | ], 375 | style: [styleProp, scrollViewStyle], 376 | } as TScrollViewProps), 377 | [ 378 | contentContainerStyleProp, 379 | handleScroll, 380 | passthroughScrollViewProps, 381 | scrollViewContentContainerStyle, 382 | scrollViewStyle, 383 | styleProp, 384 | ], 385 | ) 386 | const stickyFooterProps = useMemo( 387 | () => ({ 388 | onLayout: handleStickyFooterLayout, 389 | style: [styles.stickyFooter, {bottom: stickyFooterOffset}], 390 | }), 391 | [handleStickyFooterLayout, stickyFooterOffset], 392 | ) 393 | 394 | return { 395 | stickyFooter, 396 | containerStyle, 397 | scrollViewProps, 398 | scrollViewRef, 399 | stickyFooterProps, 400 | stickyFooterRef, 401 | } 402 | } 403 | 404 | const styles = StyleSheet.create({ 405 | container: { 406 | flex: 1, 407 | }, 408 | stickyFooter: { 409 | position: 'absolute', 410 | bottom: 0, 411 | left: 0, 412 | right: 0, 413 | }, 414 | }) 415 | -------------------------------------------------------------------------------- /src/KeyboardAvoidingFlatList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {FlatList, FlatListProps} from 'react-native' 3 | import { 4 | ExternalKeyboardAvoidingContainerProps, 5 | KeyboardAvoidingContainer, 6 | useKeyboardAvoidingContainerProps, 7 | } from './KeyboardAvoidingContainer' 8 | import {generic} from './utils/react' 9 | 10 | export interface KeyboardAvoidingFlatListProps 11 | extends FlatListProps, 12 | ExternalKeyboardAvoidingContainerProps {} 13 | 14 | export const KeyboardAvoidingFlatList = generic( 15 | (props: KeyboardAvoidingFlatListProps) => { 16 | const KeyboardAvoidingContainerProps = useKeyboardAvoidingContainerProps( 17 | props, 18 | ) 19 | 20 | return ( 21 | 25 | ) 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /src/KeyboardAvoidingScrollView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {ScrollView, ScrollViewProps} from 'react-native' 3 | import { 4 | ExternalKeyboardAvoidingContainerProps, 5 | KeyboardAvoidingContainer, 6 | useKeyboardAvoidingContainerProps, 7 | } from './KeyboardAvoidingContainer' 8 | 9 | export interface KeyboardAvoidingScrollViewProps 10 | extends ScrollViewProps, 11 | ExternalKeyboardAvoidingContainerProps {} 12 | 13 | export const KeyboardAvoidingScrollView: React.FC< 14 | KeyboardAvoidingScrollViewProps 15 | > = props => { 16 | const KeyboardAvoidingContainerProps = useKeyboardAvoidingContainerProps( 17 | props, 18 | ) 19 | 20 | return ( 21 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/KeyboardAvoidingSectionList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {SectionList, SectionListProps} from 'react-native' 3 | import { 4 | ExternalKeyboardAvoidingContainerProps, 5 | KeyboardAvoidingContainer, 6 | useKeyboardAvoidingContainerProps, 7 | } from './KeyboardAvoidingContainer' 8 | import {generic} from './utils/react' 9 | 10 | export interface KeyboardAvoidingSectionListProps 11 | extends SectionListProps, 12 | ExternalKeyboardAvoidingContainerProps {} 13 | 14 | export const KeyboardAvoidingSectionList = generic( 15 | ( 16 | props: KeyboardAvoidingSectionListProps, 17 | ) => { 18 | const KeyboardAvoidingContainerProps = useKeyboardAvoidingContainerProps( 19 | props, 20 | ) 21 | 22 | return ( 23 | 27 | ) 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './KeyboardAvoidingContainer' 2 | export * from './KeyboardAvoidingFlatList' 3 | export * from './KeyboardAvoidingScrollView' 4 | export * from './KeyboardAvoidingSectionList' 5 | -------------------------------------------------------------------------------- /src/utils/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventEmitterListener, 3 | EmitterSubscription as EventEmitterSubscription, 4 | EventEmitter as WeaklyTypedEventEmitterInstance, 5 | } from 'react-native' 6 | import WeaklyTypedEventEmitter from 'react-native/Libraries/vendor/emitter/EventEmitter' 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | export type ListenerType = [T] extends [(...args: infer U) => any] 10 | ? U 11 | : [T] extends [void] 12 | ? [] 13 | : [T] 14 | 15 | export interface EventEmitterInstance 16 | extends Omit< 17 | WeaklyTypedEventEmitterInstance, 18 | | 'addListener' 19 | | 'once' 20 | | 'removeAllListeners' 21 | | 'listeners' 22 | | 'emit' 23 | | 'removeListener' 24 | > { 25 | addListener( 26 | eventType: K, 27 | listener: (...args: ListenerType) => void, 28 | context?: unknown, 29 | ): EventEmitterSubscription 30 | once( 31 | eventType: K, 32 | listener: (...args: ListenerType) => void, 33 | context?: unknown, 34 | ): EventEmitterSubscription 35 | removeAllListeners(eventType: keyof TEvents): void 36 | listeners(eventType: keyof TEvents): EventEmitterSubscription[] 37 | emit( 38 | eventType: K, 39 | ...params: ListenerType 40 | ): void 41 | removeListener( 42 | eventType: K, 43 | listener: (...args: ListenerType) => void, 44 | ): void 45 | } 46 | 47 | export type EventEmitter = EventEmitterInstance 48 | export type EventEmitterListener = EventEmitterListener 49 | 50 | export const EventEmitter = (WeaklyTypedEventEmitter as unknown) as { 51 | new (): EventEmitterInstance 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/hijackTextInputEvents.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore: internal module 2 | import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState' 3 | import {EventEmitter} from './EventEmitter' 4 | 5 | interface TextInputEvents { 6 | textInputDidFocus: (focusedTextInputId: number) => void 7 | textInputDidBlur: (focusedTextInputId: number) => void 8 | } 9 | 10 | let textInputEvents: EventEmitter | null = null 11 | 12 | export function hijackTextInputEvents() { 13 | if (textInputEvents) return textInputEvents 14 | 15 | textInputEvents = new EventEmitter() 16 | 17 | const originalFocusTextInput = TextInputState.focusTextInput.bind( 18 | TextInputState, 19 | ) 20 | const originalBlurTextInput = TextInputState.blurTextInput.bind( 21 | TextInputState, 22 | ) 23 | 24 | let currentlyFocusedTextInputId: number | null = null 25 | 26 | TextInputState.focusTextInput = (focusedTextInputId: number | null) => { 27 | originalFocusTextInput(focusedTextInputId) 28 | 29 | if ( 30 | currentlyFocusedTextInputId !== focusedTextInputId && 31 | focusedTextInputId !== null 32 | ) { 33 | currentlyFocusedTextInputId = focusedTextInputId 34 | if (textInputEvents) { 35 | textInputEvents.emit('textInputDidFocus', focusedTextInputId) 36 | } 37 | } 38 | } 39 | TextInputState.blurTextInput = (focusedTextInputId: number | null) => { 40 | originalBlurTextInput(focusedTextInputId) 41 | 42 | if ( 43 | currentlyFocusedTextInputId === focusedTextInputId && 44 | focusedTextInputId !== null 45 | ) { 46 | currentlyFocusedTextInputId = null 47 | if (textInputEvents) { 48 | textInputEvents.emit('textInputDidBlur', focusedTextInputId) 49 | } 50 | } 51 | } 52 | 53 | return textInputEvents 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/measureInWindow.ts: -------------------------------------------------------------------------------- 1 | import {UIManager, ScreenRect} from 'react-native' 2 | 3 | export function measureInWindow(node: number) { 4 | return new Promise(resolve => { 5 | UIManager.measureInWindow(node, (screenX, screenY, width, height) => { 6 | resolve({screenX, screenY, width, height}) 7 | }) 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/react.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // Passthrough function used to type a full-fledged generic React component 4 | // based on a generic function 5 | export function generic< 6 | TComponent, 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | TProps = TComponent extends (...args: any[]) => any 9 | ? Parameters[0] 10 | : never 11 | >(Component: TComponent) { 12 | return Component as TComponent & 13 | Pick< 14 | React.ComponentType, 15 | 'propTypes' | 'contextTypes' | 'defaultProps' | 'displayName' 16 | > 17 | } 18 | 19 | // Generic version of React.memo 20 | export const genericMemo = < 21 | TComponent, // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | TProps = TComponent extends (...args: any[]) => any 23 | ? Parameters[0] 24 | : never 25 | >( 26 | Component: TComponent, 27 | propsAreEqual?: ( 28 | prevProps: Readonly, 29 | nextProps: Readonly, 30 | ) => boolean, 31 | ) => 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | (React.memo(Component as any, propsAreEqual) as any) as TComponent & 34 | Pick, 'displayName'> 35 | -------------------------------------------------------------------------------- /src/utils/utility-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Used to prevent a usage of type `T` from being inferred in other generics. 3 | * 4 | * @example 5 | * declare function assertEqual(actual: T, expected: NoInfer): boolean 6 | * 7 | * @description 8 | * Type `T` will now only be inferred based on the type of the `actual` param, and 9 | * the `expected` param is required to be assignable to the type of `actual`. 10 | * This allows you to give one particular usage of type `T` full control over how the 11 | * compiler infers type `T`. 12 | * 13 | * @see https://github.com/Microsoft/TypeScript/issues/14829#issuecomment-322267089 14 | */ 15 | export type NoInfer = T & {[K in keyof T]: T[K]} 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "jsx": "react-native", 6 | "lib": ["es2017", "esnext.asynciterable"], 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "resolveJsonModule": true, 16 | "strictFunctionTypes": true, 17 | "strictNullChecks": true, 18 | "strictPropertyInitialization": true, 19 | "target": "esnext" 20 | }, 21 | "include": ["./@types/**/*", "./src/**/*"] 22 | } 23 | --------------------------------------------------------------------------------