React NativeでInstagramの様なタブを再現する方法

インスタグラムの様な切り替えが可能なタブのサンプル

キャプチャー

.

ソースコード

.

import React from 'react';
import { StyleSheet, View, Image, ScrollView, Dimensions, NativeSyntheticEvent, NativeScrollEvent, TouchableOpacity, FlatList, Text, ViewProps, StyleProp, ViewStyle, GestureResponderEvent } from 'react-native';

interface Props {
  content: JSX.Element
}

const colors = {
  gray: "gray",
  white: "white",
  darkGray: "darkgray"
}

const images = {
  icon: require("../../assets/images/favicon.png")
}

const TAB_ICON_AREA_HEIGHT = 44;

const component: React.FC<Props> = ({ content }) => {

  const [myTabRef] = React.useState<MyTabRef>({})
  const [index, setIndex] = React.useState(0);
  
  const [viewY, setViewY] = React.useState(Dimensions.get("window").height) // host scroll view size
  const [contentY, setContentY] = React.useState(Dimensions.get("window").height) // in content size

  // const [isLockHost, setIsLockHost] = React.useState(false);
  const tabContentScrollViewHeight = viewY - TAB_ICON_AREA_HEIGHT;

  const [scrollY, setScrollY] = React.useState(0);

  return (
    <View>
      <ScrollView
        onLayout={(e)=>setViewY(e.nativeEvent.layout.height)}
        // onContentSizeChange={(h)=> setContentY(h) }
        onScroll={(e)=>{
          const y = e.nativeEvent.contentOffset.y

          if(y >= contentY){
            myTabRef?.goLock?.(false);
            console.log("isSpeed:", scrollY - y, "@", y, contentY)
            // setIsLockHost(true); 
          }
          setScrollY(y);
        }}
        scrollEventThrottle={16}
        style={{ width: Dimensions.get("window").width }}
        stickyHeaderIndices={[1]}
        bounces={false}
      >
        <View>
          <View onLayout={(e)=> setContentY(e.nativeEvent.layout.height)}>
            {content}
          </View>
          <View>
            <View style={{ display: "flex", flexDirection: "row", backgroundColor: colors.darkGray, height: TAB_ICON_AREA_HEIGHT /* 計算 */ }}>
              <TouchableOpacity
                style={[
                  myTabStyles.icon,
                  index == 0 ? { borderBottomColor: colors.white } : {}
                ]}
                onPress={() => myTabRef.goTo?.(0)}
              >
                <Image style={myTabStyles.tabIcon} source={images.icon} />
              </TouchableOpacity>
              <TouchableOpacity
                style={[
                  myTabStyles.icon,
                  index == 1 ? { borderBottomColor: colors.white } : {},
                ]}
                onPress={() => myTabRef.goTo?.(1)}
              >
                <Image style={myTabStyles.tabIcon} source={images.icon} />
              </TouchableOpacity>
            </View>
          </View>
          <_MyTabs 
            maxHeight={tabContentScrollViewHeight}
            myTabRef={myTabRef}
            onChangeIndex={setIndex}
            // setHostLockState={setIsLockHost}
            />
        </View>

      </ScrollView>
    </View>
  );
};

export default React.memo(component);

type MyTabRef = {
  goTo?: (index: number) => void
  goLock?: (state: boolean) => void
}

type MyTabProps = {
  maxHeight: number
  myTabRef?: MyTabRef
  onChangeIndex?: (ix: number) => void
  // setHostLockState: (s: boolean) => void 
}

