Skip to content

Commit

Permalink
(PC-33638) feat(chronicle): add anchor on chronicle when see more but…
Browse files Browse the repository at this point in the history
…ton used with InteractionManager
  • Loading branch information
clesausse-pass committed Feb 3, 2025
1 parent fdb05b5 commit d04439d
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 89 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import colorAlpha from 'color-alpha'
import React, { FunctionComponent, useRef } from 'react'
import React, { forwardRef, useImperativeHandle, useRef } from 'react'
import { NativeScrollEvent, NativeSyntheticEvent, useWindowDimensions, View } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import LinearGradient from 'react-native-linear-gradient'
import { useTheme } from 'styled-components'
import styled from 'styled-components/native'

import { CHRONICLE_CARD_WIDTH } from 'features/chronicle/constant'
import { ChronicleCardData } from 'features/chronicle/type'
import { useHorizontalFlatListScroll } from 'ui/hooks/useHorizontalFlatListScroll'
import { PlaylistArrowButton } from 'ui/Playlist/PlaylistArrowButton'

Expand All @@ -16,24 +17,35 @@ import {
SEPARATOR_DEFAULT_VALUE,
} from './ChronicleCardListBase'

export const ChronicleCardList: FunctionComponent<ChronicleCardListProps> = ({
data,
horizontal = true,
cardWidth,
contentContainerStyle,
headerComponent,
separatorSize = SEPARATOR_DEFAULT_VALUE,
onScroll,
style,
offerId,
shouldShowSeeMoreButton,
selectedChronicle,
}) => {
export const ChronicleCardList = forwardRef<
Partial<FlatList<ChronicleCardData>>,
ChronicleCardListProps
>(function ChronicleCardList(
{
data,
horizontal = true,
cardWidth,
contentContainerStyle,
onScroll,
headerComponent,
style,
separatorSize = SEPARATOR_DEFAULT_VALUE,
shouldShowSeeMoreButton,
offerId,
onLayout,
},
ref
) {
const { isDesktopViewport } = useTheme()
const { width: windowWidth } = useWindowDimensions()

const listRef = useRef<FlatList>(null)

useImperativeHandle(ref, () => ({
scrollToOffset: (params) => listRef.current?.scrollToOffset(params),
scrollToIndex: (params) => listRef.current?.scrollToIndex(params),
}))

const {
onScroll: internalScrollHandler,
handleScrollNext,
Expand Down Expand Up @@ -91,11 +103,11 @@ export const ChronicleCardList: FunctionComponent<ChronicleCardListProps> = ({
snapToInterval={isDesktopViewport ? CHRONICLE_CARD_WIDTH : undefined}
offerId={offerId}
shouldShowSeeMoreButton={shouldShowSeeMoreButton}
selectedChronicle={selectedChronicle}
onLayout={onLayout}
/>
</View>
)
}
})

const ArrowWrapper = styled.View.attrs({
pointerEvents: 'box-none',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,4 @@ describe('ChronicleCardListBase', () => {

expect(screen.getByText('La Nature Sauvage')).toBeOnTheScreen()
})

it('should scroll to the selected chronicle when defined', () => {
render(
<ChronicleCardListBase
data={chroniclesSnap}
selectedChronicle={chroniclesSnap[4]}
ref={ref}
/>
)

expect(screen.getByText('La Magie des Étoiles')).toBeOnTheScreen()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,9 @@ import React, {
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
FlatList,
FlatListProps,
LayoutChangeEvent,
StyleProp,
View,
ViewStyle,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import styled, { useTheme } from 'styled-components/native'
import { FlatList, FlatListProps, StyleProp, ViewStyle } from 'react-native'
import styled from 'styled-components/native'

import { ChronicleCardData } from 'features/chronicle/type'
import { getSpacing } from 'ui/theme'
Expand All @@ -35,6 +26,7 @@ export type ChronicleCardListProps = Pick<
| 'snapToInterval'
| 'onScroll'
| 'onContentSizeChange'
| 'onLayout'
> & {
offset?: number
cardWidth?: number
Expand All @@ -43,7 +35,6 @@ export type ChronicleCardListProps = Pick<
style?: StyleProp<ViewStyle>
shouldShowSeeMoreButton?: boolean
offerId?: number
selectedChronicle?: ChronicleCardData
}

const renderItem = ({
Expand Down Expand Up @@ -89,29 +80,15 @@ export const ChronicleCardListBase = forwardRef<
separatorSize = SEPARATOR_DEFAULT_VALUE,
shouldShowSeeMoreButton,
offerId,
selectedChronicle,
onLayout,
},
ref
) {
const listRef = useRef<FlatList>(null)
const [isFlatListReady, setIsFlatListReady] = useState(false)
const [headerHeight, setHeaderHeight] = useState(0)
const [listHeight, setListHeight] = useState(1)
const { top } = useSafeAreaInsets()
const { appBarHeight } = useTheme()

const handleHeaderLayout = (event: LayoutChangeEvent) => {
setHeaderHeight(event.nativeEvent.layout.height)
}

const handleListLayout = (event: LayoutChangeEvent) => {
setListHeight(event.nativeEvent.layout.height)
setIsFlatListReady(true)
}

useImperativeHandle(ref, () => ({
scrollToOffset: (params) => listRef.current?.scrollToOffset(params),
scrollToItem: (params) => listRef.current?.scrollToItem(params),
scrollToIndex: (params) => listRef.current?.scrollToIndex(params),
}))

useEffect(() => {
Expand All @@ -120,19 +97,6 @@ export const ChronicleCardListBase = forwardRef<
}
}, [offset])

useEffect(() => {
if (listRef.current && isFlatListReady && !!selectedChronicle) {
const flatListTop = top + appBarHeight + headerHeight
const viewPosition = flatListTop / listHeight

listRef.current.scrollToItem({
item: selectedChronicle,
animated: true,
viewPosition,
})
}
}, [appBarHeight, headerHeight, selectedChronicle, isFlatListReady, listHeight, top])

const Separator = useMemo(
() =>
styled.View({
Expand All @@ -147,9 +111,7 @@ export const ChronicleCardListBase = forwardRef<
ref={listRef}
data={data}
style={style}
ListHeaderComponent={
headerComponent ? <View onLayout={handleHeaderLayout}>{headerComponent}</View> : null
}
ListHeaderComponent={headerComponent}
renderItem={({ item }) => renderItem({ item, cardWidth, shouldShowSeeMoreButton, offerId })}
keyExtractor={keyExtractor}
ItemSeparatorComponent={Separator}
Expand All @@ -162,7 +124,7 @@ export const ChronicleCardListBase = forwardRef<
decelerationRate="fast"
snapToInterval={snapToInterval}
testID="chronicle-list"
onLayout={handleListLayout}
onLayout={onLayout}
/>
)
})
73 changes: 65 additions & 8 deletions src/features/chronicle/pages/Chronicles/Chronicles.native.test.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,85 @@
import React from 'react'
import { FlatList } from 'react-native'

import { useRoute } from '__mocks__/@react-navigation/native'
import { offerChroniclesFixture } from 'features/chronicle/fixtures/offerChronicles.fixture'
import { Chronicles } from 'features/chronicle/pages/Chronicles/Chronicles'
import { offerResponseSnap } from 'features/offer/fixtures/offerResponse'
import { mockServer } from 'tests/mswServer'
import { reactQueryProviderHOC } from 'tests/reactQueryProviderHOC'
import { render, screen } from 'tests/utils'
import { act, fireEvent, render, screen } from 'tests/utils'

useRoute.mockReturnValue({
params: {
offerId: offerResponseSnap.id,
const mockOnLayout = {
nativeEvent: {
layout: {
width: 375,
height: 2100,
},
},
})
}

const mockScrollToIndex = jest.fn()
jest.spyOn(FlatList.prototype, 'scrollToIndex').mockImplementation(mockScrollToIndex)

const mockChronicles = offerChroniclesFixture.chronicles
jest.mock('features/chronicle/api/useChronicles/useChronicles', () => ({
useChronicles: () => ({ data: mockChronicles, isLoading: false }),
}))

describe('Chronicles', () => {
beforeEach(() => {
mockServer.getApi(`/v2/offer/${offerResponseSnap.id}`, offerResponseSnap)
mockServer.getApi(`/v1/offer/${offerResponseSnap.id}/chronicles`, offerChroniclesFixture)
})

it('should render correctly', async () => {
render(reactQueryProviderHOC(<Chronicles />))
describe('When chronicle id not defined', () => {
beforeAll(() => {
useRoute.mockReturnValue({
params: {
offerId: offerResponseSnap.id,
},
})
})

it('should render correctly', async () => {
render(reactQueryProviderHOC(<Chronicles />))

expect(await screen.findByText('Tous les avis')).toBeOnTheScreen()
})

it('should not scroll to selected chronicle on layout', async () => {
render(reactQueryProviderHOC(<Chronicles />))

await screen.findByText('Tous les avis')

await act(async () => {
fireEvent(screen.getByTestId('chronicle-list'), 'onLayout', mockOnLayout)
})

expect(mockScrollToIndex).not.toHaveBeenCalled()
})
})

describe('When chronicle id defined', () => {
beforeAll(() => {
useRoute.mockReturnValue({
params: {
offerId: offerResponseSnap.id,
chronicleId: 1,
},
})
})

it('should scroll to selected chronicle on layout', async () => {
render(reactQueryProviderHOC(<Chronicles />))

await screen.findByText('Tous les avis')

await act(async () => {
fireEvent(screen.getByTestId('chronicle-list'), 'onLayout', mockOnLayout)
})

expect(await screen.findByText('Tous les avis')).toBeOnTheScreen()
expect(mockScrollToIndex).toHaveBeenCalledTimes(1)
})
})
})
20 changes: 16 additions & 4 deletions src/features/chronicle/pages/Chronicles/Chronicles.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRoute } from '@react-navigation/native'
import React, { FunctionComponent, useRef } from 'react'
import { FlatList } from 'react-native'
import React, { FunctionComponent, useCallback, useRef } from 'react'
import { FlatList, InteractionManager } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import styled, { useTheme } from 'styled-components/native'

Expand Down Expand Up @@ -35,7 +35,19 @@ export const Chronicles: FunctionComponent = () => {

const chroniclesListRef = useRef<FlatList<ChronicleCardData>>(null)

const selectedChronicle = chronicleCardsData?.find((item) => item.id === chronicleId)
const selectedChronicle = chronicleCardsData?.findIndex((item) => item.id === chronicleId) ?? -1

const handleLayout = useCallback(() => {
if (selectedChronicle !== -1) {
InteractionManager.runAfterInteractions(() => {
chroniclesListRef.current?.scrollToIndex({
index: selectedChronicle,
animated: true,
viewOffset: headerHeight,
})
})
}
}, [selectedChronicle, headerHeight])

if (!offer || !chronicleCardsData) return null

Expand All @@ -58,7 +70,7 @@ export const Chronicles: FunctionComponent = () => {
marginTop: getSpacing(4),
marginHorizontal: contentPage.marginHorizontal,
}}
selectedChronicle={selectedChronicle}
onLayout={handleLayout}
/>
</React.Fragment>
)
Expand Down
20 changes: 16 additions & 4 deletions src/features/chronicle/pages/Chronicles/Chronicles.web.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRoute } from '@react-navigation/native'
import React, { FunctionComponent, useRef } from 'react'
import { FlatList } from 'react-native'
import React, { FunctionComponent, useCallback, useRef } from 'react'
import { FlatList, InteractionManager } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import styled, { useTheme } from 'styled-components/native'

Expand Down Expand Up @@ -63,7 +63,19 @@ export const Chronicles: FunctionComponent = () => {
{ fractionDigits: 2 }
)

const selectedChronicle = chronicleCardsData?.find((item) => item.id === chronicleId)
const selectedChronicle = chronicleCardsData?.findIndex((item) => item.id === chronicleId) ?? -1

const handleLayout = useCallback(() => {
if (selectedChronicle !== -1) {
InteractionManager.runAfterInteractions(() => {
chroniclesListRef.current?.scrollToIndex({
index: selectedChronicle,
animated: true,
viewOffset: headerHeight,
})
})
}
}, [selectedChronicle, headerHeight])

if (!offer || !chronicleCardsData) return null

Expand All @@ -76,7 +88,7 @@ export const Chronicles: FunctionComponent = () => {
onScroll={onScroll}
paddingTop={headerHeight}
headerComponent={<StyledTitle2>Tous les avis</StyledTitle2>}
selectedChronicle={selectedChronicle}
onLayout={handleLayout}
/>
)

Expand Down

0 comments on commit d04439d

Please sign in to comment.