about summary refs log tree commit diff
path: root/src/lib/custom-animations
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/custom-animations')
-rw-r--r--src/lib/custom-animations/CountWheel.tsx177
-rw-r--r--src/lib/custom-animations/CountWheel.web.tsx121
-rw-r--r--src/lib/custom-animations/LikeIcon.tsx139
-rw-r--r--src/lib/custom-animations/LikeIcon.web.tsx115
-rw-r--r--src/lib/custom-animations/util.ts21
5 files changed, 573 insertions, 0 deletions
diff --git a/src/lib/custom-animations/CountWheel.tsx b/src/lib/custom-animations/CountWheel.tsx
new file mode 100644
index 000000000..dfa697911
--- /dev/null
+++ b/src/lib/custom-animations/CountWheel.tsx
@@ -0,0 +1,177 @@
+import React from 'react'
+import {View} from 'react-native'
+import Animated, {
+  Easing,
+  LayoutAnimationConfig,
+  useReducedMotion,
+  withTiming,
+} from 'react-native-reanimated'
+import {i18n} from '@lingui/core'
+
+import {decideShouldRoll} from 'lib/custom-animations/util'
+import {s} from 'lib/styles'
+import {formatCount} from 'view/com/util/numeric/format'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+
+const animationConfig = {
+  duration: 400,
+  easing: Easing.out(Easing.cubic),
+}
+
+function EnteringUp() {
+  'worklet'
+  const animations = {
+    opacity: withTiming(1, animationConfig),
+    transform: [{translateY: withTiming(0, animationConfig)}],
+  }
+  const initialValues = {
+    opacity: 0,
+    transform: [{translateY: 18}],
+  }
+  return {
+    animations,
+    initialValues,
+  }
+}
+
+function EnteringDown() {
+  'worklet'
+  const animations = {
+    opacity: withTiming(1, animationConfig),
+    transform: [{translateY: withTiming(0, animationConfig)}],
+  }
+  const initialValues = {
+    opacity: 0,
+    transform: [{translateY: -18}],
+  }
+  return {
+    animations,
+    initialValues,
+  }
+}
+
+function ExitingUp() {
+  'worklet'
+  const animations = {
+    opacity: withTiming(0, animationConfig),
+    transform: [
+      {
+        translateY: withTiming(-18, animationConfig),
+      },
+    ],
+  }
+  const initialValues = {
+    opacity: 1,
+    transform: [{translateY: 0}],
+  }
+  return {
+    animations,
+    initialValues,
+  }
+}
+
+function ExitingDown() {
+  'worklet'
+  const animations = {
+    opacity: withTiming(0, animationConfig),
+    transform: [{translateY: withTiming(18, animationConfig)}],
+  }
+  const initialValues = {
+    opacity: 1,
+    transform: [{translateY: 0}],
+  }
+  return {
+    animations,
+    initialValues,
+  }
+}
+
+export function CountWheel({
+  likeCount,
+  big,
+  isLiked,
+}: {
+  likeCount: number
+  big?: boolean
+  isLiked: boolean
+}) {
+  const t = useTheme()
+  const shouldAnimate = !useReducedMotion()
+  const shouldRoll = decideShouldRoll(isLiked, likeCount)
+
+  // Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
+  // animation
+  // The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
+  // be unnecessary
+  const [key, setKey] = React.useState(0)
+  const [prevCount, setPrevCount] = React.useState(likeCount)
+  const prevIsLiked = React.useRef(isLiked)
+  const formattedCount = formatCount(i18n, likeCount)
+  const formattedPrevCount = formatCount(i18n, prevCount)
+
+  React.useEffect(() => {
+    if (isLiked === prevIsLiked.current) {
+      return
+    }
+
+    const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
+    setKey(prev => prev + 1)
+    setPrevCount(newPrevCount)
+    prevIsLiked.current = isLiked
+  }, [isLiked, likeCount])
+
+  const enteringAnimation =
+    shouldAnimate && shouldRoll
+      ? isLiked
+        ? EnteringUp
+        : EnteringDown
+      : undefined
+  const exitingAnimation =
+    shouldAnimate && shouldRoll
+      ? isLiked
+        ? ExitingUp
+        : ExitingDown
+      : undefined
+
+  return (
+    <LayoutAnimationConfig skipEntering skipExiting>
+      {likeCount > 0 ? (
+        <View style={[a.justify_center]}>
+          <Animated.View entering={enteringAnimation} key={key}>
+            <Text
+              testID="likeCount"
+              style={[
+                big ? a.text_md : {fontSize: 15},
+                a.user_select_none,
+                isLiked
+                  ? [a.font_bold, s.likeColor]
+                  : {color: t.palette.contrast_500},
+              ]}>
+              {formattedCount}
+            </Text>
+          </Animated.View>
+          {shouldAnimate ? (
+            <Animated.View
+              entering={exitingAnimation}
+              // Add 2 to the key so there are never duplicates
+              key={key + 2}
+              style={[a.absolute, {width: 50}]}
+              aria-disabled={true}>
+              <Text
+                style={[
+                  big ? a.text_md : {fontSize: 15},
+                  a.user_select_none,
+                  isLiked
+                    ? [a.font_bold, s.likeColor]
+                    : {color: t.palette.contrast_500},
+                ]}>
+                {formattedPrevCount}
+              </Text>
+            </Animated.View>
+          ) : null}
+        </View>
+      ) : null}
+    </LayoutAnimationConfig>
+  )
+}
diff --git a/src/lib/custom-animations/CountWheel.web.tsx b/src/lib/custom-animations/CountWheel.web.tsx
new file mode 100644
index 000000000..618dcb1a5
--- /dev/null
+++ b/src/lib/custom-animations/CountWheel.web.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useReducedMotion} from 'react-native-reanimated'
+import {i18n} from '@lingui/core'
+
+import {decideShouldRoll} from 'lib/custom-animations/util'
+import {s} from 'lib/styles'
+import {formatCount} from 'view/com/util/numeric/format'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+
+const animationConfig = {
+  duration: 400,
+  easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+  fill: 'forwards' as FillMode,
+}
+
+const enteringUpKeyframe = [
+  {opacity: 0, transform: 'translateY(18px)'},
+  {opacity: 1, transform: 'translateY(0)'},
+]
+
+const enteringDownKeyframe = [
+  {opacity: 0, transform: 'translateY(-18px)'},
+  {opacity: 1, transform: 'translateY(0)'},
+]
+
+const exitingUpKeyframe = [
+  {opacity: 1, transform: 'translateY(0)'},
+  {opacity: 0, transform: 'translateY(-18px)'},
+]
+
+const exitingDownKeyframe = [
+  {opacity: 1, transform: 'translateY(0)'},
+  {opacity: 0, transform: 'translateY(18px)'},
+]
+
+export function CountWheel({
+  likeCount,
+  big,
+  isLiked,
+}: {
+  likeCount: number
+  big?: boolean
+  isLiked: boolean
+}) {
+  const t = useTheme()
+  const shouldAnimate = !useReducedMotion()
+  const shouldRoll = decideShouldRoll(isLiked, likeCount)
+
+  const countView = React.useRef<HTMLDivElement>(null)
+  const prevCountView = React.useRef<HTMLDivElement>(null)
+
+  const [prevCount, setPrevCount] = React.useState(likeCount)
+  const prevIsLiked = React.useRef(isLiked)
+  const formattedCount = formatCount(i18n, likeCount)
+  const formattedPrevCount = formatCount(i18n, prevCount)
+
+  React.useEffect(() => {
+    if (isLiked === prevIsLiked.current) {
+      return
+    }
+
+    const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
+    if (shouldAnimate && shouldRoll) {
+      countView.current?.animate?.(
+        isLiked ? enteringUpKeyframe : enteringDownKeyframe,
+        animationConfig,
+      )
+      prevCountView.current?.animate?.(
+        isLiked ? exitingUpKeyframe : exitingDownKeyframe,
+        animationConfig,
+      )
+      setPrevCount(newPrevCount)
+    }
+    prevIsLiked.current = isLiked
+  }, [isLiked, likeCount, shouldAnimate, shouldRoll])
+
+  if (likeCount < 1) {
+    return null
+  }
+
+  return (
+    <View>
+      <View
+        aria-disabled={true}
+        // @ts-expect-error is div
+        ref={countView}>
+        <Text
+          testID="likeCount"
+          style={[
+            big ? a.text_md : {fontSize: 15},
+            a.user_select_none,
+            isLiked
+              ? [a.font_bold, s.likeColor]
+              : {color: t.palette.contrast_500},
+          ]}>
+          {formattedCount}
+        </Text>
+      </View>
+      {shouldAnimate ? (
+        <View
+          style={{position: 'absolute'}}
+          aria-disabled={true}
+          // @ts-expect-error is div
+          ref={prevCountView}>
+          <Text
+            style={[
+              big ? a.text_md : {fontSize: 15},
+              a.user_select_none,
+              isLiked
+                ? [a.font_bold, s.likeColor]
+                : {color: t.palette.contrast_500},
+            ]}>
+            {formattedPrevCount}
+          </Text>
+        </View>
+      ) : null}
+    </View>
+  )
+}
diff --git a/src/lib/custom-animations/LikeIcon.tsx b/src/lib/custom-animations/LikeIcon.tsx
new file mode 100644
index 000000000..06d5c2850
--- /dev/null
+++ b/src/lib/custom-animations/LikeIcon.tsx
@@ -0,0 +1,139 @@
+import React from 'react'
+import {View} from 'react-native'
+import Animated, {
+  Keyframe,
+  LayoutAnimationConfig,
+  useReducedMotion,
+} from 'react-native-reanimated'
+
+import {s} from 'lib/styles'
+import {useTheme} from '#/alf'
+import {
+  Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
+  Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
+} from '#/components/icons/Heart2'
+
+const keyframe = new Keyframe({
+  0: {
+    transform: [{scale: 1}],
+  },
+  10: {
+    transform: [{scale: 0.7}],
+  },
+  40: {
+    transform: [{scale: 1.2}],
+  },
+  100: {
+    transform: [{scale: 1}],
+  },
+})
+
+const circle1Keyframe = new Keyframe({
+  0: {
+    opacity: 0,
+    transform: [{scale: 0}],
+  },
+  10: {
+    opacity: 0.4,
+  },
+  40: {
+    transform: [{scale: 1.5}],
+  },
+  95: {
+    opacity: 0.4,
+  },
+  100: {
+    opacity: 0,
+    transform: [{scale: 1.5}],
+  },
+})
+
+const circle2Keyframe = new Keyframe({
+  0: {
+    opacity: 0,
+    transform: [{scale: 0}],
+  },
+  10: {
+    opacity: 1,
+  },
+  40: {
+    transform: [{scale: 0}],
+  },
+  95: {
+    opacity: 1,
+  },
+  100: {
+    opacity: 0,
+    transform: [{scale: 1.5}],
+  },
+})
+
+export function AnimatedLikeIcon({
+  isLiked,
+  big,
+}: {
+  isLiked: boolean
+  big?: boolean
+}) {
+  const t = useTheme()
+  const size = big ? 22 : 18
+  const shouldAnimate = !useReducedMotion()
+
+  return (
+    <View>
+      <LayoutAnimationConfig skipEntering>
+        {isLiked ? (
+          <Animated.View
+            entering={shouldAnimate ? keyframe.duration(300) : undefined}>
+            <HeartIconFilled style={s.likeColor} width={size} />
+          </Animated.View>
+        ) : (
+          <HeartIconOutline
+            style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
+            width={size}
+          />
+        )}
+        {isLiked ? (
+          <>
+            <Animated.View
+              entering={
+                shouldAnimate ? circle1Keyframe.duration(300) : undefined
+              }
+              style={[
+                {
+                  position: 'absolute',
+                  backgroundColor: s.likeColor.color,
+                  top: 0,
+                  left: 0,
+                  width: size,
+                  height: size,
+                  zIndex: -1,
+                  pointerEvents: 'none',
+                  borderRadius: size / 2,
+                },
+              ]}
+            />
+            <Animated.View
+              entering={
+                shouldAnimate ? circle2Keyframe.duration(300) : undefined
+              }
+              style={[
+                {
+                  position: 'absolute',
+                  backgroundColor: t.atoms.bg.backgroundColor,
+                  top: 0,
+                  left: 0,
+                  width: size,
+                  height: size,
+                  zIndex: -1,
+                  pointerEvents: 'none',
+                  borderRadius: size / 2,
+                },
+              ]}
+            />
+          </>
+        ) : null}
+      </LayoutAnimationConfig>
+    </View>
+  )
+}
diff --git a/src/lib/custom-animations/LikeIcon.web.tsx b/src/lib/custom-animations/LikeIcon.web.tsx
new file mode 100644
index 000000000..c131dcf67
--- /dev/null
+++ b/src/lib/custom-animations/LikeIcon.web.tsx
@@ -0,0 +1,115 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useReducedMotion} from 'react-native-reanimated'
+
+import {s} from 'lib/styles'
+import {useTheme} from '#/alf'
+import {
+  Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
+  Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
+} from '#/components/icons/Heart2'
+
+const animationConfig = {
+  duration: 400,
+  easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+  fill: 'forwards' as FillMode,
+}
+
+const keyframe = [
+  {transform: 'scale(1)'},
+  {transform: 'scale(0.7)'},
+  {transform: 'scale(1.2)'},
+  {transform: 'scale(1)'},
+]
+
+const circle1Keyframe = [
+  {opacity: 0, transform: 'scale(0)'},
+  {opacity: 0.4},
+  {transform: 'scale(1.5)'},
+  {opacity: 0.4},
+  {opacity: 0, transform: 'scale(1.5)'},
+]
+
+const circle2Keyframe = [
+  {opacity: 0, transform: 'scale(0)'},
+  {opacity: 1},
+  {transform: 'scale(0)'},
+  {opacity: 1},
+  {opacity: 0, transform: 'scale(1.5)'},
+]
+
+export function AnimatedLikeIcon({
+  isLiked,
+  big,
+}: {
+  isLiked: boolean
+  big?: boolean
+}) {
+  const t = useTheme()
+  const size = big ? 22 : 18
+  const shouldAnimate = !useReducedMotion()
+  const prevIsLiked = React.useRef(isLiked)
+
+  const likeIconRef = React.useRef<HTMLDivElement>(null)
+  const circle1Ref = React.useRef<HTMLDivElement>(null)
+  const circle2Ref = React.useRef<HTMLDivElement>(null)
+
+  React.useEffect(() => {
+    if (prevIsLiked.current === isLiked) {
+      return
+    }
+
+    if (shouldAnimate && isLiked) {
+      likeIconRef.current?.animate?.(keyframe, animationConfig)
+      circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
+      circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
+    }
+    prevIsLiked.current = isLiked
+  }, [shouldAnimate, isLiked])
+
+  return (
+    <View>
+      {isLiked ? (
+        // @ts-expect-error is div
+        <View ref={likeIconRef}>
+          <HeartIconFilled style={s.likeColor} width={size} />
+        </View>
+      ) : (
+        <HeartIconOutline
+          style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
+          width={size}
+        />
+      )}
+      <View
+        // @ts-expect-error is div
+        ref={circle1Ref}
+        style={{
+          position: 'absolute',
+          backgroundColor: s.likeColor.color,
+          top: 0,
+          left: 0,
+          width: size,
+          height: size,
+          zIndex: -1,
+          pointerEvents: 'none',
+          borderRadius: size / 2,
+        }}
+      />
+      <View
+        // @ts-expect-error is div
+        ref={circle2Ref}
+        style={{
+          position: 'absolute',
+          backgroundColor: t.atoms.bg.backgroundColor,
+          top: 0,
+          left: 0,
+          width: size,
+          height: size,
+          zIndex: -1,
+          pointerEvents: 'none',
+          borderRadius: size / 2,
+        }}
+      />
+    </View>
+  )
+}
diff --git a/src/lib/custom-animations/util.ts b/src/lib/custom-animations/util.ts
new file mode 100644
index 000000000..0aebab57b
--- /dev/null
+++ b/src/lib/custom-animations/util.ts
@@ -0,0 +1,21 @@
+// It should roll when:
+// - We're going from 1 to 0 (roll backwards)
+// - The count is anywhere between 1 and 999
+// - The count is going up and is a multiple of 100
+// - The count is going down and is 1 less than a multiple of 100
+export function decideShouldRoll(isSet: boolean, count: number) {
+  let shouldRoll = false
+  if (!isSet && count === 0) {
+    shouldRoll = true
+  } else if (count > 0 && count < 1000) {
+    shouldRoll = true
+  } else if (count > 0) {
+    const mod = count % 100
+    if (isSet && mod === 0) {
+      shouldRoll = true
+    } else if (!isSet && mod === 99) {
+      shouldRoll = true
+    }
+  }
+  return shouldRoll
+}