satya164 / react-native-tab-view

A cross-platform Tab View component for React Native
MIT License
5.13k stars 1.07k forks source link

Indicator borderRadius doesn't work with tabBarItemStyle : width: 'auto' #1254

Open simonronec opened 3 years ago

simonronec commented 3 years ago

Current behavior

Border radius of indicator is not respected when tabStyle width is set to auto. Works okay when not using tab width auto.

Expected behavior

Should be working

Reproduction

https://snack.expo.dev/@ronec_onesimplicity/border-radius-not-woking

Platform

Environment

package version
react-native-tab-view 3.1.1
react-native-pager-view 5.0.12
react-native https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz
expo 42.0.0
node
npm or yarn
github-actions[bot] commented 3 years ago

Couldn't find version numbers for the following packages in the issue:

Can you update the issue to include version numbers for those packages? The version numbers must match the format 1.2.3.

The versions mentioned in the issue for the following packages differ from the latest versions on npm:

Can you verify that the issue still exists after upgrading to the latest versions of these packages?

izakfilmalter commented 3 years ago

Anyone have any ideas for fixing this, was working fin in v2 of this lib. The problem in the current lib is that there is a scale animation now for the auto width indicator.

My best guess is that you would need to break apart the Indicator into the following:

<Container>
  <Left />
  <Center />
  <Right />
</Conteiner>

Center gets the scale animation. Container gets the left animation, and then you have to style Left and Right with your border radius.

izakfilmalter commented 3 years ago

Ended up being a lot simpler that expected.

Basically just had to change the scaleX to width. I know that scaleX is better in every way except for the border-radius. @satya164 if you want me to open a PR with this change, I am more than happy to. I think it's also acceptable to just point people towards having their own indicator using the bellow rather than having this change in the lib.

import * as React from 'react'
import {
  Animated,
  Easing,
  StyleSheet,
  I18nManager,
  StyleProp,
  ViewStyle,
  Platform,
} from 'react-native'

import type {
  Route,
  SceneRendererProps,
  NavigationState,
} from 'react-native-tab-view'

export type GetTabWidth = (index: number) => number

export type Props<T extends Route> = SceneRendererProps & {
  state: NavigationState<T>
  width: string | number
  style?: StyleProp<ViewStyle>
  getTabWidth: GetTabWidth
}

export class TabBarIndicator<T extends Route> extends React.Component<
  Props<T>
> {
  componentDidMount() {
    this.fadeInIndicator()
  }

  componentDidUpdate() {
    this.fadeInIndicator()
  }

  private fadeInIndicator = () => {
    const { state: navigationState, layout, width, getTabWidth } = this.props

    if (
      !this.isIndicatorShown &&
      width === 'auto' &&
      layout.width &&
      // We should fade-in the indicator when we have widths for all the tab items
      navigationState.routes.every((_, i) => getTabWidth(i))
    ) {
      this.isIndicatorShown = true

      Animated.timing(this.opacity, {
        toValue: 1,
        duration: 150,
        easing: Easing.in(Easing.linear),
        useNativeDriver: true,
      }).start()
    }
  }

  private isIndicatorShown = false

  private opacity = new Animated.Value(this.props.width === 'auto' ? 0 : 1)

  private getTranslateX = (
    position: Animated.AnimatedInterpolation,
    routes: Route[],
    getTabWidth: GetTabWidth,
  ) => {
    const inputRange = routes.map((_, i) => i)

    // every index contains widths at all previous indices
    const outputRange = routes.reduce<number[]>((acc, _, i) => {
      if (i === 0) return [0]
      return [...acc, acc[i - 1] + getTabWidth(i - 1)]
    }, [])

    const translateX = position.interpolate({
      inputRange,
      outputRange,
      extrapolate: 'clamp',
    })

    return Animated.multiply(translateX, I18nManager.isRTL ? -1 : 1)
  }

  render() {
    const {
      position,
      state: navigationState,
      getTabWidth,
      width,
      style,
      layout,
    } = this.props
    const { routes } = navigationState

    const transform = []

    if (layout.width) {
      const translateX =
        routes.length > 1
          ? this.getTranslateX(position, routes, getTabWidth)
          : 0

      transform.push({ translateX })
    }

    if (width === 'auto') {
      transform.push({ translateX: 0.5 })
    }

    const getWidth = () => {
      if (width === 'auto') {
        const inputRange = routes.map((_, i) => i)
        const outputRange = inputRange.map(getTabWidth)

        return {
          width:
            routes.length > 1
              ? position.interpolate({
                  inputRange,
                  outputRange,
                  extrapolate: 'clamp',
                })
              : outputRange[0],
        }
      }
      return {}
    }

    return (
      <Animated.View
        style={[
          styles.indicator,
          { width: width === 'auto' ? 1 : width },
          // If layout is not available, use `left` property for positioning the indicator
          // This avoids rendering delay until we are able to calculate translateX
          // If platform is macos use `left` property as `transform` is broken at the moment.
          // See: https://github.com/microsoft/react-native-macos/issues/280
          layout.width && Platform.OS !== 'macos'
            ? { left: 0 }
            : { left: `${(100 / routes.length) * navigationState.index}%` },
          { transform },
          width === 'auto' ? { opacity: this.opacity } : null,
          getWidth(),
          style,
        ]}
      />
    )
  }
}

