/** * ✔ src * ✔ mode * ✘ show-menu-by-longpress * ✔ binderror * ✔ bindload * ✘ fade-in * ✔ webp * ✘ lazy-load * ✔ bindtap * ✔ DEFAULT_SIZE */ import { useEffect, useMemo, useState, useRef, forwardRef, createElement } from 'react' import { Image as RNImage, View, ImageStyle, ImageResizeMode, NativeSyntheticEvent, ImageErrorEventData, LayoutChangeEvent, DimensionValue, ImageLoadEventData } from 'react-native' import { noop } from '@mpxjs/utils' import { SvgCssUri } from 'react-native-svg/css' import useInnerProps, { getCustomEvent } from './getInnerListeners' import useNodesRef, { HandlerRef } from './useNodesRef' import { SVG_REGEXP, useLayout, useTransformStyle, renderImage, extendObject, isAndroid } from './utils' import Portal from './mpx-portal' export type Mode = | 'scaleToFill' | 'aspectFit' | 'aspectFill' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'center' | 'left' | 'right' | 'top left' | 'top right' | 'bottom left' | 'bottom right' export interface ImageProps { src?: string mode?: Mode svg?: boolean style?: ImageStyle & Record 'enable-offset'?: boolean 'enable-var'?: boolean 'external-var-context'?: Record 'parent-font-size'?: number 'parent-width'?: number 'parent-height'?: number 'enable-fast-image'?: boolean bindload?: (evt: NativeSyntheticEvent | unknown) => void binderror?: (evt: NativeSyntheticEvent | unknown) => void } interface ImageState { viewWidth: number viewHeight: number imageWidth?: number imageHeight?: number ratio?: number } const DEFAULT_IMAGE_WIDTH = 320 const DEFAULT_IMAGE_HEIGHT = 240 const cropMode: Mode[] = [ 'top', 'bottom', 'center', 'right', 'left', 'top left', 'top right', 'bottom left', 'bottom right' ] const ModeMap = new Map([ ['scaleToFill', 'stretch'], ['aspectFit', 'contain'], ['aspectFill', 'cover'], ['widthFix', 'stretch'], ['heightFix', 'stretch'], ...cropMode.map<[Mode, ImageResizeMode]>(mode => [mode, 'stretch']) ]) const isNumber = (value: DimensionValue): value is number => typeof value === 'number' const relativeCenteredSize = (viewSize: number, imageSize: number) => { return (viewSize - imageSize) / 2 } // 获取能完全显示图片的缩放比例:长宽方向的缩放比例最小值即为能完全展示的比例 function getFitScale (width1: number, height1: number, width2: number, height2: number) { return Math.min(width2 / width1, height2 / height1) } function getFillScale (width1: number, height1: number, width2: number, height2: number) { return Math.max(width2 / width1, height2 / height1) } function noMeetCalcRule (isSvg: boolean, mode: Mode, viewWidth: number, viewHeight: number, ratio: number) { const isMeetSize = viewWidth && viewHeight && ratio if (isSvg && !isMeetSize) return true if (!isSvg && !['scaleToFill', 'aspectFit', 'aspectFill'].includes(mode) && !isMeetSize) return true return false } const getFixedWidth = (viewWidth: number, viewHeight: number, ratio: number) => { if (!ratio) return viewWidth const fixed = viewHeight / ratio return !fixed ? viewWidth : fixed } const getFixedHeight = (viewWidth: number, viewHeight: number, ratio: number) => { const fixed = viewWidth * ratio return !fixed ? viewHeight : fixed } const Image = forwardRef, ImageProps>((props, ref): JSX.Element => { const { src = '', mode = 'scaleToFill', style = {}, 'enable-var': enableVar, 'external-var-context': externalVarContext, 'parent-font-size': parentFontSize, 'enable-fast-image': enableFastImage, 'parent-width': parentWidth, 'parent-height': parentHeight, bindload, binderror } = props const defaultStyle = { width: DEFAULT_IMAGE_WIDTH, height: DEFAULT_IMAGE_HEIGHT } const styleObj = extendObject( {}, defaultStyle, style, { overflow: 'hidden' } ) const nodeRef = useRef(null) useNodesRef(props, ref, nodeRef, { defaultStyle }) const isSvg = SVG_REGEXP.test(src) const isWidthFixMode = mode === 'widthFix' const isHeightFixMode = mode === 'heightFix' const isCropMode = cropMode.includes(mode) const isLayoutMode = isWidthFixMode || isHeightFixMode || isCropMode const resizeMode: ImageResizeMode = ModeMap.get(mode) || 'stretch' const onLayout = ({ nativeEvent: { layout: { width, height } } }: LayoutChangeEvent) => { state.current.viewWidth = width state.current.viewHeight = height // 实际渲染尺寸可能会指定的值不一致,误差低于 0.5 则认为没有变化 if (Math.abs(viewHeight - height) < 0.5 && Math.abs(viewWidth - width) < 0.5) { if (state.current.imageWidth && state.current.imageHeight && state.current.ratio) { if (!loaded) setLoaded(true) } return } if (state.current.imageWidth && state.current.imageHeight && state.current.ratio) { setRatio(state.current.ratio) setImageWidth(state.current.imageWidth) setImageHeight(state.current.imageHeight) setViewSize(state.current.viewWidth!, state.current.viewHeight!, state.current.ratio!) setLoaded(true) } } const { hasPositionFixed, hasSelfPercent, normalStyle, setWidth, setHeight } = useTransformStyle(styleObj, { enableVar, transformRadiusPercent: isAndroid && !isSvg && !isLayoutMode, externalVarContext, parentFontSize, parentWidth, parentHeight }) const { layoutRef, layoutStyle, layoutProps } = useLayout({ props, hasSelfPercent, setWidth, setHeight, nodeRef, onLayout: isLayoutMode ? onLayout : noop }) const { width, height } = normalStyle const [viewWidth, setViewWidth] = useState(isNumber(width) ? width : 0) const [viewHeight, setViewHeight] = useState(isNumber(height) ? height : 0) const [imageWidth, setImageWidth] = useState(0) const [imageHeight, setImageHeight] = useState(0) const [ratio, setRatio] = useState(0) const [loaded, setLoaded] = useState(!isLayoutMode) const state = useRef({ viewWidth, viewHeight }) function setViewSize (viewWidth: number, viewHeight: number, ratio: number) { // 在特定模式下可预测 view 的变化,在onLayout触发时能以此避免重复render switch (mode) { case 'widthFix': { setViewWidth(viewWidth) const fixedHeight = getFixedHeight(viewWidth, viewHeight, ratio) setViewHeight(fixedHeight) break } case 'heightFix': { setViewHeight(viewHeight) const fixedWidth = getFixedWidth(viewWidth, viewHeight, ratio) setViewWidth(fixedWidth) break } default: setViewHeight(viewHeight) setViewWidth(viewWidth) break } } const modeStyle: ImageStyle = useMemo(() => { if (noMeetCalcRule(isSvg, mode, viewWidth, viewHeight, ratio)) return {} switch (mode) { case 'scaleToFill': // wx 中 svg 图片的 scaleToFill 模式效果与 aspectFit 一致,不会就行图片缩放,此处保持一致 case 'aspectFit': if (isSvg) { const scale = getFitScale(imageWidth, imageHeight, viewWidth, viewHeight) return { transform: [ { translateY: relativeCenteredSize(viewHeight, imageHeight * scale) }, { translateX: relativeCenteredSize(viewWidth, imageWidth * scale) }, { scale } ] } } return {} case 'aspectFill': if (isSvg) { const scale = getFillScale(imageWidth, imageHeight, viewWidth, viewHeight) return { transform: [ { translateY: relativeCenteredSize(viewHeight, imageHeight * scale) }, { translateX: relativeCenteredSize(viewWidth, imageWidth * scale) }, { scale } ] } } return {} case 'widthFix': case 'heightFix': if (isSvg) { const scale = getFitScale(imageWidth, imageHeight, viewWidth, viewHeight) return { transform: [{ scale }] } } return {} case 'top': return { transform: [ { translateX: relativeCenteredSize(viewWidth, imageWidth) } ] } case 'bottom': return { transform: [ { translateY: viewHeight - imageHeight }, { translateX: relativeCenteredSize(viewWidth, imageWidth) } ] } case 'center': return { transform: [ { translateY: relativeCenteredSize(viewHeight, imageHeight) }, { translateX: relativeCenteredSize(viewWidth, imageWidth) } ] } case 'left': return { transform: [ { translateY: relativeCenteredSize(viewHeight, imageHeight) } ] } case 'right': return { transform: [ { translateY: relativeCenteredSize(viewHeight, imageHeight) }, { translateX: viewWidth - imageWidth } ] } case 'top left': return {} case 'top right': return { transform: [ { translateX: viewWidth - imageWidth } ] } case 'bottom left': return { transform: [ { translateY: viewHeight - imageHeight } ] } case 'bottom right': return { transform: [ { translateY: viewHeight - imageHeight }, { translateX: viewWidth - imageWidth } ] } default: return {} } }, [isSvg, mode, viewWidth, viewHeight, imageWidth, imageHeight, ratio]) const onSvgLoad = (evt: LayoutChangeEvent) => { const { width, height } = evt.nativeEvent.layout state.current.imageHeight = height setImageHeight(height) state.current.ratio = !width ? 0 : height / width if (isWidthFixMode ? state.current.viewWidth : isHeightFixMode ? state.current.viewHeight : state.current.viewWidth && state.current.viewHeight) { setRatio(state.current.ratio) setImageWidth(width) setImageHeight(height) setViewSize(state.current.viewWidth!, state.current.viewHeight!, state.current.ratio) setLoaded(true) } bindload && bindload( getCustomEvent( 'load', evt, { detail: { width, height }, layoutRef }, props ) ) } const onSvgError = (evt: Error) => { binderror!( getCustomEvent( 'error', evt, { detail: { errMsg: evt?.message }, layoutRef }, props ) ) } const onImageLoad = (evt: NativeSyntheticEvent) => { evt.persist() RNImage.getSize(src, (width: number, height: number) => { bindload!( getCustomEvent( 'load', evt, { detail: { width, height }, layoutRef }, props ) ) }) } const onImageError = (evt: NativeSyntheticEvent) => { binderror!( getCustomEvent( 'error', evt, { detail: { errMsg: evt.nativeEvent.error }, layoutRef }, props ) ) } useEffect(() => { if (!isSvg && isLayoutMode) { RNImage.getSize( src, (width: number, height: number) => { state.current.imageWidth = width state.current.imageHeight = height state.current.ratio = !width ? 0 : height / width if (isWidthFixMode ? state.current.viewWidth : isHeightFixMode ? state.current.viewHeight : state.current.viewWidth && state.current.viewHeight) { setRatio(state.current.ratio) setImageWidth(width) setImageHeight(height) setViewSize(state.current.viewWidth!, state.current.viewHeight!, state.current.ratio!) setLoaded(true) } }, () => { setLoaded(true) } ) } }, [src, isSvg, isLayoutMode]) const innerProps = useInnerProps( extendObject( {}, props, layoutProps, { ref: nodeRef, style: extendObject( {}, normalStyle, layoutStyle, isHeightFixMode ? { width: viewWidth } : {}, isWidthFixMode ? { height: viewHeight } : {} ) } ), [ 'src', 'mode', 'svg' ], { layoutRef } ) function renderSvgImage () { return createElement( View, innerProps, createElement(SvgCssUri, { uri: src, onLayout: onSvgLoad, onError: binderror && onSvgError, style: extendObject( { transformOrigin: 'left top' }, modeStyle ) }) ) } function renderBaseImage () { return renderImage( extendObject( { source: { uri: src }, resizeMode: resizeMode, onLoad: bindload && onImageLoad, onError: binderror && onImageError, style: extendObject( { transformOrigin: 'left top', width: isCropMode ? imageWidth : '100%', height: isCropMode ? imageHeight : '100%' }, isCropMode ? modeStyle : {} ) }, isLayoutMode ? {} : innerProps ), enableFastImage ) } function renderLayoutImage () { return createElement(View, innerProps, loaded && renderBaseImage()) } const finalComponent = isSvg ? renderSvgImage() : isLayoutMode ? renderLayoutImage() : renderBaseImage() if (hasPositionFixed) { return createElement(Portal, null, finalComponent) } return finalComponent }) Image.displayName = 'MpxImage' export default Image