Anchor links and scroll-to utilities for React Native (+ Web). It has zero dependencies.
yarn add @nandorojo/anchor
If you're using react-native-web, you'll need at least version 0.15.3.
This works great to scroll to errors in Formik forms. See the ScrollToField
component.
This is the simplest usage:
import React from 'react'
import { ScrollTo, Target, ScrollView } from '@nandorojo/anchor'
import { View, Text } from 'react-native'
export default function App() {
return (
<View style={{ flex: 1 }}>
<ScrollView>
<ScrollTo target="bottom-content">
<Text>Scroll to bottom content</Text>
</ScrollTo>
<View style={{ height: 1000 }} />
<Target name="bottom-content">
<View style={{ height: 100, backgroundColor: 'blue' }} />
</Target>
</ScrollView>
</View>
)
}
- Expo Snack example (iOS and Android only, since Expo Snack uses an outdated react-native-web version)
- CodeSandbox example (web)
The library exports a ScrollView
and FlatList
component you can use as drop-in replacements for the react-native ones.
Note that the scroll to will only work if you use this library's scrollable components, or if you use a custom scrollable with the AnchorProvider
, as shown in the example below.
If you want to use your own scrollable, that's fine. You'll just have to do 2 things:
- Wrap them with the
AnchorProvider
- Register the scrollable with
useRegisterScroller
That's all the exported ScrollView
does for you.
import { AnchorProvider, useRegisterScrollable } from '@nandorojo/anchor'
import { ScrollView } from 'react-native'
export default function Provider() {
return (
<AnchorProvider>
<MyComponent />
</AnchorProvider>
)
}
// make sure this is the child of AnchorProvider
function MyComponent() {
const { register } = useRegisterScroller()
return (
<ScrollView ref={register}>
<YourContentHere />
</ScrollView>
)
}
If you need horizontal
scrolling, make sure you pass the horizontal
prop to both the AnchorProvider
, and the ScrollView
.
import { AnchorProvider, useRegisterScrollable } from '@nandorojo/anchor'
import { ScrollView } from 'react-native'
export default function Provider() {
return (
<AnchorProvider horizontal>
<MyComponent />
</AnchorProvider>
)
}
// make sure this is the child of AnchorProvider
function MyComponent() {
const { register } = useRegisterScroller()
return (
<ScrollView horizontal ref={register}>
<YourContentHere />
</ScrollView>
)
}
There are a few options for triggering a scroll-to event. The basic premise is the same as HTML anchor links. You need 1) a target to scroll to, and 2) something to trigger the scroll.
The simplest way to make a target is to use the Target
component.
Each target needs a unique name
prop. The name indicates where to scroll.
import { ScrollView, Target } from '@nandorojo/anchor'
export default function App() {
return (
<ScrollView>
<Target name="bottom">
<YourComponent />
</Target>
</ScrollView>
)
}
Next, we need a way to scroll to that target. The easiest way is to use the ScrollTo
component:
import { ScrollView, Target } from '@nandorojo/anchor'
import { Text, View } from 'react-native'
export default function App() {
return (
<ScrollView>
<ScrollTo target="bottom">
<Text>Click me to scroll down</Text>
</ScrollTo>
<View style={{ height: 500 }} />
<Target name="bottom">
<YourComponent />
</Target>
</ScrollView>
)
}
If you don't want to use the ScrollTo
component, you can also rely on the useScrollTo
with a custom pressable.
import { ScrollView, Target } from '@nandorojo/anchor'
import { Text, View } from 'react-native'
function CustomScrollTo() {
const { scrollTo } = useScrollTo()
const onPress = () => {
scrollTo('scrollhere') // required: target name
// you can also pass these optional parameters:
scrollTo('scrollhere', {
animated: true, // default true
offset: -10 // offset to scroll to, default -10 pts
})
}
return <Text onPress={onPress}>Scroll down</Text>
}
export default function App() {
return (
<ScrollView>
<CustomScrollTo />
<View style={{ height: 500 }} />
<Target name="scrollhere">
<YourComponent />
</Target>
</ScrollView>
)
}
The basic usage for determing the target to scroll to is using the Target
component.
However, if you want to use a custom component as your target, you'll use the useRegisterTarget
hook.
import { ScrollTo, useRegisterTarget, ScrollView } from '@nandorojo/anchor';
import { View } from 'react-native'
function BottomContent() {
const { register } = useRegisterTarget()
const ref = register('bottom-content') // use a unique name here
return <View ref={ref} />
}
function App() {
return (
<ScrollView>
<ScrollTo target="bottom-content">Scroll to bottom content</Anchor>
<View style={{ height: 500 }} />
<BottomContent />
</ScrollView>
);
}
This works with web (react-native-web 0.15.3 or higher).
To support iOS browsers, you should polyfill the smooth scroll API.
yarn add smoothscroll-polyfill
Then at the root of your app (App.js
, or pages/_app.js
for Next.js) call this:
import { Platform } from 'react-native'
if (Platform.OS === 'web' && typeof window !== 'undefined') {
require('smoothscroll-polyfill').polyfill()
}
react-native-web
's resolution for scroll position is currently wrong.
Until this issue is closed (necolas/react-native-web#2109), I'll be using this patch with patch-package
:
Click me to view patch
diff --git a/node_modules/@nandorojo/anchor/lib/module/index.js b/node_modules/@nandorojo/anchor/lib/module/index.js
index 6decdc0..d27b884 100644
--- a/node_modules/@nandorojo/anchor/lib/module/index.js
+++ b/node_modules/@nandorojo/anchor/lib/module/index.js
@@ -1,7 +1,7 @@
function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
import * as React from 'react';
-import { View, ScrollView as NativeScrollView, FlatList as NativeFlatList, findNodeHandle, Pressable } from 'react-native';
+import { View, ScrollView as NativeScrollView, FlatList as NativeFlatList, findNodeHandle, Pressable, Platform } from 'react-native';
const {
createContext,
forwardRef,
@@ -130,7 +130,10 @@ const useCreateAnchorsContext = ({
return new Promise(resolve => {
var _targetRefs$current, _targetRefs$current2;
- const node = scrollRef.current && findNodeHandle(scrollRef.current);
+ const node = Platform.select({
+ default: scrollRef.current,
+ web: scrollRef.current && scrollRef.current.getInnerViewNode && scrollRef.current.getInnerViewNode()
+ })
if (!node) {
return resolve({
diff --git a/node_modules/@nandorojo/anchor/src/index.js b/node_modules/@nandorojo/anchor/src/index.js
index 7259856..80fc63c 100644
--- a/node_modules/@nandorojo/anchor/src/index.js
+++ b/node_modules/@nandorojo/anchor/src/index.js
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { View, ScrollView as NativeScrollView, FlatList as NativeFlatList, findNodeHandle, Pressable, } from 'react-native';
+import { View, ScrollView as NativeScrollView, FlatList as NativeFlatList, findNodeHandle, Pressable, Platform } from 'react-native';
const { createContext, forwardRef, useContext, useMemo, useRef, useImperativeHandle, } = React;
// from react-merge-refs (avoid dependency)
function mergeRefs(refs) {
@@ -109,7 +109,11 @@ const useCreateAnchorsContext = ({ horizontal, }) => {
horizontal,
scrollTo: (name, { animated = true, offset = -10 } = {}) => {
return new Promise((resolve) => {
- const node = scrollRef.current && findNodeHandle(scrollRef.current);
+ const node = Platform.select({
+ default: scrollRef.current,
+ web: scrollRef.current && scrollRef.current.getInnerViewRef()
+ })
+ // const node = scrollRef.current && findNodeHandle(scrollRef.current);
if (!node) {
return resolve({
success: false,
One thing to keep in mind: the parent view of a ScrollView
on web must have a fixed height. Otherwise, the ScrollView
will just use window scrolling. This is a common source of confusion on web, and it took me a while to learn.
Typically, it's solved by doing this:
import { View, ScrollView } from 'react-native'
export default function App() {
return (
<View style={{ flex: 1 }}>
<ScrollView />
</View>
)
}
By wrapping ScrollView
with a flex: 1
View, we are confining its parent's size. If this doesn't solve it, try giving your parent View
a fixed height
:
import { View, ScrollView, Platform } from 'react-native'
export default function App() {
return (
<View style={{ flex: 1, height: Platform.select({ web: '100vh', default: undefined }) }}>
<ScrollView />
</View>
)
}
import {
AnchorProvider,
ScrollView,
FlatList,
useRegisterTarget,
useScrollTo,
ScrollTo,
Target,
useRegisterScroller,
useAnchors
} from '@nandorojo/anchor'
const Trigger = () => (
<ScrollTo
target="bottom"
options={{
animated: true,
offset: -10
}}
/>
)
target
required unique string indicating thename
of theTarget
to scroll tooptions
optional dictionaryanimated = true
whether the scroll should animate or notoffset = -10
a number in pixels to offset the scroll by. By default, it scrolls 10 pixels above the content.
const ContentToScrollTo = () => <Target name="bottom-content" />
name
required, unique string that identifies this View to scroll to- it only needs to be unique within a given ScrollView. You can reuse names for different scrollables, but I'd avoid doing that.
A react hook that returns a scrollTo(name, options?)
function. This serves as an alternative to the ScrollTo
component.
The first argument is required. It's a string that corresponds to your target's unique name
prop.
The second argument is an optional options
object, which is identical to the ScrollTo
component's options
prop.
import { ScrollView, Target } from '@nandorojo/anchor'
import { Text, View } from 'react-native'
function CustomScrollTo() {
const { scrollTo } = useScrollTo()
const onPress = () => {
scrollTo('scrollhere') // required: target name
// you can also pass these optional parameters:
scrollTo('scrollhere', {
animated: true, // default true
offset: -10 // offset to scroll to, default -10 pts
})
}
return <Text onPress={onPress}>Scroll down</Text>
}
export default function App() {
return (
<ScrollView>
<CustomScrollTo />
<View style={{ height: 500 }} />
<Target name="scrollhere">
<YourComponent />
</Target>
</ScrollView>
)
}
A hook that returns a register
function. This is an alternative option to using the ScrollView
or FlatList
components provided by this library.
Note that, to use this, you must first wrap the scrollable with AnchorProvider
. It's probably easier to just use the exported ScrollView
, but it's your call.
import { AnchorProvider, useRegisterScrollable } from '@nandorojo/anchor'
import { ScrollView } from 'react-native'
// make sure this is the child of AnchorProvider
function MyComponent() {
const { register } = useRegisterScroller()
return (
<ScrollView ref={register}>
<YourContentHere />
</ScrollView>
)
}
export default function Provider() {
return (
<AnchorProvider>
<MyComponent />
</AnchorProvider>
)
}
If you need to control a ScrollView
or FlatList
from outside of their scope:
import React from 'react'
import { useAnchors, ScrollView } from '@nandorojo/anchor'
export default function App() {
const anchors = useAnchors()
const onPress = () => {
anchors.current?.scrollTo('list')
}
return (
<ScrollView anchors={anchors}>
<Target name="list" />
</ScrollView>
)
}
- Create a
ScrollToField
component:
import React, { useEffect, useRef } from 'react'
import { Target, useScrollTo } from '@nandorojo/anchor'
import { useFormikContext } from 'formik'
function isObject(value?: object) {
return value && typeof value === 'object' && value.constructor === Object
}
function getRecursiveName(object?: object): string {
if (!object || !isObject(object)) {
return ''
}
const currentKey = Object.keys(object)[0]
if (!currentKey) {
return ''
}
if (!getRecursiveName(object[currentKey])) {
return currentKey
}
return currentKey + '.' + getRecursiveName(object[currentKey])
}
export function ScrollToField({ name }: { name: string }) {
const { submitCount, errors } = useFormikContext()
const { scrollTo } = useScrollTo()
const previousSubmitCount = useRef(submitCount)
const errorPath = getRecursiveName(errors)
useEffect(
function scrollOnSubmissionError() {
if (!errorPath) return
if (submitCount > previousSubmitCount.current && name) {
if (name === errorPath) {
scrollTo(name).then((didScroll) => console.log('[scroll-to-field] did scroll', name, didScroll))
}
}
previousSubmitCount.current = submitCount
},
[errorPath, errors, name, scrollTo, submitCount]
)
return <Target name={name} />
}
- Add it alongside your field:
const InputField = ({ name }) => {
const [{ value }] = useField(name)
return (
<View>
<ScrollToField name={name} />
<TextInput value={value} />
</View>
)
}
See the contributing guide to learn how to contribute to the repository and the development workflow.
MIT