const styles = StyleSheet.create({
  indicator: {
    backgroundColor: '#ffeb3b',
    position: 'absolute',
    left: 0,
    bottom: 0,
    right: 0,
    height: 2,
  },
})
izakfilmalter commented 3 years ago

The above only works on web which is where I do most of my development because width transforms don't work with nativeDriver.

Here is a solution that works for nativeDriver.

It's still a bit dirty, but I tried to comment all the math that is happening. I rewrote the component to be a function component. I am using fp-ts to do all the array manipulation. You can sub out for native methods easily.

The basic principle is the same as my first comment where you do the scaleX on Center. Container moves to the right location in the tabBar, and Right moves within Container to compensate for the new size of Center.

@satya164 I did notice a bug in your code as well, when you do return [...acc, acc[i - 1] + getTabWidth(i - 1)]; you actually make the indicator 1px wider than the the tab because you are scaling up from 1px width. I resolved this in my component by just subtracting the extra px when I need the scaleX amount which is different that the tabWidth.

I found another optimization when the opacity is toggled. We don't need to see if all of the tabs have widths, just the one that is active.

@roneconesimplicity let me know if this works for you.

import styled from '@emotion/native'
import React from 'react'
import {
  Animated,
  StyleProp,
  ViewStyle,
  StyleSheet,
  Easing,
  Platform,
  I18nManager,
} from 'react-native'
import type {
  NavigationState,
  Route,
  SceneRendererProps,
} from 'react-native-tab-view'
import { useEffect, useRef, useState } from 'react'
import { A, O, pipe } from 'Helpers/fp-ts-imports'
import { GetTabWidth } from 'react-native-tab-view/lib/typescript/TabBarIndicator'

const BORDER_RADIUS = 8
// Use a large OVERLAP value so that the border draws correctly on iOS.
const OVERLAP = 8

type TabBarIndicatorProps<T extends Route> = SceneRendererProps & {
  state: NavigationState<T>
  width: string | number
  style?: StyleProp<ViewStyle>
  getTabWidth: GetTabWidth
}

