Skip to content

πŸ¦… Anchor links and scroll-to utilities for React Native (+ Web)

License

Notifications You must be signed in to change notification settings

nandorojo/react-native-anchors

Repository files navigation

React Native Anchor πŸ¦…

Anchor links and scroll-to utilities for React Native (+ Web). It has zero dependencies.

Installation

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.

Usage

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>
  )
}

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.

Use custom scrollables

If you want to use your own scrollable, that's fine. You'll just have to do 2 things:

  1. Wrap them with the AnchorProvider
  2. 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>
  )
}

Trigger a scroll-to event

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>
  )
}

Create a custom scrollTo component

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>
  )
}

useRegisterTarget()

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>
);
}

Web usage

Smooth Scrolling

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()
}

Patch

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,

Gotchas

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>
  )
}

Imports

import {
  AnchorProvider,
  ScrollView,
  FlatList,
  useRegisterTarget,
  useScrollTo,
  ScrollTo,
  Target,
  useRegisterScroller,
  useAnchors
} from '@nandorojo/anchor'

ScrollTo

const Trigger = () => (
  <ScrollTo
    target="bottom"
    options={{
      animated: true,
      offset: -10
    }}
  />
)

Props

  • target required unique string indicating the name of the Target to scroll to
  • options optional dictionary
    • animated = true whether the scroll should animate or not
    • offset = -10 a number in pixels to offset the scroll by. By default, it scrolls 10 pixels above the content.

Target

const ContentToScrollTo = () => <Target name="bottom-content" />

Props

  • 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.

useScrollTo

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>
  )
}

useRegisterScroller

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>
  )
}

useAnchors

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>
 )
}

Formik Error Usage

  1. 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} />
}
  1. Add it alongside your field:
const InputField = ({ name }) => {
  const [{ value }] = useField(name)

  return (
    <View>
      <ScrollToField name={name} />
      <TextInput value={value} />
    </View>
  )
}

Contributing

See the contributing guide to learn how to contribute to the repository and the development workflow.

License

MIT