zkit-ui 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/android/build.gradle +100 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewLayoutSupport.java +450 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewMediaItem.java +36 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewModule.kt +43 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewPagerAdapter.java +140 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewSharedElementGeometry.java +25 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewSharedElementNames.java +39 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewSharedElementState.java +25 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewSourceViewRegistry.java +115 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewTransitionSnapshotView.java +60 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewVideoPlayerView.java +338 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewView.kt +305 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewViewerOverlayView.java +636 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewViewerPageView.java +357 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/NativeImagePreviewViewerTransitionController.java +223 -0
- package/android/src/main/res/layout/native_image_preview_player_view.xml +9 -0
- package/dist/all.d.ts +2 -0
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js +24 -22
- package/dist/ui/NativeImagePreview/NativeImagePreview.d.ts +3 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.js +99 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.web.d.ts +4 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.web.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.web.js +150 -0
- package/dist/ui/NativeImagePreview/context.d.ts +14 -0
- package/dist/ui/NativeImagePreview/context.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/context.js +38 -0
- package/dist/ui/NativeImagePreview/index.d.ts +3 -0
- package/dist/ui/NativeImagePreview/index.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/index.js +5 -0
- package/dist/ui/NativeImagePreview/types.d.ts +68 -0
- package/dist/ui/NativeImagePreview/types.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/types.js +2 -0
- package/dist/ui/NativeImagePreview/utils.d.ts +8 -0
- package/dist/ui/NativeImagePreview/utils.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/utils.js +83 -0
- package/dist/ui/NativeImagePreview/web/geometry.d.ts +20 -0
- package/dist/ui/NativeImagePreview/web/geometry.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/web/geometry.js +121 -0
- package/dist/ui/NativeImagePreview/web/media.d.ts +6 -0
- package/dist/ui/NativeImagePreview/web/media.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/web/media.js +90 -0
- package/dist/ui/NativeImagePreview/web/styles.css +137 -0
- package/dist/ui/NativeImagePreview/web/transition.d.ts +15 -0
- package/dist/ui/NativeImagePreview/web/transition.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/web/transition.js +77 -0
- package/dist/ui/NativeImagePreview/web/types.d.ts +74 -0
- package/dist/ui/NativeImagePreview/web/types.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/web/types.js +3 -0
- package/dist/ui/NativeImagePreview/web/viewer.d.ts +104 -0
- package/dist/ui/NativeImagePreview/web/viewer.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/web/viewer.js +894 -0
- package/expo-module.config.json +11 -0
- package/ios/NativeImagePreview/NativeImagePreviewImageLoader.swift +73 -0
- package/ios/NativeImagePreview/NativeImagePreviewImageViewInteraction.swift +245 -0
- package/ios/NativeImagePreview/NativeImagePreviewLayoutSupport.swift +143 -0
- package/ios/NativeImagePreview/NativeImagePreviewMedia.swift +28 -0
- package/ios/NativeImagePreview/NativeImagePreviewSourceViewRegistry.swift +80 -0
- package/ios/NativeImagePreview/NativeImagePreviewVideoPlayerView.swift +437 -0
- package/ios/NativeImagePreview/NativeImagePreviewViewerConfiguration.swift +51 -0
- package/ios/NativeImagePreview/NativeImagePreviewViewerController.swift +745 -0
- package/ios/NativeImagePreview/NativeImagePreviewViewerPageView.swift +623 -0
- package/ios/NativeImagePreview/NativeImagePreviewViewerTransitionCoordinator.swift +312 -0
- package/ios/NativeImagePreviewModule.swift +46 -0
- package/ios/NativeImagePreviewView.swift +252 -0
- package/package.json +16 -2
- package/src/all.ts +18 -0
- package/src/services/ImagePreviewService/README.md +19 -0
- package/src/types.d.ts +1 -0
- package/src/ui/NativeImagePreview/NativeImagePreview.tsx +146 -0
- package/src/ui/NativeImagePreview/NativeImagePreview.web.tsx +201 -0
- package/src/ui/NativeImagePreview/README.md +111 -0
- package/src/ui/NativeImagePreview/context.tsx +22 -0
- package/src/ui/NativeImagePreview/index.tsx +18 -0
- package/src/ui/NativeImagePreview/types.ts +94 -0
- package/src/ui/NativeImagePreview/utils.ts +108 -0
- package/src/ui/NativeImagePreview/web/geometry.ts +134 -0
- package/src/ui/NativeImagePreview/web/media.ts +91 -0
- package/src/ui/NativeImagePreview/web/styles.css +137 -0
- package/src/ui/NativeImagePreview/web/transition.ts +111 -0
- package/src/ui/NativeImagePreview/web/types.ts +88 -0
- package/src/ui/NativeImagePreview/web/viewer.ts +1024 -0
- package/zkit-ui.podspec +13 -1
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
`ImagePreview` 是全屏图片预览基础件,支持 iOS / Android / Web 的统一声明式使用,也提供 `imagePreview.open()` 作为全局命令式入口。
|
|
4
4
|
|
|
5
|
+
如果需要从真实缩略图原生共享转场到全屏 viewer,请使用 `NativeImagePreview`。`ImagePreview` 适合普通命令式预览,`NativeImagePreview` 适合相册九宫格、瀑布流和商品图集这类有稳定图片锚点的场景。
|
|
6
|
+
|
|
5
7
|
## 设计取舍
|
|
6
8
|
|
|
7
9
|
- 核心是声明式组件:`open/defaultOpen` 控制显隐,`value/defaultValue/onChange` 控制当前图片索引。
|
|
@@ -60,6 +62,23 @@ console.log(result.reason, result.value);
|
|
|
60
62
|
- `renderHeader/renderFooter/renderOverlay`:自定义顶部、底部和覆盖层内容。
|
|
61
63
|
- `onClose`:关闭动画结束后触发,返回最终索引、图片和关闭原因。
|
|
62
64
|
|
|
65
|
+
## NativeImagePreview 关系
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { NativeImagePreview } from 'zkit-ui/native-image-preview';
|
|
69
|
+
|
|
70
|
+
<NativeImagePreview items={items} colorScheme="dark">
|
|
71
|
+
<NativeImagePreview.Item index={0}>
|
|
72
|
+
<Image source={thumbnailSource} />
|
|
73
|
+
</NativeImagePreview.Item>
|
|
74
|
+
</NativeImagePreview>;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
两者不要混用成同一个本地封装:
|
|
78
|
+
|
|
79
|
+
- `ImagePreview`:普通全屏预览,支持声明式 `open/value` 和命令式 `imagePreview.open()`。
|
|
80
|
+
- `NativeImagePreview`:共享转场预览,需要包住真实缩略图节点,iOS / Android 走原生 viewer,Web 走 H5 viewer。
|
|
81
|
+
|
|
63
82
|
## 手动确认点
|
|
64
83
|
|
|
65
84
|
- Android:Modal 返回键、下滑关闭后的状态回收、超长图/大图内存表现。
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { Platform, View, useColorScheme } from 'react-native';
|
|
4
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
5
|
+
import {
|
|
6
|
+
controlEdgeToEdgeValues,
|
|
7
|
+
isEdgeToEdge,
|
|
8
|
+
} from 'react-native-is-edge-to-edge';
|
|
9
|
+
import { NativeImagePreviewContext } from './context';
|
|
10
|
+
import type {
|
|
11
|
+
NativeImagePreviewChangeEvent,
|
|
12
|
+
NativeImagePreviewComponent,
|
|
13
|
+
NativeImagePreviewItemProps,
|
|
14
|
+
NativeImagePreviewNativeItem,
|
|
15
|
+
NativeImagePreviewProps,
|
|
16
|
+
} from './types';
|
|
17
|
+
import {
|
|
18
|
+
clampPreviewIndex,
|
|
19
|
+
normalizeNativeImagePreviewItems,
|
|
20
|
+
resolveNativeImagePreviewColorScheme,
|
|
21
|
+
toNativePreviewItems,
|
|
22
|
+
} from './utils';
|
|
23
|
+
|
|
24
|
+
type NativePreviewViewProps = {
|
|
25
|
+
children: React.ReactElement;
|
|
26
|
+
index: number;
|
|
27
|
+
style?: StyleProp<ViewStyle>;
|
|
28
|
+
previewGroupId: string;
|
|
29
|
+
items: readonly NativeImagePreviewNativeItem[];
|
|
30
|
+
theme: 'dark' | 'light';
|
|
31
|
+
edgeToEdge: boolean;
|
|
32
|
+
closeIconName?: string;
|
|
33
|
+
onIndexChange?: (event: NativeImagePreviewChangeEvent) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const NativePreviewView = requireNativeView<NativePreviewViewProps>(
|
|
37
|
+
'ZKitNativeImagePreview'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const ANDROID_EDGE_TO_EDGE = Platform.OS === 'android' ? isEdgeToEdge() : false;
|
|
41
|
+
|
|
42
|
+
function createPreviewGroupId() {
|
|
43
|
+
return `zkit-native-image-preview-${Date.now().toString(36)}-${Math.random()
|
|
44
|
+
.toString(36)
|
|
45
|
+
.slice(2, 10)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NativeImagePreviewRoot = React.memo(function NativeImagePreview({
|
|
49
|
+
children,
|
|
50
|
+
colorScheme = 'dark',
|
|
51
|
+
disabled = false,
|
|
52
|
+
edgeToEdge = false,
|
|
53
|
+
items,
|
|
54
|
+
nativeProps,
|
|
55
|
+
onChange,
|
|
56
|
+
}: NativeImagePreviewProps) {
|
|
57
|
+
const systemColorScheme = useColorScheme();
|
|
58
|
+
const previewGroupIdRef = React.useRef<string | null>(null);
|
|
59
|
+
if (previewGroupIdRef.current == null) {
|
|
60
|
+
previewGroupIdRef.current = createPreviewGroupId();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolvedItems = React.useMemo(
|
|
64
|
+
() => normalizeNativeImagePreviewItems(items),
|
|
65
|
+
[items]
|
|
66
|
+
);
|
|
67
|
+
const nativeItems = React.useMemo(
|
|
68
|
+
() => toNativePreviewItems(resolvedItems),
|
|
69
|
+
[resolvedItems]
|
|
70
|
+
);
|
|
71
|
+
const resolvedColorScheme = resolveNativeImagePreviewColorScheme(colorScheme, systemColorScheme);
|
|
72
|
+
const contextValue = React.useMemo(
|
|
73
|
+
() => ({
|
|
74
|
+
previewGroupId: previewGroupIdRef.current!,
|
|
75
|
+
items: resolvedItems,
|
|
76
|
+
nativeItems,
|
|
77
|
+
colorScheme: resolvedColorScheme,
|
|
78
|
+
disabled,
|
|
79
|
+
edgeToEdge,
|
|
80
|
+
nativeProps,
|
|
81
|
+
onChange,
|
|
82
|
+
}),
|
|
83
|
+
[disabled, edgeToEdge, nativeItems, nativeProps, onChange, resolvedColorScheme, resolvedItems]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<NativeImagePreviewContext.Provider value={contextValue}>
|
|
88
|
+
{children}
|
|
89
|
+
</NativeImagePreviewContext.Provider>
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const NativeImagePreviewItem = React.memo(function NativeImagePreviewItem({
|
|
94
|
+
children,
|
|
95
|
+
disabled = false,
|
|
96
|
+
index,
|
|
97
|
+
onChange,
|
|
98
|
+
style,
|
|
99
|
+
}: NativeImagePreviewItemProps) {
|
|
100
|
+
const context = React.useContext(NativeImagePreviewContext);
|
|
101
|
+
|
|
102
|
+
const handleIndexChange = React.useCallback(
|
|
103
|
+
(event: NativeImagePreviewChangeEvent) => {
|
|
104
|
+
if (!context) return;
|
|
105
|
+
|
|
106
|
+
const value = event.nativeEvent.currentIndex;
|
|
107
|
+
const meta = {
|
|
108
|
+
reason: 'swipe' as const,
|
|
109
|
+
item: context.items[value] ?? null,
|
|
110
|
+
nativeEvent: event,
|
|
111
|
+
};
|
|
112
|
+
context.onChange?.(value, meta);
|
|
113
|
+
onChange?.(value, meta);
|
|
114
|
+
},
|
|
115
|
+
[context, onChange]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (!context || context.disabled || disabled || context.nativeItems.length === 0) {
|
|
119
|
+
return <View style={style}>{children}</View>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const resolvedEdgeToEdge = ANDROID_EDGE_TO_EDGE || context.edgeToEdge;
|
|
123
|
+
|
|
124
|
+
if (__DEV__ && Platform.OS === 'android') {
|
|
125
|
+
controlEdgeToEdgeValues({ edgeToEdge: resolvedEdgeToEdge });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<NativePreviewView
|
|
130
|
+
closeIconName={context.nativeProps?.iosCloseIconName}
|
|
131
|
+
edgeToEdge={resolvedEdgeToEdge}
|
|
132
|
+
previewGroupId={context.previewGroupId}
|
|
133
|
+
index={clampPreviewIndex(index, context.nativeItems.length)}
|
|
134
|
+
items={context.nativeItems}
|
|
135
|
+
onIndexChange={handleIndexChange}
|
|
136
|
+
style={style}
|
|
137
|
+
theme={context.colorScheme}
|
|
138
|
+
>
|
|
139
|
+
{children}
|
|
140
|
+
</NativePreviewView>
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const NativeImagePreview = Object.assign(NativeImagePreviewRoot, {
|
|
145
|
+
Item: NativeImagePreviewItem,
|
|
146
|
+
}) as NativeImagePreviewComponent;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import './web/styles.css';
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { View, useColorScheme } from 'react-native';
|
|
7
|
+
import { clampPreviewIndex, normalizeNativeImagePreviewItems, resolveNativeImagePreviewColorScheme } from './utils';
|
|
8
|
+
import type {
|
|
9
|
+
NativeImagePreviewChangeMeta,
|
|
10
|
+
NativeImagePreviewComponent,
|
|
11
|
+
NativeImagePreviewItemProps,
|
|
12
|
+
NativeImagePreviewProps,
|
|
13
|
+
NativeImagePreviewResolvedItem,
|
|
14
|
+
} from './types';
|
|
15
|
+
import { createNativeImagePreviewWeb } from './web/viewer';
|
|
16
|
+
import type {
|
|
17
|
+
NativeImagePreviewWebChangeMeta,
|
|
18
|
+
NativeImagePreviewWebController,
|
|
19
|
+
NativeImagePreviewWebMediaItem,
|
|
20
|
+
} from './web/types';
|
|
21
|
+
|
|
22
|
+
type SourceElement = HTMLElement | null;
|
|
23
|
+
|
|
24
|
+
type NativeImagePreviewWebContextValue = {
|
|
25
|
+
disabled: boolean;
|
|
26
|
+
items: readonly NativeImagePreviewResolvedItem[];
|
|
27
|
+
open: (
|
|
28
|
+
index: number,
|
|
29
|
+
onItemChange?: (value: number, meta: NativeImagePreviewChangeMeta) => void
|
|
30
|
+
) => void;
|
|
31
|
+
sourceElementsRef: { current: SourceElement[] };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const NativeImagePreviewWebContext =
|
|
35
|
+
React.createContext<NativeImagePreviewWebContextValue | null>(null);
|
|
36
|
+
|
|
37
|
+
const WebView = View as unknown as React.ElementType;
|
|
38
|
+
|
|
39
|
+
function toWebPreviewItems(
|
|
40
|
+
items: readonly NativeImagePreviewResolvedItem[]
|
|
41
|
+
): NativeImagePreviewWebMediaItem[] {
|
|
42
|
+
return items.map(({ alt, height, id, poster, type, url, width }) => ({
|
|
43
|
+
alt: alt ?? id,
|
|
44
|
+
height,
|
|
45
|
+
id,
|
|
46
|
+
poster,
|
|
47
|
+
type,
|
|
48
|
+
url,
|
|
49
|
+
width,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findPreviewSourceElement(element: SourceElement): SourceElement {
|
|
54
|
+
if (!element) return null;
|
|
55
|
+
if (element instanceof HTMLImageElement || element instanceof HTMLVideoElement) {
|
|
56
|
+
return element;
|
|
57
|
+
}
|
|
58
|
+
return element.querySelector('img, video') ?? element;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toChangeMeta(
|
|
62
|
+
meta: NativeImagePreviewWebChangeMeta,
|
|
63
|
+
items: readonly NativeImagePreviewResolvedItem[]
|
|
64
|
+
): NativeImagePreviewChangeMeta {
|
|
65
|
+
return {
|
|
66
|
+
reason: meta.reason,
|
|
67
|
+
item: items[meta.index] ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const NativeImagePreviewRoot = React.memo(function NativeImagePreview({
|
|
72
|
+
children,
|
|
73
|
+
colorScheme = 'dark',
|
|
74
|
+
disabled = false,
|
|
75
|
+
items,
|
|
76
|
+
onChange,
|
|
77
|
+
}: NativeImagePreviewProps) {
|
|
78
|
+
const systemColorScheme = useColorScheme();
|
|
79
|
+
const controllerRef = React.useRef<NativeImagePreviewWebController | null>(null);
|
|
80
|
+
const sourceElementsRef = React.useRef<SourceElement[]>([]);
|
|
81
|
+
const resolvedItems = React.useMemo(
|
|
82
|
+
() => normalizeNativeImagePreviewItems(items),
|
|
83
|
+
[items]
|
|
84
|
+
);
|
|
85
|
+
const webItems = React.useMemo(
|
|
86
|
+
() => toWebPreviewItems(resolvedItems),
|
|
87
|
+
[resolvedItems]
|
|
88
|
+
);
|
|
89
|
+
const resolvedColorScheme = resolveNativeImagePreviewColorScheme(colorScheme, systemColorScheme);
|
|
90
|
+
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
const controller = createNativeImagePreviewWeb();
|
|
93
|
+
controllerRef.current = controller;
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
controller.destroy();
|
|
97
|
+
controllerRef.current = null;
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const open = React.useCallback<NativeImagePreviewWebContextValue['open']>(
|
|
102
|
+
(index, onItemChange) => {
|
|
103
|
+
const controller = controllerRef.current;
|
|
104
|
+
if (disabled || !controller || webItems.length === 0) return;
|
|
105
|
+
|
|
106
|
+
const value = clampPreviewIndex(index, webItems.length);
|
|
107
|
+
const getSourceElement = (nextIndex: number) =>
|
|
108
|
+
findPreviewSourceElement(sourceElementsRef.current[nextIndex] ?? null);
|
|
109
|
+
|
|
110
|
+
void controller.open({
|
|
111
|
+
getSourceElement,
|
|
112
|
+
index: value,
|
|
113
|
+
items: webItems,
|
|
114
|
+
objectFit: 'cover',
|
|
115
|
+
sourceElement: getSourceElement(value),
|
|
116
|
+
sourceImageSize: resolvedItems[value]
|
|
117
|
+
? {
|
|
118
|
+
width: resolvedItems[value].width ?? 0,
|
|
119
|
+
height: resolvedItems[value].height ?? 0,
|
|
120
|
+
}
|
|
121
|
+
: null,
|
|
122
|
+
sourceObjectFit: 'cover',
|
|
123
|
+
sourceVisibility: 'hidden',
|
|
124
|
+
theme: resolvedColorScheme,
|
|
125
|
+
onChange: (nextValue, meta) => {
|
|
126
|
+
const changeMeta = toChangeMeta(meta, resolvedItems);
|
|
127
|
+
onChange?.(nextValue, changeMeta);
|
|
128
|
+
onItemChange?.(nextValue, changeMeta);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
[disabled, onChange, resolvedColorScheme, resolvedItems, webItems]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const contextValue = React.useMemo<NativeImagePreviewWebContextValue>(
|
|
136
|
+
() => ({
|
|
137
|
+
disabled,
|
|
138
|
+
items: resolvedItems,
|
|
139
|
+
open,
|
|
140
|
+
sourceElementsRef,
|
|
141
|
+
}),
|
|
142
|
+
[disabled, open, resolvedItems]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<NativeImagePreviewWebContext.Provider value={contextValue}>
|
|
147
|
+
{children}
|
|
148
|
+
</NativeImagePreviewWebContext.Provider>
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const NativeImagePreviewItem = React.memo(function NativeImagePreviewItem({
|
|
153
|
+
children,
|
|
154
|
+
disabled = false,
|
|
155
|
+
index,
|
|
156
|
+
onChange,
|
|
157
|
+
style,
|
|
158
|
+
}: NativeImagePreviewItemProps) {
|
|
159
|
+
const context = React.useContext(NativeImagePreviewWebContext);
|
|
160
|
+
const sourceRef = React.useCallback(
|
|
161
|
+
(node: SourceElement) => {
|
|
162
|
+
if (!context) return;
|
|
163
|
+
context.sourceElementsRef.current[index] = node;
|
|
164
|
+
},
|
|
165
|
+
[context, index]
|
|
166
|
+
);
|
|
167
|
+
const itemDisabled = !context || context.disabled || disabled || context.items.length === 0;
|
|
168
|
+
|
|
169
|
+
const open = React.useCallback(() => {
|
|
170
|
+
if (itemDisabled) return;
|
|
171
|
+
context.open(index, onChange);
|
|
172
|
+
}, [context, index, itemDisabled, onChange]);
|
|
173
|
+
|
|
174
|
+
const handleKeyDown = React.useCallback(
|
|
175
|
+
(event: React.KeyboardEvent) => {
|
|
176
|
+
if (itemDisabled) return;
|
|
177
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
open();
|
|
180
|
+
},
|
|
181
|
+
[itemDisabled, open]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<WebView
|
|
186
|
+
ref={sourceRef}
|
|
187
|
+
aria-label={itemDisabled ? undefined : 'Open image preview'}
|
|
188
|
+
onClick={itemDisabled ? undefined : open}
|
|
189
|
+
onKeyDown={itemDisabled ? undefined : handleKeyDown}
|
|
190
|
+
role={itemDisabled ? undefined : 'button'}
|
|
191
|
+
style={style}
|
|
192
|
+
tabIndex={itemDisabled ? undefined : 0}
|
|
193
|
+
>
|
|
194
|
+
{children}
|
|
195
|
+
</WebView>
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export const NativeImagePreview = Object.assign(NativeImagePreviewRoot, {
|
|
200
|
+
Item: NativeImagePreviewItem,
|
|
201
|
+
}) as NativeImagePreviewComponent;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# NativeImagePreview
|
|
2
|
+
|
|
3
|
+
`NativeImagePreview` 是强调真实缩略图锚点与共享转场体验的图片预览组件。它和普通 `ImagePreview` 服务分工不同:`ImagePreview` 适合命令式打开图片;`NativeImagePreview` 适合从真实缩略图自然过渡到全屏 viewer,并在 iOS / Android 上走原生手势、缩放、滑动和拖拽关闭路径,在 Web 上走 H5 共享转场 viewer。
|
|
4
|
+
|
|
5
|
+
## 适用场景
|
|
6
|
+
|
|
7
|
+
- 相册九宫格、瀑布流、商品图集这类有真实缩略图锚点的页面。
|
|
8
|
+
- 需要从缩略图拾取到全屏 viewer,再从 viewer 顺滑回到原缩略图的位置。
|
|
9
|
+
- 需要原生 pinch / pan / swipe / drag-to-close 手势链路,而不是普通 Modal 图片预览。
|
|
10
|
+
|
|
11
|
+
如果只是从富文本、消息流或按钮命令式打开图片,用 `ImagePreview` 或 `imagePreview.open()` 更合适。
|
|
12
|
+
|
|
13
|
+
## 基础用法
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { Image, type ImageSourcePropType } from 'react-native';
|
|
17
|
+
import { NativeImagePreview, type NativeImagePreviewItemDescriptor } from 'zkit-ui/native-image-preview';
|
|
18
|
+
|
|
19
|
+
const items: NativeImagePreviewItemDescriptor[] = [
|
|
20
|
+
{ id: 'photo-a', source: require('./photo-a.png') },
|
|
21
|
+
{ id: 'photo-b', source: require('./photo-b.png'), width: 1200, height: 900 },
|
|
22
|
+
{ id: 'clip', type: 'video', url: videoUrl, poster: require('./poster.png') },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function getThumbnailSource(item: NativeImagePreviewItemDescriptor): ImageSourcePropType | undefined {
|
|
26
|
+
const source = item.poster ?? item.source;
|
|
27
|
+
return typeof source === 'string' ? { uri: source } : source;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Demo() {
|
|
31
|
+
return (
|
|
32
|
+
<NativeImagePreview items={items} colorScheme="dark">
|
|
33
|
+
{items.map((item, index) => {
|
|
34
|
+
const thumbnailSource = getThumbnailSource(item);
|
|
35
|
+
if (!thumbnailSource) return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<NativeImagePreview.Item index={index} key={item.id ?? index}>
|
|
39
|
+
<Image source={thumbnailSource} />
|
|
40
|
+
</NativeImagePreview.Item>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</NativeImagePreview>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`NativeImagePreview.Item` 必须包住真实图片节点,原生层会从内部找到 `UIImageView` / `ImageView` 作为转场锚点。不要用普通 `View` 替代缩略图,否则共享转场会退化。
|
|
49
|
+
|
|
50
|
+
## 推荐数据模型
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
type NativeImagePreviewItem =
|
|
54
|
+
| string
|
|
55
|
+
| ImageSourcePropType
|
|
56
|
+
| {
|
|
57
|
+
id?: string;
|
|
58
|
+
type?: 'image' | 'video';
|
|
59
|
+
source?: string | ImageSourcePropType;
|
|
60
|
+
url?: string;
|
|
61
|
+
poster?: string | ImageSourcePropType;
|
|
62
|
+
width?: number;
|
|
63
|
+
height?: number;
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- 图片可以只传 `source`,也可以用字符串 URL。
|
|
68
|
+
- 视频必须传 `type: 'video'` 和 `url`,`poster` 用于缩略图和过渡封面。
|
|
69
|
+
- `id` 用于稳定媒体身份;不传时会回退为索引字符串。
|
|
70
|
+
- `width / height` 是媒体元信息,帮助 viewer 更稳定地计算布局。
|
|
71
|
+
|
|
72
|
+
## Props
|
|
73
|
+
|
|
74
|
+
### NativeImagePreview
|
|
75
|
+
|
|
76
|
+
- `items`:图片或媒体集合,顺序必须和 `NativeImagePreview.Item index` 对齐。
|
|
77
|
+
- `colorScheme`:viewer 明暗外观,支持 `dark / light / system`,默认 `dark`。
|
|
78
|
+
- `disabled`:禁用所有锚点预览。
|
|
79
|
+
- `edgeToEdge`:Android edge-to-edge 预览,默认关闭;系统已处于 edge-to-edge 时会自动启用。
|
|
80
|
+
- `nativeProps.iosCloseIconName`:iOS 关闭按钮 SF Symbol 名称。
|
|
81
|
+
- `onChange(value, meta)`:viewer 滑动到新索引时触发。
|
|
82
|
+
|
|
83
|
+
### NativeImagePreview.Item
|
|
84
|
+
|
|
85
|
+
- `index`:当前缩略图在 `items` 中的索引。
|
|
86
|
+
- `disabled`:禁用当前锚点。
|
|
87
|
+
- `style`:锚点容器样式,常用 `StyleSheet.absoluteFill` 覆盖图片卡片。
|
|
88
|
+
- `onChange(value, meta)`:仅当前锚点打开的 viewer 发生索引变化时触发。
|
|
89
|
+
|
|
90
|
+
## 与 ImagePreview 的区别
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import { imagePreview } from 'zkit-ui/image-preview';
|
|
94
|
+
|
|
95
|
+
imagePreview.open({ images, index: 0 });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`ImagePreview` 是普通全屏预览服务,适合没有稳定缩略图锚点的命令式场景。`NativeImagePreview` 是声明式共享转场组件,必须就地包住真实图片节点。两者不要混成同一个封装,否则很容易把共享转场退化成普通弹层。
|
|
99
|
+
|
|
100
|
+
## 平台行为
|
|
101
|
+
|
|
102
|
+
- iOS / Android:使用原生 viewer 和真实图片锚点,主打共享转场、缩放、滑动和拖拽关闭。
|
|
103
|
+
- Web:使用 H5 viewer 和真实 DOM 图片锚点,支持共享转场、缩放、滑动、视频播放和拖拽关闭。
|
|
104
|
+
- 首次接入或变更原生依赖后需要重新构建 native app;仅刷新 JS bundle 不会注册新的原生 ViewManager。
|
|
105
|
+
- 原生模块未注册时不会降级到 `ImagePreview`。这是 native 接入或构建错误,需要重新安装依赖并重建 native app。
|
|
106
|
+
|
|
107
|
+
## 手动确认点
|
|
108
|
+
|
|
109
|
+
- iOS:缩略图拾取和回落位置、快速左右滑、视频封面、横竖屏、安全区、关闭按钮。
|
|
110
|
+
- Android:edge-to-edge、返回键、快速滑动、列表复用、视频页、普通机型掉帧情况。
|
|
111
|
+
- 列表场景:确保 `items` 顺序、`Item index` 和缩略图渲染顺序稳定一致。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
NativeImagePreviewChangeMeta,
|
|
4
|
+
NativeImagePreviewNativeItem,
|
|
5
|
+
NativeImagePreviewNativeProps,
|
|
6
|
+
NativeImagePreviewResolvedItem,
|
|
7
|
+
NativeImagePreviewResolvedColorScheme,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
export type NativeImagePreviewContextValue = {
|
|
11
|
+
previewGroupId: string;
|
|
12
|
+
items: readonly NativeImagePreviewResolvedItem[];
|
|
13
|
+
nativeItems: readonly NativeImagePreviewNativeItem[];
|
|
14
|
+
colorScheme: NativeImagePreviewResolvedColorScheme;
|
|
15
|
+
disabled: boolean;
|
|
16
|
+
edgeToEdge: boolean;
|
|
17
|
+
nativeProps?: NativeImagePreviewNativeProps;
|
|
18
|
+
onChange?: (value: number, meta: NativeImagePreviewChangeMeta) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const NativeImagePreviewContext =
|
|
22
|
+
React.createContext<NativeImagePreviewContextValue | null>(null);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { NativeImagePreview } from './NativeImagePreview';
|
|
2
|
+
export type {
|
|
3
|
+
NativeImagePreviewChangeEvent,
|
|
4
|
+
NativeImagePreviewChangeEventPayload,
|
|
5
|
+
NativeImagePreviewChangeMeta,
|
|
6
|
+
NativeImagePreviewChangeReason,
|
|
7
|
+
NativeImagePreviewColorScheme,
|
|
8
|
+
NativeImagePreviewComponent,
|
|
9
|
+
NativeImagePreviewItem,
|
|
10
|
+
NativeImagePreviewItemDescriptor,
|
|
11
|
+
NativeImagePreviewItemProps,
|
|
12
|
+
NativeImagePreviewMediaType,
|
|
13
|
+
NativeImagePreviewNativeProps,
|
|
14
|
+
NativeImagePreviewProps,
|
|
15
|
+
NativeImagePreviewResolvedItem,
|
|
16
|
+
NativeImagePreviewResolvedColorScheme,
|
|
17
|
+
NativeImagePreviewSource,
|
|
18
|
+
} from './types';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
ImageSourcePropType,
|
|
4
|
+
NativeSyntheticEvent,
|
|
5
|
+
StyleProp,
|
|
6
|
+
ViewStyle,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
|
|
9
|
+
export type NativeImagePreviewResolvedColorScheme = 'dark' | 'light';
|
|
10
|
+
export type NativeImagePreviewColorScheme =
|
|
11
|
+
| NativeImagePreviewResolvedColorScheme
|
|
12
|
+
| 'system';
|
|
13
|
+
export type NativeImagePreviewMediaType = 'image' | 'video';
|
|
14
|
+
export type NativeImagePreviewSource = ImageSourcePropType | string;
|
|
15
|
+
|
|
16
|
+
export type NativeImagePreviewItemDescriptor = {
|
|
17
|
+
id?: string;
|
|
18
|
+
alt?: string;
|
|
19
|
+
type?: NativeImagePreviewMediaType;
|
|
20
|
+
url?: string;
|
|
21
|
+
source?: NativeImagePreviewSource;
|
|
22
|
+
poster?: NativeImagePreviewSource;
|
|
23
|
+
width?: number;
|
|
24
|
+
height?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type NativeImagePreviewItem =
|
|
28
|
+
| NativeImagePreviewSource
|
|
29
|
+
| NativeImagePreviewItemDescriptor;
|
|
30
|
+
|
|
31
|
+
export type NativeImagePreviewResolvedItem = {
|
|
32
|
+
id: string;
|
|
33
|
+
type: NativeImagePreviewMediaType;
|
|
34
|
+
url: string;
|
|
35
|
+
poster?: string;
|
|
36
|
+
width?: number;
|
|
37
|
+
height?: number;
|
|
38
|
+
alt?: string;
|
|
39
|
+
raw: NativeImagePreviewItem;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type NativeImagePreviewNativeItem = {
|
|
43
|
+
id: string;
|
|
44
|
+
type: NativeImagePreviewMediaType;
|
|
45
|
+
url: string;
|
|
46
|
+
poster?: string;
|
|
47
|
+
width?: number;
|
|
48
|
+
height?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type NativeImagePreviewChangeReason = 'api' | 'swipe';
|
|
52
|
+
|
|
53
|
+
export type NativeImagePreviewChangeEventPayload = {
|
|
54
|
+
currentIndex: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type NativeImagePreviewChangeEvent =
|
|
58
|
+
NativeSyntheticEvent<NativeImagePreviewChangeEventPayload>;
|
|
59
|
+
|
|
60
|
+
export type NativeImagePreviewChangeMeta = {
|
|
61
|
+
reason: NativeImagePreviewChangeReason;
|
|
62
|
+
item: NativeImagePreviewResolvedItem | null;
|
|
63
|
+
nativeEvent?: NativeImagePreviewChangeEvent;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type NativeImagePreviewNativeProps = {
|
|
67
|
+
iosCloseIconName?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type NativeImagePreviewProps = {
|
|
71
|
+
items: readonly NativeImagePreviewItem[];
|
|
72
|
+
children: React.ReactNode;
|
|
73
|
+
colorScheme?: NativeImagePreviewColorScheme;
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
edgeToEdge?: boolean;
|
|
76
|
+
nativeProps?: NativeImagePreviewNativeProps;
|
|
77
|
+
onChange?: (value: number, meta: NativeImagePreviewChangeMeta) => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type NativeImagePreviewItemProps = {
|
|
81
|
+
index: number;
|
|
82
|
+
children: React.ReactElement;
|
|
83
|
+
disabled?: boolean;
|
|
84
|
+
style?: StyleProp<ViewStyle>;
|
|
85
|
+
onChange?: (value: number, meta: NativeImagePreviewChangeMeta) => void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type NativeImagePreviewComponent = React.MemoExoticComponent<
|
|
89
|
+
(props: NativeImagePreviewProps) => React.ReactElement
|
|
90
|
+
> & {
|
|
91
|
+
Item: React.MemoExoticComponent<
|
|
92
|
+
(props: NativeImagePreviewItemProps) => React.ReactElement
|
|
93
|
+
>;
|
|
94
|
+
};
|