diff --git a/lib/common/widgets/image/custom_grid_view.dart b/lib/common/widgets/image/custom_grid_view.dart deleted file mode 100644 index 0791e71b0..000000000 --- a/lib/common/widgets/image/custom_grid_view.dart +++ /dev/null @@ -1,544 +0,0 @@ -/* - * This file is part of PiliPlus - * - * PiliPlus is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * PiliPlus is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with PiliPlus. If not, see . - */ - -import 'dart:io' show Platform; -import 'dart:math' show min; - -import 'package:PiliPlus/common/constants.dart'; -import 'package:PiliPlus/common/widgets/badge.dart'; -import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; -import 'package:PiliPlus/models/common/badge_type.dart'; -import 'package:PiliPlus/models/common/image_preview_type.dart'; -import 'package:PiliPlus/utils/extension/context_ext.dart'; -import 'package:PiliPlus/utils/extension/num_ext.dart'; -import 'package:PiliPlus/utils/extension/size_ext.dart'; -import 'package:PiliPlus/utils/image_utils.dart'; -import 'package:PiliPlus/utils/page_utils.dart'; -import 'package:PiliPlus/utils/platform_utils.dart'; -import 'package:PiliPlus/utils/storage_pref.dart'; -import 'package:flutter/gestures.dart' - show TapGestureRecognizer, LongPressGestureRecognizer; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' - show - ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin, - MultiChildLayoutParentData, - BoxHitTestResult, - BoxHitTestEntry; -import 'package:flutter/services.dart' show HapticFeedback; -import 'package:get/get_core/src/get_main.dart'; -import 'package:get/get_navigation/get_navigation.dart'; - -class ImageModel { - ImageModel({ - required num? width, - required num? height, - required this.url, - this.liveUrl, - }) { - this.width = width == null || width == 0 ? 1 : width; - this.height = height == null || height == 0 ? 1 : height; - } - - late num width; - late num height; - String url; - String? liveUrl; - bool? _isLongPic; - bool? _isLivePhoto; - - bool get isLongPic => - _isLongPic ??= (height / width) > StyleString.imgMaxRatio; - bool get isLivePhoto => - _isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true; - - static bool enableLivePhoto = Pref.enableLivePhoto; -} - -class CustomGridView extends StatelessWidget { - const CustomGridView({ - super.key, - this.space = 5, - required this.maxWidth, - required this.picArr, - this.onViewImage, - this.fullScreen = false, - }); - - final double maxWidth; - final double space; - final List picArr; - final VoidCallback? onViewImage; - final bool fullScreen; - - static bool horizontalPreview = Pref.horizontalPreview; - static const _routes = ['/videoV', '/dynamicDetail']; - - void onTap(BuildContext context, int index) { - final imgList = picArr.map( - (item) { - bool isLive = item.isLivePhoto; - return SourceModel( - sourceType: isLive ? SourceType.livePhoto : SourceType.networkImage, - url: item.url, - liveUrl: isLive ? item.liveUrl : null, - width: isLive ? item.width.toInt() : null, - height: isLive ? item.height.toInt() : null, - isLongPic: item.isLongPic, - ); - }, - ).toList(); - if (horizontalPreview && - !fullScreen && - _routes.contains(Get.currentRoute) && - !context.mediaQuerySize.isPortrait) { - final scaffoldState = Scaffold.maybeOf(context); - if (scaffoldState != null) { - onViewImage?.call(); - PageUtils.onHorizontalPreviewState( - scaffoldState, - imgList, - index, - ); - return; - } - } - PageUtils.imageView( - initialPage: index, - imgList: imgList, - ); - } - - static BorderRadius _borderRadius( - int col, - int length, - int index, { - Radius r = StyleString.imgRadius, - }) { - if (length == 1) return StyleString.mdRadius; - - final bool hasUp = index - col >= 0; - final bool hasDown = index + col < length; - - final bool isRowStart = (index % col) == 0; - final bool isRowEnd = (index % col) == col - 1 || index == length - 1; - - final bool hasLeft = !isRowStart; - final bool hasRight = !isRowEnd && (index + 1) < length; - - return BorderRadius.only( - topLeft: !hasUp && !hasLeft ? r : Radius.zero, - topRight: !hasUp && !hasRight ? r : Radius.zero, - bottomLeft: !hasDown && !hasLeft ? r : Radius.zero, - bottomRight: !hasDown && !hasRight ? r : Radius.zero, - ); - } - - static bool enableImgMenu = Pref.enableImgMenu; - - void _showMenu(BuildContext context, int index, Offset offset) { - HapticFeedback.mediumImpact(); - final item = picArr[index]; - showMenu( - context: context, - position: PageUtils.menuPosition(offset), - items: [ - if (PlatformUtils.isMobile) - PopupMenuItem( - height: 42, - onTap: () => ImageUtils.onShareImg(item.url), - child: const Text('分享', style: TextStyle(fontSize: 14)), - ), - PopupMenuItem( - height: 42, - onTap: () => ImageUtils.downloadImg([item.url]), - child: const Text('保存图片', style: TextStyle(fontSize: 14)), - ), - if (PlatformUtils.isDesktop) - PopupMenuItem( - height: 42, - onTap: () => PageUtils.launchURL(item.url), - child: const Text('网页打开', style: TextStyle(fontSize: 14)), - ) - else if (picArr.length > 1) - PopupMenuItem( - height: 42, - onTap: () => - ImageUtils.downloadImg(picArr.map((item) => item.url).toList()), - child: const Text('保存全部', style: TextStyle(fontSize: 14)), - ), - if (item.isLivePhoto) - PopupMenuItem( - height: 42, - onTap: () => ImageUtils.downloadLivePhoto( - url: item.url, - liveUrl: item.liveUrl!, - width: item.width.toInt(), - height: item.height.toInt(), - ), - child: Text( - '保存${Platform.isIOS ? '实况' : '视频'}', - style: const TextStyle(fontSize: 14), - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - double imageWidth; - double imageHeight; - final length = picArr.length; - final isSingle = length == 1; - final isFour = length == 4; - if (length == 2) { - imageWidth = imageHeight = (maxWidth - space) / 2; - } else { - imageHeight = imageWidth = (maxWidth - 2 * space) / 3; - if (isSingle) { - final img = picArr.first; - final width = img.width; - final height = img.height; - final ratioWH = width / height; - final ratioHW = height / width; - imageWidth = ratioWH > 1.5 - ? maxWidth - : (ratioWH >= 1 || (height > width && ratioHW < 1.5)) - ? 2 * imageWidth - : 1.5 * imageWidth; - if (width != 1) { - imageWidth = min(imageWidth, width.toDouble()); - } - imageHeight = imageWidth * min(ratioHW, StyleString.imgMaxRatio); - } - } - - final int column = isFour ? 2 : 3; - final int row = isFour ? 2 : (length / 3).ceil(); - late final placeHolder = Container( - width: imageWidth, - height: imageHeight, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onInverseSurface.withValues(alpha: 0.4), - ), - child: Image.asset( - 'assets/images/loading.png', - width: imageWidth, - height: imageHeight, - cacheWidth: imageWidth.cacheSize(context), - ), - ); - - return Padding( - padding: const EdgeInsets.only(top: 6), - child: SizedBox( - width: maxWidth, - height: imageHeight * row + space * (row - 1), - child: ImageGrid( - space: space, - column: column, - width: imageWidth, - height: imageHeight, - onTap: (index) => onTap(context, index), - onSecondaryTapUp: enableImgMenu && PlatformUtils.isDesktop - ? (index, offset) => _showMenu(context, index, offset) - : null, - onLongPressStart: enableImgMenu && PlatformUtils.isMobile - ? (index, offset) => _showMenu(context, index, offset) - : null, - children: List.generate(length, (index) { - final item = picArr[index]; - final borderRadius = _borderRadius(column, length, index); - Widget child = Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - NetworkImgLayer( - src: item.url, - width: imageWidth, - height: imageHeight, - borderRadius: borderRadius, - alignment: item.isLongPic ? .topCenter : .center, - cacheWidth: item.width <= item.height, - getPlaceHolder: () => placeHolder, - ), - if (item.isLivePhoto) - const PBadge( - text: 'Live', - right: 8, - bottom: 8, - type: PBadgeType.gray, - ) - else if (item.isLongPic) - const PBadge( - text: '长图', - right: 8, - bottom: 8, - ), - ], - ); - if (!item.isLongPic) { - child = Hero( - tag: item.url, - child: child, - ); - } - return LayoutId( - id: index, - child: child, - ); - }), - ), - ), - ); - } -} - -class ImageGrid extends MultiChildRenderObjectWidget { - const ImageGrid({ - super.key, - super.children, - required this.space, - required this.column, - required this.width, - required this.height, - required this.onTap, - required this.onSecondaryTapUp, - required this.onLongPressStart, - }); - - final double space; - final int column; - final double width; - final double height; - final ValueChanged onTap; - final OnShowMenu? onSecondaryTapUp; - final OnShowMenu? onLongPressStart; - - @override - RenderObject createRenderObject(BuildContext context) { - return RenderImageGrid( - space: space, - column: column, - width: width, - height: height, - onTap: onTap, - onSecondaryTapUp: onSecondaryTapUp, - onLongPressStart: onLongPressStart, - ); - } - - @override - void updateRenderObject(BuildContext context, RenderImageGrid renderObject) { - renderObject - ..space = space - ..column = column - ..width = width - ..height = height - ..onTap = onTap - ..onSecondaryTapUp = onSecondaryTapUp - ..onLongPressStart = onLongPressStart; - } -} - -typedef OnShowMenu = Function(int index, Offset offset); - -class RenderImageGrid extends RenderBox - with - ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin { - RenderImageGrid({ - required double space, - required int column, - required double width, - required double height, - required ValueChanged onTap, - required OnShowMenu? onSecondaryTapUp, - required OnShowMenu? onLongPressStart, - }) : _space = space, - _column = column, - _width = width, - _height = height, - _onTap = onTap, - _onSecondaryTapUp = onSecondaryTapUp, - _onLongPressStart = onLongPressStart { - _tapGestureRecognizer = TapGestureRecognizer()..onTap = _handleOnTap; - if (onSecondaryTapUp != null) { - _tapGestureRecognizer.onSecondaryTapUp = _handleSecondaryTapUp; - } - if (onLongPressStart != null) { - _longPressGestureRecognizer = LongPressGestureRecognizer() - ..onLongPressStart = _handleLongPressStart; - } - } - - ValueChanged _onTap; - set onTap(ValueChanged value) { - _onTap = value; - } - - OnShowMenu? _onSecondaryTapUp; - set onSecondaryTapUp(OnShowMenu? value) { - _onSecondaryTapUp = value; - } - - OnShowMenu? _onLongPressStart; - set onLongPressStart(OnShowMenu? value) { - _onLongPressStart = value; - } - - int? _index; - - void _handleOnTap() { - _onTap(_index!); - } - - void _handleSecondaryTapUp(TapUpDetails details) { - _onSecondaryTapUp!(_index!, details.globalPosition); - } - - void _handleLongPressStart(LongPressStartDetails details) { - _onLongPressStart!(_index!, details.globalPosition); - } - - double _space; - double get space => _space; - set space(double value) { - if (_space == value) return; - _space = value; - markNeedsLayout(); - } - - int _column; - int get column => _column; - set column(int value) { - if (_column == value) return; - _column = value; - markNeedsLayout(); - } - - double _width; - double get width => _width; - set width(double value) { - if (_width == value) return; - _width = value; - markNeedsLayout(); - } - - double _height; - double get height => _height; - set height(double value) { - if (_height == value) return; - _height = value; - markNeedsLayout(); - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! MultiChildLayoutParentData) { - child.parentData = MultiChildLayoutParentData(); - } - } - - @override - void performLayout() { - size = constraints.constrain(constraints.biggest); - - final itemConstraints = BoxConstraints( - minWidth: width, - maxWidth: width, - minHeight: height, - maxHeight: height, - ); - RenderBox? child = firstChild; - while (child != null) { - final childParentData = child.parentData as MultiChildLayoutParentData; - final index = childParentData.id as int; - child.layout(itemConstraints); - childParentData.offset = Offset( - (space + width) * (index % column), - (space + height) * (index ~/ column), - ); - child = childParentData.nextSibling; - } - } - - @override - void paint(PaintingContext context, Offset offset) { - defaultPaint(context, offset); - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - RenderBox? child = lastChild; - while (child != null) { - final childParentData = child.parentData as MultiChildLayoutParentData; - final bool isHit = result.addWithPaintOffset( - offset: childParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - childParentData.offset); - if (child!.size.contains(transformed)) { - result.add(BoxHitTestEntry(child, transformed)); - return true; - } - return false; - }, - ); - if (isHit) { - _index = childParentData.id as int; - return true; - } - child = childParentData.previousSibling; - } - _index = null; - return false; - } - - @override - void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - if (event is PointerDownEvent) { - _tapGestureRecognizer.addPointer(event); - _longPressGestureRecognizer?.addPointer(event); - } - } - - late final TapGestureRecognizer _tapGestureRecognizer; - LongPressGestureRecognizer? _longPressGestureRecognizer; - - @override - void dispose() { - _tapGestureRecognizer - ..onTap = null - ..onSecondaryTapUp = null - ..dispose(); - _longPressGestureRecognizer - ?..onLongPressStart = null - ..dispose(); - _longPressGestureRecognizer = null; - _onSecondaryTapUp = null; - _onLongPressStart = null; - super.dispose(); - } - - @override - bool get isRepaintBoundary => true; // gif repaint -} diff --git a/lib/common/widgets/image_grid/image_grid_builder.dart b/lib/common/widgets/image_grid/image_grid_builder.dart new file mode 100644 index 000000000..3203c89b9 --- /dev/null +++ b/lib/common/widgets/image_grid/image_grid_builder.dart @@ -0,0 +1,634 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:collection' show HashSet; +import 'dart:math' as math; + +import 'package:PiliPlus/common/constants.dart' show StyleString; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart' + show ImageModel; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/gestures.dart' + show TapGestureRecognizer, LongPressGestureRecognizer; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' + show + ContainerRenderObjectMixin, + MultiChildLayoutParentData, + RenderBoxContainerDefaultsMixin, + RenderObjectWithLayoutCallbackMixin, + Constraints, + LayoutCallback, + BoxHitTestResult, + BoxHitTestEntry, + ContainerParentDataMixin, + InformationCollector, + DiagnosticsDebugCreator; +import 'package:flutter/scheduler.dart'; + +/// ref [LayoutBuilder] + +const space = 5.0; +typedef ImageGridInfo = ({int column, int row, Size size}); + +class ImageGridBuilder extends RenderObjectWidget { + const ImageGridBuilder({ + super.key, + required this.picArr, + required this.onTap, + required this.onSecondaryTapUp, + required this.onLongPressStart, + required this.builder, + }); + + final List picArr; + final ValueChanged onTap; + final OnShowMenu? onSecondaryTapUp; + final OnShowMenu? onLongPressStart; + final List Function(BuildContext context, ImageGridInfo imageGridInfo) + builder; + + @protected + bool updateShouldRebuild(ImageGridBuilder oldWidget) => true; + + @override + ImageGridRenderObjectElement createElement() => + ImageGridRenderObjectElement(this); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderImageGrid( + onTap: onTap, + onSecondaryTapUp: onSecondaryTapUp, + onLongPressStart: onLongPressStart, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderImageGrid renderObject) { + renderObject + ..onTap = onTap + ..onSecondaryTapUp = onSecondaryTapUp + ..onLongPressStart = onLongPressStart; + } +} + +typedef OnShowMenu = Function(int index, Offset offset); + +class RenderImageGrid extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin, + RenderObjectWithLayoutCallbackMixin { + RenderImageGrid({ + required ValueChanged onTap, + required OnShowMenu? onSecondaryTapUp, + required OnShowMenu? onLongPressStart, + }) : _onTap = onTap, + _onSecondaryTapUp = onSecondaryTapUp, + _onLongPressStart = onLongPressStart { + _tapGestureRecognizer = TapGestureRecognizer()..onTap = _handleOnTap; + if (onSecondaryTapUp != null) { + _tapGestureRecognizer.onSecondaryTapUp = _handleSecondaryTapUp; + } + if (onLongPressStart != null) { + _longPressGestureRecognizer = LongPressGestureRecognizer() + ..onLongPressStart = _handleLongPressStart; + } + } + + ValueChanged _onTap; + set onTap(ValueChanged value) { + _onTap = value; + } + + OnShowMenu? _onSecondaryTapUp; + set onSecondaryTapUp(OnShowMenu? value) { + _onSecondaryTapUp = value; + } + + OnShowMenu? _onLongPressStart; + set onLongPressStart(OnShowMenu? value) { + _onLongPressStart = value; + } + + int? _index; + + void _handleOnTap() { + _onTap(_index!); + } + + void _handleSecondaryTapUp(TapUpDetails details) { + _onSecondaryTapUp!(_index!, details.globalPosition); + } + + void _handleLongPressStart(LongPressStartDetails details) { + _onLongPressStart!(_index!, details.globalPosition); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! MultiChildLayoutParentData) { + child.parentData = MultiChildLayoutParentData(); + } + } + + ImageGridInfo? imageGridInfo; + LayoutCallback? _callback; + + void _updateCallback(LayoutCallback value) { + if (value == _callback) { + return; + } + _callback = value; + scheduleLayoutCallback(); + } + + @override + void layoutCallback() => _callback!(constraints); + + @protected + BoxConstraints get layoutInfo => constraints; + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + runLayoutCallback(); + final info = imageGridInfo!; + final row = info.row; + final column = info.column; + final width = info.size.width; + final height = info.size.height; + final childConstraints = BoxConstraints.tightFor( + width: width, + height: height, + ); + RenderBox? child = firstChild; + while (child != null) { + child.layout(childConstraints); + final childParentData = child.parentData as MultiChildLayoutParentData; + final index = childParentData.id as int; + childParentData.offset = Offset( + (space + width) * (index % column), + (space + height) * (index ~/ column), + ); + child = childParentData.nextSibling; + } + size = constraints.constrainDimensions( + width * column + space * (column - 1), + height * row + space * (row - 1), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? child = lastChild; + while (child != null) { + final childParentData = child.parentData as MultiChildLayoutParentData; + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + if (child!.size.contains(transformed)) { + result.add(BoxHitTestEntry(child, transformed)); + return true; + } + return false; + }, + ); + if (isHit) { + _index = childParentData.id as int; + return true; + } + child = childParentData.previousSibling; + } + _index = null; + return false; + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + if (event is PointerDownEvent) { + _tapGestureRecognizer.addPointer(event); + _longPressGestureRecognizer?.addPointer(event); + } + } + + late final TapGestureRecognizer _tapGestureRecognizer; + LongPressGestureRecognizer? _longPressGestureRecognizer; + + @override + void dispose() { + _tapGestureRecognizer + ..onTap = null + ..onSecondaryTapUp = null + ..dispose(); + _longPressGestureRecognizer + ?..onLongPressStart = null + ..dispose(); + _longPressGestureRecognizer = null; + _onSecondaryTapUp = null; + _onLongPressStart = null; + super.dispose(); + } + + @override + bool get isRepaintBoundary => true; // gif repaint +} + +class ImageGridRenderObjectElement extends RenderObjectElement { + ImageGridRenderObjectElement(ImageGridBuilder super.widget); + + @override + RenderImageGrid get renderObject { + return super.renderObject as RenderImageGrid; + } + + @protected + @visibleForTesting + Iterable get children => + _children!.where((Element child) => !_forgottenChildren.contains(child)); + + List? _children; + // We keep a set of forgotten children to avoid O(n^2) work walking _children + // repeatedly to remove children. + final Set _forgottenChildren = HashSet(); + + @override + BuildScope get buildScope => _buildScope; + + late final BuildScope _buildScope = BuildScope( + scheduleRebuild: _scheduleRebuild, + ); + + bool _deferredCallbackScheduled = false; + void _scheduleRebuild() { + if (_deferredCallbackScheduled) { + return; + } + + final bool deferMarkNeedsLayout = + switch (SchedulerBinding.instance.schedulerPhase) { + SchedulerPhase.idle || SchedulerPhase.postFrameCallbacks => true, + SchedulerPhase.transientCallbacks || + SchedulerPhase.midFrameMicrotasks || + SchedulerPhase.persistentCallbacks => false, + }; + if (!deferMarkNeedsLayout) { + renderObject.scheduleLayoutCallback(); + return; + } + _deferredCallbackScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback(_frameCallback); + } + + void _frameCallback(Duration timestamp) { + _deferredCallbackScheduled = false; + // This method is only called when the render tree is stable, if the Element + // is deactivated it will never be reincorporated back to the tree. + if (mounted) { + renderObject.scheduleLayoutCallback(); + } + } + + @override + void insertRenderObjectChild(RenderObject child, IndexedSlot slot) { + final ContainerRenderObjectMixin< + RenderObject, + ContainerParentDataMixin + > + renderObject = this.renderObject; + assert(renderObject.debugValidateChild(child)); + renderObject.insert(child, after: slot.value?.renderObject); + assert(renderObject == this.renderObject); + } + + @override + void moveRenderObjectChild( + RenderObject child, + IndexedSlot oldSlot, + IndexedSlot newSlot, + ) { + final ContainerRenderObjectMixin< + RenderObject, + ContainerParentDataMixin + > + renderObject = this.renderObject; + assert(child.parent == renderObject); + renderObject.move(child, after: newSlot.value?.renderObject); + assert(renderObject == this.renderObject); + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + final ContainerRenderObjectMixin< + RenderObject, + ContainerParentDataMixin + > + renderObject = this.renderObject; + assert(child.parent == renderObject); + renderObject.remove(child); + assert(renderObject == this.renderObject); + } + + @override + void visitChildren(ElementVisitor visitor) { + if (_children == null) return; + for (final Element child in _children!) { + if (!_forgottenChildren.contains(child)) { + visitor(child); + } + } + } + + @override + void forgetChild(Element child) { + if (_children == null) return; + assert(_children!.contains(child)); + assert(!_forgottenChildren.contains(child)); + _forgottenChildren.add(child); + super.forgetChild(child); + } + + bool _debugCheckHasAssociatedRenderObject(Element newChild) { + assert(() { + if (newChild.renderObject == null) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError.fromParts([ + ErrorSummary( + 'The children of `MultiChildRenderObjectElement` must each has an associated render object.', + ), + ErrorHint( + 'This typically means that the `${newChild.widget}` or its children\n' + 'are not a subtype of `RenderObjectWidget`.', + ), + newChild.describeElement( + 'The following element does not have an associated render object', + ), + DiagnosticsDebugCreator(DebugCreator(newChild)), + ]), + ), + ); + } + return true; + }()); + return true; + } + + @override + Element inflateWidget(Widget newWidget, Object? newSlot) { + final Element newChild = super.inflateWidget(newWidget, newSlot); + assert(_debugCheckHasAssociatedRenderObject(newChild)); + return newChild; + } + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + renderObject._updateCallback(_rebuildWithConstraints); + // final multiChildRenderObjectWidget = widget as MultiChildRenderObjectWidget; + // final children = List.filled( + // multiChildRenderObjectWidget.children.length, + // _NullElement.instance, + // ); + // Element? previousChild; + // for (var i = 0; i < children.length; i += 1) { + // final Element newChild = inflateWidget( + // multiChildRenderObjectWidget.children[i], + // IndexedSlot(i, previousChild), + // ); + // children[i] = newChild; + // previousChild = newChild; + // } + // _children = children; + } + + @override + void update(ImageGridBuilder newWidget) { + super.update(newWidget); + final multiChildRenderObjectWidget = widget as ImageGridBuilder; + assert(widget == newWidget); + // _children = updateChildren( + // _children, + // multiChildRenderObjectWidget.children, + // forgottenChildren: _forgottenChildren, + // ); + // _forgottenChildren.clear(); + renderObject._updateCallback(_rebuildWithConstraints); + if (newWidget.updateShouldRebuild(multiChildRenderObjectWidget)) { + _needsBuild = true; + renderObject.scheduleLayoutCallback(); + } + } + + @override + void markNeedsBuild() { + // Calling super.markNeedsBuild is not needed. This Element does not need + // to performRebuild since this call already does what performRebuild does, + // So the element is clean as soon as this method returns and does not have + // to be added to the dirty list or marked as dirty. + renderObject.scheduleLayoutCallback(); + _needsBuild = true; + } + + @override + void performRebuild() { + // This gets called if markNeedsBuild() is called on us. + // That might happen if, e.g., our builder uses Inherited widgets. + + // Force the callback to be called, even if the layout constraints are the + // same. This is because that callback may depend on the updated widget + // configuration, or an inherited widget. + renderObject.scheduleLayoutCallback(); + _needsBuild = true; + super + .performRebuild(); // Calls widget.updateRenderObject (a no-op in this case). + } + + @override + void unmount() { + renderObject._callback = null; + super.unmount(); + } + + // The LayoutInfoType that was used to invoke the layout callback with last time, + // during layout. The `_previousLayoutInfo` value is compared to the new one + // to determine whether [LayoutBuilderBase.builder] needs to be called. + BoxConstraints? _previousLayoutInfo; + bool _needsBuild = true; + + static ImageGridInfo _calcGridInfo( + List picArr, + BoxConstraints layoutInfo, + ) { + final maxWidth = layoutInfo.maxWidth; + double imageWidth; + double imageHeight; + final length = picArr.length; + final isSingle = length == 1; + final isFour = length == 4; + if (length == 2) { + imageWidth = imageHeight = (maxWidth - space) / 2; + } else { + imageHeight = imageWidth = (maxWidth - 2 * space) / 3; + if (isSingle) { + final img = picArr.first; + final width = img.width; + final height = img.height; + final ratioWH = width / height; + final ratioHW = height / width; + imageWidth = ratioWH > 1.5 + ? maxWidth + : (ratioWH >= 1 || (height > width && ratioHW < 1.5)) + ? 2 * imageWidth + : 1.5 * imageWidth; + if (width != 1) { + imageWidth = math.min(imageWidth, width.toDouble()); + } + imageHeight = imageWidth * math.min(ratioHW, StyleString.imgMaxRatio); + } + } + + final int column = isFour ? 2 : 3; + final int row = isFour ? 2 : (length / 3).ceil(); + + return ( + row: row, + column: column, + size: Size(imageWidth, imageHeight), + ); + } + + void _rebuildWithConstraints(Constraints _) { + final BoxConstraints layoutInfo = renderObject.layoutInfo; + @pragma('vm:notify-debugger-on-exception') + void updateChildCallback() { + List built; + try { + assert(layoutInfo == renderObject.layoutInfo); + built = (widget as ImageGridBuilder).builder( + this, + renderObject.imageGridInfo = _calcGridInfo( + (widget as ImageGridBuilder).picArr, + layoutInfo, + ), + ); + } catch (e, stack) { + built = [ + ErrorWidget.builder( + _reportException( + ErrorDescription('building $widget'), + e, + stack, + informationCollector: () => [ + if (kDebugMode) DiagnosticsDebugCreator(DebugCreator(this)), + ], + ), + ), + ]; + } + try { + if (_children == null) { + final children = List.filled( + built.length, + _NullElement.instance, + ); + Element? previousChild; + for (var i = 0; i < children.length; i += 1) { + final Element newChild = inflateWidget( + built[i], + IndexedSlot(i, previousChild), + ); + children[i] = newChild; + previousChild = newChild; + } + _children = children; + } else { + _children = updateChildren( + _children!, + built, + forgottenChildren: _forgottenChildren, + ); + } + } catch (e, stack) { + built = [ + ErrorWidget.builder( + _reportException( + ErrorDescription('building $widget'), + e, + stack, + informationCollector: () => [ + if (kDebugMode) DiagnosticsDebugCreator(DebugCreator(this)), + ], + ), + ), + ]; + _children = updateChildren([], built); + } finally { + _needsBuild = false; + _previousLayoutInfo = layoutInfo; + _forgottenChildren.clear(); + } + } + + final VoidCallback? callback = + _needsBuild || (layoutInfo != _previousLayoutInfo) + ? updateChildCallback + : null; + owner!.buildScope(this, callback); + } +} + +FlutterErrorDetails _reportException( + DiagnosticsNode context, + Object exception, + StackTrace stack, { + InformationCollector? informationCollector, +}) { + final details = FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets library', + context: context, + informationCollector: informationCollector, + ); + FlutterError.reportError(details); + return details; +} + +class _NullElement extends Element { + _NullElement() : super(const _NullWidget()); + + static _NullElement instance = _NullElement(); + + @override + bool get debugDoingBuild => throw UnimplementedError(); +} + +class _NullWidget extends Widget { + const _NullWidget(); + + @override + Element createElement() => throw UnimplementedError(); +} diff --git a/lib/common/widgets/image_grid/image_grid_view.dart b/lib/common/widgets/image_grid/image_grid_view.dart new file mode 100644 index 000000000..a29fb6297 --- /dev/null +++ b/lib/common/widgets/image_grid/image_grid_view.dart @@ -0,0 +1,271 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:io' show Platform; + +import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_builder.dart'; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models/common/image_preview_type.dart'; +import 'package:PiliPlus/utils/extension/context_ext.dart'; +import 'package:PiliPlus/utils/extension/num_ext.dart'; +import 'package:PiliPlus/utils/extension/size_ext.dart'; +import 'package:PiliPlus/utils/image_utils.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/platform_utils.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show HapticFeedback; +import 'package:get/get_core/src/get_main.dart'; +import 'package:get/get_navigation/get_navigation.dart'; + +class ImageModel { + ImageModel({ + required num? width, + required num? height, + required this.url, + this.liveUrl, + }) { + this.width = width == null || width == 0 ? 1 : width; + this.height = height == null || height == 0 ? 1 : height; + } + + late num width; + late num height; + String url; + String? liveUrl; + bool? _isLongPic; + bool? _isLivePhoto; + + bool get isLongPic => + _isLongPic ??= (height / width) > StyleString.imgMaxRatio; + bool get isLivePhoto => + _isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true; + + static bool enableLivePhoto = Pref.enableLivePhoto; +} + +class ImageGridView extends StatelessWidget { + const ImageGridView({ + super.key, + required this.picArr, + this.onViewImage, + this.fullScreen = false, + }); + + final List picArr; + final VoidCallback? onViewImage; + final bool fullScreen; + + static bool horizontalPreview = Pref.horizontalPreview; + static const _routes = ['/videoV', '/dynamicDetail']; + + void _onTap(BuildContext context, int index) { + final imgList = picArr.map( + (item) { + bool isLive = item.isLivePhoto; + return SourceModel( + sourceType: isLive ? SourceType.livePhoto : SourceType.networkImage, + url: item.url, + liveUrl: isLive ? item.liveUrl : null, + width: isLive ? item.width.toInt() : null, + height: isLive ? item.height.toInt() : null, + isLongPic: item.isLongPic, + ); + }, + ).toList(); + if (horizontalPreview && + !fullScreen && + _routes.contains(Get.currentRoute) && + !context.mediaQuerySize.isPortrait) { + final scaffoldState = Scaffold.maybeOf(context); + if (scaffoldState != null) { + onViewImage?.call(); + PageUtils.onHorizontalPreviewState( + scaffoldState, + imgList, + index, + ); + return; + } + } + PageUtils.imageView( + initialPage: index, + imgList: imgList, + ); + } + + static BorderRadius _borderRadius( + int col, + int length, + int index, { + Radius r = StyleString.imgRadius, + }) { + if (length == 1) return StyleString.mdRadius; + + final bool hasUp = index - col >= 0; + final bool hasDown = index + col < length; + + final bool isRowStart = (index % col) == 0; + final bool isRowEnd = (index % col) == col - 1 || index == length - 1; + + final bool hasLeft = !isRowStart; + final bool hasRight = !isRowEnd && (index + 1) < length; + + return BorderRadius.only( + topLeft: !hasUp && !hasLeft ? r : Radius.zero, + topRight: !hasUp && !hasRight ? r : Radius.zero, + bottomLeft: !hasDown && !hasLeft ? r : Radius.zero, + bottomRight: !hasDown && !hasRight ? r : Radius.zero, + ); + } + + static bool enableImgMenu = Pref.enableImgMenu; + + void _showMenu(BuildContext context, int index, Offset offset) { + HapticFeedback.mediumImpact(); + final item = picArr[index]; + showMenu( + context: context, + position: PageUtils.menuPosition(offset), + items: [ + if (PlatformUtils.isMobile) + PopupMenuItem( + height: 42, + onTap: () => ImageUtils.onShareImg(item.url), + child: const Text('分享', style: TextStyle(fontSize: 14)), + ), + PopupMenuItem( + height: 42, + onTap: () => ImageUtils.downloadImg([item.url]), + child: const Text('保存图片', style: TextStyle(fontSize: 14)), + ), + if (PlatformUtils.isDesktop) + PopupMenuItem( + height: 42, + onTap: () => PageUtils.launchURL(item.url), + child: const Text('网页打开', style: TextStyle(fontSize: 14)), + ) + else if (picArr.length > 1) + PopupMenuItem( + height: 42, + onTap: () => + ImageUtils.downloadImg(picArr.map((item) => item.url).toList()), + child: const Text('保存全部', style: TextStyle(fontSize: 14)), + ), + if (item.isLivePhoto) + PopupMenuItem( + height: 42, + onTap: () => ImageUtils.downloadLivePhoto( + url: item.url, + liveUrl: item.liveUrl!, + width: item.width.toInt(), + height: item.height.toInt(), + ), + child: Text( + '保存${Platform.isIOS ? '实况' : '视频'}', + style: const TextStyle(fontSize: 14), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .only(top: 6), + child: ImageGridBuilder( + picArr: picArr, + onTap: (index) => _onTap(context, index), + onSecondaryTapUp: enableImgMenu && PlatformUtils.isDesktop + ? (index, offset) => _showMenu(context, index, offset) + : null, + onLongPressStart: enableImgMenu && PlatformUtils.isMobile + ? (index, offset) => _showMenu(context, index, offset) + : null, + builder: (BuildContext context, ImageGridInfo info) { + final width = info.size.width; + final height = info.size.height; + late final placeHolder = Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onInverseSurface.withValues(alpha: 0.4), + ), + child: Image.asset( + 'assets/images/loading.png', + width: width, + height: height, + cacheWidth: width.cacheSize(context), + ), + ); + return List.generate(picArr.length, (index) { + final item = picArr[index]; + final borderRadius = _borderRadius( + info.column, + picArr.length, + index, + ); + Widget child = Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + NetworkImgLayer( + src: item.url, + width: width, + height: height, + borderRadius: borderRadius, + alignment: item.isLongPic ? .topCenter : .center, + cacheWidth: item.width <= item.height, + getPlaceHolder: () => placeHolder, + ), + if (item.isLivePhoto) + const PBadge( + text: 'Live', + right: 8, + bottom: 8, + type: PBadgeType.gray, + ) + else if (item.isLongPic) + const PBadge( + text: '长图', + right: 8, + bottom: 8, + ), + ], + ); + if (!item.isLongPic) { + child = Hero( + tag: item.url, + child: child, + ); + } + return LayoutId( + id: index, + child: child, + ); + }); + }, + ), + ); + } +} diff --git a/lib/pages/article/widgets/opus_content.dart b/lib/pages/article/widgets/opus_content.dart index 825f04ee6..ae3d13be6 100644 --- a/lib/pages/article/widgets/opus_content.dart +++ b/lib/pages/article/widgets/opus_content.dart @@ -2,8 +2,8 @@ import 'dart:math' as math; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/image/cached_network_svg_image.dart'; -import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'; import 'package:PiliPlus/common/widgets/image_viewer/hero.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; @@ -247,8 +247,7 @@ class OpusContent extends StatelessWidget { child: child, ); } else { - return CustomGridView( - maxWidth: maxWidth, + return ImageGridView( picArr: element.pic!.pics! .map( (e) => ImageModel( diff --git a/lib/pages/dynamics/widgets/author_panel.dart b/lib/pages/dynamics/widgets/author_panel.dart index 2991fc6d2..46d056db9 100644 --- a/lib/pages/dynamics/widgets/author_panel.dart +++ b/lib/pages/dynamics/widgets/author_panel.dart @@ -293,9 +293,7 @@ class AuthorPanel extends StatelessWidget { height: 3, decoration: BoxDecoration( color: theme.colorScheme.outline, - borderRadius: const BorderRadius.all( - Radius.circular(3), - ), + borderRadius: const .all(.circular(1.5)), ), ), ), diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 3f8dd400d..41b2a2c18 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,7 +1,7 @@ // 内容 import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/flutter/text/text.dart' as custom_text; -import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart'; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/dynamics/widgets/rich_node_panel.dart'; import 'package:PiliPlus/utils/page_utils.dart'; @@ -94,8 +94,7 @@ Widget content( primary: theme.colorScheme.primary, ), if (pics != null && pics.isNotEmpty) - CustomGridView( - maxWidth: maxWidth, + ImageGridView( picArr: pics .map( (item) => ImageModel( diff --git a/lib/pages/dynamics/widgets/rich_node_panel.dart b/lib/pages/dynamics/widgets/rich_node_panel.dart index b298aa304..0c9b67903 100644 --- a/lib/pages/dynamics/widgets/rich_node_panel.dart +++ b/lib/pages/dynamics/widgets/rich_node_panel.dart @@ -1,8 +1,8 @@ import 'dart:io' show Platform; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; -import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/search.dart'; @@ -252,9 +252,8 @@ TextSpan? richNode( ..add(const TextSpan(text: '\n')) ..add( WidgetSpan( - child: CustomGridView( + child: ImageGridView( fullScreen: true, - maxWidth: maxWidth, picArr: i.pics! .map( (item) => ImageModel( diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index 396541da0..a7851acda 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -5,8 +5,8 @@ import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; import 'package:PiliPlus/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart' show touchSlopH; -import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart' - show CustomGridView, ImageModel; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart' + show ImageGridView, ImageModel; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/grpc/reply.dart'; import 'package:PiliPlus/http/fav.dart'; @@ -158,7 +158,7 @@ List get extraSettings => [ leading: const Icon(Icons.photo_outlined), setKey: SettingBoxKey.horizontalPreview, defaultVal: false, - onChanged: (value) => CustomGridView.horizontalPreview = value, + onChanged: (value) => ImageGridView.horizontalPreview = value, ), NormalModel( title: '评论折叠行数', @@ -468,7 +468,7 @@ List get extraSettings => [ leading: const Icon(Icons.menu), setKey: SettingBoxKey.enableImgMenu, defaultVal: false, - onChanged: (value) => CustomGridView.enableImgMenu = value, + onChanged: (value) => ImageGridView.enableImgMenu = value, ), SwitchModel( setKey: SettingBoxKey.feedBackEnable, diff --git a/lib/pages/video/reply/widgets/reply_item_grpc.dart b/lib/pages/video/reply/widgets/reply_item_grpc.dart index cdb96b463..65cba75d6 100644 --- a/lib/pages/video/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/reply/widgets/reply_item_grpc.dart @@ -5,8 +5,8 @@ import 'package:PiliPlus/common/widgets/badge.dart'; import 'package:PiliPlus/common/widgets/dialog/report.dart'; import 'package:PiliPlus/common/widgets/flutter/text/text.dart' as custom_text; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; -import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo, ReplyControl, Content, Url; @@ -298,20 +298,17 @@ class ReplyItemGrpc extends StatelessWidget { if (replyItem.content.pictures.isNotEmpty) ...[ Padding( padding: padding, - child: LayoutBuilder( - builder: (context, constraints) => CustomGridView( - maxWidth: constraints.maxWidth, - picArr: replyItem.content.pictures - .map( - (item) => ImageModel( - width: item.imgWidth, - height: item.imgHeight, - url: item.imgSrc, - ), - ) - .toList(), - onViewImage: onViewImage, - ), + child: ImageGridView( + picArr: replyItem.content.pictures + .map( + (item) => ImageModel( + width: item.imgWidth, + height: item.imgHeight, + url: item.imgSrc, + ), + ) + .toList(), + onViewImage: onViewImage, ), ), const SizedBox(height: 4),