Last active
December 13, 2023 14:10
-
-
Save pskink/81aa39d1eefd58d72fb8b945499487ec to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/scheduler.dart'; | |
// import 'package:boxy/boxy.dart'; | |
typedef AccordionItem = ({Widget header, Widget body, double offsetFactor}); | |
class Accordion extends StatefulWidget { | |
const Accordion({ | |
super.key, | |
required this.itemCount, | |
required this.itemBuilder, | |
required this.controller, | |
this.duration = const Duration(milliseconds: 800), | |
Duration? reverseDuration, | |
this.curve = Curves.easeIn, | |
Curve? reverseCurve, | |
}) : reverseDuration = reverseDuration ?? duration, reverseCurve = reverseCurve ?? curve; | |
final int itemCount; | |
final AccordionItem Function(BuildContext context, int index, bool active, Animation<double> animation) itemBuilder; | |
final AccordionController controller; | |
final Duration duration; | |
final Duration reverseDuration; | |
final Curve curve; | |
final Curve reverseCurve; | |
@override | |
State<Accordion> createState() => _AccordionState(); | |
} | |
typedef _AccordionSection = ({AnimationController controller, CurvedAnimation animation, double offsetFactor}); | |
class _AccordionState extends State<Accordion> with TickerProviderStateMixin { | |
final sections = <_AccordionSection>[]; | |
int activeSectionIndex = -1; | |
@override | |
void initState() { | |
super.initState(); | |
assert(widget.controller.index < widget.itemCount); | |
activeSectionIndex = widget.controller.index; | |
widget.controller.addListener(_sectionClicked); | |
} | |
@override | |
Widget build(BuildContext context) { | |
// timeDilation = 10; | |
final children = <Widget>[]; | |
bool buildSections = false; | |
if (sections.length != widget.itemCount) { | |
buildSections = true; | |
sections.forEach(_disposeSection); | |
sections.clear(); | |
} | |
for (int i = 0; i < widget.itemCount; i++) { | |
final controller = buildSections? | |
AnimationController(vsync: this, duration: widget.duration, reverseDuration: widget.reverseDuration) : | |
sections[i].controller; | |
final item = widget.itemBuilder(context, i, i == activeSectionIndex, controller); | |
assert(0 < item.offsetFactor && item.offsetFactor <= 1); | |
children | |
..add(item.header) | |
..add(item.body); | |
if (buildSections) { | |
final section = ( | |
controller: controller, | |
animation: CurvedAnimation(parent: controller, curve: widget.curve, reverseCurve: widget.reverseCurve), | |
offsetFactor: i < widget.itemCount - 1? item.offsetFactor : 1.0 | |
); | |
sections.add(section); | |
} | |
} | |
return ClipRect( | |
// NOTE: instead of complex _Accordion and _RenderAccordion classes | |
// you can use handy CustomBoxy (package:boxy/boxy.dart) with simple | |
// _AccordionDelegate (see commented code below) | |
child: _Accordion( | |
sections: sections, | |
children: children, | |
), | |
// child: CustomBoxy( | |
// delegate: _AccordionDelegate( | |
// sections: sections, | |
// ), | |
// children: children, | |
// ), | |
); | |
} | |
@override | |
void dispose() { | |
widget.controller.removeListener(_sectionClicked); | |
sections.forEach(_disposeSection); | |
super.dispose(); | |
} | |
_disposeSection(_AccordionSection section) => section | |
..animation.dispose() | |
..controller.dispose(); | |
void _sectionClicked() { | |
final index = widget.controller.index; | |
for (int i = 0; i < sections.length; i++) { | |
final controller = sections[i].controller; | |
final status = controller.status; | |
if (i == index) { | |
switch (status) { | |
case AnimationStatus.dismissed || AnimationStatus.reverse: | |
activeSectionIndex = index; | |
controller.forward(); | |
case AnimationStatus.completed || AnimationStatus.forward: | |
activeSectionIndex = -1; | |
controller.reverse(); | |
} | |
} else | |
if (status == AnimationStatus.completed || status == AnimationStatus.forward) { | |
controller.reverse(); | |
} | |
} | |
setState(() {}); | |
} | |
} | |
/* | |
class _AccordionDelegate extends BoxyDelegate { | |
_AccordionDelegate({ | |
required this.sections, | |
}) : super(relayout: Listenable.merge(sections.map((section) => section.animation).toList())); | |
final List<_AccordionSection> sections; | |
@override | |
Size layout() { | |
double offset = 0; | |
for (int i = 0; i < children.length; i += 2) { | |
final tab = i ~/ 2; | |
final header = children[i]; | |
final body = children[i + 1]; | |
final headerSize = header.layout(constraints); | |
final bodySize = body.layout(constraints); | |
header.position(Offset(0, offset)); | |
body.position(Offset(0, offset + headerSize.height)); | |
final section = sections[tab]; | |
if (section.animation.value > 0) { | |
offset += bodySize.height * section.animation.value; | |
} | |
offset += headerSize.height * section.offsetFactor; | |
} | |
return Size(constraints.maxWidth, offset); | |
} | |
} | |
*/ | |
class _Accordion extends MultiChildRenderObjectWidget { | |
const _Accordion({ | |
required this.sections, | |
required super.children, | |
}); | |
final List<_AccordionSection> sections; | |
@override | |
RenderObject createRenderObject(BuildContext context) { | |
return _RenderAccordion( | |
sections: sections, | |
); | |
} | |
} | |
class _AccordionParentData extends ContainerBoxParentData<RenderBox> { } | |
class _RenderAccordion extends RenderBox | |
with ContainerRenderObjectMixin<RenderBox, _AccordionParentData>, | |
RenderBoxContainerDefaultsMixin<RenderBox, _AccordionParentData> { | |
_RenderAccordion({ | |
required this.sections, | |
}); | |
List<_AccordionSection> sections; | |
@override | |
void setupParentData(RenderBox child) { | |
if (child.parentData is! _AccordionParentData) { | |
child.parentData = _AccordionParentData(); | |
} | |
} | |
@override | |
void attach(PipelineOwner owner) { | |
super.attach(owner); | |
for (final section in sections) { | |
section.animation.addListener(markNeedsLayout); | |
} | |
} | |
@override | |
void detach() { | |
for (final section in sections) { | |
section.animation.removeListener(markNeedsLayout); | |
} | |
super.detach(); | |
} | |
@override | |
void performLayout() { | |
double offset = 0; | |
int index = 0; | |
RenderBox? header = firstChild; | |
while (header != null) { | |
final body = childAfter(header)!; | |
// layout | |
header.layout(constraints, parentUsesSize: true); | |
body.layout(constraints, parentUsesSize: true); | |
// position | |
final headerParentData = header.parentData! as _AccordionParentData; | |
final bodyParentData = body.parentData! as _AccordionParentData; | |
headerParentData.offset = Offset(0, offset); | |
bodyParentData.offset = Offset(0, offset + header.size.height); | |
final section = sections[index]; | |
if (section.animation.value > 0) { | |
offset += body.size.height * section.animation.value; | |
} | |
offset += header.size.height * section.offsetFactor; | |
index++; | |
header = childAfter(body); | |
} | |
size = constraints.constrain(Size(constraints.maxWidth, offset)); | |
} | |
@override | |
void paint(PaintingContext context, Offset offset) { | |
defaultPaint(context, offset); | |
} | |
@override | |
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { | |
return defaultHitTestChildren(result, position: position); | |
} | |
} | |
class AccordionController extends ChangeNotifier { | |
AccordionController({ | |
int initialIndex = -1, | |
}) : _index = initialIndex; | |
int get index => _index; | |
int _index; | |
void setIndex(int newIndex) { | |
_index = newIndex; | |
notifyListeners(); | |
} | |
@override | |
String toString() => '${describeIdentity(this)}($index)'; | |
} | |
// ============================================================================= | |
// | |
// simple example | |
// | |
final accordionController = AccordionController(); | |
final data = [ | |
(color: Colors.red, count: 10, title: 'January'), | |
(color: Colors.pink, count: 3, title: 'February'), | |
(color: Colors.purple, count: 10, title: 'March'), | |
(color: Colors.deepPurple, count: 5, title: 'April'), | |
(color: Colors.indigo, count: 15, title: 'May'), | |
(color: Colors.blue, count: 6, title: 'June'), | |
(color: Colors.lightBlue, count: 12, title: 'July'), | |
(color: Colors.cyan, count: 3, title: 'August'), | |
(color: Colors.teal, count: 5, title: 'September'), | |
(color: Colors.green, count: 7, title: 'October'), | |
(color: Colors.lightGreen, count: 12, title: 'November'), | |
(color: Colors.lime, count: 10, title: 'December'), | |
]; | |
final fakeSalesData = data.map((d) { | |
final rnd = Random(); | |
final sales = List.generate(d.count, (index) => 1000 + rnd.nextInt(9000)); | |
return (sales, sales.reduce((a, b) => a + b)); | |
}).toList(); | |
const totalStyle = TextStyle(fontSize: kDefaultFontSize * 1.4, fontWeight: FontWeight.bold); | |
main() { | |
runApp( | |
MaterialApp( | |
scrollBehavior: const MaterialScrollBehavior().copyWith( | |
dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch}, | |
), | |
home: Scaffold( | |
body: AccordionDemo(), | |
), | |
), | |
); | |
Future.delayed(const Duration(seconds: 2), () => accordionController.setIndex(2)); | |
} | |
class AccordionDemo extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return SingleChildScrollView( | |
child: Accordion( | |
reverseCurve: Curves.easeOutCubic, | |
itemCount: data.length, | |
itemBuilder: (ctx, i, active, animation) => ( | |
// tab's header | |
header: SizedBox( | |
height: 72, | |
child: AnimatedContainer( | |
duration: const Duration(milliseconds: 400), | |
curve: Curves.easeIn, | |
decoration: ShapeDecoration( | |
shape: TabShape(active? 1 : 0), | |
shadows: const [BoxShadow(color: Colors.black, blurRadius: 4)], | |
color: active? data[i].color : data[i].color.shade800, | |
), | |
clipBehavior: Clip.antiAlias, | |
child: Stack( | |
children: [ | |
Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
splashColor: Colors.orange, | |
highlightColor: Colors.transparent, | |
onTap: () { | |
// change the current tab | |
accordionController.setIndex(i); | |
}, | |
), | |
), | |
Builder( | |
builder: (context) { | |
final width = MediaQuery.of(context).size.width; | |
final rect = Offset.zero & Size(width, 72); | |
final(_, upperRect, lowerRect) = getTabGeometry(rect, 0); | |
final theme = Theme.of(context).textTheme; | |
final label = active? | |
'${data[i].title} sales results (including other services)' : | |
'${i + 1}. ${data[i].title}'; | |
return AnimatedPositioned.fromRect( | |
rect: active? lowerRect : upperRect, | |
duration: const Duration(milliseconds: 400), | |
child: AnimatedDefaultTextStyle( | |
style: TextStyle( | |
fontWeight: active? FontWeight.bold : FontWeight.normal, | |
fontSize: active? theme.bodyMedium?.fontSize : theme.titleMedium?.fontSize ?? kDefaultFontSize, | |
color: active? Colors.black : Colors.white38, | |
), | |
duration: const Duration(milliseconds: 400), | |
child: IgnorePointer( | |
child: Center( | |
child: Text(label, softWrap: false, overflow: TextOverflow.fade), | |
), | |
), | |
), | |
); | |
}, | |
), | |
], | |
), | |
), | |
), | |
// tab's body | |
body: Container( | |
padding: const EdgeInsets.only(bottom: 36), | |
color: data[i].color.shade400, | |
child: Row( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
const Expanded(flex: 1, child: UnconstrainedBox()), | |
Expanded( | |
flex: 2, | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.end, | |
children: [ | |
...fakeSalesData[i].$1.map((d) => Text(d.toString())), | |
Container(color: Colors.black45, height: 2), | |
Text.rich(TextSpan(text: '= ${fakeSalesData[i].$2}', style: totalStyle)), | |
], | |
), | |
), | |
Expanded( | |
flex: 3, | |
child: TextButtonTheme( | |
data: const TextButtonThemeData( | |
style: ButtonStyle( | |
foregroundColor: MaterialStatePropertyAll(Colors.black54), | |
), | |
), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
if (i > 0) TextButton.icon( | |
onPressed: () => accordionController.setIndex(i - 1), | |
label: const Text('prev month'), | |
icon: const Icon(Icons.navigate_before), | |
), | |
if (i < data.length - 1) TextButton.icon( | |
onPressed: () => accordionController.setIndex(i + 1), | |
label: const Text('next month'), | |
icon: const Icon(Icons.navigate_next), | |
), | |
TextButton.icon( | |
onPressed: () => accordionController.setIndex(i), | |
label: const Text('fold'), | |
icon: const Icon(Icons.expand_less), | |
), | |
], | |
), | |
), | |
), | |
], | |
), | |
), | |
// headers are covered by themselves in the middle | |
offsetFactor: 0.5, | |
), | |
controller: accordionController, | |
), | |
); | |
} | |
} | |
(List<Offset> points, Rect upperRect, Rect lowerRect) getTabGeometry(Rect rect, double phase) { | |
// r0, r1, r2: | points: | |
// ┌─────────────────────────────────┐ | | |
// │ 1──2────────────2──1 │ | 4────────────5 | |
// │ │ │ │ │ │ | / \ | |
// 0────1──2────────────2──1─────────0 | 2────3 6─────────7 | |
// │ │ | │ | |
// │ │ | │ | |
// 0─────────────────────────────────0 | 1───────────────────────────────0 | |
final r0 = Rect.fromPoints(rect.centerLeft, rect.bottomRight); | |
final r1 = EdgeInsets.only( | |
left: rect.width * lerpDouble(0.2, 0.05, phase)!, | |
top: rect.height * 0.1, | |
right: rect.width * lerpDouble(0.4, 0.3, phase)!, | |
bottom: rect.height * 0.5, | |
).deflateRect(rect); | |
final r2 = EdgeInsets.symmetric(horizontal: rect.height * lerpDouble(0.3, 0.1, phase)!).deflateRect(r1); | |
final points = [ | |
r0.bottomRight, r0.bottomLeft, r0.topLeft, r1.bottomLeft, | |
r2.topLeft, r2.topRight, r1.bottomRight, r0.topRight, | |
]; | |
return (points, r1, r0); | |
} | |
class TabShape extends ShapeBorder { | |
const TabShape(this.phase); | |
final double phase; | |
@override | |
ShapeBorder? lerpFrom(ShapeBorder? a, double t) => a is TabShape? | |
TabShape(lerpDouble(a.phase, phase, t)!) : | |
super.lerpFrom(a, t); | |
@override | |
EdgeInsetsGeometry get dimensions => EdgeInsets.zero; | |
@override | |
Path getInnerPath(Rect rect, {TextDirection? textDirection}) => getOuterPath(rect); | |
@override | |
Path getOuterPath(Rect rect, {TextDirection? textDirection}) { | |
final (pts, _, _) = getTabGeometry(rect, phase); | |
// final path = Path(); | |
// for (final o in pts) { | |
// o == pts.first? path.moveTo(o.dx, o.dy) : path.lineTo(o.dx, o.dy); | |
// } | |
const t = 0.25; | |
return Path() | |
..moveTo(pts[0].dx, pts[0].dy) | |
..lineTo(pts[1].dx, pts[1].dy) | |
..lineTo(pts[2].dx, pts[2].dy) | |
..lineTo(pts[3].dx, pts[3].dy) | |
..quadraticBezierTo(_lerp(pts, 3, 4, t).dx, pts[4].dy, pts[4].dx, pts[4].dy) | |
..lineTo(pts[5].dx, pts[5].dy) | |
..quadraticBezierTo(_lerp(pts, 6, 5, t).dx, pts[5].dy, pts[6].dx, pts[6].dy) | |
..lineTo(pts[7].dx, pts[7].dy); | |
} | |
Offset _lerp(List<Offset> points, int i0, int i1, double t) { | |
return Offset.lerp(points[i0], points[i1], t)!; | |
} | |
@override | |
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { | |
} | |
@override | |
ShapeBorder scale(double t) => this; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment