diff --git a/lib/common/widgets/custom_height_widget.dart b/lib/common/widgets/custom_height_widget.dart index 6d14c357b..b6958c7ed 100644 --- a/lib/common/widgets/custom_height_widget.dart +++ b/lib/common/widgets/custom_height_widget.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show RenderProxyBox; +import 'package:flutter/rendering.dart' show RenderProxyBox, BoxHitTestResult; class CustomHeightWidget extends SingleChildRenderObjectWidget { const CustomHeightWidget({ super.key, - required this.height, + this.height, this.offset = .zero, - required super.child, + required Widget super.child, }); - final double height; + final double? height; final Offset offset; @@ -34,14 +34,14 @@ class CustomHeightWidget extends SingleChildRenderObjectWidget { class RenderCustomHeightWidget extends RenderProxyBox { RenderCustomHeightWidget({ - required double height, + double? height, required Offset offset, }) : _height = height, _offset = offset; - double _height; - double get height => _height; - set height(double value) { + double? _height; + double? get height => _height; + set height(double? value) { if (_height == value) return; _height = value; markNeedsLayout(); @@ -57,12 +57,40 @@ class RenderCustomHeightWidget extends RenderProxyBox { @override void performLayout() { - child!.layout(constraints); - size = constraints.constrainDimensions(constraints.maxWidth, height); + if (height != null) { + child!.layout(constraints.copyWith(maxHeight: .infinity)); + size = constraints.constrainDimensions(constraints.maxWidth, height!); + } else { + child!.layout( + constraints.copyWith(maxHeight: .infinity), + parentUsesSize: true, + ); + size = constraints.constrainDimensions( + constraints.maxWidth, + child!.size.height, + ); + } } @override void paint(PaintingContext context, Offset offset) { context.paintChild(child!, offset + _offset); } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + return result.addWithPaintOffset( + offset: _offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - _offset); + return child!.hitTest(result, position: transformed); + }, + ); + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + transform.translateByDouble(_offset.dx, _offset.dy, 0.0, 1.0); + } } diff --git a/lib/common/widgets/dynamic_sliver_app_bar/dynamic_sliver_app_bar.dart b/lib/common/widgets/dynamic_sliver_app_bar/dynamic_sliver_app_bar.dart index 523178580..e64acbcbe 100644 --- a/lib/common/widgets/dynamic_sliver_app_bar/dynamic_sliver_app_bar.dart +++ b/lib/common/widgets/dynamic_sliver_app_bar/dynamic_sliver_app_bar.dart @@ -17,6 +17,7 @@ import 'dart:math' as math; +import 'package:PiliPlus/common/widgets/custom_height_widget.dart'; import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/rendering/sliver_persistent_header.dart'; import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/sliver_persistent_header.dart'; import 'package:PiliPlus/common/widgets/only_layout_widget.dart' @@ -24,6 +25,7 @@ import 'package:PiliPlus/common/widgets/only_layout_widget.dart' import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide SliverPersistentHeader, SliverPersistentHeaderDelegate; +import 'package:flutter/rendering.dart' show RenderOpacity, OpacityLayer; import 'package:flutter/services.dart'; /// ref [SliverAppBar] @@ -133,12 +135,10 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { title: effectiveTitle, actions: actions, automaticallyImplyActions: automaticallyImplyActions, - flexibleSpace: maxExtent == .infinity - ? flexibleSpace - : IgnorePointer( - ignoring: isScrolledUnder, - child: FlexibleSpaceBar(background: flexibleSpace), - ), + flexibleSpace: IgnorePointer( + ignoring: isScrolledUnder, + child: DynamicFlexibleSpaceBar(background: flexibleSpace), + ), bottom: bottom, elevation: isScrolledUnder ? elevation : 0.0, scrolledUnderElevation: scrolledUnderElevation, @@ -350,3 +350,148 @@ class _DynamicSliverAppBarState extends State { ); } } + +/// ref [FlexibleSpaceBar] +class DynamicFlexibleSpaceBar extends StatefulWidget { + const DynamicFlexibleSpaceBar({ + super.key, + required this.background, + this.collapseMode = CollapseMode.parallax, + }); + + final Widget background; + + final CollapseMode collapseMode; + + @override + State createState() => + _DynamicFlexibleSpaceBarState(); +} + +class _DynamicFlexibleSpaceBarState extends State { + double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) { + switch (widget.collapseMode) { + case CollapseMode.pin: + return -(settings.maxExtent - settings.currentExtent); + case CollapseMode.none: + return 0.0; + case CollapseMode.parallax: + final double deltaExtent = settings.maxExtent - settings.minExtent; + return -Tween(begin: 0.0, end: deltaExtent / 4.0).transform(t); + } + } + + @override + Widget build(BuildContext context) { + final FlexibleSpaceBarSettings settings = context + .dependOnInheritedWidgetOfExactType()!; + + double? height; + final double opacity; + final double topPadding; + if (settings.maxExtent == .infinity) { + opacity = 1.0; + topPadding = 0.0; + } else { + final double deltaExtent = settings.maxExtent - settings.minExtent; + + // 0.0 -> Expanded + // 1.0 -> Collapsed to toolbar + final double t = clampDouble( + 1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent, + 0.0, + 1.0, + ); + + final double fadeStart = math.max( + 0.0, + 1.0 - kToolbarHeight / deltaExtent, + ); + const fadeEnd = 1.0; + assert(fadeStart <= fadeEnd); + // If the min and max extent are the same, the app bar cannot collapse + // and the content should be visible, so opacity = 1. + opacity = settings.maxExtent == settings.minExtent + ? 1.0 + : 1.0 - Interval(fadeStart, fadeEnd).transform(t); + + topPadding = _getCollapsePadding(t, settings); + } + + return ClipRect( + child: CustomHeightWidget( + height: height, + offset: Offset(0.0, topPadding), + child: _FlexibleSpaceHeaderOpacity( + // IOS is relying on this semantics node to correctly traverse + // through the app bar when it is collapsed. + alwaysIncludeSemantics: true, + opacity: opacity, + child: widget.background, + ), + ), + ); + } +} + +/// [_FlexibleSpaceHeaderOpacity] +class _FlexibleSpaceHeaderOpacity extends SingleChildRenderObjectWidget { + const _FlexibleSpaceHeaderOpacity({ + required this.opacity, + required super.child, + required this.alwaysIncludeSemantics, + }); + + final double opacity; + final bool alwaysIncludeSemantics; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderFlexibleSpaceHeaderOpacity( + opacity: opacity, + alwaysIncludeSemantics: alwaysIncludeSemantics, + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderFlexibleSpaceHeaderOpacity renderObject, + ) { + renderObject + ..alwaysIncludeSemantics = alwaysIncludeSemantics + ..opacity = opacity; + } +} + +class _RenderFlexibleSpaceHeaderOpacity extends RenderOpacity { + _RenderFlexibleSpaceHeaderOpacity({ + super.opacity, + super.alwaysIncludeSemantics, + }); + + @override + bool get isRepaintBoundary => false; + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) { + return; + } + if ((opacity * 255).roundToDouble() <= 0) { + layer = null; + return; + } + assert(needsCompositing); + layer = context.pushOpacity( + offset, + (opacity * 255).round(), + super.paint, + oldLayer: layer as OpacityLayer?, + ); + assert(() { + layer!.debugCreator = debugCreator; + return true; + }()); + } +} diff --git a/lib/common/widgets/image_viewer/gallery_viewer.dart b/lib/common/widgets/image_viewer/gallery_viewer.dart index 2e324f981..9bc185c67 100644 --- a/lib/common/widgets/image_viewer/gallery_viewer.dart +++ b/lib/common/widgets/image_viewer/gallery_viewer.dart @@ -54,6 +54,7 @@ class GalleryViewer extends StatefulWidget { required this.quality, required this.sources, this.initIndex = 0, + this.onPageChanged, }); final double minScale; @@ -61,6 +62,7 @@ class GalleryViewer extends StatefulWidget { final int quality; final List sources; final int initIndex; + final ValueChanged? onPageChanged; @override State createState() => _GalleryViewerState(); @@ -346,6 +348,7 @@ class _GalleryViewerState extends State _player?.pause(); _playIfNeeded(widget.sources[index]); _currIndex.value = index; + widget.onPageChanged?.call(index); } late final ValueChanged? _onChangePage = widget.sources.length == 1 diff --git a/lib/common/widgets/pendant_avatar.dart b/lib/common/widgets/pendant_avatar.dart index 7d0ae796f..3b336524e 100644 --- a/lib/common/widgets/pendant_avatar.dart +++ b/lib/common/widgets/pendant_avatar.dart @@ -15,11 +15,13 @@ class PendantAvatar extends StatelessWidget { final String? garbPendantImage; final int? roomId; final VoidCallback? onTap; + final bool isMemberAvatar; const PendantAvatar({ super.key, required this.avatar, - this.size = 80, + required this.size, + this.isMemberAvatar = false, double? badgeSize, bool isVip = false, int? officialType, @@ -42,13 +44,12 @@ class PendantAvatar extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final isMemberAvatar = size == 80; Widget? pendant; if (showDynDecorate && !garbPendantImage.isNullOrEmpty) { final pendantSize = size * 1.75; pendant = Positioned( // -(size * 1.75 - size) / 2 - top: -0.375 * size + (size == 80 ? 2 : 0), + top: -0.375 * size + (isMemberAvatar ? 2 : 0), child: IgnorePointer( child: NetworkImgLayer( type: .emote, diff --git a/lib/models_new/space/space/top.dart b/lib/models_new/space/space/top.dart index 65100d5e3..a378bd3b5 100644 --- a/lib/models_new/space/space/top.dart +++ b/lib/models_new/space/space/top.dart @@ -1,9 +1,40 @@ +import 'package:PiliPlus/utils/parse_string.dart'; + class Top { - dynamic result; + List? imgUrls; - Top({this.result}); + Top({this.imgUrls}); - factory Top.fromJson(Map json) => Top( - result: json['result'] as dynamic, - ); + Top.fromJson(Map json) { + try { + final list = json['result'] as List?; + if (list != null && list.isNotEmpty) { + imgUrls = list.map((e) => TopImage.fromJson(e)).toList(); + } + } catch (_) {} + } +} + +class TopImage { + late final String cover; + late final double dy; + + TopImage.fromJson(Map json) { + cover = + noneNullOrEmptyString(json['item']?['image']?['default_image']) ?? + json['cover']; + try { + final Map image = json['item']['image'] ?? json['item']['animation']; + final num halfHeight = (image['height'] as num) / 2; + final List location = (image['location'] as String) + .split('-') + .map(num.parse) + .toList(); + final start = location[1]; + final end = location[2]; + dy = (start + (end - start) / 2 - halfHeight) / halfHeight; + } catch (_) { + dy = 0.0; + } + } } diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index f37d6a3c9..9d22d3154 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -39,6 +39,8 @@ class _MemberPageState extends State { late final int _mid; late final String _heroTag; late final MemberController _userController; + PageController? _headerController; + PageController get headerController => _headerController ??= PageController(); @override void initState() { @@ -51,6 +53,13 @@ class _MemberPageState extends State { ); } + @override + void dispose() { + _headerController?.dispose(); + _headerController = null; + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -358,6 +367,7 @@ class _MemberPageState extends State { onFollow: () => _userController.onFollow(context), live: _userController.live, silence: _userController.silence, + headerControllerBuilder: () => headerController, ), ), ); diff --git a/lib/pages/member/widget/header_layout_widget.dart b/lib/pages/member/widget/header_layout_widget.dart index 5d719a68e..de18a775b 100644 --- a/lib/pages/member/widget/header_layout_widget.dart +++ b/lib/pages/member/widget/header_layout_widget.dart @@ -23,8 +23,9 @@ import 'package:flutter/rendering.dart' show BoxHitTestResult, BoxParentData; const double kHeaderHeight = 135.0; const double kAvatarSize = 80.0; +const double kPendantAvatarSize = 70.0; const double _kAvatarLeftPadding = 20.0; -const double _kAvatarTopPadding = 110.0; +const double _kAvatarTopPadding = 115.0; const double _kAvatarEffectiveHeight = kAvatarSize - (kHeaderHeight - _kAvatarTopPadding); diff --git a/lib/pages/member/widget/user_info_card.dart b/lib/pages/member/widget/user_info_card.dart index ded97fe71..5593d0a61 100644 --- a/lib/pages/member/widget/user_info_card.dart +++ b/lib/pages/member/widget/user_info_card.dart @@ -2,6 +2,7 @@ import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/avatars.dart'; import 'package:PiliPlus/common/widgets/image_viewer/hero.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; +import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/common/widgets/view_safe_area.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/member/user_info_type.dart'; @@ -10,6 +11,7 @@ import 'package:PiliPlus/models_new/space/space/followings_followed_upper.dart'; import 'package:PiliPlus/models_new/space/space/images.dart'; import 'package:PiliPlus/models_new/space/space/live.dart'; import 'package:PiliPlus/models_new/space/space/pr_info.dart'; +import 'package:PiliPlus/models_new/space/space/top.dart'; import 'package:PiliPlus/pages/fan/view.dart'; import 'package:PiliPlus/pages/follow/view.dart'; import 'package:PiliPlus/pages/follow_type/followed/view.dart'; @@ -38,6 +40,7 @@ class UserInfoCard extends StatelessWidget { required this.onFollow, this.live, this.silence, + required this.headerControllerBuilder, }); final bool isOwner; @@ -47,6 +50,7 @@ class UserInfoCard extends StatelessWidget { final VoidCallback onFollow; final Live? live; final int? silence; + final ValueGetter headerControllerBuilder; @override Widget build(BuildContext context) { @@ -111,37 +115,6 @@ class UserInfoCard extends StatelessWidget { ); } - Widget _buildHeader( - BuildContext context, - ColorScheme colorScheme, - bool isLight, - double width, - ) { - String imgUrl = - (isLight - ? images.imgUrl - : images.nightImgurl.isNullOrEmpty - ? images.imgUrl - : images.nightImgurl) - .http2https; - return GestureDetector( - onTap: () => PageUtils.imageView(imgList: [SourceModel(url: imgUrl)]), - child: fromHero( - tag: imgUrl, - child: CachedNetworkImage( - fit: .cover, - height: kHeaderHeight, - width: width, - memCacheWidth: width.cacheSize(context), - imageUrl: ImageUtils.thumbnailUrl(imgUrl), - placeholder: (_, _) => const SizedBox.shrink(), - color: isLight ? const Color(0x5DFFFFFF) : const Color(0x8D000000), - colorBlendMode: isLight ? BlendMode.lighten : BlendMode.darken, - ), - ), - ); - } - List _buildLeft( BuildContext context, ColorScheme colorScheme, @@ -455,15 +428,16 @@ class UserInfoCard extends StatelessWidget { ], ); - Widget get _buildAvatar => fromHero( + Widget _buildAvatar(bool hasPendant) => fromHero( tag: card.face ?? '', child: PendantAvatar( avatar: card.face, - size: kAvatarSize, + size: hasPendant ? kPendantAvatarSize : kAvatarSize, + isMemberAvatar: true, badgeSize: 20, officialType: card.officialVerify?.type, isVip: (card.vip?.status ?? -1) > 0, - garbPendantImage: card.pendant!.image!, + garbPendantImage: card.pendant?.image, roomId: live?.liveStatus == 1 ? live!.roomid : null, onTap: () => PageUtils.imageView( imgList: [SourceModel(url: card.face.http2https)], @@ -473,25 +447,146 @@ class UserInfoCard extends StatelessWidget { Column _buildV( BuildContext context, - ColorScheme colorScheme, + ColorScheme scheme, bool isLight, double width, - ) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - HeaderLayoutWidget( - header: _buildHeader(context, colorScheme, isLight, width), - avatar: _buildAvatar, - actions: _buildRight(colorScheme), + ) { + final hasPendant = card.pendant?.image?.isNotEmpty ?? false; + final imgUrls = images.collectionTopSimple?.top?.imgUrls; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeaderLayoutWidget( + header: imgUrls != null + ? _buildCollectionHeader(context, scheme, isLight, imgUrls, width) + : _buildHeader( + context, + isLight, + width, + (isLight + ? images.imgUrl + : images.nightImgurl.isNullOrEmpty + ? images.imgUrl + : images.nightImgurl) + .http2https, + ), + avatar: _buildAvatar(hasPendant), + actions: _buildRight(scheme), + ), + const SizedBox(height: 5), + ..._buildLeft(context, scheme, isLight), + if (card.prInfo?.content?.isNotEmpty == true) + buildPrInfo(context, scheme, isLight, card.prInfo!), + const SizedBox(height: 5), + ], + ); + } + + Widget _buildCollectionHeader( + BuildContext context, + ColorScheme scheme, + bool isLight, + List imgUrls, + double width, + ) { + if (imgUrls.length == 1) { + return _buildHeader( + context, + isLight, + width, + imgUrls.single.cover, + filter: false, + ); + } + final controller = headerControllerBuilder(); + final memCacheWidth = width.cacheSize(context); + return GestureDetector( + behavior: .opaque, + onTap: () => PageUtils.imageView( + initialPage: controller.page?.round() ?? 0, + imgList: imgUrls.map((e) => SourceModel(url: e.cover)).toList(), + onPageChanged: controller.jumpToPage, ), - const SizedBox(height: 5), - ..._buildLeft(context, colorScheme, isLight), - if (card.prInfo?.content?.isNotEmpty == true) - buildPrInfo(context, colorScheme, isLight, card.prInfo!), - const SizedBox(height: 5), - ], - ); + child: Stack( + children: [ + SizedBox( + width: .infinity, + height: kHeaderHeight, + child: PageView.builder( + controller: controller, + itemCount: imgUrls.length, + physics: clampingScrollPhysics, + itemBuilder: (context, index) { + final img = imgUrls[index]; + return fromHero( + tag: img.cover, + child: CachedNetworkImage( + fit: .cover, + alignment: Alignment(0.0, img.dy), + height: kHeaderHeight, + width: width, + memCacheWidth: memCacheWidth, + imageUrl: ImageUtils.thumbnailUrl(img.cover), + fadeInDuration: const Duration(milliseconds: 120), + fadeOutDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => + const SizedBox(width: .infinity, height: kHeaderHeight), + ), + ); + }, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: HeaderIndicator( + length: imgUrls.length, + pageController: controller, + ), + ), + ], + ), + ); + } + + Widget _buildHeader( + BuildContext context, + bool isLight, + double width, + String imgUrl, { + bool filter = true, + }) { + return GestureDetector( + behavior: .opaque, + onTap: () => PageUtils.imageView(imgList: [SourceModel(url: imgUrl)]), + child: fromHero( + tag: imgUrl, + child: CachedNetworkImage( + fit: .cover, + height: kHeaderHeight, + width: width, + memCacheWidth: width.cacheSize(context), + imageUrl: ImageUtils.thumbnailUrl(imgUrl), + placeholder: (_, _) => + const SizedBox(width: .infinity, height: kHeaderHeight), + color: filter + ? isLight + ? const Color(0x5DFFFFFF) + : const Color(0x8D000000) + : null, + colorBlendMode: filter + ? isLight + ? BlendMode.lighten + : BlendMode.darken + : null, + fadeInDuration: const Duration(milliseconds: 120), + fadeOutDuration: const Duration(milliseconds: 120), + ), + ), + ); + } Widget buildPrInfo( BuildContext context, @@ -520,6 +615,8 @@ class UserInfoCard extends StatelessWidget { memCacheHeight: 20.cacheSize(context), imageUrl: ImageUtils.thumbnailUrl(icon), placeholder: (_, _) => const SizedBox.shrink(), + fadeInDuration: .zero, + fadeOutDuration: .zero, ), const SizedBox(width: 16), ], @@ -554,7 +651,7 @@ class UserInfoCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // _buildHeader(context), - const SizedBox(height: 56), + const SizedBox(height: kToolbarHeight), Row( children: [ const SizedBox(width: 20), @@ -563,7 +660,7 @@ class UserInfoCard extends StatelessWidget { top: 10, bottom: card.prInfo?.content?.isNotEmpty == true ? 0 : 10, ), - child: _buildAvatar, + child: _buildAvatar(card.pendant?.image?.isNotEmpty ?? false), ), const SizedBox(width: 10), Expanded( @@ -632,3 +729,54 @@ class UserInfoCard extends StatelessWidget { ); } } + +class HeaderIndicator extends StatefulWidget { + const HeaderIndicator({ + super.key, + required this.length, + required this.pageController, + }); + + final int length; + final PageController pageController; + + @override + State createState() => _HeaderIndicatorState(); +} + +class _HeaderIndicatorState extends State { + late double _progress; + + @override + void initState() { + super.initState(); + _updateProgress(); + widget.pageController.addListener(_listener); + } + + void _listener() { + _updateProgress(); + setState(() {}); + } + + void _updateProgress() { + _progress = ((widget.pageController.page ?? 0) + 1) / widget.length; + } + + @override + void dispose() { + widget.pageController.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LinearProgressIndicator( + // ignore: deprecated_member_use + year2023: true, + minHeight: 3.5, + backgroundColor: const Color(0xA09E9E9E), + value: _progress, + ); + } +} diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 97ca641a0..57739e4f1 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -48,6 +48,7 @@ abstract final class PageUtils { int initialPage = 0, required List imgList, int? quality, + ValueChanged? onPageChanged, }) { return Get.key.currentState!.push( HeroDialogRoute( @@ -55,6 +56,7 @@ abstract final class PageUtils { sources: imgList, initIndex: initialPage, quality: quality ?? GlobalData().imgQuality, + onPageChanged: onPageChanged, ), ), );