Skip to content

Commit 7de135c

Browse files
dylanjeffersclaude
andauthored
feat(mobile): circle-clip avatar viewer + banner viewer on profile cover photo (#14445)
Follow-ups to #14444. ## 1. Avatar viewer keeps the circle The avatar on the profile screen is a circle, so the full-screen viewer should match — not a letterboxed square on black. `AvatarViewer` now renders the image inside a fixed square wrapper with `borderRadius: size / 2` + `overflow: 'hidden'` and `resizeMode='cover'`, sized to `min(windowWidth, windowHeight) - 48 px` so it doesn't touch the screen edges on landscape phones. ## 2. Banner viewer on the profile cover photo Tapping the cover photo at the top of the profile screen now opens a parallel full-screen viewer for the banner. Uses `WidthSizes.SIZE_2000` (the largest cached cover-photo size — the header uses `SIZE_640`) and `resizeMode='contain'` to preserve the cover photo's native aspect ratio on the black backdrop. No circular clip. ## Refactor: shared `FullscreenImageViewer` Rather than duplicate the modal/gesture/close-button machinery between Avatar and Banner viewers, the chrome is factored into a new **`FullscreenImageViewer`** that owns: - `<Modal transparent animationType='fade' statusBarTranslucent>` + `light-content` StatusBar - Pan gesture via `react-native-gesture-handler` — Reanimated shared values follow the finger in any direction, dismiss thresholds at **120 px** distance or **800 px/s** velocity, **200 ms** `withTiming` spring-back, translation reset on each open - X close button at `insets.top + 8 px` with `hitSlop`, accessible label/role - Black `#000` backdrop `AvatarViewer` and `BannerViewer` are now thin wrappers that supply the image content as `children` and a `closeAccessibilityLabel`. Same dismiss UX in both. ## ProfileCoverPhoto tap wiring The existing `<CoverPhoto>` is wrapped in a `<Pressable>` that flips local `isViewerOpen` state. The artist-badge overlay (which has no own `onPress`) sits absolute on top and stays visually unchanged — taps in the badge corner still hit the Pressable underneath. ```diff + <Pressable onPress={handleOpenViewer} accessibilityRole='imagebutton' …> <CoverPhoto userId={user_id}> <AnimatedBlurView … /> {isArtist ? <Animated.View style={styles.darkOverlay} /> : null} </CoverPhoto> + </Pressable> {isArtist ? <Animated.View style={[styles.artistBadge, badgeStyle]}>…</Animated.View> : null} + <BannerViewer userId={user_id} isOpen={isViewerOpen} onClose={handleCloseViewer} /> ``` ## Files | File | Change | |---|---| | `packages/mobile/src/screens/profile-screen/FullscreenImageViewer.tsx` | **new** — shared chrome | | `packages/mobile/src/screens/profile-screen/AvatarViewer.tsx` | refactored — circle clip child of FullscreenImageViewer | | `packages/mobile/src/screens/profile-screen/BannerViewer.tsx` | **new** — `SIZE_2000`, `resizeMode='contain'`, child of FullscreenImageViewer | | `packages/mobile/src/screens/profile-screen/ProfileCoverPhoto.tsx` | Pressable wrap + BannerViewer mount | Net diff: **+288 / −144**. ## Verification - [x] `tsc --noEmit` clean in `packages/mobile` - [ ] Manual: tap an avatar → fades in, image rendered as a circle on black backdrop. - [ ] Manual: tap the cover photo → fades in, image rendered with `contain` aspect ratio. - [ ] Manual: short drag on either viewer → springs back. - [ ] Manual: longer drag or flick in any of the four directions → dismisses. - [ ] Manual: tap the X in the top-right → dismisses. - [ ] Manual: tap the artist badge (where present) → still navigates to `CoinDetailsScreen` without opening the avatar viewer. - [ ] Manual: cover-photo parallax/blur on scroll still works the same once the Pressable is in place. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be907a3 commit 7de135c

4 files changed

Lines changed: 288 additions & 144 deletions

File tree

Lines changed: 32 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,17 @@
1-
import { useCallback, useEffect } from 'react'
2-
31
import type { ID } from '@audius/common/models'
42
import { SquareSizes } from '@audius/common/models'
5-
import {
6-
Image,
7-
Modal,
8-
StatusBar,
9-
StyleSheet,
10-
TouchableOpacity,
11-
useWindowDimensions
12-
} from 'react-native'
13-
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
14-
import Animated, {
15-
runOnJS,
16-
useAnimatedStyle,
17-
useSharedValue,
18-
withTiming
19-
} from 'react-native-reanimated'
20-
import { useSafeAreaInsets } from 'react-native-safe-area-context'
3+
import { Image, StyleSheet, useWindowDimensions, View } from 'react-native'
214

22-
import { IconClose } from '@audius/harmony-native'
235
import { useProfilePicture } from 'app/components/image/UserImage'
246

25-
// Distance (px) the user has to drag in any direction before the viewer
26-
// dismisses on release. Velocity above the threshold also dismisses to
27-
// allow a quick flick.
28-
const DISMISS_DISTANCE_THRESHOLD = 120
29-
const DISMISS_VELOCITY_THRESHOLD = 800
30-
const SPRING_BACK_DURATION_MS = 200
7+
import { FullscreenImageViewer } from './FullscreenImageViewer'
8+
9+
const CIRCLE_PADDING = 24
3110

3211
const styles = StyleSheet.create({
33-
backdrop: {
34-
flex: 1,
35-
backgroundColor: '#000',
36-
justifyContent: 'center',
37-
alignItems: 'center'
38-
},
39-
imageWrapper: {
40-
width: '100%',
41-
height: '100%',
42-
justifyContent: 'center',
43-
alignItems: 'center'
44-
},
4512
image: {
4613
width: '100%',
4714
height: '100%'
48-
},
49-
closeButton: {
50-
position: 'absolute',
51-
right: 16,
52-
width: 44,
53-
height: 44,
54-
justifyContent: 'center',
55-
alignItems: 'center'
5615
}
5716
})
5817

@@ -63,14 +22,13 @@ type AvatarViewerProps = {
6322
}
6423

6524
/**
66-
* Full-screen avatar viewer for the profile screen. Shows the user's profile
67-
* picture at the largest cached size on a black backdrop with an X button in
68-
* the top-right. Any swipe gesture (up, down, left, or right) past a small
69-
* distance / velocity threshold dismisses it; smaller drags spring back.
25+
* Full-screen viewer for the profile-screen avatar. The image is clipped to
26+
* a circle so the on-tile shape carries through to the viewer; the
27+
* surrounding backdrop, fade, swipe-to-dismiss, and close button live in
28+
* the shared {@link FullscreenImageViewer}.
7029
*/
7130
export const AvatarViewer = ({ userId, isOpen, onClose }: AvatarViewerProps) => {
72-
const insets = useSafeAreaInsets()
73-
const { width: windowWidth } = useWindowDimensions()
31+
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
7432

7533
// Use the largest cached size the image API serves so the full-screen
7634
// render isn't a blurry upscale of the 150-px tile shown in the header.
@@ -79,92 +37,31 @@ export const AvatarViewer = ({ userId, isOpen, onClose }: AvatarViewerProps) =>
7937
size: SquareSizes.SIZE_1000_BY_1000
8038
})
8139

