Tap controlled ScrollView in React Native

Facebooktwittergoogle_plustumblrmailFacebooktwittergoogle_plustumblrmail

Note: This was developed for Android. I don’t have an iOS device, so I don’t know how it will work in iOS.

I’m working on a React Native index card app. I want the user to be able to swipe through the cards. I also want the user to be able to add as much content as they need to do the index card, which means the content may need to be scrollable. This presented an issue, though, as the scrolling action interferes with the swipe animation. After some thought, I decided a good approach to this is to have up and down icons to scroll through the content, rather than swiping through the content. Looking through the ScrollView documentation, I don’t see an existing way to create a tap controlled ScrollView, so I decided to make my own.

An index card with a tap controlled scrollview

An index card with a tap controlled scrollview

Scrolling a tap controlled scrollview programmatically

Scrolling through a ScrollView programmatically is fairly straight forward. React Native provides a scrollTo() method. To use scrollTo() you need to have a reference to the ScrollView. You set the ref when create the component, like this:

<ScrollView ref="_ScrollView"> ... </ScrollView>

To access this particular ScrollView and is properties and methods you use this.refs['_ScrollView']. You can name the ref whatever you want, I chose ‘_ScrollView’, but you could call it Sparky if you wanted.

this.refs['_ScrollView'] gives you access to all of the methods on the ScrollView. So now we can use scrollTo() to scroll programmatically, like this:

this.refs['_ScrollView'].scrollTo({ x: 0, y: 10, animated: true });

Where x is left/right scroll and y is up/down scroll.

To impliment this, I create up and down icons wrapped with a TouchableOpacity components that have onPress events pointing to doIconPress(direction). In the doIconPress(direction) method I use scrollTo() to scroll up or down, depending on the direction. In order to do this, I need to know the current y value.

I’m getting the y value by listening to the ScrollView’s onScroll event and setting it on the state:

watchScroll = (event) => {
    this.setState({
        scrollPosition: event.nativeEvent.contentOffset.y
    });
}

Now in my scrollTo() I can do this.refs['_ScrollView'].scrollTo({ x: 0, y: this.state.scrollPosition + distance * way, animated: true })

The distance variable comes from a property ‘scrollDistance’ set on the tap controlled scrollview component:

<TapControlledScrollView scrollDistance="30"> ... </TapControlledScrollView>

The way variable determines the direction of the scroll and is passed in from the Touchable Opacity onPress event:

<TouchableOpacity
    style={{ alignSelf: 'flex-start'}}
    onPress={ this.doIconPress.bind(this, 'up', this.props.scrollDistance) }
>
    { this.renderIcon('top') }
</TouchableOpacity>

Conditionally rendering the icons

So, now I can scroll up and down by tapping on the up and down icons, but it doesn’t really make sense for these icons to be available if there is nothing to scroll.

Determining when to render the up icon is easy. If the scrollPosition property on the state is anything other than 0, there is something to scroll up to. For this one I just check if this.state.scrollPosition > 0 and render it if it is.

Determining if the down icon should show is a  bit more complicated. I need to determine height of the content relative to the height of the view and the scroll position.

When the component initially renders I can get the height of the content area from the onContentSizeChange method of the ScrollView and pass it to a helper function I called setIcons():

onContentSizeChange={ (width, height) => this.setIcons(height) }

Now I can compare the content height to the view height in the setIcons() function by using the measure() method from NativeMethodsMixin. NativeMethodsMixin is part of React Native and doesn’t need to be installed separately. You can just import it using import NativeMethodsMixin from 'NativeMethodsMixin;. This took a bit of time for me to figure out because my IDE was not happy with the syntax.

You call measure() with a reference to your element and a callback. If the call is successful, the callback will be called with x, y, width, height, pageX, and pageY respectively. I really only needed height, though.

The height from the measure() method is the view height. So, just check if the content height (which was passed in from the onContentSizeChange event earlier) is greater than the height from measure():

setIcons(contentHeight) {
    NativeMethodsMixin.measure.call(this.refs['_ScrollView'],
        (x, y, width, height) => {
            if(contentHeight > height) {
                this.setState({ bottomVisible: true })
            } else {
                this.setState({ bottomVisible: false });
            }
        }
    )
}

This determines whether or not to render the button on initial rendering of the tap controlled ScrollView, but I also need to hide the icon when the end of the scrollable content is reached. For this, I’m using going back to the onScroll event. I can check if the view height plus the y offset is greater than the content height. In this case, I get layoutMeasurement and contentSize from event.nativeEvent:

watchScroll = (event) => {
    const nEvent = event.nativeEvent;

    this.setState({
        scrollPosition: nEvent.contentOffset.y
    });

    if ((nEvent.contentOffset.y +
        nEvent.layoutMeasurement.height) >=
            nEvent.contentSize.height) {
        this.setState({ bottomVisible: false });
    } else {
        this.setState({ bottomVisible: true });
    }

}

And that is pretty much it. The full component is below.

Leave a Reply

Your email address will not be published. Required fields are marked *