opt: 图片预览

This commit is contained in:
guozhigq
2024-07-13 16:47:44 +08:00
parent c0371b3d78
commit 9afecbecdb
17 changed files with 958 additions and 599 deletions

View File

@ -1,130 +0,0 @@
// ignore_for_file: depend_on_referenced_packages
import 'dart:math';
import 'dart:ui' as ui show Image;
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart';
double get maxDragOffset => 100;
double hideHeight = maxDragOffset / 2.3;
double refreshHeight = maxDragOffset / 1.5;
class PullToRefreshHeader extends StatelessWidget {
const PullToRefreshHeader(
this.info,
this.lastRefreshTime, {
this.color,
super.key,
});
final PullToRefreshScrollNotificationInfo? info;
final DateTime? lastRefreshTime;
final Color? color;
@override
Widget build(BuildContext context) {
final PullToRefreshScrollNotificationInfo? infos = info;
if (infos == null) {
return const SizedBox();
}
String text = '';
if (infos.mode == PullToRefreshIndicatorMode.armed) {
text = 'Release to refresh';
} else if (infos.mode == PullToRefreshIndicatorMode.refresh ||
infos.mode == PullToRefreshIndicatorMode.snap) {
text = 'Loading...';
} else if (infos.mode == PullToRefreshIndicatorMode.done) {
text = 'Refresh completed.';
} else if (infos.mode == PullToRefreshIndicatorMode.drag) {
text = 'Pull to refresh';
} else if (infos.mode == PullToRefreshIndicatorMode.canceled) {
text = 'Cancel refresh';
}
final TextStyle ts = const TextStyle(
color: Colors.grey,
).copyWith(fontSize: 14);
final double dragOffset = info?.dragOffset ?? 0.0;
final DateTime time = lastRefreshTime ?? DateTime.now();
final double top = -hideHeight + dragOffset;
return Container(
height: dragOffset,
color: color ?? Colors.transparent,
// padding: EdgeInsets.only(top: dragOffset / 3),
// padding: EdgeInsets.only(bottom: 5.0),
child: Stack(
children: <Widget>[
Positioned(
left: 0.0,
right: 0.0,
top: top,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Expanded(
child: Container(
alignment: Alignment.centerRight,
margin: const EdgeInsets.only(right: 12.0),
child: RefreshImage(top, null),
),
),
Column(
children: <Widget>[
Text(text, style: ts),
Text(
'Last updated:${DateFormat('yyyy-MM-dd hh:mm').format(time)}',
style: ts.copyWith(fontSize: 14),
)
],
),
const Spacer(),
],
),
)
],
),
);
}
}
class RefreshImage extends StatelessWidget {
const RefreshImage(this.top, Key? key) : super(key: key);
final double top;
@override
Widget build(BuildContext context) {
const double imageSize = 30;
return ExtendedImage.asset(
'assets/flutterCandies_grey.png',
width: imageSize,
height: imageSize,
afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) {
final double imageHeight = image.height.toDouble();
final double imageWidth = image.width.toDouble();
final Size size = rect.size;
final double y =
(1 - min(top / (refreshHeight - hideHeight), 1)) * imageHeight;
canvas.drawImageRect(
image,
Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y),
Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height,
size.width, (imageHeight - y) / imageHeight * size.height),
Paint()
..colorFilter =
const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn)
..isAntiAlias = false
..filterQuality = FilterQuality.low,
);
//canvas.restore();
},
);
}
}

View File

@ -1,11 +1,11 @@
// 内容
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/badge.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/dynamics/result.dart';
import 'package:pilipala/pages/preview/index.dart';
import 'package:pilipala/plugin/pl_gallery/index.dart';
import 'rich_node_panel.dart';
// ignore: must_be_immutable
@ -59,17 +59,15 @@ class _ContentState extends State<Content> {
(pictureItem.height != null && pictureItem.width != null
? pictureItem.height! / pictureItem.width!
: 1);
return GestureDetector(
onTap: () {
showDialog(
useSafeArea: false,
context: context,
builder: (context) {
return ImagePreview(initialPage: 0, imgList: picList);
},
);
return Hero(
tag: pictureItem.url!,
placeholderBuilder:
(BuildContext context, Size heroSize, Widget child) {
return child;
},
child: Container(
child: GestureDetector(
onTap: () => onPreviewImg(picList, 1, context),
child: Container(
padding: const EdgeInsets.only(top: 4),
constraints: BoxConstraints(maxHeight: maxHeight),
width: box.maxWidth / 2,
@ -91,7 +89,9 @@ class _ContentState extends State<Content> {
)
: const SizedBox(),
],
)),
),
),
),
);
},
),
@ -102,26 +102,23 @@ class _ContentState extends State<Content> {
List<Widget> list = [];
for (var i = 0; i < len; i++) {
picList.add(pics[i].url!);
}
for (var i = 0; i < len; i++) {
list.add(
LayoutBuilder(
builder: (context, BoxConstraints box) {
double maxWidth = box.maxWidth.truncateToDouble();
return GestureDetector(
onTap: () {
showDialog(
useSafeArea: false,
context: context,
builder: (context) {
return ImagePreview(initialPage: i, imgList: picList);
},
);
},
child: NetworkImgLayer(
src: pics[i].url,
width: maxWidth,
height: maxWidth,
origAspectRatio:
pics[i].width!.toInt() / pics[i].height!.toInt(),
return Hero(
tag: picList[i],
child: GestureDetector(
onTap: () => onPreviewImg(picList, i, context),
child: NetworkImgLayer(
src: pics[i].url,
width: maxWidth,
height: maxWidth,
origAspectRatio:
pics[i].width!.toInt() / pics[i].height!.toInt(),
),
),
);
},
@ -163,6 +160,43 @@ class _ContentState extends State<Content> {
);
}
void onPreviewImg(picList, initIndex, context) {
Navigator.of(context).push(
HeroDialogRoute<void>(
builder: (BuildContext context) => InteractiveviewerGallery(
sources: picList,
initIndex: initIndex,
itemBuilder: (
BuildContext context,
int index,
bool isFocus,
bool enablePageView,
) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (enablePageView) {
Navigator.of(context).pop();
}
},
child: Center(
child: Hero(
tag: picList[index],
child: CachedNetworkImage(
fadeInDuration: const Duration(milliseconds: 0),
imageUrl: picList[index],
fit: BoxFit.contain,
),
),
),
);
},
onPageChanged: (int pageIndex) {},
),
),
);
}
@override
Widget build(BuildContext context) {
TextStyle authorStyle =

View File

@ -1,9 +1,47 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/constants.dart';
import 'package:pilipala/common/widgets/badge.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/pages/preview/index.dart';
import 'package:pilipala/plugin/pl_gallery/index.dart';
void onPreviewImg(currentUrl, picList, initIndex, context) {
Navigator.of(context).push(
HeroDialogRoute<void>(
builder: (BuildContext context) => InteractiveviewerGallery(
sources: picList,
initIndex: initIndex,
itemBuilder: (
BuildContext context,
int index,
bool isFocus,
bool enablePageView,
) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (enablePageView) {
Navigator.of(context).pop();
}
},
child: Center(
child: Hero(
tag: picList[index],
child: CachedNetworkImage(
fadeInDuration: const Duration(milliseconds: 0),
imageUrl: picList[index],
fit: BoxFit.contain,
),
),
),
);
},
onPageChanged: (int pageIndex) {},
),
),
);
}
Widget picWidget(item, context) {
String type = item.modules.moduleDynamic.major.type;
@ -21,25 +59,25 @@ Widget picWidget(item, context) {
List<Widget> list = [];
for (var i = 0; i < len; i++) {
picList.add(pictures[i].src ?? pictures[i].url);
}
for (var i = 0; i < len; i++) {
list.add(
LayoutBuilder(
builder: (context, BoxConstraints box) {
return GestureDetector(
onTap: () {
showDialog(
useSafeArea: false,
context: context,
builder: (context) {
return ImagePreview(initialPage: i, imgList: picList);
},
);
return Hero(
tag: picList[i],
placeholderBuilder:
(BuildContext context, Size heroSize, Widget child) {
return child;
},
child: NetworkImgLayer(
src: pictures[i].src ?? pictures[i].url,
width: box.maxWidth,
height: box.maxWidth,
child: GestureDetector(
onTap: () => onPreviewImg(picList[i], picList, i, context),
child: NetworkImgLayer(
src: pictures[i].src ?? pictures[i].url,
width: box.maxWidth,
height: box.maxWidth,
),
),
// ),
);
},
),

View File

@ -27,6 +27,7 @@ class MainController extends GetxController {
RxBool userLogin = false.obs;
late Rx<DynamicBadgeMode> dynamicBadgeType = DynamicBadgeMode.number.obs;
late bool enableGradientBg;
bool imgPreviewStatus = false;
@override
void onInit() {

View File

@ -1,50 +0,0 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:share_plus/share_plus.dart';
class PreviewController extends GetxController {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
RxInt initialPage = 0.obs;
RxInt currentPage = 1.obs;
RxList imgList = [].obs;
bool storage = true;
bool videos = true;
bool photos = true;
String currentImgUrl = '';
requestPermission() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
// Permission.photos
].request();
statuses[Permission.storage].toString();
// final photosInfo = statuses[Permission.photos].toString();
}
// 图片分享
void onShareImg() async {
SmartDialog.showLoading();
var response = await Dio().get(imgList[initialPage.value],
options: Options(responseType: ResponseType.bytes));
final temp = await getTemporaryDirectory();
SmartDialog.dismiss();
String imgName =
"plpl_pic_${DateTime.now().toString().split('-').join()}.jpg";
var path = '${temp.path}/$imgName';
File(path).writeAsBytesSync(response.data);
Share.shareXFiles([XFile(path)], subject: imgList[initialPage.value]);
}
void onChange(int index) {
initialPage.value = index;
currentPage.value = index + 1;
currentImgUrl = imgList[index];
}
}

View File

@ -1,4 +0,0 @@
library preview;
export './controller.dart';
export './view.dart';

View File

@ -1,290 +0,0 @@
// ignore_for_file: library_private_types_in_public_api
import 'dart:io';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:extended_image/extended_image.dart';
import 'package:pilipala/utils/download.dart';
import 'controller.dart';
import 'package:status_bar_control/status_bar_control.dart';
typedef DoubleClickAnimationListener = void Function();
class ImagePreview extends StatefulWidget {
final int? initialPage;
final List<String>? imgList;
const ImagePreview({
Key? key,
this.initialPage,
this.imgList,
}) : super(key: key);
@override
_ImagePreviewState createState() => _ImagePreviewState();
}
class _ImagePreviewState extends State<ImagePreview>
with TickerProviderStateMixin {
final PreviewController _previewController = Get.put(PreviewController());
// late AnimationController animationController;
late AnimationController _doubleClickAnimationController;
Animation<double>? _doubleClickAnimation;
late DoubleClickAnimationListener _doubleClickAnimationListener;
List<double> doubleTapScales = <double>[1.0, 2.0];
bool _dismissDisabled = false;
@override
void initState() {
super.initState();
_previewController.initialPage.value = widget.initialPage!;
_previewController.currentPage.value = widget.initialPage! + 1;
_previewController.imgList.value = widget.imgList!;
_previewController.currentImgUrl = widget.imgList![widget.initialPage!];
// animationController = AnimationController(
// vsync: this, duration: const Duration(milliseconds: 400));
setStatusBar();
_doubleClickAnimationController = AnimationController(
duration: const Duration(milliseconds: 250), vsync: this);
}
onOpenMenu() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
onTap: () {
_previewController.onShareImg();
Get.back();
},
dense: true,
title: const Text('分享', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Clipboard.setData(
ClipboardData(text: _previewController.currentImgUrl))
.then((value) {
Get.back();
SmartDialog.showToast('已复制到粘贴板');
}).catchError((err) {
SmartDialog.showNotify(
msg: err.toString(),
notifyType: NotifyType.error,
);
});
},
dense: true,
title: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
DownloadUtils.downloadImg(_previewController.currentImgUrl);
},
dense: true,
title: const Text('保存到手机', style: TextStyle(fontSize: 14)),
),
],
),
);
},
);
}
// 隐藏状态栏,避免遮挡图片内容
setStatusBar() async {
if (Platform.isIOS || Platform.isAndroid) {
await StatusBarControl.setHidden(true,
animation: StatusBarAnimation.SLIDE);
}
}
@override
void dispose() {
// animationController.dispose();
try {
StatusBarControl.setHidden(false, animation: StatusBarAnimation.SLIDE);
} catch (_) {}
_doubleClickAnimationController.dispose();
clearGestureDetailsCache();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
primary: false,
extendBody: true,
appBar: AppBar(
primary: false,
toolbarHeight: 0,
backgroundColor: Colors.black,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
body: Stack(
children: [
GestureDetector(
onLongPress: () => onOpenMenu(),
child: ExtendedImageGesturePageView.builder(
controller: ExtendedPageController(
initialPage: _previewController.initialPage.value,
pageSpacing: 0,
),
onPageChanged: (int index) => _previewController.onChange(index),
canScrollPage: (GestureDetails? gestureDetails) =>
gestureDetails!.totalScale! <= 1.0,
itemCount: widget.imgList!.length,
itemBuilder: (BuildContext context, int index) {
return ExtendedImage.network(
widget.imgList![index],
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
onDoubleTap: (ExtendedImageGestureState state) {
final Offset? pointerDownPosition =
state.pointerDownPosition;
final double? begin = state.gestureDetails!.totalScale;
double end;
//remove old
_doubleClickAnimation
?.removeListener(_doubleClickAnimationListener);
//stop pre
_doubleClickAnimationController.stop();
//reset to use
_doubleClickAnimationController.reset();
if (begin == doubleTapScales[0]) {
setState(() {
_dismissDisabled = true;
});
end = doubleTapScales[1];
} else {
setState(() {
_dismissDisabled = false;
});
end = doubleTapScales[0];
}
_doubleClickAnimationListener = () {
state.handleDoubleTap(
scale: _doubleClickAnimation!.value,
doubleTapPosition: pointerDownPosition);
};
_doubleClickAnimation = _doubleClickAnimationController
.drive(Tween<double>(begin: begin, end: end));
_doubleClickAnimation!
.addListener(_doubleClickAnimationListener);
_doubleClickAnimationController.forward();
},
// ignore: body_might_complete_normally_nullable
loadStateChanged: (ExtendedImageState state) {
if (state.extendedImageLoadState == LoadState.loading) {
final ImageChunkEvent? loadingProgress =
state.loadingProgress;
final double? progress =
loadingProgress?.expectedTotalBytes != null
? loadingProgress!.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 150.0,
child: LinearProgressIndicator(
value: progress,
color: Colors.white,
),
),
// const SizedBox(height: 10.0),
// Text('${((progress ?? 0.0) * 100).toInt()}%',),
],
),
);
}
},
initGestureConfigHandler: (ExtendedImageState state) {
return GestureConfig(
inPageView: true,
initialScale: 1.0,
maxScale: 5.0,
animationMaxScale: 6.0,
initialAlignment: InitialAlignment.center,
);
},
);
},
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: EdgeInsets.only(
left: 20,
right: 20,
bottom: MediaQuery.of(context).padding.bottom + 30),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.transparent,
Colors.black87,
],
tileMode: TileMode.mirror,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
widget.imgList!.length > 1
? Obx(
() => Text.rich(
textAlign: TextAlign.center,
TextSpan(
style: const TextStyle(
color: Colors.white, fontSize: 16),
children: [
TextSpan(
text: _previewController.currentPage
.toString()),
const TextSpan(text: ' / '),
TextSpan(
text:
widget.imgList!.length.toString()),
]),
),
)
: const SizedBox(),
IconButton(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, color: Colors.white),
),
],
)),
),
],
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:appscheme/appscheme.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -9,9 +10,10 @@ import 'package:pilipala/common/widgets/badge.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/common/reply_type.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/preview/index.dart';
import 'package:pilipala/pages/main/index.dart';
import 'package:pilipala/pages/video/detail/index.dart';
import 'package:pilipala/pages/video/detail/reply_new/index.dart';
import 'package:pilipala/plugin/pl_gallery/index.dart';
import 'package:pilipala/utils/app_scheme.dart';
import 'package:pilipala/utils/feed_back.dart';
import 'package:pilipala/utils/id_utils.dart';
@ -540,6 +542,53 @@ InlineSpan buildContent(
);
}
void onPreviewImg(picList, initIndex) {
final MainController mainController = Get.find<MainController>();
mainController.imgPreviewStatus = true;
Navigator.of(context).push(
HeroDialogRoute<void>(
builder: (BuildContext context) => InteractiveviewerGallery(
sources: picList,
initIndex: initIndex,
itemBuilder: (
BuildContext context,
int index,
bool isFocus,
bool enablePageView,
) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (enablePageView) {
Navigator.of(context).pop();
final MainController mainController =
Get.find<MainController>();
mainController.imgPreviewStatus = false;
}
},
child: Center(
child: Hero(
tag: picList[index],
child: CachedNetworkImage(
fadeInDuration: const Duration(milliseconds: 0),
imageUrl: picList[index],
fit: BoxFit.contain,
),
),
),
);
},
onPageChanged: (int pageIndex) {},
onDismissed: (int value) {
print('onDismissed');
final MainController mainController = Get.find<MainController>();
mainController.imgPreviewStatus = false;
},
),
),
);
}
// 分割文本并处理每个部分
content.message.splitMapJoin(
pattern,
@ -831,38 +880,33 @@ InlineSpan buildContent(
.truncateToDouble();
} catch (_) {}
return GestureDetector(
onTap: () {
showDialog(
useSafeArea: false,
context: context,
builder: (BuildContext context) {
return ImagePreview(initialPage: 0, imgList: picList);
},
);
},
child: Container(
padding: const EdgeInsets.only(top: 4),
constraints: BoxConstraints(maxHeight: maxHeight),
width: box.maxWidth / 2,
height: height,
child: Stack(
children: [
Positioned.fill(
child: NetworkImgLayer(
src: pictureItem['img_src'],
width: box.maxWidth / 2,
height: height,
return Hero(
tag: picList[0],
child: GestureDetector(
onTap: () => onPreviewImg(picList, 0),
child: Container(
padding: const EdgeInsets.only(top: 4),
constraints: BoxConstraints(maxHeight: maxHeight),
width: box.maxWidth / 2,
height: height,
child: Stack(
children: [
Positioned.fill(
child: NetworkImgLayer(
src: picList[0],
width: box.maxWidth / 2,
height: height,
),
),
),
height > Get.size.height * 0.9
? const PBadge(
text: '长图',
right: 8,
bottom: 8,
)
: const SizedBox(),
],
height > Get.size.height * 0.9
? const PBadge(
text: '长图',
right: 8,
bottom: 8,
)
: const SizedBox(),
],
),
),
),
);
@ -874,25 +918,22 @@ InlineSpan buildContent(
List<Widget> list = [];
for (var i = 0; i < len; i++) {
picList.add(content.pictures[i]['img_src']);
}
for (var i = 0; i < len; i++) {
list.add(
LayoutBuilder(
builder: (context, BoxConstraints box) {
return GestureDetector(
onTap: () {
showDialog(
useSafeArea: false,
context: context,
builder: (context) {
return ImagePreview(initialPage: i, imgList: picList);
},
);
},
child: NetworkImgLayer(
src: content.pictures[i]['img_src'],
width: box.maxWidth,
height: box.maxWidth,
origAspectRatio: content.pictures[i]['img_width'] /
content.pictures[i]['img_height']),
return Hero(
tag: picList[i],
child: GestureDetector(
onTap: () => onPreviewImg(picList, i),
child: NetworkImgLayer(
src: picList[i],
width: box.maxWidth,
height: box.maxWidth,
origAspectRatio: content.pictures[i]['img_width'] /
content.pictures[i]['img_height']),
),
);
},
),

View File

@ -15,6 +15,7 @@ import 'package:pilipala/http/user.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'package:pilipala/pages/bangumi/introduction/index.dart';
import 'package:pilipala/pages/danmaku/view.dart';
import 'package:pilipala/pages/main/index.dart';
import 'package:pilipala/pages/video/detail/reply/index.dart';
import 'package:pilipala/pages/video/detail/controller.dart';
import 'package:pilipala/pages/video/detail/introduction/index.dart';
@ -240,6 +241,11 @@ class _VideoDetailPageState extends State<VideoDetailPage>
@override
// 离开当前页面时
void didPushNext() async {
final MainController mainController = Get.find<MainController>();
if (mainController.imgPreviewStatus) {
return;
}
/// 开启
if (setting.get(SettingBoxKey.enableAutoBrightness, defaultValue: false)
as bool) {
@ -259,6 +265,11 @@ class _VideoDetailPageState extends State<VideoDetailPage>
@override
// 返回当前页面时
void didPopNext() async {
final MainController mainController = Get.find<MainController>();
if (mainController.imgPreviewStatus) {
return;
}
if (plPlayerController != null &&
plPlayerController!.videoPlayerController != null) {
setState(() {

View File

@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
/// A widget used to dismiss its [child].
///
/// Similar to [Dismissible] with some adjustments.
class CustomDismissible extends StatefulWidget {
const CustomDismissible({
required this.child,
this.onDismissed,
this.dismissThreshold = 0.2,
this.enabled = true,
Key? key,
}) : super(key: key);
final Widget child;
final double dismissThreshold;
final VoidCallback? onDismissed;
final bool enabled;
@override
State<CustomDismissible> createState() => _CustomDismissibleState();
}
class _CustomDismissibleState extends State<CustomDismissible>
with SingleTickerProviderStateMixin {
late AnimationController _animateController;
late Animation<Offset> _moveAnimation;
late Animation<double> _scaleAnimation;
late Animation<Decoration> _opacityAnimation;
double _dragExtent = 0;
bool _dragUnderway = false;
bool get _isActive => _dragUnderway || _animateController.isAnimating;
@override
void initState() {
super.initState();
_animateController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_updateMoveAnimation();
}
@override
void dispose() {
_animateController.dispose();
super.dispose();
}
void _updateMoveAnimation() {
final double end = _dragExtent.sign;
_moveAnimation = _animateController.drive(
Tween<Offset>(
begin: Offset.zero,
end: Offset(0, end),
),
);
_scaleAnimation = _animateController.drive(Tween<double>(
begin: 1,
end: 0.5,
));
_opacityAnimation = DecorationTween(
begin: const BoxDecoration(color: Color(0xFF000000)),
end: const BoxDecoration(color: Color(0x00000000)),
).animate(_animateController);
}
void _handleDragStart(DragStartDetails details) {
_dragUnderway = true;
if (_animateController.isAnimating) {
_dragExtent =
_animateController.value * context.size!.height * _dragExtent.sign;
_animateController.stop();
} else {
_dragExtent = 0.0;
_animateController.value = 0.0;
}
setState(_updateMoveAnimation);
}
void _handleDragUpdate(DragUpdateDetails details) {
if (!_isActive || _animateController.isAnimating) {
return;
}
final double delta = details.primaryDelta!;
final double oldDragExtent = _dragExtent;
if (_dragExtent + delta < 0) {
_dragExtent += delta;
} else if (_dragExtent + delta > 0) {
_dragExtent += delta;
}
if (oldDragExtent.sign != _dragExtent.sign) {
setState(_updateMoveAnimation);
}
if (!_animateController.isAnimating) {
_animateController.value = _dragExtent.abs() / context.size!.height;
}
}
void _handleDragEnd(DragEndDetails details) {
if (!_isActive || _animateController.isAnimating) {
return;
}
_dragUnderway = false;
if (_animateController.isCompleted) {
return;
}
if (!_animateController.isDismissed) {
// if the dragged value exceeded the dismissThreshold, call onDismissed
// else animate back to initial position.
if (_animateController.value > widget.dismissThreshold) {
widget.onDismissed?.call();
} else {
_animateController.reverse();
}
}
}
@override
Widget build(BuildContext context) {
final Widget content = DecoratedBoxTransition(
decoration: _opacityAnimation,
child: SlideTransition(
position: _moveAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.child,
),
),
);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: widget.enabled ? _handleDragStart : null,
onVerticalDragUpdate: widget.enabled ? _handleDragUpdate : null,
onVerticalDragEnd: widget.enabled ? _handleDragEnd : null,
child: content,
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
/// A [PageRoute] with a semi transparent background.
///
/// Similar to calling [showDialog] except it can be used with a [Navigator] to
/// show a [Hero] animation.
class HeroDialogRoute<T> extends PageRoute<T> {
HeroDialogRoute({
required this.builder,
this.onBackgroundTap,
}) : super();
final WidgetBuilder builder;
/// Called when the background is tapped.
final VoidCallback? onBackgroundTap;
@override
bool get opaque => false;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => null;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
@override
bool get maintainState => true;
@override
Color? get barrierColor => null;
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
);
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
final Widget child = builder(context);
final Widget result = Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: child,
);
return result;
}
}

View File

@ -0,0 +1,6 @@
library pl_gallery;
export './hero_dialog_route.dart';
export './custom_dismissible.dart';
export './interactiveviewer_gallery.dart';
export './interactive_viewer_boundary.dart';

View File

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
/// A callback for the [InteractiveViewerBoundary] that is called when the scale
/// changed.
typedef ScaleChanged = void Function(double scale);
/// Builds an [InteractiveViewer] and provides callbacks that are called when a
/// horizontal boundary has been hit.
///
/// The callbacks are called when an interaction ends by listening to the
/// [InteractiveViewer.onInteractionEnd] callback.
class InteractiveViewerBoundary extends StatefulWidget {
const InteractiveViewerBoundary({
required this.child,
required this.boundaryWidth,
this.controller,
this.onScaleChanged,
this.onLeftBoundaryHit,
this.onRightBoundaryHit,
this.onNoBoundaryHit,
this.maxScale,
this.minScale,
Key? key,
}) : super(key: key);
final Widget child;
/// The max width this widget can have.
///
/// If the [InteractiveViewer] can take up the entire screen width, this
/// should be set to `MediaQuery.of(context).size.width`.
final double boundaryWidth;
/// The [TransformationController] for the [InteractiveViewer].
final TransformationController? controller;
/// Called when the scale changed after an interaction ended.
final ScaleChanged? onScaleChanged;
/// Called when the left boundary has been hit after an interaction ended.
final VoidCallback? onLeftBoundaryHit;
/// Called when the right boundary has been hit after an interaction ended.
final VoidCallback? onRightBoundaryHit;
/// Called when no boundary has been hit after an interaction ended.
final VoidCallback? onNoBoundaryHit;
final double? maxScale;
final double? minScale;
@override
InteractiveViewerBoundaryState createState() =>
InteractiveViewerBoundaryState();
}
class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary> {
TransformationController? _controller;
double? _scale;
@override
void initState() {
super.initState();
_controller = widget.controller ?? TransformationController();
}
@override
void dispose() {
_controller!.dispose();
super.dispose();
}
void _updateBoundaryDetection() {
final double scale = _controller!.value.row0[0];
if (_scale != scale) {
// the scale changed
_scale = scale;
widget.onScaleChanged?.call(scale);
}
if (scale <= 1.01) {
// cant hit any boundaries when the child is not scaled
return;
}
final double xOffset = _controller!.value.row0[3];
final double boundaryWidth = widget.boundaryWidth;
final double boundaryEnd = boundaryWidth * scale;
final double xPos = boundaryEnd + xOffset;
if (boundaryEnd.round() == xPos.round()) {
// left boundary hit
widget.onLeftBoundaryHit?.call();
} else if (boundaryWidth.round() == xPos.round()) {
// right boundary hit
widget.onRightBoundaryHit?.call();
} else {
widget.onNoBoundaryHit?.call();
}
}
@override
Widget build(BuildContext context) {
return InteractiveViewer(
maxScale: widget.maxScale!,
minScale: widget.minScale!,
transformationController: _controller,
onInteractionEnd: (_) => _updateBoundaryDetection(),
child: widget.child,
);
}
}

View File

@ -0,0 +1,399 @@
library interactiveviewer_gallery;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pilipala/utils/download.dart';
import 'package:share_plus/share_plus.dart';
import 'package:status_bar_control/status_bar_control.dart';
import 'custom_dismissible.dart';
import 'interactive_viewer_boundary.dart';
/// Builds a carousel controlled by a [PageView] for the tweet media sources.
///
/// Used for showing a full screen view of the [TweetMedia] sources.
///
/// The sources can be panned and zoomed interactively using an
/// [InteractiveViewer].
/// An [InteractiveViewerBoundary] is used to detect when the boundary of the
/// source is hit after zooming in to disable or enable the swiping gesture of
/// the [PageView].
///
typedef IndexedFocusedWidgetBuilder = Widget Function(
BuildContext context, int index, bool isFocus, bool enablePageView);
typedef IndexedTagStringBuilder = String Function(int index);
class InteractiveviewerGallery<T> extends StatefulWidget {
const InteractiveviewerGallery({
required this.sources,
required this.initIndex,
required this.itemBuilder,
this.maxScale = 4.5,
this.minScale = 1.0,
this.onPageChanged,
this.onDismissed,
Key? key,
}) : super(key: key);
/// The sources to show.
final List<T> sources;
/// The index of the first source in [sources] to show.
final int initIndex;
/// The item content
final IndexedFocusedWidgetBuilder itemBuilder;
final double maxScale;
final double minScale;
final ValueChanged<int>? onPageChanged;
final ValueChanged<int>? onDismissed;
@override
State<InteractiveviewerGallery> createState() =>
_InteractiveviewerGalleryState();
}
class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
with SingleTickerProviderStateMixin {
PageController? _pageController;
TransformationController? _transformationController;
/// The controller to animate the transformation value of the
/// [InteractiveViewer] when it should reset.
late AnimationController _animationController;
Animation<Matrix4>? _animation;
/// `true` when an source is zoomed in and not at the at a horizontal boundary
/// to disable the [PageView].
bool _enablePageView = true;
/// `true` when an source is zoomed in to disable the [CustomDismissible].
bool _enableDismiss = true;
late Offset _doubleTapLocalPosition;
int? currentIndex;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: widget.initIndex);
_transformationController = TransformationController();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)
..addListener(() {
_transformationController!.value =
_animation?.value ?? Matrix4.identity();
})
..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed && !_enableDismiss) {
setState(() {
_enableDismiss = true;
});
}
});
currentIndex = widget.initIndex;
setStatusBar();
}
setStatusBar() async {
if (Platform.isIOS || Platform.isAndroid) {
await StatusBarControl.setHidden(true,
animation: StatusBarAnimation.FADE);
}
}
@override
void dispose() {
_pageController!.dispose();
_animationController.dispose();
try {
StatusBarControl.setHidden(false, animation: StatusBarAnimation.FADE);
} catch (_) {}
super.dispose();
}
/// When the source gets scaled up, the swipe up / down to dismiss gets
/// disabled.
///
/// When the scale resets, the dismiss and the page view swiping gets enabled.
void _onScaleChanged(double scale) {
final bool initialScale = scale <= widget.minScale;
if (initialScale) {
if (!_enableDismiss) {
setState(() {
_enableDismiss = true;
});
}
if (!_enablePageView) {
setState(() {
_enablePageView = true;
});
}
} else {
if (_enableDismiss) {
setState(() {
_enableDismiss = false;
});
}
if (_enablePageView) {
setState(() {
_enablePageView = false;
});
}
}
}
/// When the left boundary has been hit after scaling up the source, the page
/// view swiping gets enabled if it has a page to swipe to.
void _onLeftBoundaryHit() {
if (!_enablePageView && _pageController!.page!.floor() > 0) {
setState(() {
_enablePageView = true;
});
}
}
/// When the right boundary has been hit after scaling up the source, the page
/// view swiping gets enabled if it has a page to swipe to.
void _onRightBoundaryHit() {
if (!_enablePageView &&
_pageController!.page!.floor() < widget.sources.length - 1) {
setState(() {
_enablePageView = true;
});
}
}
/// When the source has been scaled up and no horizontal boundary has been hit,
/// the page view swiping gets disabled.
void _onNoBoundaryHit() {
if (_enablePageView) {
setState(() {
_enablePageView = false;
});
}
}
/// When the page view changed its page, the source will animate back into the
/// original scale if it was scaled up.
///
/// Additionally the swipe up / down to dismiss gets enabled.
void _onPageChanged(int page) {
setState(() {
currentIndex = page;
});
widget.onPageChanged?.call(page);
if (_transformationController!.value != Matrix4.identity()) {
// animate the reset for the transformation of the interactive viewer
_animation = Matrix4Tween(
begin: _transformationController!.value,
end: Matrix4.identity(),
).animate(
CurveTween(curve: Curves.easeOut).animate(_animationController),
);
_animationController.forward(from: 0);
}
}
@override
Widget build(BuildContext context) {
return InteractiveViewerBoundary(
controller: _transformationController,
boundaryWidth: MediaQuery.of(context).size.width,
onScaleChanged: _onScaleChanged,
onLeftBoundaryHit: _onLeftBoundaryHit,
onRightBoundaryHit: _onRightBoundaryHit,
onNoBoundaryHit: _onNoBoundaryHit,
maxScale: widget.maxScale,
minScale: widget.minScale,
child: Stack(children: [
CustomDismissible(
onDismissed: () {
Navigator.of(context).pop();
widget.onDismissed?.call(_pageController!.page!.floor());
},
enabled: _enableDismiss,
child: PageView.builder(
onPageChanged: _onPageChanged,
controller: _pageController,
physics:
_enablePageView ? null : const NeverScrollableScrollPhysics(),
itemCount: widget.sources.length,
itemBuilder: (BuildContext context, int index) {
return GestureDetector(
onDoubleTapDown: (TapDownDetails details) {
_doubleTapLocalPosition = details.localPosition;
},
onDoubleTap: onDoubleTap,
child: widget.itemBuilder(
context,
index,
index == currentIndex,
_enablePageView,
),
);
},
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.fromLTRB(
12, 8, 20, MediaQuery.of(context).padding.bottom + 8),
decoration: _enablePageView
? BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3)
],
),
)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () {
Navigator.of(context).pop();
widget.onDismissed?.call(_pageController!.page!.floor());
},
),
widget.sources.length > 1
? Text(
"${currentIndex! + 1}/${widget.sources.length}",
style: const TextStyle(color: Colors.white),
)
: const SizedBox(),
PopupMenuButton(
itemBuilder: (context) {
return [
PopupMenuItem(
value: 0,
onTap: () => onShareImg(widget.sources[currentIndex!]),
child: const Text("分享图片"),
),
PopupMenuItem(
value: 1,
onTap: () {
Clipboard.setData(ClipboardData(
text:
widget.sources[currentIndex!].toString()))
.then((value) {
SmartDialog.showToast('已复制到粘贴板');
}).catchError((err) {
SmartDialog.showNotify(
msg: err.toString(),
notifyType: NotifyType.error,
);
});
},
child: const Text("复制图片"),
),
PopupMenuItem(
value: 2,
onTap: () {
DownloadUtils.downloadImg(
widget.sources[currentIndex!]);
},
child: const Text("保存图片"),
),
];
},
child: const Icon(Icons.more_horiz, color: Colors.white),
),
],
),
),
),
]),
);
}
// 图片分享
void onShareImg(String imgUrl) async {
SmartDialog.showLoading();
var response = await Dio()
.get(imgUrl, options: Options(responseType: ResponseType.bytes));
final temp = await getTemporaryDirectory();
SmartDialog.dismiss();
String imgName =
"plpl_pic_${DateTime.now().toString().split('-').join()}.jpg";
var path = '${temp.path}/$imgName';
File(path).writeAsBytesSync(response.data);
Share.shareXFiles([XFile(path)], subject: imgUrl);
}
onDoubleTap() {
Matrix4 matrix = _transformationController!.value.clone();
double currentScale = matrix.row0.x;
double targetScale = widget.minScale;
if (currentScale <= widget.minScale) {
targetScale = widget.maxScale * 0.7;
}
double offSetX = targetScale == 1.0
? 0.0
: -_doubleTapLocalPosition.dx * (targetScale - 1);
double offSetY = targetScale == 1.0
? 0.0
: -_doubleTapLocalPosition.dy * (targetScale - 1);
matrix = Matrix4.fromList([
targetScale,
matrix.row1.x,
matrix.row2.x,
matrix.row3.x,
matrix.row0.y,
targetScale,
matrix.row2.y,
matrix.row3.y,
matrix.row0.z,
matrix.row1.z,
targetScale,
matrix.row3.z,
offSetX,
offSetY,
matrix.row2.w,
matrix.row3.w
]);
_animation = Matrix4Tween(
begin: _transformationController!.value,
end: matrix,
).animate(
CurveTween(curve: Curves.easeOut).animate(_animationController),
);
_animationController
.forward(from: 0)
.whenComplete(() => _onScaleChanged(targetScale));
}
}

View File

@ -70,14 +70,6 @@ class Routes {
CustomGetPage(name: '/hot', page: () => const HotPage()),
// 视频详情
CustomGetPage(name: '/video', page: () => const VideoDetailPage()),
// 图片预览
// GetPage(
// name: '/preview',
// page: () => const ImagePreview(),
// transition: Transition.fade,
// transitionDuration: const Duration(milliseconds: 300),
// showCupertinoParallax: false,
// ),
//
CustomGetPage(name: '/webview', page: () => const WebviewPage()),
// 设置