Repository: ant-design/ant-design-mobile
Pull request #6825 (2025-02-10 11:08:30)

 ImageViewer preview component supports self-defined preview content
ImageViewer支持自定义预览 #6801


github-actions[bot]: [<img width="500" alt="Prepare preview" src="https://user-images.githubusercontent.com/5378891/72351368-2c979e00-371b-11ea-9652-eb4e825d745e.gif">](https://preview-6825-ant-design-mobile.surge.sh)
<!-- AUTO_PREVIEW_HOOK -->
<!-- AUTO_PREVIEW_HOOK -->
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/ant-design/ant-design-mobile/pull/6825?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) Report
Attention: Patch coverage is `84.61538%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 92.69%. Comparing base [(`18d3b83`)](https://app.codecov.io/gh/ant-design/ant-design-mobile/commit/18d3b83e445431bcf0dfee21611a736daa0d0b09?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) to head [(`7bc1a00`)](https://app.codecov.io/gh/ant-design/ant-design-mobile/commit/7bc1a004b3de827ef0c070825cb962c04525215a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design).

| [Files with missing lines](https://app.codecov.io/gh/ant-design/ant-design-mobile/pull/6825?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) | Patch % | Lines |
|---|---|---|
| [src/components/image-viewer/slide.tsx](https://app.codecov.io/gh/ant-design/ant-design-mobile/pull/6825?src=pr&el=tree&filepath=src%2Fcomponents%2Fimage-viewer%2Fslide.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design#diff-c3JjL2NvbXBvbmVudHMvaW1hZ2Utdmlld2VyL3NsaWRlLnRzeA==) | 71.42% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/ant-design/ant-design-mobile/pull/6825?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #6825      +/-   ##
==========================================
- Coverage   92.70%   92.69%   -0.02%     
==========================================
  Files         335      335              
  Lines        7199     7200       +1     
  Branches     1793     1805      +12     
==========================================
  Hits         6674     6674              
  Misses        490      490              
- Partials       35       36       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ant-design/ant-design-mobile/pull/6825?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design).


Patch:
diff --git a/src/components/config-provider/index.en.md b/src/components/config-provider/index.en.md
index 20272623d..eccd1f2de 100644
--- a/src/components/config-provider/index.en.md
+++ b/src/components/config-provider/index.en.md
@@ -31,6 +31,5 @@ Configure locale messages and custom icons globally.
 | popup | Popup config | `{ closeIcon?: ReactNode }` | - |
 | result | Result config | `{ successIcon?: ReactNode, errorIcon?: ReactNode, infoIcon?: ReactNode, waitingIcon?: ReactNode, warningIcon?: ReactNode }` | - |
 | searchBar | SearchBar config | `{ searchIcon?: ReactNode }` | - |
-| toast | Toast config | `{ successIcon?: ReactNode, failIcon?: ReactNode, loadingIcon?: ReactNode }` | - |
 
 [zh-CN]: https://github.com/ant-design/ant-design-mobile/blob/master/src/locales/zh-CN.ts
diff --git a/src/components/config-provider/index.zh.md b/src/components/config-provider/index.zh.md
index aec654c56..e9ec68142 100644
--- a/src/components/config-provider/index.zh.md
+++ b/src/components/config-provider/index.zh.md
@@ -31,6 +31,5 @@
 | popup | Popup 配置 | `{ closeIcon?: ReactNode }` | - |
 | result | Result 配置 | `{ successIcon?: ReactNode, errorIcon?: ReactNode, infoIcon?: ReactNode, waitingIcon?: ReactNode, warningIcon?: ReactNode }` | - |
 | searchBar | SearchBar 配置 | `{ searchIcon?: ReactNode }` | - |
-| toast | Toast 配置 | `{ successIcon?: ReactNode, failIcon?: ReactNode, loadingIcon?: ReactNode }` | - |
 
 [zh-CN]: https://github.com/ant-design/ant-design-mobile/blob/master/src/locales/zh-CN.ts
diff --git a/src/components/floating-panel/demos/demo4.tsx b/src/components/floating-panel/demos/demo4.tsx
new file mode 100644
index 000000000..bd3bceb1e
--- /dev/null
+++ b/src/components/floating-panel/demos/demo4.tsx
@@ -0,0 +1,20 @@
+import { FloatingPanel, List } from 'antd-mobile'
+import React from 'react'
+
+const data = Array.from({ length: 17 }, (_, i) => String.fromCharCode(65 + i))
+
+const anchors = [100, window.innerHeight * 0.4, window.innerHeight * 0.8]
+
+export default () => {
+  return (
+    <>
+      <FloatingPanel anchors={anchors} placement='top'>
+        <List>
+          {data.map((item, index) => (
+            <List.Item key={index}>{item}</List.Item>
+          ))}
+        </List>
+      </FloatingPanel>
+    </>
+  )
+}
diff --git a/src/components/floating-panel/floating-panel.less b/src/components/floating-panel/floating-panel.less
index 281665490..1f21cbbd4 100644
--- a/src/components/floating-panel/floating-panel.less
+++ b/src/components/floating-panel/floating-panel.less
@@ -5,7 +5,6 @@
 
   position: fixed;
   z-index: var(--z-index);
-  bottom: 0;
   left: 0;
   width: 100vw;
   display: flex;
@@ -24,7 +23,6 @@
     content: '';
     display: block;
     position: absolute;
-    bottom: -100vh;
     height: 100vh;
     width: 100vw;
     background: var(--adm-color-background);
@@ -53,4 +51,19 @@
     overflow-y: scroll;
     background: var(--adm-color-background);
   }
+
+  // Position
+  &-top {
+    top: 0;
+    &::after {
+      top: -100vh;
+    }
+  }
+
+  &-bottom {
+    bottom: 0;
+    &::after {
+      bottom: -100vh;
+    }
+  }
 }
diff --git a/src/components/floating-panel/floating-panel.tsx b/src/components/floating-panel/floating-panel.tsx
index 5d2f37b96..4f7f7f8f2 100644
--- a/src/components/floating-panel/floating-panel.tsx
+++ b/src/components/floating-panel/floating-panel.tsx
@@ -1,13 +1,14 @@
-import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'
+import { animated, useSpring } from '@react-spring/web'
+import { useDrag } from '@use-gesture/react'
+import { useMemoizedFn } from 'ahooks'
+import classNames from 'classnames'
 import type { ReactNode } from 'react'
+import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'
 import { NativeProps, withNativeProps } from '../../utils/native-props'
-import { useDrag } from '@use-gesture/react'
-import { useSpring, animated } from '@react-spring/web'
-import { supportsPassive } from '../../utils/supports-passive'
 import { nearest } from '../../utils/nearest'
-import { mergeProps } from '../../utils/with-default-props'
+import { supportsPassive } from '../../utils/supports-passive'
 import { useLockScroll } from '../../utils/use-lock-scroll'
-import { useMemoizedFn } from 'ahooks'
+import { mergeProps } from '../../utils/with-default-props'
 
 const classPrefix = 'adm-floating-panel'
 
@@ -25,6 +26,7 @@ export type FloatingPanelProps = {
   children: ReactNode
   onHeightChange?: (height: number, animating: boolean) => void
   handleDraggingOfContent?: boolean
+  placement?: 'bottom' | 'top'
 } & NativeProps<'--border-radius' | '--z-index' | '--header-height'>
 
 const defaultProps = {
@@ -34,10 +36,11 @@ const defaultProps = {
 export const FloatingPanel = forwardRef<FloatingPanelRef, FloatingPanelProps>(
   (p, ref) => {
     const props = mergeProps(defaultProps, p)
-    const { anchors } = props
+    const { anchors, placement = 'bottom' } = props
     const maxHeight = anchors[anchors.length - 1] ?? window.innerHeight
 
-    const possibles = anchors.map(x => -x)
+    const isBottomPlacement = placement !== 'top'
+    const possibles = isBottomPlacement ? anchors.map(x => -x) : anchors
 
     const elementRef = useRef<HTMLDivElement>(null)
     const headerRef = useRef<HTMLDivElement>(null)
@@ -46,14 +49,14 @@ export const FloatingPanel = forwardRef<FloatingPanelRef, FloatingPanelProps>(
     const pullingRef = useRef(false)
 
     const bounds = {
-      top: possibles[possibles.length - 1],
-      bottom: possibles[0],
+      top: Math.min(...possibles),
+      bottom: Math.max(...possibles),
     }
 
     const onHeightChange = useMemoizedFn(props.onHeightChange ?? (() => {}))
 
     const [{ y }, api] = useSpring(() => ({
-      y: bounds.bottom,
+      y: isBottomPlacement ? bounds.bottom : bounds.top,
       config: { tension: 300 },
       onChange: result => {
         onHeightChange(-result.value.y, y.isAnimating)
@@ -130,14 +133,28 @@ export const FloatingPanel = forwardRef<FloatingPanelRef, FloatingPanelProps>(
 
     useLockScroll(elementRef, true)
 
+    const HeaderNode: ReactNode = (
+      <div className={`${classPrefix}-header`} ref={headerRef}>
+        <div className={`${classPrefix}-bar`} />
+      </div>
+    )
+
     return withNativeProps(
       props,
       <animated.div
         ref={elementRef}
-        className={classPrefix}
+        className={classNames(classPrefix, `${classPrefix}-${placement}`)}
         style={{
           height: Math.round(maxHeight),
-          translateY: y.to(y => `calc(100% + (${Math.round(y)}px))`),
+          translateY: y.to(y => {
+            if (isBottomPlacement) {
+              return `calc(100% + (${Math.round(y)}px))`
+            }
+            if (placement === 'top') {
+              return `calc(-100% + (${Math.round(y)}px))`
+            }
+            return y
+          }),
         }}
       >
         <div
@@ -146,12 +163,11 @@ export const FloatingPanel = forwardRef<FloatingPanelRef, FloatingPanelProps>(
             display: pulling ? 'block' : 'none',
           }}
         />
-        <div className={`${classPrefix}-header`} ref={headerRef}>
-          <div className={`${classPrefix}-bar`} />
-        </div>
+        {isBottomPlacement && HeaderNode}
         <div className={`${classPrefix}-content`} ref={contentRef}>
           {props.children}
         </div>
+        {placement === 'top' && HeaderNode}
       </animated.div>
     )
   }
diff --git a/src/components/floating-panel/index.en.md b/src/components/floating-panel/index.en.md
index ab64d35ac..a20395530 100644
--- a/src/components/floating-panel/index.en.md
+++ b/src/components/floating-panel/index.en.md
@@ -14,15 +14,18 @@ Users can freely and flexibly slide up and down to browse the content, which is
 
 <code src="./demos/demo2.tsx"></code>
 
+<code src="./demos/demo4.tsx"></code>
+
 ## FloatingPanel
 
 ### Props
 
-| Name | Description | Type | Description |
-| --- | --- | --- | --- |
-| anchors | What height can be dragged to, the unit is `px` | `number[]` | - |
-| handleDraggingOfContent | Whether to handle the drag event of the panel content area. If disabled, only the head area can be dragged | `boolean` | `true` |
-| onHeightChange | Triggered when the height changes, the `animating` parameter indicates whether it is in the process of animation | `(height: number, animating: boolean) => void` |  |
+| Name | Description | Type | Description | Version |
+| --- | --- | --- | --- | --- |
+| anchors | What height can be dragged to, the unit is `px` | `number[]` | - |  |
+| handleDraggingOfContent | Whether to handle the drag event of the panel content area. If disabled, only the head area can be dragged | `boolean` | `true` |  |
+| onHeightChange | Triggered when the height changes, the `animating` parameter indicates whether it is in the process of animation | `(height: number, animating: boolean) => void` |  |  |
+| placement | Specify the direction in which the panel appears. | `'bottom' \| 'top'` | `'bottom'` | 5.39.0 |
 
 ### Ref
 
diff --git a/src/components/floating-panel/index.zh.md b/src/components/floating-panel/index.zh.md
index 5148dfc79..163a1a0ff 100644
--- a/src/components/floating-panel/index.zh.md
+++ b/src/components/floating-panel/index.zh.md
@@ -14,15 +14,18 @@
 
 <code src="./demos/demo2.tsx"></code>
 
+<code src="./demos/demo4.tsx"></code>
+
 ## FloatingPanel
 
 ### 属性
 
-| 属性 | 说明 | 类型 | 默认值 |
-| --- | --- | --- | --- |
-| anchors | 可拖拽至哪些高度，单位为 `px` | `number[]` | - |
-| handleDraggingOfContent | 是否会处理面板内容区域的拖拽事件，禁用后则只能拖拽头部区域 | `boolean` | `true` |
-| onHeightChange | 当高度变化时触发，`animating` 参数表示是否处于动画过程中 | `(height: number, animating: boolean) => void` |  |
+| 属性 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| anchors | 可拖拽至哪些高度，单位为 `px` | `number[]` | - |  |
+| handleDraggingOfContent | 是否会处理面板内容区域的拖拽事件，禁用后则只能拖拽头部区域 | `boolean` | `true` |  |
+| onHeightChange | 当高度变化时触发，`animating` 参数表示是否处于动画过程中 | `(height: number, animating: boolean) => void` |  |  |
+| placement | 指定面板出现的方向 | `'bottom' \| 'top'` | `'bottom'` | 5.39.0 |
 
 ### Ref
 
diff --git a/src/components/image-viewer/demos/demo1.less b/src/components/image-viewer/demos/demo1.less
index 8d0a24bf0..939872c1a 100644
--- a/src/components/image-viewer/demos/demo1.less
+++ b/src/components/image-viewer/demos/demo1.less
@@ -5,7 +5,7 @@
 
 .footerButton {
   font-size: var(--adm-font-size-4);
-  color: #ffffff;
+  color: #fff;
   line-height: 1;
   padding: 10px 16px;
   background-color: rgba(153, 153, 153, 0.4);
@@ -13,3 +13,17 @@
   display: inline-block;
   cursor: pointer;
 }
+.image-render {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+}
+.custom-render {
+  color: #1677ff;
+  background: #fff;
+  width: 200px;
+  height: 100px;
+  border-radius: 2px;
+  padding: 8px;
+}
diff --git a/src/components/image-viewer/demos/demo1.tsx b/src/components/image-viewer/demos/demo1.tsx
index f329f831d..6129afa63 100644
--- a/src/components/image-viewer/demos/demo1.tsx
+++ b/src/components/image-viewer/demos/demo1.tsx
@@ -1,8 +1,13 @@
-import React, { useState } from 'react'
-import { ImageViewer, Button } from 'antd-mobile'
+import { Button, ImageViewer } from 'antd-mobile'
 import { DemoBlock } from 'demos'
-import { demoImage, demoImages } from './images'
+import React, { useState } from 'react'
 import styles from './demo1.less'
+import { demoImage, demoImages } from './images'
+
+const demoViewImages = [
+  'https://mdn.alipayobjects.com/huamei_iwk9zp/afts/file/A*uYT7SZwhJnUAAAAAAAAAAAAADgCCAQ',
+  'https://images.unsplash.com/photo-1620476214170-1d8080f65cdb?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=3150&q=80',
+]
 
 // 单张图片预览
 const Single = () => {
@@ -95,6 +100,38 @@ const ViewWithFooter = () => {
   )
 }
 
+// 自定义预览内容
+const ViewImageRender = () => {
+  const [visible, setVisible] = useState(false)
+  return (
+    <>
+      <Button
+        onClick={() => {
+          setVisible(true)
+        }}
+      >
+        显示图片
+      </Button>
+      <ImageViewer.Multi
+        images={demoViewImages}
+        visible={visible}
+        imageRender={(image, info) => {
+          if (info.index === 0)
+            return (
+              <div className={styles['image-render']}>
+                <video muted width='100%' controls src={image} />
+              </div>
+            )
+        }}
+        defaultIndex={0}
+        onClose={() => {
+          setVisible(false)
+        }}
+      />
+    </>
+  )
+}
+
 export default () => {
   return (
     <>
@@ -134,6 +171,10 @@ export default () => {
       <DemoBlock title='自定义底部额外内容'>
         <ViewWithFooter />
       </DemoBlock>
+
+      <DemoBlock title='自定义预览内容'>
+        <ViewImageRender />
+      </DemoBlock>
     </>
   )
 }
diff --git a/src/components/image-viewer/image-viewer.tsx b/src/components/image-viewer/image-viewer.tsx
index cf93dc0ed..ce92e0060 100644
--- a/src/components/image-viewer/image-viewer.tsx
+++ b/src/components/image-viewer/image-viewer.tsx
@@ -1,32 +1,33 @@
+import classNames from 'classnames'
+import type { FC, ReactNode } from 'react'
 import React, {
   forwardRef,
+  useCallback,
   useImperativeHandle,
   useRef,
   useState,
-  useCallback,
 } from 'react'
-import type { FC, ReactNode } from 'react'
-import { mergeProps } from '../../utils/with-default-props'
 import {
   GetContainer,
   renderToContainer,
 } from '../../utils/render-to-container'
+import { mergeProps } from '../../utils/with-default-props'
 import Mask from '../mask'
 import SafeArea from '../safe-area'
 import { Slide } from './slide'
 import { Slides, SlidesRef } from './slides'
-import classNames from 'classnames'
 
 const classPrefix = `adm-image-viewer`
 
 export type ImageViewerProps = {
-  image?: string
+  image: string
   maxZoom?: number | 'auto'
   getContainer?: GetContainer
   visible?: boolean
   onClose?: () => void
   afterClose?: () => void
   renderFooter?: (image: string) => ReactNode
+  imageRender?: (image: string, { index }: { index: number }) => ReactNode
   classNames?: {
     mask?: string
     body?: string
@@ -57,11 +58,12 @@ export const ImageViewer: FC<ImageViewerProps> = p => {
           props?.classNames?.body
         )}
       >
-        {props.image && (
+        {(props.image || typeof props.imageRender === 'function') && (
           <Slide
             image={props.image}
             onTap={props.onClose}
             maxZoom={props.maxZoom}
+            imageRender={props.imageRender}
           />
         )}
       </div>
@@ -80,12 +82,13 @@ export type MultiImageViewerRef = SlidesRef
 
 export type MultiImageViewerProps = Omit<
   ImageViewerProps,
-  'image' | 'renderFooter'
+  'image' | 'renderFooter' | 'imageRender'
 > & {
   images?: string[]
   defaultIndex?: number
   onIndexChange?: (index: number) => void
   renderFooter?: (image: string, index: number) => ReactNode
+  imageRender?: (image: string, { index }: { index: number }) => ReactNode
 }
 
 const multiDefaultProps = {
@@ -140,6 +143,7 @@ export const MultiImageViewer = forwardRef<
             images={props.images}
             onTap={props.onClose}
             maxZoom={props.maxZoom}
+            imageRender={props.imageRender}
           />
         )}
       </div>
diff --git a/src/components/image-viewer/index.en.md b/src/components/image-viewer/index.en.md
index 5d864f8b9..b074e0f01 100644
--- a/src/components/image-viewer/index.en.md
+++ b/src/components/image-viewer/index.en.md
@@ -21,6 +21,7 @@ You need to click on the picture to view the details and use it with the thumbna
 | maxZoom | The maximum zoom ratio | `number \| 'auto'` | `3` |
 | onClose | Triggered when it is closed | `() => void` | - |
 | renderFooter | Render extra content on footer | `(image: string) => ReactNode` | - |
+| imageRender | Custom rendering content | `(image: string, { index }: { index: number }) => ReactNode` |
 | visible | Whether to show or hide | `boolean` | `false` |
 
 ## ImageViewer.Multi
@@ -34,6 +35,7 @@ On the basis of `ImageViewer`, the following props have been added:
 | images | Url list of image resources | `string[]` | - |
 | onIndexChange | Triggered when the picture is switched | `(index: number) => void` | - |
 | renderFooter | Render extra content on footer | `(image: string, index: number) => ReactNode` | - |
+| imageRender | Custom rendering content | `(image: string, { index }: { index: number }) => ReactNode` | - |
 
 At the same time, the `image` prop is removed.
 
diff --git a/src/components/image-viewer/index.zh.md b/src/components/image-viewer/index.zh.md
index aa1023e8e..e385b731c 100644
--- a/src/components/image-viewer/index.zh.md
+++ b/src/components/image-viewer/index.zh.md
@@ -21,6 +21,7 @@
 | maxZoom | 最大缩放比例 | `number \| 'auto'` | `3` |
 | onClose | 关闭时触发 | `() => void` | - |
 | renderFooter | 渲染底部额外内容 | `(image: string) => ReactNode` | - |
+| imageRender | 自定义渲染内容 | `(image: string, { index }: { index: number }) => ReactNode` | - |
 | visible | 是否显示 | `boolean` | `false` |
 
 ## ImageViewer.Multi
@@ -32,6 +33,7 @@
 | images | 图片资源的 url 列表 | `string[]` | - |
 | onIndexChange | 切换图片时触发 | `(index: number) => void` | - |
 | renderFooter | 渲染底部额外内容 | `(image: string, index: number) => ReactNode` | - |
+| imageRender | 自定义渲染内容 | `(image: string, { index }: { index: number }) => ReactNode` | - |
 
 其他属性同 `ImageViewer`，但是去掉了 `image` 属性。
 
diff --git a/src/components/image-viewer/slide.tsx b/src/components/image-viewer/slide.tsx
index 50e680fa1..496bc68ad 100644
--- a/src/components/image-viewer/slide.tsx
+++ b/src/components/image-viewer/slide.tsx
@@ -1,12 +1,12 @@
-import React, { useRef } from 'react'
-import type { FC, MutableRefObject } from 'react'
-import { useSpring, animated } from '@react-spring/web'
+import { animated, useSpring } from '@react-spring/web'
 import { useSize } from 'ahooks'
-import { rubberbandIfOutOfBounds } from '../../utils/rubberband'
-import { useDragAndPinch } from '../../utils/use-drag-and-pinch'
+import type { FC, MutableRefObject, ReactNode } from 'react'
+import React, { useRef } from 'react'
 import { bound } from '../../utils/bound'
 import type { Matrix } from '../../utils/matrix'
 import * as mat from '../../utils/matrix'
+import { rubberbandIfOutOfBounds } from '../../utils/rubberband'
+import { useDragAndPinch } from '../../utils/use-drag-and-pinch'
 
 const classPrefix = `adm-image-viewer`
 
@@ -16,10 +16,12 @@ type Props = {
   onTap?: () => void
   onZoomChange?: (zoom: number) => void
   dragLockRef?: MutableRefObject<boolean>
+  imageRender?: (image: string, { index }: { index: number }) => ReactNode
+  index?: number
 }
 
 export const Slide: FC<Props> = props => {
-  const { dragLockRef, maxZoom } = props
+  const { dragLockRef, maxZoom, imageRender, index } = props
   const initialMartix = useRef<boolean[]>([])
   const controlRef = useRef<HTMLDivElement>(null)
   const imgRef = useRef<HTMLImageElement>(null)
@@ -295,6 +297,10 @@ export const Slide: FC<Props> = props => {
     }
   )
 
+  const customRendering =
+    typeof imageRender === 'function' &&
+    imageRender(props.image, { index } as { index: number })
+
   return (
     <div className={`${classPrefix}-slide`}>
       <div className={`${classPrefix}-control`} ref={controlRef}>
@@ -304,12 +310,16 @@ export const Slide: FC<Props> = props => {
             matrix,
           }}
         >
-          <img
-            ref={imgRef}
-            src={props.image}
-            draggable={false}
-            alt={props.image}
-          />
+          {customRendering ? (
+            customRendering
+          ) : (
+            <img
+              ref={imgRef}
+              src={props.image}
+              draggable={false}
+              alt={props.image}
+            />
+          )}
         </animated.div>
       </div>
     </div>
diff --git a/src/components/image-viewer/slides.tsx b/src/components/image-viewer/slides.tsx
index 37837074a..8a10e94fc 100644
--- a/src/components/image-viewer/slides.tsx
+++ b/src/components/image-viewer/slides.tsx
@@ -1,9 +1,14 @@
-import React, { forwardRef, useImperativeHandle, useRef } from 'react'
+import { animated, useSpring } from '@react-spring/web'
 import { useDrag } from '@use-gesture/react'
-import { useSpring, animated } from '@react-spring/web'
-import { Slide } from './slide'
-import { convertPx } from '../../utils/convert-px'
+import React, {
+  ReactNode,
+  forwardRef,
+  useImperativeHandle,
+  useRef,
+} from 'react'
 import { bound } from '../../utils/bound'
+import { convertPx } from '../../utils/convert-px'
+import { Slide } from './slide'
 
 const classPrefix = `adm-image-viewer`
 
@@ -13,6 +18,7 @@ export type SlidesType = {
   maxZoom: number
   defaultIndex: number
   onIndexChange?: (index: number) => void
+  imageRender?: (image: string, { index }: { index: number }) => ReactNode
 }
 export type SlidesRef = {
   swipeTo: (index: number, immediate?: boolean) => void
@@ -95,6 +101,8 @@ export const Slides = forwardRef<SlidesRef, SlidesType>((props, ref) => {
             image={image}
             onTap={props.onTap}
             maxZoom={props.maxZoom}
+            imageRender={props.imageRender}
+            index={index}
             onZoomChange={zoom => {
               if (zoom !== 1) {
                 const index: number = Math.round(x.get() / slideWidth)


Test patch:
diff --git a/src/components/image-viewer/tests/image-viewer.test.tsx b/src/components/image-viewer/tests/image-viewer.test.tsx
index 3645a2322..f03faa4f4 100644
--- a/src/components/image-viewer/tests/image-viewer.test.tsx
+++ b/src/components/image-viewer/tests/image-viewer.test.tsx
@@ -1,16 +1,16 @@
 import React, { useRef, useState } from 'react'
 import {
-  render,
-  testA11y,
+  act,
   fireEvent,
-  waitFor,
+  mockDrag,
+  render,
   screen,
+  testA11y,
   userEvent,
-  mockDrag,
-  act,
+  waitFor,
 } from 'testing'
-import ImageViewer, { MultiImageViewerRef } from '../index'
 import Button from '../../button'
+import ImageViewer, { MultiImageViewerRef } from '../index'
 import image from './image.json'
 const classPrefix = `adm-image-viewer`
 
@@ -21,6 +21,11 @@ const demoImages = [
   `data:image/avif;base64,${image.content}`,
 ]
 
+const demoViewImages = [
+  'https://mdn.alipayobjects.com/huamei_iwk9zp/afts/file/A*uYT7SZwhJnUAAAAAAAAAAAAADgCCAQ',
+  'https://images.unsplash.com/photo-1620476214170-1d8080f65cdb?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=3150&q=80',
+]
+
 const G = global as any
 
 // `@react-spring/web` with `skipAnimation` not work in test env. Strange
@@ -165,7 +170,6 @@ describe('ImageViewer.Multi', () => {
     })
     await waitFor(() => expect(img).not.toBeVisible())
   })
-
   test('slide and slide with pinched should be work', async () => {
     Object.defineProperty(window, 'innerWidth', {
       value: 300,
@@ -228,6 +232,21 @@ describe('ImageViewer.Multi', () => {
     await waitFor(() => expect(onIndexChange).toBeCalledWith(3))
     expect(screen.getByText('4 / 4')).toBeInTheDocument()
   })
+  test('rendering with imageRender', () => {
+    function App() {
+      return (
+        <ImageViewer.Multi
+          images={demoViewImages}
+          visible
+          imageRender={(image, info) => (
+            <div className={`customize-preview-node-${info.index}`} />
+          )}
+        />
+      )
+    }
+    render(<App />)
+    expect(document.querySelector('.customize-preview-node-0')).toBeTruthy()
+  })
 })
 
 describe('ImageViewer', () => {