IjzerenHein / react-navigation-shared-element

React Navigation bindings for react-native-shared-element 💫
https://github.com/IjzerenHein/react-native-shared-element
MIT License
1.27k stars 124 forks source link

Transitioning in and out with different elements #279

Closed stevengoldberg closed 1 year ago

stevengoldberg commented 1 year ago

My app displays a grid of images. On pressing an image, it transitions to a new route which contains a full-sized view of the image and additional data. The full-sized view is displayed in a FlatList, so the list of images can be swiped through. Navigating back from here returns to the grid.

The behavior I want to achieve is like the iOS photos app: Use a shared element transition on the image to transition into and out of the full-sized image view. This is straightforward if the user taps an image and then returns to the grid without swiping in the FlatList, because the shared element is the same both times.

Here it is working as intended:

https://github.com/IjzerenHein/react-navigation-shared-element/assets/2230992/c0efa11e-c68a-47d2-8abb-d1cade169a17

However, if the user transitions to the full-sized view, swipes through the FlatList, then goes back, the shared element transition happens with the original element rather than the updated one. This video shows the issue:

https://github.com/IjzerenHein/react-navigation-shared-element/assets/2230992/bff2538c-2a0e-46b4-84bc-13af46ba9f2e

How can I solve this and keep the sharedElements in sync? I'm successfully keeping track of the correct shared element ID using the onViewableItemsChanged prop of FlatList. But given the arguments to the sharedElements function, it seems like I'd need to pass the id via the route params. Any way I do that, though, seems to cause unnecessary re-renders. What am I missing?

thinh-nn3386 commented 1 year ago

@stevengoldberg when shared element ID change, you must update stackRouteParam to update SharedElementsConfig in Stack.Screen.

  // navigation.goBack() will animate the current `shared element ID`
  useEffect(() => {
      navigation.setParams(
        { sharedElementId:  `shared element ID` }
      )
  }, [`shared element ID`])
stevengoldberg commented 1 year ago

Thanks @thinh-nn3386 — I ultimately went with a similar solution. In my case, I use the route params to set the initialScrollIndex of the FlatList, so I don't want to update that each time I scroll through the list, but I do want to update the title of the screen displaying the FlatList (in addition to selecting the correct shared element for the transition back). Here are some simplified code snippets of my approach:

Setting up the navigator:

export default function LibraryView({

}) {
    return (
        <Stack.Navigator initialRouteName="CalendarList">
            <Stack.Screen
                name="DayView"
                component={DayView}
                sharedElements={(route, otherRoute, showing) => {
                    if (showing) {
                        return [imagesByDateString[route.params.date].id]
                    }
                }}
            />
            <Stack.Screen
                name="CalendarList"
                component={CalendarView}
                sharedElements={(route, otherRoute, showing) => {
                    if (showing) {
                        return [imagesByDateString[route.params.date].id]
                    }
                }}
            />
        </Stack.Navigator>
    )
}

The component inside CalendarView that sets up navigating from the overview to the expanded view list:

const DayWithImage = ({ dateString, uri, id, day, setImageLoaded }) => {
    const navigation = useNavigation()
    return (
        <Center width="100%">
            <Pressable
                onPress={() => {
                    navigation.navigate('DayView', { date: dateString })
                }}
                width="100%"
            >
                <SharedElement id={id}>
                    <Image
                        alt={`image for ${dateString}`}
                        source={{ uri }}
                        width="100%"
                        aspectRatio={3 / 4}
                        borderWidth={1}
                        borderColor={'blue.600'}
                        borderRadius={4}
                        onLoadEnd={() => setImageLoaded(true)}
                    />
                </SharedElement>

                <Text
                    width="100%"
                    color="white"
                    position="absolute"
                    textAlign="center"
                    top={0}
                >
                    {day}
                </Text>
            </Pressable>
        </Center>
    )
}

The DayView, which displays details in a FlatList and keeps the screen header in sync:

function DayView({ route, navigation }) {
    const imagesByDateString = useContext(ImageContext)

    const { date: initialDate } = route.params
    const dateStrings = Object.keys(imagesByDateString)
    const sortedDateStrings = dateStrings.sort((a, b) => a - b)
    const initialIndex = sortedDateStrings.indexOf(initialDate)
    const windowWidth = Dimensions.get('window').width

    const handleViewableItemsChanged = useCallback(
        ({ viewableItems, changed }) => {
            if (viewableItems.length) {
                const current = viewableItems[0].item
                const time = imagesByDateString[current].creationTime
                navigation.setOptions({
                    header: () => <DayCardHeader date={current} time={time} />,
                })
            }
        },
        [navigation, imagesByDateString]
    )
    const viewabilityConfigRef = useRef({
        itemVisiblePercentThreshold: 100,
    })

    useEffect(() => {
        const time = imagesByDateString[route.params.date].creationTime
        navigation.setOptions({
            header: ({ route, options, navigation }) => (
                <DayCardHeader date={route.params.date} time={time} />
            ),
        })
    }, [navigation, imagesByDateString, route.params.date])

    const handleRenderItem = useCallback(
        ({ item: dateString }) => {
            const currentImage = imagesByDateString[dateString]
            return (
                <DayCard
                    key={currentImage.id}
                    imageCreationTime={currentImage.creationTime}
                    uri={currentImage.uri}
                    id={currentImage.id}
                    dateString={dateString}
                    creationTime={currentImage.creationTime}
                    windowWidth={windowWidth}
                />
            )
        },
        [imagesByDateString, windowWidth]
    )

    return (
        <FlatList
            width={windowWidth}
            data={sortedDateStrings}
            horizontal
            initialScrollIndex={initialIndex}
            pagingEnabled
            removeClippedSubviews
            maxToRenderPerBatch={3}
            initialNumToRender={1}
            windowSize={3}
            viewabilityConfig={viewabilityConfigRef.current}
            getItemLayout={(data, index) => ({
                length: windowWidth,
                offset: windowWidth * index,
                index,
            })}
            onViewableItemsChanged={handleViewableItemsChanged}
            renderItem={handleRenderItem}
        />
    )
}

And the DayCardHeader, which is responsible for navigating back to the CalendarView:

const DayCardHeader = ({ date, time }) => {
    const navigation = useNavigation()
    const [year, month, day] = date.split('-')
    const displayTime = format(new Date(time), 'p')
    const displayDate = isToday(new Date(year, month - 1, day))
        ? 'Today'
        : format(new Date(year, month - 1, day), 'PPPP')

    console.log(`${date} ${displayDate}`)
    return (
        <Row safeAreaTop pb={1}>
            <Box pl={3} width={20} alignItems="flex-start">
                <IconButton
                    icon={
                        <Ionicons
                            name="ios-close-circle"
                            size={24}
                            color="black"
                        />
                    }
                    onPress={() =>
                        navigation.navigate('CalendarList', {
                            date,
                        })
                    }
                >
                    Back
                </IconButton>
            </Box>
            <Box flex={1}>
                <Text flex={1} bold textAlign="center">
                    {displayDate}
                </Text>

                <Text flex={1} textAlign="center">
                    {displayTime}
                </Text>
            </Box>
            <Box width={20} pr={3} />
        </Row>
    )
}