Skip to content
This repository has been archived by the owner on Nov 27, 2022. It is now read-only.

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

Open
2 of 5 tasks
simonronec opened this issue Aug 24, 2021 · 7 comments
Open
2 of 5 tasks

Comments

@simonronec
Copy link

simonronec commented Aug 24, 2021

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

  • Android
  • iOS
  • Web
  • Windows
  • MacOS

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
@simonronec simonronec added the bug label Aug 24, 2021
@github-actions
Copy link

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

  • react-native

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:

  • react-native-pager-view (found: 5.0.12, latest: 5.4.1)
  • expo (found: 42.0.0, latest: 42.0.3)

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

@izakfilmalter
Copy link

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
Copy link

izakfilmalter commented Oct 13, 2021

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
Copy link

izakfilmalter commented Oct 14, 2021

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
Copy link

deorst commented Feb 7, 2022

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
Copy link

I am facing the same issue. Any progress?

Found a related issue (closed):
#1323

@showtan001
Copy link

+1

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

5 participants