bastienrobert/la-ferme

View on GitHub
packages/mobile/src/components/shared/SlideToAnswer.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import React, {
  FC,
  useState,
  useRef,
  useEffect,
  useCallback,
  useMemo
} from 'react'
import {
  Animated,
  PanResponder,
  LayoutChangeEvent,
  LayoutRectangle
} from 'react-native'
import styled from 'styled-components/native'
import { Colors, Icon } from '@la-ferme/components/native'

import Container from './Container'
import Text from '@/components/typo/Text'

const ICON_CONTAINER_DIMENSIONS = 70
const ICON_CONTAINER_MARGINS = 2.5
const ICON_DIMENSIONS = 30
const COMPONENT_HEIGHT = ICON_CONTAINER_DIMENSIONS + ICON_CONTAINER_MARGINS * 2
const RELEASE_OFFSET = 10

const clampToX = (value: number, pan: Animated.ValueXY) => {
  Animated.spring(pan, {
    toValue: { x: value, y: 0 },
    friction: 12,
    tension: 40,
    useNativeDriver: true
  }).start()
}

export interface SlideToAnswerProps {
  onHangUp?: () => void
  onPickUp?: () => void
  touchable?: boolean
}

const SlideToAnswer: FC<SlideToAnswerProps> = ({
  onHangUp,
  onPickUp,
  touchable
}) => {
  const [answer, setAnswer] = useState<boolean>(false)
  const pan = useRef(new Animated.ValueXY()).current
  const backgroundAnim = useRef(new Animated.Value(1)).current
  const textAnim = useRef(new Animated.Value(1)).current
  const crossAnim = useRef(new Animated.Value(0)).current
  const layout = useRef<LayoutRectangle>()

  const onLayout = useCallback((e: LayoutChangeEvent) => {
    layout.current = e.nativeEvent.layout
  }, [])

  const panMove = useMemo(() => {
    return Animated.event(
      [
        null,
        {
          dx: pan.x
        }
      ],
      {
        useNativeDriver: false
      }
    )
  }, [pan.x])

  const panResponder = useMemo(() => {
    return PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderMove: (evt, gestureState) => {
        if (
          answer ||
          gestureState.dx < 0 ||
          gestureState.dx + COMPONENT_HEIGHT > layout.current.width
        ) {
          return
        }

        return panMove(evt, gestureState)
      },
      onPanResponderRelease: (_, gestureState) => {
        if (answer) return
        if (
          gestureState.dx + COMPONENT_HEIGHT + RELEASE_OFFSET >
          layout.current.width
        ) {
          setAnswer(true)
          Animated.parallel([
            Animated.timing(backgroundAnim, {
              toValue: 0,
              duration: 200,
              useNativeDriver: true
            }),
            Animated.timing(textAnim, {
              toValue: 0,
              duration: 1,
              useNativeDriver: true
            }),
            Animated.timing(crossAnim, {
              toValue: 1,
              duration: 300,
              useNativeDriver: true
            })
          ]).start()
          clampToX((layout.current.width - ICON_CONTAINER_DIMENSIONS) / 2, pan)
        } else {
          clampToX(0, pan)
        }
      }
    })
  }, [answer, backgroundAnim, crossAnim, pan, panMove, textAnim])

  useEffect(() => {
    if (answer && onPickUp) onPickUp()
  }, [answer, onPickUp])

  const onPress = useMemo(() => {
    return () => answer && onHangUp && onHangUp()
  }, [answer, onHangUp])

  return (
    <Component alignSelf="center" onLayout={onLayout}>
      <TextWrapper
        as={Animated.View}
        alignSelf="center"
        style={{ opacity: textAnim }}>
        <Text color="gray">RĂ©pondre</Text>
      </TextWrapper>
      <Background as={Animated.View} style={{ opacity: backgroundAnim }} />
      <BackgroundTranslate
        as={Animated.View}
        style={{
          opacity: textAnim,
          transform: [...pan.getTranslateTransform()]
        }}
      />
      <IconContainer
        as={Animated.View}
        style={{
          transform: [...pan.getTranslateTransform()]
        }}
        {...panResponder.panHandlers}>
        <IconTouchable onPress={onPress} disabled={!touchable || !answer}>
          <IconWrapper as={Animated.View} style={{ opacity: crossAnim }}>
            <StyledIcon icon="cross" color="beige" />
          </IconWrapper>
          <IconWrapper as={Animated.View} style={{ opacity: backgroundAnim }}>
            <StyledIcon icon="angleBracket" color="beige" />
          </IconWrapper>
        </IconTouchable>
      </IconContainer>
    </Component>
  )
}

SlideToAnswer.defaultProps = {
  touchable: true
}

const Component = styled(Container)`
  width: 90%;
  max-width: 300px;
  height: ${COMPONENT_HEIGHT}px;
  border-radius: ${COMPONENT_HEIGHT / 2}px;
  overflow: hidden;
`

const Background = styled(Container)`
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: ${Colors.beige};
`

const BackgroundTranslate = styled(Background)`
  background-color: ${Colors.beige};
  left: -100%;
  margin-left: ${ICON_CONTAINER_DIMENSIONS}px;
  z-index: 2;
`

const IconContainer = styled(Container)`
  position: absolute;
  left: 0;
  top: 0;
  height: ${ICON_CONTAINER_DIMENSIONS}px;
  width: ${ICON_CONTAINER_DIMENSIONS}px;
  margin: ${ICON_CONTAINER_MARGINS}px;
  background-color: ${Colors.red};
  border-radius: ${ICON_CONTAINER_DIMENSIONS / 2}px;
  z-index: 2;
`

const IconTouchable = styled.TouchableOpacity`
  position: absolute;
  left: 0;
  top: 0;
  height: 100%;
  width: 100%;
`

const IconWrapper = styled(Container)`
  position: absolute;
  top: ${(ICON_CONTAINER_DIMENSIONS - ICON_DIMENSIONS) / 2}px;
  left: ${(ICON_CONTAINER_DIMENSIONS - ICON_DIMENSIONS) / 2}px;
  height: ${ICON_DIMENSIONS}px;
  width: ${ICON_DIMENSIONS}px;
`

const TextWrapper = styled(Container)`
  justify-content: center;
  flex: 1;
  margin-left: 40px;
  z-index: 1;
`

const StyledIcon = styled(Icon)`
  height: 100%;
  width: 100%;
`

export default SlideToAnswer