expo-duooomi-app/components/ParallelogramGridItem.tsx

267 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { memo, useMemo } from 'react'
import { Pressable } from 'react-native'
import {
Canvas,
Image as SkiaImage,
Group,
LinearGradient,
Paragraph,
Path,
Rect,
Skia,
useAnimatedImageValue,
useImage,
useFonts,
vec,
} from '@shopify/react-native-skia'
import { Block } from '@share/components'
import ParallelogramShadow from './ParallelogramShadow'
type MediaItem = {
id: string
url: string
webpPreviewUrl?: string
authorName?: string
likeCount?: number
title?: string
}
type Props = {
item: MediaItem
isSelected: boolean
itemWidth: number
isVisible: boolean
onSelect: () => void
/** 是否使用 WebP 格式,默认 true */
isWebP?: boolean
/** 图片压缩宽度,默认 256 */
compressionWidth?: number
/** 是否启用压缩,默认 false如果 webpPreviewUrl 已存在,通常不需要再次压缩) */
isCompression?: boolean
/** 错误占位图 URL 或本地资源 */
errorSource?: string | number
/** 自定义缓存键,用于需要重定向的 URL */
cacheKey?: string
}
// Cloudflare 图片优化服务
const IMAGE_OPTIMIZATION_BASE = 'https://bowong.cc/cdn-cgi/image'
const ParallelogramGridItem = memo<Props>(function ParallelogramGridItem({
item,
isSelected,
itemWidth,
isVisible,
onSelect,
isWebP = true,
compressionWidth = 256,
isCompression = false,
errorSource,
cacheKey,
}) {
const isNetworkImage = (uri: string | number): boolean => {
if (typeof uri !== 'string') return false
return uri.startsWith('http://') || uri.startsWith('https://')
}
const getCompressionUrl = (url: string): string => {
const format = isWebP ? 'webp' : 'jpg'
return `${IMAGE_OPTIMIZATION_BASE}/width=${compressionWidth},quality=75,format=${format}/${url}`
}
// 优先级处理webpPreviewUrl > url > errorSource
const imageSource = useMemo(() => {
if (item.webpPreviewUrl) {
const source = item.webpPreviewUrl
if (isNetworkImage(source) && isCompression) {
return getCompressionUrl(source)
}
return source
}
if (item.url) {
const source = item.url
if (isNetworkImage(source) && isCompression) {
return getCompressionUrl(source)
}
return source
}
return null
}, [item.webpPreviewUrl, item.url, isCompression, isWebP, compressionWidth])
// 使用 useAnimatedImageValue 加载主图片(支持 GIF 和动画 WebP
const skiaImage = useAnimatedImageValue(imageSource || null)
const errorImage = useImage(errorSource || null)
// 最终使用的图片:主图片优先,失败时使用错误占位图
const displayImage = useMemo(() => {
if (skiaImage) {
return skiaImage
}
return errorImage || null
}, [skiaImage, errorImage])
const labelHeight = 24
const cardHeight = itemWidth + labelHeight
// 预留给外部阴影的额外画布高度,避免阴影被裁剪
const shadowMargin = 4
// 平行四边形路径(只裁剪内容,不拉伸内容)
const skewOffset = 8
const padding = 2 // 四周内缩,避免描边被裁掉
// 外层(黑色)边框 & 裁剪路径
const parallelogramPath = useMemo(() => {
const p = Skia.Path.Make()
p.moveTo(skewOffset + padding, padding)
p.lineTo(itemWidth - padding, padding)
p.lineTo(itemWidth - skewOffset - padding, cardHeight - padding)
p.lineTo(padding, cardHeight - padding)
p.close()
return p
}, [cardHeight, itemWidth])
const fontMgr = useFonts({
System: [],
})
const authorParagraph = useMemo(() => {
if (!fontMgr) return null
const builder = Skia.ParagraphBuilder.Make(
{
textAlign: 0,
},
fontMgr,
)
builder.pushStyle({
color: Skia.Color('#323232'),
fontSize: 10,
fontFamilies: ['System'],
fontStyle: { weight: 400 },
})
builder.addText(item.authorName || '未知作者')
const para = builder.build()
para.layout(40)
return para
}, [fontMgr, item.authorName])
const likeParagraph = useMemo(() => {
if (!fontMgr) return null
const builder = Skia.ParagraphBuilder.Make(
{
textAlign: 0,
},
fontMgr,
)
builder.pushStyle({
color: Skia.Color('#FF0000'),
fontSize: 8,
fontFamilies: ['System'],
fontStyle: { weight: 900 },
})
builder.addText('❤ ')
builder.pushStyle({
color: Skia.Color('#323232'),
fontSize: 10,
fontFamilies: ['System'],
fontStyle: { weight: 400 },
})
builder.addText(String(item.likeCount ?? 0))
const para = builder.build()
para.layout(40)
return para
}, [fontMgr, item.likeCount])
return (
<Pressable
onPress={onSelect}
style={{
marginBottom: 12,
width: itemWidth,
height: cardHeight,
}}
>
<Block style={{ width: itemWidth, height: cardHeight + shadowMargin, position: 'relative' }}>
<Canvas style={{ width: itemWidth, height: cardHeight + shadowMargin, position: 'absolute' }}>
<Group clip={parallelogramPath}>
{isVisible && displayImage && (
<SkiaImage
image={displayImage}
x={0}
y={0}
width={itemWidth}
height={itemWidth}
fit="cover"
/>
)}
<LinearGradient
start={vec(0, itemWidth)}
end={vec(0, itemWidth * 0.6)}
colors={['rgba(0,0,0,0.8)', 'transparent']}
/>
<Rect
x={0}
y={itemWidth}
width={itemWidth}
height={labelHeight}
color={isSelected ? '#FFE500' : '#FFFFFF'}
/>
{!isSelected && (
<Rect
x={0}
y={itemWidth}
width={itemWidth}
height={1}
color="#323232"
/>
)}
{authorParagraph && (
<Paragraph
paragraph={authorParagraph}
x={8}
y={itemWidth + (labelHeight - 10) / 3}
width={60}
/>
)}
{likeParagraph && (
<Paragraph
paragraph={likeParagraph}
x={itemWidth - 32}
y={itemWidth + (labelHeight - 10) / 3}
width={40}
/>
)}
</Group>
{/* 先画阴影,再画描边,保证描边永远在最外层 */}
<ParallelogramShadow
height={cardHeight + shadowMargin}
baseHeight={cardHeight}
padding={padding}
skewOffset={skewOffset}
width={itemWidth}
/>
<Path
path={parallelogramPath}
style="stroke"
strokeWidth={3}
color={isSelected ? '#FFE500' : '#323232'}
/>
</Canvas>
</Block>
</Pressable>
)
})
export default ParallelogramGridItem