Last active
September 22, 2023 12:30
-
-
Save proteye/f463558c70fb7cad3f41e7a3b5f4b622 to your computer and use it in GitHub Desktop.
SingleChildWithKeepPositionScrollView - single child with keep position scroll view in Flutter
This file contains 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: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