初始化
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user