Merge pull request #9823 from bluesky-social/app-1788

[APP-1788] update design for stacked avatars
This commit is contained in:
Spence Pope
2026-02-11 15:57:07 -05:00
committed by GitHub
3 changed files with 84 additions and 63 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>

After

Width:  |  Height:  |  Size: 357 B

+79 -63
View File
@@ -1,4 +1,10 @@
import {type StyleProp, View, type ViewStyle} from 'react-native' import {useState} from 'react'
import {
type LayoutChangeEvent,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -11,7 +17,7 @@ import {
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -51,7 +57,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.flex_col, a.flex_col,
a.gap_md, a.gap_md,
a.rounded_md, a.rounded_md,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
a.p_lg, a.p_lg,
style, style,
]}> ]}>
@@ -81,8 +87,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.justify_between, a.justify_between,
a.gap_sm, a.gap_sm,
] ]
: a.flex_col, : [a.flex_col, a.gap_md],
!inlineLayout && a.gap_md,
]}> ]}>
<StackedAvatars follows={follows?.pages?.[0]?.follows} /> <StackedAvatars follows={follows?.pages?.[0]?.follows} />
<FollowDialog guide={guide} showArrow={inlineLayout} /> <FollowDialog guide={guide} showArrow={inlineLayout} />
@@ -112,70 +117,81 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
const t = useTheme() const t = useTheme()
const {centerColumnOffset} = useLayoutBreakpoints() const [containerWidth, setContainerWidth] = useState(0)
// Smaller avatars for narrower viewport const onLayout = (e: LayoutChangeEvent) => {
const avatarSize = centerColumnOffset ? 30 : 37 setContainerWidth(e.nativeEvent.layout.width)
const overlap = centerColumnOffset ? 9 : 11 }
const iconSize = centerColumnOffset ? 14 : 18
// Overlap ratio (22% of avatar size)
const overlapRatio = 0.22
// Calculate avatar size to fill container width
// Formula: containerWidth = avatarSize * count - overlap * (count - 1)
// Where overlap = avatarSize * overlapRatio
const visiblePortions = TOTAL_AVATARS - overlapRatio * (TOTAL_AVATARS - 1)
const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0
const overlap = avatarSize * overlapRatio
const iconSize = avatarSize * 0.5
// Use actual follows count, not the guide's event counter
const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? [] const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
const remainingSlots = TOTAL_AVATARS - followedAvatars.length const remainingSlots = TOTAL_AVATARS - followedAvatars.length
// Total width calculation: first avatar + (remaining * visible portion)
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
return ( return (
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}> <View style={[a.flex_row, a.flex_1]} onLayout={onLayout}>
{/* Show followed user avatars */} {containerWidth > 0 && (
{followedAvatars.map((follow, i) => ( <>
<View {/* Show followed user avatars */}
key={follow.did} {followedAvatars.map((follow, i) => (
style={[ <View
a.rounded_full, key={follow.did}
{ style={[
marginLeft: i === 0 ? 0 : -overlap, a.rounded_full,
zIndex: TOTAL_AVATARS - i, a.border,
borderWidth: 2, t.atoms.border_contrast_low,
borderColor: t.atoms.bg_contrast_25.backgroundColor, {
}, marginLeft: i === 0 ? 0 : -overlap,
]}> zIndex: TOTAL_AVATARS - i,
<UserAvatar },
type="user" ]}>
size={avatarSize - 4} <UserAvatar
avatar={follow.avatar} type="user"
/> size={avatarSize - 2}
</View> avatar={follow.avatar}
))} noBorder
{/* Show placeholder avatars for remaining slots */} />
{Array(remainingSlots) </View>
.fill(0) ))}
.map((_, i) => ( {/* Show placeholder avatars for remaining slots */}
<View {Array(remainingSlots)
key={`placeholder-${i}`} .fill(0)
style={[ .map((_, i) => (
a.align_center, <View
a.justify_center, key={`placeholder-${i}`}
a.rounded_full, style={[
t.atoms.bg_contrast_100, a.align_center,
{ a.justify_center,
width: avatarSize, a.rounded_full,
height: avatarSize, t.atoms.bg_contrast_300,
marginLeft: a.border,
followedAvatars.length === 0 && i === 0 ? 0 : -overlap, t.atoms.border_contrast_low,
zIndex: TOTAL_AVATARS - followedAvatars.length - i, {
borderWidth: 2, width: avatarSize,
borderColor: t.atoms.bg_contrast_25.backgroundColor, height: avatarSize,
}, marginLeft:
]}> followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
<PersonIcon zIndex: TOTAL_AVATARS - followedAvatars.length - i,
width={iconSize} },
height={iconSize} ]}>
fill={t.atoms.text_contrast_low.color} <PersonIcon
/> width={iconSize}
</View> height={iconSize}
))} fill={t.atoms.bg_contrast_50.backgroundColor}
/>
</View>
))}
</>
)}
</View> </View>
) )
} }
+4
View File
@@ -36,3 +36,7 @@ export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({
export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({ export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z', path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z',
}) })
export const Person_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z',
})