82-
const translationX = useSharedValue(0)
83-
const translationY = useSharedValue(0)
84-
85-
// Reset the translation any time the viewer opens — without this a partial
86-
// drag from a previous open lingers and the next open starts off-center.
87-
useEffect(() => {
88-
if (isOpen) {
89-
translationX.value = 0
90-
translationY.value = 0
91-
}
92-
}, [isOpen, translationX, translationY])
93-
94-
const dismiss = useCallback(() => {
95-
onClose()
96-
}, [onClose])
97-
98-
const panGesture = Gesture.Pan()
99-
.onUpdate((e) => {
100-
'worklet'
101-
translationX.value = e.translationX
102-
translationY.value = e.translationY
103-
})
104-
.onEnd((e) => {
105-
'worklet'
106-
const distance = Math.sqrt(
107-
e.translationX * e.translationX + e.translationY * e.translationY
108-
)
109-
const speed = Math.sqrt(
110-
e.velocityX * e.velocityX + e.velocityY * e.velocityY
111-
)
112-
if (
113-
distance > DISMISS_DISTANCE_THRESHOLD ||
114-
speed > DISMISS_VELOCITY_THRESHOLD
115-
) {
116-
runOnJS(dismiss)()
117-
} else {
118-
translationX.value = withTiming(0, {
119-
duration: SPRING_BACK_DURATION_MS
120-
})
121-
translationY.value = withTiming(0, {
122-
duration: SPRING_BACK_DURATION_MS
123-
})
124-
}
125-
})
126-
127-
const animatedImageStyle = useAnimatedStyle(() => ({
128-
transform: [
129-
{ translateX: translationX.value },
130-
{ translateY: translationY.value }
131-
]
132-
}))
40+
// Circle fits the smaller of the two window dimensions (after a little
41+
// padding so it doesn't touch the edges on landscape phones).
42+
const circleSize = Math.min(windowWidth, windowHeight) - CIRCLE_PADDING * 2
13343

