Scroll Home feed to top on Android back press when scrolled down

On Android, pressing the hardware back button while on the Home screen
now scrolls the focused feed back to the top if it is scrolled down
(past the same threshold that shows the "Load new posts" button). When
already at the top, the press falls through to the default behavior as
before.

The focused feed page registers a fallback handler in a small module
registry, and the shell's existing back handler runs it only after
closeAnyActiveElement declines the press, so open dialogs, the
lightbox, the composer, and the drawer keep their priority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Q8GRaUcvhqy2G1M4odtZx
This commit is contained in:
Claude
2026-07-11 15:34:36 +00:00
parent 3989356316
commit cdd4b05755
3 changed files with 68 additions and 3 deletions
+31
View File
@@ -0,0 +1,31 @@
type HardwareBackPressFallback = () => boolean
let fallback: HardwareBackPressFallback | undefined
/**
* Registers the fallback handler for the Android hardware back press. The
* shell's back handler runs it only after any active overlays (lightbox,
* dialogs, composer, drawer) have been given the chance to close, so the
* fallback never steals the press from them. Return true from the fallback to
* consume the press, false to allow the system default behavior.
*
* Returns a function that unregisters the fallback (if it is still the
* registered one).
*/
export function setHardwareBackPressFallback(
fn: HardwareBackPressFallback,
): () => void {
fallback = fn
return () => {
if (fallback === fn) {
fallback = undefined
}
}
}
/**
* Runs the registered fallback, returning true if it consumed the back press.
*/
export function runHardwareBackPressFallback(): boolean {
return fallback?.() ?? false
}
+22 -1
View File
@@ -28,6 +28,7 @@ import {
} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session'
import {setHardwareBackPressFallback} from '#/state/shell/hardware-back-press'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {FAB} from '#/view/com/util/fab/FAB'
import {type ListMethods} from '#/view/com/util/List'
@@ -37,7 +38,7 @@ import {useTheme} from '#/alf'
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {IS_ANDROID, IS_NATIVE} from '#/env'
const POLL_FREQ = 60e3 // 60sec
@@ -120,6 +121,26 @@ export function FeedPage({
return listenSoftReset(onSoftReset)
}, [onSoftReset, isPageFocused])
/*
* On Android, a hardware back press on the Home screen scrolls back to top
* when the feed is scrolled down, instead of leaving the app.
*/
useEffect(() => {
if (!IS_ANDROID || !isPageFocused) {
return
}
return setHardwareBackPressFallback(() => {
const isScreenFocused =
getTabState(getRootNavigation(navigation).getState(), 'Home') ===
TabState.InsideAtRoot
if (isScreenFocused && isScrolledDown) {
scrollToTop()
return true
}
return false
})
}, [navigation, isPageFocused, isScrolledDown, scrollToTop])
const onPressCompose = useCallback(() => {
openComposer({logContext: 'Fab'})
}, [openComposer])
+15 -2
View File
@@ -18,6 +18,7 @@ import {
useIsDrawerSwipeDisabled,
useSetDrawerOpen,
} from '#/state/shell'
import {runHardwareBackPressFallback} from '#/state/shell/hardware-back-press'
import {useCloseAnyActiveElement} from '#/state/util'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Deactivated} from '#/screens/Deactivated'
@@ -54,6 +55,7 @@ function ShellInner() {
const {state: policyUpdateState} = usePolicyUpdateContext()
const closeAnyActiveElement = useCloseAnyActiveElement()
const isDrawerOpen = useIsDrawerOpen()
useNotificationsRegistration()
useNotificationsHandler()
@@ -61,14 +63,25 @@ function ShellInner() {
useEffect(() => {
if (IS_ANDROID) {
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
return closeAnyActiveElement()
if (closeAnyActiveElement()) {
return true
}
/*
* closeAnyActiveElement doesn't report closing the drawer, so check
* it here: when the drawer was open, keep the previous default
* behavior rather than running the fallback underneath it.
*/
if (isDrawerOpen) {
return false
}
return runHardwareBackPressFallback()
})
return () => {
listener.remove()
}
}
}, [closeAnyActiveElement])
}, [closeAnyActiveElement, isDrawerOpen])
// HACK
// expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually