|
| 1 | +import type { ReactNode } from 'react' |
| 2 | + |
| 3 | +import { BlurView } from '@react-native-community/blur' |
| 4 | +import type { ViewProps } from 'react-native' |
| 5 | +import { Platform, StyleSheet, View } from 'react-native' |
| 6 | +import type { SharedValue } from 'react-native-reanimated' |
| 7 | +import Animated, { |
| 8 | + interpolate, |
| 9 | + useAnimatedStyle, |
| 10 | + Extrapolation |
| 11 | +} from 'react-native-reanimated' |
| 12 | + |
| 13 | +import { isDarkTheme, useThemeColors, useThemeVariant } from 'app/utils/theme' |
| 14 | + |
| 15 | +/** |
| 16 | + * Opacity of the tint laid over the blur. Mirrors the desktop `Frosted` |
| 17 | + * surface, which sits at 65% of `--harmony-n-25` over a 10px backdrop blur. |
| 18 | + * Slightly higher here because mobile blurs a much busier backdrop (album art |
| 19 | + * scrolling underneath) and the header text has to stay legible at a glance. |
| 20 | + */ |
| 21 | +const IOS_TINT_OPACITY = 0.72 |
| 22 | + |
| 23 | +/** |
| 24 | + * Android does not get a real backdrop blur. `@react-native-community/blur`'s |
| 25 | + * Android implementation is expensive enough to drop frames on a surface that |
| 26 | + * is composited on every scroll frame, and the existing app-wide precedent |
| 27 | + * (ProfileNavOverlay) already falls back to a solid fill there. We use a high |
| 28 | + * -opacity tint instead: content still slides under the header, it just isn't |
| 29 | + * blurred while it does. |
| 30 | + */ |
| 31 | +const ANDROID_TINT_OPACITY = 0.94 |
| 32 | + |
| 33 | +/** |
| 34 | + * Scroll distance (px) over which the bottom edge fades from invisible to |
| 35 | + * fully drawn. Short enough that the edge is there by the time the first tile |
| 36 | + * is meaningfully behind the glass, long enough not to snap. |
| 37 | + */ |
| 38 | +const EDGE_FADE_DISTANCE = 24 |
| 39 | + |
| 40 | +type GlassSurfaceProps = ViewProps & { |
| 41 | + children?: ReactNode |
| 42 | + /** Draw a hairline separator along the bottom edge. */ |
| 43 | + showBorder?: boolean |
| 44 | + /** |
| 45 | + * Which edge the separator sits on. Bottom for glass that content scrolls |
| 46 | + * *under* (the header stack), top for glass that content scrolls *behind* |
| 47 | + * from below (the bottom tab bar). |
| 48 | + */ |
| 49 | + borderEdge?: 'top' | 'bottom' |
| 50 | + /** |
| 51 | + * Scroll offset of the content behind the glass. When provided, the bottom |
| 52 | + * edge (hairline + soft shadow) is absent at rest and fades in as content |
| 53 | + * slides underneath — so the stack reads flush with the page until there is |
| 54 | + * actually something behind it to separate from. Without it the edge is |
| 55 | + * drawn statically. |
| 56 | + */ |
| 57 | + scrollY?: SharedValue<number> |
| 58 | + /** Override the blur radius used on iOS. */ |
| 59 | + blurAmount?: number |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * A translucent "frosted glass" surface that content scrolls behind. |
| 64 | + * |
| 65 | + * Renders the blur/tint as an absolutely-positioned layer so the caller keeps |
| 66 | + * full control of its own layout — drop it in as the first child of a |
| 67 | + * position-relative container and the surface fills it. |
| 68 | + * |
| 69 | + * iOS gets a genuine backdrop blur; Android gets a near-opaque tint. See the |
| 70 | + * opacity constants above for why. |
| 71 | + */ |
| 72 | +export const GlassSurface = (props: GlassSurfaceProps) => { |
| 73 | + const { |
| 74 | + children, |
| 75 | + style, |
| 76 | + showBorder = true, |
| 77 | + borderEdge = 'bottom', |
| 78 | + scrollY, |
| 79 | + blurAmount = 20, |
| 80 | + ...other |
| 81 | + } = props |
| 82 | + const isDarkMode = isDarkTheme(useThemeVariant()) |
| 83 | + const { backgroundSurface, borderStrong } = useThemeColors() |
| 84 | + |
| 85 | + const tintOpacity = |
| 86 | + Platform.OS === 'ios' ? IOS_TINT_OPACITY : ANDROID_TINT_OPACITY |
| 87 | + |
| 88 | + const edgeStyle = useAnimatedStyle(() => { |
| 89 | + // No scrollY (or nothing scrolled yet) → draw the edge statically, which |
| 90 | + // is what non-scrolling callers want. |
| 91 | + if (!scrollY) return { opacity: 1 } |
| 92 | + return { |
| 93 | + opacity: interpolate( |
| 94 | + scrollY.value, |
| 95 | + [0, EDGE_FADE_DISTANCE], |
| 96 | + [0, 1], |
| 97 | + Extrapolation.CLAMP |
| 98 | + ) |
| 99 | + } |
| 100 | + }) |
| 101 | + |
| 102 | + return ( |
| 103 | + <View style={[styles.root, style]} {...other}> |
| 104 | + <View style={StyleSheet.absoluteFill} pointerEvents='none'> |
| 105 | + {Platform.OS === 'ios' ? ( |
| 106 | + <BlurView |
| 107 | + blurType={isDarkMode ? 'dark' : 'light'} |
| 108 | + blurAmount={blurAmount} |
| 109 | + reducedTransparencyFallbackColor={backgroundSurface} |
| 110 | + style={StyleSheet.absoluteFill} |
| 111 | + /> |
| 112 | + ) : null} |
| 113 | + <View |
| 114 | + style={[ |
| 115 | + StyleSheet.absoluteFill, |
| 116 | + { backgroundColor: backgroundSurface, opacity: tintOpacity } |
| 117 | + ]} |
| 118 | + /> |
| 119 | + {showBorder ? ( |
| 120 | + <Animated.View |
| 121 | + style={[ |
| 122 | + styles.border, |
| 123 | + borderEdge === 'top' ? styles.borderTop : styles.borderBottom, |
| 124 | + { backgroundColor: borderStrong }, |
| 125 | + edgeStyle |
| 126 | + ]} |
| 127 | + /> |
| 128 | + ) : null} |
| 129 | + </View> |
| 130 | + {children} |
| 131 | + </View> |
| 132 | + ) |
| 133 | +} |
| 134 | + |
| 135 | +const styles = StyleSheet.create({ |
| 136 | + root: { |
| 137 | + position: 'relative' |
| 138 | + }, |
| 139 | + border: { |
| 140 | + position: 'absolute', |
| 141 | + left: 0, |
| 142 | + right: 0, |
| 143 | + height: StyleSheet.hairlineWidth, |
| 144 | + // Soft drop off the hairline so the glass reads as a layer above the |
| 145 | + // content passing under it, not just a ruled line. |
| 146 | + shadowColor: '#000', |
| 147 | + shadowOpacity: 0.1, |
| 148 | + shadowRadius: 3, |
| 149 | + elevation: 3 |
| 150 | + }, |
| 151 | + borderBottom: { |
| 152 | + bottom: 0, |
| 153 | + shadowOffset: { width: 0, height: 2 } |
| 154 | + }, |
| 155 | + borderTop: { |
| 156 | + top: 0, |
| 157 | + shadowOffset: { width: 0, height: -2 } |
| 158 | + } |
| 159 | +}) |
0 commit comments