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