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 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 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 createState() => _ImageBrowserPageState(); } class _ImageBrowserPageState extends State with TickerProviderStateMixin { // ========== 分页 ========== late final PageController _pageCtr; late int _curIndex; bool _isForward = true; // 页码翻滚方向:下一张上滚、上一张下滚 // ========== 缩放(双指/双击) ========== late final List _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(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 _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(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)), ), ), ), ); } }