feat: new img grid view

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-09-02 11:05:42 +08:00
parent 3cbfd158e1
commit e6af0ef15b
9 changed files with 755 additions and 815 deletions

View File

@@ -0,0 +1,462 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: uri_does_not_exist_in_doc_import
/// @docImport 'package:flutter/widgets.dart';
///
/// @docImport 'stack.dart';
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class CustomMultiChildLayout extends MultiChildRenderObjectWidget {
/// Creates a custom multi-child layout.
const CustomMultiChildLayout({
super.key,
required this.delegate,
super.children,
});
/// The delegate that controls the layout of the children.
final MultiChildLayoutDelegate delegate;
@override
RenderCustomMultiChildLayoutBox createRenderObject(BuildContext context) {
return RenderCustomMultiChildLayoutBox(delegate: delegate);
}
@override
void updateRenderObject(
BuildContext context,
RenderCustomMultiChildLayoutBox renderObject,
) {
renderObject.delegate = delegate;
}
}
/// A delegate that controls the layout of multiple children.
///
/// Used with [CustomMultiChildLayout] (in the widgets library) and
/// [RenderCustomMultiChildLayoutBox] (in the rendering library).
///
/// Delegates must be idempotent. Specifically, if two delegates are equal, then
/// they must produce the same layout. To change the layout, replace the
/// delegate with a different instance whose [shouldRelayout] returns true when
/// given the previous instance.
///
/// Override [getSize] to control the overall size of the layout. The size of
/// the layout cannot depend on layout properties of the children. This was
/// a design decision to simplify the delegate implementations: This way,
/// the delegate implementations do not have to also handle various intrinsic
/// sizing functions if the parent's size depended on the children.
/// If you want to build a custom layout where you define the size of that widget
/// based on its children, then you will have to create a custom render object.
/// See [MultiChildRenderObjectWidget] with [ContainerRenderObjectMixin] and
/// [RenderBoxContainerDefaultsMixin] to get started or [RenderStack] for an
/// example implementation.
///
/// Override [performLayout] to size and position the children. An
/// implementation of [performLayout] must call [layoutChild] exactly once for
/// each child, but it may call [layoutChild] on children in an arbitrary order.
/// Typically a delegate will use the size returned from [layoutChild] on one
/// child to determine the constraints for [performLayout] on another child or
/// to determine the offset for [positionChild] for that child or another child.
///
/// Override [shouldRelayout] to determine when the layout of the children needs
/// to be recomputed when the delegate changes.
///
/// The most efficient way to trigger a relayout is to supply a `relayout`
/// argument to the constructor of the [MultiChildLayoutDelegate]. The custom
/// layout will listen to this value and relayout whenever the Listenable
/// notifies its listeners, such as when an [Animation] ticks. This allows
/// the custom layout to avoid the build phase of the pipeline.
///
/// Each child must be wrapped in a [LayoutId] widget to assign the id that
/// identifies it to the delegate. The [LayoutId.id] needs to be unique among
/// the children that the [CustomMultiChildLayout] manages.
///
/// {@tool snippet}
///
/// Below is an example implementation of [performLayout] that causes one widget
/// (the follower) to be the same size as another (the leader):
///
/// ```dart
/// // Define your own slot numbers, depending upon the id assigned by LayoutId.
/// // Typical usage is to define an enum like the one below, and use those
/// // values as the ids.
/// enum _Slot {
/// leader,
/// follower,
/// }
///
/// class FollowTheLeader extends MultiChildLayoutDelegate {
/// @override
/// void performLayout(Size size) {
/// Size leaderSize = Size.zero;
///
/// if (hasChild(_Slot.leader)) {
/// leaderSize = layoutChild(_Slot.leader, BoxConstraints.loose(size));
/// positionChild(_Slot.leader, Offset.zero);
/// }
///
/// if (hasChild(_Slot.follower)) {
/// layoutChild(_Slot.follower, BoxConstraints.tight(leaderSize));
/// positionChild(_Slot.follower, Offset(size.width - leaderSize.width,
/// size.height - leaderSize.height));
/// }
/// }
///
/// @override
/// bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
/// }
/// ```
/// {@end-tool}
///
/// The delegate gives the leader widget loose constraints, which means the
/// child determines what size to be (subject to fitting within the given size).
/// The delegate then remembers the size of that child and places it in the
/// upper left corner.
///
/// The delegate then gives the follower widget tight constraints, forcing it to
/// match the size of the leader widget. The delegate then places the follower
/// widget in the bottom right corner.
///
/// The leader and follower widget will paint in the order they appear in the
/// child list, regardless of the order in which [layoutChild] is called on
/// them.
///
/// See also:
///
/// * [CustomMultiChildLayout], the widget that uses this delegate.
/// * [RenderCustomMultiChildLayoutBox], render object that uses this
/// delegate.
abstract class MultiChildLayoutDelegate {
/// Creates a layout delegate.
///
/// The layout will update whenever [relayout] notifies its listeners.
MultiChildLayoutDelegate({Listenable? relayout}) : _relayout = relayout;
final Listenable? _relayout;
Map<Object, RenderBox>? _idToChild;
Set<RenderBox>? _debugChildrenNeedingLayout;
/// True if a non-null LayoutChild was provided for the specified id.
///
/// Call this from the [performLayout] method to determine which children
/// are available, if the child list might vary.
///
/// This method cannot be called from [getSize] as the size is not allowed
/// to depend on the children.
bool hasChild(Object childId) => _idToChild![childId] != null;
/// Ask the child to update its layout within the limits specified by
/// the constraints parameter. The child's size is returned.
///
/// Call this from your [performLayout] function to lay out each
/// child. Every child must be laid out using this function exactly
/// once each time the [performLayout] function is called.
Size layoutChild(Object childId, BoxConstraints constraints) {
final RenderBox? child = _idToChild![childId];
assert(() {
if (child == null) {
throw FlutterError(
'The $this custom multichild layout delegate tried to lay out a non-existent child.\n'
'There is no child with the id "$childId".',
);
}
if (!_debugChildrenNeedingLayout!.remove(child)) {
throw FlutterError(
'The $this custom multichild layout delegate tried to lay out the child with id "$childId" more than once.\n'
'Each child must be laid out exactly once.',
);
}
try {
assert(constraints.debugAssertIsValid(isAppliedConstraint: true));
} on AssertionError catch (exception) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'The $this custom multichild layout delegate provided invalid box constraints for the child with id "$childId".',
),
DiagnosticsProperty<AssertionError>(
'Exception',
exception,
showName: false,
),
ErrorDescription(
'The minimum width and height must be greater than or equal to zero.\n'
'The maximum width must be greater than or equal to the minimum width.\n'
'The maximum height must be greater than or equal to the minimum height.',
),
]);
}
return true;
}());
child!.layout(constraints, parentUsesSize: true);
return child.size;
}
/// Specify the child's origin relative to this origin.
///
/// Call this from your [performLayout] function to position each
/// child. If you do not call this for a child, its position will
/// remain unchanged. Children initially have their position set to
/// (0,0), i.e. the top left of the [RenderCustomMultiChildLayoutBox].
void positionChild(Object childId, Offset offset) {
final RenderBox? child = _idToChild![childId];
assert(() {
if (child == null) {
throw FlutterError(
'The $this custom multichild layout delegate tried to position out a non-existent child:\n'
'There is no child with the id "$childId".',
);
}
return true;
}());
final MultiChildLayoutParentData childParentData =
child!.parentData! as MultiChildLayoutParentData;
childParentData.offset = offset;
}
DiagnosticsNode _debugDescribeChild(RenderBox child) {
final MultiChildLayoutParentData childParentData =
child.parentData! as MultiChildLayoutParentData;
return DiagnosticsProperty<RenderBox>('${childParentData.id}', child);
}
void _callPerformLayout(Size size, RenderBox? firstChild) {
// A particular layout delegate could be called reentrantly, e.g. if it used
// by both a parent and a child. So, we must restore the _idToChild map when
// we return.
final Map<Object, RenderBox>? previousIdToChild = _idToChild;
Set<RenderBox>? debugPreviousChildrenNeedingLayout;
assert(() {
debugPreviousChildrenNeedingLayout = _debugChildrenNeedingLayout;
_debugChildrenNeedingLayout = <RenderBox>{};
return true;
}());
try {
_idToChild = <Object, RenderBox>{};
RenderBox? child = firstChild;
while (child != null) {
final MultiChildLayoutParentData childParentData =
child.parentData! as MultiChildLayoutParentData;
assert(() {
if (childParentData.id == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'Every child of a RenderCustomMultiChildLayoutBox must have an ID in its parent data.',
),
child!.describeForError('The following child has no ID'),
]);
}
return true;
}());
_idToChild![childParentData.id!] = child;
assert(() {
_debugChildrenNeedingLayout!.add(child!);
return true;
}());
child = childParentData.nextSibling;
}
performLayout(size);
assert(() {
if (_debugChildrenNeedingLayout!.isNotEmpty) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Each child must be laid out exactly once.'),
DiagnosticsBlock(
name:
'The $this custom multichild layout delegate forgot '
'to lay out the following '
'${_debugChildrenNeedingLayout!.length > 1 ? 'children' : 'child'}',
properties: _debugChildrenNeedingLayout!
.map<DiagnosticsNode>(_debugDescribeChild)
.toList(),
),
]);
}
return true;
}());
} finally {
_idToChild = previousIdToChild;
assert(() {
_debugChildrenNeedingLayout = debugPreviousChildrenNeedingLayout;
return true;
}());
}
}
/// Override this method to return the size of this object given the
/// incoming constraints.
///
/// The size cannot reflect the sizes of the children. If this layout has a
/// fixed width or height the returned size can reflect that; the size will be
/// constrained to the given constraints.
///
/// By default, attempts to size the box to the biggest size
/// possible given the constraints.
Size getSize(BoxConstraints constraints) => constraints.biggest;
/// Override this method to lay out and position all children given this
/// widget's size.
///
/// This method must call [layoutChild] for each child. It should also specify
/// the final position of each child with [positionChild].
void performLayout(Size size);
/// Override this method to return true when the children need to be
/// laid out.
///
/// This should compare the fields of the current delegate and the given
/// `oldDelegate` and return true if the fields are such that the layout would
/// be different.
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);
/// Override this method to include additional information in the
/// debugging data printed by [debugDumpRenderTree] and friends.
///
/// By default, returns the [runtimeType] of the class.
@override
String toString() => objectRuntimeType(this, 'MultiChildLayoutDelegate');
}
/// Defers the layout of multiple children to a delegate.
///
/// The delegate can determine the layout constraints for each child and can
/// decide where to position each child. The delegate can also determine the
/// size of the parent, but the size of the parent cannot depend on the sizes of
/// the children.
class RenderCustomMultiChildLayoutBox extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
/// Creates a render object that customizes the layout of multiple children.
RenderCustomMultiChildLayoutBox({
List<RenderBox>? children,
required MultiChildLayoutDelegate delegate,
}) : _delegate = delegate {
addAll(children);
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
/// The delegate that controls the layout of the children.
MultiChildLayoutDelegate get delegate => _delegate;
MultiChildLayoutDelegate _delegate;
set delegate(MultiChildLayoutDelegate newDelegate) {
if (_delegate == newDelegate) {
return;
}
final MultiChildLayoutDelegate oldDelegate = _delegate;
if (newDelegate.runtimeType != oldDelegate.runtimeType ||
newDelegate.shouldRelayout(oldDelegate)) {
markNeedsLayout();
}
_delegate = newDelegate;
if (attached) {
oldDelegate._relayout?.removeListener(markNeedsLayout);
newDelegate._relayout?.addListener(markNeedsLayout);
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_delegate._relayout?.addListener(markNeedsLayout);
}
@override
void detach() {
_delegate._relayout?.removeListener(markNeedsLayout);
super.detach();
}
Size _getSize(BoxConstraints constraints) {
assert(constraints.debugAssertIsValid());
return constraints.constrain(_delegate.getSize(constraints));
}
// TODO(ianh): It's a bit dubious to be using the getSize function from the delegate to
// figure out the intrinsic dimensions. We really should either not support intrinsics,
// or we should expose intrinsic delegate callbacks and throw if they're not implemented.
@override
double computeMinIntrinsicWidth(double height) {
final double width = _getSize(
BoxConstraints.tightForFinite(height: height),
).width;
if (width.isFinite) {
return width;
}
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
final double width = _getSize(
BoxConstraints.tightForFinite(height: height),
).width;
if (width.isFinite) {
return width;
}
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
final double height = _getSize(
BoxConstraints.tightForFinite(width: width),
).height;
if (height.isFinite) {
return height;
}
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
final double height = _getSize(
BoxConstraints.tightForFinite(width: width),
).height;
if (height.isFinite) {
return height;
}
return 0.0;
}
@override
@protected
Size computeDryLayout(covariant BoxConstraints constraints) {
return _getSize(constraints);
}
@override
void performLayout() {
size = _getSize(constraints);
delegate._callPerformLayout(size, firstChild);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -0,0 +1,274 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:math' show min;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/custom_layout.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.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/material.dart'
hide CustomMultiChildLayout, MultiChildLayoutDelegate;
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) > _maxRatio;
bool get isLivePhoto =>
_isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true;
static bool enableLivePhoto = Pref.enableLivePhoto;
}
const double _maxRatio = 22 / 9;
class CustomGridView extends StatelessWidget {
const CustomGridView({
super.key,
this.space = 5,
required this.maxWidth,
required this.picArr,
this.onViewImage,
this.onDismissed,
this.callback,
});
final double maxWidth;
final double space;
final List<ImageModel> picArr;
final VoidCallback? onViewImage;
final ValueChanged<int>? onDismissed;
final Function(List<String>, int)? callback;
void onTap(int index) {
if (callback != null) {
callback!(picArr.map((item) => item.url).toList(), index);
} else {
onViewImage?.call();
PageUtils.imageView(
initialPage: index,
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,
);
},
).toList(),
onDismissed: onDismissed,
);
}
}
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,
);
}
@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, _maxRatio);
}
}
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: Center(
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: CustomMultiChildLayout(
delegate: _CustomGridViewDelegate(
space: space,
itemCount: length,
column: column,
width: imageWidth,
height: imageHeight,
),
children: List.generate(length, (index) {
final item = picArr[index];
final radius = borderRadius(column, length, index);
return LayoutId(
id: index,
child: Hero(
tag: item.url,
child: GestureDetector(
onTap: () => onTap(index),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: radius,
child: NetworkImgLayer(
radius: 0,
src: item.url,
width: imageWidth,
height: imageHeight,
isLongPic: item.isLongPic,
forceUseCacheWidth: 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,
),
],
),
),
),
);
}),
),
),
);
}
}
class _CustomGridViewDelegate extends MultiChildLayoutDelegate {
_CustomGridViewDelegate({
required this.space,
required this.itemCount,
required this.column,
required this.width,
required this.height,
});
final double space;
final int itemCount;
final int column;
final double width;
final double height;
@override
void performLayout(Size size) {
final constraints = BoxConstraints.loose(Size(width, height));
for (int i = 0; i < itemCount; i++) {
layoutChild(i, constraints);
positionChild(
i,
Offset(
(space + width) * (i % column),
(space + height) * (i ~/ column),
),
);
}
}
@override
bool shouldRelayout(_CustomGridViewDelegate oldDelegate) {
return space != oldDelegate.space || itemCount != oldDelegate.itemCount;
}
}

