Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active December 13, 2023 14:10
Show Gist options
  • Save pskink/81aa39d1eefd58d72fb8b945499487ec to your computer and use it in GitHub Desktop.
Save pskink/81aa39d1eefd58d72fb8b945499487ec to your computer and use it in GitHub Desktop.
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