v87 旅程查看器 Swiper 化:图片+视频混排翻页,滑动带动画
用户 2026-10-24:「图片和视频是不是可以都支持滑动啊?……滑动到视频 的时候,点击就直接播放。另外滑动的时候有没有个动画呀?现在就滑动, 整个一下就变化,特别生硬。」 实现: - photoViewer 从「单张 Image + PanGesture(松手瞬跳 index)」改成 Swiper:跟手滑动 + 换页缓动动画(duration 350ms),loop 循环翻页 - 媒体列表改 mediaOf() = 图片在前视频在后(与缩略图墙同序), viewerKeys:string[] → viewerPhotos:FbTripNodePhoto[](要带 mime) - 视频也是翻页项:滑到视频页显示 ▶ + 文件名 +「点一下播放」, 点一下走 openVideo() 弹原有全屏播放器(系统控件不变) - 底部 ‹ › 按钮走 viewerCtl.showPrevious()/showNext()(与手势同一条 动画通道;直接改 index 是瞬跳);计数由 onChange 统一更新 - 打开查看器用 changeIndex(at, false) 跳到所点那张,开局不播动画 - viewerStep()/imageKeysOf() 随 PanGesture 方案一并退场 真机实测(Mate X7,"2026国庆回老家"段附件 3 个媒体): 开查看器 1/3 → 左滑 2/3 → 3/3 视频页(点一下播放)→ 点击后视频 真实播放(5.mp4,00:05/00:29)→ 关播放器回查看器原位 → 按钮翻页 2/3→1/3 → 循环回绕 1/3→3/3 全部通过。 验证:守卫 1177/0(v87 组新增 10 条)· 反向验证全量 249/0 (v87 组 7 条全部命中)· 装机成功
This commit is contained in:
@@ -293,11 +293,16 @@ export struct FbTripDetailPage {
|
||||
/* ---- v76:照片查看器 / 视频播放器 ----
|
||||
🔴 用户 2026-10-22:「里面的照片要可以点击查看,视频要能够播放。」
|
||||
⚠️ 两个都是**全屏覆盖层**,所以用"非空即开着"的语义
|
||||
(`viewerKeys` 空 / `videoKey` 空 = 关着),不用额外的布尔开关 ——
|
||||
(`viewerPhotos` 空 / `videoKey` 空 = 关着),不用额外的布尔开关 ——
|
||||
少一个开关就少一处"关了浮层忘了复位"的漏洞。 */
|
||||
/** 查看器里的图片键列表(空 = 关着) */
|
||||
@Local viewerKeys: string[] = [];
|
||||
/** 查看器里的媒体列表(v87:**图片+视频混排**,空 = 关着)。
|
||||
* 🔴 用户 2026-10-24:「图片和视频是不是可以都支持滑动…滑动到视频的时候,
|
||||
* 点击就直接播放。」⇒ 列表不再是纯键数组,要带 `mime` 才分得清图片/视频。 */
|
||||
@Local viewerPhotos: FbTripNodePhoto[] = [];
|
||||
@Local viewerIndex: number = 0;
|
||||
/** v87:Swiper 控制器 —— 底部「上一张/下一张」走 `showPrevious()/showNext()`,
|
||||
* 与手势翻页同一条动画通道(直接改 index 是瞬跳,又要回到"生硬"的老毛病)。 */
|
||||
private viewerCtl: SwiperController = new SwiperController();
|
||||
/** 正在播放的视频键(空 = 关着) */
|
||||
@Local videoKey: string = '';
|
||||
@Local videoName: string = '';
|
||||
@@ -328,8 +333,8 @@ export struct FbTripDetailPage {
|
||||
this.videoName = '';
|
||||
return true;
|
||||
}
|
||||
if (this.viewerKeys.length > 0) {
|
||||
this.viewerKeys = [];
|
||||
if (this.viewerPhotos.length > 0) {
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
return true;
|
||||
}
|
||||
@@ -398,9 +403,9 @@ export struct FbTripDetailPage {
|
||||
|
||||
/* v76:全屏查看器 / 播放器也要让底部悬浮栏让位(否则大图底下漏出一条悬浮栏)。
|
||||
⚠️ 同样是"一个 @Monitor 盯一个变量",两个分开写 —— 它们互斥,直接赋值即可。 */
|
||||
@Monitor('viewerKeys')
|
||||
@Monitor('viewerPhotos')
|
||||
onViewerChanged(): void {
|
||||
fbAppState().sheetCount = this.viewerKeys.length > 0 ? 1 : 0;
|
||||
fbAppState().sheetCount = this.viewerPhotos.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
@Monitor('videoKey')
|
||||
@@ -408,26 +413,19 @@ export struct FbTripDetailPage {
|
||||
fbAppState().sheetCount = this.videoKey !== '' ? 1 : 0;
|
||||
}
|
||||
|
||||
/** 打开某一组图片的大图查看器(从第几张开始) */
|
||||
private openViewer(keys: string[], at: number): void {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.viewerKeys = keys;
|
||||
// ⚠️ 必须夹到 [0, n):点第 3 张时若列表刚被删短,直接赋会越界
|
||||
this.viewerIndex = at < 0 ? 0 : (at >= keys.length ? keys.length - 1 : at);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看器翻页。
|
||||
* ⚠️ 取模归一到 [0, n):直接加减会让 index 越界或变负数(相册里踩过)。
|
||||
* 打开某一段行程的大图查看器(从第 `at` 个开始)。
|
||||
*
|
||||
* 🔴 v87:传入的是**图片+视频混排**的整列表(`mediaOf`),不再是纯图片 ——
|
||||
* 左右滑动能一路划到视频,划到视频那页点一下就播。
|
||||
*/
|
||||
private viewerStep(delta: number): void {
|
||||
const n: number = this.viewerKeys.length;
|
||||
if (n === 0) {
|
||||
private openViewer(photos: FbTripNodePhoto[], at: number): void {
|
||||
if (photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n;
|
||||
this.viewerPhotos = photos;
|
||||
// ⚠️ 必须夹到 [0, n):点第 3 张时若列表刚被删短,直接赋会越界
|
||||
this.viewerIndex = at < 0 ? 0 : (at >= photos.length ? photos.length - 1 : at);
|
||||
}
|
||||
|
||||
/** 打开一个视频(`key` 是裸 key,播放地址现拼,不带缓存) */
|
||||
@@ -657,7 +655,7 @@ export struct FbTripDetailPage {
|
||||
/* 🔴 v76:照片查看器 / 视频播放器放在**最后一个位置**。
|
||||
在 `Stack` 里"后写的盖在上面",所以它压住所有浮层 —— 用户点了照片
|
||||
就该直接看到大图,而不是"点开又冒出一个浮层把它盖住"。 */
|
||||
if (this.viewerKeys.length > 0) {
|
||||
if (this.viewerPhotos.length > 0) {
|
||||
this.photoViewer()
|
||||
}
|
||||
if (this.videoKey !== '') {
|
||||
@@ -1258,7 +1256,9 @@ export struct FbTripDetailPage {
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.onClick((): void => {
|
||||
// 点第几张就从第几张开始看(不是每次都从第一张)
|
||||
this.openViewer(this.imageKeysOf(n), this.imagesOf(n).indexOf(ph));
|
||||
// 🔴 v87:给的是**图片+视频混排**的整列表 —— 滑动能划到视频
|
||||
const all: FbTripNodePhoto[] = this.mediaOf(n);
|
||||
this.openViewer(all, all.indexOf(ph));
|
||||
})
|
||||
}, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString())
|
||||
|
||||
@@ -1375,32 +1375,76 @@ export struct FbTripDetailPage {
|
||||
.height('100%')
|
||||
.backgroundColor('#E6000000')
|
||||
.onClick((): void => {
|
||||
this.viewerKeys = [];
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
})
|
||||
|
||||
// ② 大图 + 左右滑翻页
|
||||
/* ② 媒体翻页(v87:Swiper)
|
||||
🔴 用户 2026-10-24:「图片和视频是不是可以都支持滑动…滑动到视频的时候,
|
||||
点击就直接播放。另外滑动的时候有没有个动画呀?现在整个一下就变化,特别生硬。」
|
||||
⚠️ 原来是单张 `Image` + `PanGesture`,松手直接换 index ⇒ "整个一下就变化"。
|
||||
`Swiper` 的滑动**跟手**,松手(或 `changeIndex`)自带缓动动画,
|
||||
不用自己写位移插值;`.duration()` 调换页动画时长。
|
||||
⚠️ 视频也是翻页项(`viewerPhotos` 本来就是混排):滑到视频那页,点一下
|
||||
走 `openVideo()`,原 `videoPlayer` 全屏播放器(系统控件)原样叠上来。
|
||||
⚠️ `loop(true)`:首尾相接,与旧版"取模循环翻页"行为一致(Swiper 自己处理回绕)。 */
|
||||
Swiper(this.viewerCtl) {
|
||||
ForEach(this.viewerPhotos, (m: FbTripNodePhoto) => {
|
||||
if (fbPhotoKind(m) === 'video') {
|
||||
/* 视频页:没有服务端封面 ⇒ 深色底 + ▶ + 文件名 + 一句"点一下播放",
|
||||
比放一张坏图诚实。点它才播(用户:滑动到视频的时候,点击就直接播放)。 */
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Image(fbImageSrc(this.viewerKeys[this.viewerIndex], FB_TOKEN_STORE.get()))
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#2A2320')
|
||||
Column({ space: 10 }) {
|
||||
Text('▶')
|
||||
.fontSize(44)
|
||||
.fontColor('#FFFFFFCC')
|
||||
Text(m.name !== '' ? m.name : '视频')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor('#FFFFFF99')
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('点一下播放')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor('#FFFFFF66')
|
||||
}
|
||||
.padding(20)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.borderRadius(10)
|
||||
.clip(true)
|
||||
.onClick((): void => {
|
||||
this.openVideo(m);
|
||||
})
|
||||
} else {
|
||||
Image(fbImageSrc(m.key, FB_TOKEN_STORE.get()))
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.objectFit(ImageFit.Contain)
|
||||
.gesture(
|
||||
PanGesture({ direction: PanDirection.Horizontal })
|
||||
.onActionEnd((event: GestureEvent): void => {
|
||||
const dx: number = event.offsetX;
|
||||
const dy: number = event.offsetY;
|
||||
// 位移太小 = 手抖;竖着划 = 用户想上下看,不该翻页
|
||||
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) {
|
||||
return;
|
||||
}
|
||||
// 左滑看下一张,右滑看上一张(与相册一致)
|
||||
this.viewerStep(dx < 0 ? 1 : -1);
|
||||
})
|
||||
)
|
||||
}, (m: FbTripNodePhoto): string => 'vw_' + m.id.toString())
|
||||
}
|
||||
.loop(true)
|
||||
.duration(350)
|
||||
.cachedCount(1)
|
||||
.width('100%')
|
||||
.height('76%')
|
||||
.onChange((i: number): void => {
|
||||
/* 手势与按钮翻页统一在这里落 index(动画结束才回调 ⇒ 底部计数不闪)。 */
|
||||
this.viewerIndex = i;
|
||||
})
|
||||
.onAppear((): void => {
|
||||
/* ⚠️ Swiper 挂载时只能从第 0 页开始 ⇒ 打开瞬间跳到用户点的那张。
|
||||
`useAnimation=false`:**开查看器**这一下不该放翻页动画(否则看起来
|
||||
像"从第一张滑过去")。之后的手势/按钮翻页都走 Swiper 自带动画。 */
|
||||
this.viewerCtl.changeIndex(this.viewerIndex, false);
|
||||
})
|
||||
|
||||
/* ③ 顶部关闭 + 底部翻页条
|
||||
🔴🔴 v76 真机实测踩到:原来这两块各自写 `.align(Alignment.Bottom)` /
|
||||
@@ -1412,6 +1456,8 @@ export struct FbTripDetailPage {
|
||||
⚠️ `hitTestBehavior(Transparent)` 必须有:这一层是**透明的**,
|
||||
它只负责摆按钮的位置;中间大片空白要让点击穿下去给 ① 的遮罩
|
||||
(否则"点背景关闭"失效)。Transparent = 自身与子节点响应、不阻挡兄弟。
|
||||
⚠️ v87:‹ › 按钮走 `viewerCtl.showPrevious()/showNext()` —— 与手势同一条
|
||||
动画通道;直接改 `viewerIndex` 是瞬跳(Swiper 也不认外部改 index)。
|
||||
*/
|
||||
Column() {
|
||||
Row() {
|
||||
@@ -1427,7 +1473,7 @@ export struct FbTripDetailPage {
|
||||
.padding({ left: 18, right: 6, top: 8, bottom: 8 })
|
||||
.alignItems(HorizontalAlign.End)
|
||||
.onClick((): void => {
|
||||
this.viewerKeys = [];
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
})
|
||||
}
|
||||
@@ -1441,9 +1487,9 @@ export struct FbTripDetailPage {
|
||||
.fontColor('#FFFFFFCC')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.onClick((): void => {
|
||||
this.viewerStep(-1);
|
||||
this.viewerCtl.showPrevious();
|
||||
})
|
||||
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerKeys.length.toString())
|
||||
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerPhotos.length.toString())
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor('#FFFFFF99')
|
||||
Text('下一张 ›')
|
||||
@@ -1451,7 +1497,7 @@ export struct FbTripDetailPage {
|
||||
.fontColor('#FFFFFFCC')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.onClick((): void => {
|
||||
this.viewerStep(1);
|
||||
this.viewerCtl.showNext();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
@@ -1681,12 +1727,12 @@ export struct FbTripDetailPage {
|
||||
return n.attachs.filter((a: FbTripNodePhoto): boolean => fbPhotoKind(a) === 'video' && a.key !== '');
|
||||
}
|
||||
|
||||
/** 查看器要用的键列表(与 `imagesOf` 顺序严格一致 —— 索引要对得上) */
|
||||
private imageKeysOf(n: FbTripNode): string[] {
|
||||
const out: string[] = [];
|
||||
for (const a of this.imagesOf(n)) {
|
||||
out.push(a.key);
|
||||
}
|
||||
return out;
|
||||
/**
|
||||
* 查看器要用的整份媒体列表(v87)。
|
||||
* 🔴 图片在前、视频在后 —— 与缩略图墙的铺排顺序严格一致,`indexOf(ph)` 才对得上。
|
||||
* 混排后左右滑动就能从图片一路划到视频(用户 2026-10-24 的原话)。
|
||||
*/
|
||||
private mediaOf(n: FbTripNode): FbTripNodePhoto[] {
|
||||
return this.imagesOf(n).concat(this.videosOf(n));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user