Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/grumpy-candies-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

💫 improve carousel transitions
127 changes: 61 additions & 66 deletions src/components/auth/Auth.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -35,6 +37,10 @@ import View from "../shared/View";

import type { EmbeddingContext } from "../../utils/queryClient";

function renderItem({ item, animationValue }: { animationValue: SharedValue<number>; item: Page }) {
return <ListItem item={item} animationValue={animationValue} />;
}

export default function Auth() {
const router = useRouter();
const { t } = useTranslation();
Expand All @@ -43,9 +49,13 @@ export default function Auth() {
const [signUpModalOpen, setSignUpModalOpen] = useState(false);
const [signInModalOpen, setSignInModalOpen] = useState(false);

const flatListRef = useRef<Animated.FlatList<Page>>(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;
Expand All @@ -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 <ListItem item={item} index={index} x={offsetX} />;
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);
Expand All @@ -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 }, () => {
Comment on lines -100 to -102

This comment was marked as outdated.

if (progress.value >= 1) {
scheduleOnRN(scrollToNextPage);
progress.value = 0;
}
});
}, 1000);
return () => {
clearInterval(timer);
};
}, [activeIndex, progress]);

const loading = loadingAuth || loadingContext;

return (
<SafeView fullScreen backgroundColor="$backgroundSoft">
<View flexGrow={1} justifyContent="center" flexShrink={1}>
<Animated.FlatList
ref={flatListRef}
onScroll={handleScroll}
scrollEventThrottle={16}
<Carousel
data={pages}
keyExtractor={(_, index) => 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}
/>
</View>
<View
Expand All @@ -143,7 +138,12 @@ export default function Auth() {
>
<View flexDirection="column" alignSelf="stretch" gap="$s5">
<View flexDirection="row" justifyContent="center">
<Pagination length={pages.length} x={offsetX} progress={progress} />
<Pagination
length={pages.length}
scrollOffset={scrollOffset}
progress={progress}
isScrolling={isScrolling}
/>
</View>
<View flexDirection="column" gap="$s5">
<Text emphasized title brand centered>
Expand Down Expand Up @@ -272,11 +272,6 @@ export type Page = {
title: string;
};

const containerStyle: StyleProp<ViewStyle> = {
justifyContent: Platform.OS === "web" ? undefined : "center",
alignItems: Platform.OS === "web" ? "stretch" : "center",
};

const pages: [Page, ...Page[]] = [
{
backgroundImage: exaCardBlob,
Expand Down
48 changes: 19 additions & 29 deletions src/components/auth/ListItem.tsx
Original file line number Diff line number Diff line change
@@ -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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/auth/ListItem.tsx

Repository: exactly/exa

Length of output: 2007


Replace View with YStack for layout.
The wrapper is a pure layout container applying layout properties and should use Tamagui's stack primitives.

♻️ Suggested refactor
-import { View } from "tamagui";
+import { YStack } from "tamagui";
@@
-    <View width="100%" height="100%" justifyContent="center" alignItems="center">
+    <YStack width="100%" height="100%" justifyContent="center" alignItems="center">
@@
-    </View>
+    </YStack>

Also applies to: lines 32-39

🤖 Prompt for AI Agents
In `@src/components/auth/ListItem.tsx` at line 6, The component uses Tamagui's
View as a pure layout wrapper—replace the import and usages with YStack: change
the import from "View" to "YStack" and replace each <View ...> wrapper
(including the occurrences around lines 32-39) with <YStack ...>, preserving the
same layout props but using YStack's primitives and closing tags (update any
View-specific props if needed to Tamagui stack equivalents); ensure the
component export/function ListItem remains unchanged.


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<number> }) {
const aspectRatio = useAspectRatio();
const { width, height } = useWindowDimensions();
const itemWidth = Platform.OS === "web" ? height * aspectRatio : width;
type ListItemProperties = {
animationValue: SharedValue<number>;
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 (
<View width={itemWidth} aspectRatio={aspectRatio} justifyContent="center" alignItems="center">
<View width="100%" height="100%" justifyContent="center" alignItems="center">
<AnimatedView style={rBackgroundStyle} width="100%" height="100%">
<item.backgroundImage width="100%" height="100%" />
</AnimatedView>
Expand All @@ -50,4 +38,6 @@ export default memo(function ListItem({ item, index, x }: { index: number; item:
</AnimatedView>
</View>
);
});
}

export default memo(ListItem);
Loading