useAnimatedImageValue

This commit is contained in:
郭文文 2026-01-20 11:46:12 +08:00
parent f2f460850c
commit aebbb13f87
2 changed files with 84 additions and 39 deletions

View File

@ -1,11 +1,11 @@
import { Image, type ImageRef } from 'expo-image'
import { memo, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { type ViewStyle } from 'react-native'
import { useSharedValue } from 'react-native-reanimated'
import { useDerivedValue, useSharedValue } from 'react-native-reanimated'
import {
Canvas,
Image,
Image as SkiaImage,
useAnimatedImageValue,
useImage,
useVideo
} from "@shopify/react-native-skia";
@ -17,12 +17,13 @@ type Props = {
style?: ViewStyle
width?: number
autoplay?: boolean // 控制动画播放,默认 true
} & React.ComponentProps<typeof Video>
}
// 默认宽度256半屏宽度
const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ...videoProps }: Props) => {
const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true }: Props) => {
const [urlFinal, setUrlFinal] = useState('')
const imageRef = useRef<ImageRef>(null)
const pausedValue = useSharedValue(!autoplay)
const looping = useSharedValue(true)
const createUrl = (url: string) => {
return `https://modal-dev.bowong.cc/api/custom/video/converter/v2?media_url=${encodeURI(url)}&options=compression_level=3,quality=70,loop=true,resolution=${width}x${width},fps=24`
@ -34,6 +35,15 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
return lowerUrl?.match(/\.(jpg|jpeg|png|gif|webp|bmp|tiff|svg)(\?.*)?$/i)
}
// 检测是否为动画图片GIF 或动画 WebP
const isAnimatedImage = (u?: string | null) => {
if (!u) return false
const lowerUrl = u.toLowerCase()
// GIF 文件通常是动画的
// WebP 可能是动画的,需要通过内容判断,这里先简单判断扩展名
return lowerUrl.includes('.gif') || lowerUrl.includes('.webp')
}
async function resolveRedirect(url: string) {
const res = await fetch(url, {
method: 'GET',
@ -85,14 +95,8 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
// 控制动画播放/停止
useEffect(() => {
if (imageRef?.current) {
if (autoplay) {
imageRef.current?.startAnimating()
} else {
imageRef.current?.stopAnimating()
}
}
}, [autoplay])
pausedValue.value = !autoplay
}, [autoplay, pausedValue])
if (!url) return null
@ -122,13 +126,25 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
// 判断是否为图片(需要检查原始 URL 和最终 URL
const shouldUseImage = isImageFile || (displayUrl ? isImg(displayUrl) : false)
// 判断是否为动画图片
const shouldUseAnimatedImage = shouldUseImage && isAnimatedImage(displayUrl)
// 判断是否为静态图片
const shouldUseStaticImage = shouldUseImage && !shouldUseAnimatedImage
// 只有在明确是视频链接且不是图片时才调用 useVideo避免触发 Skia.Video 原生异常
const shouldUseVideo = !shouldUseImage && isVideoUrl(displayUrl)
// Hooks 必须在顶层调用,不能条件调用
// 对于图片,使用 useImage对于视频使用 useVideo
// 对于静态图片,使用 useImage
// 对于动画图片,使用 useAnimatedImageValue支持 autoplay 控制)
// 对于视频,使用 useVideo
// 如果 displayUrl 为 nullhooks 会返回 null
const image = useImage(shouldUseImage ? displayUrl : null)
const staticImage = useImage(shouldUseStaticImage ? displayUrl : null)
const animatedImage = useAnimatedImageValue(
shouldUseAnimatedImage ? displayUrl : null,
shouldUseAnimatedImage ? pausedValue : undefined
)
const videoResult = useVideo(
shouldUseVideo ? displayUrl : null,
{
@ -137,38 +153,67 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
}
)
// 使用 useDerivedValue 从 videoResult.currentFrame 获取当前帧
// 如果 videoResult 为 nullvideoFrame 也会是 null
const videoFrame = useDerivedValue(() => {
return videoResult?.currentFrame.value ?? null
})
if (!url || !displayUrl) return null
const canvasStyle = style || {}
const canvasWidth = (canvasStyle.width as number) || width
const canvasHeight = (canvasStyle.height as number) || width
// 如果是图片且已加载
if (shouldUseImage && image) {
// 如果是静态图片且已加载
if (shouldUseStaticImage && staticImage) {
return (
<Image
cachePolicy="disk"
source={{ uri: url }}
style={style as any}
transition={{ duration: 200, effect: 'cross-dissolve' }}
/>
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={staticImage}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
return (
// 移除 key 避免组件重建导致闪烁,使用 transition 实现平滑切换
<Image
ref={imageRef}
// 只使用 disk 缓存,减少内存占用
cachePolicy="disk"
// 添加 recyclingKey 帮助内存回收
recyclingKey={urlFinal}
source={{ uri: urlFinal }}
style={style as any}
autoplay={autoplay}
transition={{ duration: 200, effect: 'cross-dissolve' }}
/>
)
// 如果是动画图片GIF 或动画 WebP
if (shouldUseAnimatedImage && animatedImage) {
return (
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={animatedImage}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
// 如果是视频,使用 currentFrame SharedValue
if (shouldUseVideo && videoResult && videoFrame) {
return (
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={videoFrame}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
return null
}
export default memo(VideoBox)

BIN
public/canvaskit.wasm Normal file

Binary file not shown.