Fix fixed footer experiment (#4969)
* Split minimal shell mode into headerMode and footerMode For now, we'll always write them in sync. When we read them, we'll use headerMode as source of truth. This will let us keep footerMode independent in a future commit. * Remove fixed_bottom_bar special cases during calculation This isn't the right time to determine special behavior. Instead we'll adjust footerMode itself conditionally on the gate. * Copy-paste setMode into MainScrollProvider This lets us fork the implementation later just for this case. * Gate footer adjustment in MainScrollProvider This is the final piece. Normal calls to setMode() keep setting both header and footer, but MainScrollProvider adjusts the footer conditionally.
This commit is contained in:
parent
2ae3ffcf78
commit
b8dbb71781
4 changed files with 95 additions and 52 deletions
|
@ -2,21 +2,24 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
|
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
import {useGate} from '../statsig/statsig'
|
|
||||||
|
|
||||||
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
||||||
|
|
||||||
export function useMinimalShellHeaderTransform() {
|
export function useMinimalShellHeaderTransform() {
|
||||||
const mode = useMinimalShellMode()
|
const {headerMode} = useMinimalShellMode()
|
||||||
const {headerHeight} = useShellLayout()
|
const {headerHeight} = useShellLayout()
|
||||||
|
|
||||||
const headerTransform = useAnimatedStyle(() => {
|
const headerTransform = useAnimatedStyle(() => {
|
||||||
return {
|
return {
|
||||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
pointerEvents: headerMode.value === 0 ? 'auto' : 'none',
|
||||||
opacity: Math.pow(1 - mode.value, 2),
|
opacity: Math.pow(1 - headerMode.value, 2),
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
|
translateY: interpolate(
|
||||||
|
headerMode.value,
|
||||||
|
[0, 1],
|
||||||
|
[0, -headerHeight.value],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
@ -26,21 +29,20 @@ export function useMinimalShellHeaderTransform() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMinimalShellFooterTransform() {
|
export function useMinimalShellFooterTransform() {
|
||||||
const mode = useMinimalShellMode()
|
const {footerMode} = useMinimalShellMode()
|
||||||
const {footerHeight} = useShellLayout()
|
const {footerHeight} = useShellLayout()
|
||||||
const gate = useGate()
|
|
||||||
const isFixedBottomBar = gate('fixed_bottom_bar')
|
|
||||||
|
|
||||||
const footerTransform = useAnimatedStyle(() => {
|
const footerTransform = useAnimatedStyle(() => {
|
||||||
if (isFixedBottomBar) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
pointerEvents: footerMode.value === 0 ? 'auto' : 'none',
|
||||||
opacity: Math.pow(1 - mode.value, 2),
|
opacity: Math.pow(1 - footerMode.value, 2),
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
|
translateY: interpolate(
|
||||||
|
footerMode.value,
|
||||||
|
[0, 1],
|
||||||
|
[0, footerHeight.value],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
@ -50,24 +52,13 @@ export function useMinimalShellFooterTransform() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMinimalShellFabTransform() {
|
export function useMinimalShellFabTransform() {
|
||||||
const mode = useMinimalShellMode()
|
const {footerMode} = useMinimalShellMode()
|
||||||
const gate = useGate()
|
|
||||||
const isFixedBottomBar = gate('fixed_bottom_bar')
|
|
||||||
|
|
||||||
const fabTransform = useAnimatedStyle(() => {
|
const fabTransform = useAnimatedStyle(() => {
|
||||||
if (isFixedBottomBar) {
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: -44,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
|
translateY: interpolate(footerMode.value, [0, 1], [-44, 0]),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,32 +6,55 @@ import {
|
||||||
withSpring,
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
|
|
||||||
type StateContext = SharedValue<number>
|
type StateContext = {
|
||||||
|
headerMode: SharedValue<number>
|
||||||
|
footerMode: SharedValue<number>
|
||||||
|
}
|
||||||
type SetContext = (v: boolean) => void
|
type SetContext = (v: boolean) => void
|
||||||
|
|
||||||
const stateContext = React.createContext<StateContext>({
|
const stateContext = React.createContext<StateContext>({
|
||||||
value: 0,
|
headerMode: {
|
||||||
addListener() {},
|
value: 0,
|
||||||
removeListener() {},
|
addListener() {},
|
||||||
modify() {},
|
removeListener() {},
|
||||||
|
modify() {},
|
||||||
|
},
|
||||||
|
footerMode: {
|
||||||
|
value: 0,
|
||||||
|
addListener() {},
|
||||||
|
removeListener() {},
|
||||||
|
modify() {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const mode = useSharedValue(0)
|
const headerMode = useSharedValue(0)
|
||||||
|
const footerMode = useSharedValue(0)
|
||||||
const setMode = React.useCallback(
|
const setMode = React.useCallback(
|
||||||
(v: boolean) => {
|
(v: boolean) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
// Cancel any existing animation
|
// Cancel any existing animation
|
||||||
cancelAnimation(mode)
|
cancelAnimation(headerMode)
|
||||||
mode.value = withSpring(v ? 1 : 0, {
|
headerMode.value = withSpring(v ? 1 : 0, {
|
||||||
|
overshootClamping: true,
|
||||||
|
})
|
||||||
|
cancelAnimation(footerMode)
|
||||||
|
footerMode.value = withSpring(v ? 1 : 0, {
|
||||||
overshootClamping: true,
|
overshootClamping: true,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[mode],
|
[headerMode, footerMode],
|
||||||
|
)
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
headerMode,
|
||||||
|
footerMode,
|
||||||
|
}),
|
||||||
|
[headerMode, footerMode],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<stateContext.Provider value={mode}>
|
<stateContext.Provider value={value}>
|
||||||
<setContext.Provider value={setMode}>{children}</setContext.Provider>
|
<setContext.Provider value={setMode}>{children}</setContext.Provider>
|
||||||
</stateContext.Provider>
|
</stateContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,11 +4,13 @@ import {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
interpolate,
|
interpolate,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import EventEmitter from 'eventemitter3'
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||||
import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {useMinimalShellMode} from '#/state/shell'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
import {isNative, isWeb} from 'platform/detection'
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
@ -21,11 +23,29 @@ function clamp(num: number, min: number, max: number) {
|
||||||
|
|
||||||
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
const {headerHeight} = useShellLayout()
|
const {headerHeight} = useShellLayout()
|
||||||
const mode = useMinimalShellMode()
|
const {headerMode, footerMode} = useMinimalShellMode()
|
||||||
const setMode = useSetMinimalShellMode()
|
|
||||||
const startDragOffset = useSharedValue<number | null>(null)
|
const startDragOffset = useSharedValue<number | null>(null)
|
||||||
const startMode = useSharedValue<number | null>(null)
|
const startMode = useSharedValue<number | null>(null)
|
||||||
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
||||||
|
const gate = useGate()
|
||||||
|
const isFixedBottomBar = gate('fixed_bottom_bar')
|
||||||
|
|
||||||
|
const setMode = React.useCallback(
|
||||||
|
(v: boolean) => {
|
||||||
|
'worklet'
|
||||||
|
cancelAnimation(headerMode)
|
||||||
|
headerMode.value = withSpring(v ? 1 : 0, {
|
||||||
|
overshootClamping: true,
|
||||||
|
})
|
||||||
|
if (!isFixedBottomBar) {
|
||||||
|
cancelAnimation(footerMode)
|
||||||
|
footerMode.value = withSpring(v ? 1 : 0, {
|
||||||
|
overshootClamping: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[headerMode, footerMode, isFixedBottomBar],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
|
@ -55,11 +75,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
setMode(true)
|
setMode(true)
|
||||||
} else {
|
} else {
|
||||||
// Snap to whichever state is the closest.
|
// Snap to whichever state is the closest.
|
||||||
setMode(Math.round(mode.value) === 1)
|
setMode(Math.round(headerMode.value) === 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[startDragOffset, startMode, setMode, mode, headerHeight],
|
[startDragOffset, startMode, setMode, headerMode, headerHeight],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onBeginDrag = useCallback(
|
const onBeginDrag = useCallback(
|
||||||
|
@ -67,10 +87,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
'worklet'
|
'worklet'
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
startDragOffset.value = e.contentOffset.y
|
startDragOffset.value = e.contentOffset.y
|
||||||
startMode.value = mode.value
|
startMode.value = headerMode.value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mode, startDragOffset, startMode],
|
[headerMode, startDragOffset, startMode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onEndDrag = useCallback(
|
const onEndDrag = useCallback(
|
||||||
|
@ -102,7 +122,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
'worklet'
|
'worklet'
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
if (startDragOffset.value === null || startMode.value === null) {
|
if (startDragOffset.value === null || startMode.value === null) {
|
||||||
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
|
if (
|
||||||
|
headerMode.value !== 0 &&
|
||||||
|
e.contentOffset.y < headerHeight.value
|
||||||
|
) {
|
||||||
// If we're close enough to the top, always show the shell.
|
// If we're close enough to the top, always show the shell.
|
||||||
// Even if we're not dragging.
|
// Even if we're not dragging.
|
||||||
setMode(false)
|
setMode(false)
|
||||||
|
@ -119,11 +142,15 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
[-1, 1],
|
[-1, 1],
|
||||||
)
|
)
|
||||||
const newValue = clamp(startMode.value + dProgress, 0, 1)
|
const newValue = clamp(startMode.value + dProgress, 0, 1)
|
||||||
if (newValue !== mode.value) {
|
if (newValue !== headerMode.value) {
|
||||||
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
||||||
// Cancel any any existing animation
|
// Cancel any any existing animation
|
||||||
cancelAnimation(mode)
|
cancelAnimation(headerMode)
|
||||||
mode.value = newValue
|
headerMode.value = newValue
|
||||||
|
if (!isFixedBottomBar) {
|
||||||
|
cancelAnimation(footerMode)
|
||||||
|
footerMode.value = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (didJustRestoreScroll.value) {
|
if (didJustRestoreScroll.value) {
|
||||||
|
@ -145,11 +172,13 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
headerHeight,
|
headerHeight,
|
||||||
mode,
|
headerMode,
|
||||||
|
footerMode,
|
||||||
setMode,
|
setMode,
|
||||||
startDragOffset,
|
startDragOffset,
|
||||||
startMode,
|
startMode,
|
||||||
didJustRestoreScroll,
|
didJustRestoreScroll,
|
||||||
|
isFixedBottomBar,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -167,7 +167,7 @@ function HomeScreenReady({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mode = useMinimalShellMode()
|
const {footerMode} = useMinimalShellMode()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
|
@ -177,7 +177,7 @@ function HomeScreenReady({
|
||||||
}
|
}
|
||||||
const listener = AppState.addEventListener('change', nextAppState => {
|
const listener = AppState.addEventListener('change', nextAppState => {
|
||||||
if (nextAppState === 'active') {
|
if (nextAppState === 'active') {
|
||||||
if (isMobile && mode.value === 1) {
|
if (isMobile && footerMode.value === 1) {
|
||||||
// Reveal the bottom bar so you don't miss notifications or messages.
|
// Reveal the bottom bar so you don't miss notifications or messages.
|
||||||
// TODO: Experiment with only doing it when unread > 0.
|
// TODO: Experiment with only doing it when unread > 0.
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
|
@ -187,7 +187,7 @@ function HomeScreenReady({
|
||||||
return () => {
|
return () => {
|
||||||
listener.remove()
|
listener.remove()
|
||||||
}
|
}
|
||||||
}, [setMinimalShellMode, mode, isMobile, gate]),
|
}, [setMinimalShellMode, footerMode, isMobile, gate]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPageSelected = React.useCallback(
|
const onPageSelected = React.useCallback(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue