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
|
progress: 100
|
||||||
position: [980, 1080]
|
position: [980, 1080]
|
||||||
|
|
||||||
|
- id: pdf-report
|
||||||
|
name: PDF 报告导出
|
||||||
|
area: rn-app
|
||||||
|
status: concept
|
||||||
|
progress: 0
|
||||||
|
position: [980, 1280]
|
||||||
|
|
||||||
# ── 前端应用(Web,预留)──────────────────────────────────────
|
# ── 前端应用(Web,预留)──────────────────────────────────────
|
||||||
- id: app-scaffold
|
- id: app-scaffold
|
||||||
name: 应用脚手架模板
|
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
|
- depends: photo-capture
|
||||||
- acceptance: 查看模式下右侧面板;按物品分组展示照片缩略图(4 列网格 + 物品名标题);点击缩略图全屏 Modal 预览;Modal 为非透明全屏黑底(解决 Android transparent Modal 仅覆盖父容器的问题);单张显示 + onTouchStart/End 手势滑动切换(≥50px 触发)+ 左右箭头按钮;计数器实时同步
|
- 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 事件手势,彻底绕开分页宽度问题。
|
- **照片预览放弃 ScrollView/FlatList pagingEnabled**:在 Android 上 `transparent` Modal 的渲染范围被限制在父组件(右侧 260px 面板)内而非全屏,导致 `pagingEnabled` 页宽与实际视口不匹配,两张照片始终并排显示。尝试多种宽度测量方案(`SCREEN_W` 静态值、`onLayout` 动态测量、`useWindowDimensions`)均无效。最终改为单张显示 + 原生 Touch 事件手势,彻底绕开分页宽度问题。
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
"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 { useEffect, useState } from 'react'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { ActivityIndicator, View } from 'react-native'
|
import { ActivityIndicator, View } from 'react-native'
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||||
import { initDatabase } from '../src/db/database'
|
import { initDatabase } from '../src/db/database'
|
||||||
import { CategoriesProvider } from '../src/features/settings/CategoriesContext'
|
import { CategoriesProvider } from '../src/features/settings/CategoriesContext'
|
||||||
@ -27,6 +28,7 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<CategoriesProvider>
|
<CategoriesProvider>
|
||||||
@ -61,5 +63,6 @@ export default function RootLayout() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</CategoriesProvider>
|
</CategoriesProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
Modal,
|
Modal,
|
||||||
@ -8,6 +8,13 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} 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 { Ionicons } from '@expo/vector-icons'
|
||||||
import type { Marker, Photo } from '@repo/types'
|
import type { Marker, Photo } from '@repo/types'
|
||||||
import type { ItemWithPhotoCount } 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 [groups, setGroups] = useState<PhotoGroup[]>([])
|
||||||
const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([])
|
const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([])
|
||||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!marker) { setGroups([]); setAllPhotos([]); return }
|
if (!marker) { setGroups([]); setAllPhotos([]); return }
|
||||||
@ -56,17 +81,102 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
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 totalPhotos = allPhotos.length
|
||||||
const current = previewIndex !== null ? allPhotos[previewIndex] : null
|
|
||||||
|
|
||||||
function goPrev() {
|
const goPrev = useCallback(() => {
|
||||||
setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i))
|
setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i))
|
||||||
}
|
}, [])
|
||||||
function goNext() {
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
setPreviewIndex((i) => (i !== null && i < totalPhotos - 1 ? i + 1 : i))
|
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 (
|
return (
|
||||||
<View style={styles.panel}>
|
<View style={styles.panel}>
|
||||||
@ -132,33 +242,26 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
|
|||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
onRequestClose={() => setPreviewIndex(null)}
|
onRequestClose={() => setPreviewIndex(null)}
|
||||||
>
|
>
|
||||||
<View
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
style={styles.overlay}
|
<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()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 顶部信息栏 */}
|
|
||||||
<View style={styles.previewTopBar}>
|
<View style={styles.previewTopBar}>
|
||||||
<Text style={styles.previewItemName} numberOfLines={1}>
|
<Text style={styles.previewItemName} numberOfLines={1}>
|
||||||
{current?.itemName ?? ''}
|
{current?.itemName ?? ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.previewCounter}>
|
|
||||||
{(previewIndex ?? 0) + 1} / {totalPhotos}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setPreviewIndex(null)}
|
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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 单张照片 */}
|
{/* 照片区域(手势区) */}
|
||||||
<View style={styles.photoArea}>
|
<View style={styles.photoArea}>
|
||||||
|
<GestureDetector gesture={photoGesture}>
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, imageAnimStyle]}>
|
||||||
{current && (
|
{current && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: current.photo.filePath }}
|
source={{ uri: current.photo.filePath }}
|
||||||
@ -166,21 +269,44 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 左箭头 */}
|
{/* 左右箭头(放大模式下隐藏) */}
|
||||||
{previewIndex !== null && previewIndex > 0 && (
|
{!zoomMode && previewIndex !== null && previewIndex > 0 && (
|
||||||
<TouchableOpacity style={styles.arrowLeft} onPress={goPrev}>
|
<TouchableOpacity style={styles.arrowLeft} onPress={goPrev}>
|
||||||
<Ionicons name="chevron-back" size={34} color="#fff" />
|
<Ionicons name="chevron-back" size={34} color="#fff" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{/* 右箭头 */}
|
{!zoomMode && previewIndex !== null && previewIndex < totalPhotos - 1 && (
|
||||||
{previewIndex !== null && previewIndex < totalPhotos - 1 && (
|
|
||||||
<TouchableOpacity style={styles.arrowRight} onPress={goNext}>
|
<TouchableOpacity style={styles.arrowRight} onPress={goNext}>
|
||||||
<Ionicons name="chevron-forward" size={34} color="#fff" />
|
<Ionicons name="chevron-forward" size={34} color="#fff" />
|
||||||
</TouchableOpacity>
|
</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>
|
||||||
|
</View>
|
||||||
|
</GestureHandlerRootView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@ -241,16 +367,39 @@ const styles = StyleSheet.create({
|
|||||||
previewTopBar: {
|
previewTopBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 52,
|
paddingTop: 52,
|
||||||
paddingBottom: 12,
|
paddingBottom: 8,
|
||||||
},
|
},
|
||||||
previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' },
|
previewItemName: { flex: 1, color: 'rgba(255,255,255,0.85)', fontSize: 14, fontWeight: '600' },
|
||||||
previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginHorizontal: 12 },
|
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: {
|
photoArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 12 },
|
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 {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
@ -11,6 +11,12 @@ import {
|
|||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} 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 { Ionicons } from '@expo/vector-icons'
|
||||||
import * as MediaLibrary from 'expo-media-library'
|
import * as MediaLibrary from 'expo-media-library'
|
||||||
import type { Photo } from '@repo/types'
|
import type { Photo } from '@repo/types'
|
||||||
@ -25,9 +31,28 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
|||||||
const [photos, setPhotos] = useState<Photo[]>([])
|
const [photos, setPhotos] = useState<Photo[]>([])
|
||||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [zoomMode, setZoomMode] = useState(false)
|
||||||
const { width: screenWidth } = useWindowDimensions()
|
const { width: screenWidth } = useWindowDimensions()
|
||||||
const flatListRef = useRef<FlatList<Photo>>(null)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getPhotosByItem(itemId)
|
const rows = await getPhotosByItem(itemId)
|
||||||
@ -37,9 +62,74 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
|||||||
}
|
}
|
||||||
}, [itemId])
|
}, [itemId])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// 切换照片时退出缩放模式并重置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
setZoomMode(false)
|
||||||
}, [load])
|
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) {
|
function handleLongPress(photo: Photo) {
|
||||||
Alert.alert('删除照片', '确认删除此张照片?', [
|
Alert.alert('删除照片', '确认删除此张照片?', [
|
||||||
@ -62,7 +152,6 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
|||||||
const handleSaveToAlbum = useCallback(async () => {
|
const handleSaveToAlbum = useCallback(async () => {
|
||||||
const currentPhoto = previewIndex !== null ? photos[previewIndex] : null
|
const currentPhoto = previewIndex !== null ? photos[previewIndex] : null
|
||||||
if (!currentPhoto) return
|
if (!currentPhoto) return
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync()
|
const { status } = await MediaLibrary.requestPermissionsAsync()
|
||||||
@ -106,21 +195,24 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setPreviewIndex(null)}
|
onRequestClose={() => setPreviewIndex(null)}
|
||||||
>
|
>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<View style={styles.overlay}>
|
<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
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={photos}
|
data={photos}
|
||||||
@ -148,10 +240,42 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
|||||||
</View>
|
</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}>
|
<Text style={styles.counter}>
|
||||||
{(previewIndex ?? 0) + 1} / {photos.length}
|
{(previewIndex ?? 0) + 1} / {photos.length}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</GestureHandlerRootView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@ -169,33 +293,46 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#E5E7EB',
|
backgroundColor: '#E5E7EB',
|
||||||
},
|
},
|
||||||
thumbImg: { width: '100%', height: '100%' },
|
thumbImg: { width: '100%', height: '100%' },
|
||||||
|
|
||||||
overlay: {
|
overlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.92)',
|
backgroundColor: 'rgba(0,0,0,0.92)',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
closeBtn: {
|
|
||||||
position: 'absolute',
|
// 缩放模式照片区域
|
||||||
top: 48,
|
zoomArea: {
|
||||||
right: 16,
|
...StyleSheet.absoluteFillObject,
|
||||||
zIndex: 10,
|
justifyContent: 'center',
|
||||||
padding: 4,
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
saveBtn: {
|
|
||||||
position: 'absolute',
|
// 普通模式 FlatList 每页
|
||||||
top: 48,
|
|
||||||
left: 16,
|
|
||||||
zIndex: 10,
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
saveBtnDisabled: { opacity: 0.4 },
|
|
||||||
previewPage: {
|
previewPage: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
previewImg: {
|
previewImg: { height: 500 },
|
||||||
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: {
|
counter: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 48,
|
bottom: 48,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user