Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active May 27, 2024 05:32
Show Gist options
  • Save pskink/5fd59d240beb8dd9d780f978e5c76b3c to your computer and use it in GitHub Desktop.
Save pskink/5fd59d240beb8dd9d780f978e5c76b3c to your computer and use it in GitHub Desktop.
import 'dart:ui' as ui;
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// A custom [Scrollable] with a fixed content.
///
/// Can be used with:
/// 1. static content animated with given [ViewportOffset]
/// 2. dynamic content drawn by [CustomPaint] widget
/// 3. any mix of both
class SizedContentScrollable extends StatelessWidget {
const SizedContentScrollable({
super.key,
required this.viewportBuilder,
required this.contentSize,
this.scrollDirection = Axis.vertical,
this.physics,
});
/// Builds the viewport through which the scrollable content is displayed.
/// See [Scrollable.viewportBuilder] for more info.
final ViewportBuilder viewportBuilder;
/// Called to return the content size along the [scrollDirection].
final double Function(double viewportSize) contentSize;
/// {@macro flutter.widgets.scroll_view.scrollDirection}
final Axis scrollDirection;
final ScrollPhysics? physics;
@override
Widget build(BuildContext context) {
return Scrollable(
axisDirection: scrollDirection == Axis.vertical? AxisDirection.down : AxisDirection.right,
physics: physics,
viewportBuilder: (context, offset) {
return LayoutBuilder(
builder: (context, constraints) {
// print(constraints);
final size = scrollDirection == Axis.vertical? constraints.maxHeight : constraints.maxWidth;
offset
..applyViewportDimension(size)
..applyContentDimensions(0, contentSize(size) - size);
return viewportBuilder(context, offset);
}
);
}
);
}
}
// ============================================================================
// ============================================================================
//
// examples
//
// ============================================================================
// ============================================================================
main() {
final examples = [
_SizedContentScrollableExample0(),
_SizedContentScrollableExample1(),
_SizedContentScrollableExample2(),
];
runApp(MaterialApp(
scrollBehavior: const MaterialScrollBehavior().copyWith(
dragDevices: {ui.PointerDeviceKind.mouse, ui.PointerDeviceKind.touch},
),
initialRoute: '/',
routes: {
'/': (ctx) => const Scaffold(body: StartPage()),
for (int i = 0; i < examples.length; i++)
'sizedContentScrollableExample$i': (ctx) => Scaffold(
appBar: AppBar(
titleTextStyle: Theme.of(ctx).textTheme.labelLarge,
title: Text('_SizedContentScrollableExample$i'),
),
body: examples[i],
),
},
));
}
class StartPage extends StatelessWidget {
const StartPage({super.key});
@override
Widget build(BuildContext context) {
return ListView(
children: [
ListTile(
title: const Text('CustomPainter 0 example'),
subtitle: const Text('_SizedContentScrollableExample0'),
onTap: () => Navigator.of(context).pushNamed('sizedContentScrollableExample0'),
),
ListTile(
title: const Text('CustomPainter 1 example'),
subtitle: const Text('_SizedContentScrollableExample1'),
onTap: () => Navigator.of(context).pushNamed('sizedContentScrollableExample1'),
),
ListTile(
title: const Text('CustomMultiChildLayout example'),
subtitle: const Text('_SizedContentScrollableExample2'),
onTap: () => Navigator.of(context).pushNamed('sizedContentScrollableExample2'),
),
],
);
}
}
class _SizedContentScrollableExample0 extends StatefulWidget {
@override
State<_SizedContentScrollableExample0> createState() => _SizedContentScrollableExample0State();
}
class _SizedContentScrollableExample0State extends State<_SizedContentScrollableExample0> with TickerProviderStateMixin {
late AnimationController ctrl;
ValueNotifier<bool>? isScrolling;
@override
void initState() {
super.initState();
ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
height: 100,
child: ColoredBox(
color: Colors.green.shade100,
child: SizedContentScrollable(
contentSize: (size) => size * 6,
scrollDirection: Axis.horizontal,
viewportBuilder: (context, offset) {
isScrolling?.removeListener(_listener);
isScrolling = Scrollable.of(context).position.isScrollingNotifier;
isScrolling!.addListener(_listener);
// print(isScrolling);
return CustomPaint(
painter: _SizedContentScrollableExample0Painter(offset, ctrl),
child: const SizedBox.expand(),
);
},
),
),
),
),
);
}
void _listener() => isScrolling!.value? ctrl.forward() : ctrl.reverse();
@override
void dispose() {
super.dispose();
ctrl.dispose();
}
}
class _SizedContentScrollableExample0Painter extends CustomPainter {
_SizedContentScrollableExample0Painter(this.offset, this.animation) : super(
repaint: Listenable.merge([offset, animation])
);
final ViewportOffset offset;
final Animation<double> animation;
@override
void paint(Canvas canvas, Size size) {
final value = offset.pixels;
canvas
..save()
..translate(-value, 0);
final paint = Paint();
final style = TextStyle(
color: Colors.teal,
fontWeight: FontWeight.w300,
fontSize: size.height / 2,
);
final points = <Offset>[];
for (int i = 0; i <= 10 * 5; i++) {
final x = size.width / 2 + i * size.width / 10;
final pad = size.width / 4;
if (value - pad <= x && x <= value + size.width + pad) {
double y = size.height * 0.666;
if (i % 10 == 0) {
y = size.height * 0.5;
_paintLabel(i.toString(), x, canvas, style);
}
points
..add(Offset(x, y))
..add(Offset(x, size.height));
}
}
// print(points.length / 2);
canvas.drawPoints(ui.PointMode.lines, points, paint);
canvas.restore();
final p1 = size.topCenter(Offset.zero);
final p2 = size.bottomCenter(Offset.zero);
canvas.drawLine(p1, p2, paint..color = Colors.black38);
if (animation.value != 0) {
// timeDilation = 10;
final t = Curves.ease.transform(animation.value);
final r0 = Alignment.center.inscribe(Size(size.width, size.height / 4), Offset.zero & size);
final r1 = Alignment.center.inscribe(Size(16, size.height), Offset.zero & size);
final rect = Rect.lerp(r0, r1, t)!;
paint
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Color.lerp(Colors.black26, Colors.indigo, t)!;
canvas
..drawLine(rect.topLeft, rect.bottomLeft, paint)
..drawLine(rect.topRight, rect.bottomRight, paint);
}
}
@override
bool shouldRepaint(_SizedContentScrollableExample0Painter oldDelegate) => false;
_paintLabel(String label, double x, Canvas canvas, TextStyle style) {
final textPainter = TextPainter(
text: TextSpan(text: label, style: style),
textDirection: TextDirection.ltr,
);
textPainter
..layout()
..paint(canvas, Offset(x - textPainter.width / 2, 0));
}
}
// -----------------------------------------
class _SizedContentScrollableExample1 extends StatefulWidget {
@override
State<_SizedContentScrollableExample1> createState() => _SizedContentScrollableExample1State();
}
class _SizedContentScrollableExample1State extends State<_SizedContentScrollableExample1> with TickerProviderStateMixin {
final scrollController = ScrollController();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AspectRatio(
aspectRatio: 2.5,
child: SizedContentScrollable(
contentSize: (size) => size * 6,
scrollDirection: Axis.horizontal,
viewportBuilder: (context, offset) {
// print(isScrolling);
return CustomPaint(
painter: _SizedContentScrollableExample1Painter(offset),
child: const SizedBox.expand(),
);
},
),
),
),
);
}
}
class _SizedContentScrollableExample1Painter extends CustomPainter {
_SizedContentScrollableExample1Painter(this.offset) : super(repaint: offset);
final ViewportOffset offset;
@override
void paint(Canvas canvas, Size size) {
final r = Offset.zero & size;
canvas.drawRect(r, Paint()..color = Colors.green.shade100);
final value = offset.pixels;
final paint = Paint();
final width2 = size.width / 2;
final height2 = size.height / 2;
final style = TextStyle(
color: Colors.teal,
fontWeight: FontWeight.w300,
fontSize: height2 / 2,
);
final points = <(double, double, double)>[];
final labels = <double, String>{};
for (int i = 0; i <= 10 * 5; i++) {
final x = width2 + i * size.width / 10;
final pad = size.width / 4;
if (value - pad <= x && x <= value + size.width + pad) {
double y = height2 * 0.666;
if (i % 10 == 0) {
y = height2 * 0.5;
labels[x] = i.toString();
}
points.add((x, y, height2));
}
}
// print(points.length);
final anchor = Offset(0, size.width);
for (final line in points) {
final x = line.$1;
final matrix = composeMatrix(
translate: anchor.translate(width2, 0),
rotation: (x - value - width2) / size.width,
anchor: anchor,
);
canvas
..save()
..transform(matrix.storage)
..drawLine(Offset(0, line.$2), Offset(0, line.$3), paint);
if (labels[x] != null) {
_paintLabel(labels[x]!, canvas, style);
}
canvas.restore();
}
final p1 = size.topCenter(Offset.zero);
final p2 = size.bottomCenter(Offset.zero);
canvas.drawLine(p1, p2, paint..color = Colors.black38);
}
@override
bool shouldRepaint(_SizedContentScrollableExample1Painter oldDelegate) => false;
_paintLabel(String label, Canvas canvas, TextStyle style) {
final textPainter = TextPainter(
text: TextSpan(text: label, style: style),
textDirection: TextDirection.ltr,
);
textPainter
..layout()
..paint(canvas, Offset(-textPainter.width / 2, 0));
}
}
Matrix4 composeMatrix({
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
}) {
final double c = cos(rotation) * scale;
final double s = sin(rotation) * scale;
final double dx = translate.dx - c * anchor.dx + s * anchor.dy;
final double dy = translate.dy - s * anchor.dx - c * anchor.dy;
return Matrix4(c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
}
// -----------------------------------------
class _SizedContentScrollableExample2 extends StatelessWidget {
static const shadow = DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: Colors.white54, blurRadius: 4, offset: Offset(3, 2)),
BoxShadow(color: Colors.white54, blurRadius: 4, offset: Offset(2, 3)),
BoxShadow(color: Colors.black54, blurRadius: 4, offset: Offset(-3, -2)),
BoxShadow(color: Colors.black54, blurRadius: 4, offset: Offset(-2, -3)),
],
),
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
height: 300,
child: SizedContentScrollable(
contentSize: (size) => 2 * size,
scrollDirection: Axis.horizontal,
viewportBuilder: (context, offset) {
return Stack(
fit: StackFit.expand,
children: [
Image.network('https://picsum.photos/300/200',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const GridPaper(
color: Colors.indigo,
child: SizedBox.expand(),
),
),
ClipPath(
clipper: _SizedContentScrollableExample2Clipper(offset as ScrollPosition),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.green.shade200, Colors.green.shade600]),
),
child: CustomMultiChildLayout(
delegate: _SizedContentScrollableExample2Delegate(offset),
children: [
for (int id = 0; id < 6; id++)
LayoutId(id: id, child: shadow)
],
),
),
),
CustomMultiChildLayout(
delegate: _SizedContentScrollableExample2Delegate(offset),
children: [
for (int id = 0; id < 6; id++)
LayoutId(
id: id,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(side: BorderSide(color: Colors.black54, width: 0)),
clipBehavior: Clip.antiAlias,
child: InkWell(
splashColor: HSVColor.fromAHSV(0.75, 360 * id / 6, 1, 1).toColor(),
highlightColor: Colors.transparent,
onTap: () => debugPrint('$id pressed'),
child: FittedBox(child: Text('$id')),
),
)
),
LayoutId(
id: -1,
child: Card(
elevation: 4,
color: Colors.grey.shade400,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 2, horizontal: 8),
child: SizedBox(
width: 128,
child: Text('move your finger left & right', textAlign: TextAlign.center),
),
),
),
),
],
),
],
);
},
),
),
),
);
}
}
class _SizedContentScrollableExample2Clipper extends CustomClipper<Path> {
_SizedContentScrollableExample2Clipper(this.offset) : super(reclip: offset);
final ScrollPosition offset;
@override
Path getClip(Size size) {
final path = Path()
..addRect(Offset.zero & size)
..fillType = PathFillType.evenOdd;
getBounds(size, offset.pixels / offset.maxScrollExtent).forEach(path.addOval);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class _SizedContentScrollableExample2Delegate extends MultiChildLayoutDelegate {
_SizedContentScrollableExample2Delegate(this.offset) : super(relayout: offset);
final ScrollPosition offset;
@override
void performLayout(ui.Size size) {
var id = 0;
getBounds(size, offset.pixels / offset.maxScrollExtent).forEach((rect) {
layoutChild(id, BoxConstraints.tight(rect.size));
positionChild(id, rect.topLeft);
id++;
});
if (hasChild(-1)) {
final t = offset.pixels / offset.maxScrollExtent;
final alignment = Alignment.lerp(Alignment.centerRight, Alignment.centerLeft, t)!;
final childSize = layoutChild(-1, BoxConstraints.loose(size));
final childRect = alignment.inscribe(childSize, Offset.zero & size);
positionChild(-1, childRect.topLeft);
}
}
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => false;
}
Iterable<Rect> getBounds(Size size, double t) sync* {
final center = size.center(Offset.zero);
final side = size.shortestSide / 5;
for (int id = 0; id < 6; id++) {
final direction = 2 * pi * (id / 6 - 0.25 + t);
final childOffset = Offset.fromDirection(direction, size.shortestSide * 0.4 - 4);
yield Rect.fromCircle(center: center + childOffset, radius: side / 2);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment