Skip to content

Instantly share code, notes, and snippets.

@proteye
Last active September 22, 2023 12:30
Show Gist options
  • Save proteye/f463558c70fb7cad3f41e7a3b5f4b622 to your computer and use it in GitHub Desktop.
Save proteye/f463558c70fb7cad3f41e7a3b5f4b622 to your computer and use it in GitHub Desktop.
SingleChildWithKeepPositionScrollView - single child with keep position scroll view in Flutter
import 'dart:math' as math;
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class SingleChildWithKeepPositionScrollView extends StatelessWidget {
/// Creates a box in which a single widget can be scrolled.
const SingleChildWithKeepPositionScrollView({
super.key,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.padding,
this.primary,
this.physics,
this.controller,
this.child,
this.dragStartBehavior = DragStartBehavior.start,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
}) : assert(
!(controller != null && (primary ?? false)),
'Primary ScrollViews obtain their ScrollController via inheritance '
'from a PrimaryScrollController widget. You cannot both set primary to '
'true and pass an explicit controller.',
);
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the scroll view scrolls in the reading direction.
///
/// For example, if the reading direction is left-to-right and
/// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from
/// left to right when [reverse] is false and from right to left when
/// [reverse] is true.
///
/// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
final bool reverse;
/// The amount of space by which to inset the child.
final EdgeInsetsGeometry? padding;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
final ScrollController? controller;
/// {@macro flutter.widgets.scroll_view.primary}
final bool? primary;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions.
final ScrollPhysics? physics;
/// The widget that scrolls.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget? child;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
/// {@macro flutter.widgets.scroll_view.keyboardDismissBehavior}
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
AxisDirection _getDirection(BuildContext context) {
return getAxisDirectionFromAxisReverseAndDirectionality(
context, scrollDirection, reverse);
}
@override
Widget build(BuildContext context) {
final AxisDirection axisDirection = _getDirection(context);
Widget? contents = child;
if (padding != null) {
contents = Padding(padding: padding!, child: contents);
}
final bool effectivePrimary = primary ??
controller == null &&
PrimaryScrollController.shouldInherit(context, scrollDirection);
final ScrollController? scrollController = effectivePrimary
? PrimaryScrollController.maybeOf(context)
: controller;
Widget scrollable = Scrollable(
dragStartBehavior: dragStartBehavior,
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return _SingleChildViewport(
axisDirection: axisDirection,
offset: offset,
clipBehavior: clipBehavior,
child: contents,
);
},
);
if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
scrollable = NotificationListener<ScrollUpdateNotification>(
child: scrollable,
onNotification: (ScrollUpdateNotification notification) {
final FocusScopeNode focusNode = FocusScope.of(context);
if (notification.dragDetails != null && focusNode.hasFocus) {
focusNode.unfocus();
}
return false;
},
);
}
return effectivePrimary && scrollController != null
// Further descendant ScrollViews will not inherit the same
// PrimaryScrollController
? PrimaryScrollController.none(child: scrollable)
: scrollable;
}
}
class _SingleChildViewport extends SingleChildRenderObjectWidget {
const _SingleChildViewport({
this.axisDirection = AxisDirection.down,
required this.offset,
super.child,
required this.clipBehavior,
});
final AxisDirection axisDirection;
final ViewportOffset offset;
final Clip clipBehavior;
@override
_RenderSingleChildViewport createRenderObject(BuildContext context) {
return _RenderSingleChildViewport(
axisDirection: axisDirection,
offset: offset,
clipBehavior: clipBehavior,
);
}
@override
void updateRenderObject(
BuildContext context, _RenderSingleChildViewport renderObject) {
// Order dependency: The offset setter reads the axis direction.
renderObject
..axisDirection = axisDirection
..offset = offset
..clipBehavior = clipBehavior;
}
@override
SingleChildRenderObjectElement createElement() {
return _SingleChildViewportElement(this);
}
}
class _SingleChildViewportElement extends SingleChildRenderObjectElement
with NotifiableElementMixin, ViewportElementMixin {
_SingleChildViewportElement(_SingleChildViewport super.widget);
}
class _RenderSingleChildViewport extends RenderBox
with RenderObjectWithChildMixin<RenderBox>
implements RenderAbstractViewport {
_RenderSingleChildViewport({
AxisDirection axisDirection = AxisDirection.down,
required ViewportOffset offset,
RenderBox? child,
required Clip clipBehavior,
}) : _axisDirection = axisDirection,
_offset = offset,
_clipBehavior = clipBehavior {
this.child = child;
}
AxisDirection get axisDirection => _axisDirection;
AxisDirection _axisDirection;
set axisDirection(AxisDirection value) {
if (value == _axisDirection) {
return;
}
_axisDirection = value;
markNeedsLayout();
}
Axis get axis => axisDirectionToAxis(axisDirection);
ViewportOffset get offset => _offset;
ViewportOffset _offset;
set offset(ViewportOffset value) {
if (value == _offset) {
return;
}
if (attached) {
_offset.removeListener(_hasScrolled);
}
_offset = value;
if (attached) {
_offset.addListener(_hasScrolled);
}
markNeedsLayout();
}
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.none], and must not be null.
Clip get clipBehavior => _clipBehavior;
Clip _clipBehavior = Clip.none;
set clipBehavior(Clip value) {
if (value != _clipBehavior) {
_clipBehavior = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
void _hasScrolled() {
markNeedsPaint();
markNeedsSemanticsUpdate();
}
@override
void setupParentData(RenderObject child) {
// We don't actually use the offset argument in BoxParentData, so let's
// avoid allocating it at all.
if (child.parentData is! ParentData) {
child.parentData = ParentData();
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_offset.addListener(_hasScrolled);
}
@override
void detach() {
_offset.removeListener(_hasScrolled);
super.detach();
}
@override
bool get isRepaintBoundary => true;
double get _viewportExtent {
assert(hasSize);
switch (axis) {
case Axis.horizontal:
return size.width;
case Axis.vertical:
return size.height;
}
}
double get _minScrollExtent {
assert(hasSize);
return 0.0;
}
double get _maxScrollExtent {
assert(hasSize);
if (child == null) {
return 0.0;
}
switch (axis) {
case Axis.horizontal:
return math.max(0.0, child!.size.width - size.width);
case Axis.vertical:
return math.max(0.0, child!.size.height - size.height);
}
}
BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
switch (axis) {
case Axis.horizontal:
return constraints.heightConstraints();
case Axis.vertical:
return constraints.widthConstraints();
}
}
@override
double computeMinIntrinsicWidth(double height) {
if (child != null) {
return child!.getMinIntrinsicWidth(height);
}
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
if (child != null) {
return child!.getMaxIntrinsicWidth(height);
}
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
if (child != null) {
return child!.getMinIntrinsicHeight(width);
}
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
if (child != null) {
return child!.getMaxIntrinsicHeight(width);
}
return 0.0;
}
// We don't override computeDistanceToActualBaseline(), because we
// want the default behavior (returning null). Otherwise, as you
// scroll, it would shift in its parent if the parent was baseline-aligned,
// which makes no sense.
@override
Size computeDryLayout(BoxConstraints constraints) {
if (child == null) {
return constraints.smallest;
}
final Size childSize =
child!.getDryLayout(_getInnerConstraints(constraints));
return constraints.constrain(childSize);
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
if (child == null) {
size = constraints.smallest;
} else {
child!.layout(_getInnerConstraints(constraints), parentUsesSize: true);
size = constraints.constrain(child!.size);
}
offset.applyViewportDimension(_viewportExtent);
offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
}
Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);
Offset _paintOffsetForPosition(double position) {
switch (axisDirection) {
case AxisDirection.up:
return Offset(0.0, position - child!.size.height + size.height);
case AxisDirection.down:
return Offset(0.0, -position);
case AxisDirection.left:
return Offset(position - child!.size.width + size.width, 0.0);
case AxisDirection.right:
return Offset(-position, 0.0);
}
}
bool _shouldClipAtPaintOffset(Offset paintOffset) {
assert(child != null);
switch (clipBehavior) {
case Clip.none:
return false;
case Clip.hardEdge:
case Clip.antiAlias:
case Clip.antiAliasWithSaveLayer:
return paintOffset.dx < 0 ||
paintOffset.dy < 0 ||
paintOffset.dx + child!.size.width > size.width ||
paintOffset.dy + child!.size.height > size.height;
}
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final Offset paintOffset = _paintOffset;
void paintContents(PaintingContext context, Offset offset) {
context.paintChild(child!, offset + paintOffset);
}
if (_shouldClipAtPaintOffset(paintOffset)) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
paintContents,
clipBehavior: clipBehavior,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
paintContents(context, offset);
}
}
}
final LayerHandle<ClipRectLayer> _clipRectLayer =
LayerHandle<ClipRectLayer>();
@override
void dispose() {
_clipRectLayer.layer = null;
super.dispose();
}
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
final Offset paintOffset = _paintOffset;
transform.translate(paintOffset.dx, paintOffset.dy);
}
@override
Rect? describeApproximatePaintClip(RenderObject? child) {
if (child != null && _shouldClipAtPaintOffset(_paintOffset)) {
return Offset.zero & size;
}
return null;
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
if (child != null) {
return result.addWithPaintOffset(
offset: _paintOffset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position + -_paintOffset);
return child!.hitTest(result, position: transformed);
},
);
}
return false;
}
@override
RevealedOffset getOffsetToReveal(RenderObject target, double alignment,
{Rect? rect}) {
rect ??= target.paintBounds;
if (target is! RenderBox) {
return RevealedOffset(offset: offset.pixels, rect: rect);
}
final RenderBox targetBox = target;
final Matrix4 transform = targetBox.getTransformTo(child);
final Rect bounds = MatrixUtils.transformRect(transform, rect);
final Size contentSize = child!.size;
final double leadingScrollOffset;
final double targetMainAxisExtent;
final double mainAxisExtent;
switch (axisDirection) {
case AxisDirection.up:
mainAxisExtent = size.height;
leadingScrollOffset = contentSize.height - bounds.bottom;
targetMainAxisExtent = bounds.height;
break;
case AxisDirection.right:
mainAxisExtent = size.width;
leadingScrollOffset = bounds.left;
targetMainAxisExtent = bounds.width;
break;
case AxisDirection.down:
mainAxisExtent = size.height;
leadingScrollOffset = bounds.top;
targetMainAxisExtent = bounds.height;
break;
case AxisDirection.left:
mainAxisExtent = size.width;
leadingScrollOffset = contentSize.width - bounds.right;
targetMainAxisExtent = bounds.width;
break;
}
final double targetOffset = leadingScrollOffset -
(mainAxisExtent - targetMainAxisExtent) * alignment;
final Rect targetRect = bounds.shift(_paintOffsetForPosition(targetOffset));
return RevealedOffset(offset: targetOffset, rect: targetRect);
}
@override
void showOnScreen({
RenderObject? descendant,
Rect? rect,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
}) {
if (!offset.allowImplicitScrolling) {
return super.showOnScreen(
descendant: descendant,
rect: rect,
duration: duration,
curve: curve,
);
}
final Rect? newRect = RenderViewportBase.showInViewport(
descendant: descendant,
viewport: this,
offset: offset,
rect: rect,
duration: duration,
curve: curve,
);
super.showOnScreen(
rect: newRect,
duration: duration,
curve: curve,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Offset>('offset', _paintOffset));
}
@override
Rect describeSemanticsClip(RenderObject child) {
final double remainingOffset = _maxScrollExtent - offset.pixels;
switch (axisDirection) {
case AxisDirection.up:
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - remainingOffset,
semanticBounds.right,
semanticBounds.bottom + offset.pixels,
);
case AxisDirection.right:
return Rect.fromLTRB(
semanticBounds.left - offset.pixels,
semanticBounds.top,
semanticBounds.right + remainingOffset,
semanticBounds.bottom,
);
case AxisDirection.down:
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - offset.pixels,
semanticBounds.right,
semanticBounds.bottom + remainingOffset,
);
case AxisDirection.left:
return Rect.fromLTRB(
semanticBounds.left - remainingOffset,
semanticBounds.top,
semanticBounds.right + offset.pixels,
semanticBounds.bottom,
);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment