LiuHui's Blog

React Native 如何实现吸顶效果

By LiuHui on Aug 26, 2020
Image post 3

前言

上个月接到一个需求对会员的数据展示、统计。这个也不复杂,当时定的技术是使用react-native实现,有吸顶的效果,在实现的过程中遇到的问题就记录一下。

效果

思路

  • 1、利用FlatList的stickyHeaderIndices属性实现
  • 2、通过监听滚动,判断滚动位置来控制是否固定在顶部

实现1: 利用FlatList的stickyHeaderIndices属性实现

代码

import React, { useEffect } from 'react'
import { View, StyleSheet, FlatList, findNodeHandle, NativeScrollEvent } from 'react-native'
import { NavigationInjectedProps } from 'react-navigation'
import { useDispatch, useSelector } from 'react-redux'
import { FloatingHeader, LoadingFooter, PageFooter, Provider } from '@comps'
import { setState, resetFilter } from '@pages/member-analysis/slice'
import { PageBgEnum } from '@pages/member-analysis/enum'
import {
  getStoreCustomerStatistics,
  getPullNewStatistics,
  queryMemberList,
  Member
} from '@pages/member-analysis/service'
import { HeaderThemeEnum, px2Dp } from '@kit'
import { RootState, AppDispatch } from '@store'
import { common } from '@config/common'
import { WithScreenProps } from '@type'

import CardStatistics from '@pages/member-analysis/components/card-statistics'
import ConsumptionData from '@pages/member-analysis/components/consumption-data'
import UserListSelection from '@pages/member-analysis/components/user-list-selection'
import UserItem from '@pages/member-analysis/components/user-item'
import BottomButton from '@pages/member-analysis/components/bottom-btn'
import PhoneModal from '@pages/member-analysis/components/phone-modal'
import SourceChannelModal from '@pages/member-analysis/components/source-channel-modal'
import NoData from '@pages/member-analysis/components/no-data'

const MemberAnalysis = (props: MemberAnalysisProps) => {
  const { navigation, screenProps } = props
  const { storeCode } = screenProps

  const dispatch: AppDispatch = useDispatch()
  const dataRef = React.createRef<View>()
  const listRef = React.createRef<FlatList>()
  const {
    dataList,
    memberType,
    pageBgColor,
    currentPageNum,
    isLoading,
    hasMore,
    purchasedCategory,
    purchasedBrand,
    beginDate,
    endDate,
    sureFlag
  } = useSelector((state: RootState) => state.memberAnalysis)

  useEffect(() => {
    queryCustomerStatistics()
    queryPullNewStatistics()
  }, [])

  useEffect(() => {
    queryMemberListByPage(1)
  }, [sureFlag])

  return (
    <Provider>
      <View
        style={[
          styles.container,
          { paddingTop: px2Dp(88) + common.currentValue.statusBarHeight, backgroundColor: pageBgColor }
        ]}
      >
        <FloatingHeader
          onBack={() => dispatch(resetFilter())}
          title="会员分析"
          headerTheme={HeaderThemeEnum.transparent}
          navigation={navigation}
        />
        {dataList.length ? (
          <FlatList<Member>
            ref={listRef}
            data={dataList}
            style={styles.container}
            ListHeaderComponent={
              <>
                <CardStatistics />
                <View ref={dataRef} style={styles.bg}>
                  <ConsumptionData />
                </View>
              </>
            }
            stickyHeaderIndices={[1]}
            renderItem={({ item, index }) => {
              if (item?.itemType === 'topSelection' && index === 0) {
                return <UserListSelection navigation={navigation} />
              } else {
                return <UserItem key={item.snCustNum} data={item} navigation={navigation} />
              }
            }}
            ListEmptyComponent={<NoData />}
            onScroll={({ nativeEvent }) => onPageScroll(nativeEvent)}
            onEndReached={() => {
              if (!isLoading && hasMore) {
                queryMemberListByPage(currentPageNum + 1)
              }
            }}
            ListFooterComponent={
              isLoading && hasMore ? (
                <LoadingFooter />
              ) : !isLoading && !hasMore && dataList.length > 0 ? (
                <PageFooter />
              ) : null
            }
          />
        ) : null}
        <BottomButton navigation={navigation} />
        <PhoneModal />
        <SourceChannelModal />
      </View>
    </Provider>
  )

  async function queryCustomerStatistics() {
    getStoreCustomerStatistics(storeCode).then(res => {
      dispatch(setState({ consumption: res }))
    })
  }

  async function queryPullNewStatistics() {
    getPullNewStatistics(storeCode).then(res => {
      dispatch(setState({ pullNewList: res }))
    })
  }

  async function queryMemberListByPage(currentPage: number) {
    await dispatch(setState({ isLoading: true }))
    const params = {
      storeCode,
      memberType,
      categoryCode: purchasedCategory,
      brandCode: purchasedBrand,
      startTime: beginDate,
      endTime: endDate,
      pageNumber: currentPage,
      pageSize: 15
    }
    const res = await queryMemberList(params)
    const data = res?.dataList || []
    const list = currentPage === 1 ? data : dataList.concat(data)
    if (currentPage === 1) {
      list.unshift({ itemType: 'topSelection' })
    }

    dispatch(
      setState({
        isLoading: false,
        isSelectAll: false,
        dataList: list,
        totalCount: res.totalCount,
        currentPageNum: currentPage,
        hasMore: currentPage < res.totalPageCount
      })
    )
  }

  function onPageScroll(nativeEvent: NativeScrollEvent) {
    const offsetTop = nativeEvent.contentOffset.y

    dataRef.current?.measureLayout(
      findNodeHandle(listRef.current) || 0,
      (left: number, top: number) => {
        dispatch(
          setState({
            pageBgColor: offsetTop >= top ? PageBgEnum.WHITE : PageBgEnum.BLUE
          })
        )
      },
      () => {}
    )
  }
}

