diff --git a/lib/common/widgets/custom_layout.dart b/lib/common/widgets/custom_layout.dart new file mode 100644 index 00000000..2dce8ff8 --- /dev/null +++ b/lib/common/widgets/custom_layout.dart @@ -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? _idToChild; + Set? _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([ + ErrorSummary( + 'The $this custom multichild layout delegate provided invalid box constraints for the child with id "$childId".', + ), + DiagnosticsProperty( + '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('${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? previousIdToChild = _idToChild; + + Set? debugPreviousChildrenNeedingLayout; + assert(() { + debugPreviousChildrenNeedingLayout = _debugChildrenNeedingLayout; + _debugChildrenNeedingLayout = {}; + return true; + }()); + + try { + _idToChild = {}; + RenderBox? child = firstChild; + while (child != null) { + final MultiChildLayoutParentData childParentData = + child.parentData! as MultiChildLayoutParentData; + assert(() { + if (childParentData.id == null) { + throw FlutterError.fromParts([ + 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([ + 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(_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, + RenderBoxContainerDefaultsMixin { + /// Creates a render object that customizes the layout of multiple children. + RenderCustomMultiChildLayoutBox({ + List? 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; +} diff --git a/lib/common/widgets/image/custom_grid_view.dart b/lib/common/widgets/image/custom_grid_view.dart new file mode 100644 index 00000000..e761d215 --- /dev/null +++ b/lib/common/widgets/image/custom_grid_view.dart @@ -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 . + */ + +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 picArr; + final VoidCallback? onViewImage; + final ValueChanged? onDismissed; + final Function(List, 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; + } +} diff --git a/lib/common/widgets/image/image_view.dart b/lib/common/widgets/image/image_view.dart deleted file mode 100644 index dd564d35..00000000 --- a/lib/common/widgets/image/image_view.dart +++ /dev/null @@ -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 picArr, { - VoidCallback? onViewImage, - ValueChanged? onDismissed, - Function(List, 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, - ), - ], - ), - ), - ); - }, - ); -} diff --git a/lib/common/widgets/image/nine_grid_view.dart b/lib/common/widgets/image/nine_grid_view.dart deleted file mode 100644 index 0174bcf5..00000000 --- a/lib/common/widgets/image/nine_grid_view.dart +++ /dev/null @@ -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 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 Kit,using 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 createState() { - return _NineGridViewState(); - } -} - -/// _NineGridViewState. -class _NineGridViewState extends State { - /// 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 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 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 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 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? getImageSize(Image? image) { - if (image == null) { - return null; - } - Completer completer = Completer(); - 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 { - 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 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 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 oldClipper) { - return this != oldClipper; - } -} diff --git a/lib/pages/article/widgets/opus_content.dart b/lib/pages/article/widgets/opus_content.dart index 8cb5a782..939809ac 100644 --- a/lib/pages/article/widgets/opus_content.dart +++ b/lib/pages/article/widgets/opus_content.dart @@ -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, diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index fd30b038..9aaf2ec7 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -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, diff --git a/lib/pages/dynamics/widgets/rich_node_panel.dart b/lib/pages/dynamics/widgets/rich_node_panel.dart index 2b1a7f03..505d6f92 100644 --- a/lib/pages/dynamics/widgets/rich_node_panel.dart +++ b/lib/pages/dynamics/widgets/rich_node_panel.dart @@ -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 ?? '', diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index f6d2685d..15963287 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -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 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, diff --git a/lib/pages/video/reply/widgets/reply_item_grpc.dart b/lib/pages/video/reply/widgets/reply_item_grpc.dart index 8200917e..fe24c684 100644 --- a/lib/pages/video/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/reply/widgets/reply_item_grpc.dart @@ -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,