13444
return (
135-
<Modal
136-
visible={isOpen}
137-
transparent
138-
animationType='fade'
139-
statusBarTranslucent
140-
onRequestClose={onClose}
45+
<FullscreenImageViewer
46+
isOpen={isOpen}
47+
onClose={onClose}
48+
closeAccessibilityLabel='Close avatar viewer'
14149
>
142-
<StatusBar
143-
barStyle='light-content'
144-
backgroundColor='#000'
145-
translucent={false}
146-
/>
147-
<GestureDetector gesture={panGesture}>
148-
<Animated.View style={styles.backdrop}>
149-
<Animated.View style={[styles.imageWrapper, animatedImageStyle]}>
150-
<Image
151-
source={source}
152-
style={[styles.image, { maxWidth: windowWidth }]}
153-
resizeMode='contain'
154-
accessibilityIgnoresInvertColors
155-
/>
156-
</Animated.View>
157-
<TouchableOpacity
158-
onPress={onClose}
159-
accessibilityLabel='Close avatar viewer'
160-
accessibilityRole='button'
161-
hitSlop={{ top: 12, right: 12, bottom: 12, left: 12 }}
162-
style={[styles.closeButton, { top: insets.top + 8 }]}
163-
>
164-
<IconClose color='staticWhite' size='l' />
165-
</TouchableOpacity>
166-
</Animated.View>
167-
</GestureDetector>
168-
</Modal>
50+
<View
51+
style={{
52+
width: circleSize,
53+
height: circleSize,
54+
borderRadius: circleSize / 2,
55+
overflow: 'hidden'
56+
}}
57+
>
58+
<Image
59+
source={source}
60+
style={styles.image}
61+
resizeMode='cover'
62+
accessibilityIgnoresInvertColors
63+
/>
64+
</View>
65+
</FullscreenImageViewer>
16966
)
17067
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { ID } from '@audius/common/models'
2+
import { WidthSizes } from '@audius/common/models'
3+
import { Image, StyleSheet, useWindowDimensions } from 'react-native'
4+
5+
import { useCoverPhoto } from 'app/components/image/CoverPhoto'
6+
7+
import { FullscreenImageViewer } from './FullscreenImageViewer'
8+
9+
const styles = StyleSheet.create({
10+
image: {
11+
width: '100%',
12+
height: '100%'
13+
}
14+
})
15+
16+
type BannerViewerProps = {
17+
userId: ID | undefined
18+
isOpen: boolean
19+
onClose: () => void
20+
}
21+
22+
/**
23+
* Full-screen viewer for the profile-screen cover photo / banner. Renders
24+
* the image at the largest cached size with `resizeMode="contain"` so the
25+
* cover-photo aspect ratio is preserved (no crop, letterboxed on the black
26+
* backdrop). Modal + swipe-to-dismiss + close button come from the shared
27+
* {@link FullscreenImageViewer}.
28+
*/
29+
export const BannerViewer = ({ userId, isOpen, onClose }: BannerViewerProps) => {
30+
const { width: windowWidth } = useWindowDimensions()
31+
32+
// Largest cached cover-photo size. Header uses SIZE_640.
33+
const { source } = useCoverPhoto({
34+
userId,
35+
size: WidthSizes.SIZE_2000
36+
})
37+
38+
return (
39+
<FullscreenImageViewer
40+
isOpen={isOpen}
41+
onClose={onClose}
42+
closeAccessibilityLabel='Close banner viewer'
43+
>
44+
<Image
45+
source={source}
46+
style={[styles.image, { maxWidth: windowWidth }]}
47+
resizeMode='contain'
48+
accessibilityIgnoresInvertColors
49+
/>
50+
</FullscreenImageViewer>
51+
)
52+
}

0 commit comments

Comments
 (0)