react native 抖音视频列表页

实现效果

需要实现的效果主要有两个,一个是上下翻页效果,还有就是点赞的动画。

效果 (简书好像不支持 webp 格式图片 只能将就一下)

列表页上下翻页实现

播放视频使用 react-native-video 库。列表使用 FlatList,每个 item 占用一整屏,配合 pagingEnabled 属性可以翻页效果。
通过 onViewableItemsChanged 来控制只有当前的页面才播放视频。

const ShortVideoPage = () => {
  const [currentItem, setCurrentItem] = useState(0);
  const [data, setData] = useState<ItemData[]>([]);

  const _onViewableItemsChanged = useCallback(({ viewableItems }) => {
    // 这个方法为了让state对应当前呈现在页面上的item的播放器的state
    // 也就是只会有一个播放器播放,而不会每个item都播放
    // 可以理解为,只要不是当前再页面上的item 它的状态就应该暂停
    // 只有100%呈现再页面上的item(只会有一个)它的播放器是播放状态
    if (viewableItems.length === 1) {
      setCurrentItem(viewableItems[0].index);
    }
  }, []);

  useEffect(() => {
    const mockData = [];
    for (let i = 0; i < 100; i++) {
      mockData.push({ id: i, pause: false });
    }
    setData(mockData);
  }, []);

  return (
    <View style={{ flex: 1 }}>
      <StatusBar
        backgroundColor="transparent"
        translucent
      />
      <FlatList<ItemData>
        onMoveShouldSetResponder={() => true}
        data={data}
        renderItem={({ item, index }) => (
          <ShortVideoItem
            paused={index !== currentItem}
            id={item.id}
          />
        )}
        pagingEnabled={true}
        getItemLayout={(item, index) => {
          return { length: HEIGHT, offset: HEIGHT * index, index };
        }}
        onViewableItemsChanged={_onViewableItemsChanged}
        keyExtractor={(item, index) => index.toString()}
        viewabilityConfig={{
          viewAreaCoveragePercentThreshold: 80, // item滑动80%部分才会到下一个
        }}
      />
    </View>
  );
};

点赞效果

单次点击的时候切换暂停/播放状态,连续多次点击每次在点击位置出现一个爱心,随机旋转一个角度,爱心先放大再变透明消失。

爱心动画实现

const AnimatedHeartView = React.memo(
  (props: AnimatedHeartProps) => {
    // [-25, 25]随机一个角度
    const rotateAngle = `${Math.round(Math.random() * 50 - 25)}deg`;
    const animValue = React.useRef(new Animated.Value(0)).current;

    React.useEffect(() => {
      Animated.sequence([
        Animated.spring(animValue, {
          toValue: 1,
          useNativeDriver: true,
          bounciness: 5,
        }),
        Animated.timing(animValue, {
          toValue: 2,
          useNativeDriver: true,
        }),
      ]).start(() => {
        props.onAnimFinished();
      });
    }, [animValue, props]);

    return (
      <Animated.Image
        style={{
          position: 'absolute',
          width: 108,
          height: 126,
          top: props.y - 100,
          left: props.x - 54,
          opacity: animValue.interpolate({
            inputRange: [0, 1, 2],
            outputRange: [1, 1, 0],
          }),
          transform: [
            {
              scale: animValue.interpolate({
                inputRange: [0, 1, 2],
                outputRange: [1.5, 1.0, 2],
              }),
            },
            {
              rotate: rotateAngle,
            },
          ],
        }}
        source={require('./img/heart.webp')}
      />
    );
  },
  () => true,
);

连续点赞判定

监听手势,记录每次点击时间 lastClickTime,设置 CLICK_THRESHOLD 连续两次点击事件间隔小于 CLICK_THRESHOLD 视为连续点击,在点击位置创建爱心,添加到 heartList,否则视为单次点击,暂停播放。