View File

@@ -1,200 +0,0 @@
import 'dart:math';
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/nine_grid_view.dart';
import 'package:PiliPlus/models/common/badge_type.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/material.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) > _maxRatio;
bool get isLivePhoto =>
_isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true;
static bool enableLivePhoto = Pref.enableLivePhoto;
}
const double _maxRatio = 22 / 9;
Widget imageView(
double maxWidth,
List<ImageModel> picArr, {
VoidCallback? onViewImage,
ValueChanged<int>? onDismissed,
Function(List<String>, int)? callback,
}) {
double imageWidth = (maxWidth - 10) / 3;
double imageHeight = imageWidth;
if (picArr.length == 1) {
num width = picArr[0].width;
num height = picArr[0].height;
double ratioWH = width / height;
double 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, _maxRatio);
} else if (picArr.length == 2) {
imageWidth = imageHeight = 2 * imageWidth;
}
late final int row = picArr.length == 4 ? 2 : 3;
BorderRadius borderRadius(index) {
if (picArr.length == 1) {
return StyleString.mdRadius;
}
return BorderRadius.only(
topLeft:
index - row >= 0 ||
((index - 1) >= 0 && (index - 1) % row < index % row)
? Radius.zero
: StyleString.imgRadius,
topRight:
index - row >= 0 ||
((index + 1) < picArr.length && (index + 1) % row > index % row)
? Radius.zero
: StyleString.imgRadius,
bottomLeft:
index + row < picArr.length ||
((index - 1) >= 0 && (index - 1) % row < index % row)
? Radius.zero
: StyleString.imgRadius,
bottomRight:
index + row < picArr.length ||
((index + 1) < picArr.length && (index + 1) % row > index % row)
? Radius.zero
: StyleString.imgRadius,
);
}
int parseSize(size) {
return switch (size) {
int() => size,
double() => size.round(),
String() => int.tryParse(size) ?? 1,
_ => 1,
};
}
void onTap(int index) {
if (callback != null) {
callback(picArr.map((item) => item.url).toList(), index);
} else {
onViewImage?.call();
PageUtils.imageView(
initialPage: index,
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 ? parseSize(item.width) : null,
height: isLive ? parseSize(item.height) : null,
);
},
).toList(),
onDismissed: onDismissed,
);
}
}
return NineGridView(
type: NineGridType.weiBo,
margin: const EdgeInsets.only(top: 6),
bigImageWidth: imageWidth,
bigImageHeight: imageHeight,
space: 5,
height: picArr.length == 1 ? imageHeight : null,
width: picArr.length == 1 ? imageWidth : maxWidth,
itemCount: picArr.length,
itemBuilder: (context, index) {
final item = picArr[index];
return Hero(
tag: item.url,
child: GestureDetector(
onTap: () => onTap(index),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: borderRadius(index),
child: NetworkImgLayer(
radius: 0,
src: item.url,
width: imageWidth,
height: imageHeight,
isLongPic: item.isLongPic,
forceUseCacheWidth: item.width <= item.height,
getPlaceHolder: () {
return Container(
width: imageWidth,
height: imageHeight,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.4),
borderRadius: borderRadius(index),
),
child: Center(
child: Image.asset(
'assets/images/loading.png',
width: imageWidth,
height: imageHeight,
cacheWidth: imageWidth.cacheSize(context),
),
),
);
},
),
),
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,
),
],
),
),
);
},
);
}

