diff --git a/.changeset/grumpy-candies-return.md b/.changeset/grumpy-candies-return.md new file mode 100644 index 000000000..ee03e68e6 --- /dev/null +++ b/.changeset/grumpy-candies-return.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💫 improve carousel transitions diff --git a/src/components/auth/Auth.tsx b/src/components/auth/Auth.tsx index 79fa49c7d..8170d80da 100644 --- a/src/components/auth/Auth.tsx +++ b/src/components/auth/Auth.tsx @@ -1,14 +1,15 @@ -import React, { useCallback, useEffect, useRef, useState, type FC } from "react"; +import React, { useCallback, useEffect, useState, type FC } from "react"; import { useTranslation } from "react-i18next"; -import type { StyleProp, ViewStyle, ViewToken } from "react-native"; import { Platform } from "react-native"; -import Animated, { Easing, useAnimatedScrollHandler, useSharedValue, withTiming } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; +import { cancelAnimation, Easing, useSharedValue, withTiming } from "react-native-reanimated"; +import Carousel from "react-native-reanimated-carousel"; import type { SvgProps } from "react-native-svg"; -import { scheduleOnRN } from "react-native-worklets"; import { useRouter } from "expo-router"; import { Key, User } from "@tamagui/lucide-icons"; +import { useWindowDimensions } from "tamagui"; import { sdk } from "@farcaster/miniapp-sdk"; import { TimeToFullDisplay } from "@sentry/react-native"; @@ -25,6 +26,7 @@ import exaCard from "../../assets/images/exa-card.svg"; import qrCodeBlob from "../../assets/images/qr-code-blob.svg"; import qrCode from "../../assets/images/qr-code.svg"; import reportError from "../../utils/reportError"; +import useAspectRatio from "../../utils/useAspectRatio"; import useAuth from "../../utils/useAuth"; import ConnectSheet from "../shared/ConnectSheet"; import ErrorDialog from "../shared/ErrorDialog"; @@ -35,6 +37,10 @@ import View from "../shared/View"; import type { EmbeddingContext } from "../../utils/queryClient"; +function renderItem({ item, animationValue }: { animationValue: SharedValue; item: Page }) { + return ; +} + export default function Auth() { const router = useRouter(); const { t } = useTranslation(); @@ -43,9 +49,13 @@ export default function Auth() { const [signUpModalOpen, setSignUpModalOpen] = useState(false); const [signInModalOpen, setSignInModalOpen] = useState(false); - const flatListRef = useRef>(null); - const offsetX = useSharedValue(0); const progress = useSharedValue(0); + const scrollOffset = useSharedValue(0); + const isScrolling = useSharedValue(false); + + const { width, height } = useWindowDimensions(); + const aspectRatio = useAspectRatio(); + const itemWidth = Math.max(Platform.OS === "web" ? height * aspectRatio : width, 250); const currentItem = pages[activeIndex] ?? pages[0]; const { title, disabled } = currentItem; @@ -56,29 +66,41 @@ export default function Auth() { queryKey: ["embedding-context"], }); - const onViewableItemsChanged = useCallback( - ({ viewableItems }: { viewableItems: ViewToken[] }) => { - const newValue = viewableItems.length > 0 ? viewableItems[0]?.index : 0; - setActiveIndex(newValue ?? 0); - progress.value = 0; - }, - [progress], - ); + const startProgressAnimation = useCallback(() => { + progress.value = 0; + progress.value = withTiming(1, { duration: 5000, easing: Easing.linear }); + }, [progress]); - /* istanbul ignore next */ - const handleScroll = useAnimatedScrollHandler({ - onScroll: (event) => { - offsetX.value = event.contentOffset.x; - }, - }); + const handleSnapToItem = useCallback((index: number) => setActiveIndex(index), []); + + const handleScrollEnd = useCallback(() => { + isScrolling.value = false; + startProgressAnimation(); + }, [isScrolling, startProgressAnimation]); + + const handleProgressChange = useCallback( + (_: number, absoluteProgress: number) => { + const previousOffset = scrollOffset.value; + const delta = Math.abs(absoluteProgress - previousOffset); + scrollOffset.value = absoluteProgress; - const renderItem = useCallback( - ({ item, index }: { index: number; item: Page }) => { - return ; + const nearestIndex = Math.round(absoluteProgress); + const distanceFromRest = Math.abs(absoluteProgress - nearestIndex); + const scrolling = distanceFromRest > 0.01 && delta > 0.001; + + if (scrolling && !isScrolling.value) { + isScrolling.value = true; + cancelAnimation(progress); + progress.value = 0; + } }, - [offsetX], + [scrollOffset, isScrolling, progress], ); + useEffect(() => { + startProgressAnimation(); + }, [startProgressAnimation]); + const { signIn, isPending: loadingAuth } = useAuth( () => { setErrorDialogOpen(true); @@ -88,49 +110,22 @@ export default function Auth() { }, ); - useEffect(() => { - function scrollToNextPage() { - flatListRef.current?.scrollToIndex({ - index: activeIndex < pages.length - 1 ? activeIndex + 1 : 0, - animated: true, - viewPosition: 0.5, - }); - } - - const timer = setInterval(() => { - /* istanbul ignore next */ - progress.value = withTiming(progress.value + 0.2, { duration: 1000, easing: Easing.linear }, () => { - if (progress.value >= 1) { - scheduleOnRN(scrollToNextPage); - progress.value = 0; - } - }); - }, 1000); - return () => { - clearInterval(timer); - }; - }, [activeIndex, progress]); - const loading = loadingAuth || loadingContext; return ( - String(index)} - viewabilityConfig={{ itemVisiblePercentThreshold: 80 }} + width={itemWidth} + height={itemWidth / aspectRatio} + autoPlay + autoPlayInterval={5000} + scrollAnimationDuration={500} + onSnapToItem={handleSnapToItem} + onScrollEnd={handleScrollEnd} + onProgressChange={handleProgressChange} renderItem={renderItem} - onViewableItemsChanged={onViewableItemsChanged} - horizontal - onScrollToIndexFailed={() => undefined} - pagingEnabled={Platform.OS !== "web"} - bounces={false} - showsHorizontalScrollIndicator={false} - contentContainerStyle={containerStyle} /> - + @@ -272,11 +272,6 @@ export type Page = { title: string; }; -const containerStyle: StyleProp = { - justifyContent: Platform.OS === "web" ? undefined : "center", - alignItems: Platform.OS === "web" ? "stretch" : "center", -}; - const pages: [Page, ...Page[]] = [ { backgroundImage: exaCardBlob, diff --git a/src/components/auth/ListItem.tsx b/src/components/auth/ListItem.tsx index 450c3b0fa..b692d4eec 100644 --- a/src/components/auth/ListItem.tsx +++ b/src/components/auth/ListItem.tsx @@ -1,47 +1,35 @@ import React, { memo } from "react"; -import { Platform, StyleSheet } from "react-native"; +import { StyleSheet } from "react-native"; import type { SharedValue } from "react-native-reanimated"; import { Extrapolation, interpolate, useAnimatedStyle } from "react-native-reanimated"; -import { useWindowDimensions, View } from "tamagui"; +import { View } from "tamagui"; -import useAspectRatio from "../../utils/useAspectRatio"; import AnimatedView from "../shared/AnimatedView"; import type { Page } from "./Auth"; -export default memo(function ListItem({ item, index, x }: { index: number; item: Page; x: SharedValue }) { - const aspectRatio = useAspectRatio(); - const { width, height } = useWindowDimensions(); - const itemWidth = Platform.OS === "web" ? height * aspectRatio : width; +type ListItemProperties = { + animationValue: SharedValue; + item: Page; +}; + +function ListItem({ item, animationValue }: ListItemProperties) { /* istanbul ignore next */ const rBackgroundStyle = useAnimatedStyle(() => { - const animatedScale = interpolate( - x.value, - [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth], - [0, 1, 0], - Extrapolation.CLAMP, - ); - const interpolatedOpacity = interpolate( - x.value, - [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth], - [0, 1, 0], - Extrapolation.CLAMP, - ); + const animatedScale = interpolate(animationValue.value, [-1, 0, 1], [0.5, 1, 0.5], Extrapolation.CLAMP); + const interpolatedOpacity = interpolate(animationValue.value, [-1, 0, 1], [0.3, 1, 0.3], Extrapolation.CLAMP); return { transform: [{ scale: animatedScale }], opacity: interpolatedOpacity }; - }, [index, x]); + }, [animationValue]); + /* istanbul ignore next */ const rImageStyle = useAnimatedStyle(() => { - const animatedScale = interpolate( - x.value, - [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth], - [0.5, 1, 0.5], - Extrapolation.CLAMP, - ); + const animatedScale = interpolate(animationValue.value, [-1, 0, 1], [0.7, 1, 0.7], Extrapolation.CLAMP); return { transform: [{ scale: animatedScale }] }; - }, [index, x]); + }, [animationValue]); + return ( - + @@ -50,4 +38,6 @@ export default memo(function ListItem({ item, index, x }: { index: number; item: ); -}); +} + +export default memo(ListItem); diff --git a/src/components/auth/Pagination.tsx b/src/components/auth/Pagination.tsx index e89b2ecc0..8e3bc4dd9 100644 --- a/src/components/auth/Pagination.tsx +++ b/src/components/auth/Pagination.tsx @@ -3,85 +3,99 @@ import { StyleSheet } from "react-native"; import type { SharedValue } from "react-native-reanimated"; import { Extrapolation, interpolate, useAnimatedStyle } from "react-native-reanimated"; -import { useTheme, useWindowDimensions, View } from "tamagui"; +import { useTheme, XStack } from "tamagui"; import AnimatedView from "../shared/AnimatedView"; +/* istanbul ignore next */ +function calculateDistance(scrollOffset: number, index: number, length: number) { + "worklet"; + const normalizedOffset = ((scrollOffset % length) + length) % length; + let distance = Math.abs(normalizedOffset - index); + if (distance > length / 2) { + distance = length - distance; + } + return distance; +} + function PaginationComponent({ index, - x, + length, + scrollOffset, progress, + isScrolling, activeColor, }: { activeColor: string; index: number; - progress: SharedValue; - x: SharedValue; + isScrolling?: SharedValue; + length: number; + progress?: SharedValue; + scrollOffset: SharedValue; }) { - const { width } = useWindowDimensions(); - const itemWidth = width - 40; - /* istanbul ignore next */ - const rPaginatorStyle = useAnimatedStyle(() => { - const interpolatedWidth = interpolate( - x.value, - [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth], - [8, 24, 8], - Extrapolation.CLAMP, - ); - - return { - width: interpolatedWidth, - backgroundColor: "rgba(0,0,0,0.1)", - overflow: "hidden", - }; - }, [x]); + const rContainerStyle = useAnimatedStyle(() => { + const distance = calculateDistance(scrollOffset.value, index, length); + const width = interpolate(distance, [0, 1, 2], [24, 8, 8], Extrapolation.CLAMP); + return { width }; + }, [scrollOffset, index, length]); /* istanbul ignore next */ const rFillStyle = useAnimatedStyle(() => { - const isActive = interpolate( - x.value, - [(index - 0.5) * itemWidth, index * itemWidth, (index + 0.5) * itemWidth], - [0, 1, 0], - Extrapolation.CLAMP, - ); + if (!progress) return { width: "0%", backgroundColor: activeColor }; + if (isScrolling?.value) return { width: "0%", backgroundColor: activeColor }; + + const distance = calculateDistance(scrollOffset.value, index, length); + const isSettled = distance < 0.05; return { - width: isActive ? `${progress.value * 100}%` : "0%", + width: isSettled ? `${progress.value * 100}%` : "0%", backgroundColor: activeColor, }; - }, [progress, x]); + }, [scrollOffset, progress, isScrolling, index, length, activeColor]); return ( - + ); } -export default memo(function Pagination({ +const styles = StyleSheet.create({ + dot: { + borderRadius: 4, + height: 4, + overflow: "hidden", + }, +}); + +function Pagination({ length, - x, + scrollOffset, progress, + isScrolling, }: { + isScrolling?: SharedValue; length: number; - progress: SharedValue; - x: SharedValue; + progress?: SharedValue; + scrollOffset: SharedValue; }) { const theme = useTheme(); return ( - - {Array.from({ length }).map((_, index) => { - return ( - - ); - })} - + + {Array.from({ length }).map((_, index) => ( + + ))} + ); -}); +} + +export default memo(Pagination); diff --git a/src/components/benefits/BenefitsSection.tsx b/src/components/benefits/BenefitsSection.tsx index 218ee6bc4..98e5b27fb 100644 --- a/src/components/benefits/BenefitsSection.tsx +++ b/src/components/benefits/BenefitsSection.tsx @@ -1,9 +1,11 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, useWindowDimensions } from "react-native"; +import { Platform, StyleSheet, useWindowDimensions } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; +import { Extrapolation, interpolate, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import Carousel from "react-native-reanimated-carousel"; -import { View, XStack } from "tamagui"; +import { useTheme, View, XStack } from "tamagui"; import BenefitCard from "./BenefitCard"; import BenefitSheet from "./BenefitSheet"; @@ -11,6 +13,7 @@ import AiraloLogo from "../../assets/images/airalo.svg"; import PaxLogo from "../../assets/images/pax.svg"; import VisaLogo from "../../assets/images/visa.svg"; import useAspectRatio from "../../utils/useAspectRatio"; +import AnimatedView from "../shared/AnimatedView"; import Text from "../shared/Text"; const BENEFITS = [ @@ -57,18 +60,70 @@ const BENEFITS = [ export type Benefit = (typeof BENEFITS)[number]; +const styles = StyleSheet.create({ dot: { height: 8, borderRadius: 10 } }); + +/* istanbul ignore next */ +function calculateDistance(scrollOffset: number, index: number, length: number) { + "worklet"; + const normalizedOffset = ((scrollOffset % length) + length) % length; + let distance = Math.abs(normalizedOffset - index); + if (distance > length / 2) { + distance = length - distance; + } + return distance; +} + +function PaginationDot({ + index, + scrollOffset, + activeColor, + inactiveColor, +}: { + activeColor: string; + inactiveColor: string; + index: number; + scrollOffset: SharedValue; +}) { + const length = BENEFITS.length; + + /* istanbul ignore next */ + const rStyle = useAnimatedStyle(() => { + const distance = calculateDistance(scrollOffset.value, index, length); + const width = interpolate(distance, [0, 1], [20, 8], Extrapolation.CLAMP); + const opacity = interpolate(distance, [0, 1], [1, 0.4], Extrapolation.CLAMP); + return { width, opacity }; + }, [scrollOffset, index, length]); + + /* istanbul ignore next */ + const rColorStyle = useAnimatedStyle(() => { + const distance = calculateDistance(scrollOffset.value, index, length); + const isActive = distance < 0.5; + return { backgroundColor: isActive ? activeColor : inactiveColor }; + }, [scrollOffset, index, activeColor, inactiveColor]); + + return ; +} + export default function BenefitsSection() { const { t } = useTranslation(); + const theme = useTheme(); const [selectedBenefit, setSelectedBenefit] = useState(); const [sheetOpen, setSheetOpen] = useState(false); - const [currentIndex, setCurrentIndex] = useState(0); + const scrollOffset = useSharedValue(0); const aspectRatio = useAspectRatio(); const { width, height } = useWindowDimensions(); const carouselWidth = Math.max(Platform.OS === "web" ? Math.min(height * aspectRatio, 600) - 64 : width - 64, 250); + const handleProgressChange = useCallback( + (_: number, absoluteProgress: number) => { + scrollOffset.value = absoluteProgress; + }, + [scrollOffset], + ); + return ( <> @@ -77,20 +132,12 @@ export default function BenefitsSection() { {t("Benefits")} {BENEFITS.map((benefit, index) => ( - ))} @@ -102,7 +149,7 @@ export default function BenefitsSection() { autoPlay autoPlayInterval={5000} scrollAnimationDuration={500} - onSnapToItem={(index) => setCurrentIndex(index)} + onProgressChange={handleProgressChange} renderItem={({ item }) => (