Files
huangguo_android/lib/tools_base/widget/image_browser_page.dart
T
2026-09-15 15:44:13 +07:00

371 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/image_util.dart';
import 'package:hgdj/tools_base/cache/image_cache_manager.dart';
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
/// 微信风格图片浏览器
/// 横滑切图 + 双指/双击缩放 + 上下滑拖拽退出(图片跟手、背景随拖动渐隐露出下层页面) + 长按保存
///
/// 用法(透明路由,拖动渐隐才能看到下层):
/// ```dart
/// ImageBrowserPage.open(['url1', 'url2'], index: 0);
/// ```
class ImageBrowserPage extends StatefulWidget {
final List<String> images;
final int initialIndex;
final bool showSaveButton; // 右上角显式"保存到相册"按钮(AI 生成图等场景);默认只支持长按保存
const ImageBrowserPage(
{super.key,
required this.images,
this.initialIndex = 0,
this.showSaveButton = false});
/// 打开浏览器(透明路由 + 淡入)
/// [showSaveButton] 显示右上角"保存到相册"按钮
static void open(List<String> images,
{int index = 0, bool showSaveButton = false}) {
final valid = images.where((e) => e.isNotEmpty).toList();
if (valid.isEmpty) return;
// fullscreenDialog:true → GetX canTransitionTo 返回 false,下层页面不做外出转场,
// 保持完整渲染 → 拖拽偷看时看到的是整页而非"pop 一半"。
// 瞬时 fadeIn:打开无残影。仍是 Get.to → Get.back 能正常 pop,下滑退出正常
Get.to(
() => ImageBrowserPage(
images: valid,
initialIndex: index.clamp(0, valid.length - 1),
showSaveButton: showSaveButton),
opaque: false,
transition: Transition.fadeIn,
duration: Duration.zero,
fullscreenDialog: true,
);
}
@override
State<ImageBrowserPage> createState() => _ImageBrowserPageState();
}
class _ImageBrowserPageState extends State<ImageBrowserPage>
with TickerProviderStateMixin {
// ========== 分页 ==========
late final PageController _pageCtr;
late int _curIndex;
bool _isForward = true; // 页码翻滚方向:下一张上滚、上一张下滚
// ========== 缩放(双指/双击) ==========
late final List<TransformationController> _transCtrs; // 每张图各自的缩放/平移矩阵
late final AnimationController _zoomCtr; // 双击缩放过渡
Offset? _tapPos; // 双击落点,作为放大锚点
// ========== 拖拽退出 ==========
double _dragY = 0; // 只跟竖直方向
bool _isDragging = false;
late final AnimationController _resetCtr; // 松手未达阈值时回弹
// ========== 入场 / 退出 ==========
late final AnimationController _enterCtr; // 入场:图片在实底上淡入
late final AnimationController _exitCtr; // 退出:整体淡出
bool _isExiting = false;
// ========== 保存 ==========
bool _isSaving = false; // 动图转码耗时,挡重复触发并盖个转圈
@override
void initState() {
super.initState();
_curIndex = widget.initialIndex;
_pageCtr = PageController(initialPage: _curIndex);
_transCtrs =
List.generate(widget.images.length, (_) => TransformationController());
_resetCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
_zoomCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
_enterCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 220))
..forward();
_exitCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
}
@override
void dispose() {
_pageCtr.dispose();
for (final c in _transCtrs) {
c.dispose();
}
_resetCtr.dispose();
_zoomCtr.dispose();
_enterCtr.dispose();
_exitCtr.dispose();
super.dispose();
}
// ========== 派生状态 ==========
// 当前图是否已放大(放大时禁用下滑退出,交给 InteractiveViewer 平移)
bool get _isZoomed => _transCtrs[_curIndex].value.getMaxScaleOnAxis() > 1.05;
// 背景不透明度:随竖直拖动距离渐隐(下拉偷看下层)
double get _bgOpacity =>
(1 - _dragY.abs() / (Get.height * 0.6)).clamp(0.0, 1.0);
// 拖动时图片轻微缩小
double get _dragScale =>
(1 - _dragY.abs() / (Get.height * 2)).clamp(0.85, 1.0);
// 退出淡出系数:1→0
double get _exitFactor => 1 - _exitCtr.value;
// ========== 拖拽退出 ==========
void _onDragUpdate(DragUpdateDetails d) {
setState(() {
_isDragging = true;
_dragY += d.delta.dy;
});
}
void _onDragEnd(DragEndDetails d) {
// 拖够距离或甩动够快 → 退出,否则回弹
if (_dragY.abs() > 120 || d.velocity.pixelsPerSecond.dy.abs() > 800) {
_exit();
return;
}
final anim = Tween<double>(begin: _dragY, end: 0)
.animate(CurvedAnimation(parent: _resetCtr, curve: Curves.easeOut));
void listener() => setState(() => _dragY = anim.value);
anim.addListener(listener);
// whenComplete 在 dispose 取消 ticker 时也会触发,一律先判 mounted
_resetCtr.forward(from: 0).whenComplete(() {
anim.removeListener(listener);
if (mounted) setState(() => _isDragging = false);
});
}
// 退出:图片+背景就地整体淡出再 pop —— 还原点赞那版手感,也避免"背景先没图片还在"的残留
void _exit() {
setState(() => _isExiting = true); // 隐藏返回/保存/页码,只留图片淡出
// 淡出途中被系统返回键 pop 掉时 whenComplete 照样触发,不判 mounted 会把下层页面也弹掉
_exitCtr.forward(from: 0).whenComplete(() {
if (mounted) Get.back();
});
}
// ========== 双击缩放 ==========
void _onDoubleTap() {
final ctr = _transCtrs[_curIndex];
final Matrix4 target;
if (_isZoomed) {
target = Matrix4.identity();
} else {
// 以双击点为锚点放大到 2.5 倍(列主序构造缩放+平移,避开已废弃的 Matrix4.translate/scale)
final pos = _tapPos ?? Offset(Get.width / 2, Get.height / 2);
const scale = 2.5;
target = Matrix4(
scale, 0, 0, 0, //
0, scale, 0, 0, //
0, 0, 1, 0, //
-pos.dx * (scale - 1), -pos.dy * (scale - 1), 0, 1, //
);
}
final anim = Matrix4Tween(begin: ctr.value, end: target)
.animate(CurvedAnimation(parent: _zoomCtr, curve: Curves.easeOut));
void listener() => ctr.value = anim.value;
anim.addListener(listener);
_zoomCtr.forward(from: 0).whenComplete(() {
anim.removeListener(listener);
if (mounted) setState(() {}); // 刷新 _isZoomed → 下滑退出开关
});
}
// ========== 长按保存 ==========
// 缓存存的是解密后的图(见 ImageCacheManager.CustomFileRespons),直接取字节保存
// 动图(AI 图生视频的结果就是多帧 webp)由 saveImageToAlbum 内部转 mp4,否则相册里只有第一帧
Future<void> _saveCurrent() async {
if (_isSaving) return;
final url = widget.images[_curIndex];
if (url.isEmpty) return;
setState(() => _isSaving = true);
try {
final file = await ImageCacheManager().getSingleFile(url);
final ok = await ImageUtil.saveImageToAlbum(await file.readAsBytes());
showToast(ok ? '已保存到相册' : '保存失败');
} catch (_) {
showToast('保存失败');
} finally {
if (mounted) setState(() => _isSaving = false); //可能保存途中已被下滑退出
}
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
// 退出淡出逐帧重建交给 AnimatedBuilder,不用自己挂 listener + 空 setState
child: AnimatedBuilder(
animation: _exitCtr,
builder: (_, __) => Stack(
children: [
// 黑色背景,随拖动/退出渐隐
Positioned.fill(
child: ColoredBox(
color: Colors.black
.withValues(alpha: _bgOpacity * _exitFactor))),
GestureDetector(
onLongPress: _saveCurrent, // 长按保存
onDoubleTapDown: (d) => _tapPos = d.localPosition,
onDoubleTap: _onDoubleTap,
// 未放大才下滑退出;放大时为 null,竖直拖动交给 InteractiveViewer 平移
onVerticalDragUpdate: _isZoomed ? null : _onDragUpdate,
onVerticalDragEnd: _isZoomed ? null : _onDragEnd,
child: Opacity(
opacity: _exitFactor, // 退出时整体淡出
child: FadeTransition(
opacity: _enterCtr,
child: Transform.translate(
offset: Offset(0, _dragY),
child:
Transform.scale(scale: _dragScale, child: _pageView()),
),
),
),
),
// 拖拽/退出中只留图片,顶部按钮和页码全隐藏
if (!_isDragging && !_isExiting) ...[
_backButton(),
if (widget.showSaveButton) _saveButton(),
if (widget.images.length > 1) _indicator(), // 多图才显示页码
],
// 保存中(动图要解码+转码,要几秒),压在最上层挡住交互
// 用和上传图片一致的 LoadingAlertWidget,但内嵌而非 .show() 弹窗——
// 弹窗的 cancel() 是 Get.back(),本页自身也靠 Get.back() 退出,容易互相弹错
if (_isSaving)
const Positioned.fill(
child: AbsorbPointer(
child: ColoredBox(
color: Color(0x80000000),
child: LoadingAlertWidget(title: '保存中...')),
),
),
],
),
),
);
}
Widget _pageView() {
return PageView.builder(
controller: _pageCtr,
itemCount: widget.images.length,
onPageChanged: (i) {
_transCtrs[_curIndex].value = Matrix4.identity(); // 离开的图复位缩放
setState(() {
_isForward = i >= _curIndex;
_curIndex = i;
});
},
itemBuilder: (_, i) => InteractiveViewer(
transformationController: _transCtrs[i],
minScale: 1,
maxScale: 4,
onInteractionEnd: (_) => setState(() {}), // 缩放结束刷新 _isZoomed
child: SizedBox(
width: Get.width,
height: Get.height,
child: NetworkImageLoader(
imageUrl: widget.images[i],
fit: BoxFit.contain,
borderRadius: 0,
isResizeImage: false, // 看大图用原图,不按组件尺寸压缩
),
),
),
);
}
// 页码指示(白色胶囊,当前页红色) —— 沿用 CommunityImagePage 样式
Widget _indicator() {
return Positioned(
bottom: 50,
right: 20,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 2),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 当前页码:切换时上下翻滚(下一张上滚、上一张下滚)
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, anim) {
final isIncoming = child.key == ValueKey(_curIndex);
// 进入的从对向滑入到原位,离开的从原位滑出到反向
final begin = _isForward
? (isIncoming ? const Offset(0, 1) : const Offset(0, -1))
: (isIncoming ? const Offset(0, -1) : const Offset(0, 1));
return ClipRect(
child: SlideTransition(
position: Tween<Offset>(begin: begin, end: Offset.zero)
.animate(anim),
child: child,
),
);
},
child: Text(
'${_curIndex + 1}',
key: ValueKey(_curIndex),
style: TextStyle(color: AppColors.actionRed, fontSize: 12),
),
),
Text('/${widget.images.length}',
style: const TextStyle(color: Color(0xff3D3D3D), fontSize: 12)),
],
),
),
);
}
// 左上返回按钮 —— 沿用 CommunityImagePage
Widget _backButton() {
return Positioned(
top: 0,
left: 16,
child: SafeArea(
child: InkWell(
enableFeedback: false,
onTap: _exit,
child: Image.asset('back_circle.png'.commonImgPath, width: 24),
),
),
);
}
// 右上"保存到相册"按钮 —— 沿用 AiNewImageView 样式
Widget _saveButton() {
return Positioned(
top: 0,
right: 16,
child: SafeArea(
child: InkWell(
enableFeedback: false,
onTap: _saveCurrent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFE57310)),
child: const Text('保存到相册',
style: TextStyle(color: Colors.white, fontSize: 14)),
),
),
),
);
}
}