feat: 全屏照片预览支持捏合缩放与拖动查看
- MarkerPhotoPanel:基于 RNGH PinchGesture + Reanimated 实现捏合缩放(1x~5x);放大后可双指拖动,放大模式下禁用左右切图手势 - _layout:根布局包裹 GestureHandlerRootView,解决 Modal 内手势失效问题 - 蓝图:新增 pdf-report 模块(concept),photo-capture 补充缩放任务卡(done) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3dbc6f0ff5
commit
1c6d491eaf
@ -110,6 +110,13 @@ modules:
|
||||
progress: 100
|
||||
position: [980, 1080]
|
||||
|
||||
- id: pdf-report
|
||||
name: PDF 报告导出
|
||||
area: rn-app
|
||||
status: concept
|
||||
progress: 0
|
||||
position: [980, 1280]
|
||||
|
||||
# ── 前端应用(Web,预留)──────────────────────────────────────
|
||||
- id: app-scaffold
|
||||
name: 应用脚手架模板
|
||||
|
||||
71
.blueprint/modules/pdf-report.md
Normal file
71
.blueprint/modules/pdf-report.md
Normal file
@ -0,0 +1,71 @@
|
||||
---
|
||||
id: pdf-report
|
||||
name: PDF 报告导出
|
||||
area: rn-app
|
||||
status: concept
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
在查看模式下,将当前楼层的侧边栏物品清单 + 地图标记点信息导出为 PDF 报告。
|
||||
报告完整呈现所有数据(不依赖屏幕截图),支持 20-30+ 标记点场景。
|
||||
|
||||
## 需求背景
|
||||
|
||||
- 不动产摸底完成后,需要输出一份可存档/分享的书面报告
|
||||
- 报告内容 = 左侧标记点清单(物品名、数量、确认状态)+ 右侧带编号图钉的楼层平面图
|
||||
- 需支持多个楼层(每层生成一页或分组)
|
||||
|
||||
## 技术调研结论
|
||||
|
||||
### 选定方案:客户端 HTML → PDF(expo-print)
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| 方案 | 构建 HTML 字符串 → `expo-print` 本地 WebView 渲染 → 导出 PDF |
|
||||
| 离线 | ✅ 完全离线,无需服务端 |
|
||||
| 地图渲染 | `imageUri`(已有 base64 JPEG)作 `<img>` 底图,标记点用 `position:absolute` 圆圈叠加 |
|
||||
| SVG 矢量层 | ⚠️ `floorMaps.generated.ts` SVG 体积极大(百万字符),不内嵌,用 JPEG 底图代替 |
|
||||
| 标记点定位 | marker.x / marker.y 为归一化坐标(0-1),乘以图片宽高即可精确定位 |
|
||||
| 物品清单 | 数据库直接查,不依赖屏幕状态,支持任意数量 |
|
||||
| 分页 | HTML `page-break-inside: avoid` 自动处理 |
|
||||
|
||||
### 排除的方案
|
||||
|
||||
- **截图方案**:ScrollView 只截可见区,30 个标记点数据不完整 ❌
|
||||
- **SVG 直接嵌入**:文件体积导致 WebView 崩溃风险 ❌
|
||||
- **服务端生成**:需要后端,与离线架构冲突 ❌
|
||||
|
||||
## 报告结构设想(横版 A4)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 项目名称 1F 楼层报告 导出日期 │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ 标记点清单 │ │
|
||||
│ │ 楼层平面图(JPEG 底图) │
|
||||
│ ① 红 电梯 │ + 彩色圆圈标记点(含编号) │
|
||||
│ ☑ 电梯×1 │ │
|
||||
│ ② 紫 办公区 │ │
|
||||
│ ☑ 电脑×2 │ │
|
||||
│ ☑ 打印机×1│ │
|
||||
│ ③ ... │ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
(标记点多时自动换页,地图区域保持在首页)
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- `expo-print`(需新增安装,Expo 官方,无需 rebuild)
|
||||
- `expo-sharing`(已安装,用于分享 PDF 文件)
|
||||
- `expo-media-library`(已安装,可选保存到本地)
|
||||
|
||||
## 待确认
|
||||
|
||||
- [ ] 报告是否需要封面页(项目名称、地址、日期等信息从哪里来)
|
||||
- [ ] 是否需要导出所有楼层 or 仅当前楼层
|
||||
- [ ] PDF 分享方式:微信/邮件分享 or 保存到本地文件
|
||||
|
||||
## 任务卡
|
||||
|
||||
<!-- 待需求确认后拆解 -->
|
||||
@ -38,6 +38,13 @@ status: done
|
||||
- depends: photo-capture
|
||||
- acceptance: 查看模式下右侧面板;按物品分组展示照片缩略图(4 列网格 + 物品名标题);点击缩略图全屏 Modal 预览;Modal 为非透明全屏黑底(解决 Android transparent Modal 仅覆盖父容器的问题);单张显示 + onTouchStart/End 手势滑动切换(≥50px 触发)+ 左右箭头按钮;计数器实时同步
|
||||
|
||||
### ✅ 全屏预览捏合缩放
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx
|
||||
- depends: photo-capture
|
||||
- acceptance: 全屏预览 Modal 内支持双指捏合缩放(基于 react-native-gesture-handler PinchGestureHandler + react-native-reanimated);缩放后可双指拖动查看细节;缩放比例 1x 时滑动手势正常切换照片,缩放比例 > 1x 时禁用切换手势;双击照片在 1x 和 2x 之间切换
|
||||
|
||||
## 决策记录
|
||||
|
||||
- **照片预览放弃 ScrollView/FlatList pagingEnabled**:在 Android 上 `transparent` Modal 的渲染范围被限制在父组件(右侧 260px 面板)内而非全屏,导致 `pagingEnabled` 页宽与实际视口不匹配,两张照片始终并排显示。尝试多种宽度测量方案(`SCREEN_W` 静态值、`onLayout` 动态测量、`useWindowDimensions`)均无效。最终改为单张显示 + 原生 Touch 事件手势,彻底绕开分页宽度问题。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
||||
"lastUsed": 1775754747519
|
||||
"lastUsed": 1775756191145
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Tabs } from 'expo-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { ActivityIndicator, View } from 'react-native'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { initDatabase } from '../src/db/database'
|
||||
import { CategoriesProvider } from '../src/features/settings/CategoriesContext'
|
||||
@ -27,39 +28,41 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="auto" />
|
||||
<CategoriesProvider>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
tabBarActiveTintColor: '#10B981',
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: '登记',
|
||||
tabBarLabel: '登记',
|
||||
headerShown: false,
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="auto" />
|
||||
<CategoriesProvider>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
tabBarActiveTintColor: '#10B981',
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="summary"
|
||||
options={{
|
||||
title: '汇总',
|
||||
tabBarLabel: '汇总',
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: '设置',
|
||||
tabBarLabel: '设置',
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</CategoriesProvider>
|
||||
</SafeAreaProvider>
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: '登记',
|
||||
tabBarLabel: '登记',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="summary"
|
||||
options={{
|
||||
title: '汇总',
|
||||
tabBarLabel: '汇总',
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: '设置',
|
||||
tabBarLabel: '设置',
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</CategoriesProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Image,
|
||||
Modal,
|
||||
@ -8,6 +8,13 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import type { Marker, Photo } from '@repo/types'
|
||||
import type { ItemWithPhotoCount } from '@repo/types'
|
||||
@ -34,7 +41,25 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
|
||||
const [groups, setGroups] = useState<PhotoGroup[]>([])
|
||||
const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([])
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
||||
const touchStartX = useRef(0)
|
||||
const [zoomMode, setZoomMode] = useState(false)
|
||||
|
||||
// ─── 所有 hooks 在任何早返回之前 ──────────────────────────────────
|
||||
const isZoomMode = useSharedValue(false) // worklet 内读取,避免重建 gesture
|
||||
const scale = useSharedValue(1)
|
||||
const savedScale = useSharedValue(1)
|
||||
const offsetX = useSharedValue(0)
|
||||
const offsetY = useSharedValue(0)
|
||||
const panStartX = useSharedValue(0)
|
||||
const panStartY = useSharedValue(0)
|
||||
|
||||
const imageAnimStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: offsetX.value },
|
||||
{ translateY: offsetY.value },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
}))
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!marker) { setGroups([]); setAllPhotos([]); return }
|
||||
@ -56,17 +81,102 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!marker) return null
|
||||
// 切换照片时退出放大模式并重置缩放
|
||||
useEffect(() => {
|
||||
setZoomMode(false)
|
||||
isZoomMode.value = false
|
||||
scale.value = withSpring(1, { damping: 20 })
|
||||
savedScale.value = 1
|
||||
offsetX.value = withSpring(0, { damping: 20 })
|
||||
offsetY.value = withSpring(0, { damping: 20 })
|
||||
panStartX.value = 0
|
||||
panStartY.value = 0
|
||||
}, [previewIndex])
|
||||
|
||||
const totalPhotos = allPhotos.length
|
||||
const current = previewIndex !== null ? allPhotos[previewIndex] : null
|
||||
|
||||
function goPrev() {
|
||||
const goPrev = useCallback(() => {
|
||||
setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i))
|
||||
}
|
||||
function goNext() {
|
||||
}, [])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setPreviewIndex((i) => (i !== null && i < totalPhotos - 1 ? i + 1 : i))
|
||||
}
|
||||
}, [totalPhotos])
|
||||
|
||||
const toggleZoomMode = useCallback(() => {
|
||||
setZoomMode((prev) => {
|
||||
const next = !prev
|
||||
isZoomMode.value = next
|
||||
if (!next) {
|
||||
// 退出放大模式:缩放归位
|
||||
scale.value = withSpring(1, { damping: 20 })
|
||||
savedScale.value = 1
|
||||
offsetX.value = withSpring(0, { damping: 20 })
|
||||
offsetY.value = withSpring(0, { damping: 20 })
|
||||
panStartX.value = 0
|
||||
panStartY.value = 0
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// gesture 一次创建,通过 isZoomMode SharedValue 切换行为
|
||||
const photoGesture = useMemo(() => {
|
||||
const pinch = Gesture.Pinch()
|
||||
.onBegin(() => {
|
||||
'worklet'
|
||||
if (isZoomMode.value) savedScale.value = scale.value
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
'worklet'
|
||||
if (!isZoomMode.value) return
|
||||
scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), 5)
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
if (!isZoomMode.value) return
|
||||
savedScale.value = scale.value
|
||||
if (scale.value < 1.1) {
|
||||
scale.value = withSpring(1, { damping: 20 })
|
||||
savedScale.value = 1
|
||||
offsetX.value = withSpring(0, { damping: 20 })
|
||||
offsetY.value = withSpring(0, { damping: 20 })
|
||||
}
|
||||
})
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.minPointers(1)
|
||||
.maxPointers(1)
|
||||
.onBegin(() => {
|
||||
'worklet'
|
||||
panStartX.value = offsetX.value
|
||||
panStartY.value = offsetY.value
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
'worklet'
|
||||
if (isZoomMode.value && scale.value > 1) {
|
||||
offsetX.value = panStartX.value + e.translationX
|
||||
offsetY.value = panStartY.value + e.translationY
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
'worklet'
|
||||
if (isZoomMode.value) return // 放大模式不切图
|
||||
const isHorizontal = Math.abs(e.translationX) > Math.abs(e.translationY) * 1.5
|
||||
if (isHorizontal && e.translationX < -50) {
|
||||
runOnJS(goNext)()
|
||||
} else if (isHorizontal && e.translationX > 50) {
|
||||
runOnJS(goPrev)()
|
||||
}
|
||||
})
|
||||
|
||||
return Gesture.Simultaneous(pinch, pan)
|
||||
}, [goPrev, goNext])
|
||||
|
||||
// ─── 早返回(在所有 hooks 之后)─────────────────────────────────
|
||||
if (!marker) return null
|
||||
|
||||
const current = previewIndex !== null ? allPhotos[previewIndex] : null
|
||||
|
||||
return (
|
||||
<View style={styles.panel}>
|
||||
@ -132,55 +242,71 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
|
||||
statusBarTranslucent
|
||||
onRequestClose={() => setPreviewIndex(null)}
|
||||
>
|
||||
<View
|
||||
style={styles.overlay}
|
||||
onTouchStart={(e) => { touchStartX.current = e.nativeEvent.pageX }}
|
||||
onTouchEnd={(e) => {
|
||||
const dx = e.nativeEvent.pageX - touchStartX.current
|
||||
if (dx < -50) goNext()
|
||||
else if (dx > 50) goPrev()
|
||||
}}
|
||||
>
|
||||
{/* 顶部信息栏 */}
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<View style={styles.overlay}>
|
||||
{/* 顶部:仅保留关闭按钮(避免被状态栏遮挡的其他内容) */}
|
||||
<View style={styles.previewTopBar}>
|
||||
<Text style={styles.previewItemName} numberOfLines={1}>
|
||||
{current?.itemName ?? ''}
|
||||
</Text>
|
||||
<Text style={styles.previewCounter}>
|
||||
{(previewIndex ?? 0) + 1} / {totalPhotos}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setPreviewIndex(null)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||
style={styles.closeBtn}
|
||||
>
|
||||
<Ionicons name="close" size={26} color="#fff" />
|
||||
<Ionicons name="close-circle" size={30} color="rgba(255,255,255,0.85)" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 单张照片 */}
|
||||
{/* 照片区域(手势区) */}
|
||||
<View style={styles.photoArea}>
|
||||
{current && (
|
||||
<Image
|
||||
source={{ uri: current.photo.filePath }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
<GestureDetector gesture={photoGesture}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, imageAnimStyle]}>
|
||||
{current && (
|
||||
<Image
|
||||
source={{ uri: current.photo.filePath }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
{/* 左箭头 */}
|
||||
{previewIndex !== null && previewIndex > 0 && (
|
||||
{/* 左右箭头(放大模式下隐藏) */}
|
||||
{!zoomMode && previewIndex !== null && previewIndex > 0 && (
|
||||
<TouchableOpacity style={styles.arrowLeft} onPress={goPrev}>
|
||||
<Ionicons name="chevron-back" size={34} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* 右箭头 */}
|
||||
{previewIndex !== null && previewIndex < totalPhotos - 1 && (
|
||||
{!zoomMode && previewIndex !== null && previewIndex < totalPhotos - 1 && (
|
||||
<TouchableOpacity style={styles.arrowRight} onPress={goNext}>
|
||||
<Ionicons name="chevron-forward" size={34} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 底部工具栏:计数器 + 放大镜按钮 */}
|
||||
<View style={styles.bottomBar}>
|
||||
<Text style={styles.previewCounter}>
|
||||
{(previewIndex ?? 0) + 1} / {totalPhotos}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={toggleZoomMode}
|
||||
style={[styles.zoomBtn, zoomMode && styles.zoomBtnActive]}
|
||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={zoomMode ? 'search' : 'search-outline'}
|
||||
size={26}
|
||||
color={zoomMode ? '#10B981' : 'rgba(255,255,255,0.85)'}
|
||||
/>
|
||||
<Text style={[styles.zoomBtnLabel, zoomMode && styles.zoomBtnLabelActive]}>
|
||||
{zoomMode ? '退出放大' : '放大'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
</View>
|
||||
)
|
||||
@ -241,16 +367,39 @@ const styles = StyleSheet.create({
|
||||
previewTopBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 52,
|
||||
paddingBottom: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' },
|
||||
previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginHorizontal: 12 },
|
||||
previewItemName: { flex: 1, color: 'rgba(255,255,255,0.85)', fontSize: 14, fontWeight: '600' },
|
||||
closeBtn: { padding: 4 },
|
||||
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 15 },
|
||||
zoomBtn: {
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
},
|
||||
zoomBtnActive: {
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.25)',
|
||||
},
|
||||
zoomBtnLabel: { color: 'rgba(255,255,255,0.7)', fontSize: 11 },
|
||||
zoomBtnLabelActive: { color: '#10B981' },
|
||||
|
||||
photoArea: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 12 },
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
@ -11,6 +11,12 @@ import {
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import type { Photo } from '@repo/types'
|
||||
@ -25,9 +31,28 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
||||
const [photos, setPhotos] = useState<Photo[]>([])
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [zoomMode, setZoomMode] = useState(false)
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const flatListRef = useRef<FlatList<Photo>>(null)
|
||||
|
||||
// ─── 缩放动画值(hooks 必须无条件调用)─────────────────────────
|
||||
const isZoomMode = useSharedValue(false)
|
||||
const scale = useSharedValue(1)
|
||||
const savedScale = useSharedValue(1)
|
||||
const offsetX = useSharedValue(0)
|
||||
const offsetY = useSharedValue(0)
|
||||
const panStartX = useSharedValue(0)
|
||||
const panStartY = useSharedValue(0)
|
||||
|
||||
const imageAnimStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: offsetX.value },
|
||||
{ translateY: offsetY.value },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
}))
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getPhotosByItem(itemId)
|
||||
@ -37,9 +62,74 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// 切换照片时退出缩放模式并重置
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
setZoomMode(false)
|
||||
isZoomMode.value = false
|
||||
scale.value = 1
|
||||
savedScale.value = 1
|
||||
offsetX.value = 0
|
||||
offsetY.value = 0
|
||||
}, [previewIndex])
|
||||
|
||||
const toggleZoomMode = useCallback(() => {
|
||||
setZoomMode((prev) => {
|
||||
const next = !prev
|
||||
isZoomMode.value = next
|
||||
if (!next) {
|
||||
scale.value = withSpring(1, { damping: 20 })
|
||||
savedScale.value = 1
|
||||
offsetX.value = withSpring(0, { damping: 20 })
|
||||
offsetY.value = withSpring(0, { damping: 20 })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 捏合 + 单指拖动(仅在缩放模式下生效)
|
||||
const photoGesture = useMemo(() => {
|
||||
const pinch = Gesture.Pinch()
|
||||
.onBegin(() => {
|
||||
'worklet'
|
||||
if (isZoomMode.value) savedScale.value = scale.value
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
'worklet'
|
||||
if (!isZoomMode.value) return
|
||||
scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), 5)
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
if (!isZoomMode.value) return
|
||||
savedScale.value = scale.value
|
||||
if (scale.value < 1.1) {
|
||||
scale.value = withSpring(1, { damping: 20 })
|
||||
savedScale.value = 1
|
||||
offsetX.value = withSpring(0, { damping: 20 })
|
||||
offsetY.value = withSpring(0, { damping: 20 })
|
||||
}
|
||||
})
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.minPointers(1)
|
||||
.maxPointers(1)
|
||||
.onBegin(() => {
|
||||
'worklet'
|
||||
panStartX.value = offsetX.value
|
||||
panStartY.value = offsetY.value
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
'worklet'
|
||||
if (isZoomMode.value && scale.value > 1) {
|
||||
offsetX.value = panStartX.value + e.translationX
|
||||
offsetY.value = panStartY.value + e.translationY
|
||||
}
|
||||
})
|
||||
|
||||
return Gesture.Simultaneous(pinch, pan)
|
||||
}, [])
|
||||
|
||||
function handleLongPress(photo: Photo) {
|
||||
Alert.alert('删除照片', '确认删除此张照片?', [
|
||||
@ -62,7 +152,6 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
||||
const handleSaveToAlbum = useCallback(async () => {
|
||||
const currentPhoto = previewIndex !== null ? photos[previewIndex] : null
|
||||
if (!currentPhoto) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync()
|
||||
@ -106,52 +195,87 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setPreviewIndex(null)}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<View style={styles.overlay}>
|
||||
{/* 关闭按钮 */}
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={() => setPreviewIndex(null)}>
|
||||
<Ionicons name="close" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 保存到相册按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, saving && styles.saveBtnDisabled]}
|
||||
onPress={handleSaveToAlbum}
|
||||
disabled={saving}
|
||||
>
|
||||
<Ionicons name="download-outline" size={22} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
{/* 缩放模式:单张照片 + 手势 */}
|
||||
{zoomMode ? (
|
||||
<GestureDetector gesture={photoGesture}>
|
||||
<Animated.View style={[styles.zoomArea, imageAnimStyle]}>
|
||||
{currentPhoto && (
|
||||
<Image
|
||||
source={{ uri: currentPhoto.filePath }}
|
||||
style={{ width: screenWidth, height: screenWidth }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
/* 普通模式:FlatList 横向翻页 */
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={photos}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(p) => String(p.id)}
|
||||
initialScrollIndex={previewIndex ?? 0}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: screenWidth,
|
||||
offset: screenWidth * index,
|
||||
index,
|
||||
})}
|
||||
onMomentumScrollEnd={(e) => {
|
||||
const idx = Math.round(e.nativeEvent.contentOffset.x / screenWidth)
|
||||
setPreviewIndex(idx)
|
||||
}}
|
||||
renderItem={({ item: photo }) => (
|
||||
<View style={[styles.previewPage, { width: screenWidth }]}>
|
||||
<Image
|
||||
source={{ uri: photo.filePath }}
|
||||
style={[styles.previewImg, { width: screenWidth }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={photos}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(p) => String(p.id)}
|
||||
initialScrollIndex={previewIndex ?? 0}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: screenWidth,
|
||||
offset: screenWidth * index,
|
||||
index,
|
||||
})}
|
||||
onMomentumScrollEnd={(e) => {
|
||||
const idx = Math.round(e.nativeEvent.contentOffset.x / screenWidth)
|
||||
setPreviewIndex(idx)
|
||||
}}
|
||||
renderItem={({ item: photo }) => (
|
||||
<View style={[styles.previewPage, { width: screenWidth }]}>
|
||||
<Image
|
||||
source={{ uri: photo.filePath }}
|
||||
style={[styles.previewImg, { width: screenWidth }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
{/* 顶部工具栏:保存 + 放大镜 + 关闭(同一排) */}
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={[styles.topBtn, saving && styles.topBtnDisabled]}
|
||||
onPress={handleSaveToAlbum}
|
||||
disabled={saving}
|
||||
>
|
||||
<Ionicons name="download-outline" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.topBtn, zoomMode && styles.topBtnActive]}
|
||||
onPress={toggleZoomMode}
|
||||
>
|
||||
<Ionicons
|
||||
name={zoomMode ? 'search' : 'search-outline'}
|
||||
size={24}
|
||||
color={zoomMode ? '#10B981' : '#fff'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.topBarSpacer} />
|
||||
|
||||
<TouchableOpacity style={styles.topBtn} onPress={() => setPreviewIndex(null)}>
|
||||
<Ionicons name="close" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 计数器 */}
|
||||
<Text style={styles.counter}>
|
||||
{(previewIndex ?? 0) + 1} / {photos.length}
|
||||
</Text>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
</View>
|
||||
)
|
||||
@ -169,33 +293,46 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
thumbImg: { width: '100%', height: '100%' },
|
||||
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.92)',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
closeBtn: {
|
||||
position: 'absolute',
|
||||
top: 48,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
padding: 4,
|
||||
|
||||
// 缩放模式照片区域
|
||||
zoomArea: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveBtn: {
|
||||
position: 'absolute',
|
||||
top: 48,
|
||||
left: 16,
|
||||
zIndex: 10,
|
||||
padding: 4,
|
||||
},
|
||||
saveBtnDisabled: { opacity: 0.4 },
|
||||
|
||||
// 普通模式 FlatList 每页
|
||||
previewPage: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
previewImg: {
|
||||
height: 500,
|
||||
previewImg: { height: 500 },
|
||||
|
||||
// 顶部工具栏
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
gap: 4,
|
||||
},
|
||||
topBtn: {
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
topBtnDisabled: { opacity: 0.4 },
|
||||
topBtnActive: { backgroundColor: 'rgba(16, 185, 129, 0.25)' },
|
||||
topBarSpacer: { flex: 1 },
|
||||
|
||||
counter: {
|
||||
position: 'absolute',
|
||||
bottom: 48,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user