Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React Native - How to simulate a RefreshControl in a horizontal scrollable list

The Problem

I am developing a horizontal scrollable list which always scroll one entire screen (i.e. one "page") on each swipe. When it is on the first page and the user pulls to right again, I need to refresh the entire list.

As for a vertical ScrollView, a standard RefreshControl would appear if a user pulls down when the view is at its topmost position. But for the horizontal version, there is no standard solution.

My Experiment

The idea

I have tried to add a PanResponder to capture the touching and moving events. Note that I am using a VirtualizedList in my application. The basic logic is that:

  • I create a hidden View (whose width is set to 0 by default) with an ActivityIndicator in it, and insert this View as the ListHeaderComponent into the VirtualizedList
  • I listen to the VirtualizedList's onScroll event and keep saving the latest scroll position
  • When then scroll position is nearly 0, and the user pulls right (event captured gesture through PanResponder), then I animate the width of the hidden View with ActivityIndicator to simulate the RefreshControl behavior

The result

However, the PanResponder does not properly capture the desired event. Most of the time, when I pull right at the beginning of the VirtualizedList (on an Android device), only the standard hitting edge animation appears (a gradient blue layer shows up).

By adding some logs, I found that onPanResponderGrant can occasionally be triggered, but none of onPanResponderMove, onPanResponderRelease, onPanResponderTerminate or onPanResponderTerminationRequest is ever fired.

When onPanResponderGrant is triggered, the hidden ActivityIndicator occasionally appears paritally. The "pull to right" effect only shows up shortly and always stops instantly with only a very narrow part of the hidden View appears.

I guess that the underlying ScrollView steals the event and prevents my code from responding. But even if I set onPanResponderTerminationRequest to always reture false still won't help. Even if I directly subsitute the VirtualizedList with a ScrollView also yield the same result.

Does anyone have any idea how to properly capture the touch and move event when ScrollView scrolls beyond is top? Or is there another viable way to implement a horizontal version of a RefreshControl?

The Experimental Code

import React from 'react'
import {
  Text, View, ActivityIndicator, ScrollView, VirtualizedList,
  Dimensions, PanResponder, Animated, Easing
} from 'react-native'


let DATA = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20];

class Test extends React.PureComponent {

  constructor(props) {
    super(props);

    this.state = {
      refreshIndicatorWidth: new Animated.Value(0),
    };

    this.onScroll = evt => this._scrollPos = evt.nativeEvent.contentOffset.x;
    this.onPullToRefresh = dist => Animated.timing(this.state.refreshIndicatorWidth, {
      toValue: dist,
      duration: 0,
      easing: Easing.linear,
    }).start();
    this.resetRefreshStatus = () => {
      Animated.timing(this.state.refreshIndicatorWidth, {
        toValue: 0,
      }).start();
    };

    const shouldRespondPan = ({dx, dy}) => (this._scrollPos||0) < 50 && dx > 10 && Math.abs(dx) > Math.abs(dy);
    this._panResponder = PanResponder.create({
      onMoveShouldSetPanResponder: (evt, gestureState) => {
        return shouldRespondPan(gestureState)
      },
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
        return shouldRespondPan(gestureState)
      },
      onPanResponderGrant: (evt, gestureState) => {
        console.warn('----pull-Grant: ' + JSON.stringify(gestureState));
      },
      onPanResponderMove: (evt, gestureState) => {
        console.warn('----pull-Move: ' + JSON.stringify(gestureState));
        this.onPullToRefresh(gestureState.dx)
      },
      onPanResponderTerminationRequest: () => {
        console.warn('----pull-TerminationRequest: ' + JSON.stringify(gestureState));
        return false
      },
      onPanResponderRelease: (evt, gestureState) => {
        console.warn('----pull-Release: ' + JSON.stringify(gestureState));
        this.props.refreshMoodList();
      },
      onPanResponderTerminate: () => {
        console.warn('----pull-Terminate: ' + JSON.stringify(gestureState));
        this.resetRefreshStatus();
      },
    });

    this.renderRow = this.renderRow.bind(this);
  }

  componentDidUpdate(prevProps, prevState, prevContext) {
    console.warn('----updated: ' + JSON.stringify(this.props));
    this.resetRefreshStatus();
  }

  renderRow({item, index}) {
    return (
      <View style={{flex: 1, alignItems: 'center', justifyContent: 'center', width: Dimensions.get('window').width, height: '100%'}}>
        <Text>{item}</Text>
      </View>
    );
  }

  render() {
    /******* Test with a VirtualizedList *******/
    return (
      <VirtualizedList
        {...this._panResponder.panHandlers}
        data={DATA}
        ListEmptyComponent={<View/>}
        getItem={getItem}
        getItemCount={getItemCount}
        keyExtractor={keyExtractor}
        renderItem={this.renderRow}
        horizontal={true}
        pagingEnabled={true}
        showsHorizontalScrollIndicator={false}
        ListHeaderComponent={(
          <Animated.View style={{flex: 1, height: '100%', width: this.state.refreshIndicatorWidth, justifyContent: 'center', alignItems: 'center'}}>
            <ActivityIndicator size="large"/>
          </Animated.View>
        )}
        onScroll={this.onScroll}
      />
    );
    /******* Test with a ScrollView *******/
    return (
      <View style={{height:Dimensions.get('window').height}}>
        <ScrollView
          {...this._panResponder.panHandlers}
          horizontal={true}
        >
          <View style={{width: Dimensions.get('window').width * 2, height: 100, backgroundColor: 'green'}}>
            <Text>123</Text>
          </View>
        </ScrollView>
      </View>
    );
  }
}

const getItem = (data, index) => data[index];
const getItemCount = data => data.length;
const keyExtractor = (item, index) => 'R' + index;

export default Test;

Temporary Workarounds (Not so perfect)

  • Fire the refresh event (as well as the animation) directly inside onPanResponderGrant: This way, I'm still not able to track touch moves. So the scrolling pane cannot follow the touch point.

  • Move the operations in onPanResponderMove to onMoveShouldSetPanResponder with additional conditions (e.g. adding my own "grant" condition judgment): This way, I can track the touch movements, but since I haven't got the real "grant", I'm not able to capture the *Release/*Terminate/*End event (i.e., I won't know when the touch event ends, therefore never actually know when to fire the refreshing event!)

Perfect Solutions

Waiting for your idea...

like image 583
DeNG Avatar asked Nov 07 '22 19:11

DeNG


1 Answers

You can use the contentOffset to determine if a user has "pulled to refresh" on a horizontal list. The following should work on both a FlatList and a ScrollView:

onScroll={(e) => {
  const xOffset = e.nativeEvent.contentOffset.x;

  // logic goes here to handle a negative value

}}
scrollEventThrottle={16}

You can conditinally do something or set a flag when the xOffset hits a certain value. Hopefully that will get you started!

like image 146
Jason Gaare Avatar answered Nov 15 '22 12:11

Jason Gaare