From 82116480cb267258f7fad46aef71146dec4985fc Mon Sep 17 00:00:00 2001 From: dom Date: Sat, 11 Jul 2026 09:42:05 +0800 Subject: [PATCH] refa expandable Signed-off-by: dom --- lib/common/widgets/animated_height.dart | 245 +++++++++++++++++ lib/common/widgets/animated_multi_height.dart | 260 ++++++++++++++++++ lib/common/widgets/expandable.dart | 27 +- lib/pages/setting/pages/color_select.dart | 93 +++---- lib/pages/video/introduction/ugc/view.dart | 13 +- 5 files changed, 570 insertions(+), 68 deletions(-) create mode 100644 lib/common/widgets/animated_height.dart create mode 100644 lib/common/widgets/animated_multi_height.dart diff --git a/lib/common/widgets/animated_height.dart b/lib/common/widgets/animated_height.dart new file mode 100644 index 000000000..2688297cd --- /dev/null +++ b/lib/common/widgets/animated_height.dart @@ -0,0 +1,245 @@ +import 'package:PiliPlus/utils/extension/num_ext.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' + show ClipRectLayer, LayerHandle, RenderAnimatedSize, RenderProxyBox; + +typedef Heights = ({double from, double to}); + +/// ref [AnimatedSize] +class AnimatedHeight extends StatefulWidget { + const AnimatedHeight({ + super.key, + required this.child, + this.curve = Curves.linear, + required this.duration, + this.reverseDuration, + this.clipBehavior = .hardEdge, + required this.expand, + }); + + final Widget child; + final Curve curve; + final Duration duration; + final Duration? reverseDuration; + final Clip clipBehavior; + final bool expand; + + @override + State createState() => _AnimatedHeightState(); +} + +class _AnimatedHeightState extends State + with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return _AnimatedHeight( + curve: widget.curve, + duration: widget.duration, + reverseDuration: widget.reverseDuration, + vsync: this, + clipBehavior: widget.clipBehavior, + expand: widget.expand, + child: widget.child, + ); + } +} + +class _AnimatedHeight extends SingleChildRenderObjectWidget { + const _AnimatedHeight({ + required Widget super.child, + this.curve = Curves.linear, + required this.duration, + this.reverseDuration, + required this.vsync, + this.clipBehavior = .hardEdge, + required this.expand, + }); + + final Curve curve; + final Duration duration; + final Duration? reverseDuration; + final TickerProvider vsync; + final Clip clipBehavior; + final bool expand; + + @override + RenderAnimatedHeight createRenderObject(BuildContext context) { + return RenderAnimatedHeight( + duration: duration, + reverseDuration: reverseDuration, + curve: curve, + vsync: vsync, + clipBehavior: clipBehavior, + expand: expand, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderAnimatedHeight renderObject, + ) { + renderObject + ..duration = duration + ..reverseDuration = reverseDuration + ..curve = curve + ..vsync = vsync + ..clipBehavior = clipBehavior + ..expand = expand; + } +} + +/// ref [RenderAnimatedSize] +class RenderAnimatedHeight extends RenderProxyBox { + RenderAnimatedHeight({ + required this._vsync, + required Duration duration, + Duration? reverseDuration, + this._curve = Curves.linear, + this._clipBehavior = .hardEdge, + required this._expand, + }) { + _controller = + AnimationController( + vsync: vsync, + value: expand ? 1.0 : 0.0, + duration: duration, + reverseDuration: reverseDuration, + )..addListener(() { + if (_controller.value != _lastValue) { + markNeedsLayout(); + } + }); + } + + bool _expand; + bool get expand => _expand; + set expand(bool value) { + if (_expand == value) return; + _expand = value; + _lastValue = 0.0; + _controller.forward(from: 0); + } + + late final AnimationController _controller; + bool get isAnimating => _controller.isAnimating; + bool get _isInvisible => !isAnimating && !expand; + + double? _lastValue; + Heights? _heights; + + Duration get duration => _controller.duration!; + set duration(Duration value) { + if (value == _controller.duration) { + return; + } + _controller.duration = value; + } + + Duration? get reverseDuration => _controller.reverseDuration; + set reverseDuration(Duration? value) { + if (value == _controller.reverseDuration) { + return; + } + _controller.reverseDuration = value; + } + + Curve _curve; + Curve get curve => _curve; + set curve(Curve value) { + if (value == _curve) { + return; + } + _curve = value; + } + + Clip get clipBehavior => _clipBehavior; + Clip _clipBehavior = .hardEdge; + set clipBehavior(Clip value) { + if (value != _clipBehavior) { + _clipBehavior = value; + markNeedsPaint(); + } + } + + TickerProvider get vsync => _vsync; + TickerProvider _vsync; + set vsync(TickerProvider value) { + if (value == _vsync) { + return; + } + _vsync = value; + _controller.resync(vsync); + } + + @override + void detach() { + _controller.stop(); + super.detach(); + } + + @override + void performLayout() { + if (_isInvisible) { + _heights = const (from: 0, to: 0); + size = .zero; + return; + } + + _lastValue = _controller.value; + + final BoxConstraints constraints = this.constraints; + final childSize = (child!..layout(constraints, parentUsesSize: true)).size; + + final Size animatedSize; + + if (isAnimating && _heights != null) { + final to = expand ? childSize.height : 0.0; + if (_heights!.to != to) { + _heights = (from: size.height, to: to); + } + animatedSize = Size( + childSize.width, + curve.transform(_controller.value).lerp(_heights!.from, _heights!.to), + ); + } else { + animatedSize = childSize; + _heights = (from: childSize.height, to: childSize.height); + } + + size = constraints.constrain(animatedSize); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_isInvisible) { + _clipRectLayer.layer = null; + return; + } + + if (isAnimating && clipBehavior != .none) { + final Rect rect = Offset.zero & size; + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + rect, + super.paint, + clipBehavior: clipBehavior, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + super.paint(context, offset); + } + } + + final LayerHandle _clipRectLayer = + LayerHandle(); + + @override + void dispose() { + _clipRectLayer.layer = null; + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/common/widgets/animated_multi_height.dart b/lib/common/widgets/animated_multi_height.dart new file mode 100644 index 000000000..157cb1ba4 --- /dev/null +++ b/lib/common/widgets/animated_multi_height.dart @@ -0,0 +1,260 @@ +import 'package:PiliPlus/common/widgets/animated_height.dart' show Heights; +import 'package:PiliPlus/utils/extension/num_ext.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' + show ClipRectLayer, LayerHandle, PipelineOwner, RenderProxyBox; + +class AnimatedMultiHeight extends StatefulWidget { + const AnimatedMultiHeight({ + super.key, + required Widget this.child, + this.curve = Curves.linear, + required this.duration, + this.reverseDuration, + this.clipBehavior = .hardEdge, + this.onEnd, + required this.expand, + }); + + final Widget? child; + final Curve curve; + final Duration duration; + final Duration? reverseDuration; + final Clip clipBehavior; + final VoidCallback? onEnd; + final bool expand; + + @override + State createState() => _AnimatedMultiHeightState(); +} + +class _AnimatedMultiHeightState extends State + with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return _AnimatedMultiHeight( + curve: widget.curve, + duration: widget.duration, + reverseDuration: widget.reverseDuration, + vsync: this, + clipBehavior: widget.clipBehavior, + onEnd: widget.onEnd, + expand: widget.expand, + child: widget.child, + ); + } +} + +class _AnimatedMultiHeight extends SingleChildRenderObjectWidget { + const _AnimatedMultiHeight({ + super.child, + this.curve = Curves.linear, + required this.duration, + this.reverseDuration, + required this.vsync, + this.clipBehavior = .hardEdge, + this.onEnd, + required this.expand, + }); + + final Curve curve; + final Duration duration; + final Duration? reverseDuration; + final TickerProvider vsync; + final Clip clipBehavior; + final VoidCallback? onEnd; + final bool expand; + + @override + RenderAnimatedMultiHeight createRenderObject(BuildContext context) { + return RenderAnimatedMultiHeight( + duration: duration, + reverseDuration: reverseDuration, + curve: curve, + vsync: vsync, + clipBehavior: clipBehavior, + onEnd: onEnd, + expand: expand, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderAnimatedMultiHeight renderObject, + ) { + renderObject + ..duration = duration + ..reverseDuration = reverseDuration + ..curve = curve + ..vsync = vsync + ..clipBehavior = clipBehavior + ..onEnd = onEnd + ..expand = expand; + } +} + +class RenderAnimatedMultiHeight extends RenderProxyBox { + RenderAnimatedMultiHeight({ + required TickerProvider vsync, + required Duration duration, + Duration? reverseDuration, + this._curve = Curves.linear, + this._clipBehavior = .hardEdge, + this._onEnd, + required this._expand, + }) : _vsync = vsync { + _controller = + AnimationController( + vsync: vsync, + value: _expand ? 1.0 : 0.0, + duration: duration, + reverseDuration: reverseDuration, + )..addListener(() { + if (_controller.value != _lastValue) { + markNeedsLayout(); + } + }); + } + + bool _expand; + bool get expand => _expand; + set expand(bool value) { + if (_expand == value) return; + _expand = value; + _lastValue = 0.0; + _controller.forward(from: 0.0); + } + + late final AnimationController _controller; + bool get isAnimating => _controller.isAnimating; + + double? _lastValue; + Heights? _heights; + + Duration get duration => _controller.duration!; + set duration(Duration value) { + if (value == _controller.duration) { + return; + } + _controller.duration = value; + } + + Duration? get reverseDuration => _controller.reverseDuration; + set reverseDuration(Duration? value) { + if (value == _controller.reverseDuration) { + return; + } + _controller.reverseDuration = value; + } + + Curve _curve; + Curve get curve => _curve; + set curve(Curve value) { + if (value == _curve) { + return; + } + _curve = value; + } + + Clip get clipBehavior => _clipBehavior; + Clip _clipBehavior = .hardEdge; + set clipBehavior(Clip value) { + if (value != _clipBehavior) { + _clipBehavior = value; + markNeedsPaint(); + } + } + + TickerProvider get vsync => _vsync; + TickerProvider _vsync; + set vsync(TickerProvider value) { + if (value == _vsync) { + return; + } + _vsync = value; + _controller.resync(vsync); + } + + VoidCallback? get onEnd => _onEnd; + VoidCallback? _onEnd; + set onEnd(VoidCallback? value) { + if (value == _onEnd) { + return; + } + _onEnd = value; + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _controller.addStatusListener(_animationStatusListener); + } + + @override + void detach() { + _controller + ..stop() + ..removeStatusListener(_animationStatusListener); + super.detach(); + } + + @override + void performLayout() { + _lastValue = _controller.value; + + final BoxConstraints constraints = this.constraints; + final childSize = (child!..layout(constraints, parentUsesSize: true)).size; + + final Size animatedSize; + if (isAnimating && _heights != null) { + final to = childSize.height; + if (_heights!.to != to) { + _heights = (from: size.height, to: to); + } + animatedSize = Size( + childSize.width, + curve.transform(_controller.value).lerp(_heights!.from, _heights!.to), + ); + } else { + animatedSize = childSize; + _heights = (from: childSize.height, to: childSize.height); + } + + size = constraints.constrain(animatedSize); + } + + void _animationStatusListener(AnimationStatus status) { + if (status.isCompleted) { + _onEnd?.call(); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (isAnimating && clipBehavior != .none) { + final Rect rect = Offset.zero & size; + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + rect, + super.paint, + clipBehavior: clipBehavior, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + super.paint(context, offset); + } + } + + final LayerHandle _clipRectLayer = + LayerHandle(); + + @override + void dispose() { + _clipRectLayer.layer = null; + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/common/widgets/expandable.dart b/lib/common/widgets/expandable.dart index 3d34f779a..93e947274 100644 --- a/lib/common/widgets/expandable.dart +++ b/lib/common/widgets/expandable.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/common/widgets/animated_multi_height.dart'; import 'package:flutter/material.dart'; class ExpandablePanel extends StatelessWidget { @@ -102,6 +103,7 @@ class _AnimatedCrossState extends State<_AnimatedCross> { const Key kFirstChildKey = ValueKey(.showFirst); const Key kSecondChildKey = ValueKey(.showSecond); + final bool expand; final Key topKey; Widget topChild; final Key bottomKey; @@ -109,30 +111,29 @@ class _AnimatedCrossState extends State<_AnimatedCross> { switch (widget.crossFadeState) { case .showFirst: + expand = false; topKey = kFirstChildKey; topChild = firstChild; bottomKey = kSecondChildKey; bottomChild = secondChild; case .showSecond: + expand = true; topKey = kSecondChildKey; topChild = secondChild; bottomKey = kFirstChildKey; bottomChild = firstChild; } - return ClipRect( - child: AnimatedSize( - clipBehavior: .none, - alignment: widget.alignment, - duration: widget.duration, - curve: widget.sizeCurve, - onEnd: _onEnd, - child: AnimatedCrossFade.defaultLayoutBuilder( - topChild, - topKey, - bottomChild, - bottomKey, - ), + return AnimatedMultiHeight( + duration: widget.duration, + curve: widget.sizeCurve, + onEnd: _onEnd, + expand: expand, + child: AnimatedCrossFade.defaultLayoutBuilder( + topChild, + topKey, + bottomChild, + bottomKey, ), ); } diff --git a/lib/pages/setting/pages/color_select.dart b/lib/pages/setting/pages/color_select.dart index b80766ea9..b38dfd623 100644 --- a/lib/pages/setting/pages/color_select.dart +++ b/lib/pages/setting/pages/color_select.dart @@ -1,5 +1,6 @@ import 'dart:io' show Platform; +import 'package:PiliPlus/common/widgets/animated_height.dart'; import 'package:PiliPlus/common/widgets/color_palette.dart'; import 'package:PiliPlus/main.dart' show MyApp; import 'package:PiliPlus/models/common/nav_bar_config.dart'; @@ -135,56 +136,50 @@ class _ColorSelectPageState extends State { ), ), Padding( - padding: padding, - child: AnimatedSize( - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - duration: const Duration(milliseconds: 200), - child: Obx( - () => ctr.dynamicColor.value - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.all(12), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 22, - runSpacing: 18, - children: colorThemeTypes.mapIndexed( - (index, item) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - ctr.currentColor.value = index; - GStorage.setting - .put(SettingBoxKey.customColor, index) - .whenComplete(Get.updateMyAppTheme); - }, - child: Column( - spacing: 3, - children: [ - ColorPalette( - colorScheme: item.color.asColorSchemeSeed( - _dynamicSchemeVariant, - theme.brightness, - ), - selected: ctr.currentColor.value == index, - ), - Text( - item.label, - style: TextStyle( - fontSize: 12, - color: ctr.currentColor.value != index - ? theme.colorScheme.outline - : null, - ), - ), - ], - ), - ); - }, - ).toList(), + padding: padding + const .all(12), + child: Obx( + () => AnimatedHeight( + expand: ctr.dynamicColor.value, + duration: const Duration(milliseconds: 200), + child: Wrap( + alignment: .center, + spacing: 22, + runSpacing: 18, + children: colorThemeTypes.mapIndexed( + (i, e) { + return GestureDetector( + behavior: .opaque, + onTap: () { + ctr.currentColor.value = i; + GStorage.setting + .put(SettingBoxKey.customColor, i) + .whenComplete(Get.updateMyAppTheme); + }, + child: Column( + spacing: 3, + children: [ + ColorPalette( + colorScheme: e.color.asColorSchemeSeed( + _dynamicSchemeVariant, + theme.brightness, + ), + selected: ctr.currentColor.value == i, + ), + Text( + e.label, + style: TextStyle( + fontSize: 12, + color: ctr.currentColor.value != i + ? theme.colorScheme.outline + : null, + ), + ), + ], ), - ), + ); + }, + ).toList(), + ), ), ), ), diff --git a/lib/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index 3d32ca8ae..8cf0a92aa 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -1,6 +1,7 @@ import 'package:PiliPlus/common/assets.dart'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/style.dart'; +import 'package:PiliPlus/common/widgets/animated_height.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/expandable.dart'; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; @@ -235,14 +236,14 @@ class _UgcIntroPanelState extends State { ..._infos(theme, videoDetail) else Obx( - () => ExpandablePanel( - collapsed: const SizedBox(width: .infinity, height: 0), - expanded: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + () => AnimatedHeight( + expand: introController.expand.value, + duration: const Duration(milliseconds: 300), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, children: _infos(theme, videoDetail), ), - expand: introController.expand.value, ), ), Obx(