Skip to content

Commit

Permalink
Add scroll-based animation to HeaderBackground
Browse files Browse the repository at this point in the history
This required a significant refactor and improvement to global scroll
state handling for each scene and component which uses the
`useSceneScrollHandler` hook.
  • Loading branch information
samholmes committed Jan 12, 2024
1 parent 543322d commit c1aad19
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 55 deletions.
2 changes: 1 addition & 1 deletion src/components/common/SceneWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function SceneWrapper(props: SceneWrapperProps): JSX.Element {
const headerBarHeight = getDefaultHeaderHeight(frame, false, 0)

// If the scene has scroll, this will be required for tabs and/or header animation
const handleScroll = useSceneScrollHandler()
const handleScroll = useSceneScrollHandler(scroll && (hasTabs || hasHeader))

const renderScene = (safeAreaInsets: EdgeInsets, keyboardAnimation: Animated.Value | undefined, trackerValue: number): JSX.Element => {
// If function children, the caller handles the insets and overscroll
Expand Down
27 changes: 19 additions & 8 deletions src/components/navigation/HeaderBackground.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
import * as React from 'react'
import { StyleSheet, View } from 'react-native'
import { StyleSheet } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import Animated, { interpolate, SharedValue, useAnimatedStyle } from 'react-native-reanimated'
import { BlurView } from 'rn-id-blurview'

import { useSceneScrollContext } from '../../state/SceneScrollState'
import { styled } from '../hoc/styled'
import { useTheme } from '../services/ThemeContext'
import { DividerLine } from '../themed/DividerLine'
import { MAX_TAB_BAR_HEIGHT } from '../themed/MenuTabs'

export const HeaderBackground = () => {
export const HeaderBackground = (props: any) => {
const theme = useTheme()

const { scrollY } = useSceneScrollContext()

return (
<HeaderBackgroundContainerView>
<HeaderBackgroundContainerView scrollY={scrollY}>
<BlurView blurType={theme.isDark ? 'dark' : 'light'} style={StyleSheet.absoluteFill} overlayColor="#00000000" />
<HeaderLinearGradient colors={theme.headerBackground} start={theme.headerBackgroundStart} end={theme.headerBackgroundEnd} />
<DividerLine colors={theme.headerOutlineColors} />
</HeaderBackgroundContainerView>
)
}

const HeaderBackgroundContainerView = styled(View)({
...StyleSheet.absoluteFillObject,
alignItems: 'stretch',
justifyContent: 'flex-end'
})
const HeaderBackgroundContainerView = styled(Animated.View)<{ scrollY: SharedValue<number> }>(() => ({ scrollY }) => [
{
...StyleSheet.absoluteFillObject,
alignItems: 'stretch',
justifyContent: 'flex-end',
opacity: 0
},
useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, MAX_TAB_BAR_HEIGHT], [0, 1])
}))
])

const HeaderLinearGradient = styled(LinearGradient)({
flex: 1
Expand Down
178 changes: 132 additions & 46 deletions src/state/SceneScrollState.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,156 @@
import { useMemo } from 'react'
import { useIsFocused } from '@react-navigation/native'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { SharedValue, useAnimatedScrollHandler, useDerivedValue, useSharedValue } from 'react-native-reanimated'

import { createStateProvider } from './createStateProvider'

export const [SceneScrollProvider, useSceneScrollContext] = createStateProvider(() => {
interface InternalScrollState {
dragStartX: SharedValue<number>
dragStartY: SharedValue<number>
scrollX: SharedValue<number>
scrollY: SharedValue<number>
scrollBeginEvent: SharedValue<NativeScrollEvent | null>
scrollEndEvent: SharedValue<NativeScrollEvent | null>
scrollMomentumBeginEvent: SharedValue<NativeScrollEvent | null>
scrollMomentumEndEvent: SharedValue<NativeScrollEvent | null>
}

export interface ScrollContextValue {
scrollX: SharedValue<number>
scrollY: SharedValue<number>
scrollXDelta: SharedValue<number>
scrollYDelta: SharedValue<number>
scrollBeginEvent: SharedValue<NativeScrollEvent | null>
scrollEndEvent: SharedValue<NativeScrollEvent | null>
scrollMomentumBeginEvent: SharedValue<NativeScrollEvent | null>
scrollMomentumEndEvent: SharedValue<NativeScrollEvent | null>
updateScrollState: (state: InternalScrollState) => void
}

export const [SceneScrollProvider, useSceneScrollContext] = createStateProvider((): ScrollContextValue => {
const dragStartX = useSharedValue(0)
const dragStartY = useSharedValue(0)
const scrollX = useSharedValue(0)
const scrollXDelta = useSharedValue(0)
const scrollY = useSharedValue(0)
const scrollYDelta = useSharedValue(0)
const scrollBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
const scrollEndEvent = useSharedValue<NativeScrollEvent | null>(null)
const scrollMomentumBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
const scrollMomentumEndEvent = useSharedValue<NativeScrollEvent | null>(null)

return useMemo(
() => ({
scrollX,
const scrollXDelta = useDerivedValue(() => scrollX.value - dragStartX.value)
const scrollYDelta = useDerivedValue(() => scrollY.value - dragStartY.value)

const updateScrollState = useCallback((state: InternalScrollState) => {
setScrollState(state)
}, [])

const [scrollState, setScrollState] = useState<InternalScrollState>({
dragStartX,
dragStartY,
scrollX,
scrollY,
scrollBeginEvent,
scrollEndEvent,
scrollMomentumBeginEvent,
scrollMomentumEndEvent
})

return useMemo(() => {
return {
scrollX: scrollState.scrollX,
scrollY: scrollState.scrollY,
scrollBeginEvent: scrollState.scrollBeginEvent,
scrollEndEvent: scrollState.scrollEndEvent,
scrollMomentumBeginEvent: scrollState.scrollMomentumBeginEvent,
scrollMomentumEndEvent: scrollState.scrollMomentumEndEvent,
scrollXDelta,
scrollY,
scrollYDelta,
scrollBeginEvent,
scrollEndEvent,
scrollMomentumBeginEvent,
scrollMomentumEndEvent
}),
[scrollBeginEvent, scrollEndEvent, scrollMomentumBeginEvent, scrollMomentumEndEvent, scrollX, scrollXDelta, scrollY, scrollYDelta]
)
updateScrollState
}
}, [scrollState, scrollXDelta, scrollYDelta, updateScrollState])
})

export type SceneScrollHandler = (event: NativeSyntheticEvent<NativeScrollEvent>) => void

export const useSceneScrollHandler = (): SceneScrollHandler => {
const sceneScrollContext = useSceneScrollContext()
/**
* Return a Reanimated scroll handler (special worklet handler ref) to be attached
* to a animated scrollable component (Animate.ScrollView, Animate.FlatList, etc).
*
* The hook works by creating local component state of reanimated shared-values which
* are updated based on the scroll component's scroll position. This local state is
* passed to the global scroll state update function which stomps the global shared
* values with the local ones as the context provider's value. This will only happen
* if the scene is focused (react-navigation's useIsFocused). In addition to scene
* focus requirement, the caller of this hook has the option to control enabling
* the hook by the optional `isEnabled` boolean parameter.
*/
export const useSceneScrollHandler = (isEnabled: boolean = true): SceneScrollHandler => {
const { updateScrollState } = useSceneScrollContext()

// Local scroll state
const dragStartX = useSharedValue(0)
const dragStartY = useSharedValue(0)
const scrollX = useSharedValue(0)
const scrollY = useSharedValue(0)
const scrollBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
const scrollEndEvent = useSharedValue<NativeScrollEvent | null>(null)
const scrollMomentumBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
const scrollMomentumEndEvent = useSharedValue<NativeScrollEvent | null>(null)

const isFocused = useIsFocused()

const handler = useAnimatedScrollHandler(
{
onScroll: (nativeEvent: NativeScrollEvent) => {
'worklet'
sceneScrollContext.scrollX.value = nativeEvent.contentOffset.y
sceneScrollContext.scrollXDelta.value = nativeEvent.contentOffset.x - dragStartX.value
sceneScrollContext.scrollY.value = nativeEvent.contentOffset.y
sceneScrollContext.scrollYDelta.value = nativeEvent.contentOffset.y - dragStartY.value
},
onBeginDrag: (nativeEvent: NativeScrollEvent) => {
'worklet'
dragStartX.value = nativeEvent.contentOffset.x
dragStartY.value = nativeEvent.contentOffset.y

sceneScrollContext.scrollBeginEvent.value = nativeEvent
},
onEndDrag: nativeEvent => {
'worklet'
sceneScrollContext.scrollEndEvent.value = nativeEvent
},
onMomentumBegin: nativeEvent => {
sceneScrollContext.scrollMomentumBeginEvent.value = nativeEvent
},
onMomentumEnd: nativeEvent => {
sceneScrollContext.scrollMomentumEndEvent.value = nativeEvent
}
useEffect(() => {
if (isFocused && isEnabled) {
updateScrollState({
dragStartX,
dragStartY,
scrollX,
scrollY,
scrollBeginEvent,
scrollEndEvent,
scrollMomentumBeginEvent,
scrollMomentumEndEvent
})
}
}, [
dragStartX,
dragStartY,
isEnabled,
isFocused,
scrollBeginEvent,
scrollEndEvent,
scrollMomentumBeginEvent,
scrollMomentumEndEvent,
scrollX,
scrollY,
updateScrollState
])

const handler = useAnimatedScrollHandler({
onScroll: (nativeEvent: NativeScrollEvent) => {
'worklet'
scrollX.value = nativeEvent.contentOffset.x
scrollY.value = nativeEvent.contentOffset.y
},
onBeginDrag: (nativeEvent: NativeScrollEvent) => {
'worklet'
dragStartX.value = nativeEvent.contentOffset.x
dragStartY.value = nativeEvent.contentOffset.y

scrollBeginEvent.value = nativeEvent
},
onEndDrag: nativeEvent => {
'worklet'
scrollEndEvent.value = nativeEvent
},
onMomentumBegin: nativeEvent => {
scrollMomentumBeginEvent.value = nativeEvent
},
[]
)
onMomentumEnd: nativeEvent => {
scrollMomentumEndEvent.value = nativeEvent
}
})

return handler
}

0 comments on commit c1aad19

Please sign in to comment.