export const TabBarIndicator = <T extends Route>(
  props: TabBarIndicatorProps<T>,
) => {
  const {
    position,
    state: { routes, index },
    getTabWidth,
    width,
    layout,
    style,
  } = props

  const isAuto = width === 'auto'

  const opacity = useRef(new Animated.Value(isAuto ? 0 : 1)).current
  const [isIndicatorShown, setIsIndicatorShown] = useState(false)

  const tabIndices = pipe(
    routes,
    A.mapWithIndex((i) => i),
  )
  const tabWidths = pipe(
    tabIndices,
    A.map((i) => getTabWidth(i) - 1),
  )
  const tabCenterWidths = pipe(
    tabWidths,
    // The tab center should be as wide as the tab minus Left and Right, or
    // 2 * BORDER_RADIUS
    A.map((i) => i - BORDER_RADIUS * 2),
  )

  const tabLocations = pipe(
    tabIndices,
    A.reduce<number, Array<number>>([], (b, a) => {
      // The first item is zero away from the left side.
      if (a === 0) {
        return [0]
      }

      return [
        ...b,
        ...pipe(
          b,
          // Get the previous saved left value.
          A.lookup(a - 1),
          // Add this left value to the previous tab width.
          O.map((x) => [x + getTabWidth(a - 1)]),
          O.getOrElse<Array<number>>(() => []),
        ),
      ]
    }),
  )

  const rightLocations = pipe(
    tabIndices,
    A.mapWithIndex(
      // We want to place Right to the right of Center, but with the OVERLAP
      // underneath Center. Take tab width and remove BORDER_RADIUS for Left
      // and Right. We then have to remove the OVERLAP for both ends.
      (i, a) => getTabWidth(i) - 1 - BORDER_RADIUS * 2 - OVERLAP * 2,
    ),
  )

  useEffect(() => {
    if (
      !isIndicatorShown &&
      isAuto &&
      layout.width !== 0 &&
      // We should fade-in the indicator when we have widths for all the tab items
      tabWidths[index] !== 0
    ) {
      setIsIndicatorShown(true)

      Animated.timing(opacity, {
        toValue: 1,
        duration: 150,
        easing: Easing.in(Easing.linear),
        useNativeDriver: true,
      }).start()
    }
  }, [isIndicatorShown, isAuto, layout.width, tabWidths])

  const getTranslateX = ({ outputRange }: { outputRange: Array<number> }) => {
    // every index contains widths at all previous indices

    const translateX = position.interpolate({
      inputRange: tabIndices,
      outputRange,
      extrapolate: 'clamp',
    })

    return Animated.multiply(translateX, I18nManager.isRTL ? -1 : 1)
  }

  const tabPositionTransform = layout.width
    ? [
        {
          translateX:
            routes.length > 1
              ? getTranslateX({ outputRange: tabLocations })
              : 0,
        },
      ]
    : []

  const rightPositionTransform = layout.width
    ? [
        {
          translateX:
            routes.length > 1
              ? getTranslateX({ outputRange: rightLocations })
              : 0,
        },
      ]
    : []

  const centerScaleTransform = isAuto
    ? [
        {
          scaleX:
            routes.length > 1
              ? position.interpolate({
                  inputRange: tabIndices,
                  outputRange: tabCenterWidths,
                  extrapolate: 'clamp',
                })
              : pipe(
                  tabWidths,
                  A.lookup(0),
                  O.getOrElse(() => 0),
                ),
        },
        { translateX: 0.5 },
      ]
    : []

  return (
    <TabIndicatorContainer
      style={[
        styles.indicator,
        isAuto ? { width: 1, opacity } : { width },
        // If layout is not available, use `left` property for positioning the indicator
        // This avoids rendering delay until we are able to calculate translateX
        // If platform is macos use `left` property as `transform` is broken at the moment.
        // See: https://github.com/microsoft/react-native-macos/issues/280
        layout.width && Platform.OS !== 'macos'
          ? { left: 0 }
          : { left: `${(100 / routes.length) * index}%` },
        { transform: tabPositionTransform },
        style,
      ]}
    >
      <Left />
      <Center style={{ transform: centerScaleTransform }} />
      <Right style={{ transform: rightPositionTransform }} />
    </TabIndicatorContainer>
  )
}

const TabIndicatorContainer = styled(Animated.View)``

const Center = styled(Animated.View)`
  width: 1px;
  height: 40px;
  background: black;
  left: -${OVERLAP.toString()}px;
`

const Left = styled.View`
  width: ${(BORDER_RADIUS + OVERLAP).toString()}px;
  height: 40px;
  background: black;
  border-top-left-radius: ${BORDER_RADIUS.toString()}px;
  border-bottom-left-radius: ${BORDER_RADIUS.toString()}px;
`

const Right = styled(Animated.View)`
  width: ${(BORDER_RADIUS + OVERLAP).toString()}px;
  height: 40px;
  background: black;
  border-top-right-radius: ${BORDER_RADIUS.toString()}px;
  border-bottom-right-radius: ${BORDER_RADIUS.toString()}px;
`

const styles = StyleSheet.create({
  indicator: {
    backgroundColor: 'transparent',
    position: 'absolute',
    left: 0,
    bottom: 0,
    right: 0,
    height: 2,
    flexDirection: 'row',
  },
})
deorst commented 2 years ago

Thanks @izakfilmalter, there is a couple of mistakes:

const centerScaleTransform = isAuto
    ? [
        {
          scaleX:
            routes.length > 1
              ? position.interpolate({
                  inputRange: tabIndices,
                  outputRange: tabCenterWidths,
                  extrapolate: 'clamp',
                })
              : pipe(
                  tabWidths,     // <-- here should be tabCenterWidths
                  A.lookup(0),
                  O.getOrElse(() => 0),
                ),
        },
        { translateX: 0.5 },
      ]
    : []

Also mistake in rightPositionTransform

const rightPositionTransform = layout.width
    ? [
        {
          translateX:
            routes.length > 1
              ? getTranslateX({ outputRange: rightLocations })
              : 0,         // <-- here should be rightLocations[0]
        },
      ]
    : []

Without these corrections the center and the right portion have wrong positioning if there is just one tab in the tab bar.

ibovegar commented 2 years ago

I am facing the same issue. Any progress?

Found a related issue (closed): https://github.com/satya164/react-native-tab-view/issues/1323

showtan001 commented 2 years ago

+1