View File

@@ -1,595 +0,0 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/material.dart';
/**
* @Author: Sky24n
* @GitHub: https://github.com/Sky24n
* @Description: NineGridView.
* @Date: 2020/06/16
*/
/// NineGridView Type.
enum NineGridType {
/// normal NineGridView.
normal,
/// like WeChat NineGridView.
weChat,
/// like WeiBo International NineGridView.
weiBo,
/// like WeChat group.
weChatGp,
/// like DingTalk group.
dingTalkGp,
/// like QQ group.
qqGp,
}
/// big images size cache map.
Map<String, Rect> ngvBigImageSizeMap = HashMap();
/// NineGridView.
/// like WeChat, WeiBo International, WeChat group, DingTalk group, QQ group.
///
/// Another [NineGridView](https://github.com/flutterchina/flukit) in [flukit](https://github.com/flutterchina/flukit) UI Kitusing GridView implementation。
class NineGridView extends StatefulWidget {
/// create NineGridView.
/// If you want to show a single big picture.
/// It is recommended to use a medium-quality picture, because the original picture is too large and takes time to load.
/// 单张大图建议使用中等质量图片,因为原图太大加载耗时。
/// you need input (bigImageWidth + bigImageHeight) or (bigImage + bigImageUrl).
const NineGridView({
super.key,
this.width,
this.height,
this.space = 3,
this.arcAngle = 0,
this.initIndex = 1,
this.padding = EdgeInsets.zero,
this.margin = EdgeInsets.zero,
this.alignment,
this.color,
this.decoration,
this.type = NineGridType.weChat,
required this.itemCount,
required this.itemBuilder,
this.bigImageWidth,
this.bigImageHeight,
this.bigImage,
this.bigImageUrl,
});
/// View width.
final double? width;
/// View height.
final double? height;
/// The number of logical pixels between each child.
final double space;
/// QQ group arc angle (0 ~ 180).
final double arcAngle;
/// QQ group init index (0 or 1). def 1.
final int initIndex;
/// View padding.
final EdgeInsets padding;
/// View margin.
final EdgeInsets margin;
/// Align the [child] within the container.
final AlignmentGeometry? alignment;
/// The color to paint behind the [child].
final Color? color;
/// The decoration to paint behind the [child].
final Decoration? decoration;
/// NineGridView type.
final NineGridType type;
/// The total number of children this delegate can provide.
final int itemCount;
/// Called to build children for the view.
final IndexedWidgetBuilder itemBuilder;
/// Single big picture width.
final double? bigImageWidth;
/// Single big picture height.
final double? bigImageHeight;
/// It is recommended to use a medium-quality picture, because the original picture is too large and takes time to load.
/// 单张大图建议使用中等质量图片,因为原图太大加载耗时。
/// Single big picture Image.
final Image? bigImage;
/// Single big picture url.
final String? bigImageUrl;
@override
State<StatefulWidget> createState() {
return _NineGridViewState();
}
}
/// _NineGridViewState.
class _NineGridViewState extends State<NineGridView> {
/// init view size.
Rect _initSize(BuildContext context) {
EdgeInsets padding = widget.padding;
if (widget.itemCount == 0) {
return Rect.fromLTRB(0, 0, padding.horizontal, padding.vertical);
}
double width =
widget.width ??
(MediaQuery.sizeOf(context).width - widget.margin.horizontal);
width = width - padding.horizontal;
double space = widget.space;
double itemW;
if (widget.type == NineGridType.weiBo &&
(widget.itemCount == 1 || widget.itemCount == 2)) {
// || itemCount == 4
itemW = (width - space) / 2;
} else {
itemW = (width - space * 2) / 3;
}
bool fourGrid =
(widget.itemCount == 4 && widget.type != NineGridType.normal);
int column = fourGrid ? 2 : math.min(3, widget.itemCount);
int row = fourGrid ? 2 : (widget.itemCount / 3).ceil();
double realWidth =
itemW * column + space * (column - 1) + padding.horizontal;
double realHeight = itemW * row + space * (row - 1) + padding.vertical;
return Rect.fromLTRB(itemW, 0, realWidth, realHeight);
}
/// build nine grid view.
Widget _buildChild(BuildContext context, double itemW) {
double space = widget.space;
int column = (widget.itemCount == 4 && widget.type != NineGridType.normal)
? 2
: 3;
List<Widget> list = [];
for (int i = 0; i < widget.itemCount; i++) {
list.add(
Positioned(
top: (space + itemW) * (i ~/ column),
left: (space + itemW) * (i % column),
child: SizedBox(
width: itemW,
height: itemW,
child: widget.itemBuilder(context, i),
),
),
);
}
return Stack(
clipBehavior: Clip.none,
children: list,
);
}
/// build one child.
Widget? _buildOneChild(BuildContext context) {
double? bigImgWidth = widget.bigImageWidth?.toDouble();
double? bigImgHeight = widget.bigImageHeight?.toDouble();
if (!_isZero(bigImgWidth) && !_isZero(bigImgHeight)) {
return _getOneChild(context, bigImgWidth!, bigImgHeight!);
} else if (widget.bigImage != null) {
String bigImageUrl = widget.bigImageUrl!;
Rect? bigImgRect = ngvBigImageSizeMap[bigImageUrl];
bigImgWidth = bigImgRect?.width;
bigImgHeight = bigImgRect?.height;
if (!_isZero(bigImgWidth) && !_isZero(bigImgHeight)) {
return _getOneChild(context, bigImgWidth!, bigImgHeight!);
} else {
_ImageUtil()
.getImageSize(widget.bigImage)
?.then((rect) {
ngvBigImageSizeMap[bigImageUrl] = rect;
if (!mounted) return;
setState(() {});
})
.catchError((e) {});
}
}
return null;
}
/// get one child.
Widget _getOneChild(BuildContext context, double width, double height) {
Rect rect = _getBigImgSize(width, height);
return SizedBox(
width: rect.width,
height: rect.height,
child: widget.itemBuilder(context, 0),
);
}
/// build weChat group.
Widget _buildWeChatGroup(BuildContext context) {
double width = widget.width! - widget.padding.horizontal;
double space = widget.space;
double itemW;
int column = widget.itemCount < 5 ? 2 : 3;
int row = 0;
if (widget.itemCount == 1) {
row = 1;
itemW = width;
} else if (widget.itemCount < 5) {
row = widget.itemCount == 2 ? 1 : 2;
itemW = (width - space) / 2;
} else if (widget.itemCount < 7) {
row = 2;
itemW = (width - space * 2) / 3;
} else {
row = 3;
itemW = (width - space * 2) / 3;
}
int first = widget.itemCount % column;
List<Widget> list = [];
for (int i = 0; i < widget.itemCount; i++) {
double left;
if (first > 0 && i < first) {
left =
(width - itemW * first - space * (first - 1)) / 2 +
(itemW + space) * i;
} else {
left = (space + itemW) * ((i - first) % column);
}
int itemIndex = (first > 0 && i < first)
? 0
: (first > 0 ? (i + column - first) : i) ~/ column;
double top =
(width - itemW * row - space * (row - 1)) / 2 +
(space + itemW) * itemIndex;
list.add(
Positioned(
top: top,
left: left,
child: SizedBox(
width: itemW,
height: itemW,
child: widget.itemBuilder(context, i),
),
),
);
}
return Stack(
clipBehavior: Clip.none,
children: list,
);
}
/// build dingTalk group.
Widget _buildDingTalkGroup(BuildContext context) {
double width = widget.width! - widget.padding.horizontal;
int itemCount = math.min(4, widget.itemCount);
double itemW = (width - widget.space) / 2;
List<Widget> children = [];
for (int i = 0; i < itemCount; i++) {
children.add(
Positioned(
top: (widget.space + itemW) * (i ~/ 2),
left:
(widget.space + itemW) *
(((itemCount == 3 && i == 2) ? i + 1 : i) % 2),
child: SizedBox(
width: itemCount == 1 ? width : itemW,
height:
(itemCount == 1 || itemCount == 2 || (itemCount == 3 && i == 0))
? width
: itemW,
child: widget.itemBuilder(context, i),
),
),
);
}
return ClipOval(
child: Stack(
clipBehavior: Clip.none,
children: children,
),
);
}
/// build QQ group.
Widget _buildQQGroup(BuildContext context) {
double width = widget.width! - widget.padding.horizontal;
int itemCount = math.min(5, widget.itemCount);
if (itemCount == 1) {
return ClipOval(
child: SizedBox(
width: width,
height: width,
child: widget.itemBuilder(context, 0),
),
);
}
List<Widget> children = [];
double startDegree = 0;
double r = 0;
double r1 = 0;
double centerX = width / 2;
double centerY = width / 2;
switch (itemCount) {
case 2:
startDegree = 135;
r = width / (2 + 2 * math.sin(math.pi / 4));
r1 = r;
break;
case 3:
startDegree = 210;
r = width / (2 + 4 * math.sin(math.pi * (3 - 2) / (2 * 3)));
r1 = r / math.cos(math.pi * (3 - 2) / (2 * 3));
double R =
r *
(1 + math.sin(math.pi / itemCount)) /
math.sin(math.pi / itemCount);
double dy = 0.5 * (width - R - r * (1 + 1 / math.tan(math.pi / 3)));
centerY = dy + r + r1;
break;
case 4:
startDegree = 180;
r = width / 4;
r1 = r / math.cos(math.pi / 4);
break;
case 5:
startDegree = 126;
r = width / (2 + 4 * math.sin(math.pi * (5 - 2) / (2 * 5)));
r1 = r / math.cos(math.pi * (5 - 2) / (2 * 5));
double R =
r *
(1 + math.sin(math.pi / itemCount)) /
math.sin(math.pi / itemCount);
double dy = 0.5 * (width - R - r * (1 + 1 / math.tan(math.pi / 5)));
centerY = dy + r + r1;
break;
}
for (int i = 0; i < itemCount; i++) {
double degree1 = (itemCount == 2 || itemCount == 4) ? (-math.pi / 4) : 0;
double x = centerX + r1 * math.sin(degree1 + i * 2 * math.pi / itemCount);
double y = centerY - r1 * math.cos(degree1 + i * 2 * math.pi / itemCount);
double degree = startDegree + i * 2 * 180 / itemCount;
if (degree >= 360) degree = degree % 360;
double previousX = r + 2 * r * math.sin(degree / 180 * math.pi);
double previousY = r - 2 * r * math.cos(degree / 180 * math.pi);
Widget child = Positioned.fromRect(
rect: Rect.fromCircle(center: Offset(x, y), radius: r),
child: ClipPath(
clipper: QQClipper(
total: itemCount,
index: i,
initIndex: widget.initIndex,
previousX: previousX,
previousY: previousY,
degree: degree,
arcAngle: widget.arcAngle,
space: widget.space,
),
child: widget.itemBuilder(context, i),
),
);
children.add(child);
}
return Stack(
clipBehavior: Clip.none,
children: children,
);
}
/// double is zero.
bool _isZero(double? value) {
return value == null || value == 0;
}
/// get big image size.
Rect _getBigImgSize(double originalWidth, double originalHeight) {
double width =
widget.width ??
(MediaQuery.sizeOf(context).width - widget.margin.horizontal);
width = width - widget.padding.horizontal;
double itemW = (width - widget.space * 2) / 3;
// double devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
double devicePixelRatio = 1.0;
double tempWidth = originalWidth / devicePixelRatio;
double tempHeight = originalHeight / devicePixelRatio;
double maxW = itemW * 2 + widget.space;
double minW = width / 2;
double relWidth = tempWidth >= maxW ? maxW : math.max(minW, tempWidth);
double relHeight;
double ratio = tempWidth / tempHeight;
if (tempWidth == tempHeight) {
relHeight = relWidth;
} else if (tempWidth > tempHeight) {
relHeight = relWidth / (math.min(ratio, 4 / 3));
} else {
relHeight = relWidth / (math.max(ratio, 3 / 4));
}
return Rect.fromLTRB(0, 0, relWidth, relHeight);
}
@override
Widget build(BuildContext context) {
Widget? child;
double? realWidth = widget.width;
double? realHeight = widget.height;
switch (widget.type) {
case NineGridType.normal:
case NineGridType.weiBo:
case NineGridType.weChat:
Rect size = _initSize(context);
if (widget.itemCount == 1) {
child = _buildOneChild(context);
if (child == null) {
realWidth = size.right;
realHeight = size.bottom;
child = _buildChild(context, size.left);
}
} else {
realWidth = size.right;
realHeight = size.bottom;
child = _buildChild(context, size.left);
}
break;
case NineGridType.weChatGp:
child = _buildWeChatGroup(context);
break;
case NineGridType.dingTalkGp:
child = _buildDingTalkGroup(context);
break;
case NineGridType.qqGp:
child = _buildQQGroup(context);
break;
}
return Container(
alignment: widget.alignment,
color: widget.color,
decoration: widget.decoration,
margin: widget.margin,
padding: widget.padding,
width: realWidth,
height: realHeight,
child: child,
);
}
}
/// image util.
class _ImageUtil {
late ImageStreamListener listener;
late ImageStream imageStream;
/// get image size.
Future<Rect>? getImageSize(Image? image) {
if (image == null) {
return null;
}
Completer<Rect> completer = Completer<Rect>();
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
imageStream.removeListener(listener);
if (!completer.isCompleted) {
completer.complete(
Rect.fromLTWH(
0,
0,
info.image.width.toDouble(),
info.image.height.toDouble(),
),
);
}
},
onError: (dynamic exception, StackTrace? stackTrace) {
imageStream.removeListener(listener);
if (!completer.isCompleted) {
completer.completeError(exception, stackTrace);
}
},
);
imageStream = image.image.resolve(ImageConfiguration.empty);
imageStream.addListener(listener);
return completer.future;
}
}
/// QQ Clipper.
class QQClipper extends CustomClipper<Path> {
QQClipper({
this.total = 0,
this.index = 0,
this.initIndex = 1,
this.previousX = 0,
this.previousY = 0,
this.degree = 0,
this.arcAngle = 0,
this.space = 0,
}) : assert(arcAngle >= 0 && arcAngle <= 180);
final int total;
final int index;
final int initIndex;
final double previousX;
final double previousY;
final double degree;
final double arcAngle;
final double space;
@override
Path getClip(Size size) {
double r = size.width / 2;
Path path = Path();
List<Offset> points = [];
if (total == 2 && index == initIndex) {
path.addOval(Rect.fromLTRB(0, 0, size.width, size.height));
} else {
/// arcAngle and space, prefer to use arcAngle.
double spaceA = arcAngle > 0
? (arcAngle / 2)
: (math.acos((r - math.min(r, space)) / r) / math.pi * 180);
double startA = degree + spaceA;
double endA = degree - spaceA;
for (double i = startA; i <= 360 + endA; i = i + 1) {
double x1 = r + r * math.sin(d2r(i));
double y1 = r - r * math.cos(d2r(i));
points.add(Offset(x1, y1));
}
double spaceB =
math.atan(
r * math.sin(d2r(spaceA)) / (2 * r - r * math.cos(d2r(spaceA))),
) /
math.pi *
180;
double r1 = (2 * r - r * math.cos(d2r(spaceA))) / math.cos(d2r(spaceB));
double startB = degree - 180 - spaceB;
double endB = degree - 180 + spaceB;
List<Offset> pointsB = [];
for (double i = startB; i < endB; i = i + 1) {
double x1 = previousX + r1 * math.sin(d2r(i));
double y1 = previousY - r1 * math.cos(d2r(i));
pointsB.add(Offset(x1, y1));
}
points.addAll(pointsB.reversed);
path.addPolygon(points, true);
}
return path;
}
/// degree to radian.
double d2r(double degree) {
return degree / 180 * math.pi;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return this != oldClipper;
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:math';
import 'package:PiliPlus/common/widgets/image/image_view.dart';
import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/http/constants.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
@@ -207,9 +207,9 @@ class OpusContent extends StatelessWidget {
),
);
} else {
return imageView(
maxWidth,
element.pic!.pics!
return CustomGridView(
maxWidth: maxWidth,
picArr: element.pic!.pics!
.map(
(e) => ImageModel(
width: e.width,

View File

@@ -1,6 +1,6 @@
// 内容
import 'package:PiliPlus/common/widgets/custom_icon.dart';
import 'package:PiliPlus/common/widgets/image/image_view.dart';
import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart';
import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text;
import 'package:PiliPlus/models/dynamics/result.dart';
import 'package:PiliPlus/pages/dynamics/widgets/rich_node_panel.dart';
@@ -80,9 +80,9 @@ Widget content(
maxLines: isSave ? null : 6,
),
if (item.modules.moduleDynamic?.major?.opus?.pics?.isNotEmpty == true)
imageView(
maxWidth,
item.modules.moduleDynamic!.major!.opus!.pics!
CustomGridView(
maxWidth: maxWidth,
picArr: item.modules.moduleDynamic!.major!.opus!.pics!
.map(
(item) => ImageModel(
width: item.width,

View File

@@ -1,6 +1,6 @@
import 'dart:io' show Platform;
import 'package:PiliPlus/common/widgets/image/image_view.dart';
import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/http/dynamics.dart';
import 'package:PiliPlus/http/search.dart';
@@ -247,9 +247,9 @@ TextSpan? richNode(
..add(const TextSpan(text: '\n'))
..add(
WidgetSpan(
child: imageView(
maxWidth,
i.pics!
child: CustomGridView(
maxWidth: maxWidth,
picArr: i.pics!
.map(
(item) => ImageModel(
url: item.src ?? '',

View File

@@ -1,7 +1,8 @@
import 'dart:io';
import 'dart:math' show pi, max;
import 'package:PiliPlus/common/widgets/image/image_view.dart';
import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart'
show ImageModel;
import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
import 'package:PiliPlus/common/widgets/radio_widget.dart';
import 'package:PiliPlus/common/widgets/refresh_indicator.dart';
@@ -510,9 +511,7 @@ List<SettingsModel> get extraSettings => [
leading: const Icon(Icons.image_outlined),
setKey: SettingBoxKey.enableLivePhoto,
defaultVal: true,
onChanged: (value) {
ImageModel.enableLivePhoto = value;
},
onChanged: (value) => ImageModel.enableLivePhoto = value,
),
const SettingsModel(
settingsType: SettingsType.sw1tch,

View File

@@ -3,7 +3,7 @@ import 'dart:math';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/dialog/report.dart';
import 'package:PiliPlus/common/widgets/image/image_view.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/pendant_avatar.dart';
import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text;
@@ -299,9 +299,9 @@ class ReplyItemGrpc extends StatelessWidget {
Padding(
padding: padding,
child: LayoutBuilder(
builder: (context, constraints) => imageView(
constraints.maxWidth,
replyItem.content.pictures
builder: (context, constraints) => CustomGridView(
maxWidth: constraints.maxWidth,
picArr: replyItem.content.pictures
.map(
(item) => ImageModel(
width: item.imgWidth,