Last active
May 27, 2024 05:32
-
-
Save pskink/5fd59d240beb8dd9d780f978e5c76b3c 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: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