const ShortVideoItem = React.memo((props: ShortVideoItemProps) => {
  const [paused, setPaused] = React.useState(props.paused);
  const [data, setData] = React.useState<VideoData>();
  const [heartList, setHeartList] = React.useState<HeartData[]>([]);
  const lastClickTime = React.useRef(0); // 记录上次点击时间
  const pauseHandler = React.useRef<number>();

  useEffect(() => {
    setTimeout(() => {
      setData({
        video: TEST_VIDEO,
        hasFavor: false,
      });
    });
  }, []);

  useEffect(() => {
    setPaused(props.paused);
  }, [props.paused]);

  const _addHeartView = React.useCallback(heartViewData => {
    setHeartList(list => [...list, heartViewData]);
  }, []);

  const _removeHeartView = React.useCallback(index => {
    setHeartList(list => list.filter((item, i) => index !== i));
  }, []);

  const _favor = React.useCallback(
    (hasFavor, canCancelFavor = true) => {
      if (!hasFavor || canCancelFavor) {
        setData(preValue => (preValue ? { ...preValue, hasFavor: !hasFavor } : preValue));
      }
    }, [],
  );

  const _handlerClick = React.useCallback(
    (event: GestureResponderEvent) => {
      const { pageX, pageY } = event.nativeEvent;
      const heartViewData = {
        x: pageX,
        y: pageY - 60,
        key: new Date().getTime().toString(),
      };
      const currentTime = new Date().getTime();
      // 连续点击
      if (currentTime - lastClickTime.current < CLICK_THRESHOLD) {
        pauseHandler.current && clearTimeout(pauseHandler.current);
        _addHeartView(heartViewData);
        if (data && !data.hasFavor) {
          _favor(false, false);
        }
      } else {
        pauseHandler.current = setTimeout(() => {
          setPaused(preValue => !preValue);
        }, CLICK_THRESHOLD);
      }

      lastClickTime.current = currentTime;
    }, [_addHeartView, _favor, data],
  );

  return <View
    onStartShouldSetResponder={() => true}
    onResponderGrant={_handlerClick}
    style={{ height: HEIGHT }}
  >
    {
      data
        ? <Video source={{ uri: data?.video }}
          style={styles.backgroundVideo}
          paused={paused}
          resizeMode={'contain'}
          repeat
        />
        : null
    }
    {
      heartList.map(({ x, y, key }, index) => {
        return (
          <AnimatedHeartView
            x={x}
            y={y}
            key={key}
            onAnimFinished={() => _removeHeartView(index)}
          />
        );
      })
    }
    <View style={{ justifyContent: 'flex-end', paddingHorizontal: 22, flex: 1 }}>
      <View style={{
        backgroundColor: '#000',
        opacity: 0.8,
        height: 32,
        borderRadius: 16,
        alignItems: 'center',
        justifyContent: 'center',
        marginRight: 'auto',
        paddingHorizontal: 8,
      }}>
        <Text
          style={{ fontSize: 14, color: '#FFF' }}
        >
          短视频招募了
        </Text>
      </View>
      <View
        style={{ height: 1, marginTop: 12, backgroundColor: '#FFF' }}
      />
      <Text
        style={{
          marginTop: 12,
          color: '#FFF',
          fontSize: 16,
          fontWeight: 'bold',
        }}
        numberOfLines={1}
      >
        5㎡长条形卫生间如何设计干湿分离?
      </Text>
      <Text
        style={{
          marginTop: 8,
          color: '#FFF',
          opacity: 0.6,
          fontSize: 12,
        }}
        numberOfLines={2}
      >
        家里只有一个卫生间,一定要这样装!颜值比五星酒店卫生间还高级,卫生间,一定要这样装!颜值比卫生间,一定要这样装!
      </Text>
      <View style={{
        flexDirection: 'row',
        marginTop: 18,
        marginBottom: 20,
        alignItems: 'center',
      }}>
        <View
          style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: '#FFF' }}
        />
        <Text style={{ color: '#FFF', fontSize: 14, marginLeft: 4 }}>
          造作设计工作坊
        </Text>
      </View>
    </View>
    <View style={{
      position: 'absolute',
      right: 20,
      bottom: 165,
    }}>
      <Image
        style={styles.icon}
        source={data?.hasFavor ? require('./img/love-f.png') : require('./img/love.png')}
      />
      <Text style={styles.countNumber}>1.2w</Text>
      <Image
        style={styles.icon}
        source={require('./img/collect.png')}
      />
      <Text style={styles.countNumber}>1.2w</Text>
      <Image
        style={styles.icon}
        source={require('./img/comment.png')}
      />
      <Text style={styles.countNumber}>1.2w</Text>
    </View>
    {
      paused
        ? <Image
          style={{
            position: 'absolute',
            top: '50%',
            left: '50%',
            width: 40,
            height: 40,
            marginLeft: -20,
            marginTop: -20,
          }}
          source={require('./img/play.webp')}
        />
        : null
    }
  </View>;
}, (preValue, nextValue) => preValue.id === nextValue.id && preValue.paused === nextValue.paused);

手势冲突

通过 GestureResponder 拦截点击事件之后会造成 FlatList 滚动事件失效,所以需要将滚动事件交给 FlatList。通过 onResponderTerminationRequest 属性可以让 View 放弃处理事件的权利,将滚动事件交给 FlatList来处理。

 <View
      onStartShouldSetResponder={() => true}
      onResponderTerminationRequest={() => true}   <---- here
      onResponderGrant={_handlerClick}>
    {/* some code */}
</View>

代码

MyTiktok

参考文献

https://blog.csdn.net/qq_38356174/article/details/96439456
https://juejin.im/post/5b504823e51d451953125799

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342