refa expandable

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-07-11 09:42:05 +08:00
parent 24e17c5cdd
commit 82116480cb
5 changed files with 570 additions and 68 deletions

View File

@@ -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<AnimatedHeight> createState() => _AnimatedHeightState();
}
class _AnimatedHeightState extends State<AnimatedHeight>
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> _clipRectLayer =
LayerHandle<ClipRectLayer>();
@override
void dispose() {
_clipRectLayer.layer = null;
_controller.dispose();
super.dispose();
}
}

View File

@@ -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<AnimatedMultiHeight> createState() => _AnimatedMultiHeightState();
}
class _AnimatedMultiHeightState extends State<AnimatedMultiHeight>
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> _clipRectLayer =
LayerHandle<ClipRectLayer>();
@override
void dispose() {
_clipRectLayer.layer = null;
_controller.dispose();
super.dispose();
}
}

View File

@@ -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<CrossFadeState>(.showFirst);
const Key kSecondChildKey = ValueKey<CrossFadeState>(.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,
),
);
}

View File

@@ -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<ColorSelectPage> {
),
),
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(),
),
),
),
),

View File

@@ -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<UgcIntroPanel> {
..._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(