React Native实现一个异形弧线|长度可变|背景动态渐变TabBar

587 阅读3分钟

一.需求背景

最近接到了一个TabBar重构任务,样式及需求如下:

截屏2024-11-14 10.34.16.png

需求:

  1. 一个上下滑动的,背景色随着渐变色背景实时变化的TabBar
  2. 每一个Button长度可随着文字长短自动拉伸
  3. 每一个Button的背景呈圆角异形
  4. 滑动到最上,需吸顶,且右侧自动增加一个搜索图标

二.设计思路

看到这种略显复杂的TabBar设计,想都不用想,不会有现成的轮子,自定义是必然的了。但是有些效果,还是可以借助现有的一些组件,减少工作量。具体思路如下。

2.1 吸顶效果

不用重复造轮子。可使用React Native官方组件SectionListSectionHeader自带的吸顶效果。

2.2 圆角异形的弧线和长短可变

要么绘制、要么采用三段式背景图片,既左右各一个异形弧线的背景图,中间一个 <View />进行拼接。

2.3 背景实时变化

这个难点在于页面背景色是个不同位置不同颜色的渐变色,采用透明色自然直接就能呈现后面的页面背景,但是也会把后面的文字显示出来, 所以透明色的方案Pass了,那就只能使用ScrollViewonScroll函数监听y轴的滑动距离,根据不同的滑动距离给TabBar背景赋值为不同的颜色。

2.4 TabBar的定制

如果没有右侧的加号图标和搜索图标,那么使用@react-navigation/material-top-tabs将能够使用极少的代码,并且能使用自带效果实现底下列表页面左右滑动切换联动,我们只需要关注TabBar定制就可以了,但显然因为加号图标和搜索图标并不能支持,所以我们只能使用ScrollView自定义material-top-tabs另外再自定义TabBar,并通过ScrollView左右滑动跟底下的列表页面进行滑动联动了。

三.实现过程

关于那个异形的弧线,我先是想到了使用业内成熟的绘图引擎:@shopify/react-native-skia,毕竟连flutter底层用的都是skia引擎。

@shopify/react-native-skiaPath 组件可用于各类复杂曲线绘制,其中arcToTangent方法为Tangent三角函数方式绘制,conicTo方法为二阶贝塞尔曲线绘制,cubicTo为三阶贝塞尔曲线绘制,这三个方法都能帮助我们实现需求中的弧线。

于是我使用了@shopify/react-native-skiaPath 组件自定义了一版TabBar, 实现了UI设计师要求的左右共三种不同的异形弧线背景图,但因为不能取到精确的数学函数值,故实现出来的效果和UI设计图存在非常轻微的差异,于是只好让设计师提供三张不同的背景图片来呈现左右三种不同的异形弧线背景。

最后,我们看下最终实现出来的效果:

飞书20241114-115805 -middle-original.gif

四.主要代码

renderSectionHeader

  const renderSection = () => {
    return (
      <View style={{ flexDirection: 'row', alignItems: 'center', width: windowWidth, backgroundColor: showSearchIcon ? '#C6DFFA' : '#F0F5F9' }}>
        <ScrollView bounces={false} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sectionBack}>
          {
            jobs.map((item, i) => (
              <TopTabBar key={i} index={i} name={item.name} isFocused={selectedTab === i} onPress={() => {
                scrollViewRef.current?.scrollTo({
                  x: i * windowWidth
                });
                setSelectedTab(i)
              }} />
            ))
          }
        </ScrollView>
        <Pressable onPress={() => { navigationRef.navigate('SeafarerTypeScreen') }} style={styles.sectionIcon}>
          <Image source={require('../../../assets/images/add.png')} style={styles.sectionImage}></Image>
        </Pressable>
        {showSearchIcon && <Pressable onPress={() => { navigationRef.navigate('EditJobItemsScreen') }} style={styles.sectionIcon}>
          <Image source={require('../../../assets/images/search_big.png')} style={styles.sectionImage}></Image>
        </Pressable>}
      </View>
    )
  }

renderItem

  const renderItem = () => {
    return(
        <ScrollView
          ref={scrollViewRef}
          pagingEnabled
          horizontal
          bounces={false}
          scrollEventThrottle={windowWidth}
          showsHorizontalScrollIndicator={false}
          scrollEnabled={false}
          contentContainerStyle={{ flexDirection: 'row', width: windowWidth * jobs.length }}>
          {
            jobs.map((item, i) => (
              <JobListScreen key={i} />
            ))
          }
        </ScrollView>
    )
  }

sectionList

  return (
    <LinearGradient
      colors={['#C6DFFA', '#C6DFFA', '#F0F5F9']}
      start={{ x: 0.5, y: 0 }}
      end={{ x: 0.5, y: 1 }}
      locations={[0, 0.1, 0.4]}
      style={[globalStyles.rootContainer]}>
      <SafeAreaView style={[globalStyles.container]}>
        <SectionList
          keyboardShouldPersistTaps={'never'}
          showsVerticalScrollIndicator={false}
          keyExtractor={(item, index) => index + ''}
          sections={[{ title: "", data: [0], index: 0 }]}
          stickySectionHeadersEnabled={true} //安卓需手动设置
          ListHeaderComponent={renderHeader}
          renderSectionHeader={renderSection}
          renderItem={renderItem}
          contentContainerStyle={{ backgroundColor: AppColors.transparent }}
          onScroll={handleScroll}
        />
      </SafeAreaView>
    </LinearGradient>
  );

styles

const styles = StyleSheet.create({
  container: {
    backgroundColor: AppColors.transparent,
    paddingTop: Platform.OS === 'android' ? 16 : 0,
  },
  searchIcon: {
    width: 16,
    height: 16,
    marginRight: 10
  },
  searchBack: {
    paddingHorizontal: 12,
    backgroundColor: 'white',
    height: searchHeight,
    borderRadius: 18,
    marginVertical: spaceHeight,
    marginHorizontal: 16
  },
  resumeBack: {
    paddingHorizontal: 12,
    backgroundColor: 'white',
    height: resumeHeight,
    borderRadius: 9,
    marginBottom: spaceHeight,
    marginHorizontal: 16
  },
  selectPlaceholder: {
    color: `${AppColors.jetBlack}${OPACITY_HEX_CODE.O50}`,
  },
  verticalLine: {
    backgroundColor: `${AppColors.jetBlack}${OPACITY_HEX_CODE.O50}`,
    height: 16,
    width: 0.6,
    marginHorizontal: 12
  },
  searchText: {
    color: 'rgb(31,31,31)'
  },
  sectionIcon: { width: 32, height: 41, justifyContent: 'center', alignItems: 'center' },
  sectionBack: { height: 41, flexDirection: 'row' },
  sectionImage: { width: 24, height: 24 },
});