flutter_bouncy 0.0.4 flutter_bouncy: ^0.0.4 copied to clipboard
Scrolling with bouncy and natural item movement
example/lib/main.dart
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// Try running your application with "flutter run". You'll see the
// application has a blue toolbar. Then, without quitting the app, try
// changing the primarySwatch below to Colors.green and then invoke
// "hot reload" (press "r" in the console where you ran "flutter run",
// or simply save your changes to "hot reload" in a Flutter IDE).
// Notice that the counter didn't reset back to zero; the application
// is not restarted.
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
int _counter = 0;
final tween = Tween<double>(begin: 0.0, end: 0.0);
Animation<double> tweenAnimation;
AnimationController controller;
ScrollController scrollController;
double scrollDelta = 0.0;
double lastPixels;
@override
void initState() {
scrollController = ScrollController();
controller = AnimationController(
duration: Duration(milliseconds: 100),
vsync: this,
);
tweenAnimation = controller.drive(tween);
tweenAnimation.addListener(() {
setState(() {
scrollDelta = tweenAnimation.value;
// print(tweenAnimation.value);
// print(scrollDelta);
});
});
super.initState();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
// print('Pixels: ${notification.metrics.pixels}');
if (notification is ScrollUpdateNotification) {
if (scrollController.position != null) {
setState(() {
scrollDelta = notification.scrollDelta;
});
// tween.begin = scrollDelta;
// tween.end = notification.scrollDelta;
// if (controller.isCompleted) {
// controller.reset();
// }
// if (!controller.isAnimating) {
// controller.forward();
// }
}
}
if (notification is ScrollEndNotification) {
// controller.reset();
setState(() {
scrollDelta = 0;
});
}
return false;
},
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverBouncyList(
scrollDelta: scrollDelta,
delegate: SliverChildListDelegate(
[
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
_buildChatMessage(false),
_buildChatMessage(true),
_buildChatMessage(true),
],
),
),
],
),
),
),
),
);
}
Widget _buildChatMessage(bool isSender) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Align(
alignment: isSender ? Alignment.centerRight : Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: 0.8,
child: Container(
height: 25,
decoration: BoxDecoration(
color: isSender ? Colors.blue : Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(10),
),
),
),
),
);
}
}
class SliverBouncyList extends SliverMultiBoxAdaptorWidget {
final double scrollDelta;
const SliverBouncyList({
Key key,
@required SliverChildDelegate delegate,
@required this.scrollDelta,
}) : super(key: key, delegate: delegate);
@override
SliverMultiBoxAdaptorElement createElement() =>
SliverMultiBoxAdaptorElement(this, replaceMovedChildren: true);
@override
void updateRenderObject(
BuildContext context,
covariant RenderSliverBouncyList renderObject,
) {
renderObject..scrollDelta = scrollDelta;
}
@override
RenderSliverBouncyList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element =
context as SliverMultiBoxAdaptorElement;
return RenderSliverBouncyList(
childManager: element,
);
}
}
class RenderSliverBouncyList extends RenderSliverMultiBoxAdaptor {
double scrollDelta = 0.0;
double lastScrollOffset = 0.0;
final springMap = <RenderObject, Spring>{};
/// Creates a sliver that places multiple box children in a linear array along
/// the main axis.
///
/// The [childManager] argument must not be null.
RenderSliverBouncyList({
@required RenderSliverBoxChildManager childManager,
}) : super(childManager: childManager);
PointerEvent lastPointerEvent;
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
lastPointerEvent = event;
RenderBox child = firstChild;
var i = 0;
while (child != null) {
var resistance = 0.0;
if (lastPointerEvent != null) {
resistance = (lastPointerEvent.position.dy -
child.localToGlobal(Offset(0, 0)).dy) /
100;
}
final calculatedDelta = (scrollDelta > 0
? max(scrollDelta, scrollDelta * resistance.abs())
: min(scrollDelta, scrollDelta * resistance.abs()))
.floorToDouble();
final spring = springMap[child];
if (spring != null) {
// springMap[child].setTarget(calculatedDelta);
}
if (i == 0) {
// print(calculatedDelta);
}
child = childAfter(child);
i++;
}
if (event is PointerCancelEvent) {
for (var spring in springMap.values) {
spring.setTarget(0);
}
}
markNeedsPaint();
}
@override
void setupParentData(covariant RenderBox child) {
super.setupParentData(child);
if (springMap[child] == null) {
var i = 0;
springMap[child] = Spring(() {
final spring = springMap[child];
Future.microtask(() {
if (i % 100 == 0) {
// print('Length: ${spring.length}');
}
markNeedsPaint();
// child.markNeedsLayout();
});
if (!child.attached) {
return false;
}
});
}
}
@override
void performLayout() {
final SliverConstraints constraints = this.constraints;
childManager.didStartLayout();
childManager.setDidUnderflow(false);
final double scrollOffset =
constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetEndScrollOffset = scrollOffset + remainingExtent;
final BoxConstraints childConstraints = constraints.asBoxConstraints();
int leadingGarbage = 0;
int trailingGarbage = 0;
bool reachedEnd = false;
// This algorithm in principle is straight-forward: find the first child
// that overlaps the given scrollOffset, creating more children at the top
// of the list if necessary, then walk down the list updating and laying out
// each child and adding more at the end if necessary until we have enough
// children to cover the entire viewport.
//
// It is complicated by one minor issue, which is that any time you update
// or create a child, it's possible that the some of the children that
// haven't yet been laid out will be removed, leaving the list in an
// inconsistent state, and requiring that missing nodes be recreated.
//
// To keep this mess tractable, this algorithm starts from what is currently
// the first child, if any, and then walks up and/or down from there, so
// that the nodes that might get removed are always at the edges of what has
// already been laid out.
// Make sure we have at least one child to start from.
if (firstChild == null) {
if (!addInitialChild()) {
// There are no children.
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
}
// We have at least one child.
// These variables track the range of children that we have laid out. Within
// this range, the children have consecutive indices. Outside this range,
// it's possible for a child to get removed without notice.
RenderBox leadingChildWithLayout, trailingChildWithLayout;
RenderBox earliestUsefulChild = firstChild;
// A firstChild with null layout offset is likely a result of children
// reordering.
//
// We rely on firstChild to have accurate layout offset. In the case of null
// layout offset, we have to find the first child that has valid layout
// offset.
if (childScrollOffset(firstChild) == null) {
int leadingChildrenWithoutLayoutOffset = 0;
while (childScrollOffset(earliestUsefulChild) == null) {
earliestUsefulChild = childAfter(firstChild);
leadingChildrenWithoutLayoutOffset += 1;
}
// We should be able to destroy children with null layout offset safely,
// because they are likely outside of viewport
collectGarbage(leadingChildrenWithoutLayoutOffset, 0);
assert(firstChild != null);
}
// Find the last child that is at or before the scrollOffset.
earliestUsefulChild = firstChild;
for (double earliestScrollOffset = childScrollOffset(earliestUsefulChild);
earliestScrollOffset > scrollOffset;
earliestScrollOffset = childScrollOffset(earliestUsefulChild)) {
// We have to add children before the earliestUsefulChild.
earliestUsefulChild =
insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
if (earliestUsefulChild == null) {
final SliverMultiBoxAdaptorParentData childParentData =
firstChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
if (scrollOffset == 0.0) {
// insertAndLayoutLeadingChild only lays out the children before
// firstChild. In this case, nothing has been laid out. We have
// to lay out firstChild manually.
firstChild.layout(childConstraints, parentUsesSize: true);
earliestUsefulChild = firstChild;
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
break;
} else {
// We ran out of children before reaching the scroll offset.
// We must inform our parent that this sliver cannot fulfill
// its contract and that we need a scroll offset correction.
geometry = SliverGeometry(
scrollOffsetCorrection: -scrollOffset,
);
return;
}
}
final double firstChildScrollOffset =
earliestScrollOffset - paintExtentOf(firstChild);
// firstChildScrollOffset may contain double precision error
if (firstChildScrollOffset < -precisionErrorTolerance) {
// Let's assume there is no child before the first child. We will
// correct it on the next layout if it is not.
geometry = SliverGeometry(
scrollOffsetCorrection: -firstChildScrollOffset,
);
final SliverMultiBoxAdaptorParentData childParentData =
firstChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
return;
}
final SliverMultiBoxAdaptorParentData childParentData =
earliestUsefulChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = firstChildScrollOffset;
assert(earliestUsefulChild == firstChild);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
}
assert(childScrollOffset(firstChild) > -precisionErrorTolerance);
// If the scroll offset is at zero, we should make sure we are
// actually at the beginning of the list.
if (scrollOffset < precisionErrorTolerance) {
// We iterate from the firstChild in case the leading child has a 0 paint
// extent.
while (indexOf(firstChild) > 0) {
final double earliestScrollOffset = childScrollOffset(firstChild);
// We correct one child at a time. If there are more children before
// the earliestUsefulChild, we will correct it once the scroll offset
// reaches zero again.
earliestUsefulChild =
insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
assert(earliestUsefulChild != null);
final double firstChildScrollOffset =
earliestScrollOffset - paintExtentOf(firstChild);
final SliverMultiBoxAdaptorParentData childParentData =
firstChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
// We only need to correct if the leading child actually has a
// paint extent.
if (firstChildScrollOffset < -precisionErrorTolerance) {
geometry = SliverGeometry(
scrollOffsetCorrection: -firstChildScrollOffset,
);
return;
}
}
}
// At this point, earliestUsefulChild is the first child, and is a child
// whose scrollOffset is at or before the scrollOffset, and
// leadingChildWithLayout and trailingChildWithLayout are either null or
// cover a range of render boxes that we have laid out with the first being
// the same as earliestUsefulChild and the last being either at or after the
// scroll offset.
assert(earliestUsefulChild == firstChild);
assert(childScrollOffset(earliestUsefulChild) <= scrollOffset);
// Make sure we've laid out at least one child.
if (leadingChildWithLayout == null) {
earliestUsefulChild.layout(childConstraints, parentUsesSize: true);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout = earliestUsefulChild;
}
// Here, earliestUsefulChild is still the first child, it's got a
// scrollOffset that is at or before our actual scrollOffset, and it has
// been laid out, and is in fact our leadingChildWithLayout. It's possible
// that some children beyond that one have also been laid out.
bool inLayoutRange = true;
RenderBox child = earliestUsefulChild;
int index = indexOf(child);
double endScrollOffset = childScrollOffset(child) + paintExtentOf(child);
bool advance() {
// returns true if we advanced, false if we have no more children
// This function is used in two different places below, to avoid code duplication.
assert(child != null);
if (child == trailingChildWithLayout) inLayoutRange = false;
child = childAfter(child);
if (child == null) inLayoutRange = false;
index += 1;
if (!inLayoutRange) {
if (child == null || indexOf(child) != index) {
// We are missing a child. Insert it (and lay it out) if possible.
child = insertAndLayoutChild(
childConstraints,
after: trailingChildWithLayout,
parentUsesSize: true,
);
if (child == null) {
// We have run out of children.
return false;
}
} else {
// Lay out the child.
child.layout(childConstraints, parentUsesSize: true);
}
trailingChildWithLayout = child;
}
assert(child != null);
final SliverMultiBoxAdaptorParentData childParentData =
child.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = endScrollOffset;
assert(childParentData.index == index);
endScrollOffset = childScrollOffset(child) + paintExtentOf(child);
return true;
}
// Find the first child that ends after the scroll offset.
while (endScrollOffset < scrollOffset) {
leadingGarbage += 1;
if (!advance()) {
assert(leadingGarbage == childCount);
assert(child == null);
// we want to make sure we keep the last child around so we know the end scroll offset
collectGarbage(leadingGarbage - 1, 0);
assert(firstChild == lastChild);
final double extent =
childScrollOffset(lastChild) + paintExtentOf(lastChild);
geometry = SliverGeometry(
scrollExtent: extent,
paintExtent: 0.0,
maxPaintExtent: extent,
);
return;
}
}
// Now find the first child that ends after our end.
while (endScrollOffset < targetEndScrollOffset) {
if (!advance()) {
reachedEnd = true;
break;
}
}
// Finally count up all the remaining children and label them as garbage.
if (child != null) {
child = childAfter(child);
while (child != null) {
trailingGarbage += 1;
child = childAfter(child);
}
}
// At this point everything should be good to go, we just have to clean up
// the garbage and report the geometry.
collectGarbage(leadingGarbage, trailingGarbage);
assert(debugAssertChildListIsNonEmptyAndContiguous());
double estimatedMaxScrollOffset;
if (reachedEnd) {
estimatedMaxScrollOffset = endScrollOffset;
} else {
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: indexOf(firstChild),
lastIndex: indexOf(lastChild),
leadingScrollOffset: childScrollOffset(firstChild),
trailingScrollOffset: endScrollOffset,
);
assert(estimatedMaxScrollOffset >=
endScrollOffset - childScrollOffset(firstChild));
}
final double paintExtent = calculatePaintOffset(
constraints,
from: childScrollOffset(firstChild),
to: endScrollOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: childScrollOffset(firstChild),
to: endScrollOffset,
);
final double targetEndScrollOffsetForPaint =
constraints.scrollOffset + constraints.remainingPaintExtent;
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
// Conservative to avoid flickering away the clip during scroll.
hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint ||
constraints.scrollOffset > 0.0,
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
if (estimatedMaxScrollOffset == endScrollOffset)
childManager.setDidUnderflow(true);
childManager.didFinishLayout();
}
@override
void paint(PaintingContext context, Offset offset) {
if (firstChild == null) return;
// _calculateDelta();
// offset is to the top-left corner, regardless of our axis direction.
// originOffset gives us the delta from the real origin to the origin in the axis direction.
Offset mainAxisUnit, crossAxisUnit, originOffset;
bool addExtent;
switch (applyGrowthDirectionToAxisDirection(
constraints.axisDirection, constraints.growthDirection)) {
case AxisDirection.up:
mainAxisUnit = const Offset(0.0, -1.0);
crossAxisUnit = const Offset(1.0, 0.0);
originOffset = offset + Offset(0.0, geometry.paintExtent);
addExtent = true;
break;
case AxisDirection.right:
mainAxisUnit = const Offset(1.0, 0.0);
crossAxisUnit = const Offset(0.0, 1.0);
originOffset = offset;
addExtent = false;
break;
case AxisDirection.down:
mainAxisUnit = const Offset(0.0, 1.0);
crossAxisUnit = const Offset(1.0, 0.0);
originOffset = offset;
addExtent = false;
break;
case AxisDirection.left:
mainAxisUnit = const Offset(-1.0, 0.0);
crossAxisUnit = const Offset(0.0, 1.0);
originOffset = offset + Offset(geometry.paintExtent, 0.0);
addExtent = true;
break;
}
assert(mainAxisUnit != null);
assert(addExtent != null);
RenderBox child = firstChild;
var i = 0;
while (child != null) {
final double mainAxisDelta = childMainAxisPosition(child);
final double crossAxisDelta = childCrossAxisPosition(child);
Offset childOffset = Offset(
originOffset.dx +
mainAxisUnit.dx * mainAxisDelta +
crossAxisUnit.dx * crossAxisDelta,
originOffset.dy +
mainAxisUnit.dy * mainAxisDelta +
crossAxisUnit.dy * crossAxisDelta,
);
if (addExtent) childOffset += mainAxisUnit * paintExtentOf(child);
if (springMap[child] != null) {
final spring = springMap[child];
// print('Spring Current: ${spring.current}');
childOffset += Offset(0, spring.current);
// print('Length: ${spring.length}');
}
// If the child's visible interval (mainAxisDelta, mainAxisDelta + paintExtentOf(child))
// does not intersect the paint extent interval (0, constraints.remainingPaintExtent), it's hidden.
// if (mainAxisDelta < constraints.remainingPaintExtent &&
// mainAxisDelta + paintExtentOf(child) > 0)
// context.paintChild(child, childOffset);
context.paintChild(child, childOffset);
i++;
child = childAfter(child);
}
_calculateDelta();
lastScrollOffset = constraints.scrollOffset;
}
void _calculateDelta() {
if (lastScrollOffset == null || lastPointerEvent == null) {
return;
}
RenderBox child = firstChild;
var i = 0;
while (child != null) {
var resistance = 0.0;
double delta = constraints.scrollOffset - lastScrollOffset;
if (lastScrollOffset != null) {
resistance = (lastPointerEvent.position.dy -
child.localToGlobal(Offset(0, 0)).dy) /
100;
}
final calculatedDelta = (delta > 0
? max(delta, delta * resistance.abs())
: min(delta, delta * resistance.abs()))
.floorToDouble();
// print('Calculated Delta: ${calculatedDelta}');
final spring = springMap[child];
if (spring != null) {
springMap[child].setTarget(calculatedDelta);
}
if (i == 0) {
// print(calculatedDelta);
}
child = childAfter(child);
i++;
}
}
}
class Spring {
final Function handler;
SpringSimulation _simulation;
double current = 0.0;
double dt = 0;
Spring(this.handler) {
final timer = Timer.periodic(
Duration(milliseconds: 1),
(_) {
if (_simulation != null) {
current = _simulation.x(dt / 100);
// print('Current: $current');
}
if (dt % 1000 == 0) {
// print('DT: $dt');
}
dt++;
handler();
},
);
}
void setTarget(double target) {
_simulation = SpringSimulation(
_getSpringDescription(),
current,
target,
0.01,
);
dt = 0;
}
SpringDescription _getSpringDescription() {
return const SpringDescription(
mass: 30.0,
stiffness: 0.6,
damping: 0.4,
);
}
}