diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 4e55ed4ef651b..1fc3388b1b721 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -72,7 +72,6 @@ import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolb import { useWorkspaceSidebarHeaderItem } from "../layout/useWorkspaceSidebarHeaderItem"; import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation"; import { EmptyState } from "../../components/ui/EmptyState"; -import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { SymbolView } from "../../components/ui/AppSymbol"; import { RelativeTime } from "../../components/RelativeTime"; import { NativeStackScreenOptions } from "../../native/StackHeader"; @@ -114,6 +113,7 @@ import { usePullRequestFilesSection } from "./PullRequestFiles"; import { usePullRequestMaintainerEdits } from "./usePullRequestMaintainerEdits"; import { PullRequestDescription } from "./PullRequestDescription"; import { PullRequestMediaVisibleContext } from "./PullRequestMedia"; +import { usePullRequestTabControl } from "./PullRequestTabControl"; import { PullRequestMarkdownRepository } from "./PullRequestMarkdown"; import { PullRequestStatusMenu, @@ -132,6 +132,7 @@ import { } from "@t3tools/client-runtime/source-control-clients"; const EMPTY_IDS: readonly string[] = []; +const LIST_PADDING_TOP = 8; function PullRequestTabScrollView(props: ScrollViewProps) { return ( @@ -401,6 +402,17 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { bodyY.current = event.nativeEvent.layout.height; setHeaderHeight(event.nativeEvent.layout.height); + tabControl.onHeaderLayout(event.nativeEvent.layout.y); }} > - {/* UIKit draws the tapped control's glass lens outside the hidden - tab's opacity, so only the visible tab mounts the control. */} {Platform.OS !== "ios" || section === tab ? ( - + tabControl.header ) : ( )} @@ -900,6 +900,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps ))} + {identity ? tabControl.overlay : null} ({ + overview: { scrollOffset: overviewScroll }, + guide: { scrollOffset: guideScroll }, + activity: { scrollOffset: activityScroll }, + })); + const layout = useRef<{ header?: number; control?: number }>({}); + // A shared value, not state, so a header layout change moves the control without a re-render. + const controlY = useSharedValue(null); + // The two onLayout events can arrive in either order. Until both have, the header copy shows. + const measure = () => { + const { header, control } = layout.current; + if (header !== undefined && control !== undefined) { + controlY.set(props.listPaddingTop + header + control); + } + }; + const shownScroll = listScroll[props.selected].scrollOffset; + const { barInsetTop } = props; + const underBar = useDerivedValue( + () => controlY.value === null || controlY.value - shownScroll.value < barInsetTop, + ); + const overlayStyle = useAnimatedStyle(() => ({ + opacity: underBar.value ? 0 : 1, + transform: [{ translateY: (controlY.value ?? 0) - shownScroll.value }], + })); + const headerStyle = useAnimatedStyle(() => ({ opacity: underBar.value ? 1 : 0 })); + // Touch follows whichever copy shows. It changes only when the control crosses the bar, so + // the JS state does not update while scrolling. + const [headerShown, setHeaderShown] = useState(true); + useAnimatedReaction( + () => underBar.value, + (shown, previous) => { + if (shown !== previous) runOnJS(setHeaderShown)(shown); + }, + ); + const control = ( + + ); + return { + listSharedValues: (section) => listScroll[section], + onHeaderLayout: (y) => { + layout.current.header = y; + measure(); + }, + header: ( + { + layout.current.control = event.nativeEvent.layout.y; + measure(); + }} + > + + {control} + + {/* VoiceOver skips views at opacity 0 and reads the overlay after the lists, so these + invisible tabs keep the reading order. Their taps land on the control under them. */} + + {props.options.map((option) => ( + + ))} + + + ), + overlay: ( + + {control} + + ), + }; +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestTabControl.tsx b/apps/mobile/src/features/pull-requests/PullRequestTabControl.tsx new file mode 100644 index 0000000000000..6b10990ce19c9 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestTabControl.tsx @@ -0,0 +1,46 @@ +import type { AnimatedLegendListSharedValues } from "@legendapp/list/reanimated"; +import type { ReactNode } from "react"; + +import { SegmentedControl } from "../../components/ui/SegmentedControl"; + +export type PullRequestSection = "overview" | "guide" | "activity"; + +export interface PullRequestTabControlProps { + readonly options: readonly { readonly value: PullRequestSection; readonly label: string }[]; + readonly selected: PullRequestSection; + readonly onSelect: (section: PullRequestSection) => void; + /** Top inset the lists scroll under. */ + readonly barInsetTop: number; + /** Top padding of each list's content container. */ + readonly listPaddingTop: number; +} + +export interface PullRequestTabControl { + readonly listSharedValues: ( + section: PullRequestSection, + ) => AnimatedLegendListSharedValues | undefined; + /** Reports the y of the list header view that holds `header`. */ + readonly onHeaderLayout: (y: number) => void; + /** Goes in each list header. */ + readonly header: ReactNode; + /** Goes above the lists. */ + readonly overlay: ReactNode; +} + +// oxlint-disable-next-line t3code/no-hookless-use-function -- Non-iOS stub of a hook; the .ios.tsx file calls hooks. +export function usePullRequestTabControl(props: PullRequestTabControlProps): PullRequestTabControl { + return { + listSharedValues: () => undefined, + onHeaderLayout: () => {}, + header: ( + + ), + overlay: null, + }; +}