blurred header on iOS 26

This commit is contained in:
Samuel Newman
2026-02-25 12:59:19 +02:00
parent 8a1f8997fe
commit 43aee8c014
6 changed files with 396 additions and 308 deletions
@@ -0,0 +1,87 @@
import {useLayoutEffect, useMemo, useState} from 'react'
import {useWindowDimensions} from 'react-native'
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {atoms as a} from '#/alf'
import {IS_LIQUID_GLASS} from '#/env'
import {Outer as DefaultOuter, type OuterProps} from './index.shared'
export {
BackButton,
Content,
MenuButton,
type OuterProps,
Slot,
SubtitleText,
TitleText,
} from './index.shared'
export function Outer(props: OuterProps) {
if (IS_LIQUID_GLASS && props.transparent) {
return <TransparentOuter {...props} />
}
return <DefaultOuter {...props} />
}
function TransparentOuter({children, headerRef}: OuterProps) {
const [headerWidth, setHeaderWidth] = useState(0)
const {width: screenWidth} = useWindowDimensions()
// bit of a hack - react-native-screens initially renders the header too wide
// so let's delay showing it until the padding has been applied -sfn
const isInitialRender = headerWidth === 0 || headerWidth === screenWidth
const animatedOpacity = useAnimatedStyle(() => ({
opacity: withTiming(isInitialRender ? 0 : 1, {duration: 100}),
}))
const headerElement = useMemo(() => {
return (
<Animated.View
ref={headerRef}
onLayout={evt => setHeaderWidth(evt.nativeEvent.layout.width)}
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
a.px_xs,
animatedOpacity,
]}>
{children}
</Animated.View>
)
}, [children, headerRef, animatedOpacity])
// this is how expo-router handles it
// https://github.com/expo/expo/blob/main/packages/expo-router/src/views/Screen.tsx#L34
// note: I'm skipping handling preloading for now -sfn
const navigation = useNavigation<NavigationProp>()
const isFocused = useIsFocused()
useLayoutEffect(() => {
if (isFocused) {
navigation.setOptions({
headerShown: true,
// abuse the headerItems API so we get
// the sweet sweet progressive blur header.
// unclear why just `header: () => elem` doesn't work -sfn
unstable_headerRightItems: () => [
{
type: 'custom',
element: headerElement,
hidesSharedBackground: true,
},
],
headerTransparent: true,
headerBackVisible: false,
scrollEdgeEffects: {
top: 'soft',
},
})
}
}, [isFocused, navigation, headerElement])
return null
}
@@ -0,0 +1,233 @@
import {createContext, useCallback, useContext} from 'react'
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
CENTER_COLUMN_OFFSET,
HEADER_SLOT_SIZE,
SCROLLBAR_OFFSET,
} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
export type OuterProps = {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.RefObject<View | null>
sticky?: boolean
/**
* Use native transparent blurred header on iOS 26 (looks very nice)
*
* When using this, make sure to enable these props on the scrollview for the screen:
* ```tsx
* <Layout.Content
* contentInsetAdjustmentBehavior="automatic"
* automaticallyAdjustsScrollIndicatorInsets
* // everything else
* />
* ```
* and also set `noInsetTop={IS_LIQUID_GLASS}` on `Layout.Screen`
*
* @platform ios
* */
transparent?: boolean
}
export function Outer({
children,
noBottomBorder,
headerRef,
sticky = true,
}: OuterProps) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
const {gtMobile} = useBreakpoints()
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {centerColumnOffset} = useLayoutBreakpoints()
return (
<View
ref={headerRef}
style={[
a.w_full,
!noBottomBorder && a.border_b,
a.flex_row,
a.align_center,
a.gap_sm,
sticky && web([a.sticky, {top: 0}, a.z_10, t.atoms.bg]),
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}>
{children}
</View>
)
}
const AlignmentContext = createContext<'platform' | 'left'>('platform')
AlignmentContext.displayName = 'AlignmentContext'
export function Content({
children,
align = 'platform',
}: {
children?: React.ReactNode
align?: 'platform' | 'left'
}) {
return (
<View
style={[
a.flex_1,
a.justify_center,
IS_IOS && align === 'platform' && a.align_center,
{minHeight: HEADER_SLOT_SIZE},
]}>
<AlignmentContext.Provider value={align}>
{children}
</AlignmentContext.Provider>
</View>
)
}
export function Slot({children}: {children?: React.ReactNode}) {
return <View style={[a.z_50, {width: HEADER_SLOT_SIZE}]}>{children}</View>
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(
(evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
[onPress, navigation],
)
return (
<Slot>
<Button
label={_(msg`Go back`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
export function MenuButton() {
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const {gtMobile} = useBreakpoints()
const onPress = useCallback(() => {
Keyboard.dismiss()
setDrawerOpen(true)
}, [setDrawerOpen])
return gtMobile ? null : (
<Slot>
<Button
label={_(msg`Open drawer menu`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={onPress}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
]}>
<ButtonIcon icon={Menu} size="lg" />
</Button>
</Slot>
)
}
export function TitleText({
children,
style,
}: {children: React.ReactNode} & TextStyleProp) {
const {gtMobile} = useBreakpoints()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_lg,
a.font_semi_bold,
a.leading_tight,
IS_IOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
style,
]}
numberOfLines={2}
emoji>
{children}
</Text>
)
}
export function SubtitleText({children}: {children: React.ReactNode}) {
const t = useTheme()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_sm,
a.leading_snug,
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
{children}
</Text>
)
}
+1 -215
View File
@@ -1,215 +1 @@
import {createContext, useCallback, useContext} from 'react'
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
CENTER_COLUMN_OFFSET,
HEADER_SLOT_SIZE,
SCROLLBAR_OFFSET,
} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
export function Outer({
children,
noBottomBorder,
headerRef,
sticky = true,
}: {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.RefObject<View | null>
sticky?: boolean
}) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
const {gtMobile} = useBreakpoints()
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {centerColumnOffset} = useLayoutBreakpoints()
return (
<View
ref={headerRef}
style={[
a.w_full,
!noBottomBorder && a.border_b,
a.flex_row,
a.align_center,
a.gap_sm,
sticky && web([a.sticky, {top: 0}, a.z_10, t.atoms.bg]),
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}>
{children}
</View>
)
}
const AlignmentContext = createContext<'platform' | 'left'>('platform')
AlignmentContext.displayName = 'AlignmentContext'
export function Content({
children,
align = 'platform',
}: {
children?: React.ReactNode
align?: 'platform' | 'left'
}) {
return (
<View
style={[
a.flex_1,
a.justify_center,
IS_IOS && align === 'platform' && a.align_center,
{minHeight: HEADER_SLOT_SIZE},
]}>
<AlignmentContext.Provider value={align}>
{children}
</AlignmentContext.Provider>
</View>
)
}
export function Slot({children}: {children?: React.ReactNode}) {
return <View style={[a.z_50, {width: HEADER_SLOT_SIZE}]}>{children}</View>
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(
(evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
[onPress, navigation],
)
return (
<Slot>
<Button
label={_(msg`Go back`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
export function MenuButton() {
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const {gtMobile} = useBreakpoints()
const onPress = useCallback(() => {
Keyboard.dismiss()
setDrawerOpen(true)
}, [setDrawerOpen])
return gtMobile ? null : (
<Slot>
<Button
label={_(msg`Open drawer menu`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={onPress}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
]}>
<ButtonIcon icon={Menu} size="lg" />
</Button>
</Slot>
)
}
export function TitleText({
children,
style,
}: {children: React.ReactNode} & TextStyleProp) {
const {gtMobile} = useBreakpoints()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_lg,
a.font_semi_bold,
a.leading_tight,
IS_IOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
style,
]}
numberOfLines={2}
emoji>
{children}
</Text>
)
}
export function SubtitleText({children}: {children: React.ReactNode}) {
const t = useTheme()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_sm,
a.leading_snug,
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
{children}
</Text>
)
}
export * from './index.shared'
+67 -89
View File
@@ -12,16 +12,18 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {type ConvoItem, type ConvoState} from '#/state/messages/convo/types'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {
type SimpleVerificationState,
useSimpleVerificationState,
} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB} from '#/env'
@@ -35,6 +37,10 @@ export function MessagesListHeader({
moderation?: ModerationDecision
}) {
const t = useTheme()
const convoState = useConvo()
const verification = useSimpleVerificationState({
profile,
})
const blockInfo = useMemo(() => {
if (!moderation) return
@@ -49,8 +55,8 @@ export function MessagesListHeader({
}, [moderation])
return (
<Layout.Header.Outer>
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
<Layout.Header.Outer transparent>
<View style={[a.flex_1, a.flex_row, a.gap_xs, a.align_start]}>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
</View>
@@ -59,6 +65,8 @@ export function MessagesListHeader({
profile={profile}
moderation={moderation}
blockInfo={blockInfo}
convoState={convoState}
verification={verification}
/>
) : (
<>
@@ -101,6 +109,8 @@ function HeaderReady({
profile,
moderation,
blockInfo,
convoState,
verification,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation: ModerationDecision
@@ -108,13 +118,11 @@ function HeaderReady({
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
convoState: ConvoState
verification: SimpleVerificationState
}) {
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const verification = useSimpleVerificationState({
profile,
})
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
@@ -136,90 +144,60 @@ function HeaderReady({
: undefined
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</View>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke
size="xs"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
<View style={[a.flex_1, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
{convoState.convo?.muted && (
<>
<Text> &middot; </Text>
<BellStroke size="xs" style={t.atoms.text_contrast_medium} />
</>
)}
</View>
</Link>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
</Layout.Header.Slot>
</View>
</View>
</Link>
<View
style={[
{
paddingLeft: PFP_SIZE + a.gap_md.gap,
},
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
</Layout.Header.Slot>
</View>
</View>
)
+5 -2
View File
@@ -42,7 +42,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -86,7 +86,10 @@ export function MessagesConversationScreenInner({route}: Props) {
)
return (
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
<Layout.Screen
testID="convoScreen"
style={web([{minHeight: 0}, a.flex_1])}
noInsetTop={IS_LIQUID_GLASS}>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
</ConvoProvider>
@@ -50,8 +50,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {IS_WEB} from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
@@ -421,6 +420,8 @@ export function MessagesList({
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}>
<List
contentInsetAdjustmentBehavior="automatic"
automaticallyAdjustsScrollIndicatorInsets
ref={flatListRef}
data={convoState.items}
renderItem={renderItem}