const _MyTabs: React.FC<MyTabProps> = ({ maxHeight, myTabRef, onChangeIndex }) => {
  const scrollView = React.useRef<ScrollView>(null);
  const width = Dimensions.get('window').width;

  const goTo = (index: number) => {
    scrollView.current?.scrollTo({
      x: index * width,
    });
  };

  const [isLock, setIsLock] = React.useState(true);
  const goLock = (state: boolean = true) => {
    if(!state && _chkNeedLock(index)) return; // "no need lock" request and chk. 
    setIsLock(state);
  }

  const [index, setIndex] = React.useState(0);
  const _setIndex = (ix: number) => {
    setIndex(ix);
    onChangeIndex?.(ix);

    if(_chkNeedLock(ix)) setIsLock(true);
    else if(scrollYs[ix] > 0) setIsLock(false); // <!!!> Tab Change and Can Scroll
  }

  const _chkNeedLock = (ix: number) => {
    return (contentSizes[ix] <= maxHeight) ? true : false // small and Need Lock
  }

  const onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void = (ev) => {
    const x = ev.nativeEvent.contentOffset.x;
    const i = Math.floor((width / 2 + x) / width);
    if (index != i) _setIndex(i);
  };

  if(myTabRef){
    myTabRef.goTo = goTo;
    myTabRef.goLock = goLock;
  }

  /* ScrollView in Tab */
  const [scrollYs, setScrollYs] = React.useState([0, 0]);
  const [contentSizes, setContentSizes] = React.useState([500, 500])
  const scRef0 = React.useRef<ScrollView>(null)
  const scRef1 = React.useRef<ScrollView>(null)

  const [touchY, setTouchY] = React.useState(0);

  const _onTouchStart = (e: GestureResponderEvent) => {
    // return;
    setTouchY(e.nativeEvent.locationY)
  }

  const _onTouchMove = (e: GestureResponderEvent) => {
    // return;
    const y = e.nativeEvent.locationY;
    if(touchY > y && isLock){
      if(_chkNeedLock(index)) goLock(false);
    }
  }

  return (
    <View>
      <ScrollView
        style={{ maxHeight }}
        snapToInterval={width}
        horizontal
        decelerationRate="fast"
        showsHorizontalScrollIndicator={false}
        bounces={false}
        ref={scrollView}
        onScroll={onScroll}
        scrollEventThrottle={32}
      >
        <View style={{ width }} >
          <ScrollView 
            ref={scRef0}
            scrollEnabled={!isLock}
            onScroll={(e)=>{
              const y = e.nativeEvent.contentOffset.y;
              const befY = scrollYs[0];
              if( y <= 0 && befY > y ){
                goLock(true);
                e.stopPropagation()
                scRef0.current?.scrollTo({
                  y: 0
                })
              }
              setScrollYs([y, scrollYs[1]])
              // setHostLockState(false); // Host UnLock
            }}
            onTouchStart={_onTouchStart}
            onTouchMove={_onTouchMove}
            scrollEventThrottle={16}
            >
            <View onLayout={(e)=>setContentSizes([e.nativeEvent.layout.height, contentSizes[1]])} >
              <Image source={{ uri: "https://younaship.com/Nashi.png" }} style={{height: width, width, backgroundColor: "gray" }}/>
              <Image source={{ uri: "https://younaship.com/Nashi.png" }} style={{height: width, width, backgroundColor: "gray" }}/>
              <Image source={{ uri: "https://younaship.com/Nashi.png" }} style={{height: width, width, backgroundColor: "gray" }}/>
              <Image source={{ uri: "https://younaship.com/Nashi.png" }} style={{height: width, width, backgroundColor: "gray" }}/>
            </View>
          </ScrollView>
        </View>
        <View style={{ width: Dimensions.get('window').width }}>
          <ScrollView 
            ref={scRef1}
            scrollEnabled={!isLock}
            onScroll={(e)=>{
              const y = e.nativeEvent.contentOffset.y;
              const befY = scrollYs[1];
              if( y <= 0 && befY > y ){
                goLock(true);
                e.stopPropagation()
                scRef1.current?.scrollTo({
                  y: 0
                })
              }
              setScrollYs([scrollYs[0], y])
              // setHostLockState(false); // Host UnLock
            }}
            onTouchStart={_onTouchStart}
            onTouchMove={_onTouchMove}
            scrollEventThrottle={16}
            >
            <View onLayout={(e)=>setContentSizes([contentSizes[0], e.nativeEvent.layout.height])}>
              <Image source={{ uri: "https://younaship.com/Nashi.png" }} style={{height: width, width, backgroundColor: "yellow" }}/>
            </View>
          </ScrollView>
        </View>
      </ScrollView>
    </View>
  );
};

const myTabStyles = StyleSheet.create({
  icon: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 10,
    borderBottomWidth: 2,
  },
  tabIcon: {
    width: 24,
    height: 24,
  },
});