export default MemberAnalysis

type MemberAnalysisProps = WithScreenProps<{}> & NavigationInjectedProps

const styles = StyleSheet.create({
  container: {
    flex: 1
  },
  bg: {
    backgroundColor: 'transparent'
  },
  menuContainer: {
    flex: 1,
    flexDirection: 'row',
    flexWrap: 'wrap',
    paddingHorizontal: px2Dp(24),
    paddingVertical: px2Dp(18),
    justifyContent: 'space-between'
  }
})

android端 必现如下错误, ios端正常

stickyHeaderIndices的值必须是数字数组,而且data的length必须大于0,不然在安卓会稳定触发index=xx,count=0的报错。也就是说用了stickyHeaderIndices这个属性,在 指定哪个子元素吸顶的时候,一定要保证这个元素已经渲染,首次渲染的时候,data为空或者为null的情况就要判断

实现2: 通过监听滚动,判断滚动位置来控制是否固定在顶部

const MemberAnalysis = (props: MemberAnalysisProps) => {
  const { navigation, screenProps } = props
  const { storeCode } = screenProps

  const dispatch: AppDispatch = useDispatch()
  const dataRef = React.createRef<View>()
  const listRef = React.createRef<FlatList>()
  const selectionRef = React.createRef<View>()
  const {
    dataList,
    memberType,
    pageBgColor,
    currentPageNum,
    isLoading,
    hasMore,
    purchasedCategory,
    purchasedBrand,
    beginDate,
    endDate,
    sureFlag
  } = useSelector((state: RootState) => state.memberAnalysis)

  useEffect(() => {
    queryCustomerStatistics()
    queryPullNewStatistics()
  }, [])

  useEffect(() => {
    queryMemberListByPage(1)
  }, [sureFlag])

  return (
    <Provider>
      <View
        style={[
          styles.container,
          { paddingTop: px2Dp(88) + common.currentValue.statusBarHeight, backgroundColor: pageBgColor }
        ]}
      >
        <FloatingHeader
          onBack={() => dispatch(resetFilter())}
          title="会员分析"
          headerTheme={HeaderThemeEnum.transparent}
          navigation={navigation}
        />
        <FixedUserListSelection navigation={navigation} />
        <FlatList<Member>
          ref={listRef}
          data={dataList}
          style={styles.container}
          ListHeaderComponent={
            <>
              <CardStatistics />
              <View ref={dataRef} style={styles.bg}>
                <ConsumptionData />
                <UserListSelection ref={selectionRef} isFixed={false} navigation={navigation} />
              </View>
            </>
          }
          initialNumToRender={15}
          keyExtractor={(item, index) => `${index}`}
          renderItem={({ item }) => {
            return <UserItem key={item.snCustNum} data={item} navigation={navigation} />
          }}
          ListEmptyComponent={<NoData />}
          onScroll={({ nativeEvent }) => onPageScroll(nativeEvent)}
          onEndReached={() => {
            if (!isLoading && hasMore) {
              queryMemberListByPage(currentPageNum + 1)
            }
          }}
          ListFooterComponent={
            isLoading && hasMore ? (
              <LoadingFooter />
            ) : !isLoading && !hasMore && dataList.length > 0 ? (
              <PageFooter />
            ) : null
          }
        />
        <BottomButton navigation={navigation} />
        <PhoneModal />
        <SourceChannelModal />
      </View>
    </Provider>
  )

  async function queryCustomerStatistics() {
    getStoreCustomerStatistics(storeCode).then(res => {
      dispatch(setState({ consumption: res }))
    })
  }

  async function queryPullNewStatistics() {
    getPullNewStatistics(storeCode).then(res => {
      dispatch(setState({ pullNewList: res }))
    })
  }

  async function queryMemberListByPage(currentPage: number) {
    await dispatch(setState({ isLoading: true }))
    const params = {
      storeCode,
      memberType,
      categoryCode: purchasedCategory,
      brandCode: purchasedBrand,
      startTime: beginDate,
      endTime: endDate,
      pageNumber: currentPage,
      pageSize: 15
    }
    const res = await queryMemberList(params)
    const data = res?.dataList || []
    const list = currentPage === 1 ? data : dataList.concat(data)

    dispatch(
      setState({
        isLoading: false,
        isSelectAll: false,
        dataList: list,
        totalCount: res.totalCount,
        currentPageNum: currentPage,
        hasMore: currentPage < res.totalPageCount
      })
    )
  }

  function onPageScroll(nativeEvent: NativeScrollEvent) {
    const offsetTop = nativeEvent.contentOffset.y

    dataRef.current?.measureLayout(
      findNodeHandle(listRef.current) || 0,
      (left: number, top: number) => {
        dispatch(
          setState({
            pageBgColor: offsetTop >= top ? PageBgEnum.WHITE : PageBgEnum.BLUE
          })
        )
      },
      () => {}
    )

    selectionRef.current?.measureLayout(
      findNodeHandle(listRef.current) || 0,
      (left: number, top: number) => {
        dispatch(
          setState({
            selectionFixed: offsetTop >= top
          })
        )
      },
      () => {}
    )
  }
}

export default MemberAnalysis

type MemberAnalysisProps = WithScreenProps<{}> & NavigationInjectedProps

const styles = StyleSheet.create({
  container: {
    flex: 1
  },
  bg: {
    backgroundColor: 'transparent'
  },
  menuContainer: {
    flex: 1,
    flexDirection: 'row',
    flexWrap: 'wrap',
    paddingHorizontal: px2Dp(24),
    paddingVertical: px2Dp(18),
    justifyContent: 'space-between'
  }
})

通过滚动距离进行判断

 selectionRef.current?.measureLayout(
      findNodeHandle(listRef.current) || 0,
      (left: number, top: number) => {
        dispatch(
          setState({
            selectionFixed: offsetTop >= top
          })
        )
      },
      () => {}
    )

根据selectionFixed控制吸顶

总结

  • 实现1是通过官方的api实现,动画效果更好,使用也简单些,缺点存在平台兼容问题
  • 实现2是通过监听滚动,控制显示与隐藏,所以兼容性很好,缺点就是动画效果需要自己实
© Copyright 2022 by GuoguoDad. Built with ♥ by LiuHui.